No code. Every earlier chapter added syntax to a language that never had to think about memory. This one does, and it's worth being precise about why.
Look at what a variable has meant since chapter 5. let x = 10; puts one
long into one slot, in the evaluator's environment and, since chapter 9, in
one C variable. Every type chapter 6 added, Int and Bool, is a
promise about how to read that one slot. The whole compiler, lexer through codegen, has
never had to ask "how many" about anything a program's own logic produces. Function
parameters and call arguments are a list of separate slots, not one slot holding a
count.
let a = [1, 2, 3]; asks a genuinely new question. a is not one
value. It is three, sitting next to each other, and every place that ever treated a
variable as "one name, one slot" now has to decide what it means for a name to hold
several. The environment's Binding holds one long value. The
type checker's vtype field has been a bare four-case enum since chapter 6.
The generated C has declared one variable per let since chapter 9. None of
those three facts survive this chapter unchanged, and the reason is the same in all
three places: a count is new information, and nothing before this chapter had anywhere
to put it.
That is also why this chapter has to answer a question chapter 6 never faced: does the compiler know that count before the program runs, or only while it runs? Step 2 is that decision, and it is the one the rest of the chapter is built on top of.
Checkpoint
Clean tree, everything passing, before this chapter touches anything.
No code yet. Get this wrong and the rest of the chapter, and the ones after it, gets rebuilt on top of a foundation that doesn't hold.
There are two honest designs for an array in a language like izvor.
let a = [1, 2, 3]; // length 3, forever, known when this line is checkedFixed size. The length is part of the array's type, decided at compile time, and it never changes. C's own arrays work this way for exactly this reason.
var a = [];
a.push(10); // length decided while the program runsGrowable. The length lives on the heap and can change after the array exists. This is what a Python list or a C++ std::vector is.
I picked A. Here is the actual trade, not the version that pretends one option has no cost.
Fixed size buys three things at once, and they are the same three things chapter 5 got
from refusing an allocator entirely. No malloc means no matching
free, which means no double free and no leak, for this feature, ever. There
is no ownership question to answer, because nothing outlives the scope that declared it
any differently than a plain Int already does. And the generated C is
exactly what you would hand-write: long iz_a[3] is a real C array on the
stack, not a pointer to something else. Chapter 9's whole argument for generating
readable C, that a person should be able to look at the output and see their own program,
survives this chapter intact.
It costs exactly one thing: an array can never be built at run time with a length the
program only discovers while running. let n = read_count(); let a = [0; n];
is not expressible, not this chapter, maybe not ever without an allocator. Every array
this language can make, it can make because the length was sitting right there in the
source text.
The moment a real program needs a collection whose size depends on its input rather than its source code, fixed size stops being enough, and there is no clever trick around that inside this design. That is the day izvor needs an allocator, an ownership story, and a much bigger chapter than this one. Until a real program in this language actually needs that, adding it would be building a feature nothing uses, which is its own kind of bug. Fixed size is not the permanent answer. It is the honest answer to what this language can actually build right now.
Structs get the same treatment for the same reasons. A struct Point { x: Int, y:
Int } has a field list fixed the moment the declaration is checked, and every
Point value anywhere in the program has exactly those two fields, in that
order, forever. No struct gains or loses a field while the program runs. That is not a
separate decision from the array one. It is the same decision, applied to a value made
of named slots instead of numbered ones.
Checkpoint
{, }, , and : already exist, from
chapters 6 and 7. This chapter needs [, ], ., and
the word struct.
src/lexer.h · inside the typedef enum, before TOK_EOF
TOK_LBRACKET,[. Opens an array literal or an index.
TOK_RBRACKET,]. Closes one.
TOK_DOT,.. Field access, p.x. Not the start of a number: chapter 1's number scanner only ever runs after it has already seen a digit, so .5 was never a valid izvor number and this token does not have to fight that case.
TOK_STRUCT,A keyword, sitting with let, var, fn and the rest, all before this same TOK_EOF line.
src/lexer.c · inside token_type_name, before the TOK_EOF case
case TOK_LBRACKET: return "LBRACKET"; case TOK_RBRACKET: return "RBRACKET"; case TOK_DOT: return "DOT"; case TOK_STRUCT: return "STRUCT";src/lexer.c · the switch (c) at the bottom of lexer_next, next to the other single-character punctuation
case '[': return make_token(lx, TOK_LBRACKET, start); case ']': return make_token(lx, TOK_RBRACKET, start); case '.': return make_token(lx, TOK_DOT, start);src/lexer.c · lexer_next's identifier branch, alongside the existing word_is checks
if (word_is(lx, start, "struct")) return make_token(lx, TOK_STRUCT, start);Same shape as every keyword since chapter 4. Maximal munch already protects this one too: structure eats all nine characters, fails the length check in word_is, and comes back as an ordinary name.
Checkpoint
Four new token kinds, nothing produces them yet. The parser doesn't know any of these exist.
TypeKind has been a bare four-case enum since chapter 6: pick one of four
words, done. An array's type is not one word. It's a word, an element kind, and a
length. This step is where that stops fitting.
src/type.h · inside the typedef enum, before TYPE_ERROR
TYPE_ARRAY,A fixed-length sequence of one element type, per step 2's decision.
TYPE_STRUCT,A named collection of fields, each with its own type.
Now try to answer a real question with just that tag: a[0]'s type, given
that a is a TYPE_ARRAY. TYPE_ARRAY alone doesn't
say whether the elements are Int or Bool, and it doesn't say
how many there are, which the bounds check in step 9 needs. An enum value is a single
number. You cannot staple a second number and a pointer onto it. TypeKind
was always going to be too small the moment a type needed more than a name, and this is
that moment.
The textbook answer is to make every type a small tree: an array's type points at its
element's type, which could itself be an array, which could itself be a struct field,
and so on, arbitrarily deep. That's the right design for a language where arrays can
nest and structs can hold arrays of structs. This chapter's arrays and structs cannot:
every element and every field is Int or Bool, full stop, by
the same fixed-size discipline step 2 already committed to. A recursive type needs a
pointer, which needs an allocation, which needs a lifetime somebody has to reason
about. Building that machinery to serve a case this language cannot even construct is
exactly the kind of gap step 2 warned about: real complexity paying for a program that
does not exist yet.
So the fix is small on purpose: one flat struct, no pointers into more types, no allocation, nothing to free.
typedef struct StructDef StructDef;Forward declared, not defined, here. Only a pointer to it appears in this header, and a pointer to an incomplete type is perfectly legal C. The full definition lives in type.c, in step 7, and nothing outside type.c ever needs to see inside it.
typedef struct {A plain value type. No malloc, ever, for a Type. It gets copied by assignment the same way a long does, which is exactly why it can afford to replace one everywhere vtype appears.
TypeKind kind;The one fact every type has. What chapter 6 called vtype in full used to just be this field.
TypeKind elem_kind;Meaningful only when kind == TYPE_ARRAY. Always TYPE_INT or TYPE_BOOL, per this chapter's restriction. Ignored, and left however it happens to be, for every other kind.
int elem_count;Meaningful only when kind == TYPE_ARRAY. The fixed length from step 2, known at check time, never anywhere else.
StructDef *struct_def;Meaningful only when kind == TYPE_STRUCT. Which declared struct this is. A pointer into the fixed table step 7 builds, never owned, never freed by a Type.
} Type;Three ripples follow from giving this thing a name, and all three are mechanical once you see the shape of the change.
src/ast.h · in struct Node, the vtype field chapter 6 added
TypeKind vtype; /* chapter 6 */ Type vtype;Same field, same name, richer contents. Every node still has exactly one vtype, set exactly once, by check_node, exactly as chapter 6 designed it. What changed is how much that one field can say.
src/type.h and src/type.c · type_name's signature
const char *type_name(TypeKind type); /* chapter 6 */const char *type_name(Type type);A bare TypeKind can no longer say "array of Int, length 3" or name which struct, so the function that names a type in an error message has to take the whole thing.
case TYPE_ARRAY: return "an array";Not "Int[3]". Keep reading.
case TYPE_STRUCT: return "a struct";
The four cases type_name already had all return string literals that live
for the life of the program. It would be nicer for these two to say
"Int[3]" or the struct's own name, formatted with snprintf
into a small static buffer the way plenty of real code does this. Don't. Chapter 6's
arithmetic error already calls type_name twice in one message: "found
%s and %s", type_name(left), type_name(right). Both calls happen before either
result is printed. If both wrote into the same static buffer, the second call
would overwrite the first one's text before printf ever read it, and the
error would print the same type name twice, silently, no warning from the compiler
anywhere. A generic word costs nothing and cannot alias with itself. Anywhere this
chapter's own errors genuinely need the array's length or the struct's name, they print
it directly from the node with %.*s or %d, the same way every
borrowed name has printed since chapter 6, rather than routing it through a function
with a shared buffer.
One more ripple, and this is the one worth sitting with rather than rushing past.
check_node has returned a bare TypeKind since chapter 6, and
every rule written since compares that against TYPE_INT or builds one
directly: result = TYPE_INT;, if (operand == TYPE_ERROR). Once
vtype is a Type, check_node has to return
Type too, since it's the function that fills vtype in. Every one
of those old lines needs the same two mechanical edits: a bare result like
TYPE_INT becomes (Type){ .kind = TYPE_INT }, a compound literal
with a designated initializer, valid C11, and every field but kind left at
zero. A bare comparison like operand == TYPE_ERROR becomes
operand.kind == TYPE_ERROR. That covers the leaves from chapter 6
(NODE_NUMBER, NODE_BOOL, NODE_IDENT), the
arithmetic rules for NODE_UNARY and NODE_BINARY, and
NODE_PRINT and NODE_EXPR_STMT's plain TYPE_VOID
results. Whatever chapters 7 and 8 added to this same switch for blocks, ifs, whiles,
functions, calls and returns gets the identical two-part fix wherever it compares or
returns a bare TypeKind. I'm not relisting all of it; the shape of the edit
is the same everywhere in this file, and one field, ret_type on
NODE_FN, is untouched by any of this, because it was always set directly from
the parsed annotation, never derived from check_node's return value.
The two cases that get genuinely new logic, not just a rename, are
NODE_LET/NODE_VAR and NODE_ASSIGN. Step 8 shows
both in full, because there's a real payoff worth seeing rather than describing.
Checkpoint
Expected, everywhere check_node's old callers still write a bare
TypeKind. Plain make test, not -Werror, until
step 8 finishes the rewrite.
Two node kinds. One is a leaf holding a growable list you already have. The other is postfix syntax, and it goes in the exact spot chapter 7 put function calls.
src/ast.h · inside the typedef enum for NodeType, after NODE_CALL
NODE_ARRAY,[1, 2, 3]. An expression, not a statement.
NODE_INDEX,a[0]. Also an expression: it produces the element's value, whatever that turns out to be.
src/ast.h · inside union as, after the call member
struct { NodeList elements; TypeKind elem_kind; } array;Reusing NodeList for a third purpose: statement sequences, then call arguments, now array elements. Same growable-list problem every time. elem_kind starts meaningless and gets filled in by check_node in step 8, the same lifecycle vtype itself has had since chapter 6.
struct { Node *array; Node *idx; } index;The thing being indexed, and the index expression. Both arbitrary expressions, not just names: a[0] and (get())[0] have the same shape here, even though only the first one will pass type checking in this language today.
src/ast.h · constructor declarations, after ast_call
Node *ast_array(NodeList elements);Node *ast_index(Node *array, Node *idx);src/ast.c · after ast_call, before ast_free
Node *ast_array(NodeList elements) { Node *node = new_node(NODE_ARRAY); if (node == NULL) { return NULL; } node->as.array.elements = elements; node->as.array.elem_kind = TYPE_ERROR;A real sentinel, not garbage left over from malloc. TYPE_ERROR reads as "not decided yet" here the same way it reads as "already reported" in the checker, and nothing looks at this field before step 8 sets it for real.
return node;} Node *ast_index(Node *array, Node *idx) { Node *node = new_node(NODE_INDEX); if (node == NULL) { return NULL; } node->as.index.array = array; node->as.index.idx = idx; return node;}src/ast.c · ast_free's switch, after the NODE_CALL case
case NODE_ARRAY:elem_kind is a plain enum value, nothing to free. Only the elements own anything.
nodelist_free(&node->as.array.elements); break; case NODE_INDEX:Two children, both owned, both freed. No difference from NODE_BINARY's case in shape.
ast_free(node->as.index.array); ast_free(node->as.index.idx); break;
Now the parser. An array literal is easiest: it starts with [, which nothing
else in parse_factor starts with, so it's a new branch there, the same way
TOK_LPAREN already is.
src/parser.c · inside parse_factor, after the TOK_LPAREN branch
if (parser_check(p, TOK_LBRACKET)) { long offset = p->current.start - p->lexer.src; parser_advance(p); /* '[' */ NodeList elements; nodelist_init(&elements); if (!parser_check(p, TOK_RBRACKET)) {Same shape as parse_call's argument list from chapter 7: an empty pair is legal, so check for the closer before assuming there's anything to parse.
for (;;) { Node *element = parse_comparison(p); if (element == NULL) { nodelist_free(&elements); return NULL; } if (!nodelist_push(&elements, element)) { ast_free(element); nodelist_free(&elements); return NULL; } if (!parser_match(p, TOK_COMMA)) break; } } if (!parser_match(p, TOK_RBRACKET)) { error_at(p, "expected ']'"); nodelist_free(&elements); return NULL; } return at(ast_array(elements), offset); }
Indexing is different in kind, not just in shape. It doesn't start with a token nothing
else starts with; it comes after something that's already a complete expression.
a[0] is "parse a, then notice a [ follows." That's
exactly the situation chapter 7 solved for add(1, 2): parse the name, then
notice a ( follows, then call parse_call. Indexing attaches at
that same decision point, right after a name resolves to either a plain identifier or a
call.
src/parser.c · parse_factor's TOK_IDENT branch, the one chapter 7 already extended for calls
if (parser_check(p, TOK_IDENT)) { long offset = p->current.start - p->lexer.src; const char *name = p->current.start; int length = p->current.length; parser_advance(p); Node *expr = parser_check(p, TOK_LPAREN)Chapter 7 had this as two separate return statements. It becomes one variable here because there's more to do to expr before this branch is finished: the postfix loop right below.
? parse_call(p, name, length, offset) : at(ast_ident(name, length), offset); while (expr != NULL && parser_check(p, TOK_LBRACKET)) {A loop, not an if. Step 6 adds a second postfix form, .field, to this same loop, and a chain like a[0].x needs both to fire in sequence. Nothing stops the grammar from accepting a[0][1] too; the type checker in step 8 is what refuses it, because indexing an Int is a type error, not a parse error.
long op_offset = p->current.start - p->lexer.src; parser_advance(p); /* '[' */ Node *idx = parse_expression(p); if (idx == NULL) { ast_free(expr); return NULL; } if (!parser_match(p, TOK_RBRACKET)) { error_at(p, "expected ']'"); ast_free(expr); ast_free(idx); return NULL; } expr = at(ast_index(expr, idx), op_offset); } return expr; }
Notice this loop attaches to a plain identifier and to a call: f(1)[0]
parses today, on the strength of this loop alone, even though nothing in this language can
make f(1) produce an array yet. Grammar and meaning are separate questions in
this compiler, chapter 6 said so first, and this is that separation paying rent again.
Checkpoint
[1, 2, 3] and a[0] both parse. Nothing checks or runs them yet.
p.x is the easy half, the same postfix loop again. Point { x: 1, y: 2
} runs straight into a decision chapter 7 already made, and this step has to face
that head on.
src/ast.h · before struct Node's definition
#define MAX_STRUCT_FIELDS 16A fixed cap, the same call chapter 6 made for the type table and chapter 7 for the environment: a hash table or a growable array is not worth it for something that holds a handful of entries. Sixteen fields is already a large struct.
typedef struct {One field of a struct declaration: a name and a type, no value. struct Point { x: Int, y: Int } has two of these.
const char *name; int length; TypeKind type;} FieldDecl; typedef struct {One field of a struct literal: a name and a value expression, no declared type here, since the type comes from whatever Point already says it is. Point { x: 1, y: 2 } has two of these.
const char *name; int length; Node *value;} FieldInit;
struct_lit and struct_decl, added below, hold a fixed-size
array of these by value: FieldDecl fields[MAX_STRUCT_FIELDS];
sitting directly inside the union. A member held by value has to be a complete type
before the struct containing it is defined, the same requirement chapter 7's
NodeList params and NodeList args already forced onto
NodeList itself. FieldInit holding a Node * is
fine either way, since that's only a pointer, but keep both declarations together,
above struct Node, so a reader sees the whole shape of a struct's payload
in one place.
src/ast.h · inside the typedef enum for NodeType, after NODE_INDEX
NODE_FIELD,p.x. An expression, postfix, exactly like NODE_INDEX.
NODE_STRUCT_LIT,Point { x: 1, y: 2 }. An expression: it produces a struct value.
NODE_STRUCT_DECL,
struct Point { x: Int, y: Int }. Not reserved by the design contract this
chapter started from, which only fixed index, field,
array and struct_lit as union member names. A struct's
declaration needs a node of its own because it needs to survive from parsing
through type checking the same way every other top-level thing does, and there is
nowhere else for it to live. New name, added here and to the consistency checker,
because the alternative was inventing a second way for top-level things to reach the
type checker.
src/ast.h · inside union as, after the index member
struct { Node *object; const char *name; int length; } field;The thing being accessed, and the field name borrowed from the source, exactly like every other borrowed name in this tree.
struct { const char *name; int length;
FieldInit fields[MAX_STRUCT_FIELDS]; int field_count;
StructDef *struct_def; } struct_lit;The struct name as written, the fields as written, and a pointer to the resolved StructDef, filled in by check_node in step 8, mirroring elem_kind on array.
struct { const char *name; int length;
FieldDecl fields[MAX_STRUCT_FIELDS]; int field_count; } struct_decl;No resolved pointer needed here: this node is the declaration, not a use of one.
src/ast.h · constructor declarations
Node *ast_field(Node *object, const char *name, int length);Node *ast_struct_lit(const char *name, int length, FieldInit *fields, int field_count);Node *ast_struct_decl(const char *name, int length, FieldDecl *fields, int field_count);src/ast.c · after ast_index, before ast_free
Node *ast_field(Node *object, const char *name, int length) { Node *node = new_node(NODE_FIELD); if (node == NULL) { return NULL; } node->as.field.object = object; node->as.field.name = name; node->as.field.length = length; return node;} Node *ast_struct_lit(const char *name, int length, FieldInit *fields, int field_count) {Takes a pointer and a count, the way ast_binding takes already-parsed pieces. The caller built fields as a local stack array while parsing; this copies each entry in.
Node *node = new_node(NODE_STRUCT_LIT); if (node == NULL) { return NULL; } node->as.struct_lit.name = name; node->as.struct_lit.length = length; for (int i = 0; i < field_count; i++) { node->as.struct_lit.fields[i] = fields[i]; } node->as.struct_lit.field_count = field_count; node->as.struct_lit.struct_def = NULL;Not resolved yet. Step 8 sets this once it knows Point actually names a declared struct.
return node;} Node *ast_struct_decl(const char *name, int length, FieldDecl *fields, int field_count) { Node *node = new_node(NODE_STRUCT_DECL); if (node == NULL) { return NULL; } node->as.struct_decl.name = name; node->as.struct_decl.length = length; for (int i = 0; i < field_count; i++) { node->as.struct_decl.fields[i] = fields[i]; } node->as.struct_decl.field_count = field_count; return node;}src/ast.c · ast_free's switch, after the NODE_INDEX case
case NODE_FIELD: ast_free(node->as.field.object); break; case NODE_STRUCT_LIT:struct_def is a borrowed pointer into a table this node does not own, exactly like an ident's borrowed name. Only the value expressions get freed.
for (int i = 0; i < node->as.struct_lit.field_count; i++) { ast_free(node->as.struct_lit.fields[i].value); } break; case NODE_STRUCT_DECL:Every field here is a plain TypeKind, no value expressions, nothing to recurse into.
break;Field access is the same postfix loop step 5 already wrote, extended with one more branch.
src/parser.c · the postfix loop from step 5, inside parse_factor's TOK_IDENT branch
while (expr != NULL &&One more disjunct in the loop condition. Both branches inside build a new expr and loop back, which is how a[0].x falls out for free: index once, land back at the top of the loop, see a ., access a field.
(parser_check(p, TOK_LBRACKET) || parser_check(p, TOK_DOT))) { long op_offset = p->current.start - p->lexer.src; if (parser_match(p, TOK_DOT)) { if (!parser_check(p, TOK_IDENT)) { error_at(p, "expected a field name after '.'"); ast_free(expr); return NULL; } const char *field_name = p->current.start; int field_length = p->current.length; parser_advance(p); expr = at(ast_field(expr, field_name, field_length), op_offset); continue; } /* the '[' branch from step 5 goes here, unchanged */Both branches end with either continue or falling through to the loop's own end, so either postfix form can follow the other, in any order, any number of times.
}
Now the actual struct literal. Here is where if's design collides with this
one.
Chapter 7's exact words: if n < 2 { ... } is unambiguous, because the
opening { is the only thing that can end a condition. That was true the
day it was written. It stops being true the moment a struct literal can also start
with IDENT {. if p { ... } is now genuinely two different
programs depending on how you read it: p the whole condition, then the
if's body; or the start of a struct literal called p that swallows what
was meant to be the body. The grammar cannot tell these apart by looking one token
ahead, because both readings are legal shapes right up until you've consumed the
{.
Go has this exact problem, for the exact same reason: it also allows a bare condition
and also has struct literals spelled Name{...}. Go's fix is to forbid a
bare struct literal in a condition, and require parentheses if you actually mean one
there. That's the fix here too: a small parser flag that turns struct-literal parsing
off for exactly the span of an if or while condition, and
back on the moment the condition is done.
src/parser.h · struct Parser, next to the fn_depth field chapter 7 added
bool no_struct_lit;False almost everywhere. True for exactly the width of one condition expression.
src/parser.c · parser_init, next to p->fn_depth = 0;
p->no_struct_lit = false;src/parser.c · parse_if and parse_while, around the call to parse_comparison that reads the condition
Node *cond = parse_comparison(p); // before p->no_struct_lit = true;Set immediately before parsing the condition, restored immediately after, whether parsing succeeded or not. A condition can itself contain no further conditions in this grammar, so this never needs to nest or save a previous value; it only ever goes false, then true, then false.
Node *cond = parse_comparison(p); p->no_struct_lit = false;src/parser.c · parse_factor's TOK_IDENT branch, where expr is built
Node *expr = parser_check(p, TOK_LPAREN)
? parse_call(p, name, length, offset)
: at(ast_ident(name, length), offset); // step 5 Node *expr;Three ways to continue after a name now, so this becomes an if-chain instead of one ternary.
if (parser_check(p, TOK_LPAREN)) { expr = parse_call(p, name, length, offset); } else if (!p->no_struct_lit && parser_check(p, TOK_LBRACE)) {The flag guards exactly this branch. Everywhere no_struct_lit is false, an identifier immediately followed by { is read as a struct literal. Inside a condition, it falls through to the plain-identifier case instead, and the { is left for the if or while itself to consume as the start of its body.
expr = parse_struct_lit(p, name, length, offset); } else { expr = at(ast_ident(name, length), offset); }src/parser.c · directly below parse_call
/* struct_lit -> IDENT (already consumed) "{" (IDENT ":" comparison ("," IDENT ":" comparison)*)? "}" */static Node *parse_struct_lit(Parser *p, const char *name, int length, long offset) { parser_advance(p); /* '{' */ FieldInit fields[MAX_STRUCT_FIELDS]; int field_count = 0; if (!parser_check(p, TOK_RBRACE)) { for (;;) { if (field_count >= MAX_STRUCT_FIELDS) {Checked before writing, not after, the same discipline the type table's MAX_TYPE_BINDINGS check uses. Writing past a fixed array is not a diagnostic you get to hand back cleanly.
error_at(p, "too many fields in this struct literal"); return NULL; } if (!parser_check(p, TOK_IDENT)) { error_at(p, "expected a field name"); return NULL; } fields[field_count].name = p->current.start; fields[field_count].length = p->current.length; parser_advance(p); if (!parser_match(p, TOK_COLON)) { error_at(p, "expected ':' after the field name"); return NULL; } Node *value = parse_comparison(p); if (value == NULL) return NULL; fields[field_count].value = value; field_count++; if (!parser_match(p, TOK_COMMA)) break; } } if (!parser_match(p, TOK_RBRACE)) { error_at(p, "expected '}'"); return NULL; } return at(ast_struct_lit(name, length, fields, field_count), offset);}
One leak on the early-return paths in this function, on purpose, left for step 12: the
field values already parsed before an error don't get freed. Chapter 5's own
parse_statement had the same shape of gap in its first draft and closed it
by threading ast_free through every failure path. Do the same here as part
of the tests in step 12, once you can see the leak actually happen under a sanitizer
rather than fixing an abstract worry.
Checkpoint
p.x, Point { x: 1, y: 2 }, and if p { ... } (reading p as a plain condition) all parse.
A struct declaration has no runtime behavior at all. It's a fact for the type checker, not an instruction for the evaluator. That fact decides both where it can appear and where it gets remembered.
struct Point { x: Int, y: Int } can only make sense before anything uses
Point, and this language checks top to bottom in one pass with no forward
references, so in practice that means: only at the top level, never inside a function
body or a block. parse_statement is called from both places, top level and
inside parse_block, so putting struct parsing there would need a depth
check like fn_depth just to reject it in the wrong place. Simpler: don't put
it in parse_statement at all. parser_parse, the top-level
driver, is the only caller that never recurses into a block, so a struct declaration is
parsed only there.
src/parser.c · parser_parse, chapter 5's top-level loop
Node *stmt = parse_statement(p); // chapter 5 Node *stmt = parser_check(p, TOK_STRUCT) ? parse_struct_decl(p) : parse_statement(p);Nothing else about parser_parse changes. It still pushes whatever it gets back onto the same NodeList, still bails the same way on NULL. A struct declaration is a top-level item in that list, the same as a let, it just happens to produce no runtime effect when the type checker is done with it.
src/parser.c · directly below parse_struct_lit
/* struct_decl -> "struct" IDENT "{" (IDENT ":" type ("," IDENT ":" type)*)? "}" */static Node *parse_struct_decl(Parser *p) { long offset = p->current.start - p->lexer.src; parser_advance(p); /* 'struct' */ if (!parser_check(p, TOK_IDENT)) { error_at(p, "expected a struct name after 'struct'"); return NULL; } const char *name = p->current.start; int length = p->current.length; parser_advance(p); if (!parser_match(p, TOK_LBRACE)) { error_at(p, "expected '{' after the struct name"); return NULL; } FieldDecl fields[MAX_STRUCT_FIELDS]; int field_count = 0; if (!parser_check(p, TOK_RBRACE)) { for (;;) { if (field_count >= MAX_STRUCT_FIELDS) { error_at(p, "too many fields in this struct"); return NULL; } if (!parser_check(p, TOK_IDENT)) { error_at(p, "expected a field name"); return NULL; } fields[field_count].name = p->current.start; fields[field_count].length = p->current.length; parser_advance(p); if (!parser_match(p, TOK_COLON)) { error_at(p, "expected ':' after the field name"); return NULL; } if (!type_from_name(p->current.start, p->current.length, &fields[field_count].type)) {Chapter 6's own function for reading a type name off the source, the same one chapter 7 reused for parameter types. It reports its own "unknown type" error, so this call site doesn't need to.
error_at(p, "expected a type name"); return NULL; } parser_advance(p); field_count++; if (!parser_match(p, TOK_COMMA)) break; } } if (!parser_match(p, TOK_RBRACE)) { error_at(p, "expected '}'"); return NULL; } return at(ast_struct_decl(name, length, fields, field_count), offset);}
Now the table that remembers what got declared. It lives in type.c, next to
the type table chapter 6 already built, for the same reason: this is a fact about names
that only the type checker needs, not the evaluator's environment and not the parser.
src/type.c · below the #include block, above check_node
#define MAX_STRUCT_DEFS 64A program declaring more than 64 distinct structs is not a program this teaching compiler needs to serve well.
struct StructDef {The full definition, finally. type.h only ever saw the forward declaration from step 4; this is the one and only file that needs to look inside one.
const char *name; int length; FieldDecl fields[MAX_STRUCT_FIELDS]; int field_count;}; static StructDef struct_defs[MAX_STRUCT_DEFS];static int struct_def_count = 0; static StructDef *find_struct(const char *name, int length) {Linear scan, length before bytes, the exact shape of lookup_type two years of this book's own convention deep. Returns NULL rather than reporting an error itself, because some callers, like checking whether a name is already taken, need "not found" to be a normal answer, not a diagnostic.
for (int i = 0; i < struct_def_count; i++) { if (struct_defs[i].length == length && memcmp(struct_defs[i].name, name, (size_t)length) == 0) { return &struct_defs[i]; } } return NULL;}src/type.c · type_check_program, next to binding_count = 0;
struct_def_count = 0;The exact same reason chapter 6 reset binding_count: a fresh compile, including a second call in the same test binary, starts from zero declared structs, not whatever an earlier call left behind.
Checkpoint
struct Point { x: Int, y: Int } parses at the top level and is rejected inside a function body or block.
Five new cases in check_node, and two old ones from chapter 6 that get more
than the mechanical .kind fix, because this is where a value's array or
struct type actually gets remembered.
src/type.c · inside check_node's switch, five new cases
case NODE_STRUCT_DECL: { if (find_struct(node->as.struct_decl.name, node->as.struct_decl.length) != NULL) {Redeclaring a struct name is a mistake worth its own message, the same instinct chapter 8 already applies to redeclaring a variable in one scope.
diag_error(src, node->offset, "struct '%.*s' is already declared", node->as.struct_decl.length, node->as.struct_decl.name); } else if (struct_def_count >= MAX_STRUCT_DEFS) { diag_error(src, node->offset, "too many struct declarations"); } else { StructDef *def = &struct_defs[struct_def_count++];A pointer into the fixed table, taken once, here, and handed out by every later lookup. This is the pointer Type.struct_def carries.
def->name = node->as.struct_decl.name; def->length = node->as.struct_decl.length; def->field_count = node->as.struct_decl.field_count; for (int i = 0; i < def->field_count; i++) { def->fields[i] = node->as.struct_decl.fields[i]; } } result = (Type){ .kind = TYPE_VOID }; break; } case NODE_ARRAY: { NodeList *elements = &node->as.array.elements; if (elements->count == 0) {[] parses, from step 5's parser, but there is no element to read a type from and no annotation syntax to fall back on, since step 4 deliberately never extended annotations to arrays. Refusing it here, with a specific message, beats guessing.
diag_error(src, node->offset, "an empty array literal has no element type"); result = (Type){ .kind = TYPE_ERROR }; break; } Type first = check_node(elements->items[0], src); int ok = (first.kind == TYPE_INT || first.kind == TYPE_BOOL);The first element decides what every other element has to be, and it has to decide something checkable: not TYPE_ERROR, and, per this chapter's restriction, not another array or struct either.
for (int i = 1; ok && i < elements->count; i++) { Type elem = check_node(elements->items[i], src); if (elem.kind != TYPE_ERROR && elem.kind != first.kind) { diag_error(src, elements->items[i]->offset, "element %d has type %s, expected %s", i, type_name(elem), type_name(first)); ok = 0; } } if (!ok || first.kind == TYPE_ERROR) { result = (Type){ .kind = TYPE_ERROR }; } else { node->as.array.elem_kind = first.kind;Remembered on the node itself, not just returned, because codegen_program in step 11 walks this same tree after type checking and needs to ask this node directly what it holds.
result = (Type){ .kind = TYPE_ARRAY, .elem_kind = first.kind, .elem_count = elements->count }; } break; } case NODE_INDEX: { Type base = check_node(node->as.index.array, src); Type idx = check_node(node->as.index.idx, src);Both sides checked unconditionally, the same reasoning chapter 6 gave for binary arithmetic: skipping the index just because the base already failed would hide a second, independent mistake.
if (base.kind == TYPE_ERROR || idx.kind == TYPE_ERROR) { result = (Type){ .kind = TYPE_ERROR }; } else if (idx.kind != TYPE_INT) { diag_error(src, node->as.index.idx->offset, "an index must be Int, found %s", type_name(idx)); result = (Type){ .kind = TYPE_ERROR }; } else if (base.kind != TYPE_ARRAY) { diag_error(src, node->as.index.array->offset,Pointed at the base expression, a, not at the bracket. cannot index into Int naming the wrong thing is exactly the kind of error chapter 5 introduced offset to prevent.
"cannot index into %s", type_name(base)); result = (Type){ .kind = TYPE_ERROR }; } else { result = (Type){ .kind = base.elem_kind };This is the payoff from step 4. base is the whole Type the array's name resolved to, element kind and length both, not a bare tag. Reading the element type back out is one field access, not a second lookup anywhere.
} break; } case NODE_STRUCT_LIT: { StructDef *def = find_struct(node->as.struct_lit.name, node->as.struct_lit.length); if (def == NULL) { diag_error(src, node->offset, "'%.*s' is not a declared struct", node->as.struct_lit.length, node->as.struct_lit.name); result = (Type){ .kind = TYPE_ERROR }; break; } int ok = (node->as.struct_lit.field_count == def->field_count);A different field count is wrong before a single field is even compared, so it's checked first and separately.
if (!ok) { diag_error(src, node->offset, "'%.*s' needs %d fields, found %d", def->length, def->name, def->field_count, node->as.struct_lit.field_count); } for (int i = 0; ok && i < node->as.struct_lit.field_count; i++) {This chapter requires fields in declaration order. Point { y: 2, x: 1 } is refused, not silently reordered. Matching by name instead of position is a real improvement and a real amount of extra code; it isn't in this chapter's test list, so it stays an exercise rather than a line I'd be inventing untested.
FieldInit *given = &node->as.struct_lit.fields[i]; FieldDecl *want = &def->fields[i]; if (given->length != want->length || memcmp(given->name, want->name, (size_t)given->length) != 0) { diag_error(src, node->offset, "expected field '%.*s' in position %d", want->length, want->name, i); ok = 0; break; } Type value_type = check_node(given->value, src); if (value_type.kind != TYPE_ERROR && value_type.kind != want->type) { diag_error(src, given->value->offset, "field '%.*s' is %s, found %s", want->length, want->name, type_name((Type){ .kind = want->type }), type_name(value_type)); ok = 0; } } if (ok) { node->as.struct_lit.struct_def = def; } result = ok ? (Type){ .kind = TYPE_STRUCT, .struct_def = def } : (Type){ .kind = TYPE_ERROR }; break; } case NODE_FIELD: { Type object = check_node(node->as.field.object, src); if (object.kind == TYPE_ERROR) { result = (Type){ .kind = TYPE_ERROR }; } else if (object.kind != TYPE_STRUCT) { diag_error(src, node->as.field.object->offset, "%s has no fields", type_name(object)); result = (Type){ .kind = TYPE_ERROR }; } else { StructDef *def = object.struct_def;No second name lookup. object's Type already carries the resolved StructDef pointer, again because step 4 made vtype rich enough to carry it.
int found = 0; for (int i = 0; i < def->field_count; i++) { if (def->fields[i].length == node->as.field.length && memcmp(def->fields[i].name, node->as.field.name, (size_t)node->as.field.length) == 0) { result = (Type){ .kind = def->fields[i].type }; found = 1; break; } } if (!found) {The offset here is node->offset, not the object's. Step 6's postfix loop stamped this NODE_FIELD with the offset of the . itself, so the caret lands on the field name, right after the dot, not back at the start of p.
diag_error(src, node->offset, "'%.*s' has no field '%.*s'", def->length, def->name, node->as.field.length, node->as.field.name); result = (Type){ .kind = TYPE_ERROR }; } } break; }
And here is chapter 6's NODE_LET / NODE_VAR case again, finished
against Type instead of TypeKind. This is the one worth reading
slowly.
case NODE_LET: case NODE_VAR: { Type value_type = check_node(node->as.binding.value, src); if (node->as.binding.has_annotation && value_type.kind != TYPE_ERROR && node->as.binding.annotation != value_type.kind) { diag_error(src, node->offset, "'%.*s' is declared %s but initialized with %s", node->as.binding.length, node->as.binding.name, type_name((Type){ .kind = node->as.binding.annotation }), type_name(value_type)); value_type = (Type){ .kind = TYPE_ERROR }; } record_type(node->as.binding.name, node->as.binding.length, value_type);Unchanged, on purpose. Look at what that means: let a = [1, 2, 3]; reaches this line with value_type already carrying .kind = TYPE_ARRAY, .elem_kind = TYPE_INT, .elem_count = 3, because NODE_ARRAY's own case built exactly that Type two calls up the stack. record_type just copies the whole struct in. No special case for "the value happened to be an array" had to be written here at all. That's the entire argument for making vtype a real struct instead of a tag: the richer answer flows through the same one line that already existed.
result = (Type){ .kind = TYPE_VOID }; break; }src/type.c · record_type and lookup_type, and the TypeBinding they share
typedef struct { const char *name; int length; Type vtype; } TypeBinding; // TypeKind -> Typestatic void record_type(const char *name, int length, Type vtype); // TypeKind -> Typestatic Type lookup_type(const char *name, int length, const char *src, long offset); // TypeKind -> Type
Every assignment inside both functions' bodies stays exactly as chapter 6 wrote it:
bindings[i].vtype = vtype; already copies a whole value, whatever type that
value happens to be. Only the one line at the very end of lookup_type that
builds a fallback needs the compound-literal treatment:
return (Type){ .kind = TYPE_ERROR };.
Checkpoint
let a = [1, 2, 3]; print a[0]; checks clean. let a = [1, true];,
let x = 5; print x[0];, and p.z on a two-field
Point all refuse with a message naming the actual mistake.
C does not check array bounds. That is not laziness, it's C's whole deal: no check means no cost, and the programmer eats every mistake personally. izvor checks. Here is why, and here is exactly where that check has to live.
An unchecked a[5] on a three-element array reads whatever bytes happen to
sit past the end of it. In C that's a real bug with a real name, undefined behavior,
and it's the single most common way a C program corrupts its own memory. A checked
index costs one comparison and one branch, every time, forever, even when the index is
provably safe. I'm paying that cost. A teaching language whose very first array feature
can silently corrupt memory has failed the one job an array bounds check exists to do,
and the comparison is not the part of this compiler that was ever going to be slow.
Now, where does the check actually run? There are two places a program executes in this
book: the tree-walking evaluator in main.c, and the C that
codegen.c emits and clang compiles. Chapter 9 already changed what the
evaluator is for: it stayed alive as an oracle, something a test runs a program
through so it can compare the answer against the compiled version. It was never rebuilt
to hold anything bigger than the one long that
ExecResult.value has carried since chapter 7.
Giving the evaluator a real array value means turning ExecResult.value
from one long into a tagged union of "a scalar" or "a pointer to several,"
and then deciding who allocates that block and who frees it, in an interpreter that has
never owned a single heap pointer in ten chapters. That's the same allocator-and-lifetime
problem step 2 refused to open for the language itself, showing up again one layer
down, in the tool that tests the language. It is not worth reopening it three chapters
after ExecResult shipped, to serve a test harness, for a value shape the
compiled path already handles correctly. So: eval gets a case for each of
this chapter's six node kinds, and every one of them does the same thing, reports that
the interpreter does not run aggregates, and stops. Every test this chapter adds for
arrays and structs runs through run_clang and checks the compiled
program's actual output, not through the oracle.
src/main.c · eval's switch over node->type, anywhere after the existing cases
case NODE_INDEX: case NODE_FIELD: case NODE_ARRAY: case NODE_STRUCT_LIT: case NODE_STRUCT_DECL: case NODE_STORE:Introduced in step 10. Listed here now because this switch has no default:, so the compiler will demand this case exist the moment NODE_STORE does, whichever step you're on when you hit that build.
diag_error(src, node->offset, "the interpreter does not run arrays or structs; compile this program instead"); return (ExecResult){ .ok = false };
Everything about a checked index, then, is the compiled path's job. Every call to
codegen_program now needs one small piece of runtime, written once, emitted
at the top of every generated file, right where chapter 9 already writes its
#include lines.
codegen.c · the runtime prologue codegen_program writes before the translated program
static long iz_check_index(long idx, long len) { if (idx < 0 || idx >= len) { fprintf(stderr, "izvor: index %ld out of bounds for length %ld\n", idx, len); exit(1); } return idx;}
One function, named with the same iz_ prefix chapter 9 already uses for
every izvor-originated name, for the same reason: it has to not collide with anything a
program of this user's own could be named. And here is what indexing actually compiles
to, for let a = [10, 20, 30]; print a[1];.
long iz_a[3] = {10, 20, 30};printf("%ld\n", (long)iz_a[iz_check_index(1, 3)]);
The 3 inside iz_check_index(1, 3) is not computed at run time.
It's read straight off the AST, from
node->as.index.array->vtype.elem_count, which step 8's
NODE_ARRAY case already put there during type checking. Codegen never
recomputes a length it can just read.
Checkpoint
No build yet to run; codegen.c is chapter 9's file and isn't finished. Read the two snippets above and trace them by hand against the source line they came from.
Chapter 5 restricted assignment to a plain name on purpose, precisely so this step would be small. It is.
Chapter 5's assignment branch parses an expression first, then checks what it turned out
to be: if (expr->type != NODE_IDENT) { error_at(p, "cannot assign to this"); ...
}. That single line is the entire reason a[0] = 5; and
p.x = 5; don't need a new grammar rule. They need this line to stop refusing
two more shapes, and the branch below it to build a different node when the target isn't
a plain name.
But NODE_ASSIGN's payload is the binding member: a name, a
length, a value. a[0] = 5 has no single name to put there; the target is a
whole sub-expression, the array and the index together. Reusing binding for
this would mean inventing a fourth shape for a member chapter 5 fixed at three fields for
three specific node kinds, which is exactly what this book's own design contract forbids.
So this is a new node kind, one that stores the target expression whole instead of taking
it apart.
src/ast.h · inside the typedef enum for NodeType, after NODE_STRUCT_DECL
NODE_STORE,One node kind for both a[0] = 5; and p.x = 5;, not two. Whether the target is an index or a field is already recorded on target itself, in its own type. A second node kind here would just be asking the tag to repeat a fact the tree already states.
src/ast.h · inside union as, after struct_decl
struct { Node *target; Node *value; } store;target is always a NODE_INDEX or a NODE_FIELD node, the exact one parse_statement already built while finding out it wasn't a plain name.
src/ast.h · constructor declaration
Node *ast_store(Node *target, Node *value);src/ast.c · after ast_struct_decl, before ast_free
Node *ast_store(Node *target, Node *value) { Node *node = new_node(NODE_STORE); if (node == NULL) { return NULL; } node->as.store.target = target; node->as.store.value = value; return node;}src/ast.c · ast_free's switch, after NODE_STRUCT_DECL
case NODE_STORE: ast_free(node->as.store.target); ast_free(node->as.store.value); break;Now the parser. Here is chapter 5's exact branch, with the three-line change.
src/parser.c · parse_statement, the assignment branch after the bare-expression parse
if (parser_match(p, TOK_EQUAL)) { if (expr->type != NODE_IDENT) { // chapter 5The whole change to this line: two more acceptable shapes, joined with && so the check still fires only when the target is none of the three.
if (expr->type != NODE_IDENT && expr->type != NODE_INDEX && expr->type != NODE_FIELD) { error_at(p, "cannot assign to this"); ast_free(expr); return NULL; } Node *value = parse_expression(p); if (value == NULL) { ast_free(expr); return NULL; } if (!parser_match(p, TOK_SEMICOLON)) { error_at(p, "expected ';'"); ast_free(expr); ast_free(value); return NULL; } if (expr->type == NODE_IDENT) {The old path, verbatim: pull the name out, free the now-redundant ident node, build a NODE_ASSIGN. Nothing here changed.
Node *node = at(ast_binding(NODE_ASSIGN, expr->as.ident.name, expr->as.ident.length, value), offset); ast_free(expr); return node; } return at(ast_store(expr, value), offset);The new path is shorter than the old one. expr is already the whole a[0] or p.x node, built by step 5 or 6's postfix loop; there's no name to extract, so it's handed to ast_store whole, and nothing frees it, because store.target now owns it.
}
Type checking NODE_STORE doesn't repeat the index or field rules. It calls
check_node on the target, which is a full NODE_INDEX or
NODE_FIELD node and already knows how to check itself, and adds exactly the
one new question: does the value match what's already there.
src/type.c · inside check_node's switch, after NODE_FIELD
case NODE_STORE: { Type target_type = check_node(node->as.store.target, src);This one call already ran every rule in NODE_INDEX or NODE_FIELD: Int index, array base, known field. Nothing here re-checks any of that.
Type value_type = check_node(node->as.store.value, src); if (target_type.kind == TYPE_ERROR || value_type.kind == TYPE_ERROR) { result = (Type){ .kind = TYPE_ERROR }; } else if (target_type.kind != value_type.kind) { diag_error(src, node->offset, "cannot assign %s here, expected %s", type_name(value_type), type_name(target_type)); result = (Type){ .kind = TYPE_ERROR }; } else { result = (Type){ .kind = TYPE_VOID }; } break; }Checkpoint
a[0] = 5; and p.x = true; (on an Int field) parse and check correctly, the second one refused.
Chapter 9 fixed the shape of this: emit_expr for anything that produces a
value, emit_stmt for anything that happens, iz_ in front of
every izvor-originated name, Int as long, Bool as
plain int. This chapter uses exactly that, nothing more.
A struct declaration emits a C struct, once, before anything that could use
it. codegen_program already walks the whole top-level NodeList
before emitting the rest of the program, since C needs a type's shape declared before
any variable of that type; this chapter's only addition there is one more thing worth
walking for.
struct Point { x: Int, y: Int }typedef struct { long iz_x; long iz_y;} iz_Point;
Field names get the same iz_ prefix as variables and functions, for the same
reason chapter 9 gave for all of them: a field named int is legal izvor and
would not be legal C without the rename.
A let or var whose initializer is directly a NODE_ARRAY
or NODE_STRUCT_LIT gets a dedicated shape in emit_stmt, rather
than routing through emit_expr the way a plain let x = 1 + 2;
does.
let a = [10, 20, 30];let p = Point { x: 1, y: 2 };long iz_a[3] = {10, 20, 30};iz_Point iz_p = { .iz_x = 1, .iz_y = 2 };
print [1, 2, 3]; and f([1, 2, 3]) parse and type check today,
because steps 5 and 8 never restricted where a NODE_ARRAY can appear. They
do not compile, because turning an array literal into a general C expression, one that
can sit anywhere a value can, needs either a GNU statement expression or a synthesized
temporary variable threaded back up through emit_expr's return value, and
this chapter's own tests never ask for that. emit_expr only knows how to
emit a NODE_ARRAY or NODE_STRUCT_LIT when
emit_stmt has already special-cased it as a let or
var initializer; anywhere else, it's a real, named gap rather than a
silent wrong answer.
Indexing and field access, unlike the literals, work as ordinary expressions anywhere, because they read a value rather than construct one.
print a[1] + p.y;printf("%ld\n", (long)(iz_a[iz_check_index(1, 3)] + iz_p.iz_y));
And a[0] = 5;, the NODE_STORE case, compiles to a plain C
assignment once the target is a real C lvalue, which iz_a[...] and
iz_p.iz_x both already are.
a[0] = 5; // izvoriz_a[iz_check_index(0, 3)] = 5; /* C */Checkpoint
No build yet; codegen.c belongs to chapter 9. Trace each snippet above against the source line beside it, and check the length argument to every iz_check_index call against the array's actual declared size.
One golden-error case per new failure mode, plus the leak step 6 left open, plus the happy path this whole chapter has been building toward.
tests/test_type_aggregates_1.c · new file · copy the shape of an existing type.c test
let a = [1, 2, 3]; print a[0];The happy path. Checks clean, and a's recorded type has elem_kind == TYPE_INT and elem_count == 3. This is the assertion that actually exercises step 4's whole reason for existing: read the type back out of the environment and check it carries more than a bare tag.
let x = 5; print x[0];Golden error: indexing a non-array. cannot index into Int, pointed at x.
let a = [1, 2, 3]; print a[true];Golden error: a non-Int index. an index must be Int, found Bool, pointed at true, not at a.
let a = [1, true, 3];Golden error: wrong element type in a literal. element 1 has type Bool, expected Int. Also try [] alone and confirm the separate "empty array literal" message fires instead.
struct Point { x: Int, y: Int } let p = Point { x: 1, y: 2 }; print p.z;Golden error: a field that does not exist. 'Point' has no field 'z', with the offset landing on z, not on p or on the dot. This is the assertion that actually proves step 6's offset choice was the right one.
let a = [1, 2, 3]; print a[10];Out of bounds. This one cannot be a type.c test: the length is known at check time, so a[10] passes type checking cleanly. It has to be an execution test, feeding the generated C through run_clang and asserting the compiled binary exits non-zero with izvor: index 10 out of bounds for length 3 on stderr, not a diagnostic from diag_error at all.
Two more, back where the gap was left.
Point { x: 1, y:An unterminated struct literal, missing its final value and closing brace. Run this one under a sanitizer, specifically to catch the leak step 6 flagged: the value for x is a live Node* at the moment parse_struct_lit hits the missing-value error and returns NULL, and nothing on that path frees it yet. Fix it by threading ast_free for each already-parsed fields[i].value through every early return in that function, and in parse_struct_decl's field loop too, the same discipline chapter 5 applied to parse_statement.
if Point { x: 1, y: 2 } { print 1; }Confirms the ambiguity fix from step 6 the other direction: written with explicit parens, if (Point { x: 1, y: 2 }) { ... }, wait, that still needs to fail on type grounds, since a struct isn't a condition Bool. Assert two different things: the bare form above parses Point as a plain identifier condition followed by a body that starts a new statement, not as a struct literal, and the parenthesized form parses the literal correctly but is refused by type checking with a message about the condition needing Bool, not by the parser.
Last, run the consistency checker this entire book relies on to keep chapters honest with each other.
Checkpoint