Chapter 11 the first value that doesn't fit in a long

Arrays and Structs

Every value izvor has ever held fits in one long, and every variable is one slot. This chapter breaks both of those facts at once: an array is more than one value, and the compiler has to know how many, at compile time, before it can generate a single line of C. Twelve steps, written ahead of the code.

Read this before you start

Chapters 1 through 10 were written from code already in the repo, or from the exact interfaces fixed for the chapters being written alongside this one, 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 touch8
New node kinds6
New struct types in C4
Traps5

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.

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

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.

the two optionspick one
A
let a = [1, 2, 3];   // length 3, forever, known when this line is checked

Fixed 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.

B
var a = [];
a.push(10);           // length decided while the program runs

Growable. 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.

When I would revisit this

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

src/lexer.hadd 4 lines
1
    TOK_LBRACKET,

[. Opens an array literal or an index.

2
    TOK_RBRACKET,

]. Closes one.

3
    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.

4
    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

src/lexer.cadd 8 lines
1
    case TOK_LBRACKET:
2
        return "LBRACKET";
3
    case TOK_RBRACKET:
4
        return "RBRACKET";
5
    case TOK_DOT:
6
        return "DOT";
7
    case TOK_STRUCT:
8
        return "STRUCT";

src/lexer.c · the switch (c) at the bottom of lexer_next, next to the other single-character punctuation

src/lexer.cadd 3 lines
1
        case '[': return make_token(lx, TOK_LBRACKET, start);
2
        case ']': return make_token(lx, TOK_RBRACKET, start);
3
        case '.': return make_token(lx, TOK_DOT,      start);

src/lexer.c · lexer_next's identifier branch, alongside the existing word_is checks

src/lexer.cadd 1 line
1
        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

$ make EXTRA_CFLAGS=-Werror test all unit tests passed

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

src/type.hadd 2 lines
1
    TYPE_ARRAY,

A fixed-length sequence of one element type, per step 2's decision.

2
    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 tempting fix, and why it doesn't earn its cost

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.

src/type.hnew type, 8 lines
1
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.

2
 
3
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.

4
    TypeKind kind;

The one fact every type has. What chapter 6 called vtype in full used to just be this field.

5
    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.

6
    int elem_count;

Meaningful only when kind == TYPE_ARRAY. The fixed length from step 2, known at check time, never anywhere else.

7
    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.

8
} 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

src/ast.h1 line changed
1
    TypeKind vtype;   /* chapter 6 */
2
    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

src/type.h and src/type.c1 line changed each
1
const char *type_name(TypeKind type);   /* chapter 6 */
2
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.

src/type.c2 cases added to type_name's switch
1
    case TYPE_ARRAY:  return "an array";

Not "Int[3]". Keep reading.

2
    case TYPE_STRUCT: return "a struct";
Why these two don't print the length or the name

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

$ make test error: assigning to 'TypeKind' from incompatible type 'Type'

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

src/ast.hadd 2 lines
1
    NODE_ARRAY,

[1, 2, 3]. An expression, not a statement.

2
    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

src/ast.hadd 2 union members
1
        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.

2
        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

src/ast.hadd 2 lines
1
Node *ast_array(NodeList elements);
2
Node *ast_index(Node *array, Node *idx);

src/ast.c · after ast_call, before ast_free

src/ast.cadd 20 lines
1
Node *ast_array(NodeList elements) {
2
    Node *node = new_node(NODE_ARRAY);
3
    if (node == NULL) {
4
        return NULL;
5
    }
6
    node->as.array.elements = elements;
7
    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.

8
    return node;
9
}
10
 
11
Node *ast_index(Node *array, Node *idx) {
12
    Node *node = new_node(NODE_INDEX);
13
    if (node == NULL) {
14
        return NULL;
15
    }
16
    node->as.index.array = array;
17
    node->as.index.idx = idx;
18
    return node;
19
}

src/ast.c · ast_free's switch, after the NODE_CALL case

src/ast.cadd 8 lines
1
    case NODE_ARRAY:

elem_kind is a plain enum value, nothing to free. Only the elements own anything.

2
        nodelist_free(&node->as.array.elements);
3
        break;
4
    case NODE_INDEX:

Two children, both owned, both freed. No difference from NODE_BINARY's case in shape.

5
        ast_free(node->as.index.array);
6
        ast_free(node->as.index.idx);
7
        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

src/parser.cadd 22 lines
1
    if (parser_check(p, TOK_LBRACKET)) {
2
        long offset = p->current.start - p->lexer.src;
3
        parser_advance(p); /* '[' */
4
 
5
        NodeList elements;
6
        nodelist_init(&elements);
7
 
8
        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.

9
            for (;;) {
10
                Node *element = parse_comparison(p);
11
                if (element == NULL) {
12
                    nodelist_free(&elements);
13
                    return NULL;
14
                }
15
                if (!nodelist_push(&elements, element)) {
16
                    ast_free(element);
17
                    nodelist_free(&elements);
18
                    return NULL;
19
                }
20
                if (!parser_match(p, TOK_COMMA)) break;
21
            }
22
        }
23
 
24
        if (!parser_match(p, TOK_RBRACKET)) {
25
            error_at(p, "expected ']'");
26
            nodelist_free(&elements);
27
            return NULL;
28
        }
29
        return at(ast_array(elements), offset);
30
    }

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

src/parser.crestructure, add 14 lines
1
    if (parser_check(p, TOK_IDENT)) {
2
        long offset = p->current.start - p->lexer.src;
3
        const char *name = p->current.start;
4
        int length = p->current.length;
5
        parser_advance(p);
6
 
7
        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.

8
            ? parse_call(p, name, length, offset)
9
            : at(ast_ident(name, length), offset);
10
 
11
        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.

12
            long op_offset = p->current.start - p->lexer.src;
13
            parser_advance(p); /* '[' */
14
            Node *idx = parse_expression(p);
15
            if (idx == NULL) { ast_free(expr); return NULL; }
16
            if (!parser_match(p, TOK_RBRACKET)) {
17
                error_at(p, "expected ']'");
18
                ast_free(expr); ast_free(idx);
19
                return NULL;
20
            }
21
            expr = at(ast_index(expr, idx), op_offset);
22
        }
23
 
24
        return expr;
25
    }

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

$ make EXTRA_CFLAGS=-Werror test error: enumeration value 'NODE_ARRAY' not handled in switch

[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

src/ast.hadd 12 lines, 1 constant
1
#define MAX_STRUCT_FIELDS 16

A 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.

2
 
3
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.

4
    const char *name;
5
    int length;
6
    TypeKind type;
7
} FieldDecl;
8
 
9
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.

10
    const char *name;
11
    int length;
12
    Node *value;
13
} FieldInit;
Why this goes above struct Node, not below it

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

src/ast.hadd 3 lines
1
    NODE_FIELD,

p.x. An expression, postfix, exactly like NODE_INDEX.

2
    NODE_STRUCT_LIT,

Point { x: 1, y: 2 }. An expression: it produces a struct value.

3
    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

src/ast.hadd 3 union members
1
        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.

2
        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.

3
        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

src/ast.hadd 3 lines
1
Node *ast_field(Node *object, const char *name, int length);
2
Node *ast_struct_lit(const char *name, int length, FieldInit *fields, int field_count);
3
Node *ast_struct_decl(const char *name, int length, FieldDecl *fields, int field_count);

src/ast.c · after ast_index, before ast_free

src/ast.cadd 35 lines
1
Node *ast_field(Node *object, const char *name, int length) {
2
    Node *node = new_node(NODE_FIELD);
3
    if (node == NULL) {
4
        return NULL;
5
    }
6
    node->as.field.object = object;
7
    node->as.field.name = name;
8
    node->as.field.length = length;
9
    return node;
10
}
11
 
12
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.

13
    Node *node = new_node(NODE_STRUCT_LIT);
14
    if (node == NULL) {
15
        return NULL;
16
    }
17
    node->as.struct_lit.name = name;
18
    node->as.struct_lit.length = length;
19
    for (int i = 0; i < field_count; i++) {
20
        node->as.struct_lit.fields[i] = fields[i];
21
    }
22
    node->as.struct_lit.field_count = field_count;
23
    node->as.struct_lit.struct_def = NULL;

Not resolved yet. Step 8 sets this once it knows Point actually names a declared struct.

24
    return node;
25
}
26
 
27
Node *ast_struct_decl(const char *name, int length, FieldDecl *fields, int field_count) {
28
    Node *node = new_node(NODE_STRUCT_DECL);
29
    if (node == NULL) {
30
        return NULL;
31
    }
32
    node->as.struct_decl.name = name;
33
    node->as.struct_decl.length = length;
34
    for (int i = 0; i < field_count; i++) {
35
        node->as.struct_decl.fields[i] = fields[i];
36
    }
37
    node->as.struct_decl.field_count = field_count;
38
    return node;
39
}

src/ast.c · ast_free's switch, after the NODE_INDEX case

src/ast.cadd 12 lines
1
    case NODE_FIELD:
2
        ast_free(node->as.field.object);
3
        break;
4
    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.

5
        for (int i = 0; i < node->as.struct_lit.field_count; i++) {
6
            ast_free(node->as.struct_lit.fields[i].value);
7
        }
8
        break;
9
    case NODE_STRUCT_DECL:

Every field here is a plain TypeKind, no value expressions, nothing to recurse into.

10
        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

src/parser.cthe while condition and one branch, 12 lines
1
        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.

2
               (parser_check(p, TOK_LBRACKET) || parser_check(p, TOK_DOT))) {
3
            long op_offset = p->current.start - p->lexer.src;
4
            if (parser_match(p, TOK_DOT)) {
5
                if (!parser_check(p, TOK_IDENT)) {
6
                    error_at(p, "expected a field name after '.'");
7
                    ast_free(expr);
8
                    return NULL;
9
                }
10
                const char *field_name = p->current.start;
11
                int field_length = p->current.length;
12
                parser_advance(p);
13
                expr = at(ast_field(expr, field_name, field_length), op_offset);
14
                continue;
15
            }
16
            /* 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.

17
        }

Now the actual struct literal. Here is where if's design collides with this one.

Chapter 7 said the condition needs no parentheses. This chapter breaks that.

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

src/parser.hadd 1 line
1
    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;

src/parser.cadd 1 line
1
    p->no_struct_lit = false;

src/parser.c · parse_if and parse_while, around the call to parse_comparison that reads the condition

src/parser.cwrap one line into three, in each function
1
    Node *cond = parse_comparison(p);   // before
2
    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.

3
    Node *cond = parse_comparison(p);
4
    p->no_struct_lit = false;

src/parser.c · parse_factor's TOK_IDENT branch, where expr is built

src/parser.c4 lines changed
1
        Node *expr = parser_check(p, TOK_LPAREN)
            ? parse_call(p, name, length, offset)
            : at(ast_ident(name, length), offset);   // step 5
2
        Node *expr;

Three ways to continue after a name now, so this becomes an if-chain instead of one ternary.

3
        if (parser_check(p, TOK_LPAREN)) {
4
            expr = parse_call(p, name, length, offset);
5
        } 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.

6
            expr = parse_struct_lit(p, name, length, offset);
7
        } else {
8
            expr = at(ast_ident(name, length), offset);
9
        }

src/parser.c · directly below parse_call

src/parser.cparse_struct_lit, about 30 lines
1
/* struct_lit -> IDENT (already consumed) "{" (IDENT ":" comparison ("," IDENT ":" comparison)*)? "}" */
2
static Node *parse_struct_lit(Parser *p, const char *name, int length, long offset) {
3
    parser_advance(p); /* '{' */
4
 
5
    FieldInit fields[MAX_STRUCT_FIELDS];
6
    int field_count = 0;
7
 
8
    if (!parser_check(p, TOK_RBRACE)) {
9
        for (;;) {
10
            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.

11
                error_at(p, "too many fields in this struct literal");
12
                return NULL;
13
            }
14
            if (!parser_check(p, TOK_IDENT)) {
15
                error_at(p, "expected a field name");
16
                return NULL;
17
            }
18
            fields[field_count].name = p->current.start;
19
            fields[field_count].length = p->current.length;
20
            parser_advance(p);
21
            if (!parser_match(p, TOK_COLON)) {
22
                error_at(p, "expected ':' after the field name");
23
                return NULL;
24
            }
25
            Node *value = parse_comparison(p);
26
            if (value == NULL) return NULL;
27
            fields[field_count].value = value;
28
            field_count++;
29
            if (!parser_match(p, TOK_COMMA)) break;
30
        }
31
    }
32
 
33
    if (!parser_match(p, TOK_RBRACE)) {
34
        error_at(p, "expected '}'");
35
        return NULL;
36
    }
37
    return at(ast_struct_lit(name, length, fields, field_count), offset);
38
}

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

$ make EXTRA_CFLAGS=-Werror test error: enumeration value 'NODE_FIELD' not handled in switch

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

src/parser.c1 line changed
1
        Node *stmt = parse_statement(p);   // chapter 5
2
        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

src/parser.cparse_struct_decl, about 24 lines
1
/* struct_decl -> "struct" IDENT "{" (IDENT ":" type ("," IDENT ":" type)*)? "}" */
2
static Node *parse_struct_decl(Parser *p) {
3
    long offset = p->current.start - p->lexer.src;
4
    parser_advance(p); /* 'struct' */
5
 
6
    if (!parser_check(p, TOK_IDENT)) {
7
        error_at(p, "expected a struct name after 'struct'");
8
        return NULL;
9
    }
10
    const char *name = p->current.start;
11
    int length = p->current.length;
12
    parser_advance(p);
13
 
14
    if (!parser_match(p, TOK_LBRACE)) {
15
        error_at(p, "expected '{' after the struct name");
16
        return NULL;
17
    }
18
 
19
    FieldDecl fields[MAX_STRUCT_FIELDS];
20
    int field_count = 0;
21
 
22
    if (!parser_check(p, TOK_RBRACE)) {
23
        for (;;) {
24
            if (field_count >= MAX_STRUCT_FIELDS) {
25
                error_at(p, "too many fields in this struct");
26
                return NULL;
27
            }
28
            if (!parser_check(p, TOK_IDENT)) {
29
                error_at(p, "expected a field name");
30
                return NULL;
31
            }
32
            fields[field_count].name = p->current.start;
33
            fields[field_count].length = p->current.length;
34
            parser_advance(p);
35
            if (!parser_match(p, TOK_COLON)) {
36
                error_at(p, "expected ':' after the field name");
37
                return NULL;
38
            }
39
            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.

40
                error_at(p, "expected a type name");
41
                return NULL;
42
            }
43
            parser_advance(p);
44
            field_count++;
45
            if (!parser_match(p, TOK_COMMA)) break;
46
        }
47
    }
48
 
49
    if (!parser_match(p, TOK_RBRACE)) {
50
        error_at(p, "expected '}'");
51
        return NULL;
52
    }
53
    return at(ast_struct_decl(name, length, fields, field_count), offset);
54
}

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

src/type.cadd 22 lines
1
#define MAX_STRUCT_DEFS 64

A program declaring more than 64 distinct structs is not a program this teaching compiler needs to serve well.

2
 
3
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.

4
    const char *name;
5
    int length;
6
    FieldDecl fields[MAX_STRUCT_FIELDS];
7
    int field_count;
8
};
9
 
10
static StructDef struct_defs[MAX_STRUCT_DEFS];
11
static int struct_def_count = 0;
12
 
13
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.

14
    for (int i = 0; i < struct_def_count; i++) {
15
        if (struct_defs[i].length == length &&
16
            memcmp(struct_defs[i].name, name, (size_t)length) == 0) {
17
            return &struct_defs[i];
18
        }
19
    }
20
    return NULL;
21
}

src/type.c · type_check_program, next to binding_count = 0;

src/type.cadd 1 line
1
    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

$ make EXTRA_CFLAGS=-Werror test error: enumeration value 'NODE_STRUCT_DECL' not handled in switch

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

src/type.cNODE_STRUCT_DECL, 16 lines
1
    case NODE_STRUCT_DECL: {
2
        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.

3
            diag_error(src, node->offset, "struct '%.*s' is already declared",
4
                       node->as.struct_decl.length, node->as.struct_decl.name);
5
        } else if (struct_def_count >= MAX_STRUCT_DEFS) {
6
            diag_error(src, node->offset, "too many struct declarations");
7
        } else {
8
            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.

9
            def->name = node->as.struct_decl.name;
10
            def->length = node->as.struct_decl.length;
11
            def->field_count = node->as.struct_decl.field_count;
12
            for (int i = 0; i < def->field_count; i++) {
13
                def->fields[i] = node->as.struct_decl.fields[i];
14
            }
15
        }
16
        result = (Type){ .kind = TYPE_VOID }; break;
17
    }
src/type.cNODE_ARRAY, 20 lines
1
    case NODE_ARRAY: {
2
        NodeList *elements = &node->as.array.elements;
3
        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.

4
            diag_error(src, node->offset, "an empty array literal has no element type");
5
            result = (Type){ .kind = TYPE_ERROR }; break;
6
        }
7
        Type first = check_node(elements->items[0], src);
8
        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.

9
        for (int i = 1; ok && i < elements->count; i++) {
10
            Type elem = check_node(elements->items[i], src);
11
            if (elem.kind != TYPE_ERROR && elem.kind != first.kind) {
12
                diag_error(src, elements->items[i]->offset,
13
                           "element %d has type %s, expected %s",
14
                           i, type_name(elem), type_name(first));
15
                ok = 0;
16
            }
17
        }
18
        if (!ok || first.kind == TYPE_ERROR) {
19
            result = (Type){ .kind = TYPE_ERROR };
20
        } else {
21
            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.

22
            result = (Type){ .kind = TYPE_ARRAY, .elem_kind = first.kind, .elem_count = elements->count };
23
        }
24
        break;
25
    }
src/type.cNODE_INDEX, 20 lines
1
    case NODE_INDEX: {
2
        Type base = check_node(node->as.index.array, src);
3
        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.

4
        if (base.kind == TYPE_ERROR || idx.kind == TYPE_ERROR) {
5
            result = (Type){ .kind = TYPE_ERROR };
6
        } else if (idx.kind != TYPE_INT) {
7
            diag_error(src, node->as.index.idx->offset,
8
                       "an index must be Int, found %s", type_name(idx));
9
            result = (Type){ .kind = TYPE_ERROR };
10
        } else if (base.kind != TYPE_ARRAY) {
11
            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.

12
                       "cannot index into %s", type_name(base));
13
            result = (Type){ .kind = TYPE_ERROR };
14
        } else {
15
            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.

16
        }
17
        break;
18
    }
src/type.cNODE_STRUCT_LIT, 30 lines
1
    case NODE_STRUCT_LIT: {
2
        StructDef *def = find_struct(node->as.struct_lit.name, node->as.struct_lit.length);
3
        if (def == NULL) {
4
            diag_error(src, node->offset, "'%.*s' is not a declared struct",
5
                       node->as.struct_lit.length, node->as.struct_lit.name);
6
            result = (Type){ .kind = TYPE_ERROR };
7
            break;
8
        }
9
        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.

10
        if (!ok) {
11
            diag_error(src, node->offset,
12
                       "'%.*s' needs %d fields, found %d",
13
                       def->length, def->name, def->field_count, node->as.struct_lit.field_count);
14
        }
15
        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.

16
            FieldInit *given = &node->as.struct_lit.fields[i];
17
            FieldDecl *want = &def->fields[i];
18
            if (given->length != want->length ||
19
                memcmp(given->name, want->name, (size_t)given->length) != 0) {
20
                diag_error(src, node->offset, "expected field '%.*s' in position %d",
21
                           want->length, want->name, i);
22
                ok = 0;
23
                break;
24
            }
25
            Type value_type = check_node(given->value, src);
26
            if (value_type.kind != TYPE_ERROR && value_type.kind != want->type) {
27
                diag_error(src, given->value->offset,
28
                           "field '%.*s' is %s, found %s",
29
                           want->length, want->name, type_name((Type){ .kind = want->type }), type_name(value_type));
30
                ok = 0;
31
            }
32
        }
33
        if (ok) {
34
            node->as.struct_lit.struct_def = def;
35
        }
36
        result = ok ? (Type){ .kind = TYPE_STRUCT, .struct_def = def } : (Type){ .kind = TYPE_ERROR };
37
        break;
38
    }
src/type.cNODE_FIELD, 22 lines
1
    case NODE_FIELD: {
2
        Type object = check_node(node->as.field.object, src);
3
        if (object.kind == TYPE_ERROR) {
4
            result = (Type){ .kind = TYPE_ERROR };
5
        } else if (object.kind != TYPE_STRUCT) {
6
            diag_error(src, node->as.field.object->offset,
7
                       "%s has no fields", type_name(object));
8
            result = (Type){ .kind = TYPE_ERROR };
9
        } else {
10
            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.

11
            int found = 0;
12
            for (int i = 0; i < def->field_count; i++) {
13
                if (def->fields[i].length == node->as.field.length &&
14
                    memcmp(def->fields[i].name, node->as.field.name, (size_t)node->as.field.length) == 0) {
15
                    result = (Type){ .kind = def->fields[i].type };
16
                    found = 1;
17
                    break;
18
                }
19
            }
20
            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.

21
                diag_error(src, node->offset, "'%.*s' has no field '%.*s'",
22
                           def->length, def->name, node->as.field.length, node->as.field.name);
23
                result = (Type){ .kind = TYPE_ERROR };
24
            }
25
        }
26
        break;
27
    }

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.

src/type.cNODE_LET / NODE_VAR, rewritten
1
    case NODE_LET:
2
    case NODE_VAR: {
3
        Type value_type = check_node(node->as.binding.value, src);
4
        if (node->as.binding.has_annotation && value_type.kind != TYPE_ERROR &&
5
            node->as.binding.annotation != value_type.kind) {
6
            diag_error(src, node->offset,
7
                       "'%.*s' is declared %s but initialized with %s",
8
                       node->as.binding.length, node->as.binding.name,
9
                       type_name((Type){ .kind = node->as.binding.annotation }), type_name(value_type));
10
            value_type = (Type){ .kind = TYPE_ERROR };
11
        }
12
        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.

13
        result = (Type){ .kind = TYPE_VOID };
14
        break;
15
    }

src/type.c · record_type and lookup_type, and the TypeBinding they share

src/type.c3 signatures changed
1
typedef struct { const char *name; int length; Type vtype; } TypeBinding;   // TypeKind -> Type
2
static void record_type(const char *name, int length, Type vtype);   // TypeKind -> Type
3
static 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

$ make EXTRA_CFLAGS=-Werror test all unit tests passed

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.

Checked, and the cost I'm choosing to pay

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.

Why the interpreter doesn't get an array-shaped value, not even for this

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

src/main.cadd 6 cases, about 14 lines
1
    case NODE_INDEX:
2
    case NODE_FIELD:
3
    case NODE_ARRAY:
4
    case NODE_STRUCT_LIT:
5
    case NODE_STRUCT_DECL:
6
    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.

7
        diag_error(src, node->offset,
8
                   "the interpreter does not run arrays or structs; compile this program instead");
9
        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

generated Cemitted once per file
 
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];.

generated Cthe emitted body
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

src/ast.hadd 1 line
1
    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

src/ast.hadd 1 union member
1
        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

src/ast.hadd 1 line
1
Node *ast_store(Node *target, Node *value);

src/ast.c · after ast_struct_decl, before ast_free

src/ast.cadd 10 lines
1
Node *ast_store(Node *target, Node *value) {
2
    Node *node = new_node(NODE_STORE);
3
    if (node == NULL) {
4
        return NULL;
5
    }
6
    node->as.store.target = target;
7
    node->as.store.value = value;
8
    return node;
9
}

src/ast.c · ast_free's switch, after NODE_STRUCT_DECL

src/ast.cadd 4 lines
1
    case NODE_STORE:
2
        ast_free(node->as.store.target);
3
        ast_free(node->as.store.value);
4
        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

src/parser.c3 lines changed, 5 added
1
    if (parser_match(p, TOK_EQUAL)) {
2
        if (expr->type != NODE_IDENT) {   // chapter 5

The 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.

3
        if (expr->type != NODE_IDENT && expr->type != NODE_INDEX && expr->type != NODE_FIELD) {
4
            error_at(p, "cannot assign to this");
5
            ast_free(expr);
6
            return NULL;
7
        }
8
        Node *value = parse_expression(p);
9
        if (value == NULL) { ast_free(expr); return NULL; }
10
        if (!parser_match(p, TOK_SEMICOLON)) {
11
            error_at(p, "expected ';'");
12
            ast_free(expr); ast_free(value);
13
            return NULL;
14
        }
15
        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.

16
            Node *node = at(ast_binding(NODE_ASSIGN, expr->as.ident.name,
17
                                        expr->as.ident.length, value), offset);
18
            ast_free(expr);
19
            return node;
20
        }
21
        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.

22
    }

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

src/type.cNODE_STORE, 14 lines
1
    case NODE_STORE: {
2
        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.

3
        Type value_type = check_node(node->as.store.value, src);
4
        if (target_type.kind == TYPE_ERROR || value_type.kind == TYPE_ERROR) {
5
            result = (Type){ .kind = TYPE_ERROR };
6
        } else if (target_type.kind != value_type.kind) {
7
            diag_error(src, node->offset,
8
                       "cannot assign %s here, expected %s",
9
                       type_name(value_type), type_name(target_type));
10
            result = (Type){ .kind = TYPE_ERROR };
11
        } else {
12
            result = (Type){ .kind = TYPE_VOID };
13
        }
14
        break;
15
    }

Checkpoint

$ make EXTRA_CFLAGS=-Werror test all unit tests passed

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.

izvorsource
struct Point { x: Int, y: Int }
generated Cemitted for the declaration above
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.

izvorsource
let a = [10, 20, 30];
let p = Point { x: 1, y: 2 };
generated Cemitted for the two lines above
long iz_a[3] = {10, 20, 30};
iz_Point iz_p = { .iz_x = 1, .iz_y = 2 };
Why array and struct literals only work here, directly after let

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.

izvorsource
print a[1] + p.y;
generated Cemit_expr's output for the expression above
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.

izvorsource, then generated C
a[0] = 5;      // izvor
iz_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

assertions to write6 cases
1
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.

2
let x = 5; print x[0];

Golden error: indexing a non-array. cannot index into Int, pointed at x.

3
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.

4
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.

5
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.

6
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.

tests/test_parser_aggregates_1.c2 cases
1
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.

2
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

$ make EXTRA_CFLAGS=-Werror test all unit tests passed $ python3 tools/check-consistency.py aggregates: ok