No code. Before writing a single line of a code generator, it's worth being honest about which of three real options this chapter is choosing, and what the other two would have cost instead.
A checked, sema-approved tree, which is what chapters five through eight now produce, can become something that actually runs in at least three ways. Emit machine instructions for one specific CPU directly. Build on LLVM, handing it an intermediate representation and letting its own backend target every architecture it knows how to target. Or emit C source text and hand that to a C compiler that already exists on the machine.
emit machine code directlyFull control over the final instructions, and full responsibility for register allocation, instruction selection, and a separate implementation for every CPU izvor should ever run on. Not a realistic scope for this project.
emit LLVM IREvery platform clang already targets, the same reach this chapter ends up with, but through LLVM's own C++ API: building instructions as objects, managing a module and a target data layout, and linking izvor's own build against the LLVM libraries just to compile izvor itself.
emit C source textThe same platform reach as LLVM, arrived at by asking clang to do the LLVM part for us. What's left to write is a program that prints text, in a language this whole project is already written in.
The reach is the headline reason: writing C means every platform clang already supports becomes a platform izvor supports, without codegen.c ever needing to know a single fact about a specific instruction set or calling convention. But there's a second reason that matters just as much for a project meant to be read, not just run. The intermediate representation is text. When a compiled program does the wrong thing, the question "is this a mistake in my codegen or a mistake clang made" has an answer you can get to by opening a file and reading it, the same way you'd read any other C source file. Neither raw machine code nor LLVM IR offers a beginner that option; both need their own separate tooling just to look at.
None of that is free. Generating C means giving up control over the actual instructions the program ends up running: whatever choices clang's own optimizer makes are the ones that ship, and any future feature that would need to place instructions by hand is closed off by this choice on day one. It also means izvor stops being self-contained the moment chapter ten shells out to clang. If no C compiler is installed, izvor can parse, type check, and run a program through the interpreter, but it cannot produce a native executable at all. Going the LLVM route instead would not lose the platform reach, LLVM still lowers its IR to every target it supports, but it would lose the readability, and it would trade "assume a C compiler exists somewhere on this machine" for "izvor's own build now depends on the LLVM development libraries," a heavier and more version-sensitive commitment for a project whose whole point is teaching, not shipping a production toolchain.
For a compiler whose job is to show you why each line of output exists, being able to read the intermediate form is worth more than the machine code it gives up the right to tune by hand.
Checkpoint
Start from a clean tree.
One more step with no new code. Chapter five's milestone program is still the one every later chapter has to keep working. Here is exactly what this chapter turns it into, so every step after this one is filling in a piece of a file you've already seen whole.
let x = 10;let y = 20;print x + y;#include <stdio.h> long iz_x;A global, not a local inside main. Step 10 is the whole reason why: a function's body can only ever see the top level, never whatever called it, so a name a function needs to reach has to live somewhere every function can see, which in C means file scope.
long iz_y; int main(void) {Every top-level izvor statement runs from inside here. (void), not (): an empty parameter list in C historically means "unspecified arguments," not "no arguments," so a function that truly takes none spells it out. This project's own main has used (void) since chapter one.
iz_x = 10;The declaration above and the assignment here are two separate C statements for one izvor statement. Step 10 explains why a top-level let is the one case that has to split like this.
iz_y = 20; printf("%ld\n", (long)(iz_x + iz_y));Every binary expression wrapped in its own parentheses, step 5, and every printed value cast to long, step 6, whether the underlying izvor type is Int or Bool.
return 0;}
Compile that with any C11 compiler and run it, and it prints 30, exactly what
the interpreter has printed for this program since chapter five. Nobody has to take that
on faith: step 11 is a test that checks it mechanically, for this program and several
others, every time make test runs.
Checkpoint
The whole pass is reachable through one entry point, the same shape as chapter eight's
sema_check_program: take the parsed, checked program, and a place to write to,
and hand back a count.
src/codegen.h · new file
/* Generating C: one function per node kind, indented, with every
izvor name mangled so it cannot collide with a name C already
owns. Runs after parsing, type checking, and sema have all
already agreed the program is sound. */#ifndef IZVOR_CODEGEN_H#define IZVOR_CODEGEN_H#include <stdio.h>typedef struct NodeList NodeList;The same forward declaration chapter eight's sema.h uses, for the same reason: enough for a pointer to appear in the signature below without this header needing to #include "ast.h". codegen.c includes ast.h directly, where NodeList is actually defined, so both declarations of it agree.
/* codegen_program - write a complete C translation unit for the
whole program to out. Returns the number of errors, which should
always be 0: by the time this runs, parsing, type checking, and
sema have already refused anything this pass would not know how
to translate. */A count, not a bool, matching type_check_program and sema_check_program. Unlike either of those, this one has no real way to ever return anything but zero, which is worth saying plainly rather than pretending this pass might still find a mistake nobody caught earlier.
int codegen_program(const NodeList *program, FILE *out);#endifCheckpoint
Nothing includes this yet and codegen.c doesn't exist, so there's nothing to
build or break. It exists for the next step to start filling in.
Every izvor name that reaches the generated C gets the same treatment first: prefix it
with iz_. One small function, and the reason it exists is worth more than the
four lines it takes to write.
izvor's own keyword list is short: let, var, print,
if, else, while, fn,
return, true, false. Everything else, including
words that mean something very specific in C, is just an ordinary identifier as far as
izvor's lexer is concerned. let printf = 5; and let int = 5; both
parse today, type check today, and run today, because neither printf nor
int is reserved in this language. Emit either one verbatim as a C name and the
generated file stops being C.
let printf = 5; print printf;Unmangled, this emits long printf; at file scope, right below the #include <stdio.h> that already declared int printf(const char *, ...);. Two conflicting declarations of the same name: clang refuses with "conflicting types for 'printf'", before it ever gets to check whether the izvor program itself made sense.
let int = 5; print int;Worse: int is a reserved word in C, not just a name something else is using. Unmangled, this tries to emit long int;, which clang cannot even parse as a declaration. This one fails before "conflicting types" would even be the right description of the problem.
Mangling turns both into iz_printf and iz_int, ordinary
identifiers that mean nothing special to C, while the real printf the
generated file calls for chapter five's print statement is a fixed string this
pass writes itself and never mangles. The rule that keeps that distinction simple: mangling
only ever touches a name that came from an izvor ident, binding,
call, or fn node. A name this pass writes for its own reasons,
like main or the format string "%ld\n", is never passed through
it.
src/codegen.c · new file, top of the file
/* Generating C: one function per node kind, indented, with every
izvor name mangled so it cannot collide with a name C already
owns. */#include <stdbool.h>#include <stdio.h>#include "ast.h"#include "codegen.h"#include "lexer.h"For TokenType, needed in step 5 to turn a binary operator token into the C spelling of the same operator.
#include "type.h"For TypeKind, needed in step 6 to map an izvor type onto a C one. Included directly rather than trusted to arrive through ast.h, the same explicitness chapter eight's sema.c used for the same header.
static void emit_name(const char *name, int length, FILE *out) {Borrowed, unterminated bytes in, straight into fprintf's own length-aware %.*s, the same pattern every diag_error call has used since chapter one for the exact same reason: nothing here is null-terminated.
fprintf(out, "iz_%.*s", length, name);}
It would be tempting to only mangle names that collide with something specific,
printf, int, main, and leave ordinary names like
x alone. That means maintaining a list of every C keyword and every
standard library name this pass's own output might ever call, forever, and getting it
wrong exactly once is a silent miscompile, not a build error. Mangling every single
name, unconditionally, means there is no list to maintain and no way to leave one
dangerous name off it by accident. The cost is that ordinary, harmless names like
x become iz_x too, for no reason of their own. That's a
uniform rule beating a clever one, the same trade this project has already made more
than once.
Checkpoint
Expected. Nothing calls it yet.
One function, one job: given an expression node, write the C expression that computes the
same value. It recurses the same way the parser built the tree in the first place, and the
same way eval and check_node already walk it.
Binary operators need a translation from the token that spells them in izvor source to the
text that spells them in C. Ten operators, the exact ten chapter seven's own eval
already switches on for the same reason.
src/codegen.c · below emit_name
static const char *c_binary_op(TokenType op) {A default here, same as chapter seven's own switch over this exact enum for this exact purpose: TokenType has far more values than could ever actually reach a binary node's op field, so listing only the ten that matter and falling back for the rest is the right call, not a shortcut.
switch (op) { case TOK_PLUS: return "+"; case TOK_MINUS: return "-"; case TOK_STAR: return "*"; case TOK_SLASH: return "/"; case TOK_EQEQ: return "=="; case TOK_BANGEQ: return "!="; case TOK_LESS: return "<"; case TOK_LESSEQ: return "<="; case TOK_GREATER: return ">"; case TOK_GREATEREQ: return ">="; default: return "?";Unreachable in practice: the parser only ever builds a binary node with one of the ten operators above. The fallback exists so a future ninth or tenth operator that somehow slips past this file shows up as a visibly wrong ? in generated C instead of a silent wrong answer.
}}src/codegen.c · below c_binary_op
static void emit_expr(const Node *node, FILE *out) { switch (node->type) { case NODE_NUMBER: fprintf(out, "%ld", node->as.number); break; case NODE_BOOL:A C int literal, 1 or 0, never true/false. Step 6 explains why Bool is a plain int and not <stdbool.h>'s type.
fprintf(out, "%d", node->as.boolean ? 1 : 0); break; case NODE_IDENT: emit_name(node->as.ident.name, node->as.ident.length, out); break; case NODE_UNARY:No switch on node->as.unary.op needed here: chapter two's grammar only ever builds a unary node for one operator, prefix minus. There is nothing to dispatch on.
fprintf(out, "(-"); emit_expr(node->as.unary.operand, out); fprintf(out, ")"); break; case NODE_BINARY:Every binary node gets its own parentheses, unconditionally. More on why right after this listing.
fprintf(out, "("); emit_expr(node->as.binary.left, out); fprintf(out, " %s ", c_binary_op(node->as.binary.op)); emit_expr(node->as.binary.right, out); fprintf(out, ")"); break; case NODE_CALL:A function call is an expression too, chapter seven. Its arguments are a NodeList, comma-joined the ordinary way.
emit_name(node->as.call.name, node->as.call.length, out); fprintf(out, "("); for (int i = 0; i < node->as.call.args.count; i++) { if (i > 0) fprintf(out, ", "); emit_expr(node->as.call.args.items[i], out); } fprintf(out, ")"); break; case NODE_LET: case NODE_VAR: case NODE_ASSIGN: case NODE_PRINT:Statements, never handed to this function. The trap right after this listing explains why they still need to be here.
case NODE_EXPR_STMT: case NODE_BLOCK: case NODE_IF: case NODE_WHILE: case NODE_FN: case NODE_RETURN: break; }}
Every binary node prints its own opening and closing parenthesis, no matter what the
operator is or what its operands are. That means the C this pass emits never needs to
reason about C's own precedence table at all: a + b * c in izvor parsed as
a + (b * c), chapter two, and that shape gets printed straight through as
(iz_a + (iz_b * iz_c)), which computes the right answer regardless of what
precedence C itself would have assigned + and * if the
parentheses weren't there. A person would never type it this way; it's uglier than
necessary for every expression that didn't need the parentheses in the first place. The
alternative is implementing C's own precedence rules a second time, in a second language,
and trusting that the two tables never quietly disagree. Getting that wrong would not be a
build error. It would be a program that compiles cleanly and computes the wrong number,
which is a far worse thing to ship than an ugly-looking file.
emit_expr is only ever called with an expression: a number, a boolean, a
name, a unary or binary operator, or a call. Chapter eight's resolve_expr
hit exactly this shape of question and the answer here is the same one: the switch is
over NodeType, which names every node kind the language has, not just the
ones this particular function happens to receive. Leaving a case out because "it can't
happen here" is the reasoning that lets a real mistake hide. The day a thirteenth
NodeType gets added, -Werror lists this switch alongside every
other one in the project that switches on it, and you find out here too, instead of only
where the new case needed real logic.
Checkpoint
Compiles clean, exhaustive switch and all. Nothing calls it yet; that starts in step 6.
Statements need two things expressions didn't: an indent level, since a statement sits on its own line, and a C type for anything that declares a name. Both are small, and both get reused everywhere for the rest of the chapter.
src/codegen.c · below emit_expr
static const char *c_type(TypeKind type) {No default, no trailing return after the switch, the same discipline chapter six's type_name uses for the same enum: every one of TypeKind's five values is listed, so a case left out is a build failure under -Werror rather than a function that silently falls off its own end.
switch (type) { case TYPE_INT: return "long"; case TYPE_BOOL: return "int"; case TYPE_VOID: return "void"; case TYPE_ERROR:Same reasoning as type_name's own TYPE_ERROR case: this should never actually reach codegen, since a program that produced one already got refused back in chapter six. Listed anyway, because the switch doesn't know that and shouldn't have to trust a comment instead of the compiler.
case TYPE_FN: return "long"; }} static void emit_indent(int indent, FILE *out) { for (int i = 0; i < indent; i++) { fprintf(out, " "); }}
An izvor Int becomes a C long. A Bool becomes a plain
C int holding 0 or 1, not <stdbool.h>'s
bool, so the generated file never needs an extra header just to hold a value
this project already represents as a small integer everywhere else, chapter six. Void
only ever shows up as a function's own return type, step 8; nothing in this language lets
you declare a variable of it.
Now the first five cases of emit_stmt: the ones that were already fully real
statements as of chapter five.
src/codegen.c · below emit_indent
static void emit_stmt(const Node *node, int indent, FILE *out) { switch (node->type) { case NODE_LET:node->vtype, not the annotation field. By the time this pass runs, chapter six has already checked an annotation against the inferred type and chapter eight has already resolved the name; vtype is the one settled answer both of those passes agreed on, so it's the only field codegen ever needs to read for a binding's type.
case NODE_VAR: emit_indent(indent, out); fprintf(out, "%s ", c_type(node->vtype)); emit_name(node->as.binding.name, node->as.binding.length, out); fprintf(out, " = "); emit_expr(node->as.binding.value, out); fprintf(out, ";\n"); break; case NODE_ASSIGN:No declaration, no type: an assignment just writes to a name that's already declared somewhere, whichever kind of C name that turns out to be. Same shared binding member as NODE_LET, chapter five's original design.
emit_indent(indent, out); emit_name(node->as.binding.name, node->as.binding.length, out); fprintf(out, " = "); emit_expr(node->as.binding.value, out); fprintf(out, ";\n"); break; case NODE_PRINT:One format string, "%ld\n", and one cast to long, for both an Int and a Bool. This never has to branch on the value's type: an Int is already a long, and a Bool, a C int holding 0 or 1, widens to long under the cast without changing value. One code path, not two, for the same reason parenthesizing every binary expression was one rule instead of a table of exceptions.
emit_indent(indent, out); fprintf(out, "printf(\"%%ld\\n\", (long)"); emit_expr(node->as.stmt.value, out); fprintf(out, ");\n"); break; case NODE_EXPR_STMT: emit_indent(indent, out); emit_expr(node->as.stmt.value, out); fprintf(out, ";\n"); break; }}
A local let or var, one declared anywhere other than directly at
the top level, becomes exactly one C statement: a declaration with its initializer built
right in, the same as a person would write it by hand. That's the ordinary case, and it's
the only case this step needs to handle. Step 10 covers the one situation where a
let has to become two separate C statements instead of one.
Checkpoint
Exactly right. Those five are step 7 and step 8's job.
Three more cases, and one small formatting decision that makes all three of them, and every function body in step 8, simpler to write than the alternative.
src/codegen.c · inside emit_stmt's switch, replacing the NODE_BLOCK, NODE_IF and NODE_WHILE placeholder
case NODE_BLOCK:Prints its own opening indent, its own contents one level deeper, and its own closing brace at the original indent, every single time, regardless of who's asking for it.
emit_indent(indent, out); fprintf(out, "{\n"); for (int i = 0; i < node->as.block.stmts.count; i++) { emit_stmt(node->as.block.stmts.items[i], indent + 1, out); } emit_indent(indent, out); fprintf(out, "}\n"); break; case NODE_IF:then_branch and else_branch are always a NODE_BLOCK, chapter seven's braces-required rule, so handing either one straight to emit_stmt at the same indent lands its own brace right where it belongs without this case needing to know anything about blocks at all.
emit_indent(indent, out); fprintf(out, "if ("); emit_expr(node->as.if_stmt.cond, out); fprintf(out, ")\n"); emit_stmt(node->as.if_stmt.then_branch, indent, out); if (node->as.if_stmt.else_branch != NULL) { emit_indent(indent, out); fprintf(out, "else\n"); emit_stmt(node->as.if_stmt.else_branch, indent, out); } break; case NODE_WHILE: emit_indent(indent, out); fprintf(out, "while ("); emit_expr(node->as.while_stmt.cond, out); fprintf(out, ")\n"); emit_stmt(node->as.while_stmt.body, indent, out); break;
The generated brace lands on its own line, not on the same line as the if or
the while that owns it. That reads a little unlike C a person would type by
hand, but it buys something real: NODE_IF and NODE_WHILE never
need a second, special-cased way of printing a block that happens to follow them on the
same line. They call emit_stmt on the branch or the body exactly the way they
would call it on any other statement, and NODE_BLOCK handles its own braces
and its own indentation the same way every time, regardless of what asked for it. One
formatting rule, applied uniformly, instead of a block that behaves one way when it's a
function body and a slightly different way when it's an if's branch.
Checkpoint
A function's signature gets built once and reused twice: once as a forward declaration in step 9, and once right before the body it belongs to. Writing it as its own function keeps those two call sites from silently drifting apart.
src/codegen.c · below emit_stmt's declaration, or anywhere above codegen_program
static void emit_fn_signature(const Node *fn, FILE *out) { fprintf(out, "%s ", c_type(fn->as.fn.ret_type)); emit_name(fn->as.fn.name, fn->as.fn.length, out); fprintf(out, "("); if (fn->as.fn.params.count == 0) {An empty pair of parentheses in C means "takes an unspecified number of arguments," a piece of pre-standard history C never fully closed the door on. A zero-parameter function spells its emptiness out with (void) instead, the same convention this project's own main has used since chapter one.
fprintf(out, "void"); } for (int i = 0; i < fn->as.fn.params.count; i++) {Each parameter is a NODE_IDENT, and its type lives in param_type, the field chapter seven added to the ident member specifically for this. Not vtype: a parameter's type is a fact declared at the call site's other end, not something inferred from how it's used.
if (i > 0) fprintf(out, ", "); Node *param = fn->as.fn.params.items[i]; fprintf(out, "%s ", c_type(param->as.ident.param_type)); emit_name(param->as.ident.name, param->as.ident.length, out); } fprintf(out, ")");}
No trailing newline: step 9 uses this same function once followed by ";\n"
for a forward declaration, and once followed by a bare "\n" right before the
body's own opening brace. Now the two remaining cases in emit_stmt.
src/codegen.c · inside emit_stmt's switch, replacing the NODE_FN and NODE_RETURN placeholder
case NODE_FN:Nothing. The trap right after this listing is the whole reason why.
break; case NODE_RETURN: emit_indent(indent, out); if (node->as.return_stmt.value != NULL) { fprintf(out, "return "); emit_expr(node->as.return_stmt.value, out); fprintf(out, ";\n"); } else { fprintf(out, "return;\n"); } break;
Chapter seven's grammar lets fn appear anywhere a statement can, since
parse_statement is the one dispatcher every block goes through: a function
declared inside another function's body, or inside an if, parses today.
Chapter eight's note on collect_functions is the other half of this: both
the pre-pass that makes a function callable and eval_call's own search look
only at the top-level program list, never into a nested block's own
statements. A function declared anywhere but the top level can never actually be called,
by the interpreter or by anything else, because nothing that resolves a call has ever
looked anywhere but the top of the file for a name to match. Chapter seven's own
eval treats reaching such a declaration while running as a complete no-op,
returning ok with nothing else happening. emit_stmt's
NODE_FN case matches that exactly: it emits nothing, because there is
nothing a nested function's presence should cause to happen in the generated program
either. Its body still gets checked by sema for mistakes of its own, chapter eight's
resolve_stmt recurses into it regardless of where it sits, but that's the
only trace it leaves.
Checkpoint
emit_stmt's switch is exhaustive now, and it calls itself recursively, so
it no longer counts as unused. emit_fn_signature is still waiting on step 9.
C needs a function declared, or at least its prototype seen, before anything calls it. izvor does not have that rule: chapter eight's pre-pass builds its function table by scanning the whole top level before checking any call, specifically so a function can call one declared later in the same file. Translating that permission into C means doing the same scan a second time, for a different reason.
fn main_logic() -> int { return helper(); }fn helper() -> int { return 42; }Declared after its own caller. Chapter eight's collect_functions already made this legal izvor. Translated verbatim, in the order these two functions appear, iz_main_logic's body would call iz_helper before C has seen so much as a prototype for it: "implicit declaration of function 'iz_helper'" under any reasonably strict C compiler, and a plain error under C11's own rules.
print main_logic();The fix real C compilers exist alongside every day: write every function's prototype first, in one block, before any function body appears at all. By the time C reads the first body, every function that will ever exist in this file already has a visible declaration, regardless of which one physically comes first.
src/codegen.c · below emit_fn_signature, the start of codegen_program
int codegen_program(const NodeList *program, FILE *out) { fprintf(out, "#include <stdio.h>\n\n"); bool any_fn = false;Tracked so the blank line after this block only appears when there was something to separate from what comes next, step 10.
for (int i = 0; i < program->count; i++) { Node *stmt = program->items[i]; if (stmt->type != NODE_FN) continue;Only program->items itself, never anything inside a block, an if, or another function's body. The same top-level-only scan chapter eight's collect_functions already made, for the same reason: a nested fn is never callable, so it never earns a forward declaration.
any_fn = true; emit_fn_signature(stmt, out); fprintf(out, ";\n"); } if (any_fn) fprintf(out, "\n");This also means mutual recursion, two functions that each call the other, works in the generated C for free: both prototypes exist before either body is emitted, so it makes no difference which one calls which first.
Then the bodies themselves, in the same source order they appear in.
src/codegen.c · still inside codegen_program, after the forward-declaration loop
for (int i = 0; i < program->count; i++) { Node *stmt = program->items[i]; if (stmt->type != NODE_FN) continue; emit_fn_signature(stmt, out); fprintf(out, "\n"); emit_stmt(stmt->as.fn.body, 0, out);stmt->as.fn.body is always a NODE_BLOCK, chapter seven's parse_fn guarantees it. Its own case prints the opening brace at indent 0, the body at indent 1, and the closing brace back at indent 0, matching step 7's rule with no special handling needed here.
fprintf(out, "\n"); } return 0;}
That closing return 0; is temporary scaffolding so the function compiles on
its own; step 10 moves it to the true end, after globals and main exist.
Checkpoint
Clean. Every static helper in this file is reached now, transitively, from
codegen_program itself, which is declared in the header and so is never
flagged as unused regardless of whether anything outside this file calls it yet. Nothing
does, until step 12.
Every izvor statement that isn't a function declaration runs from inside a generated
main. One of them, a top-level let or var, can't be
translated the same way step 6 translates a local one, and the reason traces straight back
to how function calls resolve names.
Chapter seven parents a call's new scope to the global environment, never to the caller's,
so a function's body can see whatever's declared at the top level, but never anything from
wherever it happened to be called. In the generated C, "visible to every function" can only
mean one thing: a name declared at file scope, outside main entirely. A local
C variable declared inside main's own body is invisible to iz_helper
or any other function, exactly the isolation C already gives every function's locals by
default. So a top-level let x = 10; has to become two separate C statements: a
bare declaration, long iz_x;, at file scope alongside the function prototypes,
and the actual assignment, iz_x = 10;, inside main, at the exact
point the original statement appears. Nothing else in this chapter needs that split. A
local let, one declared inside a block, an if, or a function body,
never needs to be seen from outside its own scope, so step 6's ordinary
declare-with-initializer already does the right thing for it.
src/codegen.c · inside codegen_program, between the forward-declaration loop and the function-body loop
bool any_global = false; for (int i = 0; i < program->count; i++) { Node *stmt = program->items[i]; if (stmt->type != NODE_LET && stmt->type != NODE_VAR) continue;Only a direct top-level item. A let nested inside a top-level if or block gets its own SemaScope in chapter eight, parented to the block it's in, not to root, so no function can ever see it either. It's handled later, by the ordinary emit_stmt call for that if or block, as a normal local.
any_global = true; fprintf(out, "%s ", c_type(stmt->vtype)); emit_name(stmt->as.binding.name, stmt->as.binding.length, out); fprintf(out, ";\n"); } if (any_global) fprintf(out, "\n");
Now main itself: every top-level statement, in order, skipping the functions
already handled and splitting a top-level let or var into the
assignment half only, since its declaration is already sitting above.
src/codegen.c · the end of codegen_program, replacing the temporary return 0; from step 9
fprintf(out, "int main(void) {\n"); for (int i = 0; i < program->count; i++) { Node *stmt = program->items[i]; if (stmt->type == NODE_FN) continue;Already emitted, as a real function, above. Reaching one here the way eval reaches one at run time would just mean doing nothing for it, but there's no need to even call into emit_stmt to find that out.
if (stmt->type == NODE_LET || stmt->type == NODE_VAR) {The other half of the split from this step's first listing. No type, no c_type call: the declaration already carries the type, this is just the assignment.
fprintf(out, " "); emit_name(stmt->as.binding.name, stmt->as.binding.length, out); fprintf(out, " = "); emit_expr(stmt->as.binding.value, out); fprintf(out, ";\n"); continue; } emit_stmt(stmt, 1, out);Every other top-level kind, NODE_ASSIGN, NODE_PRINT, NODE_EXPR_STMT, NODE_IF, NODE_WHILE, a bare NODE_BLOCK, needs no special case here at all: an assignment writes to whatever C name already exists, global or not, and every other kind was never a declaration in the first place.
} fprintf(out, " return 0;\n"); fprintf(out, "}\n"); return 0;}
That's codegen_program finished. Run it against chapter five's milestone by
hand, in your head, statement by statement, and it produces exactly the file step 2 showed
before any of this existed.
Checkpoint
Clean. codegen_program is complete, though nothing outside this file calls it yet.
Everything above this step could be wrong in a way that still compiles: a swapped operand, a missing cast, an off-by-one in an indent count. A hand-written expected string in a test file would only catch the mistakes I thought to predict when I wrote it. This test catches the ones I didn't.
eval is static in main.c, on purpose, since chapter
seven: it's an implementation detail of the driver, not part of this project's public
surface. That means this test can't call it directly the way it calls
type_check_program or sema_check_program. It doesn't need to.
The already-built izvor binary is the interpreter, reachable exactly
the way every golden test since chapter five already reaches it: run it as a subprocess on
a source file, and read back whatever it printed. The generated-C side of the comparison is
built the ordinary way instead, calling codegen_program directly, then handing
the result to whatever C compiler the machine already has and running that as a
second subprocess. Two genuinely separate programs, built two genuinely separate ways, each
asked to answer the same question.
tests/test_codegen_1.c · new file
#include <assert.h>#include <stdio.h>#include <stdlib.h>#include <string.h>#include "codegen.h"#include "lexer.h"#include "parser.h"#include "sema.h"#include "type.h" /* run_and_capture - run a shell command, read back everything it
writes to stdout. */The same idea the golden test runner has used since chapter five: run a binary, capture its stdout, compare it against something. This just captures both sides itself instead of comparing against a fixed .expected file.
static void run_and_capture(const char *cmd, char *buf, size_t bufsize) { FILE *pipe = popen(cmd, "r"); assert(pipe != NULL); size_t n = fread(buf, 1, bufsize - 1, pipe); buf[n] = '\0'; pclose(pipe);} /* check_matches - the oracle. Run src through the already-built
interpreter and through codegen, then a real C compiler, and
fail unless both printed exactly the same bytes. */Fixed temp paths, not a fresh unique name per call. Tests in this project don't run concurrently, so nothing else can collide with them mid-run, the same non-defensive choice the fixed-size arrays in sema.c already made about their own limits.
static void check_matches(const char *src) { FILE *iz = fopen("/tmp/iz_codegen_check.iz", "w"); assert(iz != NULL); fputs(src, iz); fclose(iz); char interpreted[4096];The exact command every golden-test checkpoint since chapter five has already run by hand: ./build/izvor against a source file. This assumes make has already built it, the same assumption the golden runner makes.
run_and_capture("./build/izvor /tmp/iz_codegen_check.iz", interpreted, sizeof(interpreted)); Lexer lexer; lexer_init(&lexer, src); Parser parser; parser_init(&parser, &lexer); NodeList program; assert(parser_parse(&parser, &program)); assert(type_check_program(&program, src) == 0); assert(sema_check_program(&program, src) == 0); FILE *c_file = fopen("/tmp/iz_codegen_check.c", "w"); assert(c_file != NULL); assert(codegen_program(&program, c_file) == 0); fclose(c_file); nodelist_free(&program); int status = system("cc -std=c11 -o /tmp/iz_codegen_check "A raw system() call, not the reusable, error-surfacing wrapper chapter ten builds. That's deliberate: run_clang exists to turn a real compiler failure into a proper izvor error for someone running the compiler, and nothing here is that. This is a one-off harness proving the concept before chapter ten commits to a real interface for it.
"/tmp/iz_codegen_check.c"); assert(status == 0); char compiled[4096]; run_and_capture("/tmp/iz_codegen_check", compiled, sizeof(compiled)); if (strcmp(interpreted, compiled) != 0) {Byte for byte, not "close enough." Both sides print through the exact same "%ld\n" shape, chapter seven's own NODE_PRINT case and step 6's emit_stmt, so an exact match is the only honest bar to hold this to.
fprintf(stderr, "mismatch for:\n%s\ninterpreted: %scompiled: %s", src, interpreted, compiled); exit(1); }}tests/test_codegen_1.c · below check_matches
int main(void) { check_matches("let x = 10; let y = 20; print x + y;"); check_matches("let a = 7; let b = 2; print a - b; print a * b; print a / b;"); check_matches("let ok = 3 < 5; print ok;");A Bool, deliberately: iz_ok is a C int, and this proves step 6's single cast-to-long print path actually produces the same digit the interpreter does.
check_matches("var n = 0; while n < 5 { n = n + 1; } print n;"); check_matches(double is a reserved word in C. Naming an izvor function this on purpose is the mangling story from step 4, proven on a real function this time instead of a variable.
"fn double(n: Int) -> Int { return n * 2; }\n" "print double(21);"); printf("codegen: %d programs agree\n", 5); return 0;}Five programs, and none of them needed a hand-typed expected answer anywhere in this file. The interpreter and the generated C either agree or the test fails and prints both outputs side by side, which is a better failure message than any string I could have written down in advance, because it shows you exactly where the two implementations parted ways instead of just telling you that they did. Two independent paths through the same source, built weeks apart by the same reasoning, landing on the same answer is worth more than any number of hand-picked assertions, because nobody had to already know the right answer to write the test.
Checkpoint
Two small edits close the chapter. Chapter ten is what teaches izvor to actually invoke a C compiler and run the result; all this step adds is a way to look at the C this pass produces, without running any of it.
src/main.c · near the top, alongside the other #include lines
#include "codegen.h"src/main.c · in main(), directly after chapter eight's sema_check_program call and its own failure check, before the loop that calls eval
if (argc > 2 && strcmp(argv[2], "--emit-c") == 0) {Placed after sema, before evaluation: the exact same spot chapter eight's own check landed in, because this pass needs the same guarantee sema exists to provide, a program that's already been refused if it had anything wrong with it.
int errors = codegen_program(&program, stdout); nodelist_free(&program); return errors > 0;Written this way rather than a bare return 0; for the same honesty codegen.h's own comment already committed to: this pass cannot actually fail today, but the driver checks the real return value instead of assuming that forever.
}
./build/izvor program.iz --emit-c now prints the generated C to stdout and
stops, running nothing. That's the whole feature this chapter wires into the driver:
a way to read the output, not a way to run it. Running it is chapter ten's job, and it's a
bigger one than one flag: turning a real clang failure into an izvor error instead of
letting raw compiler output reach someone who never asked to see it.
Makefile · the SRC := line
SRC := src/lexer.c src/ast.c src/parser.c src/diag.c src/env.c src/type.c src/sema.cSRC := src/lexer.c src/ast.c src/parser.c src/diag.c src/env.c src/type.c src/sema.c src/codegen.cLeave it off this list and every test that touches codegen_program or any of its static helpers fails to link, naming them as undefined symbols, the same linker problem wearing a compiler problem's clothes chapter eight's step 12 already walked through once.
Checkpoint
The last command prints the exact file step 2 showed before any of this chapter's code existed. Every earlier golden test and the interpreter's own behavior are untouched: this chapter added a new way to look at a program, not a new way to run one.