🌐 ν•œκ΅­μ–΄λ‘œ 읽기

00 β€” Overview

The arc in one page

MicroHs began on 2023-05-25 as two files: Lam.hs, 227 lines of Haskell implementing a lambda-calculus-to-combinator compiler, and eval.c, 539 lines of C that evaluate the resulting combinator graph. That first commit, ccb31388 ("First version"), is small enough to read in one sitting. There is no parser worth the name, no error handling, no modules β€” just enough machinery to compile a lambda expression to SKI-style combinators and run it. This is the whole point of starting here: you can hold the entire system in your head.

From there the author, Lennart Augustsson, grew the two files into a real compiler. Four days in, commit a9178695 is bluntly titled "Make a real compiler." β€” the toy evaluator gets an actual parser, a proper node representation, and an optimiser. Growth from here is not linear or tidy. The next day, commit 17587b2d is called "Hack around a problem." β€” a fix that works, described as exactly what it is, not dressed up as a design decision. That honesty runs through the whole history: 6f08f029, dated 2023-07-03, reads "Running IO still doesn't work. :(" and is committed anyway, because committing broken states is how this workflow works.

Over the next week or so the language fills out: let, infix operators, tuples, lists, string and character literals, do-notation, modules, a Prelude, list comprehensions β€” the surface syntax converges on something recognizably Haskell, even though the back end is still combinator graph reduction on a tiny C runtime. In parallel, the compiler learns to talk to the outside world (monadic IO, serialization of program state to a file) and grows a lab-notebook workflow of its own: command-line flags, ad hoc tests, a first README.

The second half of the story is about the compiler outgrowing GHC. Starting in July, commits accumulate around making the compiler's own source compile under itself β€” commit messages like "Make it compile with uhs" and "Make Desugar compile with uhs" appear repeatedly as each module is ported. On 2023-07-20, 298c9824 renames uhs to mhs, and within the same window the compiler successfully compiles itself. The last stretch, through early August, adds a Hindley-Milner type checker and gets the self-hosted compiler bootstrapping with type checking β€” a38c64ce, "Now bootstraps with type checking.", 2023-08-10, is where this account stops.

That is 232 commits by one author over 77 days, and this repo's git history is the primary source β€” nothing here is a retelling from memory. The reader contract for the rest of this book: read chapters 01 through 09 in order (each assumes the state left by the one before it), and actually run the "Try it" blocks in a throwaway worktree rather than just reading past them. The value of this material is in watching working code evolve commit by commit, not in a summary of what changed.

Timeline

ChDates (2023)Commit rangeChapterSummary
0105-25ccb31388 + 2116889001-the-seed.mdThe seed: Lam.hs + eval.c
0205-26 – 05-3021168890..b56ba28b02-a-real-compiler.mdA real compiler: parser, node repr, optimiser
0305-30 – 06-01b56ba28b..95ff6a3403-growing-the-language.mdGrowing the language: let, infix, tuples, lists, strings, do
0406-01 – 06-0295ff6a34..8c5fbb1a04-talking-to-the-world.mdTalking to the world: monadic IO, serialization
0506-028c5fbb1a..8018eec805-lab-notebook-workflow.mdLab-notebook workflow: flags, tests, first README
0606-02 – 06-078018eec8..41d6a7e806-becoming-haskell.mdBecoming Haskell: modules, Prelude, comprehensions
0707-01 – 07-1841d6a7e8..a824a17507-the-self-hosting-push.mdThe self-hosting push: "compile with uhs"
0807-18 – 07-20a824a175..298c982408-bootstrap.mdBootstrap: the compiler compiles itself
0907-20 – 08-10298c9824..a38c64ce09-types-at-last.mdTypes at last: HM checker, typed bootstrap

Note the jump between chapters 06 and 07: the last commit of chapter 06, 41d6a7e8, is dated 2023-06-07; the first commit of chapter 07, 44004020, is dated 2023-07-01. Nothing in this repo's history explains the 24-day gap β€” the log simply resumes. Chapter boundaries in this book follow the thematic shape of the work, not a fixed cadence.

How to use this branch

Every chapter tells you which commit range to look at and what to build and run there. The convention, used throughout this book, is to check out a commit into its own worktree rather than disturbing your main clone:

git worktree add /tmp/mhs-<shortsha> <shortsha> && cd /tmp/mhs-<shortsha> && nix develop $MICROHS -c make <target>

$MICROHS is the absolute path to your clone of this repo, set once as described in the top-level README.md. When you're done with a commit, remove its worktree:

git worktree remove --force /tmp/mhs-<shortsha>

Reading the history directly, rather than trusting a paraphrase of it, is the whole method of this book. As a worked example, here is the actual first commit:

$ git show --stat ccb31388
 .gitignore |   6 +
 Lam.hs     | 227 ++++++++++++++++++++++++++
 Makefile   |  14 ++
 eval.c     | 539 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 786 insertions(+)

Four files, 786 lines, one commit. That is the entire system chapter 01 starts from. When a chapter later claims a commit did something, you can run git show <sha> yourself and check.

What this is not

This is not a Haskell tutorial β€” it assumes you can already read Haskell comfortably. It is not a reference for modern MicroHs; the project kept going long after this account ends, and none of that later history is in scope here. This book is a close reading of one specific, bounded stretch of git history: ccb31388 (2023-05-25) through a38c64ce ("Now bootstraps with type checking.", 2023-08-10). It stops there, deliberately.

🌐 ν•œκ΅­μ–΄λ‘œ 읽기

01 β€” The seed

Where we are

Nothing. This is commit ccb31388, "First version," 2023-05-25 19:33:17 +0200 β€” the first commit in the repository. There is no prior state to assume; everything in this chapter is read straight out of one commit.

Four files land together: .gitignore, Lam.hs (227 lines of Haskell), Makefile (14 lines), and eval.c (539 lines of C) β€” 786 lines total (see 00-overview.md for the commit stat). What the code shows, without reading further intent into it than the code justifies: the author wants a compiler from a small Haskell-like language down to combinators, plus a runtime tiny enough to fit in one C file, and he wants the two halves working together by the end of the sitting. There is no parser, no type checker, no module system, no error handling worth the name β€” just enough machinery to compile one hardcoded lambda term and run it.

What happened

The commit is a two-file architecture, plus a third artifact β€” a text file β€” that sits between them.

Lam.hs: compiler, no parser

Lam.hs defines the Exp AST (Var, App, Lam, Int, Comb, Prim β€” see aux/lambda-calculus-and-ski.md for what each constructor is for), a bracket-abstraction compiler that turns Exp terms with Var/Lam into pure combinator terms (see aux/bracket-abstraction.md for the derivation), a Kiselyov-style improve pass that shrinks the combinator output, a pretty-printer, and β€” at the very bottom of the file β€” one hardcoded program, eNfib, computing nfib 35. There is no readFile, no getArgs parsing beyond what the Makefile supplies, no lexer or parser for the source language at all. Every example in this commit is Haskell source built directly as an Exp value, by hand, inside Lam.hs itself.

The whole pipeline is small enough to fit in the Makefile, quoted here in full (ccb31388:Makefile):

test:	eval nfib.comb
	./eval nfib.comb

eval:	eval.c
	gcc -O3 eval.c -o eval

nfib.comb:	Lam
	./Lam > nfib.comb

Lam:	Lam.hs
	ghc Lam.hs -o Lam

clean:
	rm -f *.hi *.o eval Lam

Read bottom to top and it's the whole story: ghc compiles Lam.hs into a Lam binary; running Lam and redirecting its stdout produces nfib.comb, a text file; gcc compiles eval.c into an eval binary; running eval on nfib.comb is the actual program. Two independent toolchains (GHC and a C compiler) meet at one text file.

The compiler itself is compile, dispatching to abstract on every Lam. Quoted whole (ccb31388:Lam.hs, lines 97–101 β€” compile above it is one line per constructor and is covered by aux/bracket-abstraction.md section 1):

abstract :: Ident -> Exp -> Exp
abstract x e@(Var y) | x == y = CI
abstract x (App f a) = App2 CS (abstract x f) (abstract x a)
abstract x (Lam y e) = abstract x $ improveC $ abstract y e
abstract x e = App CK e

Four equations, no recursion into a name table, no environment β€” by design; the aux doc walks through why. Lam.hs never grows past 227 lines in this commit, and most of that length is the pretty-printer, the improve/improveC/improveT rewrite rules (aux/bracket-abstraction.md section 3), the reference reducer reduce/redOne, and a small combinator-building DSL (ToLam, $$, comp) used to write eNfib in a readable style.

eval.c: the runtime

eval.c is a self-contained C program: a fixed-size cell heap, a switch on node tags implementing every combinator reduction rule, an explicit spine stack, and a real mark-and-scan garbage collector, with indirection nodes used for in-place update. None of that is summarized further here β€” aux/graph-reduction-and-laziness.md covers the cell representation, the spine walk, sharing, and the GC in detail with line citations, and aux/y-combinator-and-recursion.md covers how Y builds a genuinely cyclic graph for recursion. Both are verified against this exact commit; there's no reason to duplicate them here.

The .comb file: a text interface you can read

nfib.comb β€” the file Lam writes and eval reads β€” is plain text, one line, human-legible combinator syntax. Generated fresh in a worktree at this commit, it reads (whole file; it genuinely is one line):

((Y (((C' C) ((B (S (> 2))) (((C' (C' +)) (((S' (S' +)) ((C B) (-' 1))) ((C B) (-' 2)))) 1))) 1)) 35)

That is not a compact binary blob β€” it's S/K/I/B/C/S'/C' and friends, parenthesized left-to-right, printed by pretty() in eval.c and parsed back by parse() in the same file (both discussed in the aux doc). Choosing a plain-text interchange format between two otherwise-unrelated programs is itself a decision worth noticing: it means you can cat the intermediate representation, diff two versions of it, or hand-write a test case, all without either compiler.

The commented-out block in main

main is not just the eNfib-and-print line that ships. ccb31388:Lam.hs, lines 8–30, is a {- ... -}-commented block sitting directly above the line that runs:

main :: IO ()
main = do
{-
  let c1 = compile e1
      c1' = improve c1
      r1' = reduce (App c1 (Var "k"))
      r1'' = reduce (App c1 (Int 5))
  pp e1
  pp c1
  ...
  pp $ improve $ compile $ App (Lam "x" (App2 (Var "x") (Int 10) (Int 2))) (Prim "-")
-}
  putStrLn $ toStringP $ eNfib `App` 35

That block is 21 lines of pp/let calls exercising e1, e2, e3 (three small hand-written test terms defined a few lines below main) β€” compile them, improve them, reduce them, print every intermediate result. It's not dead code in the sense of being obsolete; it's a REPL session that got pasted into main and then commented out rather than deleted, so it's still there, still correct, and still runnable β€” see the "Try it" section.

Commit 2: "Clean more"

21168890, authored (and committed) 30 seconds after ccb31388 β€” same day, same two timestamps a half-minute apart β€” changes exactly one line of the Makefile:

 clean:
-	rm -f *.hi *.o eval Lam
+	rm -f *.hi *.o eval Lam *.comb

That's the entire diff. make clean didn't remove the generated nfib.comb; thirty seconds after committing the first version, that got noticed and fixed. Nothing else in the commit changes.

The B' gap

One loose end ships in this commit and stays: eval.c declares a BB tag (printed as B'), the parser accepts B' syntax, and pretty() knows how to print it β€” but the eval switch has no case BB:, so a B' node reaching the runtime crashes with bad tag 12. It happens not to matter, because improveC never emits B' in this commit, even though Lam.hs's own redOne implements the rule correctly. See aux/lambda-calculus-and-ski.md section 5 for the verified crash and aux/bracket-abstraction.md section 4 for why compile/improve never trigger it.

Why it matters

Everything the rest of this book covers is one of these two files, or the format between them, growing up. The parser that arrives four days later (chapter 02) replaces the hand-built Exp values in main; the improved node representation and optimiser extend eval.c's tag switch and improve's rewrite rules; the later self-hosting push replaces Lam.hs itself, module by module, without ever changing the shape of the pipeline it's part of. The .comb-file handoff β€” compile to a text format, then run the text format with a separate program β€” is the same shape the bootstrap uses two months later, just with a much bigger compiler on one side. Starting with a hardcoded program instead of a parser is exactly what makes day one end with something that runs end-to-end: nfib 35, for real, computed by a graph-reduction machine that didn't exist that morning.

Notebook margin

Habits visible already, at commit 1 and 2:

  • Exploratory code is commented out, not deleted. The e1/e2/e3 scratch block in main stays in the file, fully functional, just not running by default.
  • "Clean more" lands 30 seconds after "First version" β€” the same session, immediately fixing a make clean oversight rather than batching cleanup for later.
  • The interface between the Haskell half and the C half is a text file you can read with your eyes, not a serialized binary format.
  • The commit ships with a known-crashing case (B') rather than waiting until every combinator is wired up end-to-end. It doesn't block shipping because it isn't reachable yet.

Try it

Convention used throughout this book: check out the commit into its own worktree.

git worktree add /tmp/mhs-ccb31388 ccb31388
cd /tmp/mhs-ccb31388
nix develop $MICROHS -c make test

($MICROHS is the absolute path to your clone, set once per the top-level README.md.) Expect a GHC compile with one benign warning, then the program output:

Lam.hs:211:13: warning: [GHC-63394] [-Wx-partial]
    In the use of β€˜head’
    ...
./eval nfib.comb
((Y (((C' C) ((B (S (> 2))) (((C' (C' +)) (((S' (S' +)) ((C B) (-' 1))) ((C B) (-' 2)))) 1))) 1)) 35)
29860703
node size=24, heap size=100000
1045124618 reductions, 1944 GCs

29860703 is nfib 35. The warning is about Data.List.\\'s use of partial head at line 211 (a version of \\ used to pick a fresh variable name) β€” harmless here since the input lists it's subtracting from are always non-empty in this commit's usage, but a real partiality GHC is right to flag.

Exercise 1: run the commented-out experiment

Open Lam.hs and delete just the two comment markers: the {- on line 8 and the -} on line 30. Leave everything between them untouched. Then:

nix develop $MICROHS -c make Lam
nix develop $MICROHS -c ./Lam

This prints 16 lines β€” the block's own pp/putStrLn calls followed by the usual nfib term. The first three lines are compile/improve applied to e1 = \x. + x x:

(\x. + x x)
S (S (K +) I) I
S + I

β€” matching aux/bracket-abstraction.md section 2's hand-traced derivation of compile e1 and section 3's improve (compile e1) exactly, but now confirmed a second, independent way (the block computes them directly in main, not via ghci). Further down (line 14 of the run), pp $ improve $ compile e3 prints:

T

β€” the same answer aux/bracket-abstraction.md section 5 asks you to predict by hand before checking in ghci; here it's the same computation, done inline in the compiled Lam binary instead. (Put the {-/-} back before moving on β€” the next two exercises regenerate nfib.comb, and this block's extra output would land in that file too if left uncommented, since nfib.comb is just Lam's redirected stdout.)

Exercise 2: run a smaller nfib

Change line 31 from eNfib \App` 35toeNfib `App` 25. Before rebuilding, predict the answer from eNfib's own recurrence (quoted in [aux/y-combinator-and-recursion.md](aux/y-combinator-and-recursion.md) section 3): nfib n = 1ifn < 2, else nfib(n-1) + nfib(n-2) + 1`. Expanding it from the base case:

nfib: 1, 1, 3, 5, 9, 15, 25, 41, 67, 109, 177, 287, 465, 753, 1219,
      1973, 3193, 5167, 8361, 13529, 21891, 35421, 57313, 92735,
      150049, 242785
       (n= 0  1  2  3  4   5   6   7   8    9   10   11   12   13   14
            15    16    17    18     19     20     21     22     23
              24      25)

So nfib 25 should be 242785. Rebuild and check:

nix develop $MICROHS -c make test
./eval nfib.comb
((Y (((C' C) ((B (S (> 2))) (((C' (C' +)) (((S' (S' +)) ((C B) (-' 1))) ((C B) (-' 2)))) 1))) 1)) 25)
242785
node size=24, heap size=100000
8497488 reductions, 15 GCs

242785, as predicted β€” and, worth noting in passing, over two orders of magnitude fewer reductions than nfib 35 (8,497,488 vs. 1,045,124,618) for a function whose call count roughly follows the Fibonacci growth rate. Before the next exercise, change line 31 back to 35 and rebuild β€” editing Lam.hs alone does not regenerate the nfib.comb sitting on disk, which is still the 25 version:

nix develop $MICROHS -c make test

This should print the original ... 35) term and 29860703 again; nfib.comb is now back to its baseline content.

Exercise 3: break improveC and watch the term grow

improveC (ccb31388:Lam.hs, lines 106–117) has this case, one of six special-cased patterns before the catch-all:

    (App CS (App CK e1),     e2)        -> improve (App2 CB e1 e2)

This is the rule that introduces B β€” see aux/bracket-abstraction.md section 4 for why it exists. First, save the current (unbroken) nfib.comb so there's something to diff against later:

cp nfib.comb nfib.comb.orig

Then delete that one improveC line and rebuild:

nix develop $MICROHS -c make test
./eval nfib.comb
((Y ((C ((S (K C)) ((S (K (S (> 2)))) ((C ((S (K C)) ((S (K (S (K +)))) ((S ((S (K S)) ((S (K (S (K +)))) ((C ((S (K S)) K)) (-' 1))))) ((C ((S (K S)) K)) (-' 2)))))) 1)))) 1)) 35)
29860703
node size=24, heap size=100000
1373651156 reductions, 2542 GCs

Still 29860703 β€” removing one improve rule doesn't change what the program computes, only how big the compiled term is and how much work eval does to reduce it. Measured against the nfib.comb.orig saved just before the edit:

$ nix develop $MICROHS -c diff nfib.comb.orig nfib.comb
1c1
< ((Y (((C' C) ((B (S (> 2))) (((C' (C' +)) (((S' (S' +)) ((C B) (-' 1))) ((C B) (-' 2)))) 1))) 1)) 35)
---
> ((Y ((C ((S (K C)) ((S (K (S (> 2)))) ((C ((S (K C)) ((S (K (S (K +)))) ((S ((S (K S)) ((S (K (S (K +)))) ((C ((S (K S)) K)) (-' 1))))) ((C ((S (K S)) K)) (-' 2)))))) 1)))) 1)) 35)

(diff isn't in this project's own Makefile, but it's on the PATH nix develop hands you, since the devShell isn't built in isolation from the host.) The one-line term grows from 102 bytes to 181 β€” every B the missing rule would have produced instead shows up as S (K _) _, spelled out. eval pays for that: 1,373,651,156 reductions against the original 1,045,124,618 (31% more), and 2,542 GCs against 1,944 (31% more) β€” one missing rewrite rule, and both the program text and the runtime cost grow by roughly a third, for a function whose observable behavior is unchanged.

Clean up when done:

git worktree remove --force /tmp/mhs-ccb31388

🌐 ν•œκ΅­μ–΄λ‘œ 읽기

02 β€” A real compiler

Where we are

chapter 01 left two files and a text format between them: Lam.hs compiles one hand-built Exp value β€” eNfib, computing nfib 35 β€” down to combinators, and eval.c runs the result. There is still no parser. Every example the system can run is Haskell source, but it's Haskell source written as a literal Exp value inside Lam.hs itself, not text the compiler reads. Commit 21168890 ("Clean more") is the last commit before this chapter's range, and it changes one line of the Makefile. Nothing about the hardcoded-program shape has moved yet.

What happened

Warm-up: 2023-05-26

Three commits land the Friday after the seed, all still inside the old two-file world (Lam.hs/eval.c), none of them touching the parser that hasn't been written yet:

54806439 ("Use file handle for printing.") turns eval.c's pretty/ pp functions from hardcoded printf/putchar calls into print(FILE *f, ...)/pp(FILE *f, ...) that take an explicit stream. main still calls pp(stdout, n) both times, so nothing about the program's visible output changes β€” but the function can now be pointed at any file handle, which is what makes b0c33a8f, 34 minutes later, able to add a label-parsing path that reads from the same kind of handle it prints to.

6485a341 ("Move some improvements earlier.") reorders Lam.hs's optimizer without changing its rule set: the "flip arguments to commutative ops" rewrites ((CC, Prim "+") -> Prim "+" and friends) move out of improveT's case list and into improveC's, ahead of improveC's own catch-all. Both passes still exist (improve = improveT . improveC), but a term now gets flip-normalized during the first traversal instead of having to wait for the second β€” more opportunities for the S'/C' rules just above them in improveC to fire on an already-flipped operand in one pass instead of two. The commit message names the intent ("move ... earlier"), not this mechanism; that's a pattern this chapter keeps running into (see "Notebook margin").

b0c33a8f ("Print and parse sharing/cycles.") is the one warm-up commit worth quoting, because the format it introduces is what chapter 02's own compiler output uses from a9178695 onward: a way to print a DAG or a cycle β€” not just a tree β€” as flat text. The parser side (b0c33a8f:eval.c, lines 295–306):

  case '_' :
    l = (int)parse_int(f);  /* The label */
    if (shared[l] == NIL) ERR("shared == NIL");
    return shared[l];
  case ':' :
    l = (int)parse_int(f);  /* The label */
    if (!gobble(f, ' ')) ERR("parse ' '");
    if (shared[l] != NIL) ERR("shared != NIL");
    shared[l] = alloc_node(IND); /* Must have a placeholder for cycles */
    r = parse(f);
    INDIR(shared[l]) = r;
    return r;

:N <expr> defines shared node N (an indirection placeholder is allocated before <expr> is parsed, specifically so a cyclic reference inside <expr> back to N has something to point at); _N refers back to it. The print side marks any node visited twice during a pre-pass (find_sharing) as SHARED, then on the real print pass emits :N the first time a SHARED node is reached and just _N every time after (b0c33a8f:eval.c, lines 327–333):

  if (MARK(n) == PRINTED) {
    fprintf(f, "_%d", LABEL(n));
    return;
  } else if (MARK(n) == SHARED) {
    fprintf(f, ":%d ", LABEL(n));
    MARK(n) = PRINTED;
  }

Nothing in this commit's own test data actually shares a node yet β€” it's groundwork. Four days later, 17587b2d's "hack" is the first thing that puts this label format to real use.

a9178695: "Make a real compiler." (2023-05-29, 16:14)

The pivot. Six new files, 561 lines, git show a9178695 --stat:

 Desugar.hs    |  31 +++++++++
 Exp.hs        | 141 ++++++++++++++++++++++++++++++++++++++++
 Lam.hs        |   6 ++
 Main.hs       |  14 ++++
 Parse.hs      | 204 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 ParserComb.hs | 165 +++++++++++++++++++++++++++++++++++++++++++++++

ParserComb.hs and Parse.hs are a parser-combinator library and the grammar built on it β€” aux/parser-combinators.md covers both in detail. What matters here is what they accept and what changes downstream. The surface language at this commit is small: a module (module Name where plus a block of definitions), function definitions (f x y = expr), data declarations (data T = C1 t1 t2 | C2), and expressions built from variable/constructor names, integer literals, application, \x. expr lambdas, case expr of with constructor-pattern arms, and parenthesized subexpressions β€” one representative production (a9178695:Parse.hs, lines 147–150):

pDef :: P Def
pDef =
      Data <$> (pKeyword "data" *> pLHS pUIdent <* pSymbol "=") <*> esepBy1 ((,) <$> pUIdent <*> many pAType) (pSymbol "|")
  <|> Fcn <$> (pLHS pLIdent <* pSymbol "=") <*> pExpr

Exp.hs is, almost line for line, Lam.hs's own Exp type and compile/abstract/improve/reduce pipeline (aux/bracket-abstraction.md covers the original) copied into its own module β€” Lam.hs keeps its copy unmodified and stays the engine behind make test; Exp.hs becomes the engine behind the new parser-driven path. The two diverge from this commit on. Desugar.hs bridges Parse.hs's Expr/Def/Module AST to Exp.hs's Exp: a plain case-to-application rewrite for expressions (a9178695:Desugar.hs, lines 25–31, quoted whole)β€”

dsExpr :: Expr -> Exp
dsExpr (EVar i) = Var i
dsExpr (EApp f a) = App (dsExpr f) (dsExpr a)
dsExpr (ELam x e) = Lam x (dsExpr e)
dsExpr (EInt i) = Int i
dsExpr (ECase e as) = apps (dsExpr e) (map dsArm as)
  where dsArm ((_, xs), r) = lams xs $ dsExpr r

β€” and, more interestingly, a data-declaration desugaring that turns each constructor into a plain function taking its own fields followed by one continuation argument per constructor and picking its own (a9178695:Desugar.hs, lines 16–22): dsDef (Data _ cs) = zipWith dsConstr [0..] cs builds, for constructor i out of n, a function of shape \x1 x2 ... $f0 $f1 ... $f(n-1) -> $fi x1 x2 ... β€” a Scott encoding, case analysis compiled straight to application with no separate pattern-match machinery in the runtime at all. main changes from printing one hardcoded term to this (a9178695:Main.hs, quoted whole):

import System.Environment(getArgs)
import Parse
import Desugar
import Exp

main :: IO ()
main = do
  [fn] <- getArgs
  mdl <- parseDie pTop fn <$> readFile fn
  let ds = desugar mdl
      ds' = [ (i, improve $ compile e) | (i, e) <- ds ]
  mapM_ (\ (i, e) -> putStrLn $  i ++ " = " ++ toString e) ds
  mapM_ (\ (i, e) -> putStrLn $  i ++ " = " ++ toString e) ds'

One command-line argument, a file name, no more; the program is read (readFile fn), parsed (parseDie pTop fn), desugared, and each top-level definition is compiled and improved independently β€” this version doesn't yet try to link the definitions into one runnable program; it just prints, per name, the naive and the optimized combinator form. There's no writeFile yet, and no notion of which definition is the entry point. That gap is exactly what 17587b2d addresses five commits later.

648aeef0 ("Add another node representation.", 13 minutes after a9178695) and 71729105 ("Add pair and cons.", 19 seconds after that) follow the same afternoon; both are described below with the commits they belong with thematically.

648aeef0: a third node layout for eval.c

Chapter 01's eval.c ran one node representation, NODE_NAIVE β€” a C struct with real pointers, selected by #define NODE_NAIVE at the top of the file β€” but the file already carried a second, uncompiled-into option in the same #if/#elif chain, NODE_SPLIT (ccb31388:eval.c, lines 5–8: the same comment block quoted below, minus its NODE_INDEX line, which doesn't exist yet). This commit adds a third live option, NODE_INDEX, and flips the active #define to it (648aeef0:eval.c, lines 5–10):

/* Node representation:
 * NODE_NAIVE   all fields in a struct, regular pointers
 * NODE_INDEX   use 32 bit indices instead of pointers
 * NODE_SPLIT   split flags, funs, and args
 */
#define NODE_INDEX

NODE_INDEX replaces the two 8-byte pointers inside each cell (FUN/ ARG) with 32-bit array indices into one cells array (648aeef0:eval.c, lines 51–67, the union body elided β€” same shape as NODE_NAIVE's):

#elif defined(NODE_INDEX)
/* Naive node representation with minimal unions */
typedef u_int32_t NODEPTR;
typedef struct node {
  enum node_mark mark;
  enum node_tag tag;
  union {
    ...
  } u;
} node;
#define NIL (-1)
#define HEAPREF(i) (i)
#define MARK(p) cells[p].mark

This is a real, measured effect, not a hypothetical one: chapter 01's ccb31388 build reports node size=24, heap size=100000 for NODE_NAIVE; building this chapter's b56ba28b (which keeps NODE_INDEX active) and running the exact same nfib 35 program through the unmodified Lam.hs pipeline reports node size=16 β€” a third smaller, with the reduction count and GC count both unchanged (1045124618 reductions, 1944 GCs, identical in both β€” see "Try it" below for the transcript). Three representation choices now live side by side in the same #if chain, NODE_NAIVE and NODE_SPLIT both still compiling, just not selected β€” representation experiments happen as edits to one #define, on the mainline, not as a branch that gets merged later.

9cf1e937 + b4e44ded: "New optimiser." (2023-05-30, 12:46–12:47)

The next session, the day after, starts with the same commit message twice, forty seconds apart. Read literally, the first one doesn't build: 9cf1e937's entire diff is one line added to Exp.hs, toStringP (Lbl i e) = ":" ++ show i ++ " " ++ toStringP e β€” a pretty-printer case for a constructor, Lbl, that this commit's Exp type doesn't declare yet (Lbl isn't added to data Exp until b4e44ded). Building Exp.hs at exactly 9cf1e937 and nowhere else in the range confirms it:

Exp.hs:129:37: error: [GHC-58481] parse error on input β€˜=’
    |
129 | redOne (App4 (Comb "O") x1 x2 f1 f2 = Just $ App2 f2 x1 x2

β€” not even the error you'd expect from the missing Lbl constructor; a plain unbalanced-parenthesis typo, inherited unfixed from 71729105 ("Add pair and cons.", the previous afternoon), is enough on its own to stop the file from compiling at all. b4e44ded, the second "New optimiser." commit, both fixes that syntax error and does the real rewrite: the old two-pass compile (naive bracket abstraction) then improve = improveT . improveC (a separate cleanup pass) is replaced by one pass whose abstraction step calls smart constructors β€” cS/cC/cB/cS'/cC' β€” that apply the same rewrite rules inline, while building the term, instead of afterward (b4e44ded:Exp.hs, lines 73–100):

abstract :: Ident -> Exp -> Exp
abstract x e@(Var y) | x == y = CI
abstract x (App f a) = cS (abstract x f) (abstract x a)
abstract x (Lam y e) = abstract x $ abstract y e
abstract x e = cK e

cK :: Exp -> Exp
--cK CI = CT
cK e  = App CK e

cS :: Exp -> Exp -> Exp
cS (App CK e1)     (App CK e2) = cK (App e1 e2)    -- S (K e1) (K e2) = K (e1 e2)
cS (App CK e1)     CI          = e1                -- S (K e1) I      = e1
cS (App CK e1)     e2          = cB e1 e2          -- S (K e1) e2     = B e1 e2
cS e1              (App CK e2) = cC e1 e2          -- S e1     (K e2) = C e1 e2
cS (App2 CB e1 e2) e3          = cS' e1 e2 e3      -- S (B e1 e2) e3  = S' e1 e2 e3
cS e1 e2                       = App2 CS e1 e2

cC :: Exp -> Exp -> Exp
cC (App2 CB e1 e2) e3          = cC' e1 e2 e3      -- C (B e1 e2) e3  = C' e1 e2 e3
--cC (Prim op)       e2 | Just op' <- lookup op flipOps = App (Prim op') e2 -- C op e = flip-op e
cC (Var op)        e2 | Just op' <- lookup op flipOps = App (Var op') e2 -- C op e = flip-op e
cC e1              e2          = App2 CC e1 e2

cB :: Exp -> Exp -> Exp
cB CC          (App CC CI) = CP -- Pair
cB (App CB CK) CP          = CO -- Cons
cB e1          e2          = App2 CB e1 e2

The old improveC/improve block is left in the file, commented out ({- ... -}) rather than deleted, in the same "keep exploratory code, just stop running it" habit chapter 01 noted at commit 1. Machine-verified, not just read: with compileOpt = improveT . compile, compiling \x. \y. \f. f x y (the pairing function) at a9178695 needs the separate improve pass to collapse to the P combinator β€”

compile epair            = S (K C) (S (S (K C) (K I)) I)
improve (compile epair)  = P

β€” while at b4e44ded, compile alone already produces P, no second pass required:

compile epair    = P
compileOpt epair = P

Same input, same final answer, one fewer traversal to get there β€” the "New optimiser." title describes the intent; reading the diff is the only way to see that it means fusing the cleanup pass into abstraction, not adding new rules (the rule set β€” Pair/Cons included β€” is close to a straight port of what improveC already had; cB's Pair/Cons cases are literally the two rules 71729105 had commented out the day before, now live again).

17587b2d: "Hack around a problem." (2023-05-30, 12:47:15, 13 seconds later)

a9178695's main compiled each top-level definition independently and printed each one on its own β€” it never combined them into a single runnable term, and had no idea which one was the entry point. That's the problem. The fix (17587b2d:Main.hs, quoted whole):

import Data.Maybe
import System.Environment(getArgs)
import Parse
import Desugar
import Exp

primOps :: [(Ident, Exp)]
primOps =
  [ ("error", Prim "?")
  , ("+",     Prim "+")
  , ("-",     Prim "-")
  , ("-'",    Prim "-'")
  , ("*",     Prim "*")
  , ("quot",  Prim "/")
  , ("rem",   Prim "%")
  , ("<",     Prim "<")
  , ("<=",    Prim "<=")
  , (">",     Prim ">")
  , (">=",    Prim ">=")
  , ("==",    Prim "==")
  , ("/=",    Prim "!=")
  ]

main :: IO ()
main = do
  [fn] <- getArgs
  mdl <- parseDie pTop fn <$> readFile fn
  let ds = desugar mdl
      ds' = [ (n, compileOpt e) | (n, e) <- ds ]
      defs :: [(Ident, Exp)]
      defs = [ (n, ref i) | ((n, _), i) <- zip ds' [0..] ] ++ primOps
      find n = fromMaybe (error $ "undefined: " ++ show n) $ lookup n defs
      emain = find "main"
      res = foldr def emain (zip ds' [0..])
      def :: ((Ident, Exp), Int) -> Exp -> Exp
      def ((n, e), i) r = App2 CT (Lbl i (subst e)) r
      subst (Var n) = find n
      subst (App f a) = App (subst f) (subst a)
      subst e = e
      ref i = Var $ "_" ++ show i
--  mapM_ (\ (i, e) -> putStrLn $  i ++ " = " ++ toString e) ds
  mapM_ (\ (i, e) -> putStrLn $  i ++ " = " ++ toString e) ds'
  putStrLn $ toStringP res
  writeFile "out.comb" $ toStringP res

Every top-level definition n gets an integer index i (source-file order, zip ds' [0..]); every free variable reference to a top-level name inside any other definition's body is textually replaced (subst) by Var "_i" β€” a reference into b0c33a8f's label format, not a real bound variable. The whole program becomes nested T (Lbl i body_i) rest applications (T x y = y β€” T here is used purely to sequence a definition's text into the output; its value is discarded, its label is what matters), ending in a reference to whichever index main got. There is no dependency analysis, no forward-declaration handling beyond what the label format's own placeholder-before-parsing trick (b0c33a8f) buys for free, and an unresolved name doesn't fail to parse β€” it fails at find, with error $ "undefined: " ++ show n, a Haskell exception, not a diagnostic from the parser. It is exactly what the commit message says it is: a hack, applied to the actual problem (turning N independent compiled definitions into one closed, runnable graph) rather than a general solution to it (linking, dependency ordering, a real module system) β€” and it's honestly labeled as such in the commit log, not dressed up as a design decision. "Try it" exercise 1 below runs into this hack's exact limits.

4030f721 and b56ba28b: parsing and combinators keep growing

4030f721 ("Parse more") adds one new expression form β€” a parenthesized operator, (+), usable anywhere a normal identifier is β€” in twelve added lines, all ordinary combinator code built from pieces aux/parser-combinators.md section 3 already covers; that doc walks the diff in full (section 5). b56ba28b ("More combinators.") is the eval.c side of b4e44ded's revived Pair/Cons rules: the P/O combinators get a tag in enum node_tag, a global combP/combO node, parser cases ('P'/'O'), a printer case, and runtime reduction rules (b56ba28b:eval.c, lines 599–620, quoted whole):

    case P:                     /* P x y f = f x y */
      CHECK(3);
      GCCHECK(1);
      x = ARG(TOP(1));
      y = ARG(TOP(2));
      f = ARG(TOP(3));
      POP(3);
      n = TOP(0);
      FUN(n) = new_ap(f, x);
      ARG(n) = y;
      GOTO ap;
    case O:                     /* P x y g f = f x y */
      CHECK(4);
      GCCHECK(1);
      x = ARG(TOP(1));
      y = ARG(TOP(2));
      f = ARG(TOP(4));
      POP(4);
      n = TOP(0);
      FUN(n) = new_ap(f, x);
      ARG(n) = y;
      GOTO ap;

P is pair's own combinator, three arguments in, applies the third to the first two; O is cons, four arguments, applies the fourth to the first two (the third β€” a "nil" continuation β€” is skipped over, which is what cons-vs-nil case analysis needs). Even the comment above case O: still says P x y g f = f x y β€” copy-pasted from the case above it and never updated to say O. This commit closes out the range: after b56ba28b, the language a source file can express has grown from nothing to modules, functions, data declarations, case expressions, and integers, and the runtime understands two new combinators it didn't a day earlier.

Why it matters

Four days after a compiler that could only run one hand-built term, a9178695 gives it a real front door: source text in, by way of an actual parser, not Haskell values assembled by hand in main. That's the headline. But the more durable habit visible across these eleven commits is what happens after the headline commit: the node representation changes (648aeef0), the optimizer gets rewritten from a two-pass design to a one-pass one and breaks compiling on the first attempt (9cf1e937/b4e44ded, forty seconds apart), and the missing "combine N definitions into one program" piece gets bolted on as an admitted hack (17587b2d) β€” five commits, 9cf1e937 through b56ba28b, span a total of 98 seconds of commit timestamps (12:46:22 to 12:48:00), which is not how long any of that code took to write; it's how long it took to commit work that had already been done. Rewriting two-day-old code β€” the optimizer, twice β€” costs nothing here, because nothing outside this project depends on it yet. That's a luxury a toy has and a shipped compiler doesn't, and this range uses it freely.

Notebook margin

  • Commit messages state intent, not mechanism. "Make a real compiler." doesn't say it adds a parser-combinator library, a desugarer, and a second copy of the bracket-abstraction pipeline in six new files; "New optimiser." (twice, 40 seconds apart) doesn't say the first of the two doesn't compile, or that the real change is fusing a cleanup pass into abstraction rather than adding new rules.
  • Representation experiments happen as commits on the mainline, not branches: 648aeef0 adds NODE_INDEX as a third option in eval.c's existing #if/#elif chain and flips one #define to activate it; NODE_NAIVE (chapter 01's representation) stays in the file, compiling, just not selected.
  • A hack, applied and honestly labeled: 17587b2d is the commit message before you even open the diff. What it hacks around β€” turning independently-compiled top-level definitions into one runnable, entry-point-aware graph β€” is a real problem this range never gives a general solution to.

Try it

Anchor: b56ba28b. Convention (see 00-overview.md):

git worktree add /tmp/mhs-b56ba28b b56ba28b
cd /tmp/mhs-b56ba28b
nix develop $MICROHS -c make test

This still runs the old Lam.hs/nfib.comb pipeline β€” the Makefile's test target was never updated in this range to build the new Main.hs/Parse.hs path at all (git show b56ba28b:Makefile is byte-identical to 6485a341's). Expect the same nfib 35 computation as chapter 01, on the new NODE_INDEX representation:

./eval nfib.comb
((Y (((C' C) ((B (S (> 2))) (((C' (C' +)) (((S' (S' +)) ((C B) (-' 1))) ((C B) (-' 2)))) 1))) 1)) 35)
29860703
node size=16, heap size=100000
1045124618 reductions, 1944 GCs

Same answer, same reduction count and GC count as ccb31388's node size=24 build in chapter 01 β€” the representation change is invisible to the computation, only to memory per node.

Build note: the new parser-driven path (Main.hs) has no Makefile target at this commit and needs one line added to build at all with this project's flake toolchain (GHC 9.8.4, mtl 2.3.1). Building it as written fails:

nix develop $MICROHS -c ghc Main.hs -o uhs
ParserComb.hs:52:11: error: [GHC-88464]
    Variable not in scope:
      ap :: Prsr s (a -> b) -> Prsr s a -> Prsr s b
    Suggested fix: Perhaps use β€˜map’ (imported from Data.List)
   |
52 |   (<*>) = ap
   |           ^^

ap (used in ParserComb.hs's Applicative instance, aux/parser-combinators.md section 2) isn't brought into scope by any of ParserComb.hs's own imports on this GHC/ mtl combination β€” it needs Control.Monad explicitly. Add one line after the existing import Control.Monad.State.Strict in ParserComb.hs:

import Control.Monad(ap)

Then the build succeeds:

nix develop $MICROHS -c ghc Main.hs -o uhs
[1 of 6] Compiling ParserComb       ( ParserComb.hs, ParserComb.o )
[2 of 6] Compiling Parse            ( Parse.hs, Parse.o )
[3 of 6] Compiling Exp              ( Exp.hs, Exp.o )
[4 of 6] Compiling Desugar          ( Desugar.hs, Desugar.o )
[5 of 6] Compiling Main             ( Main.hs, Main.o )
[6 of 6] Linking uhs

(A GHC warning about partial tail at Parse.hs:203 β€” the same kind of partiality chapter 01 noted in Lam.hs β€” appears too; harmless here, omitted above.) Apply this one-line patch before the exercises below.

Exercise 1: a tiny program in the accepted syntax

cat > pair.hs <<'EOF'
module Pair where
f x = x
main = f 3
EOF
./uhs pair.hs
f = I
main = f 3
((T :0 I) ((T :1 (_0 3)) _1))

The first two lines are ds' (each definition's compiled, optimized form β€” f x = x collapses to the identity combinator I, exactly the (\x. x) = I case aux/bracket-abstraction.md section 1 covers). The third line is 17587b2d's linked program, written to out.comb as well as stdout. Run it:

nix develop $MICROHS -c ./eval out.comb
3
node size=16, heap size=100000
14 reductions, 0 GCs

3, as expected. Worth trying, and worth knowing about 17587b2d's hack before you rely on it: swap the declaration order in pair.hs β€” main = f 3 before f x = x β€” and rebuild:

cat > fwd.hs <<'EOF'
module Fwd where
main = f 3
f x = x
EOF
./uhs fwd.hs
nix develop $MICROHS -c ./eval out.comb
ERR: shared == NIL

Labels are assigned in source order (zip ds' [0..]), so main β€” first in this file β€” is now label 0, referencing f β€” label 1 β€” by _1, before :1 has appeared in the printed text at all. b0c33a8f's parser is a single left-to-right pass with no forward-declaration support; a label has to be defined before it's referenced in the text, not just before it's needed. 17587b2d's "hack" inherits this constraint silently β€” nothing catches or reports it above the C-level ERR. (Put f back before main before continuing, or just use pair.hs again.)

Exercise 2: trigger a parse error

cat > bad.hs <<'EOF'
module Bad where
main = )
EOF
./uhs bad.hs

Actual behavior: an uncaught Haskell exception, all to stderr, exit code 1 (parseDie, a9178695:Parse.hs line 85: Left err -> error err):

uhs: line 2, col 7:
   found: ")"
   expeced: = white-space
   expeced: lower case
   expeced: upper case
   expeced: int digit
   expeced: '('
   expeced: \ symbol
   expeced: case letter, digit
   expeced: case <empty>

CallStack (from HasCallStack):
  error, called at ./Parse.hs:85:17 in main:Parse

Read literally: expeced is misspelled in the actual source (formatFailed, a9178695:ParserComb.hs line 109: pr e = " expeced: " ++ e) β€” not a transcription error here, the running binary really prints it that way. The list is every alternative tried and failed at the point of furthest progress (column 7, right after main = ), merged via <?>'s labeling and LastFail's longest. "case <empty>" traces to a specific, verifiable spot: pExpr's third alternative, pCase, starts with pKeyword "case" (a9178695:Parse.hs, lines 98–102, quoted whole):

pKeyword kw = (skipWhite $ do
  s <- pIdentRest
  guard (kw == s)
  pure ()
  ) <?> kw

On ), pIdentRest reads zero characters () isn't a letter or digit), so s = "", guard ("case" == "") is guard False, which is Alternative's empty (guard is Control.Monad's, not ParserComb.hs's own), and <?> "case" prepends "case" to empty's own "<empty>" message β€” unwords ["case", "<empty>"]. An honest byproduct of a two-day-old error-reporting pass (empty's literal placeholder text leaking into a real error message) rather than a deliberately designed one.

Exercise 3: the optimizer, before and after

9cf1e937~1..9cf1e937 (the literal endpoints of the first "New optimiser." commit) is not buildable β€” confirmed above in "What happened": 9cf1e937 alone fails with a parse error inherited from 71729105's typo, before the Lbl-not-in-scope problem its own diff would otherwise cause even gets a chance to surface. Neither this pair nor 71729105/9cf1e937/b4e44ded in any combination gives two buildable Main.hs binaries either side of the rewrite: b4e44ded's own Exp.hs builds, but the Main.hs sitting alongside it at that commit still calls the old, now-removed improve function and fails with Variable not in scope: improve.

Build note: substituting the nearest pair of commits where the relevant code (Exp.hs's compile/improve/compileOpt) both parses and typechecks on its own β€” a9178695 (before the rewrite; the original two-pass improveC/improveT) and b4e44ded (after; the fused smart-constructor version) β€” and comparing via ghci/ghc -e directly on Exp.hs, the same way aux/bracket-abstraction.md section 2 does, rather than through the full Main.hs pipeline (which needs the ap patch above at every commit in the range and isn't the part that actually changed).

git worktree add /tmp/mhs-a9178695 a9178695
cd /tmp/mhs-a9178695

Apply the same one-line ParserComb.hs fix as above (import Control.Monad(ap), needed here too since Exp.hs imports Parse, which imports ParserComb), then:

nix develop $MICROHS -c ghc -e 'let epair = Lam "x" (Lam "y" (Lam "f" (App2 (Var "f") (Var "x") (Var "y")))) in putStrLn (toString (compile epair)) >> putStrLn (toString (improve (compile epair)))' Exp.hs
S (K C) (S (S (K C) (K I)) I)
P

epair is \x. \y. \f. f x y β€” the pairing function, the exact form the Pair rule matches. Naive compile alone doesn't collapse it; a separate improve call is required to reach P. Second worktree:

git worktree add /tmp/mhs-b4e44ded b4e44ded
cd /tmp/mhs-b4e44ded

Same one-line fix, then:

nix develop $MICROHS -c ghc -e 'let epair = Lam "x" (Lam "y" (Lam "f" (App2 (Var "f") (Var "x") (Var "y")))) in putStrLn (toString (compile epair)) >> putStrLn (toString (compileOpt epair))' Exp.hs
P
P

compile alone already produces P β€” the abstraction step and the optimization are now one pass, not two, exactly as "What happened" describes from reading the diff; running it on the same input both sides of the rewrite confirms the diff's meaning rather than just its shape.

Clean up all three worktrees when done:

git worktree remove --force /tmp/mhs-b56ba28b
git worktree remove --force /tmp/mhs-a9178695
git worktree remove --force /tmp/mhs-b4e44ded

🌐 ν•œκ΅­μ–΄λ‘œ 읽기

03 β€” Growing the language

Where we are

chapter 02 leaves a compiler that reads a small lambda language from files: modules, function definitions, data declarations, case expressions, integer literals, application, and \x. expr lambdas, all parsed by the ParserComb.hs/Parse.hs combinator library (aux/parser-combinators.md), desugared by Desugar.hs to Exp.hs's tiny combinator core, and run by eval.c. Every top-level definition is linked into one program by 17587b2d's admitted hack: source order becomes a label number, and a name used before its own label has been printed crashes the C runtime with ERR: shared == NIL. This chapter's range, b56ba28b..95ff6a34, is twelve commits β€” nine landing in one sitting on 2023-05-30, the other three the next working session, 2023-06-01 β€” that turn that minimal language into something with let, infix operators, tuples, lists, characters, strings, module imports, and do-notation.

What happened

5ccb1b2a: comments, let, and error

Three unrelated features in one commit, each small enough that "unrelated things land together when they're this cheap" is itself worth noticing. Comments are handled before the parser ever sees the text, not inside it: parseDie now runs removeComments file on the whole source string first (5ccb1b2a:Parse.hs line 84), and removeComments is a line-oriented pass that blanks anything from -- to end of line while leaving the inside of "..." string literals alone (5ccb1b2a:Parse.hs, lines 90–98, quoted whole):

removeComments :: String -> String
removeComments = unlines . map remCom . lines
  where remCom ('-':'-':_) = ""
        remCom ('"':cs) = str cs
        remCom (c:cs) = c : remCom cs
        remCom "" = ""
        str ('"':cs) = remCom cs
        str ('\\':_:cs) = str cs
        str (_:cs) = str cs
        str "" = ""

(String literals aren't parseable syntax yet β€” that's ddb1bb29, seven commits later β€” but comment-stripping already has to route around a quoted -- so it doesn't eat the rest of the line.) let is a new expression form, ELet [Def] Expr, and it desugars to nested, non-recursive application of lambdas β€” "just sequential bindings", the comment above the case says (5ccb1b2a:Desugar.hs line 33, quoted with its comment):

-- For now, just sequential bindings.
dsExpr (ELet ds e) =
  let ds' = concatMap dsDef ds
      e' = dsExpr e
      def (i, d) e = App (Lam i e) d
  in  foldr def e' ds'

let x = a; y = b in e becomes (\x -> (\y -> e) b) a β€” each binding compiles to one more App (Lam ...) wrapped around what's inside it, in source order, with no letrec-style self-reference or mutual recursion support at all (a later chapter's problem). error is not a new syntax form β€” it's a new eval.c primitive, reached the same way + or < is, except it forces its argument, prints it to stderr, and exits (5ccb1b2a:eval.c, lines 661–667, quoted whole):

    case ERROR:
      x = ARG(TOP(1));
      x = evali(x);
      fprintf(stderr, "error: ");
      print(stderr, x);
      fprintf(stderr, "\n");
      exit(1);

3aa12deb: infix operators, and how fixity actually works here

Before this commit, pExpr only knew pExprApp (plain application), pLam, pCase, and pLet β€” no expression could contain a binary operator like + or == except by writing it as a parenthesized prefix identifier, (+) x y. 3aa12deb adds a new alternative, pExprOp, and this is the whole fixity mechanism at this commit, quoted whole (3aa12deb:Parse.hs, lines 203–212):

pExprOp :: P Expr
pExprOp = pA
  where
    pA = pRightAssoc (pOpers ["$"]) pB
    pB = pRightAssoc (pOpers ["||"]) pC
    pC = pRightAssoc (pOpers ["&&"]) pD
    pD = pNonAssoc (pOpers ["==", "/=", "<", "<=", ">", ">="]) pE
    pE = pLeftAssoc (pOpers ["+", "-"]) pF
    pF = pLeftAssoc (pOpers ["*", "quot", "rem"]) pG
    pG = pExprApp

There is no fixity declaration anywhere in the language, no table read from source, nothing a user program can influence. Precedence is literally the position of a parser in this fixed chain β€” pA is looser-binding than pB, which is looser than pC, and so on down to pG, which is plain function application, tightest of all. Associativity is which of three combinator functions wraps a given level: pRightAssoc, pNonAssoc, or pLeftAssoc, each parsing one occurrence of its own operand parser, then either recursing on itself (right-associative), taking exactly one more operand with no further chaining (non-associative β€” two == in a row is a parse error at this level), or folding left over a flat list of (op, operand) pairs (left-associative) β€” pLeftAssoc, quoted whole (3aa12deb:Parse.hs, lines 235–239):

pLeftAssoc :: P String -> P Expr -> P Expr
pLeftAssoc pOp p = do
  e1 <- p
  es <- emany ((,) <$> pOp <*> p)
  pure $ foldl (\ x (op, y) -> appOp op x y) e1 es

Fourteen operator tokens total get a home across these six levels ($, ||, &&, the six comparisons, +/-, */quot/rem), and every operator not on one of these six lists simply isn't a valid infix operator yet β€” writing a ++ b at this commit is a parse error, ++ doesn't appear until e96d04be inserts a new level for it (and :) between the comparison level and +/-. It is crude β€” precedence is baked into the parser's own call graph, adding an operator at a new precedence means inserting a new mutually-recursive pN function into the chain and renumbering nothing else, which is exactly what e96d04be does thirteen minutes later (p0 through p10, eleven numbered stages instead of six lettered ones, ->, :, ++, and . added). But it works, and it's the entire mechanism: no operator table, no user-extensible fixity, just a hand-written ladder of parsers.

e96d04be: type signatures, parsed and thrown away

The same commit that widens the operator ladder also adds f :: T as parseable syntax β€” and nothing else. The new Def case, Sign Ident Type (e96d04be:Parse.hs line 19), is built the same way every other alternative in pDef is (e96d04be:Parse.hs line 178):

  <|> Sign <$> (pLIdent <* pSymbol "::") <*> pType

pType, notably, isn't a separate grammar at all β€” it's pExpr under a different name (e96d04be:Parse.hs, lines 183–184, quoted whole):

pType :: P Type
pType = pExpr

A type signature is parsed as if it were an ordinary expression β€” Type is a type synonym for Expr β€” because there's no distinct type-syntax grammar to reuse yet, and the infix chain e96d04be just widened happens to already include -> at its loosest level (p0), which is what a T -> T signature needs to parse at all. And then the desugarer drops it on the floor, one line, the entire handling (e96d04be:Desugar.hs line 24):

dsDef (Sign _ _) = []

dsDef returns a list of compiled definitions for each Def; a Sign contributes zero of them. This is "parse and ignore": accept the syntax now, so source files that write type signatures don't fail to parse, without committing to any meaning for it yet. The meaning arrives months later β€” chapter 09 covers the type checker that finally reads what Sign is carrying instead of discarding it β€” but from e96d04be onward, every commit in between is free to write and ship code with type signatures in it that do nothing at all.

The shape of a feature commit: tuples, lists, strings

Three commits in a row show the same recipe at work β€” a new Expr constructor, a pAExpr alternative that builds it, and a dsExpr case that rewrites it to something the existing combinator core already understands.

Tuples (8939d55b) parse as a comma-separated list inside parentheses and desugar to a Scott-encoded continuation-passing function β€” the same trick chapter 02's a9178695:Desugar.hs used for data constructors, this time built by hand for the built-in tuple form (8939d55b:Parse.hs, lines 197–200, quoted whole):

eTuple :: [Expr] -> Expr
eTuple [] = ELam "_x" (EVar "_x")    -- encoding of ()
eTuple [e] = e
eTuple es = ELam "_f" $ foldl EApp (EVar "_f") es

A pair (a, b) becomes \f -> f a b β€” a value that is its own eliminator, waiting for a continuation. This shape is common enough that the same commit teaches the bracket-abstraction smart constructors to recognize it and collapse it straight to the P (pair) combinator chapter 02's b56ba28b added to eval.c, rather than build it out of S/K/C at the call site (8939d55b:Exp.hs line 95, one new line in cC):

cC (App2 CC CI e1) e2          = App2 CP e1 e2

List syntax (d91b9812) is even smaller β€” one parser alternative for [e1, e2, ...], and a two-line desugaring straight to nested cons/nil using b56ba28b's O combinator and the existing K (d91b9812:Desugar.hs lines 39–40, quoted whole):

dsExpr (EList es) =
  foldr (App2 CO) CK $ map dsExpr es

[1,2,3] becomes O 1 (O 2 (O 3 K)). No new runtime primitive: O's own reduction rule (redOne (App4 (Comb "O") x1 x2 f1 f2) = Just $ App2 f2 x1 x2, chapter 02's b56ba28b:Exp.hs line 184) needs two continuations, a nil-branch and a cons-branch, applied in that order β€” K a b = a is exactly the function that, given those two continuations, always picks the first (the nil-branch), which is why CK alone is a valid empty list: a Scott-encoded [] is a value that ignores its cons-continuation and returns its nil-continuation, and K does precisely that once partially applied to nothing else.

String literals (ddb1bb29) don't even need their own desugaring rule at the list level β€” a string is defined as a list of characters and handed straight to the list-desugaring case that already exists (ddb1bb29:Desugar.hs line 47, quoted whole):

dsExpr (EStr cs) = dsExpr $ EList $ map EChar cs

Two commits earlier, 60cd285c had already done the one piece of real work in this trio: character literals do need a new runtime primitive, because a Char isn't representable as anything already in eval.c β€” a new CHAR node tag joins enum node_tag right after INT (60cd285c:eval.c line 18), the parser gains a case '\'' that reads one raw byte off the stream into a CHAR node (60cd285c:eval.c lines 321–325), and eval's reduction loop treats CHAR the same inert way it already treats INT β€” a case CHAR: arm whose whole body is RET;, the same as INT's arm right above it, no reduction rule, just "this is already a value" (60cd285c:eval.c lines 508–511). Once that groundwork exists, EStr is free: strings are lists of Chrs, and both EList's desugaring and O/K's runtime behavior are already in place to carry them.

4e4c5252 ("Refactor.", between the list-syntax and character-literal commits) is not a feature: it's the moment case-pattern parsing, which had grown ad hoc pLHS pUIdent/tuple/[]/:-infix branches bolted on piece by piece as tuples and lists were added, gets pulled into one Pat type (PConstr Ident [Ident] | PTuple [Ident]) with dsExpr's ECase arm rewritten to match it. Nothing about what parses changes; what changes is that the four ad hoc pattern shapes now share one representation instead of four separately-threaded tuples. This is the first instance of a rhythm the rest of the chapter keeps repeating: feature, feature, refactor β€” clean up the accumulated mess only after enough of it has accumulated to be worth cleaning, not before.

b25d9fc8: importing modules

This is the biggest commit in the range β€” a new file, Compile.hs, 91 lines β€” and what "module" means here is more substantial than "parse and ignore": a module is a file, <Name>.uhs, whose own declared module name must match the file name it was loaded from (b25d9fc8:Compile.hs lines 40–45, quoted whole):

compileModule :: IdentModule -> StateT Cache IO CModule
compileModule nm = do
  let fn = nm ++ ".uhs"
  mdl@(Module nm' defs) <- parseDie pTop fn <$> liftIO (readFile fn)
  when (nm /= nm') $
    error $ "module name does not agree with file name: " ++ show nm

import M (a new Def case, one keyword away from data/import's neighbors in pDef) triggers compileModuleCached on M, which checks a Map of already-compiled modules before doing any work, and tracks a working list of modules currently mid-compile so that importing a module that (transitively) imports itself is a hard error rather than an infinite loop (b25d9fc8:Compile.hs lines 24–38) β€” genuine cycle detection, not a crude stopgap. Every top-level name a module defines ends up visible under two spellings in that module's own symbol table: unqualified (i) and dotted (mn ++ "." ++ i) β€” both point at the same qualified Var (b25d9fc8:Compile.hs line 53, quoted whole):

    syms (mn, ds) = [ (s, [Var qi]) | (i, _) <- ds, let qi = mn ++ "." ++ i, s <- [i, qi] ]

which is why two imported modules defining the same unqualified name is an ambiguity error at reference time (checkExpr's Just qis -> error $ "ambiguous: " ++ ..., unchanged from 17587b2d) rather than something caught at import time. There's a smaller fix bundled into the same commit that quietly closes a gap chapter 02's "Try it" ran straight into: 17587b2d's label format required a definition's :N to appear in the printed text before any _N reference to it, so a main that textually preceded the function it called crashed the C runtime with ERR: shared == NIL. b25d9fc8:eval.c makes both the _ and : cases allocate the indirection placeholder unconditionally, whichever one is seen first, instead of the _ case demanding the placeholder already exist (b25d9fc8:eval.c, lines 336–343, quoted whole):

  case '_' :
    l = (int)parse_int(f);  /* The label */
    if (shared[l] == NIL) {
      /* Not yet defined, so make it an indirection */
      shared[l] = alloc_node(IND);
      INDIR(shared[l]) = NIL;
    }
    return shared[l];

Forward references within one linked program now just work β€” not because the linking hack from 17587b2d got redesigned, but because the label format underneath it grew the one piece of order-independence it was missing.

ff4622e8 and 693dc6ce: another refactor, then do-notation

ff4622e8 ("Refactor ident parsing.") is the second instance of the feature-feature-refactor rhythm: identifiers, which had been three separately-defined parsers (lowercase, uppercase, and an ad hoc character set for "rest of identifier"), get unified around one pWord/pIdent pair and a new pQIdent that threads .-separated segments together β€” M.name, a qualified reference, is now representable at all, which 693dc6ce, twenty-two seconds later, immediately needs. do-notation doesn't dispatch on a Monad type class β€” there isn't one yet β€” it dispatches on an explicit qualifier written at the call site: pDo parses <Uident>.do { ... }, and that identifier is threaded through every desugared bind as the prefix of the operator name it calls (ff4622e8:Parse.hs, lines 359–372, quoted whole):

pDo :: P Expr
pDo = EDo <$> pQualDo <*> pBlock pStmt

pStmt :: P Stmt
pStmt =
      (Bind <$> (pLIdent <* pSymbol "<-") <*> pExpr )
  <|> (Then <$> pExpr)

pQualDo :: P String
pQualDo = do
  s <- pUIdent
  _ <- char '.'
  pKeyword' "do"
  pure s

The desugaring, all of 693dc6ce:Desugar.hs's new lines (49–53, quoted whole):

dsExpr (EDo _ []) = error "empty do"
dsExpr (EDo _ [Bind _ _]) = error "do without final expression"
dsExpr (EDo _ [Then e]) = dsExpr e
dsExpr (EDo n (Bind i e : ss)) = App2 (Var (n ++ ".>>=")) (dsExpr e) (Lam i $ dsExpr (EDo n ss))
dsExpr (EDo n (Then   e : ss)) = App2 (Var (n ++ ".>>"))  (dsExpr e)         (dsExpr (EDo n ss))

M.do { x <- e; rest } becomes (M.>>=) e (\x -> rest'), where rest' is rest recursively desugared the same way (still as an M.do block if more than one statement remains); M.do { e; rest } becomes (M.>>) e rest' the same way β€” textbook do desugaring, except that M.>>= and M.>> are not resolved through any type-class machinery at all, just looked up in the symbol table like any other qualified name. Whatever module defines (>>=) and (>>), its own name is the qualifier that unlocks do-notation for it β€” at this commit that's IO, wired in as two more entries in Compile.hs's still-hardcoded primOps list (693dc6ce:Compile.hs lines 78–79: ("IO.>>=", Prim "IO.>>="), ("IO.>>", Prim "IO.>>")), but nothing in pQualDo or dsExpr's EDo cases is specific to IO β€” any module that defines its own (>>=)/(>>) gets its own usable do block for free, IO monad or not ("Try it" exercise 1 uses exactly this to run do-notation without touching IO at all).

95ff6a34: primitive, and decoupling the compiler from the runtime

Every runtime primitive up to this commit is a hardcoded entry in Compile.hs's primOps list β€” one Haskell-source line per primitive, maintained by hand, growing every time a new operator needs a runtime counterpart. 95ff6a34 deletes that list outright and replaces it with a new expression form that lets any module spell out its own primitive bindings: primitive "tag" is new parseable syntax (95ff6a34:Parse.hs line 291, one alternative added to pAExpr):

  <|> (EPrim <$> (pKeyword "primitive" *> pString))

and it desugars, unsurprisingly given how mechanical the mapping is, to exactly the Exp.hs constructor its old table used to build by hand (95ff6a34:Desugar.hs line 54, quoted whole):

dsExpr (EPrim s) = Prim s

Three new source files land in the same commit β€” Prelude.uhs, IO.uhs, and List.uhs (a List data declaration plus map, unrelated to primitive). Prelude.uhs and IO.uhs are, line after line, nothing but top-level bindings whose right-hand side is primitive "..." β€” (+) = primitive "+", error = primitive "?", (>>=) = primitive "IO.>>=", readFile = primitive "IO.readFile", and so on. What used to require editing Compile.hs (a compiler-internal, Haskell-source file) to add a runtime primitive now requires only writing one line in a .uhs module (an ordinary program source file) β€” the string inside primitive "..." is the only thing that has to match what eval.c actually understands, and the compiler itself no longer needs to know the runtime's combinator vocabulary at all. Exp.hs's flipOps table (used by the bracket-abstraction smart constructor cC to recognize "flip this comparison instead of building a C combinator around it") gets its entries requalified to "Prelude.+", "Prelude.<", and so on in the same commit (95ff6a34:Exp.hs lines 115–124) β€” a small, honest side effect of the primitive names now living inside a real module instead of a hardcoded list of bare strings.

Why it matters

Look at what each of these commits actually costs. dsDef (Sign _ _) = [] is one line. dsExpr (EStr cs) = dsExpr $ EList $ map EChar cs is one line and doesn't even introduce a new case in the runtime β€” it just routes through a desugaring that already exists. dsExpr (EList es) = foldr (App2 CO) CK $ map dsExpr es needs no new combinator either, because O and K were already sitting in eval.c from chapter 02. That's the payoff of compiling everything down to a handful of SKI-style combinators before it ever reaches the runtime: most new surface syntax is free at the back end β€” it's just a new way to build a term the combinator core already knows how to run. The exceptions are instructive precisely because they're rare: character literals needed a real new runtime primitive (a CHAR node tag, a parser case, a GC- visible representation in eval.c), and that's the one feature in this chapter that took more than a few lines in two Haskell files. Everything else β€” let, tuples, lists, strings, type signatures, do-notation β€” is parser lines plus desugarer lines, no runtime change at all.

"Parse and ignore" is the same economy applied to time instead of code. e96d04be doesn't need to decide what a type signature means to let programmers write f :: Int -> Int above f's definition β€” accepting the syntax and discarding it costs one line and buys months: every commit between here and chapter 09 is free to use signatures as documentation, or not use them at all, without the type checker existing yet to enforce or even read them. Momentum matters more than completeness at this stage of the project, and both habits β€” desugar to a tiny core, accept syntax before you've decided its semantics β€” are how a language grows fast without every commit becoming a small research project.

Notebook margin

  • Feature per commit, chronologically: let+comments+error, infix operators, type signatures (ignored), tuples, list syntax, character literals, string literals, module imports, do-notation, primitive. Two commits in the range aren't features at all β€” 4e4c5252 and ff4622e8 are refactors, and both land only after enough ad hoc special-casing has piled up to be worth unifying (pattern-parsing after tuples and lists both bolted their own cases on; identifier- parsing right before qualified names become load-bearing for do). Refactors are interleaved with feature work, not batched into their own commit or their own day.
  • The day's count: nine of this chapter's twelve commits β€” 5ccb1b2a through b25d9fc8 β€” land on 2023-05-30, the same day chapter 02 closes with b56ba28b. The remaining three β€” ff4622e8, 693dc6ce, 95ff6a34 β€” land on 2023-06-01, the next working session.
  • A hardcoded fixity ladder is still a fixity mechanism: 3aa12deb's pA/pB/.../pG chain has no operator table and no user-facing fixity declarations, but it correctly parses 1 + 2 * 3 as 1 + (2 * 3) because * sits one level tighter than + in the chain β€” the entire precedence table is which function calls which, nothing more abstract than that.

Try it

Anchor: 693dc6ce ("Add do desugaring."). Convention (see 00-overview.md):

git worktree add /tmp/mhs-693dc6ce 693dc6ce
cd /tmp/mhs-693dc6ce

Like chapter 02's b56ba28b, this commit's Makefile has no target for the parser-driven compiler (Main.hs) β€” only the old Lam.hs/eval.c test target exists. Building Main.hs by hand hits the exact same GHC-9.8/mtl issue chapter 02's Build note covers, plus one more that's new to this chapter's range because it's the first commit in the range where the new file the note is about, Compile.hs, exists at all.

Build note: building as written β€”

nix develop $MICROHS -c ghc Main.hs -o uhs

fails first with chapter 02's ap-not-in-scope error in ParserComb.hs (Variable not in scope: ap :: Prsr s (a -> b) -> Prsr s a -> Prsr s b, ParserComb.hs:52:11) β€” apply the same one-line fix chapter 02 uses, adding it after ParserComb.hs's existing import Control.Monad.State.Strict:

import Control.Monad(ap)

With that fix, the build gets further and hits a second, same-shaped problem in the module b25d9fc8 added, Compile.hs:

Compile.hs:30:7: error: [GHC-88464]
    Variable not in scope: when :: Bool -> a2 -> StateT Cache IO a3
    Suggested fix:
      Perhaps use data constructor β€˜Then’ (imported from Parse)
   |
30 |       when (nm `elem` ws) $
   |       ^^^^

(a second, identically-shaped error follows at Compile.hs:44:3, the function's other when call β€” omitted above.) when (used twice in Compile.hs, both to raise an error on a bad condition) isn't brought into scope by Compile.hs's own import Control.Monad.State.Strict on this GHC/mtl combination either β€” the same root cause as the ap problem, a different symbol, a different file. Add one line after Compile.hs's existing import Control.Monad.State.Strict:

import Control.Monad(when)

Then the build succeeds (harmless -Wx-partial warnings about head/ tail, the same kind chapter 02 noted, omitted below):

nix develop $MICROHS -c ghc Main.hs -o uhs
[1 of 7] Compiling ParserComb       ( ParserComb.hs, ParserComb.o )
[2 of 7] Compiling Parse            ( Parse.hs, Parse.o )
[3 of 7] Compiling Exp              ( Exp.hs, Exp.o )
[4 of 7] Compiling Desugar          ( Desugar.hs, Desugar.o )
[5 of 7] Compiling Compile          ( Compile.hs, Compile.o )
[6 of 7] Compiling Main             ( Main.hs, Main.o )
[7 of 7] Linking uhs

One more piece is needed before the runtime can execute anything the new compiler produces: eval.c itself, which the Makefile's eval target already knows how to build β€”

nix develop $MICROHS -c gcc -Wall -O3 eval.c -o eval

Apply both one-line patches and build eval before the exercises below.

Also note, since Compile.hs landed in b25d9fc8: a source file is loaded by module name, not by file argument β€” Compile.hs's compileModule reads <name>.uhs, not whatever path you pass on the command line, and ./uhs Foo (not ./uhs Foo.uhs) is how the new Main.hs invokes it ([mn] <- getArgs; ds <- compile mn, 693dc6ce:Main.hs).

Exercise 1: let, a list, and do-notation, without touching IO

do-notation at this commit doesn't require the IO module or any built-in monad β€” M.do just needs a module M with its own (>>=) and (>>) in scope, per "What happened" above. Write one small self-contained module that supplies a trivial (>>=)/(>>) pair and then uses them via do:

cat > Demo.uhs <<'EOF'
module Demo where

(>>=) x f = f x
(>>) x y = y

main = Demo.do
  y <- 10
  let
    xs = [1, 2, 3]
   in case xs of
    [] -> 0
    (:) a as -> a + y
EOF
nix develop $MICROHS -c ./uhs Demo
importing "Demo"
importing done "Demo"
Demo.>>= = C I
Demo.>> = T
Demo.main = Demo.>>= 10 (C' (P 0) (B (B K) (C +)) (O 1 (O 2 (O 3 K))))
((T :0 (C I)) ((T :1 T) ((T :2 ((_0 10) (((C' (P 0)) ((B (B K)) (C +))) ((O 1) ((O 2) ((O 3) K)))))) _2)))

Run the linked program:

nix develop $MICROHS -c ./eval out.comb
((T :99996 (C I)) ((T T) ((T :99989 ((_99996 10) (((C' (P 0)) ((B (B K)) (C +))) ((O 1) ((O 2) ((O 3) K)))))) _99989)))
11
node size=16, heap size=100000
56 reductions, 0 GCs

eval.c's main calls pp(stdout, n) twice, once right after parsing and once after reduction (693dc6ce:eval.c lines 704 and 706 β€” unchanged since chapter 02's 54806439) β€” the first line above is the parsed-but-unreduced term, printed through pp's own independent sharing-detection pass (hence label numbers like :99996 that don't match the file's own :0/:1/:2), and the second line is the answer after evali runs: 11. y is bound to 10 by the do-block's Bind, xs is the list [1,2,3], the case on xs matches the (:) a as arm with a = 1, and a + y = 11. Two dead ends worth knowing about on the way here, both genuine limits of this commit's parser, not typos in the example above:

  • let x = 5 in x on one line does not parse:

    cat > T1.uhs <<'EOF'
    module T1 where
    
    main = let x = 5 in x
    EOF
    nix develop $MICROHS -c ./uhs T1
    
    importing "T1"
    uhs: line 3, col 11:
       found: "x"
       expeced: let ' '
       ...
    

    the block let opens (via pBlock's softLC) always takes its indentation from the next physical line, never the rest of the current one, so the bound variables have to start on a new, indented line.

  • in has to be indented to a column strictly between the enclosing do-block's column and the let-block's own column (here: do starts its statements at column 2, let's own block is at column 4, so in sits at column 3). Indent in to column 2 or less and the layout algorithm's dedent-on-whitespace rule (pWhite, Parse.hs) closes the let block correctly but then also reads the same dedent as the start of a new do-statement, injecting a ; in front of in and breaking the parse. A case with only the (:) arm and no [] arm also silently fails to reduce β€” chapter 02's O combinator takes two continuations (nil-branch, then cons-branch) in that order, and a one-arm case under-applies it, leaving the result stuck as a partial application instead of a number (confirmed by running the one-arm version through eval and getting an unreduced term back, not an error).

Exercise 2: read the desugared do-chain out of out.comb

Exercise 1's out.comb is 106 bytes, small enough to read whole:

((T :0 (C I)) ((T :1 T) ((T :2 ((_0 10) (((C' (P 0)) ((B (B K)) (C +))) ((O 1) ((O 2) ((O 3) K)))))) _2)))

:0, :1, :2 are the three top-level definitions in source order β€” Demo.>>=, Demo.>>, Demo.main β€” each compiled to a combinator term and linked by 17587b2d's label scheme (chapter 02). :0's term is C I β€” (>>=) x f = f x compiled by bracket abstraction, since C f x y = f y x, so C I x y = I y x = y x = f x with f and x renamed to match. :2, Demo.main's term, is where the do-desugaring from 693dc6ce:Desugar.hs is directly visible: (_0 10) (...) is _0 (Demo.>>=, i.e. the bind operator) applied to 10 (the desugared Bind "y" (EInt 10)) applied to a lambda-turned-combinator continuation β€” exactly the shape dsExpr (EDo n (Bind i e : ss)) = App2 (Var (n ++ ".>>=")) (dsExpr e) (Lam i $ dsExpr (EDo n ss)) produces, with n = "Demo", i = "y", e = EInt 10, and ss the case-expression that becomes the continuation's body. The continuation itself, (C' (P 0) (B (B K) (C +))) (O 1 (O 2 (O 3 K))), is the compiled case-on-a-list from Exercise 1's dead-end note: O 1 (O 2 (O 3 K)) is [1,2,3] (d91b9812:Desugar.hs's EList desugaring β€” see "What happened"), and (P 0)/(B (B K) (C +)) are the compiled [] -> 0 and (:) a as -> a + y arms in that order, the two continuations O's reduction rule expects.

Exercise 3: extend the parser β€” underscores in integer literals

pInt at this commit accepts one or more digits and nothing else (693dc6ce:Parse.hs line 204, quoted whole):

pInt :: P Integer
pInt = (read <$> (skipWhite $ esome $ satisfy "digit" isDigit)) <?> "int"

Confirm that a Haskell-style digit-group separator doesn't parse today:

cat > Under.uhs <<'EOF'
module Under where

main = 1_000
EOF
nix develop $MICROHS -c ./uhs Under
importing "Under"
uhs: line 3, col 8:
   found: "_000"
   expeced: int digit
   ...

(pInt reads 1 and stops at the first non-digit; pExprApp then looks for another atomic expression to apply 1 to and _000 isn't one β€” it doesn't start with a letter, so it isn't an identifier either.) Edit pInt (Parse.hs, the same one line quoted above) to accept _ as a digit-group separator, requiring a leading digit and filtering underscores out before read:

Before (Parse.hs line 204):

pInt = (read <$> (skipWhite $ esome $ satisfy "digit" isDigit)) <?> "int"

After:

pInt = (read . filter (/= '_') <$> (skipWhite $ (:) <$> satisfy "digit" isDigit <*> emany (satisfy "digit" isDigit <|> char '_'))) <?> "int"

Rebuild and rerun:

nix develop $MICROHS -c ghc Main.hs -o uhs
nix develop $MICROHS -c ./uhs Under
[2 of 7] Compiling Parse            ( Parse.hs, Parse.o ) [Source file changed]
[7 of 7] Linking uhs [Objects changed]
importing "Under"
importing done "Under"
Under.main = 1000
((T :0 1000) _0)
nix develop $MICROHS -c ./eval out.comb
((T 1000) 1000)
1000
node size=16, heap size=100000
6 reductions, 0 GCs

(the first line is pp's pre-reduction echo, same as Exercise 1's transcript above; the second, 1000, is the reduced answer.) 1_000 now parses as the integer 1000 and runs to 1000; a plain main = 42 (no underscore) still compiles and runs to 42 unchanged β€” confirmed by rerunning it through the patched uhs the same way:

cat > Reg.uhs <<'EOF'
module Reg where

main = 42
EOF
nix develop $MICROHS -c ./uhs Reg
nix develop $MICROHS -c ./eval out.comb
((T 42) 42)
42
node size=16, heap size=100000
6 reductions, 0 GCs

Clean up:

git worktree remove --force /tmp/mhs-693dc6ce

🌐 ν•œκ΅­μ–΄λ‘œ 읽기

04 β€” Talking to the world

Where we are

Chapter 03 leaves a language with let, infix operators, tuples, lists, strings, modules, and do-notation β€” including an IO module, IO.uhs, created by 95ff6a34 alongside Prelude.uhs and List.uhs. IO.uhs at that commit is nothing but a list of primitive declarations (95ff6a34:IO.uhs, quoted whole):

module IO where

(>>=)     = primitive "IO.>>="
(>>)      = primitive "IO.>>"
return    = primitive "IO.return"
readFile  = primitive "IO.readFile"
writeFile = primitive "IO.writeFile"
putChar   = primitive "IO.putChar"
getChar   = primitive "IO.getChar"

and because chapter 03's 693dc6ce made do-notation dispatch on whatever module supplies (>>=)/(>>) rather than on any built-in notion of "the IO monad", IO.do { putChar 'h'; putChar 'i' } parses, desugars, and compiles cleanly through every stage that exists so far β€” there is no type checker yet (chapter 03's e96d04be establishes that a :: signature is parsed and thrown away) to notice or care that IO is special. But compiling is not running. eval.c, unchanged since chapter 02, is still the old character-dispatch parser and reducer: its primitive table is a hardcoded switch over single characters ('+', '-', '*', '/', '%', '=', '!', '<', '>', '?') with no entry, and no mechanism for an entry, that could ever recognize a multi-character name like IO.>>=. Verified directly: building 834fd9ba (the range's first commit β€” still before monadic IO lands, see below) with the same one-line ap/when patches chapter 03's Build note uses, and running

module TestIO where
import Prelude
import IO

main = IO.do
  putChar 'h'
  putChar 'i'

compiles to a .comb file that literally contains the text IO.>>=, IO.putChar, and so on as bare, unprefixed identifiers (Prim, at this point, prints as its own raw string β€” the $-prefix convention that lets the runtime recognize a primitive reference doesn't exist yet either), and running that file crashes immediately:

$ nix develop $MICROHS -c ./eval out.comb
ERR: parse ')'

So "where we are" is precise: IO syntax, an IO module, and generic do-notation that happens to work with it are all already in place. The runtime underneath all of it is fictional β€” every IO primitive is a name with no meaning below the compiler. This chapter's first commit is the one that gives it a meaning.

What happened

3246e2a6: monadic IO, and what an IO value actually is

"Implement monadic IO." lands the evening of 2023-06-01, about four hours and forty minutes after 95ff6a34 closed chapter 03 β€” not an empty gap: 834fd9ba "Small improvements" and e2f3cf57 "More stuff" land earlier the same day (12:09 and 12:32), both unnarrated warm-ups that extend IO.uhs β€” one more primitive declaration (print) plus a first batch of derived helpers (mapM, mapM_, putStr, putStrLn) built on the existing primitives β€” still with no runtime behind any IO.* name. Two things happen in the same commit, and it's worth keeping them separate: eval.c gains a general mechanism for named primitives (not IO-specific), and on top of that mechanism, IO gets its first real implementation.

The general mechanism: the old NODEPTR combS, combK, ... roster of individually-named global variables is replaced by one table, an array of {name, tag, node} triples, quoted with elisions (3246e2a6:eval.c, lines 168–210):

struct {
  char *name;
  enum node_tag tag;
  NODEPTR node;
} primops[] = {
  /* combinators */
  { "S", S },
  { "K", K },
  ...
  { "O", O },
  /* primops */
  { "+", ADD },
  ...
  { "ord", ORD },
  { "IO.>>=", IO_BIND },
  { "IO.>>", IO_THEN },
  { "IO.return", IO_RETURN },
  { "IO.readFile", IO_READFILE },
  { "IO.writeFile", IO_WRITEFILE },
  { "IO.getChar", IO_GETCHAR },
  { "IO.putChar", IO_PUTCHAR },
  { "IO.print", IO_PRINT },
};

and the .comb-file parser's '+'/'-'/'?'/etc. single-character cases are replaced by one '$' case that reads an arbitrary name and looks it up (3246e2a6:eval.c, lines 365–381, quoted whole):

  case '$':
    /* A primitive, keep getting char's until end */
    for (int j = 0;;) {
      c = getc(f);
      if (c == ' ' || c == ')') {
        ungetc(c, f);
        buf[j] = 0;
        break;
      }
      buf[j++] = c;
    }
    for (int j = 0; j < sizeof primops / sizeof primops[0]; j++) {
      if (strcmp(primops[j].name, buf) == 0)
        return primops[j].node;
    }
    fprintf(stderr, "bad primop %s\n", buf);
    ERR("no primop");

Every combinator and primitive, IO or not, now goes through this one path β€” the $-prefix convention this chapter's transcripts show everywhere ($K, $IO.>>=, $chr, ...) is this commit's doing, and it is the piece of infrastructure that makes IO.>>= parseable at all, independent of what IO.>>= then does.

What it does is eight new enum node_tag values (3246e2a6:eval.c, lines 18–21, quoted whole):

enum node_tag { FREE, IND, AP, INT, CHAR, S, K, I, B, C, T, Y, SS, BB, CC, P, O,
                ADD, SUB, MUL, QUOT, REM, SUBR, EQ, NE, LT, LE, GT, GE, ERROR, CHR, ORD,
                IO_BIND, IO_THEN, IO_RETURN, IO_READFILE, IO_WRITEFILE, IO_GETCHAR, IO_PUTCHAR, IO_PRINT
};

An IO a value, at this commit and every later one in this chapter, is not a distinct runtime type β€” it is an ordinary graph node whose tag happens to be one of these eight. To the normal reduction loop, eval(), these tags are opaque, already-a-value tags exactly like INT or CHAR β€” the whole handling is one RET with no reduction rule (3246e2a6:eval.c, lines 769–777, quoted whole):

    case IO_BIND:
    case IO_THEN:
    case IO_RETURN:
    case IO_READFILE:
    case IO_WRITEFILE:
    case IO_GETCHAR:
    case IO_PUTCHAR:
    case IO_PRINT:
      RET;

Meaning lives entirely in a second interpreter, evalio, that main calls once on the whole program instead of the old evali+print pattern. evalio walks the same kind of graph eval does, but its switch gives these eight tags actual behavior β€” sequencing is spelled out directly as C control flow (3246e2a6:eval.c, lines 817–830):

    case IO_BIND:
      CHECKIO(2);
      GCCHECK(1);
      x = evalio(ARG(TOP(1)));  /* first argument, unwrapped */
      f = ARG(TOP(2));          /* second argument, the continuation */
      n = new_ap(f, x);
      POP(3);
      goto top;
    case IO_THEN:
      CHECKIO(2);
      (void)evalio(ARG(TOP(1))); /* first argument, unwrapped, ignored */
      n = ARG(TOP(2));          /* second argument, the continuation */
      POP(3);
      goto top;

IO_BIND (>>=) recursively runs its left computation to get an unwrapped result, applies the continuation to it, and loops (goto top) on whatever that produces β€” a genuine trampoline, not a recursive descent, so a long do-chain doesn't grow the C stack. IO_THEN (>>) is the same shape with the left result discarded. On the Haskell/compiler side there is no analogous "IO type" either: EDo's desugaring (chapter 03's 693dc6ce:Desugar.hs) turns M.do { x <- e; rest } into App2 (Var "M.>>=") e (Lam x rest) for any M, and by the time that reaches Exp.hs it's an ordinary Prim/Var node indistinguishable at the term level from + or error. "This is IO" is a fact known only to evalio's switch, nowhere else in the system β€” the same architecture that already ran nfib.

The same commit adds one more piece: a Let statement inside a do-block (M.do { let x = e; rest }), desugaring to a plain, non-recursive application, no different from chapter 03's expression-level let at that point (3246e2a6:Desugar.hs, line 54, quoted with its two neighbors for context):

dsExpr (EDo n (Bind i e : ss)) = App2 (Var (n ++ ".>>=")) (dsExpr e) (Lam i $ dsExpr (EDo n ss))
dsExpr (EDo n (Then   e : ss)) = App2 (Var (n ++ ".>>"))  (dsExpr e)         (dsExpr (EDo n ss))
dsExpr (EDo n (Let  i e : ss)) = App  (Lam i $ dsExpr (EDo n ss)) (dsExpr e)

That line never changes for the rest of this chapter's range, even after fe22efbd (below) makes expression-level let unconditionally recursive β€” a let written inside a do-block stays simple, non-recursive sugar for a lambda application all the way through 8c5fbb1a. Two lets, spelled the same way, with different desugaring rules depending on which syntactic position they're in; nothing in this range unifies them.

And one honest gap, shipped anyway: IO_READFILE and IO_WRITEFILE are declared in the primop table, parseable, printable β€” and their evalio bodies are stubs (3246e2a6:eval.c, lines 836–841, quoted whole):

    case IO_READFILE:
      CHECKIO(1);
      ERR("IO_READFILE");
    case IO_WRITEFILE:
      CHECKIO(2);
      ERR("IO_WRITEFILE");

Console IO β€” getChar/putChar, hardcoded to stdin/stdout β€” works immediately in this commit. File IO is declared and left to crash on first use. 4c4229ec ("Fix bugs and improve.") and 7bb30b64 ("Minor improvements.") land later the same evening β€” 4c4229ec generalizes ELam to multi-argument lambdas and adds fix = primitive "Y" as a named Prelude function (a second, explicit route to the Y tag, alongside whatever let itself does β€” used immediately by List.uhs's new map/filter/foldr, each written as fix (\ rec arg -> ...) because plain recursive let doesn't exist as a language feature yet) β€” but neither commit touches IO_READFILE/IO_WRITEFILE. They stay broken until the next morning.

c5564e64: redo IO to allow files

Timestamps matter here: 3246e2a6 commits at 16:41 on 2023-06-01; c5564e64 commits at 09:38 the next day, 2023-06-02 β€” about seventeen hours, not "two days," and it's the very next work session, not a later return to old business. "Redo IO to allow files" isn't a bug fix to something that used to work; it's the first attempt to actually implement the two primitives that shipped as ERR() stubs.

The problem the flat-primitive design runs into: IO.readFile/ IO.writeFile as declared have no place to put an open file β€” there is no runtime representation for "a file that's been opened but not yet fully read or closed." c5564e64 adds one: a new node tag, HDL, whose payload is a raw C FILE * (c5564e64:eval.c, the union gains a FILE *file field addressed as HANDLE(p)), joining INT/CHAR as one more already-a-value tag the ordinary reducer just returns. Every IO primitive that used to assume stdin/stdout now takes an explicit handle argument β€” IO.getChar/IO.putChar/IO.print become hGetChar/hPutChar/hPrint, opening a file produces one of these handle nodes, and putChar/getChar/print survive as thin wrappers applying the h-prefixed primitive to a built-in stdin/stdout node (c5564e64:IO.uhs, quoted):

openFile :: FilePath -> IOMode -> IO Handle
openFile p m =
  let
    n = case m of
          ReadMode -> 0
          WriteMode -> 1
          AppendMode -> 2
          ReadWriteMode -> 3
  in  (primitive "IO.open") p n

putChar :: Char -> IO ()
putChar = hPutChar stdout

writeFile :: FilePath -> String -> IO ()
writeFile p s = IO.do
  h <- openFile p WriteMode
  hPutStr h s
  hClose h

writeFile is no longer a primitive at all β€” it's openFile + hPutStr (itself mapM_ (hPutChar h)) + hClose, three primitives composed in the Prelude rather than one primitive doing everything. IO_OPEN's C side needs a C string to hand to fopen, and the runtime has no string type β€” a String is a Scott-encoded cons-list of Char nodes, chapter 03's d91b9812/ddb1bb29 machinery, meaningful only by convention. c5564e64 adds evalstring, a new helper that walks that convention by hand, forcing each cons cell and copying its character into a fixed 10000-byte C buffer, with its own comment admitting the compromise (c5564e64:eval.c, quoted whole):

/* XXX this is cheating, should use continuations */
char *
evalstring(NODEPTR n)
{
  size_t sz = 10000;
  char *p, *name = malloc(sz);

  if (!name)
    ERR("evalstring malloc");
  for (p = name;;) {
    if (p >= name + sz)
      ERR("evalstring too long");
    n = evali(n);
    if (TAG(n) == K)
      break;
    else if (TAG(n) == AP && TAG(FUN(n)) == AP && TAG(FUN(FUN(n))) == O) {
      *p++ = evalchar(ARG(FUN(n)));
      n = ARG(n);
    } else {
      ERR("evalstring not Nil/Cons");
    }
  }
  *p = 0;
  return name;
}

A file path longer than 10000 characters is an ERR, not handled gracefully β€” a real, present limitation, left as-is. This is design-by-rewrite in miniature: the first cut declared a shape for file IO (readFile, writeFile, two primitives) without a representation underneath it, discovered that on first real use the next morning, and replaced the shape itself β€” handles as first-class values, every console/file operation parameterized over one β€” rather than patching around the gap. (readFile itself doesn't reappear until a3355632, "More functions.", later the same day, built from openFile + a new hGetContents that reads until IO.getChar returns a CHAR node holding -1 β€” getc's own EOF sentinel, stored and compared with nothing more than an Int equality check, ord c == negate 1; there is no distinct end-of-file representation anywhere in this system.)

fe22efbd: recursive let, and where the "was it recursive" question actually gets answered

Chapter 03's 5ccb1b2a desugars let as strictly sequential, non-recursive lambda application, with the comment "For now, just sequential bindings." fe22efbd ("Make let bindings recursive, and optimise the non-recursive case") makes good on that "for now" β€” every let-bound name, without exception, is now visible to its own right-hand side, via the fixed-point combinator (aux/y-combinator-and-recursion.md). The desugaring changes by inserting one Y per binding (fe22efbd:Desugar.hs, line 41):

      def (i, d) a = App (Lam i a) (App (Comb "Y") (Lam i d))

let i = d in a becomes (\i -> a) (Y (\i -> d)) β€” every binding, whether or not d actually mentions i. This is the aux doc's mechanism (Y f = f (Y f), one self-looped graph cell, case Y in eval.c) applied uniformly, which means a non-recursive let β€” the overwhelming common case β€” pays for a Y cell it doesn't need, unless something notices. The same commit adds exactly one more rule to Exp.hs's improveT, the post-compilation cleanup pass (fe22efbd:Exp.hs, lines 159–165, quoted whole):

improveT :: Exp -> Exp
improveT (App f a) =
  case (improveT f, improveT a) of
    (CK,                     CI) -> CT
    (CY,               App CK e) -> e
    (e1,                     e2) -> App e1 e2
improveT e = e

The interesting part is how "was d recursive" gets answered here β€” not by a free-variable scan over the surface syntax before desugaring, but for free, after the fact, from bracket abstraction's own smart constructors. Bracket-abstracting \i -> d when d doesn't mention i bottoms out in abstract's catch-all case, App CK d β€” and cS's existing rule from chapter 02 (cS (App CK e1) (App CK e2) = cK (App e1 e2)) means any closed subterm collapses to a single top-level K wrapper regardless of its internal shape. So a non-recursive binding's compiled form is always, mechanically, Y (K d') for some d' β€” and Y (K e) = K e (Y (K e)) = e by Y's own defining equation, one step, no cycle needed. improveT's new line recognizes exactly that shape and replaces it with e directly, so the runtime never builds the self-looped cell for a binding that never needed one β€” the recursive/ non-recursive distinction is discovered, not declared.

967b92c0: more data types, same combinators

967b92c0 ("More data types.") adds Maybe and Either to Prelude.uhs, plus showMaybe/showEither/showBool to String.uhs β€” and touches nothing else. (git show 967b92c0 --stat confirms: two .uhs files, zero lines of eval.c, Desugar.hs, or Exp.hs.) That's possible because the mechanism a data declaration compiles to hasn't changed since chapter 02, and this commit is a clean instance of it. Desugar.hs's handling, unchanged at this commit (967b92c0:Desugar.hs, lines 17–22, quoted whole):

dsDef (Data _ cs) = zipWith dsConstr [0..] cs
  where
    fs = [f i | (i, _) <- zip [0..] cs]
    dsConstr i (c, ts) = (c, lams xs $ lams fs $ apps (Var (f i)) (map Var xs))
      where xs = ["$x" ++ show (j::Int) | (j, _) <- zip [0..] ts]
    f i = "$f" ++ show (i::Int)

Each constructor at declaration-index i, with fields ts, compiles to a function of its own field values (xs) followed by one continuation per constructor of the type (fs, one $f0, $f1, ... per entry in cs) β€” which, once all of that is supplied, applies its own continuation, f i, to its own fields and ignores every other continuation entirely. data Maybe a = Nothing | Just a compiles Nothing to \$f0 $f1 -> $f0 and Just to \x $f0 $f1 -> $f1 x β€” a value of the type doesn't carry a tag to inspect; it is a dispatcher, waiting to be handed as many continuations as the type has constructors and calling exactly one of them. This is Scott encoding, the same trick chapter 02 used for hand-built tuple/list constructors and chapter 03's 8939d55b/d91b9812 reused for the tuple and list syntax β€” 967b92c0 is the first commit in this chapter's range to apply it to genuinely new user-facing sum types, and it costs nothing below Desugar.hs because the mechanism was already fully general. ECase's desugaring is the dual of this: it applies the scrutinee to its case arms' bodies, as continuations, in the order the arms are textually written β€” a fact dsDef's constructor-index numbering depends on silently matching (see Try It, Exercise 1, for what happens when it doesn't).

d2956ee8: optimizing Y better, and closing the aux doc's promise

fe22efbd's improveT rule catches Y (K e) β€” but only when that exact shape survives, structurally intact, all the way to the top of a fully bracket-abstracted term, because improveT is a separate pass that runs once, after compile finishes, pattern-matching on the finished term (compileOpt = improveT . compile). A let sitting inside an enclosing lambda β€” which is nearly every let in practice, a top-level function with even one parameter already being one lambda β€” doesn't reach improveT in that shape. The outer lambda's own bracket abstraction absorbs the inner Y (K e) and restructures it through cS/cB on the way to producing the outer function, and by the time that's finished, the term is B Y (B K e), not App CY (App CK e) β€” a shape improveT's one rule never matches, so the Y cell ships to the runtime unoptimized.

d2956ee8 ("Optimize Y better.") fixes this by moving the recognition into bracket abstraction's own machinery, so it fires bottom-up, during compilation, before the restructuring happens β€” one new line in cB, the smart constructor for the B combinator (d2956ee8:Exp.hs, lines 99–104, quoted whole):

cB :: Exp -> Exp -> Exp
cB CC          (App CC CI)    = CP -- Pair
cB (App CB CK) CP             = CO -- Cons
cB CY          (App2 CB CK e) = e  -- B Y (B K e) = e
cB CI          e              = e  -- B I e = e
cB e1          e2             = App2 CB e1 e2

Verified directly: compiling the identical program

module YOpt where
import Prelude
import IO

f :: Int -> Int
f x =
  let
    y = x + 1
   in y + y

main = print (f 10)

at 967b92c0 (before this optimization) and at d2956ee8 (after) gives two different compiled bodies for f:

967b92c0:  YOpt.f = B (S Prelude.+ I) (B Y (B K (Prelude.+ 1)))
d2956ee8:  YOpt.f = B (S Prelude.+ I) (Prelude.+ 1)

The Y is gone entirely in the second β€” not shrunk, eliminated β€” and both versions still run to the same answer, 22, with the d2956ee8 build doing it in fewer reductions (340 against 351 for the identical computation, both measured with ./eval's reduction counter). The non-recursive let inside f's body no longer costs a self-looped graph cell at runtime, regardless of how deeply it's nested under other lambdas β€” which is exactly what the aux doc, closing with "commit d2956ee8 ... revisits this implementation. What it changes is out of scope here; chapter 04 covers it.", promised. The same commit also migrates List.uhs's map/filter/foldr and IO.uhs's mapM_ from the explicit fix (\ rec arg -> ...) idiom 4c4229ec/c5564e64 had been using to plain let rec = ... in rec β€” the library code starts using the language feature the optimization just made free, in the same commit that made it free.

b1e84cf7 and 8c5fbb1a: serialization and deserialization

Main.hs's writeFile "out.comb" $ toStringP res (chapter 02/03's mechanism, unchanged) and eval.c's print/pp (used to echo a program before and after reduction) both already exist. IO.print (later renamed IO.serialize) is nothing more than wiring that same print function up as a runtime IO primitive β€” dump any live graph value, not just the whole compiled program at start-up, to a file handle, using the identical textual format ($name primitive references, :N/_N sharing labels). That wiring already existed before this chapter's range (IO.print/hPrint, c5564e64); what's new is making it survive contact with values that contain handles, and adding the reverse direction.

b1e84cf7 ("Handle serialization of standard handles.") fixes a narrow gap: before it, any HDL node reaching print's switch was an outright error (1a2f7b7a:eval.c, line 468: case HDL: ERR("Cannot serialize handles");). b1e84cf7 recognizes exactly the three well-known handles by C pointer identity and prints them as named primitives instead (b1e84cf7:eval.c, lines 483–492, quoted whole):

  case HDL:
    if (HANDLE(n) == stdin)
      fprintf(f, "$IO.stdin");
    else if (HANDLE(n) == stdout)
      fprintf(f, "$IO.stdout");
    else if (HANDLE(n) == stderr)
      fprintf(f, "$IO.stderr");
    else
      ERR("Cannot serialize handles");
    break;

Any freshly openFiled handle is still unserializable β€” the commit title says "standard handles," not "handles," and the code matches it exactly.

8c5fbb1a ("Add deserialization.") adds the reverse primitive, IO.deserialize, by calling parse() β€” the very function that reads the compiled .comb program at start-up β€” directly, on a live file handle, mid-run (8c5fbb1a:eval.c, lines 965–970, quoted whole):

    case IO_DESERIALIZE:
      CHECKIO(1);
      hdl = evalhandle(ARG(TOP(1)));
      gc();                     /* parser runs without GC */
      n = parse(hdl);
      RETIO(n);

What "serialize a running program's graph" means concretely, then, is: the exact same reader that bootstraps a program from its .comb file is reachable as a primitive from inside a running program, pointed at an arbitrary handle instead of the startup file. A value written with IO.serialize and read back with IO.deserialize round-trips through the same :N/_N label syntax chapter 02 established for whole-program linking β€” which, as Try It below shows directly, is also how a genuinely cyclic value survives the trip: :N/_N isn't just an optimization for repeated subterms, it's the only way this text format can express a graph that loops back on itself at all.

But parse()'s :/_ cases both read and write through one global, NODEPTR *shared, and that global is allocated, zeroed, and then freed β€” once, at start-up, by the function that reads the compiled program (8c5fbb1a:eval.c, lines 447–456, quoted whole):

NODEPTR
parse_top(FILE *f)
{
  shared = malloc(heap_size * sizeof(NODEPTR));
  for(int i = 0; i < heap_size; i++)
    shared[i] = NIL;
  NODEPTR n = parse(f);
  free(shared);
  return n;
}

IO_DESERIALIZE calls parse() directly, not parse_top() β€” it never reallocates shared, so it's dereferencing memory that was freed before main ever started running evalio. Deserializing a value with no :/_ labels at all (nothing shared, nothing cyclic) never touches shared[] and works fine; deserializing anything that does use a label β€” including every cyclic value, unavoidably β€” is a use-after-free. Verified directly (Try It, Exercise 3): it segfaults, consistently, every time.

Three more commits land the same day and are chapter 05's story, not this one's: ef082bbe/8c66d4dd/c0fb9a91 add independent -v verbosity flags to both uhs (the compiler) and eval (the runtime), and b0915dcf moves the library .uhs modules into a lib/ subdirectory and adds a -i<dir> module search path β€” both show up mechanically in Try It below (eval needs -v to print its reduction-count trailer at all now; uhs needs -ilib to find Prelude/List/IO/String), without being this chapter's subject.

Why it matters

Every extension in this chapter lands on the architecture chapters 01–03 already built, without changing that architecture's shape. An IO a is not a new kind of runtime value β€” it's a graph node with one of eight new tags, opaque to the ordinary reducer, meaningful only to a second small interpreter (evalio) layered on top of the same graph. A Maybe/Either value is not a new kind of runtime value either β€” it's the same Scott-encoding trick chapter 02 already generalized for tuples and lists, applied to two more sum types with zero lines of eval.c changed (967b92c0's diff is Prelude.uhs and String.uhs, nothing else). "More combinators, plus (for IO specifically) one more small interpreter that gives some of those combinators meaning" absorbs both features without touching the core loop eval() that's been running since chapter 01's ccb31388.

Redoing IO seventeen hours after shipping it is what design-by-rewrite looks like when it's working as intended: the first cut declared file IO as two primitives (readFile, writeFile) it hadn't yet figured out how to back with a real representation, shipped it anyway with ERR() stubs rather than pretending it worked, and the very next session β€” once files were actually needed β€” replaced the shape of the design (flat primitives with an implicit stdin/stdout) with one that had a place to put an open file (HDL nodes, every primitive taking an explicit handle) rather than patching the old shape into working. And d2956ee8's optimization is the same lesson at smaller scale: fe22efbd shipped a correct but naive recursive let (Y around every binding, no exceptions) with one narrow-scope cleanup already in place; d2956ee8, in the same sitting that generalized the cleanup, immediately went and rewrote List.uhs/IO.uhs's own fix (\ rec -> ...) idioms to plain recursive let β€” proof, in the same commit, that the newly-cheap feature was trusted enough to replace the manual workaround it was cheap enough to obsolete.

Notebook margin

  • 4c4229ec ("Fix bugs and improve.") and 7bb30b64 ("Minor improvements.") land the same evening as 3246e2a6, about forty-four minutes and about an hour later respectively β€” ELam generalized to multi-argument lambdas, fix = primitive "Y" added as a named function, print/pp refactored to share a printrec helper. Neither touches the IO_READFILE/IO_WRITEFILE stubs; those wait for the next morning's c5564e64.
  • 8b39ba55 ("Remove junk.") deletes Lam.hs outright β€” all 233 lines of it, the entire hand-embedded-Haskell prototype dating to ccb31388 (touched only lightly since, by two commits, 6485a341 and a9178695) that aux/y-combinator-and-recursion.md and chapters 01–02 walked through, superseded since chapter 03's 95ff6a34/b25d9fc8 gave the parser-driven Main.hs a real Prelude to replace it with. It's its own commit, not bundled with any feature β€” cleanup gets to be first-class work, done once there's enough of it to be worth doing.
  • do-block let (3246e2a6) and expression let (5ccb1b2a, fe22efbd) are spelled identically but desugar differently for the rest of this chapter's range: only the expression form goes through fe22efbd's unconditional Y-wrap-then-optimize; a let written inside M.do { ... } stays the original, simple, non-recursive lambda application all the way through 8c5fbb1a. Nothing in this range unifies the two.
  • a3355632's readFile has no distinct end-of-file value β€” hGetChar hands back whatever raw getc() returned, including -1 at EOF, packed into an ordinary CHAR node, and hGetContents stops when ord c == negate 1. The C convention leaks straight through.

Try it

Anchor: 8c5fbb1a ("Add deserialization."). Convention (see 00-overview.md):

git worktree add /tmp/mhs-8c5fbb1a 8c5fbb1a
cd /tmp/mhs-8c5fbb1a

Build note: this commit's Makefile finally has a Main: target (ghc Main.hs -o Main, added by 3246e2a6), but it's still a bare ghc invocation and hits the same GHC-9.8/mtl issues chapters 02 and 03's Build notes cover, so build by hand with -o uhs to keep the binary name consistent with earlier chapters:

nix develop $MICROHS -c ghc Main.hs -o uhs

fails first with ParserComb.hs's ap-not-in-scope error (same as chapters 02/03); add, after ParserComb.hs's existing import Control.Monad.State.Strict:

import Control.Monad(ap)

then with Compile.hs's when-not-in-scope error (same as chapter 03); add, after Compile.hs's existing import Control.Monad.State.Strict:

import Control.Monad(when)

With both one-line patches, the build succeeds (harmless -Wx-partial warnings about head/tail omitted):

nix develop $MICROHS -c ghc Main.hs -o uhs
[1 of 7] Compiling ParserComb       ( ParserComb.hs, ParserComb.o )
[2 of 7] Compiling Parse            ( Parse.hs, Parse.o )
[3 of 7] Compiling Exp              ( Exp.hs, Exp.o )
[4 of 7] Compiling Desugar          ( Desugar.hs, Desugar.o )
[5 of 7] Compiling Compile          ( Compile.hs, Compile.o )
[6 of 7] Compiling Main             ( Main.hs, Main.o )
[7 of 7] Linking uhs

Build the runtime the same way chapters 02/03 do:

nix develop $MICROHS -c gcc -Wall -O3 eval.c -o eval

Two more things are new to this commit's range and matter for every command below. b0915dcf moved Prelude.uhs/List.uhs/IO.uhs/ String.uhs into a lib/ subdirectory and added a -i<dir> module search path (Compile.hs's path field) β€” every ./uhs invocation below passes -ilib so import Prelude/import IO/etc. resolve. And ef082bbe/8c66d4dd/c0fb9a91 gated eval's own diagnostic trailer (node size=..., N reductions, M GCs) behind a -v flag that didn't exist in chapters 02/03 β€” by default ./eval now prints only the program's own output, nothing else; pass -v (as ./eval -v out.comb) to see reduction counts.

Exercise 1: read a file, transform it, print the result

printf 'hello world\nfrom a file\n' > greeting.txt
cat > Shout.uhs <<'EOF'
module Shout where
import Prelude
import List
import IO

upc :: Char -> Char
upc c =
  case (ord c >= ord 'a') && (ord c <= ord 'z') of
    True  -> chr (ord c - 32)
    False -> c

main = IO.do
  s <- readFile "greeting.txt"
  putStr (map upc s)
EOF
nix develop $MICROHS -c ./uhs -ilib Shout
nix develop $MICROHS -c ./eval out.comb
hello worldοΏ½from a fileοΏ½

Not what was wanted, and not a typo above β€” a genuine dead end. upc's case writes its arms True first, False second, but Prelude.uhs declares data Bool = False | True, making False the constructor at index 0 and True the constructor at index 1. Per "What happened" above (967b92c0), dsExpr (ECase e as) = apps (dsExpr e) (map dsArm as) applies the scrutinee to its arms' compiled bodies positionally, in the order they're written β€” nothing checks that written order against declaration order. Writing True first means the first continuation supplied is the one meant for True, but a Bool value returns its declaration-index-0 continuation (False's) first β€” so every character, whatever upc's condition actually evaluates to, runs the arm written first. Every space (ord ' ' = 32) got chr (32 - 32) = the NUL byte; every newline (ord '\n' = 10) got chr (10 - 32) = byte 0xEA β€” visibly wrong once printed, confirmed with od:

$ nix develop $MICROHS -c ./eval out.comb | od -An -tx1 | head -1
 68 65 6c 6c 6f 00 77 6f 72 6c 64 ea 66 72 6f 6d

Swap the arms to match Bool's actual declaration order β€” False first, True second β€” and rebuild:

cat > Shout.uhs <<'EOF'
module Shout where
import Prelude
import List
import IO

upc :: Char -> Char
upc c =
  case (ord c >= ord 'a') && (ord c <= ord 'z') of
    False -> c
    True  -> chr (ord c - 32)

main = IO.do
  s <- readFile "greeting.txt"
  putStr (map upc s)
EOF
nix develop $MICROHS -c ./uhs -ilib Shout
nix develop $MICROHS -c ./eval out.comb
HELLO WORLD
FROM A FILE

Exercise 2: read the sharing labels out of out.comb

Exercise 1's out.comb (the corrected version) is 2792 bytes, one line, 81 top-level definitions (Prelude, List, IO, and Shout linked together). Its opening (byte-exact, ... eliding everything after the fourth definition):

(($T :0 (($B $K) (($B $K) $K))) (($T :1 ($K (($B $K) $K))) (($T :2 ($K ($K $K))) (($T :3 ($K ($K $T))) (($T :4 $IO.>>=) ...

:0 through :3 and :4 ($IO.>>=) are top-level definitions in source-module-linking order β€” exactly chapter 02's 17587b2d label scheme, unchanged. A concrete back-reference, from further into the same file:

... (($T :16 (_7 _13)) (($T :17 (_8 _12)) (($T :18 (_9 _13)) ...

:16 (which turns out to be IO.putChar's stdout-bound wrapper, i.e. putChar) is defined as _7 _13 β€” a reference to definition 7 (IO.putChar) applied to a reference to definition 13 (IO.stdout), reusing their labels rather than repeating their bodies.

It's worth being precise about what kind of "sharing" this is, because this chapter also produces a different kind. Every one of these :N labels is assigned unconditionally, one per top-level definition, by Main.hs itself (def ((_, e), i) r = App2 CT (Lbl i (subst e)) r, iterating zip ds' [0..] over every definition whether or not anything else ever references it) β€” a static, positional scheme, decided at compile time from source order. Contrast that with what the runtime's own IO.serialize produces, which uses find_sharing's two-pass graph walk (mark on first visit, promote to shared on second) to label only nodes actually visited more than once while printing a live value β€” dynamically, from the graph's actual shape, nothing to do with source order. A plain list with no repeated substructure gets no labels at all:

cat > PlainSer.uhs <<'EOF'
module PlainSer where
import Prelude
import List
import IO

main = IO.do
  writeSerialized "plain.ser" [1, 2, 3]
  v <- readSerialized "plain.ser"
  print v
EOF
nix develop $MICROHS -c ./uhs -ilib PlainSer
nix develop $MICROHS -c ./eval out.comb
echo "exit: $?"
cat plain.ser
(($O 1) (($O 2) (($O 3) $K)))
exit: 0
(($O 1) (($O 2) (($O 3) $K)))

(the run exits cleanly, and both the runtime's own printed result and the file content are the same text, $O/$K all the way down, no :/_ anywhere.) Reusing the same let-bound list twice, so the traversal that writes it actually visits one node more than once, does produce a label:

cat > ShareDemo.uhs <<'EOF'
module ShareDemo where
import Prelude
import List
import IO

main = IO.do
  let
    xs = [1, 2, 3]
   in writeSerialized "share.ser" (xs, xs)
EOF
nix develop $MICROHS -c ./uhs -ilib ShareDemo
nix develop $MICROHS -c ./eval out.comb
cat share.ser
(($P :99474 (($O 1) (($O 2) (($O 3) $K)))) ($I _99474))

One label, :99474 (a heap-cell index, hence the large arbitrary number β€” nothing like the small sequential :0/:1/... Main.hs assigns), marking the list xs the first time the pair's traversal reaches it; _99474 the second time, because both halves of (xs, xs) are the same heap cell (one let-bound thunk, used twice), not two equal-looking copies.

Exercise 3: tie a knot

take exists as of 72d4d093 (List.uhs), so a cyclic list can be observed through a finite prefix:

cat > Knot.uhs <<'EOF'
module Knot where
import Prelude
import List
import String
import IO

main = IO.do
  let
    xs = 1 : xs
   in putStrLn (showList showInt (take 5 xs))
EOF
nix develop $MICROHS -c ./uhs -ilib Knot
nix develop $MICROHS -c ./eval out.comb
[1,1,1,1,1]

fe22efbd's unconditional Y-wrap applies to any let, not just function definitions β€” xs = 1 : xs builds a genuinely self-looped graph cell the same way nfib's Y does in aux/y-combinator-and-recursion.md, and take 5 walks five steps into it and stops, never noticing the list has no end. Serializing xs directly (not through take, the whole infinite structure) shows the cycle in the label syntax itself:

cat > KnotSer.uhs <<'EOF'
module KnotSer where
import Prelude
import List
import IO

main = IO.do
  let
    xs = 1 : xs
   in writeSerialized "knot.ser" xs
EOF
nix develop $MICROHS -c ./uhs -ilib KnotSer
nix develop $MICROHS -c ./eval out.comb
cat knot.ser
:99731 (($O 1) _99731)

This terminates β€” find_sharing's mark-based traversal handles a cyclic graph the same way eval.c's garbage collector always has, stopping at nodes it's already visited rather than following AP pointers forever β€” and the result says exactly what the graph is: definition :99731 is the cons cell (O 1 _99731), and _99731 inside it refers back to the very node being defined. Not two equal cons cells; one cell, pointing at itself.

Reading it back is where this commit's range runs out of runway. Deserializing this exact file segfaults:

cat > KnotRead.uhs <<'EOF'
module KnotRead where
import Prelude
import List
import String
import IO

main = IO.do
  ys <- readSerialized "knot.ser"
  putStrLn (showList showInt (take 5 ys))
EOF
nix develop $MICROHS -c ./uhs -ilib KnotRead
nix develop $MICROHS -c ./eval out.comb
echo "exit: $?"
exit: 139

(139 = 128 + SIGSEGV.) Per "What happened" above, this isn't specific to cycles β€” it's any use of the :/_ label syntax at all. IO_DESERIALIZE calls parse() directly, and parse()'s :/_ cases read and write the global shared pointer that parse_top() allocated once, at start-up, and already free()d before evalio ever ran. Confirmed by trying the non-cyclic share.ser from Exercise 2 the same way β€” one shared node, no cycle, same crash:

cat > ShareRead.uhs <<'EOF'
module ShareRead where
import Prelude
import List
import IO

main = IO.do
  v <- readSerialized "share.ser"
  print v
EOF
nix develop $MICROHS -c ./uhs -ilib ShareRead
nix develop $MICROHS -c ./eval out.comb
echo "exit: $?"
exit: 139

versus the earlier, un-shared [1,2,3] value from writeSerialized (no :/_ in its file at all β€” see Exercise 2), which deserializes without incident. At 8c5fbb1a, IO.serialize can write a genuine cycle to a file and IO.deserialize exists as a primitive β€” but reading anything that actually needs the label mechanism back crashes the runtime, every time, reproducibly. That is the real, current state of this feature at this commit, not a hypothetical.

Clean up:

git worktree remove --force /tmp/mhs-8c5fbb1a

🌐 ν•œκ΅­μ–΄λ‘œ 읽기

05 β€” Lab notebook: the working habits

Where we are

Chapter 04 ends at 8c5fbb1a ("Add deserialization.") with monadic IO, a working IO.serialize/IO.deserialize pair, and a documented, reproducible crash the moment deserialization touches a shared or cyclic label. But 8c5fbb1a is not the first commit of this session β€” by the time it lands (16:17:09 on 2023-06-02), four other commits have already gone in, earlier the same afternoon: ef082bbe (14:23:17), 8c66d4dd (14:28:31), c0fb9a91 (14:30:13), and b0915dcf (16:16:53, sixteen seconds before 8c5fbb1a itself). Chapter 04 named all four and explicitly deferred them: "Three more commits land the same day and are chapter 05's story, not this one's: ef082bbe/8c66d4dd/c0fb9a91 add independent -v verbosity flags to both uhs (the compiler) and eval (the runtime), and b0915dcf moves the library .uhs modules into a lib/ subdirectory and adds a -i<dir> module search path ..." None of the four touch IO, serialization, or any other feature chapter 04 was tracking β€” they're tooling, and this chapter is where tooling gets its due.

So this chapter's material spans two things at once: those four commits (which precede 8c5fbb1a in the commit graph, git log --oneline --reverse ccb31388..8018eec8 puts them at positions 40–43, 8c5fbb1a at 44), and the fifteen commits of the range 00-overview.md's timeline table actually assigns this chapter, 8c5fbb1a..8018eec8 (6c59dd36 through 8018eec8, all landing between 16:23 and 19:10 the same evening β€” under three hours). By the end of that evening the project has bin/uhs and bin/eval as the binaries' real names (no longer Main), a lib/ directory holding four modules with a -i search path to find them, .hs extensions throughout (renamed from .uhs), a three-module automated test harness wired into make test, a first README.md, and a -H<N> flag to size the heap at run time instead of only at compile time. None of it is a new language feature. All of it is what makes the previous four chapters' features usable, checkable, and explicable to someone who isn't the author β€” and that shift, from "make it work" to "make it a project," is what this chapter tracks.

What happened

Habit 1: observability first β€” ef082bbe, 8c66d4dd, c0fb9a91

Three commits, seven minutes apart, all on the afternoon of 2023-06-02, each adding one more layer of "show me what's happening":

ef082bbe ("Use a flag to be verbose.") gates eval's existing diagnostic trailer β€” the main returns ... dump, the node size=... line, the reduction/GC/cell counters β€” behind a new -v flag, off by default. Before this commit that trailer printed unconditionally on every run; a program's own output (its actual putChar/print effects) was always followed by internal instrumentation nobody but the author, mid-debugging, wanted to see every time. The whole change is one main, quoted whole (ef082bbe:eval.c, lines 989–1018):

int
main(int argc, char **argv)
{
  int verbose = 0;
  if (argc > 1 && strcmp(argv[1], "-v") == 0) {
    argc--;
    argv++;
    verbose = 1;
  }
  if (argc != 2)
    ERR("no file");
  init_nodes();
  FILE *f = fopen(argv[1], "r");
  if (!f)
    ERR("file not found");
  NODEPTR n = parse_top(f);
  fclose(f);
  PUSH(n); gc(); n = TOP(0); POP(1);
  int start_size = num_marked;
  //pp(stdout, n);
  n = evalio(n);
  if (verbose) {
    printf("\nmain returns ");
    pp(stdout, n);
    printf("node size=%ld, heap size=%ld\n", NODE_SIZE, heap_size);
    printf("%d reductions, %d GCs, max cells used %d\n", num_reductions, num_gc, max_num_marked);
    printf("%d cells at start\n", start_size);
  }
  exit(0);
}

Five minutes later, 8c66d4dd ("Add a -v flag.") does the same thing on the compiler side, but it's a bigger change because compile never had a verbosity parameter to gate on β€” it always printed. Compile.hs grows a Bool argument threaded through three functions (8c66d4dd:Compile.hs, lines 18–19 and 24, quoted):

compile :: Bool -> IdentModule -> IO [LDef]
compile verbose nm = do
compileModuleCached :: Bool -> IdentModule -> StateT Cache IO CModule

with every one of its three putStrLn "importing ..." trace lines wrapped in when verbose $. Main.hs picks the flag off the command line and passes it through (8c66d4dd:Main.hs, lines 8–15, quoted whole):

main :: IO ()
main = do
  args <- getArgs
  let verbose = "-v" `elem` args
      mn = case filter ((/= "-") . take 1) args of
             [s] -> s
             _ -> error "Usage: compile [-v] ModuleName"
  ds <- compile verbose mn

and the same flag now also gates the pre-8c66d4dd behavior of dumping every compiled definition's combinator form and the whole linked program to stdout before writing out.comb β€” previously unconditional, now when verbose $ do .... Two minutes after that, c0fb9a91 ("More verbosity.") turns eval's one-shot -v into a counter and adds a second level: the if (argc > 1 && ...) becomes a while loop so -v -v is legal and increments verbose twice, and verbose > 1 gates a call to pp(stdout, n) β€” printing the entire loaded program graph, before any reduction happens β€” that the previous commit had left as a commented-out //pp(stdout, n); one line above where it now runs uncommented (c0fb9a91:eval.c, lines 989–1008, quoted; the if (verbose > 1) pp(stdout, n); on the last two lines is the whole addition beyond widening verbose to an int):

int
main(int argc, char **argv)
{
  int verbose = 0;
  while (argc > 1 && strcmp(argv[1], "-v") == 0) {
    argc--;
    argv++;
    verbose++;
  }
  if (argc != 2)
    ERR("no file");
  init_nodes();
  FILE *f = fopen(argv[1], "r");
  if (!f)
    ERR("file not found");
  NODEPTR n = parse_top(f);
  fclose(f);
  PUSH(n); gc(); n = TOP(0); POP(1);
  int start_size = num_marked;
  if (verbose > 1)

Three commits, three tools (eval twice, uhs once), one pattern each time: something that used to print always, or never, now prints on request, at a level the caller controls. Try It, Exercise 1 runs all three levels on the same program and shows exactly what each one adds.

Habit 2: tests as you go β€” 6ac9c791, with roots two commits earlier

6ac9c791 ("Add some testing.") is not where automated testing in this project starts from nothing β€” 9b6e23c8, two commits earlier the same evening, already added tests/Fac.uhs (soon renamed tests/Fac.hs by 73a427d7), a small module computing fac 6 and fst res + snd res where res = (fac 10, nfib 30). What 6ac9c791 actually is: a Makefile loop that compiles each test module, runs it, and diffs the output against a checked-in golden file β€” nothing more elaborate than that. The whole harness, tests/Makefile at this commit, quoted whole:

UHS=../bin/uhs -i../lib
EVAL=../bin/eval

tests:
	$(UHS) IOTest && (echo q | $(EVAL) out.comb > IOTest.out) && diff IOTest.ref IOTest.out
	$(UHS) StringTest && $(EVAL) out.comb > StringTest.out && diff StringTest.ref StringTest.out
	$(UHS) Fac && $(EVAL) out.comb > Fac.out && diff Fac.ref Fac.out

Three lines, three modules, three reference files β€” IOTest.ref, StringTest.ref, and Fac.ref are all new in this commit, checked-in alongside the Makefile lines that exercise them. Each line compiles a module to out.comb, runs it, redirects stdout to a .out file, and diffs that against the .ref file checked into git; make's own && chaining means the recipe line β€” and the whole make test β€” fails loudly the moment either the compile or the diff does. There is no test framework: no assertion library, no test runner beyond make itself, no structured pass/fail report beyond diff's exit code and whatever it prints. IOTest's line has one extra piece worth noticing, (echo q | $(EVAL) out.comb > IOTest.out) β€” IOTest.hs calls getChar once, so running it non-interactively needs something on stdin, and echo q supplies it (6ac9c791 also renames IOTest.hs's scratch files, test.txt/test2.txt, to test.tmp/test2.tmp, matching a .gitignore pattern for build artifacts the next commit, 110bcc94, adds). The .ref/.out/diff shape here β€” golden output, byte comparison, no tolerance for drift β€” is exactly the harness Try It, Exercise 2 extends with one more test in the same style.

Habit 3: write the README when the project becomes real β€” 37e44acd and same-day "More text"

37e44acd ("Add README") is the first README.md this project has ever had β€” fifty commits and just under eight days after ccb31388. It is three lines, quoted in full (37e44acd:README.md):

# Micro Haskell
This directory contains an implementation of a very small subset of Haskell.
It uses combinators for the runtime execution.

That's the whole commit β€” one new file, three insertions, nothing else touched. What's notable is less the content than the timing: it lands at 16:44:57, immediately after the test harness (6ac9c791, 110bcc94) is in place and passing, and well after the language, compiler, and runtime already do real work. The README doesn't introduce the project β€” it documents a project that has, by this point, already earned documentation.

It then grows in four more commits the same evening, each named "More text" or its close variants, and the order the author fills it in is itself informative:

  1. aae0eee2 (17:01:29, "More text.") adds ## Language (a bullet list of what the language has: variables, application, lambda without pattern matching, case/let with their own listed restrictions, tuples, list syntax, qualified do, data declarations, "type signatures that are ignored") and ## Compiler with a one-line description of each of the six compiler modules. The very first substantive content after the three-line intro is a list of what the language can't do yet, stated as plainly as what it can.
  2. 04f0840b (17:09:37, "More text.") adds a new file, Example.hs (a fac function plus a main that prints four factorials), and a ## Example section in the README that reproduces that file's source verbatim inside a fenced code block, followed by build instructions ("make sure the binaries are built... compile the file by bin/uhs Example... run the combinator file by bin/eval out.comb").
  3. 91a8ff34 (17:11:02, "More text") tightens that prose ("The compile" β†’ "Then compile", "Run" β†’ "Finally, run") and adds the one thing the previous commit left out: the actual expected output, Some factorials / [1,2,6,3628800].
  4. 0bfdcd18 (17:14:21, "Fix some mistakes.") fixes the build command itself β€” bin/uhs Example becomes bin/uhs -ilib Example, because without -ilib the compiler can't find Prelude/List/IO/ String in their new lib/ home (b0915dcf's path flag, this chapter's opening habit). The README's own worked example didn't actually run as first written; the fix lands under five minutes later, the next commit touching the file.

Two more "More text" commits land later that evening once other feature work (the heap-size flag, below) is done: 60343934 (18:35:10) adds a ### Features note about serialize/deserialize preserving sharing and cycles, closing on its one caveat ("The only exception to this is file handles, which cannot be serialized"); 3cdbb820 (18:51:39) adds a ## Libraries paragraph. So the order the author wrote these sections in β€” commit order, not the order they sit in the finished README β€” is: what the language is β†’ how the compiler is organized β†’ a runnable worked example, corrected once it's shown to not actually run β†’ what the runtime can serialize, caveat included β†’ what the libraries cover. Language and workflow first; the newest, most delicate feature (cyclic serialization) last, and honestly caveated rather than oversold.

Habit 4: rename late β€” 73a427d7

Before 73a427d7 ("Rename files to .hs"), every source file in this project β€” Prelude, List, IO, String, the test modules β€” carries the extension .uhs, not .hs. 73a427d7's diffstat is the whole story (73a427d7 --stat, quoted):

 Compile.hs                              | 2 +-
 Makefile                                | 5 ++++-
 lib/{IO.uhs => IO.hs}                   | 0
 lib/{List.uhs => List.hs}               | 0
 lib/{Prelude.uhs => Prelude.hs}         | 0
 lib/{String.uhs => String.hs}           | 0
 tests/{Fac.uhs => Fac.hs}               | 0
 tests/{IOTest.uhs => IOTest.hs}         | 0
 tests/{ListTest.uhs => ListTest.hs}     | 0
 tests/Makefile                          | 5 +++++
 tests/{StringTest.uhs => StringTest.hs} | 0
 11 files changed, 10 insertions(+), 2 deletions(-)

Eight files renamed with 0 lines changed β€” pure git mv, no content touched. The other three lines in the diffstat, Compile.hs, Makefile, and tests/Makefile, carry the actual code: one line in Compile.hs, where the hardcoded extension the module loader appends to a module name flips from .uhs to .hs (compileModule's let fn = nm ++ ".uhs" becomes let fn = nm ++ ".hs"), plus a test: target added to the top-level Makefile; tests/Makefile itself is new here too β€” a five-line file with one rule, $(UHS) IOTest.hs && $(EVAL) out.comb > IOTest.out && diff IOTest.ref IOTest.out β€” the test harness's very first form, refined into the three-module loop Habit 2 describes by the very next commit, 6ac9c791.

uhs β€” "micro Haskell," or close to it, the name the compiler binary itself keeps for the rest of this chapter's range (bin/uhs, set one commit earlier by 9b6e23c8) β€” was, until this commit, also the extension every source file carried, a private in-house dialect marker. The rename happens after do-notation, IO, serialization, and Maybe/Either are all in place β€” after four chapters of "how close is this to Haskell" have already been answered, mostly, "close" β€” and, within this chapter's own session, before the README (37e44acd, eleven minutes later) that will go on to describe the result. The file extension is the last thing about the language itself to change, not the first: it follows the language's own claim on the name, rather than asserting it in advance.

Habit 5: knobs for real constraints β€” 0b97ab3e

0b97ab3e ("Add a flag to set the heap size.") turns eval's heap capacity from a value fixed at compile time β€” #define HEAP_CELLS 100000, unchanged by this commit, still the default β€” into something overridable per run, with a new -H<N> flag (0b97ab3e:eval.c, lines 1005–1021, quoted):

int
main(int argc, char **argv)
{
  int verbose = 0;
  char *fn = 0;
  
  argc--, argv++;
  while (argc > 0 && argv[0][0] == '-') {
    if (strcmp(argv[0], "-v") == 0)
      verbose++;
    else if (strncmp(argv[0], "-H", 2) == 0)
      heap_size = atoi(&argv[0][2]);
    else
      ERR("Bad flag");
    argc--;
    argv++;
  }

The same commit also generalizes argument parsing from "exactly one positional argument, the file name" to "zero or one, defaulting to out.comb" β€” a second, smaller convenience change riding along with the flag.

What actually motivated it is less certain than the mechanism, and it's worth being honest about that rather than inventing a tidy story: the commit message is bare, and nothing in the diff or the surrounding commits states a specific failure it fixes. Checked directly β€” every run in this chapter's Try It and in the existing test suite stays well under the 100000-cell default: the Try It program below (Exercise 1) peaks at 140 cells; the harness's own Fac test, run separately at this commit with -v, reports 164 cells at start and a peak of 202 cells mid-run, for its whole fac/nfib 35 computation β€” so nothing in the committed test suite was visibly hitting the ceiling. Pushing on it directly, in a throwaway build at this commit, does turn up a real limit, just not this one: a small Haskell-side program that eagerly builds a list via ordinary self-recursion (n : upto (n - 1)) and sums it runs fine at 1,000 elements (500500, correct) and segfaults, reproducibly, at 5,000 (the exact threshold between the two wasn't narrowed further) β€” a C call-stack overflow from non-tail recursion in eval's own graph walk, not a heap-cell shortage, and -H does nothing for it. The one connection the timeline does support: -H lands at 18:34:52, and seventeen minutes later 3cdbb820 adds tests/Nfib.hs, a deliberately heavier benchmark than anything run before it ("Typical nfib/s is 10M", main = print (nfib 38), with a new make time target built for it). Whether -H was written because of that heavier workload or just shortly before planning it isn't something the commits themselves settle β€” but a compile-time-only heap constant is exactly the kind of thing that stops being convenient the moment the programs being run start actually growing, and this session is visibly in the middle of making them grow.

Habit 6: cleanup as first-class work β€” 6c59dd36, 9b6e23c8, 28d53dab

Three separate commits this evening do nothing but tidy, each on its own, not bundled with any feature:

6c59dd36 ("Update"), the very first commit of this chapter's git-range proper, rewrites the top-level Makefile end to end. The Makefile it replaces still reads (6c59dd36^:Makefile, quoted whole):

test:	eval nfib.comb
	./eval nfib.comb

eval:	eval.c
	gcc -Wall -O3 eval.c -o eval

nfib.comb:	Lam
	./Lam > nfib.comb

Main:	*.hs
	ghc Main.hs -o Main

clean:
	rm -f *.hi *.o eval Lam *.comb

nfib.comb: Lam builds nfib.comb by running ./Lam, the hand-embedded-Haskell prototype chapter 04's notebook margin records as deleted outright by 8b39ba55 ("Remove junk.") — but the rule was already dead before that deletion, and for longer: 3246e2a6 ("Implement monadic IO."), the commit that opens chapter 04, had already replaced the Makefile's Lam: Lam.hs / ghc Lam.hs -o Lam rule with Main: *.hs / ghc Main.hs -o Main (visible above, both still present) without touching or removing the nfib.comb: Lam line that depended on the rule it had just replaced. From that moment — about twenty-one hours before 8b39ba55 deletes Lam.hs itself, and just under twenty-four hours before 6c59dd36 — there was no recipe left in the Makefile that could produce a Lam binary at all; 8b39ba55 only removed the source the (already unreachable) rule would have compiled. 6c59dd36 is the first commit to notice and remove the dead nfib.comb/Lam lines, in the course of introducing a bin/ output directory and moving the Main/eval targets under it as $(BIN)/Main/$(BIN)/eval — the Main→uhs rename itself is the next commit's work.

9b6e23c8 ("Better names.") follows two minutes later and is the commit that actually picks the name: the Makefile's $(BIN)/Main target becomes $(BIN)/uhs; Main.hs's usage-error string, generic and stale ("Usage: compile [-v] ModuleName" β€” "compile," not any name this project's binaries actually carry), becomes "Usage: uhs [-v] [-iPATH] ModuleName" β€” naming the just-chosen binary, and picking up the -iPATH flag b0915dcf had already added without updating this message β€” and .gitignore drops its now-stale Lam/eval/Main entries in favor of bin/*.

28d53dab ("Clean up"), the second-to-last commit of the evening, at 19:10:00, removes one function, restOfInput, from ParserComb.hs β€” four lines plus its export β€” with no evidence anywhere in this session's diffs that it was ever called. Its own commit, its own line in the log, about eighteen minutes after the last feature commit (3cdbb820) and seven seconds before the last one (8018eec8, "Add null") β€” dead code gets removed once there's a moment free to remove it, not folded silently into whatever feature commit happened to be nearby.

Why it matters

None of the six habits above are specific to Micro Haskell, or to compilers, or even to this particular language of choice. A verbosity flag that's off by default and stacks with repetition (-v, -v -v) is a decision anyone writing a CLI tool can make on day one, for free, before there's anything worth being verbose about yet. A test harness that's nothing more than "compile, run, diff against a checked-in file, let make's own exit-code chaining decide pass or fail" needs no framework, no dependency, and no more code than 6ac9c791's three Makefile lines β€” the bar for "have automated tests at all" is far lower than the bar most projects set for themselves before clearing it. A README written after the thing it describes already works, rather than as an aspirational plan beforehand, is a small discipline with an outsized effect on whether the documentation stays honest β€” and this session shows it isn't automatically honest even then: the worked example in 04f0840b didn't actually run until 0bfdcd18 fixed it under five minutes later, caught (one has to assume) by someone actually trying the command as written. A file extension that only changes once the code underneath it has grown into what the extension claims is a small act of intellectual honesty most projects skip, naming things aspirationally from the start instead. A knob for a constant that used to require a rebuild to change costs a handful of lines and pays for itself the first time someone needs a bigger number without needing a compiler on hand. And cleanup β€” a stale Makefile rule, a leftover .gitignore entry, one unused function β€” treated as its own commit rather than swept in wherever convenient, is what keeps a fast-moving project's incidental debt from compounding silently. Every one of these is adoptable, unchanged, on the first day of any project, in any language β€” which is the entire reason this chapter exists in a book that is otherwise about how a specific compiler got built.

Notebook margin

This chapter's margin is a fact about the whole project's history rather than a note on any one commit: which commit messages recur, and how. The exact command, and its real output:

$ git log --format=%s ccb31388..8018eec8 | sort | uniq -c | sort -rn | head
      3 More text.
      2 New optimiser.
      1 Various small improvements.
      1 Various little fixes,
      1 Use 'primitive'
      1 Use file handle for printing.
      1 Use a flag to be verbose.
      1 Update
      1 Small improvements
      1 Rename files to .hs

Every one of "More text."'s three occurrences is this chapter's README sequence (aae0eee2, 04f0840b, 60343934 β€” the fourth README commit, 91a8ff34, is spelled without the trailing period and so doesn't collapse into the same bucket by this exact-string count, a reminder that sort | uniq -c counts strings, not intent). "New optimiser." (b4e44ded, 9cf1e937) is earlier work, outside this chapter's range, turned up only because the command runs over the whole book so far (ccb31388..8018eec8, chapters 01–05). "Use 'primitive'" is 95ff6a34, chapter 03's closer; "Various little fixes," β€” with the trailing comma left in, 1a2f7b7a β€” is chapter 04's. Nowhere in this list, or in the full log this table samples from, is there a feat:/fix:/chore: prefix of any kind: every message here is a plain, present-tense or past-tense English sentence, occasionally without a full stop, occasionally (see above) inconsistently punctuated even by its own author across near-identical messages. The habits this chapter documents don't show up as a taxonomy in the log; they show up in what the commits actually touch.

Try it

Anchor: 37e44acd ("Add README"), the commit that gives this session its first README and, by this point, already has the .hs rename (73a427d7), the test harness's Fac/IOTest/StringTest trio (6ac9c791), and the lib/-plus--i module path (b0915dcf) all in place. Convention (see 00-overview.md):

git worktree add /tmp/mhs-37e44acd 37e44acd
cd /tmp/mhs-37e44acd

Build note: same GHC-9.8/mtl issues chapters 02–04 hit. Build by hand, into bin/, matching this commit's own Makefile layout:

mkdir -p bin
nix develop $MICROHS -c ghc Main.hs -o bin/uhs

fails first with ParserComb.hs's ap-not-in-scope error; add, after ParserComb.hs's existing import Control.Monad.State.Strict:

import Control.Monad(ap)

then with Compile.hs's when-not-in-scope error; add, after Compile.hs's existing import Control.Monad.State.Strict:

import Control.Monad(when)

With both one-line patches, the build succeeds (harmless -Wx-partial warnings about head/tail omitted):

[1 of 7] Compiling ParserComb       ( ParserComb.hs, ParserComb.o )
[2 of 7] Compiling Parse            ( Parse.hs, Parse.o )
[3 of 7] Compiling Exp              ( Exp.hs, Exp.o )
[4 of 7] Compiling Desugar          ( Desugar.hs, Desugar.o )
[5 of 7] Compiling Compile          ( Compile.hs, Compile.o )
[6 of 7] Compiling Main             ( Main.hs, Main.o )
[7 of 7] Linking bin/uhs

Build the runtime the same way:

nix develop $MICROHS -c gcc -Wall -O3 eval.c -o bin/eval

Every bin/uhs invocation below needs -ilib to find Prelude/ List/IO/String under lib/ (b0915dcf's path flag, already in place at this commit, discussed in "Where we are" above).

Exercise 1: run the compiler and the runtime at several verbosity levels

A small program, exercising both a string and an arithmetic effect:

cat > Greet.hs <<'EOF'
module Greet where
import Prelude
import IO

main = IO.do
  putStrLn "hello"
  print (6 * 7)
EOF
nix develop $MICROHS -c ./bin/uhs -ilib Greet

Compiler-side verbosity is a single flag (on or off). Without -v, bin/uhs prints nothing at all β€” it just writes out.comb. With -v, it traces every module import (including cache hits) and dumps every compiled definition's combinator form before writing the file:

nix develop $MICROHS -c ./bin/uhs -v -ilib Greet
importing "Greet"
importing "Prelude"
importing done "Prelude"
importing "IO"
importing cached "Prelude"
importing "List"
importing cached "Prelude"
importing done "List"
importing done "IO"
importing done "Greet"
Greet.main = IO.>> (IO.putStrLn (O 'h' (O 'e' (O 'l' (O 'l' (O 'o' K)))))) (IO.print (Prelude.* 6 7))
IO.ReadMode = B K (B K K)
...

(... elides the rest β€” every remaining Prelude/List/IO definition's combinator form, followed by the entire linked program on one final line. importing cached "Prelude" appears twice: IO and List both import Prelude, and the second import is served from Compile.hs's module cache rather than re-parsed β€” visible only because -v traces it.)

Runtime verbosity is graded. Level 0 is the program's own output and nothing else:

nix develop $MICROHS -c ./bin/eval out.comb > /tmp/greet-lvl0.txt
nix develop $MICROHS -c ./bin/eval -v out.comb > /tmp/greet-lvl1.txt
nix develop $MICROHS -c ./bin/eval -v -v out.comb > /tmp/greet-lvl2.txt
nix develop $MICROHS -c diff /tmp/greet-lvl0.txt /tmp/greet-lvl1.txt
2a3,7
> 
> main returns $I
> node size=16, heap size=100000
> 260 reductions, 1 GCs, max cells used 140
> 140 cells at start

Level 1 (-v) adds exactly the post-run diagnostic trailer ef082bbe gated: what main returned ($I, the identity combinator β€” IO.>>'s final result, discarded), node/heap size, and reduction/GC/cell counts. Level 2 (-v -v) adds one more thing, and it comes before the program's own output rather than after it:

nix develop $MICROHS -c diff /tmp/greet-lvl1.txt /tmp/greet-lvl2.txt
0a1
> (($IO.>> ((((($S' ($C' $IO.>>)) (($B (($B $Y) (($B ($B ($P ($IO.return $I)))) (($B ($C' $B)) ($B $IO.>>))))) $IO.putChar)) (($C $IO.putChar) '
)) $IO.stdout) (($O 'h) (($O 'e) (($O 'l) (($O 'l) (($O 'o) $K))))))) (($IO.serialize $IO.stdout) (($* 6) 7)))

That's c0fb9a91's pp(stdout, n) call: the entire loaded program graph, printed once, before evalio runs β€” the same $T/$K/$O textual form Try It exercises in earlier chapters read out of out.comb files, but here it's the in-memory graph right after loading and garbage collection, not the file on disk (the two happen to look alike because IO.serialize and pp share the same printer, per chapter 04's "What happened"). Three levels, three different questions answered: level 0, "what does the program do"; level 1, "how much work did that take"; level 2, "what does the compiled program actually look like before it runs."

Exercise 2: add one more test to the harness, in its own style

Follow 9b6e23c8+6ac9c791's pattern exactly: a module, a .ref file holding its real output, one more line in tests/Makefile.

cat > tests/ArithTest.hs <<'EOF'
-- Arithmetic test
module ArithTest where
import Prelude
import IO

main = IO.do
  print (2 + 3)
  print (10 - 4)
  print (6 * 7)
EOF

Run it once by hand to find out what it actually prints β€” the harness records real output, not predicted output:

cd tests
nix develop $MICROHS -c ../bin/uhs -i../lib ArithTest
nix develop $MICROHS -c ../bin/eval out.comb
5
6
42

Save that as the reference file:

nix develop $MICROHS -c ../bin/eval out.comb > ArithTest.ref
cat ArithTest.ref
5
6
42

Add one line to tests/Makefile, in the same style as Fac's:

	$(UHS) ArithTest && $(EVAL) out.comb > ArithTest.out && diff ArithTest.ref ArithTest.out

Run the whole harness from the top level, exactly as make test runs it for a reader who hasn't touched anything above:

cd ..
nix develop $MICROHS -c make test
ghc Main.hs -o bin/uhs
cd tests; make
../bin/uhs -i../lib IOTest && (echo q | ../bin/eval out.comb > IOTest.out) && diff IOTest.ref IOTest.out
../bin/uhs -i../lib StringTest && ../bin/eval out.comb > StringTest.out && diff StringTest.ref StringTest.out
../bin/uhs -i../lib Fac && ../bin/eval out.comb > Fac.out && diff Fac.ref Fac.out
../bin/uhs -i../lib ArithTest && ../bin/eval out.comb > ArithTest.out && diff ArithTest.ref ArithTest.out

The leading ghc Main.hs -o bin/uhs is real, and it's Exercise 1's doing, not Exercise 2's: the top-level Makefile's $(BIN)/uhs: *.hs rule matches every .hs file in the top-level directory, including Greet.hs, left there by Exercise 1. Greet.hs is newer than bin/uhs, so make reruns the link step β€” harmlessly (nothing about Main.hs or its dependencies actually changed, so the binary itself comes out identical) but visibly, on every subsequent make test too, since a relink that changes nothing doesn't update bin/uhs's own timestamp. Four tests, no diff output from any of them β€” silence is the harness saying every one of them matched its golden file, ArithTest included.

Exercise 3: classify every commit in this chapter's range

git log --stat 8c5fbb1a..8018eec8, read commit by commit, classified by what the diff actually contains rather than by the commit's own title β€” the fifteen commits from 6c59dd36 through 8018eec8. Feature/fix/cleanup/docs are one reasonable four-way split; a fifth bucket, "mixed," is used once below rather than forcing a commit that genuinely touches several of the other four into just one of them. Disagreement is invited β€” several of these could be argued either way.

CommitSubjectFiles touchedClass
6c59dd36UpdateMakefilecleanup
9b6e23c8Better names..gitignore, Main.hs, Makefile, tests/Fac.uhscleanup
73a427d7Rename files to .hs8 renames, Compile.hs, Makefile, tests/Makefilecleanup
6ac9c791Add some testing.tests/*.ref, tests/IOTest.hs, tests/Makefilefeature
110bcc94Fix minor issues..gitignore, Makefile, tests/Makefilefix
37e44acdAdd READMEREADME.mddocs
aae0eee2More text.README.mddocs
04f0840bMore text.Example.hs, README.mddocs
91a8ff34More textREADME.mddocs
0bfdcd18Fix some mistakes.README.mdfix
0b97ab3eAdd a flag to set the heap size.eval.cfeature
60343934More text.README.mddocs
3cdbb820Various small improvements.Compile.hs, Makefile, README.md, eval.c, lib/IO.hs, tests/Fac.hs, tests/Fac.ref, tests/Makefile, tests/Nfib.hsmixed
28d53dabClean upParserComb.hscleanup
8018eec8Add nulllib/List.hsfeature

9b6e23c8 is arguably itself mixed β€” most of its diff is renaming and gitignore cleanup, but it also introduces tests/Fac.uhs, genuinely new content that Habit 2's test harness later depends on; it's tabled as cleanup because that's the commit's own stated intent ("Better names.") and the larger share of its diff. 3cdbb820 is the one entry that resists a single label outright: a real bug fix (Compile.hs's module-path lookup, dots to slashes, for names it can't yet actually produce), a feature (tests/Nfib.hs, a new heavier benchmark, plus a make time target), a cleanup (lib/IO.hs's mapM moves from the fix (\ rec -> ...) idiom to plain recursive let, and tests/Fac.hs drops the nfib 35 line Nfib.hs now supersedes), and a docs addition (the README's ## Libraries paragraph) β€” four of the other four categories, in one commit (3cdbb820 --stat's own total: "9 files changed, 45 insertions(+), 18 deletions(-)").

Clean up:

cd tests && rm -f ArithTest.hs ArithTest.ref *.out *.tmp && cd ..
git worktree remove --force /tmp/mhs-37e44acd

🌐 ν•œκ΅­μ–΄λ‘œ 읽기

06 β€” Becoming Haskell

Where we are

Chapter 05 closes at 8018eec8 ("Add null"), 19:10:07 on 2023-06-02 β€” a working little language with monadic IO, a working IO.serialize/IO.deserialize pair, a three-module golden-file test harness wired into make test, and a first README.md. The module system that exists at that point is minimal: lib/Prelude.hs, lib/IO.hs, lib/List.hs, and lib/String.hs sit as four flat files, none of them carrying an export list, none of them importable with a qualifier or an as-alias, and every module's every top-level name floods into one shared unqualified symbol table β€” two imports defining the same name collide as a runtime "ambiguous" error, not something a programmer reaches for on purpose. do-notation still needs an explicit qualifier at every call site (IO.do, not do). There is no if, no list comprehensions, no operator sections. This chapter's first commit, 5f08e33b, lands seven seconds after 8018eec8 β€” the same sitting, not a new session.

What happened

The range 8018eec8..41d6a7e8 is 30 commits, and they land on four calendar days out of the six the range spans: one commit late on 2023-06-02 (a continuation of chapter 05's own evening), 22 commits in one sitting on 2023-06-03 (11:33 to 15:02, roughly three and a half hours β€” nothing on 06-04), 4 commits on 2023-06-05 (11:17 to 15:17, with a three-and-a-half-hour gap inside that day β€” nothing on 06-06), and 3 commits on 2023-06-07 (10:51 to 14:13). Then the log goes quiet: the next commit dated after 41d6a7e8 (2023-06-07 14:13:56) is 44004020 ("Small fixes."), 2023-07-01 15:41:40 β€” 24 days later, and, per this book's own overview, "[n]othing in this repo's history explains the 24-day gap β€” the log simply resumes." One more wrinkle, visible only in the commit graph, not the flat git log the overview's table reads from: 44004020 isn't actually a child of 41d6a7e8. Its own parent is 53d4bfdc (06-03 15:02:16, this chapter's next-to-last 06-03 commit) β€” a checkout that predates every commit from d5bac600 onward, missing if, list comprehensions, both file reorganizations, and the CHAR removal entirely. About six minutes after 44004020, a merge commit, d128fb6f ("Merge branch 'master' of github.com:augustss/MicroHs", also 2023-07-01), reconciles that stale branch with 41d6a7e8. Read as a graph rather than a date-sorted list, the honest picture is two checkouts that fell out of sync on 06-03 and were stitched back together on 07-01 β€” not a single line of work that simply paused for 24 days and resumed. That gap, and what resumes on the other side of it, is the next chapter's story, not this one's.

Inside the six working days that do have commits, three things happen at once: the module system grows qualification, aliasing, and required export lists; the file layout is reorganized twice to mirror Haskell's own directory conventions; and the surface syntax picks up if, operator sections, and list comprehensions. A fourth, smaller thread β€” scope checking moving into the desugarer, and Char losing its own runtime representation β€” is where the compiler's internals catch up with everything the other three threads asked of it.

The module system becomes Haskell-shaped

06-03 opens with two structural preludes before the module-system work proper starts. 35ef759d ("Refactor module desugaring.") replaces the bare (IdentModule, [LDef]) tuple chapter 03's b25d9fc8 used for a compiled module with a real Module type carrying its own export list and constructor-arity table (35ef759d:Desugar.hs line 11, quoted whole):

data Module = Module IdentModule [Export] [TypeDef] [LDef]

69437754 ("Use a map for final lookup"), fifteen minutes later, swaps Main.hs's final-linking lookup from a linear lookup over a list β€” one list scan per reference, into every earlier definition β€” to a Data.Map (69437754:Main.hs lines 21–23, quoted whole):

      defs :: M.Map Ident Exp
      defs = M.fromList [ (n, ref i) | ((n, _), i) <- zip ds' [0..] ]
      findIdent n = fromMaybe (error $ "undefined: " ++ show n) $ M.lookup n defs

Neither commit is user-visible; both are the kind of "clean the load- bearing plumbing before building more on it" move chapter 03 already named a rhythm.

Then, in quick succession: 155e9f5a ("Add 'qualified' to imports.") adds the qualified keyword, threading a Bool through ImportSpec so a qualified import's names only enter the symbol table under their dotted form, never bare (155e9f5a:Parse.hs lines 279–280, quoted whole):

  <|>            (pKeyword "import" *> (Import <$> pQua <*> pUIdent))
  where pQua = (True <$ pKeyword "qualified") <|< pure False

cfbf3c7d ("Implement 'as' in 'import' statement"), 28 minutes later, adds the alias itself β€” ImportSpec grows a third field, Maybe Ident, and pImportSpec becomes one self-contained parser instead of being spelled out inline in pDef (cfbf3c7d:Parse.hs lines 280–282, quoted whole):

pImportSpec :: P ImportSpec
pImportSpec = ImportSpec <$> pQua <*> pUIdent <*> optional (pKeyword "as" *> pUIdent)
  where pQua = (True <$ pKeyword "qualified") <|< pure False

Seven minutes after that, bd28b65c ("Avoid errors on multiple imports of the same module.") fixes a bug the previous two commits' own symbol- table plumbing exposes: when the same module is reachable by two import paths (directly, and transitively through another import), its qualified names were being inserted twice via (++), so the "ambiguous" check chapter 03 built (checkExpr's Just qis -> error "ambiguous: ") fired against a name that wasn't actually ambiguous β€” it just appeared in its own symbol-table bucket twice. The fix is switching the map-merge function from list concatenation to Data.List.union, which already dedupes (bd28b65c:Desugar.hs lines 39–44, quoted whole):

      allSyms :: SymTable
      allSyms = M.fromListWith union $ concatMap syms (imdl : mdls)
        where syms (is, Module mn qis _ _) = [ (v, [Var qi]) | (i, qi) <- qis, v <- qns is mn i ]
      allTypes :: TypeTable
      allTypes = M.fromListWith union $ concatMap types (imdl : mdls)
        where types (is, Module mn _ tds _) = [ (v, [td]) | td@(TypeDef _ cs) <- tds, (c, _) <- cs, v <- qns is mn c ]

Between cfbf3c7d and bd28b65c, two file-reorganization commits land (16c75970, 4f3b8f8d β€” covered below under "Files reorganize"), and then 7c23e672 ("Make 'do' qualifier optional.") lets do stand alone: EDo's qualifier becomes Maybe Ident instead of a required Ident, and an unqualified do looks up (>>=)/(>>) unqualified rather than demanding a module prefix at every call site β€” the change chapter 03's "Try it" exercise had to route around by hand is now built in, and Example.hs's own main drops its IO.do down to plain do in the same commit.

The last piece of the module-system thread is 1136f265 ("Require an export list."), 06-03 at 14:10:46. Before this commit module M where parses; after it, pModule demands a parenthesized export list between the module name and where, with only one kind of export item implemented β€” module M, a whole-module re-export, nothing selective (1136f265:Parse.hs lines 155–161, quoted whole):

pModule :: P EModule
pModule = EModule <$> (pKeyword "module" *> pUIdent) <*>
                      (pSym '(' *> many pExportSpec <* pSym ')') <*>
                      (pKeyword' "where" *> pBlock pDef)

pExportSpec :: P ExportSpec
pExportSpec = ExpModule <$> (pKeyword "module" *> pUIdent)

Running a module with no export list at all through the compiler (a build later in this chapter reproduces the exact transcript β€” see "Try it" Exercise 3) now fails to parse right after the module name, before where is even reached β€” module NoExport where gets rejected with expeced: '(' (the source's own spelling, kept verbatim below). The same commit updates every existing library and test module to the only form the new grammar accepts, module M(module M) where β€” a whole-module self-re-export, which is the sole way to write "export everything" until selective export lists exist. One of those mechanical edits ships with a real typo: tests/ListTest.hs gets module ListTest(Module ListTest) where β€” a capitalized Module that isn't the module keyword pExportSpec looks for, so it wouldn't have parsed. The very next commit, dba211e1 ("More tests"), three minutes later, fixes it to lowercase along with unrelated content changes to the same test β€” a small, honest reminder that a 30-commit, six-day sprint still ships the occasional bug it catches almost immediately.

Files reorganize, in commits of their own

Two separate reorganizations happen in this range, each split cleanly from any commit that changes behavior.

The first: 16c75970 ("Reorganize modules to be more like Haskell.") deletes the bodies of the flat lib/IO.hs and lib/List.hs (191 lines removed, 9 files touched, 13 insertions β€” mostly import-line updates in Desugar.hs and the test modules) and, 47 seconds later, 4f3b8f8d ("Moved files.") adds lib/Data/List.hs and lib/System/IO.hs back with that same content, now living under Haskell-shaped paths (178 lines, pure additions, two files). The pair of commits is a manual git mv β€” content deleted in one commit, recreated verbatim (module name and all) in the very next β€” rather than a single commit doing both at once.

The second reorganization is bigger and, read literally against the tree at each commit, leaves the project unbuildable for a stretch. 34c8965e ("Rearrange files."), 06-05 at 15:17:24, deletes Compile.hs, Desugar.hs, Exp.hs, Main.hs, Parse.hs, and ParserComb.hs outright β€” 975 lines of Haskell source gone, with no replacement for any of it anywhere in the same commit (the commit's --stat totals 980 deletions across 8 files; the other 5 are Makefile lines rewritten below) β€” while renaming eval.c to src/runtime/eval.c and repointing the Makefile at paths that, at that exact commit, don't exist (34c8965e:Makefile, quoted in full):

BIN=bin
.PHONY: all test time example

all:	$(BIN)/eval $(BIN)/uhs

$(BIN)/eval:	src/runtime/eval.c
	@mkdir -p bin
	gcc -Wall -O3 src/runtime/eval.c -o $(BIN)/eval

$(BIN)/uhs:	src/*/*.hs
	@mkdir -p bin
	ghc -Wall -O -isrc src/MicroHs/Main.hs -o $(BIN)/uhs

test:	$(BIN)/eval $(BIN)/uhs tests/*.hs
	cd tests; make test

time:	$(BIN)/eval $(BIN)/uhs tests/*.hs
	cd tests; make time

example:	$(BIN)/eval $(BIN)/uhs Example.hs
	$(BIN)/uhs -ilib Example && $(BIN)/eval

clean:
	rm -f src/*/*.hi src/*/*.o eval Main *.comb *.tmp *~ $(BIN)/*
	cd tests; make clean

git ls-tree -r 34c8965e --name-only confirms it directly: no file under src/MicroHs/ exists at this commit β€” the Makefile's own $(BIN)/uhs: src/*/*.hs rule has nothing to match. ac6f377c ("Make files a bit more like Haskell."), the next commit touching the tree, lands almost two days later (2023-06-07 10:51:20) and only edits lib/System/IO.hs and src/runtime/eval.c β€” the compiler sources stay missing. Only 2f89971e ("Move files."), 06-07 at 14:13:33, restores them, under src/MicroHs/*.hs and src/Text/ParserComb.hs, each file's own module line requalified to match its new path (module Compile becomes module MicroHs.Compile, and so on). But "Move files." under-describes its own diff, the same way ac6f377c (next paragraph) does, in two separate respects. First, diffing the restored files against the content 34c8965e deleted shows four of the six are indeed pure moves (module/import lines only) β€” but Desugar.hs and Exp.hs come back changed: Exp's Chr Char constructor is gone, Int Integer narrows to Int Int, and EChar c now desugars to Int (fromEnum c) β€” the compiler-side half of the CHAR removal, landing inside the "move" commit 23 seconds before 41d6a7e8 (below) deletes the runtime-side half. Second, the commit adds seven files, and the seventh, src/MicroHs/Translate.hs (78 lines), is not a moved file at all β€” it has no earlier counterpart anywhere in the project's history. It's the first draft of something new: a translator from the compiler's Exp combinator terms straight into native GHC values (trans :: Exp -> Any, built on unsafeCoerce, with a primOps table pairing each combinator name with an actual Haskell function β€” comb "S" (\ f g x -> f x (g x)), comb "Y" (let fix f = f (fix f) in fix), and so on) β€” that is, a way to run compiled programs inside GHC's own runtime instead of shipping them to eval.c (and the reason Exp's integers just became machine-width Int: Translate.hs's arithmetic table is typed Int -> Int -> Int). It's also visibly unfinished: line 52 of the committed file reads , comb "IO.h β€” an unterminated string literal, a file frozen mid-keystroke, which can't even lex. It doesn't break the build only because nothing imports it yet (2f89971e:src/MicroHs/Main.hs imports Parse/Exp/Compile, not Translate, and ghc -isrc only compiles what Main reaches). Where this thread goes is the next chapter's material; here it's a stowaway in a commit titled like pure housekeeping. Meanwhile the headline fact about the pair of commits stands: for roughly 47 hours, spanning the boundary between this chapter's 06-05 and 06-07 sessions, this repository's own history has no commit from which uhs can be built at all β€” a genuine gap in buildability, not an artifact of this book's worktree convention, and the reason this chapter's own "Try it" anchors before it (see below).

ac6f377c's own diff, landing inside that gap, is worth one aside: its title reads as pure cosmetic rename work, but the diff to lib/System/IO.hs adds real error handling β€” hGetChar and openFile now raise a proper error on EOF or a failed open instead of silently handing back a sentinel value β€” and the diff to src/runtime/eval.c fixes a genuine garbage-collector bug: scan's sweep was closing stdin/stdout/stderr as unreachable file handles whenever nothing else pointed at them from the program graph, which the commit corrects by excluding those three handles from the close-on-collect check. A commit named for tidying carries a real bug fix and a real behavior change alongside it β€” the same "commit titles don't fully describe their diffs" pattern chapter 05 flags for 3cdbb820.

Scope checking moves into desugaring

0cc677d5 ("Change desugaring to do scope checks. Add case error checks."), 06-03 at 12:30:50, is the biggest single-commit change in this thread. Before it, Compile.hs desugars a module first and then walks the already-desugared Exp tree in a separate pass, checkExpr (chapter 03's b25d9fc8), resolving each bare Var against a symbol table built from every imported module's exports. 0cc677d5 deletes that separate pass and folds its job straight into dsExpr, which now takes the symbol table (and a matching constructor-arity table) as an argument and resolves every EVar the moment it's desugared, not afterward (0cc677d5:Desugar.hs lines 53–57, quoted whole):

dsExpr syms _ (EVar i) =
  case M.lookup i syms of
    Nothing -> error $ "undefined: " ++ show i
    Just [qi] -> qi
    Just qis -> error $ "ambiguous: " ++ show i ++ ", " ++ show qis

The same commit adds the "case error checks" its message promises: a new reorderArms pass that, given a case's arms and the constructor table, checks a constructor case against the actual data declaration it belongs to β€” not just parses, but validates β€” and reorders the arms into declaration order so dsExpr's Scott-encoding case compiler (chapter 02's O/P-combinator trick) always sees them in the order the runtime expects (0cc677d5:Desugar.hs lines 92–108, quoted whole):

reorderArms :: TypeTable -> [(EPat, Expr)] -> [(EPat, Expr)]
reorderArms _ [] = error "case has no arms"
reorderArms _ as@[(PTuple _, _)] = as
reorderArms tys as@((PConstr con _, _) : _) =
  let arms = [(c, a) | a@(PConstr c _, _) <- as] in
  if length arms /= length as then error "bad tuple pattern" else
  case M.lookup con tys of
    Nothing -> error $ "undefined constructor: " ++ show con
    Just [TypeDef _ cs] -> map arm cs
      where arm (c, k) =
              case lookup c arms of
                Nothing -> error $ "constructor missing: " ++ show (c, con)
                Just a@(PConstr _ vs, _) ->
                  if length vs == k then a else error $ "bad contructor arity: " ++ show a
                _ -> undefined
    Just _tds -> error $ "ambiguous constructor: " ++ show con
reorderArms _ _ = undefined

Six distinct failure modes in one 16-line function: an empty case, a mixed tuple/constructor pattern set, an unknown constructor, a constructor present in the data declaration but missing from the case, an arm whose pattern binds the wrong number of fields for that constructor's declared arity (misspelled in the source as "contructor," kept verbatim above), and a constructor name that's ambiguous across two imported types. None of these were checked before this commit β€” a case with a typo'd constructor name, or the wrong number of bound variables, either crashed the C runtime with an unhelpful message or silently ran into undefined behavior; after 0cc677d5, all six are caught in the desugarer, at compile time, in Haskell, with a message naming what's wrong.

The Prelude splits into Data.*-style modules

3a9c0edb ("Split up Prelude") and 92960261 ("More stuff in Prelude"), 17 and 26 minutes after 1136f265, break the single lib/Prelude.hs into one file per concern and re-export all of them through Prelude. By the end of 92960261, the module map is (92960261:lib/Prelude.hs, quoted whole):

module Prelude(
  module Control.Error,
  module Data.Bool,
  module Data.Either,
  module Data.Function,
  module Data.Int,
  module Data.List,
  module Data.Maybe,
  module Data.Tuple,
  module System.IO,
  module Text.String
  ) where
import Control.Error
import Data.Bool
import Data.Either
import Data.Function
import Data.Int
import Data.List
import Data.Maybe
import Data.Tuple
import System.IO
import Text.String

Ten modules, all under hierarchical Data.*/Control.*/System.*/ Text.* prefixes, most at the exact paths where the same names live in real Haskell's base package β€” Data.Bool, Data.Either, Data.Maybe, Data.Tuple for the small ADTs and their functions; Data.Function for id/./ flip; Data.Int for arithmetic and the (soon-to-be-identity) chr/ord pair; Data.List for the list type itself and its functions; System.IO for handles and IO primitives; and two Haskell-flavored names that aren't actually base modules, Text.String for string formatting and Control.Error for error and undefined. None of this is a new feature β€” every name that moves keeps working exactly as it did in the flat file β€” but it's the module system finally being exercised at the scale it was built for: real code, split across real files, glued back together by real imports.

Surface syntax catches up

178f43d0 ("Better 'let' in 'do'") lets a do-block's let bind more than one name at once β€” Let Ident Expr becomes Let [EDef], and pStmt's let parses a whole pBlock pDef instead of one name = expr pair β€” closing a gap the previous commit's own comment had flagged ("could be better").

1a0ac4c6 ("Add operator sections."), nine minutes later, adds (e op) and (op e) as expressions β€” left and right sections. A left section, (e op) (e.g. (5 <)), fixes e as the operator's first argument, so it's just op e applied to whatever comes next β€” a plain App. A right section, (op e) (e.g. (< 5)), needs the next argument to land in the operator's first position instead, with e staying fixed in the second β€” exactly what C (chapter 03's argument-flipping combinator, C f x y = f y x) is for (1a0ac4c6:Desugar.hs lines 107–110, quoted whole):

dsExpr syms tys (ESectL e op) =
  App (dsExpr syms tys (EVar op)) (dsExpr syms tys e)
dsExpr syms tys (ESectR op e) =
  App2 CC (dsExpr syms tys (EVar op)) (dsExpr syms tys e)

with the parser side one alternative each in pAExpr (1a0ac4c6:Parse.hs lines 311–312, quoted whole):

  <|> (ESectL <$> (pSym '(' *> pExprArg) <*> (pOper <* pSym ')'))
  <|> (ESectR <$> (pSym '(' *> pOper) <*> (pExprArg <* pSym ')'))

Then a two-day break (06-03 evening to 06-05 morning). The session resumes with d5bac600 ("Some compatibility fixes.") β€” a small, unglamorous commit: Flags' path field is renamed to paths throughout Compile.hs/Main.hs, eval.c's printrec switches an INT format string from %ld to %lld, and both Makefile build rules grow @mkdir -p bin so a clean checkout's missing bin/ directory doesn't fail the build β€” the kind of small friction a second sitting turns up immediately. Eleven minutes later comes if β€” was there really no if at all before this range? A search across the entire project history for EIf (the constructor a working if would need) turns up nothing before 1baaf7b6 β€” the commit is genuinely the first time if/then/else become parseable syntax anywhere in this project's git history, not just this chapter's range. Before it, a conditional could only be written as case cond of { True -> a; False -> b }, and Example.hs's own fac uses exactly that form right up until this commit rewrites it to if n <= 0 then 1 else n * fac(n-1). 1baaf7b6 ("Add 'if'"), 06-05 at 11:28:53, adds three new keywords (if/then/else, alongside the existing list β€” 1baaf7b6:Parse.hs lines 201–203, quoted whole):

keywords :: [String]
keywords = ["case", "data", "do", "else", "if", "import",
  "in", "let", "module", "of", "primitive", "then", "where"]

and a desugaring that reuses the same case-on-Bool trick the rewritten Example.hs had been spelling out by hand β€” if compiles to an application of the (Scott-encoded) condition to its else- and then- branches, in that order (1baaf7b6:Desugar.hs lines 111–112, quoted whole):

dsExpr syms tys (EIf e1 e2 e3) =
  App2 (dsExpr syms tys e1) (dsExpr syms tys e3) (dsExpr syms tys e2)

Nineteen minutes later, 0eb369f9 ("Add list comprehensions.") adds [e | q1, q2, ...] as new pAExpr syntax, reusing the exact same EStmt type do-notation already parses (Bind, Then, Let) for its qualifiers, and desugars each of the three qualifier kinds by recursion on the rest of the comprehension (0eb369f9:Desugar.hs lines 113–119, quoted whole):

dsExpr syms tys (ECompr e []) = dsExpr syms tys (EList [e])
dsExpr syms tys (ECompr e (Bind i b : ss)) =
  App2 (Var "Data.List.concatMap") (dsExpr syms tys (ELam [i] (ECompr e ss))) (dsExpr syms tys b)
dsExpr syms tys (ECompr e (Then c : ss)) =
  dsExpr syms tys (EIf c (ECompr e ss) (EList []))
dsExpr syms tys (ECompr e (Let d : ss)) =
  dsExpr syms tys (ELet d (ECompr e ss))

Four rules, each one line: no qualifiers left ([]) is a singleton list; a generator (Bind i b) becomes concatMap (\i -> rest) b β€” the hard-coded name "Data.List.concatMap", not a re-desugared EVar lookup, so this rule depends on Data.List (or something re-exporting concatMap under that exact qualified name) being in scope wherever a comprehension is used; a guard (Then c) becomes if c then rest else [], reusing 1baaf7b6's just-added if; and a let qualifier becomes an ordinary let wrapping the rest. [x*x | x <- xs, x > 2] is, by this recipe, concatMap (\x -> if x > 2 then [x*x] else []) xs β€” textbook comprehension desugaring, built from pieces (concatMap, if, let) that all already existed by the time this commit needed them.

41d6a7e8: getting rid of CHAR

The chapter's last commit, 06-07 at 14:13:56, deletes the runtime's dedicated character representation outright β€” completing what 2f89971e, 23 seconds earlier, had already started on the compiler side by deleting Exp's Chr constructor (see "Files reorganize" above). Before it, eval.c β€” living at src/runtime/eval.c since 34c8965e's move β€” has a CHAR node tag distinct from INT, a 'c literal syntax in the runtime's own combinator-file parser, and two primitives, ORD/CHR, that convert between the two tags. 41d6a7e8 removes all of it β€” CHAR disappears from the tag enum (compare the two states directly; 41d6a7e8~1:src/runtime/eval.c line 18 still has it, 41d6a7e8:src/runtime/eval.c line 18 doesn't):

enum node_tag { FREE, IND, AP, INT, HDL, S, K, I, B, C, T, Y, SS, BB, CC, P, O,

β€” along with the '\'' literal parser case, the evalchar function, the printrec/eval/evalio cases for CHAR/ORD/CHR, and evalstring's call through evalchar (replaced with a plain evalint plus a 0 <= c <= 127 range check). On the Haskell side, chr/ord β€” previously primitive "chr"/primitive "ord", calling into the now-deleted runtime primitives β€” become plain identity functions, because a Char and its Int ordinal are now, at the runtime level, literally the same representation (41d6a7e8:lib/Data/Int.hs lines 27–28, quoted whole):

chr x = x
ord x = x

What dies is a whole node tag and its associated primitives β€” real simplification, not a rename β€” and what makes it safe to delete is that nothing above the runtime actually needed Char to be its own tag: strings were already lists of something being printed as characters (chapter 03's EStr-to-EList desugaring), and once the runtime treats that "something" as a plain integer end to end, chr/ord have nothing left to do.

Why it matters

None of the individual pieces in this chapter are large. Qualified imports are a Bool field. as is a Maybe Ident. A required export list is one new production in pModule with exactly one export-item form implemented. Operator sections are two lines of desugaring each. List comprehensions are four one-line desugaring rules that lean on concatMap and if rather than inventing new machinery. What's notable is less any single commit and more what the set, taken together, is aimed at: the module system, the Prelude's shape, and the surface syntax all move toward looking like real Haskell in the same six days, not because completeness was the goal, but because a specific, concrete target sits a few weeks out β€” getting this compiler to eventually compile a real Haskell program, and eventually, itself, once it's written in Haskell rather than in the tiny dialect the runtime speaks. (The self-hosting push is this book's next chapter's story.) Every feature this chapter adds is one that real Haskell code written the ordinary way would use without a second thought β€” if, list comprehensions, qualified imports to avoid Prelude clashes, export lists to control what a module exposes β€” and nothing here is speculative: there's still no fixity declaration mechanism, no user-defined operators, no type classes, no selective export (only whole-module re-export). YAGNI cuts both ways in this sprint: build what real code will need soon, and nothing else yet.

Notebook margin

  • "Update docs." commits land right after the user-visible change they document, not batched at the end of a session: 51a56220 ("Update docs.") follows 92960261 (the Prelude split finishing) five minutes later; 263d2f87 ("Update docs") follows 1a0ac4c6 (operator sections) three minutes later.
  • File moves get their own commits, mostly separate from commits that change behavior β€” twice in this range, at two different scales. 16c75970/4f3b8f8d split lib/IO.hs/lib/List.hs into lib/System/IO.hs/lib/Data/List.hs in a clean delete-then-recreate pair, 47 seconds apart. 34c8965e/2f89971e do the same for the compiler's own six top-level .hs files and ParserComb.hs, moving them under src/MicroHs//src/Text/ β€” except this time the "moved files" commit isn't the very next commit (it's two calendar days and one unrelated commit, ac6f377c, later, and the repository genuinely has no compiler source tree in between), and it isn't purely a move either: 2f89971e smuggles in the brand-new, half-finished Translate.hs alongside the six restored files (see "Files reorganize"). The discipline is real but not absolute β€” worth knowing before trusting any commit title over its diff.
  • The rhythm across the whole range is sprint-then-gap at two nested scales: within the six working days, commits cluster tightly (22 in one afternoon on 06-03) and then jump a day (06-04, 06-06 both idle); across the whole book so far, this chapter's close is followed by the 24-day silence the overview's own timeline table calls out before chapter 07 resumes on 07-01.

Try it

Anchor: 0eb369f9 ("Add list comprehensions."), 2023-06-05 11:48:09 β€” chosen deliberately from before 34c8965e's file move, so the compiler sources this chapter's Build note patches are still at the top level, the same layout chapters 02–05 already build from. (Checking out anything between 34c8965e and 2f89971e instead would leave you with no Compile.hs/Desugar.hs/Exp.hs/Main.hs/Parse.hs/ ParserComb.hs to build at all β€” see "Files reorganize," above.) Convention (see 00-overview.md):

git worktree add /tmp/mhs-0eb369f9 0eb369f9
cd /tmp/mhs-0eb369f9

Build note: this commit's Makefile still builds bin/uhs from *.hs at the top level and bin/eval from eval.c, same shape as chapter 05's anchor. Building as written hits the same two GHC-9.8/ mtl gaps chapters 02–05 hit, unchanged in this range:

mkdir -p bin
nix develop $MICROHS -c ghc Main.hs -o bin/uhs

fails first with ParserComb.hs's ap-not-in-scope error; add, after ParserComb.hs's existing import Control.Monad.State.Strict:

import Control.Monad(ap)

then with Compile.hs's when-not-in-scope error (five call sites this time, Compile.hs having grown since chapter 05's anchor β€” all five are the same error, only the first is shown):

Compile.hs:33:7: error: [GHC-88464]
    Variable not in scope: when :: Bool -> a5 -> StateT Cache IO a6
    Suggested fix:
      Perhaps use data constructor β€˜Then’ (imported from Parse)
   |
33 |       when (nm `elem` ws) $
   |       ^^^^

add, after Compile.hs's existing import Control.Monad.State.Strict:

import Control.Monad(when)

With both one-line patches, the build succeeds (harmless -Wx-partial warnings about head/tail omitted):

[1 of 7] Compiling ParserComb       ( ParserComb.hs, ParserComb.o )
[2 of 7] Compiling Parse            ( Parse.hs, Parse.o )
[3 of 7] Compiling Exp              ( Exp.hs, Exp.o )
[4 of 7] Compiling Desugar          ( Desugar.hs, Desugar.o )
[5 of 7] Compiling Compile          ( Compile.hs, Compile.o )
[6 of 7] Compiling Main             ( Main.hs, Main.o )
[7 of 7] Linking bin/uhs

Build the runtime the same way (one harmless -Wformat warning about %lld vs. value_t omitted):

nix develop $MICROHS -c gcc -Wall -O3 eval.c -o bin/eval

Every bin/uhs invocation below needs -ilib to find Prelude and the other library modules under lib/.

Exercise 1: export list, qualified import, and shadowing made visible

M1 exports everything via the only export-list form this commit's parser accepts (module M(module M) where β€” see "What happened," 1136f265), and defines double:

cat > M1.hs <<'EOF'
module M1(module M1) where
import Prelude

double :: Int -> Int
double x = x + x
EOF

M2 imports M1 qualified and defines its own double, a name that would otherwise collide:

cat > M2.hs <<'EOF'
module M2(module M2) where
import Prelude
import qualified M1

double :: Int -> Int
double x = x * 3

main = do
  print (double 5)
  print (M1.double 5)
EOF
nix develop $MICROHS -c ./bin/uhs -ilib M2
nix develop $MICROHS -c ./bin/eval
15
10

double 5 resolves to M2's own definition (5 * 3 = 15); M1.double 5 reaches the qualified-only import explicitly (5 + 5 = 10). The qualified keyword is what makes this shadowing safe: M1's double never enters the unqualified symbol table at all (155e9f5a's qns rule β€” qualified imports contribute only the dotted name), so there's nothing for M2's own double to collide with. Confirm the collision is real by dropping qualified and rebuilding the same program under a new name:

cat > M2plain.hs <<'EOF'
module M2plain(module M2plain) where
import Prelude
import M1

double :: Int -> Int
double x = x * 3

main = do
  print (double 5)
  print (M1.double 5)
EOF
nix develop $MICROHS -c ./bin/uhs -ilib M2plain
uhs: ambiguous: "double", [Var "M1.double",Var "M2plain.double"]
CallStack (from HasCallStack):
  error, called at ./Desugar.hs:73:17 in main:Desugar

Same two modules, same two names, and the only thing that changed is one keyword β€” 0cc677d5's scope-checking EVar case (see "What happened") is what raises this, the instant the unqualified double is looked up in M2plain.main and finds two candidates instead of one.

Exercise 2: a guarded list comprehension, desugared

cat > Compr.hs <<'EOF'
module Compr(module Compr) where
import Prelude

main = do
  putStrLn $ showList showInt [ x*x | x <- [1,2,3,4,5], x > 2 ]
EOF
nix develop $MICROHS -c ./bin/uhs -v -ilib Compr

The -v dump (chapter 05's verbosity flag) prints every compiled definition; grepping for Compr.main finds the one line this exercise cares about:

Compr.main = ((Data.Function.$ System.IO.putStrLn) ((Text.String.showList Text.String.showInt) ((Data.List.concatMap (($S (($C (($C Data.Int.>) 2)) $K)) ((($C' $O) (($S Data.Int.*) $I)) $K))) (($O 1) (($O 2) (($O 3) (($O 4) (($O 5) $K))))))))

Data.List.concatMap sitting right there as a Var reference β€” not bracket-abstracted away, because it's a call to a library function, not an inlined lambda β€” is 0eb369f9's Bind rule made visible: [x*x | x <- [1..5], x > 2] compiles through concatMap (\x -> ...) [1,2,3,4,5] exactly as the diff's second rule says. To isolate what the guard itself contributes, compile the same comprehension with the guard removed and diff the two compiled forms:

cat > ComprNoGuard.hs <<'EOF'
module ComprNoGuard(module ComprNoGuard) where
import Prelude

main = do
  putStrLn $ showList showInt [ x*x | x <- [1,2,3,4,5] ]
EOF
nix develop $MICROHS -c ./bin/uhs -v -ilib ComprNoGuard
ComprNoGuard.main = ((Data.Function.$ System.IO.putStrLn) ((Text.String.showList Text.String.showInt) ((Data.List.concatMap ((($C' $O) (($S Data.Int.*) $I)) $K)) (($O 1) (($O 2) (($O 3) (($O 4) (($O 5) $K))))))))

The no-guard body, ((($C' $O) (($S Data.Int.*) $I)) $K) β€” the compiled \x -> [x*x] β€” appears byte-for-byte inside the guarded version too, wrapped in one extra layer, $S (($C (($C Data.Int.>) 2)) $K) (...). That extra layer is 0eb369f9's third rule, if x > 2 then [x*x] else [], compiled: the guard adds exactly one wrapping combinator around the same lambda the ungated comprehension already produces, nothing more. Run it to confirm the values:

nix develop $MICROHS -c ./bin/eval
[9,16,25]

3^2, 4^2, 5^2 β€” 1 and 2 are filtered out by the x > 2 guard, as expected.

Exercise 3: the required export list, rejected

cat > NoExport.hs <<'EOF'
module NoExport where
import Prelude

main = putStrLn "hi"
EOF
nix develop $MICROHS -c ./bin/uhs -ilib NoExport
uhs: "NoExport.hs": line 1, col 16:
   found: "where"
   expeced: white-space
   expeced: '('

CallStack (from HasCallStack):
  error, called at ./Parse.hs:124:17 in main:Parse

Column 16 is formatFailed's 0-based offset for the 16 characters already consumed, "module NoExport " β€” the position where starts at in module NoExport where. pModule (1136f265:Parse.hs, quoted in "What happened") requires a ( right after the module name, and there isn't one. The filename prefix on the error ("NoExport.hs":) is itself a small feature from earlier the same day: 2d818f30 ("Better error message.") threads the source filename through ParserComb.hs's formatFailed, so a failure in any imported module β€” not just the one named on the command line β€” says which file it came from.

Clean up:

git worktree remove --force /tmp/mhs-0eb369f9

🌐 ν•œκ΅­μ–΄λ‘œ 읽기

07 β€” The self-hosting push

Where we are

Chapter 06 closes at 41d6a7e8, 2023-06-07 14:13:56 β€” the runtime's dedicated CHAR tag gone, the module system Haskell-shaped (qualified imports, as-aliases, required export lists), the surface syntax carrying if, operator sections, and list comprehensions. As that chapter's own "Why it matters" put it, none of it was speculative: it was aimed at "getting this compiler to eventually compile a real Haskell program, and eventually, itself, once it's written in Haskell rather than in the tiny dialect the runtime speaks." Chapter 06 also disclosed a stowaway inside a commit titled like pure housekeeping: 2f89971e ("Move files.") smuggled in src/MicroHs/Translate.hs, a first, unfinished draft of a translator from the compiler's own Exp combinator terms into native GHC values via unsafeCoerce β€” frozen mid-keystroke, an unterminated string literal on its last line, not imported by anything yet.

Then the log goes quiet for 24 days. Nothing in this repository's history explains the gap (see 00-overview.md's own timeline table); the next commit after 41d6a7e8 is 44004020 ("Small fixes."), 2023-07-01 15:41:40, followed six minutes later by a merge commit, d128fb6f, reconciling a stale branch that had fallen out of sync back on 06-03 β€” the same two-checkouts pattern chapter 06's own "Where we are" describes for its own opening, not something new to re-litigate here.

What does change, the moment the log resumes, is the goal. Chapter 06 was about the compiler resembling Haskell β€” modules, a Prelude, comprehensions. This chapter is about the compiler becoming a legal program written in the dialect it itself accepts: no type classes, no deriving, no fixity declarations, nothing GHC would compile without a comment doing the work instead. The chicken-and-egg mechanics of why that specific, narrower goal matters β€” and the concrete ladder of make targets MicroHs uses to prove it, once chapter 08 finishes wiring them up β€” are this book's new aux doc, bootstrapping-a-compiler.md; read it alongside this chapter's "What happened" below, which walks the same July grind chronologically.

What happened

The range 41d6a7e8..a824a175 is 90 commits (one of them, d128fb6f, a merge) landing on eleven calendar days spread across eighteen: 07-01 through 07-03 and 07-05 through 07-07 in one cluster, then a lone commit on 07-11, then 07-15 through 07-18 closing it out β€” a shape with its own internal gaps, not a steady eighteen-day burn.

The return: compiling with a local lib

The first evening back, 07-01, opens short and mechanical: 44004020 ("Small fixes.") adds -package mtl directly to the ghc ... -o $(BIN)/uhs build line and folds bin/* into clean; six minutes later, d128fb6f merges in the stale branch mentioned above.

The real re-launch follows fifty-two minutes after that, still the same evening. ff3837b9 ("Allow ghc compilation with local lib.") adds a brand-new top-level directory, ghc/, alongside a matching lib/Primitives.hs β€” two implementations of the same interface, one per audience. lib/Primitives.hs is the dialect uhs itself reads, built on the primitive "..." FFI-style calls the runtime already understands (ff3837b9:lib/Primitives.hs lines 3–4, quoted):

primIntAdd  = primitive "+"
primIntSub  = primitive "-"

ghc/Primitives.hs is the same names, this time real GHC functions over GHC's own Int and IO, for when GHC compiles the library directly (ff3837b9:ghc/Primitives.hs lines 10–11, quoted):

primIntAdd :: Int -> Int -> Int
primIntAdd = (+)

Every function lib/'s modules call through Primitives now has two bodies, chosen by which compiler's search path finds it first. Three minutes later, 2a95e71e ("Allow compilation with local lib.") wires a new Makefile target (a.out, later renamed) that compiles the whole lib/ tree plus Main.hs under GHC against the ghc/ shim instead of GHC's own base β€” this is the "local lib" build the aux doc's bootboottest traces back to. The same commit finally finishes what 2f89971e left mid-keystroke back in chapter 06: Translate.hs's comb "IO.h β€” an unterminated string literal that couldn't even lex β€” becomes a real, if still partial, primitive-ops table (2a95e71e:src/MicroHs/Translate.hs lines 60–67, quoted whole):

  , comb "IO.>>=" iobind
  , comb "IO.>>" iothen
  , comb "IO.return" ioret
--  , comb "IO.getChar" getc
  , comb "IO.putChar" putc
  , comb "IO.stdin" stdin
  , comb "IO.stdout" stdout
  , comb "IO.stderr" stderr

An hour and fourteen minutes later, fcde46f4 ("Make it compile.") is a four-line Makefile fix β€” a stray $(BOOTDIR) missing from GHCFLAGS's include path β€” restoring the a.out target's own buildability, nothing more than its title claims. The evening closes with two small, honestly-titled commits: 27df3d65 ("Skip {- -} comments. Fix bugs.") and, forty-one seconds later, fe2ea98a ("More ignore"), a nested-comment parsing fix and a .gitignore entry. 81683158 ("Better Makefile") follows the next day.

"Make it compile with uhs"

Two days after that first evening, on 07-03, a four-commit run of small refactors (688dfe52 "More functions.", 4e2bb772 "Refactor symbol table handling.", 50e30797 "Refactor symbol table creation.", a3302100 "Make module definitions shadow imports.") leads into the first commit actually titled for the new goal: d6c25f00 ("Make ParserComb compile with uhs."), 408 lines changed across ParserComb.hs and Parse.hs. The Functor/Applicative/Monad/Alternative instances chapter 02's own aux doc, parser-combinators.md, walks in detail are gone; in their place, plain top-level functions with the same names and the class methods' old bodies, newtype downgraded to data because uhs doesn't have newtype either, and the one place a real Show instance existed now sits as a dead comment (d6c25f00:src/Text/ParserComb.hs lines 65–70, quoted whole):

data Prsr s a = P ((String, s) -> Res s a)
runP :: Prsr s a -> ((String, s) -> Res s a)
runP pp =
  case pp of
    P p -> p
--instance Show (Prsr s a) where show _ = "<<Prsr>>"

The monadic bind itself moves from an instance Monad method to a free function of the same name, and its list-comprehension pattern binder ((a, u) <- aus) becomes an explicit let-destructuring inside a generator clause instead β€” uhs's comprehension desugaring at this point only ever binds one name at a time straight from a lambda argument, not a tuple pattern mid-comprehension (d6c25f00:src/Text/ParserComb.hs lines 75–82, quoted whole):

--Xinfixl 1 >>=
(>>=) :: Prsr s a -> (a -> Prsr s b) -> Prsr s b
(>>=) p k = P $ \ t ->
    case runP p t of
      Many aus plf ->
        let { xss = [ runP (k a) u | au <- aus, let { (a, u) = au } ] }
        in  case unzip [ (rs, lf) | xs <- xss, let { Many rs lf = xs } ] of
              (rss, lfs) -> Many (concat rss) (longests $ plf : lfs)

The --Xinfixl 1 >>= comment above it is the marker the aux doc explains in full: invisible to uhs, uncommented into a real infixl declaration for GHC by the convertX.sh preprocessor. Two days later, f37b389f ("Make it compile with uhs", 397 lines in Parse.hs) repeats the same treatment on the parser proper β€” every deriving (Show) becomes --Xderiving (Show), and elem/== calls that leaned on Eq get replaced by the explicit, monomorphic elemBy/eqString helpers src/Compat.hs supplies for exactly this purpose.

Later that same 07-05, bd8a47d7 ("Make the Exp module compile with uhs.") does the same to Exp.hs, and this time the commit says the quiet part out loud in its own body: "This makes it quite ugly. :(". The PatternSynonyms extension Exp.hs used since chapter 02 (pattern App2 x y z = App (App x y) z, and friends) is commented out wholesale, and every place that leaned on those patterns for readable multi-clause matches grows an explicit helper that returns an ordinary sum type and a case over it (bd8a47d7:src/MicroHs/Exp.hs lines 19–28, quoted whole):

data MaybeApp = NotApp | IsApp Exp Exp

getApp :: Exp -> MaybeApp
getApp ae =
  case ae of
    Var _   -> NotApp
    App f a -> IsApp f a
    Lam _ _ -> NotApp
    Int _   -> NotApp
    Prim _  -> NotApp

cS, the S-combinator constructor that chapter 02's optimizer built out of six terse pattern-matched equations (bd8a47d7:src/MicroHs/Exp.hs lines 189–194 show the old form, still present in the same diff as dead code below the new one), is rewritten around getApp and isPrim calls into three separate functions (cS/cS2/cS3, roughly 47 lines combined) chained by nested if/case β€” same optimization, far more code, because pattern-matching on PatternSynonyms constructors is gone and there's no other way left to ask "is this expression an application of K" without spelling out the whole case analysis by hand. Three commits, one throughline: nothing here changes what the compiler does β€” every rewrite is behavior- preserving β€” only what vocabulary it's allowed to use to say it.

515664d1: wrapping IO so unsafeCoerce behaves

Later the same day d6c25f00 lands β€” the very next commit, nine hours on, at 21:28 that evening β€” 515664d1 ("Make unsafeCoerce work by wrapping IO in a data constructor.") fixes a narrower problem: Translate.hs's trans function turns every compiled combinator term into a GHC value via unsafeCoerce :: Exp -> Any, and GHC's optimizer is free to treat a bare, coerced IO a action differently from an ordinary value β€” nothing forces it to actually stay an unevaluated IO action across the coercion boundary. The fix is a one-constructor wrapper that gives GHC something concrete to hold onto (515664d1:src/MicroHs/Translate.hs line 14, quoted, alongside its use seven lines later):

data DIO a = DIO { unDIO :: IO a }
  in  unDIO $ unsafeCoerce $ look mainName

"Running IO still doesn't work. :("

Fifteen minutes after 515664d1, 6f08f029 carries the frustration straight into its own commit title: "Running IO still doesn't work. :(". The diff is not, despite the title, a shrug β€” it's a real fix to the three functions 515664d1's new DIO wrapper had left half-done. Before this commit, iobind a k = unDIO a >>= k tries to bind a plain GHC IO action's result straight into a continuation typed a -> DIO b, which doesn't type-check as written; 6f08f029 corrects all three combinators to unwrap and rewrap DIO consistently (6f08f029:src/MicroHs/Translate.hs lines 79–84, quoted whole):

    iobind :: DIO a -> (a -> DIO b) -> DIO b
    iobind a k = DIO (unDIO a >>= \ x -> unDIO (k x))
    iothen :: DIO a -> DIO b -> DIO b
    iothen a b = DIO (unDIO a >> unDIO b)
    ioret :: a -> DIO a
    ioret a = DIO (return a)

A real correctness fix, landing under a title that reads like defeat β€” the sad face is honest about the overall state (something downstream still doesn't run), not about this particular diff's quality.

Whatever was still broken after 6f08f029 waits until the next session: nothing lands 07-04, and the next sixteen commits β€” everything from 67715b16 ("Add 'not'") through c275f468 ("More testing") β€” all carry timestamps between 12:22:03 and 12:58:43 on 07-05, a 36-minute window two days later. Inside that window sits b808bc73 ("Fix IO primitives."), which is where the actual IO fix lives. The bug is a convention mismatch across the primitives boundary. lib/System/IO.hs's hGetChar wrapper reads a character through P.primHGetChar and checks for an end-of-file sentinel (1abb181e:lib/System/IO.hs lines 25–30, quoted whole β€” 1abb181e is b808bc73's parent):

hGetChar :: Handle -> IO Char
hGetChar h = do
  c <- P.primHGetChar h
  case ord c == negate 1 of
    False -> return c
    True  -> error "hGetChar: EOF"

Under uhs's own runtime that convention works: ord is the identity function (chapter 06's CHAR removal made it so), and eval.c's IO.getChar really does hand back -1 at end of file. But ghc/Primitives.hs's shim had simply aliased GHC's own functions β€” and left two others unwritten (1abb181e:ghc/Primitives.hs lines 66–69, quoted whole):

primHPutChar      = hPutChar
primHGetChar      = hGetChar
primOpenFile      = undefined
primIsNullHandle  = undefined

GHC's hGetChar never returns a sentinel β€” it throws an exception at end of file β€” so the wrapper's ord c == negate 1 branch could never fire under the GHC build (no Char's ord is negative there), and primOpenFile had been undefined since ff3837b9 first wrote it on 07-01. The code type-checks fine on both paths; it's the semantics of the shim that don't match the runtime's contract. b808bc73 makes the shim honor the same convention the uhs runtime speaks β€” Int character codes with -1 at EOF, checked explicitly via hIsEOF (b808bc73:ghc/Primitives.hs lines 66–71, quoted whole):

primHPutChar     :: Handle -> Int -> IO ()
primHPutChar h c  = hPutChar h (toEnum c)
primHGetChar     :: Handle -> IO Int
primHGetChar h    = do eof <- hIsEOF h; if eof then pure (-1) else fromEnum <$> hGetChar h
primOpenFile     :: String -> Int -> IO Handle
primOpenFile s m  = openFile s (case m of 0->ReadMode; 1->WriteMode; 2->AppendMode; 3->ReadWriteMode)

and, in the same commit, adjusts lib/System/IO.hs's wrappers to match: the sentinel test becomes c == negate 1 on the raw Int, with an explicit chr/ord conversion at the boundary β€” b808bc73:lib/System/IO.hs line 31, quoted whole: hPutChar h c = P.primHPutChar h (ord c). Unlike 6f08f029, this title matches its diff exactly β€” it fixes IO primitives, on both sides of the boundary, and nothing else is smuggled in.

The diet

Inside that same 07-05 burst, three more threads close out the "diet" the aux doc names directly. a721e046 ("Use Int instead of Integer") narrows Expr's EInt field from Integer to Int (a721e046:src/MicroHs/Parse.hs line 49: | EInt Int) and drops the now-unneeded fromInteger call in the desugarer (a721e046:src/MicroHs/Desugar.hs line 104: dsExpr _ (EInt i) = Int i); 2ff2a2e9 ("More s/Integer/Int/") mops up one remaining Parse.hs site the same day. f113210f ("Use our own Map.") replaces qualified Data.Map with a new src/MicroHs/Map.hs β€” a plain association-list wrapper that takes its equality test as an ordinary function argument rather than an Ord constraint, candid about its own limits in a comment beside the type (f113210f:src/MicroHs/Map.hs lines 1–33, quoted whole):

module MicroHs.Map(module MicroHs.Map) where
import Prelude
--Ximport Compat

-- This is a pretty bad implementation.
data Map k v = Map [(k, v)]
  --Xderiving(Show)

insert :: k -> v -> Map k v -> Map k v
insert k v (Map kvs) = Map ((k, v):kvs)

fromListWith :: (k -> k -> Bool) -> (v -> v -> v) -> [(k, v)] -> Map k v
fromListWith eq un =
  let
    ins ikv akvs =
      case akvs of
        [] -> [ikv]
        kv : kvs ->
          if eq (fst ikv) (fst kv) then
            (fst ikv, un (snd ikv) (snd kv)) : kvs
          else
            kv : ins ikv kvs
  in
     Map . foldr ins []

fromList :: [(k, v)] -> Map k v
fromList = Map

union :: Map k v -> Map k v -> Map k v
union (Map kvs1) (Map kvs2) = Map (kvs1 ++ kvs2)

lookup :: (k -> k -> Bool) -> k -> Map k v -> Maybe v
lookup eq k (Map kvs) = lookupBy eq k kvs

And fixity: three commits inside the same burst β€” edfb9bde ("Add fixties"), 1200d1a3/56ad54ae (both "--Yinfix") β€” sprinkle --Yinfixr/--Yinfixl/--Yinfix comments through lib/Data/Bool.hs, lib/Data/Int.hs, and lib/Data/Function.hs (edfb9bde:lib/Data/Bool.hs lines 7–8, quoted):

--Yinfixr 2 ||
(||) :: Bool -> Bool -> Bool

These are not read by uhs at all β€” its own parser resolves every operator's precedence from a fixed, hand-written ladder in pExpr (unchanged by any commit in this whole range), not from declarations in the library source. The --Y marker exists purely so the other GHC build β€” the "local lib" path 2a95e71e set up, which compiles lib/Data/Bool.hs directly under GHC via the convertY.sh preprocessor β€” sees a real infixr 2 || and parses (||)'s own definition and every call site the way the library author intended. The mechanism in full, including why two separate markers (--X/--Y) exist for two separate GHC builds, is the aux doc's subject, not repeated here.

59b8b1ab: a rename that isn't just a rename

Still inside the same 16-commit, 36-minute burst (67715b16 through c275f468, above), 59b8b1ab lands at 12:57:39 under the title "Fix name" β€” no trailing period. It does contain a rename β€” lib/PreludeNOIO.hs becomes lib/PreludeNoIO.hs, fixing an inconsistent capitalization. But the same commit also adds three files the title never mentions: A.hs (9 lines, testing operator sections against +/-), Test.hs (39 lines, an early ancestor of what later becomes the project's throwaway Main.hs scratch harness), and t.hs (2 lines, a fixture Test.hs reads). Fifty lines of new Haskell, three new files, under a two-word title about one file's casing.

8ab69cb0: a squash! that never got squashed

An hour and nine minutes later, in the next cluster that same afternoon (13:22:18–14:07:11), 46c4309f ("infixr 9 .") adds one more --Yinfixr 9 . comment to Data.Function, 14:07:00. Eleven seconds after it, 8ab69cb0's commit title is squash! infixr 9 . β€” Git's own convention for a commit meant to be folded into an earlier one (here, 46c4309f) by git rebase --autosquash. That rebase never ran: 8ab69cb0 sits in this repository's history as its own commit, forever, eleven seconds after the commit it was named to merge into.

Its diff has nothing to do with infixr 9 . at all. Six files change: a new alltest Makefile target; a new enumFrom in lib/Data/List.hs; two new helpers in src/Compat.hs; four dead --X comment lines removed from src/Text/ParserComb.hs; a deriving-stripped, case- rewritten Exp.hs (the same "make it compile with uhs" treatment bd8a47d7 gives the rest of the file two commits later); and, in src/MicroHs/Main.hs, the Lbl Int Exp constructor's last use quietly disappears β€” the labeled-definition wrapper App2 CT (Lbl i (subst e)) r becomes a hand-built string, with the old call left behind as a dead comment right next to its replacement (8ab69cb0:src/MicroHs/Main.hs lines 31–33, quoted whole):

      def :: ((Ident, Exp), Int) -> String -> String
      def ((_, e), i) r = "(($T :" ++ showInt i ++ " " ++ toStringP (subst e) ++ ") " ++ r ++ ")"
        -- App2 CT (Lbl i (subst e)) r

A whole data constructor retired, in a commit whose title names an unrelated one-line fixity comment it was never even merged into β€” the same lesson chapter 06 drew from 2f89971e's stowaway Translate.hs: read the diff, not just the subject line.

The last two weeks

The range closes with a longer, more scattered stretch: 07-06 and 07-07 bring a getArgs preparation and a pattern-matching cleanup, 6b5741e1 ("Get rid of PTuple constructor."); then four quiet days, one lone commit breaking them, 4ae4fd20 ("Add copyright") on 07-11, adding a BSD-style LICENSE file, and four quiet days more before 07-15 resumes with a358eaa6 ("Add copyright.") stamping a two-line header onto 27 source files, followed 35 minutes later by 52ee83e2 ("Start of complex patterns.") β€” pattern matching resuming after the 07-07 cleanup β€” and 28 minutes after that by d514d2d9 swapping the whole project to the Apache license instead β€” copyright housekeeping and pattern-matching work interleaved the same afternoon. 07-16 and 07-17 grind through more of the "make it compile with uhs" series by name (a36ca755 "Make Desugar compile with uhs", 7e7c18d1 "Make Compile module work with uhs", 5e462798 "Make Main work with uhs."), getArgs support, and β€” honestly titled, no surprises in the diff β€” e61ea002 ("A hack to make openFile compatible with uhs."). The range's last commit, a824a175 ("Check for stack overflow."), 07-18 15:04:09, replaces the C runtime's fixed-size stack array with a malloc'd one sized by a new -K flag β€” the same flag the aux doc's bootcombtest target invokes (-K1000000) to survive interpreting the compiler's own bulky combinator file. What that target actually running looks like is chapter 08's story.

Why it matters

Self-hosting is a forcing function with an unusually sharp feedback signal: a gap in the compiler's own feature set doesn't show up as a missing test case someone has to think to write, it shows up as "my own compiler's source won't compile" the moment that gap sits between the compiler and itself. Every thread this chapter walks β€” no type classes, no deriving, a hand-rolled Map, Int instead of Integer, fixity resolved by a fixed parser ladder instead of declarations β€” is something the compiler's own source needed and didn't have, discovered one "make it compile with uhs" commit at a time rather than planned in advance. The TODO list writes itself: point the compiler at its own three-thousand-odd lines (src//lib/ combined, .hs files only, at this range's close) and see what breaks.

Notebook margin

  • "More functions." (and its unpunctuated twin, "More functions") opens 13 separate commits across this range's 90 β€” the grind's own rhythm, the same small, load-bearing filler chapter 06 first named.
  • 8ab69cb0's title, squash! infixr 9 ., is Git's own convention for "please fold me into the commit with this subject via git rebase --autosquash" β€” a request nobody ever ran. It's still sitting there, its own permanent commit, eleven seconds after the commit it asked to disappear into.
  • The 16 commits from 67715b16 through c275f468 all carry timestamps inside one 36-minute window on 07-05 (12:22:03–12:58:43) β€” the tightest cluster in this chapter's range, and the one that happens to contain both the real IO fix (b808bc73) and one of the two undisclosed-extras commits above (59b8b1ab, at 12:57:39). The other, 8ab69cb0, sits in the very next cluster that same afternoon (13:22:18–14:07:11).
  • The copyright thread took two license files four days apart: a BSD-style LICENSE on 07-11 (4ae4fd20), then an Apache-license swap on 07-15 (d514d2d9), 64 minutes after 27 files got copyright headers stamped on them by a358eaa6.

Try it

Anchor: f113210f ("Use our own Map."), 2023-07-05 20:55:37 β€” chosen because it's the last commit of that day's third, late-evening session (6c9d021f through f113210f, 19:34–20:55), sitting just after bd8a47d7's Exp-module rewrite and before the next day's getArgs work, and because β€” checked directly, not assumed β€” it builds cleanly as written. Convention (see 00-overview.md):

git worktree add /tmp/mhs-f113210f f113210f
cd /tmp/mhs-f113210f

This commit's Makefile builds $(BIN)/uhs from src/*/*.hs through the convertX.sh preprocessor described in the aux doc, and $(BIN)/eval from src/runtime/eval.c β€” both built and run below exactly as written, no patch needed:

mkdir -p bin
nix develop $MICROHS -c make bin/uhs
[...]
[10 of 11] Compiling Main             ( src/MicroHs/Main.hs, ghc-out/Main.o )
[11 of 11] Linking bin/uhs

(a run of -Wx-partial warnings about head/tail, one per call site, omitted β€” the same harmless category earlier chapters' Build notes already flag).

nix develop $MICROHS -c make bin/eval
gcc -Wall -O3 src/runtime/eval.c -o bin/eval

Exercise 1: run the ghc-built uhs on one of its own source modules

This commit's top-level Main.hs is the project's own scratch harness β€” it imports MicroHs.Parse, one of the compiler's real source modules under src/MicroHs/, and re-parses a tiny fixture file, t.hs, through it. Compiling it needs both -ilib (for Prelude) and -isrc (for MicroHs.Parse itself) β€” exactly the Makefile's own uhstest target:

nix develop $MICROHS -c ./bin/uhs -ilib -isrc Main

This exits silently and writes out.comb. A trimmed look at the file (it's one line, 32590 bytes on this build; head and tail shown, ... marking everything omitted between them):

(($T :0 _198) (($T :1 (_0 (($O 117) (($O 110) (($O 100) (($O 101) (($O 102) ...
...
))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))

Running it through bin/eval confirms the whole pipeline β€” GHC-built uhs, compiling a module that itself imports a real compiler source file, producing a .comb file eval can run β€” actually works at this commit:

nix develop $MICROHS -c ./bin/eval
Start
File read: t.hs

module T(
module T

) where

That's Main.hs's own putStrLns ("Start", then the file-read message) followed by showEModule's rendering of t.hs's one-line module, module T(module T) where β€” parsed by MicroHs.Parse, compiled by a GHC-built uhs, run by eval.

Exercise 2: what typeclass usage survives in src/

nix develop $MICROHS -c grep -rn "class \|instance " src/
src/Text/ParserComb.hs:67:--instance Show (Prsr s a) where show _ = "<<Prsr>>"

One hit, across every .hs file under src/MicroHs/ and src/Text/ParserComb.hs combined β€” and it's commented out, the fossil d6c25f00 left behind when it rewrote ParserComb.hs's instances into plain functions (quoted in "What happened," above). Zero live class declarations, zero live instance declarations anywhere in the compiler's own source at this commit. The same search over lib/ (the library uhs itself compiles programs against) comes back empty too β€” not even a dead comment there.

Exercise 3: what convertX.sh/convertY.sh paper over

cat convertX.sh
cat convertY.sh
#!/bin/sh
( echo "{-# LINE 1 \"$1\" #-}" ; sed -e 's/--X//' $2 ) > $3
#!/bin/sh
( echo "{-# LINE 1 \"$1\" #-}" ; sed -e 's/--Y//' $2 ) > $3

Two lines each, and identical but for one character: strip a marker (--X or --Y) out of every line of the file, after stamping a {-# LINE #-} pragma so GHC's own error messages still point at the original file and line number. The gap they paper over is everything GHC's Haskell needs that uhs's Haskell doesn't have a way to say: real hiding clauses on import Prelude (so GHC's own Monad/Applicative methods don't collide with this project's hand-rolled >>=/pure), real infixl/infixr/infix declarations (so GHC's parser gives operators like (||) the precedence their definition assumes), real deriving (Show) clauses. Every one of those, in this chapter's source, is written as an ordinary two-character-prefixed comment β€” invisible to uhs's own parser, which just sees -- and stops reading β€” and turned back into live code only when one of these two scripts runs as GHC's own preprocessor first.

Clean up:

git worktree remove --force /tmp/mhs-f113210f

🌐 ν•œκ΅­μ–΄λ‘œ 읽기

08 β€” Bootstrap

Where we are

Chapter 07 closes at a824a175 ("Check for stack overflow."), 2023-07-18 15:04:09 β€” the compiler's own source has been squeezed, module by module, into the narrow dialect it can read itself: no type classes, no deriving, no fixity declarations, a hand-rolled Map, Int in place of Integer. That last commit replaces the C runtime's fixed-size interpreter stack array with a malloc'd one, sized by a new -K flag. Three minutes and twenty seconds later, 456ce17c lands. The ladder those two commits and everything before them were built for β€” GHC compiles the compiler, the compiler compiles itself, the C runtime runs that self-compile, and a byte-for-byte cmp checks the two results agree β€” is aux/bootstrapping-a-compiler.md's subject in full; read it first. This chapter narrates what happens the moment that ladder gets make targets of its own and someone actually climbs it.

What happened

The range a824a175..298c9824 is 28 commits, all by Lennart Augustsson, spread across two and a half calendar days: nineteen commits on 07-18 (15:07–23:19), four on 07-19 (13:11–19:29), and five on 07-20 (11:20–14:58).

456ce17c: the milestone

456ce17c ("Add bootstrap test."), 15:07:29, adds one new Makefile target β€” four lines (456ce17c:Makefile lines 66–69, quoted whole):

bootcombtest:	$(BIN)/uhs uhs.comb
	$(BIN)/uhs -ilib -isrc -omain-uhs.comb  MicroHs.Main
	$(BIN)/eval -H10000000 -K1000000 -ruhs.comb --  -ilib -isrc -omain-comb.comb MicroHs.Main
	cmp main-uhs.comb main-comb.comb

Line one has the GHC-built bin/uhs compile MicroHs.Main β€” its own compiler's entry point, the whole src/MicroHs/lib tree it imports β€” straight to main-uhs.comb. Line two hands uhs.comb (a separate, already-built target: bin/uhs compiling itself to a combinator file) to bin/eval, the C runtime, and tells it to run that combinator file as a program, with the same compiler arguments (-ilib -isrc -omain-comb.comb MicroHs.Main) tacked on after the --. uhs.comb is uhs, translated into the combinator terms eval already knows how to run β€” chapter 02's .comb format, unchanged in shape since then. So line two is the interpreted compiler compiling the same source a second time, writing main-comb.comb. Line three is the oracle: plain byte equality, no special-casing. If a native, GHC-built compiler and that same compiler, translated into combinators and run by the C interpreter, produce identical output compiling their own source, the ladder holds. The full treatment of why cmp (rather than some semantic check) is the right oracle, and of bootboottest, the parallel "local lib" rung, is the aux doc's; this chapter's job is watching bootcombtest actually run.

fc7a2ad7 / f06bacb7 / 1876f4c1: the reality of a compiler-compiling-compiler workload

Running a compiler on nothing but a few short test files, the way every prior chapter's Try-it does, and running it on itself β€” 30 .hs files under src/ and lib/, 73,856 bytes combined at this commit (counted with git ls-tree -r -l 456ce17c -- src lib) β€” are different workloads, and the difference shows up within the hour.

fc7a2ad7 ("Add comments."), 15:36:42, is mostly what its title says: short /* ... */ annotations next to eval.c's combinator cases (/* K x y = * x */ and the like). But three of its added lines, sitting right above the IO_BIND case, are not decoration β€” they're a TODO note written against a real, anticipated problem (fc7a2ad7:src/runtime/eval.c lines 977–979, quoted whole):

      /* XXX could use associativity to avoid deep evalio recursion. */
      /* (m >>= g) >>= h    ===  m >>= (\ x -> g x >>= h) */
      /* BIND (BIND m g) h  ===  BIND m (\ x -> BIND (g x) h) == BIND m (C' BIND g h)*/

Thirty-five minutes later, f06bacb7 ("Commented out non-working alternative."), 16:12:11, tries a fix β€” in the library, not the runtime. lib/System/IO.hs's hGetContents reads a file handle one character at a time, and its recursive case is an ordinary do-bound >>=: each character read nests one more IO.>>= around the rest of the file (f06bacb7:lib/System/IO.hs lines 126–135, quoted whole):

-- Strict hGetContents
hGetContents :: Handle -> IO String
hGetContents h = do
  c <- P.primHGetChar h
  case c == negate 1 of
    False ->
      do { cs <- hGetContents h; return (chr c:cs) }
      -- This should use less stack, but it doesn't work. :(
      --return (chr c : P.primPerformIO (hGetContents h))
    True  -> return ""

This is exactly the site the TODO comment above was worried about: src/MicroHs/Compile.hs's readFilePath calls IO.hGetContents to read every module the compiler compiles, so a source file of N characters builds a chain of N nested >>=s before any of them run. The commented- out line tries routing around that with P.primPerformIO instead of a recursive do-bind; the comment says plainly it doesn't work, and the attempt is left in place, dead, rather than deleted.

Twenty-one seconds after that, 1876f4c1 ("Use associativity of IO.>>= to avoid deep evalio recursion."), 16:12:32, fixes the actual mechanism β€” in the runtime, where the TODO comment said it belonged. Before this commit, evalio's IO_BIND case handles m >>= g by calling evalio recursively on m β€” so for a left-nested chain (m >>= g) >>= h, evaluating the outer bind means a nested C function call to evaluate the inner one, one native stack frame per bind in the chain. A hand-read of one character produces one bind; a source file produces as many nested binds as it has characters, and that recursion runs on the C process's own call stack, not the interpreter's own -K-sized NODEPTR stack a824a175 just made growable β€” a different stack, with no such flag. 1876f4c1's fix uses the algebraic law fc7a2ad7's TODO comment already named β€” (m >>= g) >>= h === m >>= (\x -> g x >>= h) β€” to rewrite the term instead of recursing into it, and loops back to the top of the evaluator via goto top rather than making a nested call (1876f4c1:src/runtime/eval.c lines 980–998, quoted whole):

    case IO_BIND:
      CHECKIO(2);
      {
        /* Use associativity to avoid deep evalio recursion. */
        /* (m >>= g) >>= h      ===  m >>= (\ x -> g x >>= h) */
        /* BIND ((BIND m) g) h  ===  BIND m (\ x -> BIND (g x) h) == (BIND m) (((C' BIND) g) h)*/
        NODEPTR bm;
        NODEPTR bmg = evali(ARG(TOP(1)));
        GCCHECKSAVE(bmg, 4);
        if (TAG(bmg) == AP && TAG(bm = indir(FUN(bmg))) == AP && TAG(indir(FUN(bm))) == IO_BIND) {
          NODEPTR g = ARG(bmg);
          NODEPTR h = ARG(TOP(2));
          n = new_ap(bm, new_ap(new_ap(new_ap(combCC, combIOBIND), g), h));
          POP(3);
          goto top;
        }
      }

      x = evalio(ARG(TOP(1)));  /* first argument, unwrapped */

Precision matters here: this is not a report of >>= producing wrong answers, and nothing about it changes what any bind computes β€” every m >>= g still ends up running g's continuation with m's result, in order. What changes is how many native stack frames evaluating a left-nested chain of binds costs: unboundedly many before this commit (one per bind, however deep the chain), a constant number after it, because the interpreter loop now iterates instead of recursing whenever it can. The bug this closes is a stack-depth risk under a character-at-a-time file reader run against real source files β€” a compiler compiling itself is the first workload in this project's history that produces IO chains long enough to make that risk concrete.

5d58461f: bigger ints, honestly

Six minutes and forty-eight seconds after the associativity fix, 5d58461f ("Use bigger ints."), 16:19:20, is not about the Haskell Int type user programs see β€” that had already narrowed to machine width back in chapter 06/07's diet. It widens the runtime's own bookkeeping counters from int to int64_t: num_reductions, num_gc, num_marked, max_num_marked, start_size, and several heap-indexing loop variables in init_nodes, scan, gc, and parse_top (5d58461f:src/runtime/eval.c lines 121–122, quoted):

int64_t num_reductions = 0;
int64_t num_gc = 0;

A test program's reduction and GC counts fit comfortably in 32 bits; a compiler compiling several thousand lines of its own source does not stay comfortably inside that margin for long. Nothing user-visible about program semantics changes β€” only how far the runtime's own statistics can count before silently wrapping.

1a84b483: give up when the heap is down to 2%

6ca78e8a ("Cleanup"), 23:16:14, and eight seconds later 1a84b483 ("Give up when heap is down to 2%"), 23:16:22, land at the end of the same long evening. The GC's gc() function already tracked how many cells survived each mark-and-sweep pass; this commit adds one check right after that count is known (1a84b483:src/runtime/eval.c lines 334–338, quoted whole):

  if (num_marked > max_num_marked)
    max_num_marked = num_marked;
  int64_t free = heap_size - heap_start - num_marked;
  if (free < heap_size / 50)
    ERR("heap exhausted");

heap_size / 50 is exactly 2% of the heap. This is not a smarter collector or a bigger one β€” the heap size is still whatever -H says it is β€” it's an honest bail-out: once a GC pass leaves fewer than 2% of the heap free, the runtime stops and reports "heap exhausted" instead of continuing to collect ever more often for ever less benefit, or running out mid-allocation with no diagnostic at all. It is the runtime meeting its own limit, for the first time, under a workload big enough to find it β€” an honest ceiling stated in the same commit that discovers it matters.

ee853660 / 04a4bbe4 / 8ad7094c: performance work, only after a real workload

Two days later, on 07-20, ee853660 ("Specialize the Map to StringMap."), 11:20:05, deletes src/MicroHs/Map.hs β€” the general Map k v chapter 07 built with an explicit equality argument on every call β€” and replaces every use of it in Compile.hs, Desugar.hs, Main.hs, and Translate.hs with src/MicroHs/StringMap.hs, a version specialized to String keys with eqString baked in rather than passed at each call site: ee853660:src/MicroHs/Compile.hs line 79 reads case M.lookup nm ch of where the previous commit's same line read case M.lookup eqIdent nm ch of. Twenty-five minutes later, 04a4bbe4 ("Sort in frequency order."), 11:45:47, reorders two lookup tables that are both searched linearly, string by string, on every combinator or primop name the compiler ever reads or writes: eval.c's primops[], which parse() scans on every .comb-file token, and Translate.hs's primTable. Both diffs add a comment naming the same reasoning (04a4bbe4:src/runtime/eval.c lines 169–190 and 04a4bbe4:src/MicroHs/Translate.hs lines 36–51, quoted whole, ... marking the primop entries omitted after each combinator table):

/* We use linear search in this, because almost all lookups
 * are among the combinators.
 */
struct {
  char *name;
  enum node_tag tag;
  NODEPTR node;
} primops[] = {
  /* combinators */
  /* sorted by frequency in a typical program */
  { "B", B },
  { "O", O },
  { "K", K },
  { "C'", CC },
  { "C", C },
  { "T", T },
  { "S'", SS },
  { "P", P },
  { "I", I },
  { "S", S },
  { "Y", Y },
  { "B'", BB },
  ...
-- Use linear search in this table.
-- 99% of the hits are among the combinators.
primTable :: [(String, Any)]
primTable = [
  ("B", primitive "B"),
  ("O", primitive "O"),
  ("K", primitive "K"),
  ("C'", primitive "C'"),
  ("C", primitive "C"),
  ("T", primitive "T"),
  ("S'", primitive "S'"),
  ("P", primitive "P"),
  ("I", primitive "I"),
  ("S", primitive "S"),
  ("Y", primitive "Y"),
  ("B'", primitive "B'"),
  ...

Both tables move B and O to the front and push S, Y, and B' β€” the combinators chapter 01's bracket abstraction produces least often in practice β€” toward the back, on the strength of a one-line comment about typical frequency, not a cited measurement. Between these two, at 16:50:16 on 07-18 (the same evening as the recursion fix, not the same day as the reordering), 8ad7094c ("Add profiling option.") adds a PROF variable to the Makefile, wired into bootuhs's build and link steps: GHC's own -prof -fprof-auto cost-center profiler, applied to the "local lib" GHC build of the compiler. Built and run this chapter's own Try-it below, that profiler's report is dominated by Text.ParserComb.(>>=), Data.List.(++), and Text.String.eqString β€” Haskell-level cost centers inside the compiler's own parser and library, measured on bootuhs, a native GHC binary. It has nothing to say about which combinator names β€” S, K, B, O β€” turn up most often in a .comb file, which is what 04a4bbe4's tables are sorted by; that's a property of the C runtime eval and the translator Translate.hs, neither of which the GHC profiler ever touches. Checked directly rather than assumed: no commit message, code comment, or file in this range connects the two. They read as two independent performance interventions from the same 48-hour window β€” one instruments the compiler's own Haskell, the other hand-tunes a linear search in a completely different program β€” not a pipeline where one feeds the other.

db418344: type parsing, parse-only

db418344 ("Add type parsing."), 18:46:43, gives Parse.hs a real type syntax for the first time since chapter 06 flattened type Type = Expr. A new EType distinguishes type variables, constructor applications, and unification variables, and ETypeScheme adds forall (db418344:src/MicroHs/Parse.hs lines 99–107, quoted whole):

-- No higher order type variables (yet)
data EType
  = TVar Ident
  | TConstr Ident [EType]
  | TUVar Int
  --Xderiving (Show)

data ETypeScheme = ETypeScheme [Ident] EType
  --Xderiving (Show)

Sign :: Ident -> ETypeScheme -> EDef now carries a real parsed type scheme instead of an untyped Expr alias. But Desugar.hs, unchanged in this respect by this commit, still turns every Sign into nothing (db418344:src/MicroHs/Desugar.hs line 151, quoted): Sign _ _ -> []. Types are syntax the parser understands and the EDef AST can carry, and nothing downstream reads them yet β€” no checker, no inference, not even a warning. The same parse-only posture chapter 03 gave the compiler's earliest type signatures, one step further along; what those parsed types eventually get used for is chapter 09's subject, not this one's.

298c9824: uhs becomes mhs β€” what actually renames

The range's last commit, 298c9824 ("Rename uhs to mhs."), 14:58:46, touches exactly two files: Makefile and tests/Makefile. No file is git mv'd, no module is renamed β€” MicroHs.Main was already called that. What changes is a new MHS=mhs Makefile variable substituted everywhere the literal string uhs had named a build artifact: the binary ($(BIN)/uhs becomes $(BIN)/$(MHS)), its GHC-built, local-library sibling (bootuhs becomes boot$(MHS)), the self-compiled combinator file (uhs.comb becomes $(MHS).comb), the phony test target (uhstest becomes $(MHS)test), and, in tests/Makefile, the UHS variable itself is renamed to MHS=../bin/mhs. The project earns its name β€” MicroHs β€” in its build vocabulary first. It doesn't earn it everywhere at once: a grep -rn uhs over src//lib//ghc/ at this commit still turns up one hit, src/MicroHs/Main.hs's own usage message, unchanged by this commit (298c9824:src/MicroHs/Main.hs line 23, quoted): error "Usage: uhs [-v] [-r] [-iPATH] [-oFILE] ModuleName". The compiler renamed by this commit still calls itself uhs to its own users' faces.

Undisclosed extras

Two commits carry more than their titles say. 787d8842 ("Add an (unused) Data.Map"), 14:48:47, does add the promised file β€” 177 lines, a real balanced 2-3 tree implementation, built but wired into no call site (the Makefile only compiles it as part of bootuhs, never imports it from anywhere that matters) β€” but the same commit also adds a leIdent helper to Parse.hs and moves StringMap.hs's type signatures into a new trailing block alongside a fresh commented-out Data.Map-based alternative implementation, neither mentioned in the title. 8e2f7109 ("Change test targets."), 16:25:51 on 07-18, mostly does what it says β€” renaming and adding alltest/everytest targets β€” but its bootcombtest diff also silently drops -K1000000 from the eval invocation 456ce17c had added 1 hour 18 minutes earlier (8e2f7109:Makefile line 67, quoted): $(BIN)/eval -H10000000 -ruhs.comb -- -ilib -isrc -omain-comb.comb MicroHs.Main β€” no -K1000000, and no mention of it in the commit title β€” leaving bootcombtest to run on eval's built-in default of 10,000 interpreter-stack cells from then on β€” a hundredfold cut in an explicit safety margin, undisclosed. On the other side of the ledger: acf40ed4 ("Remove junk"), 13:11:39 on 07-19, deletes A.hs and Test.hs β€” the exact two files 59b8b1ab added, undisclosed, under the unrelated title "Fix name" back in chapter 07's range. Two weeks later, the same project removes them under a title that says plainly what the diff does.

Why it matters

456ce17c isn't a feature landing; it's a fixpoint closing. Once bootcombtest exists and passes, the compiler's own speed stops being a side concern and becomes the benchmark that matters most: every commit that makes the runtime or the compiler faster makes the next bootstrap run faster too, and the bootstrap run is now the heaviest, most representative workload in the whole project's test suite. That's visible in the commit order itself. Every performance intervention in this chapter β€” the int64_t widening, the 2%-heap bailout, StringMap, the frequency-sorted lookup tables β€” comes after 456ce17c, several of them within hours, none of them before. The runtime had gone 54 days, from this repository's first commit (ccb31388, 2023-05-25) to a824a175, without a single commit tuning its counters, its GC threshold, its data structures, or its lookup order β€” earlier commits titled "optimiser" (ae2a1403, d2956ee8, and others) are a different concern entirely, code-quality passes over Exp.hs's bracket-abstraction output, aux/bracket-abstraction.md's subject, not runtime engineering. The self-compile is what finally gave the runtime a workload worth engineering for, and the project engineered for it immediately once it existed, not before.

Notebook margin

  • The three fixes most directly forced by running a real self-compile β€” 1876f4c1's associativity rewrite, 5d58461f's wider counters, 1a84b483's heap bailout β€” land 7 hours 3 minutes 50 seconds apart at the outside (1876f4c1 to 1a84b483), and the tightest pair (f06bacb7 to 1876f4c1) is 21 seconds.
  • The StringMap/frequency-order/profiling cluster (8ad7094c, ee853660, 04a4bbe4) spans 42 hours 55 minutes 31 seconds end to end β€” still inside two days of 456ce17c, even though the last two land on a different calendar day than the first.
  • d8d8856b ("Add version string to compiler binary."), 11:53:35 on 07-20, gives every .comb file a five-byte header ("v1.0\n") and adds a checkversion check to eval's main that reads and matches those bytes before parsing the rest of the file, one ERR("version mismatch") away from running a .comb file built by an incompatible compiler version. 787d8842 ("Add an (unused) Data.Map"), the same afternoon, is the honest kind of speculative code this project allows: built, compiled, never called, and its title says so.

Try it

Anchor: 456ce17c ("Add bootstrap test."), 2023-07-18 15:07:29 β€” the commit that adds the bootcombtest target this exercise runs. Convention (see 00-overview.md):

git worktree add /tmp/mhs-456ce17c 456ce17c
cd /tmp/mhs-456ce17c

Build both binaries the target needs, exactly as written, no patch needed on this project's flake toolchain (GHC 9.8.4):

mkdir -p bin
nix develop $MICROHS -c make bin/uhs
nix develop $MICROHS -c make bin/eval
[...]
[12 of 12] Linking bin/uhs
gcc -Wall -O3 src/runtime/eval.c -o bin/eval

(The same -Wx-partial warnings on head/tail every earlier chapter's Build notes already flag appear during the bin/uhs build; omitted.)

Now the money shot β€” the whole ladder, run as bootcombtest itself runs it, timed step by step:

nix develop $MICROHS -c bash -c 'time bin/uhs -ilib -isrc -ouhs.comb MicroHs.Main'
real	0m0.964s
nix develop $MICROHS -c bash -c 'time bin/uhs -ilib -isrc -omain-uhs.comb MicroHs.Main'
real	0m0.942s
nix develop $MICROHS -c bash -c 'time bin/eval -H10000000 -K1000000 -ruhs.comb -- -ilib -isrc -omain-comb.comb MicroHs.Main'
real	0m21.273s
nix develop $MICROHS -c cmp main-uhs.comb main-comb.comb

cmp prints nothing and exits 0: main-uhs.comb and main-comb.comb β€” 68,799 bytes each β€” are byte-identical. The GHC-built bin/uhs compiled its own source; that same source, translated into combinators and interpreted by bin/eval, compiled it again; both runs produced the exact same output. That's bootcombtest, in full, actually running, at the commit that adds it β€” no stack overflow, no patch. (I also re-ran the whole ladder at this range's last commit, 298c9824, with bin/mhs in place of bin/uhs: main-mhs.comb and main-comb.comb agreed there too, 75,052 bytes each, in 20.715s for the interpreted step β€” so whatever native-stack risk 1876f4c1 closed, it isn't one this environment's default 8 MB ulimit -s happens to trigger for this particular self-compile; the fix is real and the mechanism above is read directly from the diff, but I did not manage to reproduce the crash it prevents.)

Exercise 1: time the eval-run self-compile against the GHC-built one

The three time runs above already answer this, on this machine: the GHC-built bin/uhs compiles MicroHs.Main in well under a second (0.942s–0.964s, run to run); bin/eval interpreting the combinator translation of that same compiler, doing the identical compile, takes 21.273s β€” a 22.6Γ— slowdown (21.273 Γ· 0.942): native code versus a graph-reduction interpreter walking the same combinator terms, on this compiler's own source for the first time rather than on a short test program the way every earlier chapter's time target measures it. The same three-step timing at 298c9824, two days and 27 commits of source growth later (main-uhs.comb 68,799 bytes here versus main-mhs.comb 75,052 bytes there, roughly 9% more program), gives 0.926s / 0.890s / 20.715s β€” a slightly better throughput per byte (75,052 Γ· 20.715 β‰ˆ 3,624 B/s interpreted, versus 68,799 Γ· 21.273 β‰ˆ 3,234 B/s here), consistent with β€” though this single before/after pair can't isolate exactly which of this range's performance commits earns it β€” ee853660/04a4bbe4's lookup-table work paying for itself rather than being pure overhead.

Exercise 2: corrupt one byte of uhs.comb

uhs.comb's text format names every combinator with a $-prefixed token; find one and flip a single byte:

(python3 isn't in the devShell's buildInputs either β€” like diff in chapter 01, it's whatever the host system provides on the PATH nix develop hands you.)

nix develop $MICROHS -c python3 -c "
data = bytearray(open('uhs.comb','rb').read())
assert data[53:54] == b'C'
data[53] = ord('Z')
open('uhs-corrupt.comb','wb').write(bytes(data))
"

Byte offset 53 (0-indexed) sits inside a \$C token near the start of the file; the edit above turns it into \$Z , a combinator name that does not exist. Ask eval to run it, the same way bootcombtest's middle line does:

nix develop $MICROHS -c bin/eval -H10000000 -K1000000 -ruhs-corrupt.comb -- -ilib -isrc -ocorrupt-out.comb MicroHs.Main
eval: bad primop Z
ERR: no primop

Exit code 1. parse()'s primop table lookup (the same linear search 04a4bbe4 later reorders) finds no entry named Z, prints exactly the diagnostic its fprintf/ERR calls promise, and the whole run aborts before a single reduction happens β€” one corrupted byte, caught at parse time, not miscompiled silently into the next bootstrap.

Exercise 3: the profiling flag and the frequency ordering β€” checked, not assumed

8ad7094c's PROF flag doesn't exist yet at the 456ce17c anchor, and neither does 04a4bbe4's reordered primops[] table; this exercise needs both, so it uses a second worktree at the commit where they last appear together, 04a4bbe4 itself:

git worktree add /tmp/mhs-04a4bbe4 04a4bbe4
cd /tmp/mhs-04a4bbe4
mkdir -p bin
nix develop $MICROHS -c make PROF="-prof -fprof-auto" bin/bootuhs
[...]
ghc -prof -fprof-auto -hide-all-packages -o bin/bootuhs ghc-boot/*.o ghc-boot/*/*.o
nix develop $MICROHS -c bin/bootuhs -ilib -isrc -omain-boot.comb +RTS -p -RTS MicroHs.Main

This writes bootuhs.prof, GHC's own cost-center report (trimmed to the top entries):

COST CENTRE      MODULE            SRC                                      %time %alloc

>>=.\.xss        Text.ParserComb   src/Text/ParserComb.hs:80:15-69           18.8    7.4
++               Data.List         lib/Data/List.hs:(22,1)-(25,26)           10.1   23.9
>>=.\            Text.ParserComb   src/Text/ParserComb.hs:(78,5)-(82,68)      9.9   17.0
eqString         Text.String       lib/Text/String.hs:(101,1)-(110,44)        8.7   12.9
runP             Text.ParserComb   src/Text/ParserComb.hs:(66,1)-(68,12)      6.3    0.0
foldr.rec        Data.List         lib/Data/List.hs:(57,5)-(60,30)            4.2    2.8
...

Read this list straight: it's dominated by Text.ParserComb's >>=, Data.List.(++), and Text.String.eqString β€” Haskell functions inside the compiler's own parser and prelude, measured on bootuhs, a native GHC binary running the "local lib" build. Not one combinator name β€” S, K, B, O, the names 04a4bbe4's primops[]/primTable reorder β€” appears anywhere in this report, because they belong to a different program entirely: eval.c's interpreter and Translate.hs's translator, neither of which bootuhs's GHC profiler ever runs. The "connect the profiling flag to the frequency ordering" instinct doesn't survive this run: they're two separate performance interventions, aimed at two separate programs, that happen to land in the same 48-hour window. What this report does support independently is 8f361a0a's "Fix stupid eqString" from not quite two days earlier β€” eqString alone accounts for 8.7% of this run's time and 12.9% of its allocation, exactly the kind of hot-path cost a workload this size makes visible for the first time.

Clean up both worktrees:

git worktree remove --force /tmp/mhs-456ce17c
git worktree remove --force /tmp/mhs-04a4bbe4

🌐 ν•œκ΅­μ–΄λ‘œ 읽기

09 β€” Types at last

Where we are

Chapter 08 closes at 298c9824 ("Rename uhs to mhs.", 2023-07-20 14:58:46): the compiler is self-hosting, bootcombtest passes, and the binary finally answers to the name this book has used from the start, mhs. Nothing about that binary checks a single type. f :: T has parsed since chapter 03's e96d04be (2023-05-30) and gone straight to dsDef (Sign _ _) = [] β€” accepted, then thrown away. Chapter 08's own db418344 (2023-07-18) gave signatures a real grammar β€” EType, a TVar/TConstr/TUVar type distinct from Expr, and ETypeScheme for forall β€” but left the desugarer's verdict unchanged: Sign _ _ -> [], still nothing downstream reads what's parsed. This chapter's range, 298c9824..a38c64ce, 24 commits over 21 days, is where something finally does.

What happened

Three commits named "Update"

The range opens with 7936c701, bca0b02d, and 3719955b, all on 2023-07-20, all titled "Update", and all touching exactly one file each time: README.md (4 insertions/13 deletions, 1/1, and 6 insertions with nothing removed, in that order). None of the three has anything to do with type checking β€” they're documentation upkeep sitting in the gap between chapter 08's close and this chapter's first substantive commit. Included here only because "commits named vaguely and checked anyway" is this book's own standard, not because there's anything more to say about them.

The prep, in one day: 33b009fa through e5bc9236

Five commits, all 2023-07-21, each making the ground firmer before anyone tries to write a type checker on it.

33b009fa ("Use Expr for EType.") deletes the three-constructor EType db418344 had introduced two days and twenty hours earlier and replaces it with type EType = Expr plus a validType predicate β€” the change aux/hindley-milner-gently.md opens with, since it's the one decision the rest of the checker is built on. The same commit, not mentioned in its own title, breaks showEType β€” the function that would pretty-print a type for a human β€” into a function that never returns (33b009fa:src/MicroHs/Parse.hs, quoted whole, the whole diff to this function):

 showEType :: EType -> String
-showEType at =
-  case at of
-    TVar i -> i
-    TConstr c ts ->
-      if null ts then
-        c
-      else
-        "(" ++ unwords (c : map showEType ts) ++ ")"
-    TUVar n -> "a" ++ showInt n
+showEType = showEType

There's no longer a TVar/TConstr/TUVar to pattern-match on β€” EType is just Expr now β€” and rather than rewrite showEType to walk Expr directly, 33b009fa leaves a stub that would loop forever if it were ever called. Checked directly: nothing in TypeCheck.hs or Main.hs calls showEType or showETypeScheme at any commit between 33b009fa and 617475b6 (below) β€” the stub sits dead in the source for nearly sixteen days (15 days 21 hours), across c0b279fc's skeleton checker, both "Transfer" commits, and f7db30f1's library-wide signature sweep, without ever being exercised.

45b34015, sixteen seconds later, is a genuine rewrite of a different function, showExpr β€” sixteen alternatives replaced to print terms without the (EVar "x")-style constructor-tagged debug format they used before ("(EVar " ++ v ++ ")" becomes plain v, and so on), the format unify's own error messages read from (aux/hindley-milner-gently.md quotes unifyR's use of it directly). 93a2f76e ("Allow '()' and '[]' as uppercase identifiers.") teaches pUIdent two new alternatives so that () and [] parse as ordinary constructor names rather than only as special-cased tuple/list punctuation (93a2f76e:src/MicroHs/Parse.hs, two lines added to pUIdent, quoted whole):

  <|> ("()" <$ (pSym '(' *> pWhite *> pSym ')'))  -- Allow () as a constructor name
  <|> ("[]" <$ (pSym '[' *> pWhite *> pSym ']'))  -- Allow [] as a constructor name

β€” needed because, with types now just Expr, a type like () or [a] has to resolve to an ordinary EVar constructor name (tCon "()", tApps "[]" [...], in the checker's own vocabulary) the same way Int or Maybe does, and before this commit ()/[] were parseable only inside expression syntax, never as a bare uppercase identifier a type application could apply to. ca512ed2 ("Get rid of Data.List_Type") folds data [] a = [] | (:) a [a] directly into lib/Data/List.hs, deleting the separate Data.List_Type module that used to carry only the type declaration β€” now that a module's own Data/Sign definitions and its term-level bindings live in the same EDef list the checker reads together, there's no structural reason to keep them apart. e5bc9236 ("Change ELam to abstract just one variable.") narrows ELam [Ident] Expr to ELam Ident Expr, with a new eLams :: [Ident] -> Expr -> Expr folding multi-argument lambdas down to nested single-argument ones at parse time (e5bc9236:src/MicroHs/Parse.hs, quoted whole):

pLam :: P Expr
pLam = eLams <$> (pSymbol "\\" *> esome pLIdent_) <*> (pSymbol "->" *> pExpr)

eLams :: [Ident] -> Expr -> Expr
eLams is e = foldr ELam e is

β€” the checker's own ELam case, added the next day, expects exactly one bound variable per lambda so it can unify it against exactly one tArrow at a time; a multi-argument ELam would have needed its own multi-step unification logic instead.

c0b279fc: a skeleton, wired into nothing

c0b279fc ("Start of type checking.", 2023-07-22 10:50:52) adds one new file, src/MicroHs/TypeCheck.hs, 57 lines β€” and touches nothing else, not the Makefile, not Compile.hs, not Main.hs. Nothing in the build imports it yet. It isn't even Haskell that GHC would accept on its own: tcExpr's type signature mentions a monad T (tcExpr :: Maybe EType -> Expr -> T (Typed Expr)), but the file's only import is import Control.Monad.State as T (c0b279fc:src/MicroHs/TypeCheck.hs line 3) β€” qualified import syntax that makes names like T.get available, not a bare type called T. Several cases are undefined outright (ECase _ = undefined, ELet _ = undefined, EDo _ = undefined), and the function runs off the end mid-definition β€” the file's last line is tc, an unfinished identifier with nothing after it. This is the first draft in the most literal sense: a shape for what the checker's core recursion over Expr will look like (tcExpr, an EVar/EApp/ELam/EInt walk with a Maybe EType for bidirectional checking, already recognizable in cc57346e's working version two weeks later), committed before it compiles, let alone runs.

An unrelated fix, and two commits called "Transfer"

7ae8c443 ("Make int64_t printing portable.", 2023-07-22 15:57:15) is a one-file runtime fix β€” eval.c's number formatting β€” with nothing to do with type checking; included here because it lands between c0b279fc and the next TypeCheck.hs work, a reminder that this project's git history doesn't pause other work while one feature is in progress. Then, one minute fifty seconds apart the same evening, 3437518f and 031d2125, both titled "Transfer", touch only src/MicroHs/TypeCheck.hs β€” 114 lines changed (74 insertions, 40 deletions) and then 6 more (4/2). Neither commit message says what's being transferred or from where; read as diffs, both are straightforward elaborations of the same skeleton c0b279fc started (more cases filled in, more helper functions), not a wholesale replacement β€” consistent with, though not confirmed by anything in the repository beyond the diffs themselves, work drafted somewhere else (a second checkout, a different machine) and copied in by hand rather than merged.

f7db30f1: the chapter 03 debt, collected β€” in the library

f7db30f1 ("Add lots of type signatues and forall.", 2023-07-23 19:28:49) is the commit aux/hindley-milner-gently.md quotes closely: 9 files, 134 insertions, 74 deletions, 80 added lines containing forall, 128 added lines containing ::. Worth stating precisely, because the shape is easy to get wrong: every file this commit touches is under lib/ (Data/Either.hs, Data/Function.hs, Data/Int.hs, Data/List.hs, Data/Map.hs, Data/Maybe.hs, Data/Tuple.hs, Primitives.hs) plus the Makefile β€” the standard library every MicroHs program (including the compiler's own src/ tree, which imports it) pulls in, not src/MicroHs itself. And most of those 128 :: lines are not brand-new signatures β€” const :: a -> b -> a already existed in lib/Data/Function.hs; what f7db30f1 adds to it is forall a b . in front. Chapter 03's e96d04be:Desugar.hs comment says "the meaning arrives months later" β€” f7db30f1 lands 54 days after e96d04be, and cc57346e's working checker another 14 days after that, 68 days total. What arrives first, at f7db30f1, is not "the compiler reads Sign" β€” the checker doesn't exist as a working feature yet β€” it's the library gaining the quantifiers the checker's rigid-variable mechanism will need to accept anything polymorphic at all, once it does exist, exactly as aux/hindley-milner-gently.md's forall/instantiation section demonstrates by removing one.

Two more unrelated commits, one of them quietly load-bearing

677a0f0e ("Handle negative literals.", 2023-07-25 14:58:16) is another runtime-only fix, eval.c, twelve insertions and one deletion. Eighteen seconds later, 17b450fc ("Add more lib modules.") adds two files outright: lib/Control/Monad/State.hs (52 lines) and lib/Data/IntMap.hs (77 lines). The commit message doesn't say why a compiler needs an IntMap, but TypeCheck.hs's own state β€” TCState's last field, the unification substitution (aux/hindley-milner-gently.md's "Metavariables and substitution" section) β€” is exactly IM.IntMap EType, and cc57346e's import list confirms it: import qualified Data.IntMap as IM. 17b450fc isn't titled as type-checking work, but it's the commit that puts the checker's core data structure within reach.

The quiet grind: 9a392312 to 617475b6

9a392312 ("Temp commit", 2023-07-27 13:47:19) touches TypeCheck.hs with 173 insertions and 26 deletions, and touches five other files besides (Makefile, Compile.hs, Desugar.hs, Main.hs, Parse.hs) β€” wiring the checker into the build for the first time, unlike every commit before it. Then nothing, for 9 days 21 hours 51 minutes, until 617475b6 ("Temp", 2023-08-06 11:38:42) β€” a single commit that rewrites TypeCheck.hs again, 543 insertions and 65 deletions this time, across 11 files total including a near-total restructuring of Desugar.hs (67 insertions, 205 deletions). Nothing in the repository explains the gap the same way chapter 07's 24-day silence goes unexplained in 00-overview.md β€” the log simply resumes. What the two commits' titles share β€” "Temp commit", "Temp" β€” is an honest description: neither claims to be finished, and neither is committed as a clean, working state so much as a savepoint. 617475b6 is also where the showEType infinite loop from 33b009fa, nearly sixteen days earlier, finally gets fixed β€” one line, alongside everything else this commit touches (617475b6:src/MicroHs/Parse.hs, quoted whole): showEType = showExpr. Not a redesign, just routing the type-printer through the (already rewritten, in 45b34015) term-printer, since EType has been Expr all along.

cc57346e: "First version with type checking."

Seven hours thirty-eight minutes after 617475b6's rewrite lands, the same day, cc57346e (2023-08-06 19:16:48) is the commit both this chapter and aux/hindley-milner-gently.md treat as the checker's first real snapshot: 16 files, 187 insertions, 78 deletions. src/MicroHs/TCMonad.hs is new β€” 13 lines, a thin re-export of Control.Monad.State.Strict under the compiler's own qualified name T, finally giving c0b279fc's two-week-old, never-compiling type signature somewhere real to point. TypeCheck.hs itself, at this commit, is the file aux/hindley-milner-gently.md quotes from throughout β€” unify/unifyR, EUVar-as-metavariable, tInst, tcDefValue, all present and in the shape that document describes. What it can do by this commit: check EVar/EApp/ELam/literals, ECase, ELet, ETuple, EList, EDo, EIf, and list comprehensions β€” every case c0b279fc had left as undefined two weeks earlier is filled in. What it demands in return shows up immediately in the same commit's own test suite: all six files under tests/ gain a main :: IO () signature they didn't have before (tests/Arith.hs, ListTest.hs, StringTest.hs each need only that one line; Fac.hs also needs res :: (Int, Int); IOTest.hs needs f :: Int -> Int and foo :: IO (); Misc.hs's existing first :: (a, b) -> a needs forall a b . added in front) β€” the same "every top-level binding needs an explicit signature" rule aux/hindley-milner-gently.md demonstrates by removing one from lib/Data/List.hs's null, now visible landing on the project's own test files at the exact commit that turns checking on.

The fix streak

Six commits, da5350d2 through 64345fe2, spread across three calendar days with two full silent calendar days in the middle β€” 2023-08-06 (da5350d2 19:31, 3bf3924a 23:56), 2023-08-07 (a90d1175 12:21, bb8aec3b 12:22), nothing on the 8th or 9th, then 2023-08-10 (64345fe2 11:06, the same day as the chapter's final commit). Read together, most of their TypeCheck.hs diffs are not new type-checking logic at all β€” they're the same "diet" aux/bootstrapping-a-compiler.md describes for the rest of the compiler, applied to the newest module so that it, too, can eventually compile under mhs itself, not only under GHC. da5350d2 ("Improve.") strips every HasCallStack => constraint behind a --X comment marker and switches bare mapM/mapM_ to qualified T.mapM/T.mapM_ β€” GHC.Stack and an unqualified Prelude mapM are exactly the kind of import mhs's own restricted dialect doesn't have. 3bf3924a ("Various type fixes.") continues the same pattern: EApp <$> expandType f <*> expandType a-style Applicative code, which needs <$>/<*> instances mhs has no type classes to provide, rewritten as explicit T.do binds; bare return/pure qualified to T.return. a90d1175 ("Better import") is three insertions and two deletions in TCMonad.hs, an import tweak. bb8aec3b ("More type fixes") is six insertions and one deletion in Parse.hs, mostly commented-out (--X-marked) pretty-printer alternatives for showExpr, not live code. 64345fe2 ("More type checking fixes.") is the largest of the six β€” 163 lines in TypeCheck.hs alone β€” and, checked directly rather than assumed from its title, contains at least one genuine correctness fix, not just portability churn: expandSyn's handling of a multi-argument type synonym. Before this commit, type Pair a b = (a, b) used with two arguments fails to type-check at all β€” confirmed by building cc57346e and compiling a one-line program that uses one: mhs: Cannot unify SynTest.Pair and ,. 64345fe2 rewrites the EApp case of expandSyn to walk the application spine correctly, expanding each argument before pushing it onto the accumulator instead of recursing into the wrong sub-term (64345fe2:src/MicroHs/TypeCheck.hs, quoted whole, the changed case):

        EApp f a -> T.do
          aa <- expandSyn a
          syn (aa:ts) f

β€” confirmed fixed by building a38c64ce and compiling the identical SynTest.hs: it compiles and runs, printing 1. One more honest detail, found by trying to build 64345fe2 on its own rather than only reading its diff: this commit does not compile standalone under GHC, failing with Not in scope: 'T.fail' / Not in scope: 'T.>>=' wherever TypeCheck.hs pattern-matches inside a T.do block. The fix, a38c64ce's own TCMonad.hs change, is the --X/--Y marker trick aux/bootstrapping-a-compiler.md documents, caught mid-flip: at 64345fe2, src/MicroHs/TCMonad.hs line 16 reads --import Control.Monad, a plain comment with no --X marker, so convertX.sh never touches it and Control.Monad β€” the module that would bring fail/(>>=) into scope for MicroHs.TCMonad's own module MicroHs.TCMonad export item to re-export β€” is never actually imported for the GHC build. a38c64ce changes that one line to --Ximport Control.Monad hiding(ap) (a38c64ce:src/MicroHs/TCMonad.hs line 17) β€” now marked, so convertX.sh's sed -e 's/--X//' strips the marker and the bin/mhs build actually sees import Control.Monad hiding(ap), and T.fail/ T.>>= resolve. Consistent with 00-overview.md's framing of this project's commit history β€” broken states get committed, not held back until clean.

a38c64ce: the fixpoint

a38c64ce ("Now bootstraps with type checking.", 2023-08-10 20:37:30) is seven files, 29 insertions, 24 deletions β€” small, by this chapter's standards, for a commit that closes the book's whole arc. The Makefile change is one number: bootcombtest's eval invocation grows its heap from -H10000000 to -H50000000, and the same commit deletes the $(MHS)test target (originally uhstest, chapter 07's 1d6d1210, 2023-07-05, renamed by 298c9824 in chapter 08) β€” an interpreted-mode smoke test, $(BIN)/$(MHS) -ilib -isrc Main; $(BIN)/eval with no arguments, superseded once bootcombtest itself is the thing that has to work. In TypeCheck.hs, the load-bearing change is four lines, a new function called between every top-level definition's own check (a38c64ce:src/MicroHs/TypeCheck.hs lines 360–364, quoted whole):

-- Reset type variable and unification map
tcReset :: T ()
tcReset = T.do
  TC mn _ tenv venv _ <- get
  put (TC mn 0 tenv venv IM.empty)

β€” wired in at both call sites that walk a module's definition list, tcDefsType and tcDefsValue, each changed from T.mapM tcDefType ds / T.mapM tcDefValue ds to T.mapM (\ d -> T.do {tcReset; tcDefType d}) ds and the value-checking equivalent. Before this commit, the fresh- metavariable counter and the unification substitution map accumulated across every top-level definition in a module without ever being cleared; after it, each definition starts from EUVar 0 and an empty substitution. The commit message doesn't explain what checking the compiler's own ~30-file source β€” 571 distinct top-level combinator labels in the resulting mhs.comb, counted directly β€” without that reset actually did β€” whether it was simply slow, or something worse β€” only that adding tcReset is what the title reports finally working. Alongside it, two correctness fixes narrower in scope: ESectR's case used to pattern-match its checked operator as EVar ii directly, which crashes for any section built from a data constructor rather than a plain function (the checker's own EVar case can return ECon cti qi from tLookupInst, not only EVar) β€” fixed to match a generic var and extract the name through getIdent (a38c64ce:src/MicroHs/TypeCheck.hs line 694: T.return (ESectR (getIdent var) ee, t)). And primTypes gains one new entry, "Any", the same commit that adds forall a b . to lib/Unsafe/Coerce.hs's unsafeCoerce :: a -> b β€” the "Any" primitive type and unsafeCoerce's forced universal quantification arrive together. The rest of the diff β€” a qualified MicroHs.StateIO.return in StateIO.hs where a bare return used to be ambiguous between two returns now both in scope, an unqualified Handle import fix in Compile.hs, a fallback-arm tweak in Desugar.hs's single-arm case handling β€” is the same self-hosting-compatibility housekeeping the fix streak above already established as this range's dominant texture. Built and run, at this commit, exactly as bootcombtest runs it (see "Try it" below): bin/mhs compiles MicroHs.Main from source; bin/eval, interpreting mhs's own combinator translation, compiles the identical source a second time; the two outputs, 140,782 bytes each, are byte-identical. The typed compiler type-checks itself, compiling itself.

Undisclosed extras

Three found by reading diffs rather than titles, gathered here: the showEType = showEType infinite loop 33b009fa introduces silently (under a title about EType's representation, not its printing) and 617475b6 fixes silently nearly sixteen days later, never once exercised in between; 64345fe2 ("More type checking fixes.") not compiling standalone under GHC, discovered only by building it rather than trusting the next commit's message; and the two "Transfer" commits, 3437518f/031d2125, whose titles say nothing about what's being transferred but whose diffs β€” 114 and 6 lines, both confined to TypeCheck.hs β€” are at least consistent with what they claim to be, unlike chapter 08's 8e2f7109 (which dropped a safety flag its title never mentioned). Nothing found this chapter rises to that level β€” everything here is scope not stated plainly, not effects contradicting a title.

Why it matters

Types came last. Not first, not alongside the parser, not even alongside do-notation or modules β€” after the language, the runtime, IO, and self-hosting all worked, on a compiler that had already compiled itself for three weeks with every signature in its own source doing nothing at all. That ordering is the whole shape of this book's method, not an accident of one project: chapter 03's dsDef (Sign _ _) = [] bought two months and change of writing signatures as documentation, free of any obligation to make them true; chapter 05's habits β€” commit often, note what's broken, keep a lab notebook β€” are exactly what let 9a392312 and 617475b6 sit nine days apart under titles that admit they're unfinished, without the project stalling; chapter 08's self-hosting push is what gives this chapter a workload β€” the compiler's own ~30-file source β€” big enough to need tcReset at all, a fix nothing smaller would have surfaced. The untyped compiler wasn't a detour from building a typed one; it was the scaffolding that made building a typed one tractable, one small commit at a time, on a language that already worked before anyone had to prove it worked. Two files, Lam.hs and eval.c, on 2023-05-25. A self-hosting typed compiler that checks itself compiling itself, 77 days later, on a38c64ce, 2023-08-10. This account stops here because that's where git log says the story 00-overview.md promised is told in full: "The last stretch, through early August, adds a Hindley-Milner type checker and gets the self-hosted compiler bootstrapping with type checking ...".

Notebook margin

  • "Temp commit" and "Temp" are not filler titles β€” they're savepoints, committed mid-thought on purpose, 9 days 21 hours 51 minutes apart, with no explanation in between for the gap. The habit this book has tracked since chapter 05 β€” commit even when it doesn't work β€” is what makes a multi-week rewrite like 617475b6's 608-line TypeCheck.hs diff possible without losing 9a392312's 199-line first attempt.
  • Refactor before feature, again: 33b009fa/e5bc9236's prep (types as Expr, one-variable lambdas) lands a full day before c0b279fc's first line of actual checker code β€” the same rhythm chapter 03's "Notebook margin" named for 4e4c5252 and ff4622e8, now operating one level up, on the checker's own prerequisites rather than the parser's.
  • The fix-streak rhythm: six commits, three calendar days, two full silent calendar days in the middle (08-08, 08-09) β€” and the very last commit in that streak, 64345fe2, doesn't even build standalone. Fixing real bugs (a multi-argument type synonym) and fixing self-hosting portability (qualified T.do binds, stripped HasCallStack) happen in the same commits, not separated into "correctness" and "cleanup" passes.
  • 17b450fc ("Add more lib modules.") never says why the compiler needs an IntMap β€” the connection to TypeCheck.hs's own substitution map is legible only from reading both files, not from either commit message.

Try it

Anchor: a38c64ce ("Now bootstraps with type checking."), 2023-08-10 20:37:30 β€” the commit that makes the typed self-compile agree with itself. Convention (see 00-overview.md):

git worktree add /tmp/mhs-a38c64ce a38c64ce
cd /tmp/mhs-a38c64ce

No Build-note patch needed at this commit β€” the GHC-9.8/mtl issues chapters 02, 03, and others in this book had to route around are gone from Main.hs's own import list by this point in the project. Build both binaries as written:

mkdir -p bin
nix develop $MICROHS -c make bin/mhs
nix develop $MICROHS -c make bin/eval
[...]
[16 of 16] Linking bin/mhs
gcc -Wall -O3 src/runtime/eval.c -o bin/eval

(The same -Wx-partial head/tail warnings every earlier chapter's Build notes already flag appear during the bin/mhs build; omitted.) Run the ordinary test suite first β€” seven tests/*.hs files (the six cc57346e had to add main :: IO () to, per "What happened" above, plus Case.hs, which already had one), each compiled and its output diffed against a checked-in .ref file:

nix develop $MICROHS -c make test
cd tests; make test
../bin/mhs -i../lib IOTest && (echo q | ../bin/eval -- a bb ccc > IOTest.out) && diff IOTest.ref IOTest.out
../bin/mhs -i../lib StringTest && ../bin/eval > StringTest.out && diff StringTest.ref StringTest.out
../bin/mhs -i../lib ListTest && ../bin/eval > ListTest.out && diff ListTest.ref ListTest.out
../bin/mhs -i../lib Fac && ../bin/eval > Fac.out && diff Fac.ref Fac.out
../bin/mhs -i../lib Misc && ../bin/eval > Misc.out && diff Misc.ref Misc.out
../bin/mhs -i../lib Case && ../bin/eval > Case.out && diff Case.ref Case.out
../bin/mhs -i../lib Arith && ../bin/eval > Arith.out && diff Arith.ref Arith.out

Every diff above is silent β€” all seven pass. Now the money shot, the same bootcombtest target chapter 08's Try-it ran without type checking β€” first make bootcombtest whole, then each of its three lines timed separately the way chapter 08's own Try-it does:

nix develop $MICROHS -c bash -c 'time make bootcombtest'
bin/mhs -ilib -isrc -omhs.comb MicroHs.Main
bin/mhs -ilib -isrc -omain-mhs.comb  MicroHs.Main
bin/eval -H50000000 -rmhs.comb --  -ilib -isrc -omain-comb.comb MicroHs.Main
cmp main-mhs.comb main-comb.comb

real	0m37.674s
nix develop $MICROHS -c bash -c 'time bin/mhs -ilib -isrc -omain-mhs.comb MicroHs.Main'
real	0m1.794s
nix develop $MICROHS -c bash -c 'time bin/eval -H50000000 -rmhs.comb -- -ilib -isrc -omain-comb.comb MicroHs.Main'
real	0m32.254s

cmp prints nothing and exits 0: main-mhs.comb and main-comb.comb β€” 140,782 bytes each, confirmed with ls -la and a second explicit cmp run β€” are byte-identical. Compare against chapter 08's own numbers at this chapter's own starting point, 298c9824 (75,052-byte outputs, 0.890s native / 20.715s interpreted, no type checking at all): the typed compiler's self-compiled output is nearly twice the size, the native compile is about twice as slow (1.794s), and the interpreted step is about 55% slower (32.254s) β€” and every byte of that output now passed through unify on the way, the same combinator-level oracle chapter 08 established, now proving something chapter 08 couldn't: that the type checker agrees with itself about its own source, not just that the untyped compiler does.

Exercise 1: a deliberate type error, traced to its source

cat > TypeErr.hs <<'EOF'
module TypeErr(module TypeErr) where
import Prelude

bad :: Int
bad = 1 + "hello"

main :: IO ()
main = putStrLn (showInt bad)
EOF
nix develop $MICROHS -c ./bin/mhs -ilib TypeErr
mhs: Cannot unify [Char] and Primitives.Int
EApp (EVar "Data.List.[]") (EVar "Primitives.Char") - EVar "Primitives.Int"
Map [("Data.Char.String",ETypeScheme [] (EApp (EVar "Data.List.[]") (EVar "Primitives.Char")))]
CallStack (from HasCallStack):
  error, called at src/MicroHs/TypeCheck.hs:334:11 in main:MicroHs.TypeCheck
  unifyR, called at src/MicroHs/TypeCheck.hs:326:3 in main:MicroHs.TypeCheck
  ...

(exit code 1; the rest of the CallStack β€” more unify/unifyR frames from +'s own two-argument check β€” omitted above.) The message comes from unifyR's bad binding (a38c64ce:src/MicroHs/TypeCheck.hs line 334, quoted): bad = error $ "Cannot unify " ++ showExpr a ++ " and " ++ showExpr b ++ "\n". 1 + "hello" needs "hello"'s type, [Char] (String is a type synonym for it, hence the Map line naming Data.Char.String), to unify with +'s second-argument type, Int β€” an EApp/EVar mismatch that unifyR's structural case rejects immediately, exactly as aux/hindley-milner-gently.md's unification section traces by hand.

Exercise 2: remove a signature the checker needs

(python3 here comes from the host system, not the devShell β€” the same caveat chapter 01 discloses for diff.)

python3 -c "
with open('lib/Data/List.hs') as f:
    lines = f.readlines()
out = [l for l in lines if l.strip() != 'null :: forall a . [a] -> Bool']
with open('lib/Data/List.hs', 'w') as f:
    f.writelines(out)
"
nix develop $MICROHS -c ./bin/mhs -ilib Example
mhs: undefined variable null
CallStack (from HasCallStack):
  error, called at src/MicroHs/TypeCheck.hs:382:16 in main:MicroHs.TypeCheck

Inference doesn't cope β€” there is no inference step for a missing top-level signature to fall back on. addValueType (a38c64ce:src/MicroHs/TypeCheck.hs lines 553–559) only adds a name to the value table when it sees a Sign; a Fcn with no matching Sign in the same module never gets one, and tcDefValue's tLookup i on that name hits the "not found" branch directly β€” even though null is never called anywhere in Example.hs, lib/Data/List.hs itself still has to type-check as a whole module once anything imports it, and every one of its top-level bindings, used or not, needs its own signature to be checked at all. Restore the file before continuing:

git checkout -- lib/Data/List.hs

Exercise 3: hand-infer a Prelude function, then check the source

lib/Data/Function.hs, flip:

flip :: forall a b c . (b -> a -> c) -> a -> b -> c
flip f a b = f b a

Hand-inferring flip f a b = f b a's body against the checker's own algorithm (tcExpr''s EApp case: to check g x, check x first with no expected type, then check g against (type of x) -> (fresh result)):

  1. f b a is EApp (EApp f b) a. Checking the outer application: a (the argument) gets a type first β€” it's a lambda-bound variable, so its type is whatever fresh metavariable flip's own ELam case gave it when it bound a; call that Ο„_a. The result of the whole expression gets a fresh metavariable too, Ο„_r. Then f b is checked against the expected type Ο„_a -> Ο„_r.
  2. f b is itself EApp f b. b's type is its own binding's metavariable, Ο„_b. f is checked against Ο„_b -> (Ο„_a -> Ο„_r). f is a bound variable too, with its own metavariable Ο„_f from flip's outermost ELam; unifying Ο„_f against Ο„_b -> (Ο„_a -> Ο„_r) binds it directly (f's type is Ο„_b -> Ο„_a -> Ο„_r, nothing left to check).
  3. Unwinding: flip's constructed type is Ο„_f -> Ο„_a -> Ο„_b -> Ο„_r, substituting step 2's result for Ο„_f: (Ο„_b -> Ο„_a -> Ο„_r) -> Ο„_a -> Ο„_b -> Ο„_r. Unified against the declared (b -> a -> c) -> a -> b -> c: Ο„_b ↦ b, Ο„_a ↦ a, Ο„_r ↦ c, every metavariable resolving to exactly the signature's own names, no contradiction β€” the same shape aux/hindley-milner-gently.md's const walk-through ends on.

Checked against the source: a38c64ce:lib/Data/Function.hs's own signature for flip is exactly forall a b c . (b -> a -> c) -> a -> b -> c β€” matching the hand-inferred type above term for term. Confirmed live, not just by inspection, with a program that uses it at a concrete type:

cat > FlipDemo.hs <<'EOF'
module FlipDemo(module FlipDemo) where
import Prelude

main :: IO ()
main = putStrLn (showInt (flip (-) 3 10))
EOF
nix develop $MICROHS -c ./bin/mhs -ilib FlipDemo
nix develop $MICROHS -c ./bin/eval
7

(flip (-) 3 10 = (-) 10 3 = 7, b ↦ Int, a ↦ Int, c ↦ Int all instantiated to the same concrete type here, which is why this one call doesn't distinguish flip's type from a monomorphic one β€” the point of the hand-trace above is that the checker never needed to know that in advance.)

Clean up:

git worktree remove --force /tmp/mhs-a38c64ce

🌐 ν•œκ΅­μ–΄λ‘œ 읽기

Background for chapter 01.

Lambda calculus and SKI combinators

This is the theory Lam.hs and eval.c are built on, told through the actual first-commit code rather than abstractly. If you've never seen lambda calculus or combinators before, this is the whole prerequisite.

1. Exp: an AST for lambda calculus, plus three pragmatic extras

ccb31388:Lam.hs (lines 44–51):

data Exp
  = Var Ident
  | App Exp Exp
  | Lam Ident Exp
  | Int Integer
  | Comb String
  | Prim String
  deriving (Show, Eq)

Var, App, Lam are the three term formers of the lambda calculus, verbatim: a variable reference, a function application, and a variable-binding abstraction (Lam "x" e is \x. e). Everything you can build in the pure lambda calculus is one of these three.

The other three constructors are not lambda calculus β€” they're what a compiler needs to be useful. Int is a machine integer literal, so arithmetic doesn't have to be Church-encoded. Prim names a built-in operation like "+" or "-" that eval.c implements natively. Comb names a combinator constant (S, K, I, ...) β€” these are what compile introduces; see bracket-abstraction.md. (The file also defines pattern synonyms like App2, CI, CK β€” those are just readability sugar over these six constructors, not new syntax.)

2. Beta reduction, on paper

ccb31388:Lam.hs (lines 36–39):

e1 :: Exp
e1 = Lam "x" $ App2 (Prim "+") (Var "x") (Var "x")
e2 :: Exp
e2 = Lam "x" $ Lam "y" $ App2 (Prim "-") (Var "y") (Var "x")

putStrLn (toString e1) prints (\x. + x x) and putStrLn (toString e2) prints (\x. (\y. - y x)) (checked in ghci). The one computation rule of lambda calculus is beta reduction: applying a lambda to an argument substitutes the argument for the bound variable throughout the body.

(\x. + x x) 5  -->  [x := 5](+ x x)  =  + 5 5  -->  10

That's ordinary math, not a code claim. Here's the code claim, machine verified: does Lam.hs's own reduce function actually perform this step?

ghci> pp (reduce (App e1 (Int 5)))
(\x. + x x) 5

It does not reduce β€” the term comes back unchanged. reduce/redOne (lines 137–161) pattern-match on combinators (CI, CK, CS, ...) and on Prim applications; there is no case for Lam or for "apply a Lam to an argument." Beta reduction on raw Var/Lam terms is simply not implemented anywhere in this file. That's the subject of the next section.

3. Why variables are a problem for a machine

To implement beta reduction directly, an interpreter needs to substitute into a term while avoiding variable capture β€” if you naively substitute y for x in \y. x, the x you're replacing must not be captured by the enclosing \y, so a correct substitution has to rename bound variables on the fly. The usual production fix is to not substitute at all: keep the term as-is and carry an environment (a map from names to values/closures) that grows every time you enter a Lam.

eval.c's node representation has no such thing:

typedef struct node {
  enum node_mark mark;
  enum node_tag tag;
  union {
    value_t value;
    struct {
      struct node *fun;
      struct node *arg;
    } s;
  } u;
} node;

A node is a tag plus either a value or a (fun, arg) pair β€” nothing that looks like an environment or a closure (there is no env/closure field anywhere in the 539-line file). eval.c's reduction loop (the eval function, see section 5) never needs to look up a variable by name, because by the time a term reaches eval.c it doesn't have any variables left. That transformation β€” eliminating Var/Lam entirely before runtime β€” is exactly what compile in Lam.hs does, and it's called bracket abstraction (see the companion doc).

4. S, K, I

Three combinators are enough to express any closed lambda term:

combinatordefinitionreduction rule
I\x. xI x = x
K\x y. xK x y = x
S\f g x. f x (g x)S f g x = f x (g x)

Each rule is a pure graph rewrite: match the shape on the left, produce the shape on the right. No name lookup, no substitution, no capture to worry about β€” because there are no names. That's the punchline: a term built only from S, K, I, primitives, and applications can be executed by a machine that just pattern-matches on node shape, which is exactly what eval.c's eval loop does.

Concretely, compare section 2's failure to the compiled version:

ghci> pp (reduce (App (compile e1) (Int 5)))
10

compile e1 turns \x. + x x into S (S (K +) I) I (see bracket-abstraction.md for the derivation) β€” a term with no Var/Lam in it β€” and that term, reduce handles fine.

5. The extended set: B, C, S', C', B', T, Y

S/K/I are enough in principle, but S-only translations blow up in size (see the companion doc). Lam.hs and eval.c share a larger combinator set. Here is the reduction switch, trimmed to the combinator cases (ccb31388:eval.c, from the eval function, lines 380–474; each ... marks elided stack-bookkeeping lines β€” CHECK/GCCHECK guards, f/g/x/k argument fetches, and POP/TOP adjustments β€” and the unreachable break; after each GOTO is also omitted):

    case S:
      ...
      FUN(n) = new_ap(f, x);
      ARG(n) = new_ap(g, x);
      GOTO ap;
    case SS:
      ...
      FUN(n) = new_ap(k, new_ap(f, x));
      ARG(n) = new_ap(g, x);
      GOTO ap;
    case K:
      ...
      SETIND(n, x);
      GOTO ind;
    case T:
      ...
      x = ARG(TOP(2));
      ...
      SETIND(n, x);
      GOTO ind;
    case I:
      ...
      SETIND(n, x);
      GOTO ind;
    case Y:
      ...
      FUN(n) = f;
      ARG(n) = n;
      GOTO ap;
    case B:
      ...
      FUN(n) = f;
      ARG(n) = new_ap(g, x);
      GOTO ap;
    case C:
      ...
      FUN(n) = new_ap(f, x);
      ARG(n) = g;
      GOTO ap;
    case CC:
      ...
      FUN(n) = new_ap(k, new_ap(f, x));
      ARG(n) = g;
      GOTO ap;

(pretty(), lines 288–316, prints tag SS as S', BB as B', and CC as C' β€” the enum names and the source-language names don't match, which is worth knowing before you go looking for a case S' that doesn't exist.) Lam.hs's own reference reducer, redOne (lines 147–161; the trailing primitive-arithmetic case and the Nothing catch-all are elided, marked ...), states the same rules purely in terms of Exp:

redOne :: Exp -> Maybe Exp
redOne (App CI x) = Just x
redOne (App (App CK x) _) = Just x
redOne (App (App CT _) x) = Just x
redOne (App3 CS f g x) = Just $ App (App f x) (App g x)
redOne (App3 CB f g x) = Just $ App      f    (App g x)
redOne (App3 CC f g x) = Just $ App (App f x)      g
redOne (App4 CS' k f g x) = Just $ App2 k (App f x) (App g x)
redOne (App4 CB' k f g x) = Just $ App2 k      f    (App g x)
redOne (App4 CC' k f g x) = Just $ App2 k (App f x)      g
...

One rule per combinator, all machine-checked against the running code (reduce in Lam.hs, and the real eval binary compiled from eval.c):

combinatorrulechecked
I x= xreduce (App CI (Int 9)) β†’ 9; eval on (I 9) β†’ 9
K x y= xreduce (App2 CK (Int 1) (Int 2)) β†’ 1; eval on ((K 1) 2) β†’ 1
T x y= yreduce (App2 CT (Int 1) (Int 2)) β†’ 2; eval on ((T 1) 2) β†’ 2
S f g x= f x (g x)reduce (App3 CS (Prim "+") CI (Int 5)) β†’ 10; eval on (((S +) I) 5) β†’ 10
B f g x= f (g x)reduce (App3 CB (App CK (Int 100)) CI (Int 7)) β†’ 100; eval on (((B (K 100)) I) 7) β†’ 100
C f g x= f x greduce (App3 CC (Prim "-") (Int 3) (Int 5)) β†’ 2 (i.e. 5 - 3); eval on (((C -) 3) 5) β†’ 2
S' k f g x= k (f x) (g x)reduce (App4 CS' (Prim "+") CI CI (Int 5)) β†’ 10; eval on ((((S' +) I) I) 5) β†’ 10
C' k f g x= k (f x) greduce (App4 CC' (Prim "-") CI (Int 3) (Int 10)) β†’ 7; eval on ((((C' -) I) 3) 10) β†’ 7
B' k f g x= k f (g x)reduce (App4 CB' (Prim "-") (Int 10) CI (Int 3)) β†’ 7 β€” but see below
Y f= f (Y f) (self-application node, line 434: ARG(n) = n)exercised by eNfib; ./eval nfib.comb prints 29860703 (nfib 35)

One real discrepancy worth flagging, since this doc's rule is "verify, don't assume": B' (BB in the enum) is declared in eval.c's node_tag enum, has parser support ('B' followed by '), and has a pretty() case β€” but the eval switch has no case BB:. Feeding eval.c a term that needs to reduce a B' node crashes it:

$ ./eval test_bprime.comb      # ((((B' -) 10) I) 3)
((((B' -) 10) I) 3)
bad tag 12
ERR: eval tag

(Tag 12 is BB β€” count the enum node_tag list: FREE=0, IND=1, AP=2, INT=3, S=4, K=5, I=6, B=7, C=8, T=9, Y=10, SS=11, BB=12, ....) Lam.hs's own redOne does implement B', so the two files disagree here β€” a small, genuine gap in the very first commit, not a mistake in this doc. As the companion doc shows, it happens not to matter in practice: B' is never actually produced by compile/improve in this commit.

Next: bracket-abstraction.md β€” how compile turns Var/Lam terms into exactly these combinators.

🌐 ν•œκ΅­μ–΄λ‘œ 읽기

Background for chapter 01.

Bracket abstraction

lambda-calculus-and-ski.md established that eval.c can only run combinator terms β€” no Var, no Lam. Bracket abstraction is the algorithm that gets you from one to the other: given Lam x body, it produces a combinator expression E with no free x such that E applied to any argument behaves like body did with x bound to that argument. Do this recursively from the inside out and every Lam in a term disappears, variable by variable.

1. compile and abstract

ccb31388:Lam.hs, lines 92–101, quoted whole (both functions together are ten lines):

compile :: Exp -> Exp
compile (App f a) = App (compile f) (compile a)
compile (Lam x a) = abstract x a
compile e = e

abstract :: Ident -> Exp -> Exp
abstract x e@(Var y) | x == y = CI
abstract x (App f a) = App2 CS (abstract x f) (abstract x a)
abstract x (Lam y e) = abstract x $ improveC $ abstract y e
abstract x e = App CK e

compile walks the term recursing into App and, on hitting a Lam x a, hands off to abstract x a β€” "eliminate x from a." compile e = e covers Var/Int/Comb/Prim: nothing left to do to a term with no App/Lam at its root.

abstract x e has exactly four equations, and each one is a standard bracket-abstraction rule:

  • abstract x (Var y) | x == y = CI β€” abstracting x out of x itself gives the identity: (\x. x) = I.
  • abstract x (App f a) = App2 CS (abstract x f) (abstract x a) β€” to abstract x out of an application, abstract it out of both sides and glue them with S, since S is exactly "apply both branches to the same argument": S (abstract x f) (abstract x a) y = (abstract x f) y ((abstract x a) y).
  • abstract x (Lam y e) = abstract x $ improveC $ abstract y e β€” a nested Lam is abstracted from the inside out: first eliminate the inner bound variable y (recursively), simplify what comes out (improveC, section 3), then eliminate x from that.
  • abstract x e = App CK e (the catch-all: e is Var y with y /= x, or Int/Comb/Prim) β€” x doesn't occur free in e at all, so the result must ignore whatever it's applied to and always yield e: that's K e, since K e y = e.

Together these four equations are a complete, mechanical proof that abstract never leaves a Var x behind: every case either replaces x directly (CI), or recurses into strictly smaller subterms (App, Lam), or removes x because it isn't there (App CK e).

2. Worked trace: compile e1

e1 = Lam "x" $ App2 (Prim "+") (Var "x") (Var "x"), i.e. \x. + x x. compile e1 = abstract "x" (App (App (Prim "+") (Var "x")) (Var "x")). Applying the App equation, then recursing into both sides:

abstract x (App (Prim "+") (Var "x"))
  = App2 CS (abstract x (Prim "+")) (abstract x (Var "x"))
  = App2 CS (App CK (Prim "+"))     CI
  = S (K +) I

abstract x (Var "x") = CI

compile e1 = App2 CS (S (K +) I) CI = S (S (K +) I) I

Machine-verified (nix develop $MICROHS -c ghci Lam.hs, then pp (compile e1)):

ghci> pp (compile e1)
S (S (K +) I) I

matches the hand trace exactly. And it runs: reduce (App (compile e1) (Int 5)) gives 10, confirmed in ghci β€” the combinator form does what \x. + x x applied to 5 should.

3. improve

S (S (K +) I) I is already redundant: S (K f) I is the classic identity S (K f) I x = (K f) x (I x) = f (I x) = f x, i.e. S (K f) I and f behave identically β€” the S (K f) I wrapper buys nothing. improveC (ccb31388:Lam.hs, lines 106–117) spots exactly this and similar patterns and rewrites them away:

improveC :: Exp -> Exp
improveC (App f a) =
  case (improveC f, improveC a) of
    (App CS (App CK e1),     App CK e2) -> improve (App CK (App e1 e2))
    (App CS (App CK e1),     CI)        -> e1
    (App CS (App CK e1),     e2)        -> improve (App2 CB e1 e2)
    (App CS         e1,      App CK e2) -> improve (App2 CC e1 e2)
    (App CS (App2 CB e1 e2), e3)        -> improve (App3 CS' e1 e2 e3)
    (App CC (App2 CB e1 e2), e3)        -> improve (App3 CC' e1 e2 e3)
--    (CK,                     CI)        -> CT
    (e1,                     e2)        -> App e1 e2
improveC e = e

improve = improveT . improveC (line 104); improveT (lines 119–135) does one more pass in the same style. Its first equation is a second, smaller wrapper-removal rule (improveC's own copy of it, at line 115 above, is commented out β€” this is the live version):

    (CK,                     CI)        -> CT

K and I glued together by an enclosing S/C/B collapse to the single combinator T (T x y = y, from lambda-calculus-and-ski.md). The rest of improveT canonicalizes commutative/flippable primitives so improveC's CC-rules apply to them too β€” not shown here in full, but the same "spot a pattern, rewrite it" style.

Applied to S (S (K +) I) I: the inner S (K +) I matches (App CS (App CK e1), CI) -> e1 and collapses straight to +. The outer application is now App2 CS (Prim "+") CI, whose pieces (App CS (Prim "+"), CI) don't match any of the five special patterns above (the left side isn't App CS (App CK e1) or App CS (App2 CB e1 e2), and the right side isn't App CK e2), so it falls through to the default case unchanged apart from the inner collapse, giving S + I. Machine-verified:

ghci> pp (improve (compile e1))
S + I

Three combinators instead of five, and reduce (App (improve (compile e1)) (Int 5)) still gives 10.

4. Why this matters: naive S-only translation explodes

The plain S/K/I bracket-abstraction rules (abstract's four equations, using only CS/CK/CI) are enough on their own β€” the App/Lam equations never need improveC to terminate. But every Lam you abstract wraps its whole body in another layer of Ss, and nested Lams multiply that: abstract x (Lam y e) first fully abstracts y out of e (an S/K/I term the size of e), and then abstracts x out of that whole term β€” so k nested lambdas can blow the combinator count up exponentially in k. Concretely, in abstract's own App case, App2 CS (abstract x f) (abstract x a) duplicates every occurrence of x's eventual argument once per S, even when x doesn't occur in f or a at all (that's exactly the S (K _) I/S (K _) e waste improveC is cleaning up above).

improveC's extra combinators are the standard Kiselyov-style fix:

  • B f g x = f (g x) β€” used when x occurs only in the second argument (App CK e1 on the left with a non-trivial right side): no need for S to duplicate x into both branches when only one branch uses it.
  • C f g x = f x g β€” the mirror case: x occurs only in the first argument.
  • S' k f g x = k (f x) (g x) and C' k f g x = k (f x) g β€” these arise when the left side of an S-node is itself a B-combinator application (App CS (App2 CB e1 e2) e3 / App CC (App2 CB e1 e2) e3): instead of stacking a B inside an S, fuse them into one combinator that does the composition and the branching in a single reduction step.

Each rewrite either removes a redundant S/K/I wrapper outright or merges two combinator applications into one, so the output shrinks instead of growing with nesting depth. One member of the extended set is notably absent: nothing in abstract, improveC, or improveT ever constructs B' (B' k f g x = k f (g x)) β€” the only mentions of CB' in the whole file are its pattern-synonym declaration (lines 62 and 70) and its redOne reduction case (line 155). So in this commit B' can never appear in compiled output, which is why the missing case BB: in eval.c's switch (see the companion doc's section 5) goes unnoticed. A real, non-toy example: this commit's nfib function (eNfib, line 225 of Lam.hs) compiles via comp = improve . compile . toLam to (from ./eval nfib.comb):

((Y (((C' C) ((B (S (> 2))) (((C' (C' +)) (((S' (S' +)) ((C B) (-' 1))) ((C B) (-' 2)))) 1))) 1)) 35)

β€” dense with B, C, S', C', and only two bare S/K/I-free uses of S, for a function with three variables (nfib, n, and the implicit recursion argument) referenced many times. Without improve, every one of those references would cost a separate S (K _) _ wrapper.

5. Check your understanding

e3 = Lam "x" $ Lam "y" $ Var "y", i.e. \x. \y. y β€” a function that ignores its first argument. Before running anything, use abstract's four equations from section 1 to predict compile e3, then predict what improve does to your answer (hint: section 3's (CK, CI) -> CT rule). Then check yourself:

nix develop $MICROHS -c ghci Lam.hs
ghci> pp (compile e3)
ghci> pp (improve (compile e3))

Machine-verified expected answers (do not peek until you've predicted): compile e3 is K I, and improve (compile e3) is T β€” the two-argument "return the second argument" combinator, T x y = y, which lambda-calculus-and-ski.md already covered. That match (improveC's commented-out (CK, CI) -> CT rule at line 115 is dead in improveC, but the live (CK, CI) -> CT rule in improveT, line 122, catches it) is itself a small confirmation that abstract + improve really do collapse \x. \y. y down to the simplest combinator that has that behavior.

🌐 ν•œκ΅­μ–΄λ‘œ 읽기

Background for chapter 01.

Graph reduction and laziness

lambda-calculus-and-ski.md and bracket-abstraction.md get you to a combinator term with no Var/Lam left. This doc is about the other half: ccb31388:eval.c, the ~540-line C program that actually runs that term. The central idea is that a term isn't a tree that gets copied around β€” it's a graph of mutable cells, and reduction rewrites cells in place. That one design choice is where sharing and laziness both come from.

1. A cell is a tag plus two pointers (or a value)

ccb31388:eval.c (lines 23–33):

typedef struct node {
  enum node_mark mark;
  enum node_tag tag;
  union {
    value_t value;
    struct {
      struct node *fun;
      struct node *arg;
    } s;
  } u;
} node;

and the tag enum (line 17):

enum node_tag { FREE, IND, AP, INT, S, K, I, B, C, T, Y, SS, BB, CC, ADD, SUB, MUL, DIV, MOD, SUBR, EQ, NE, LT, LE, GT, GE };

Every node in the heap is one of these. An AP node is a function application: its fun/arg union fields (accessed via the FUN/ARG macros) point at two other cells. A combinator like S or K is a node that carries no payload at all β€” the tag alone is the value. INT nodes use the value field instead of fun/arg. The whole compiled program is one big tree of AP cells with combinator/INT/prim leaves β€” but as section 3 shows, "tree" stops being accurate the moment reduction starts, because cells get reused and cross-linked.

2. The spine stack and the S rewrite

eval's job is to find the leftmost-outermost redex. It does this by walking down the left spine of applications β€” FUN, FUN, FUN, ... β€” pushing every node it passes onto an explicit stack, until it hits something that isn't an AP. The stack macros (ccb31388:eval.c, lines 99–101):

#define PUSH(x) stack[++stack_ptr] = (x)
#define TOP(n) stack[stack_ptr - (n)]
#define POP(n) stack_ptr -= (n)

and the loop that uses them (lines 364–377, the AP unwinding step only):

  PUSH(n);
  for(;;) {
    num_reductions++;
    switch (TAG(n)) {
    ind:
    case IND:
      n = INDIR(n);
      TOP(0) = n;
      break;
    ap:
    case AP:
      n = FUN(n);
      PUSH(n);
      break;

TOP(0) is the innermost node just found, TOP(1) the one one level out, and so on β€” after unwinding k applications, TOP(k) is the outermost node, the root of the redex. Once the switch lands on a combinator tag with enough arguments pushed above it, it reads the arguments off the stack via TOP/ARG and rewrites. Here is case S in full (lines 380–391):

    case S:
      CHECK(3);
      GCCHECK(2);
      f = ARG(TOP(1));
      g = ARG(TOP(2));
      x = ARG(TOP(3));
      POP(3);
      n = TOP(0);
      FUN(n) = new_ap(f, x);
      ARG(n) = new_ap(g, x);
      GOTO ap;
      break;

CHECK(3) bails out (returns without reducing) if fewer than three arguments are on the stack β€” S is only a redex when fully applied. Otherwise f, g, x are read off the spine, the three intermediate nodes are popped, and n = TOP(0) recovers the outermost node of the redex β€” the original ((S f) g) x application cell. That cell's FUN/ ARG fields are then overwritten to new_ap(f, x) and new_ap(g, x): the node is turned, in place, into f x (g x), exactly the S rule from lambda-calculus-and-ski.md. GOTO ap restarts unwinding from this now-rewritten node β€” section 3 traces what happens next.

3. Sharing: x is fetched once, not copied

Look again at case S: x = ARG(TOP(3)) reads one pointer, and that same pointer value is used in both new_ap(f, x) and new_ap(g, x). The two x-occurrences in f x (g x) are not two copies of a subterm β€” they are two AP cells whose arg field happens to point at the identical heap cell. That is the whole mechanism of sharing in one line of C.

Take the smallest term that exercises it, S f g x itself, as (((S +) I) ((+ 2) 3)) β€” i.e. + x (I x) with x = 2 + 3. Before the S-reduction, the term is three stacked AP cells (call them n_c, the outermost, applied last) over a leaf S, plus the separate subgraph X for ((+ 2) 3):

n_c ──fun──▢ n_b ──fun──▢ n_a ──fun──▢ [S]
 β”‚            β”‚            └──arg──▢ [+]
 β”‚            └──arg──▢ [I]
 └──arg──▢ X            where X = AP(AP(+, 2), 3)

Tracing case S against this graph exactly as in section 2: f = ARG(n_a) = +, g = ARG(n_b) = I, x = ARG(n_c) = X, then n = TOP(0) = n_c β€” the outermost cell, reused as the answer. After the rewrite:

n_c ──fun──▢ n_d = AP(+, X)
 └──arg──▢ n_e = AP(I, X)
                    β”‚
        both n_d.arg and n_e.arg point at the SAME cell X

n_c itself was never freed and reallocated β€” its fun/arg fields were just overwritten, so any other pointer into n_c anywhere in the graph sees the rewritten form for free. And n_d/n_e both point at the one X cell, not at two copies of ((+ 2) 3).

Now the payoff. Unwinding restarts from n_c and lands on the ADD prim, whose ARITH2 macro (line 476) forces both operands: r = evalint(ARG(TOP(1))) op evalint(ARG(TOP(2))); with op = + β€” here ARG(TOP(1)) = X and ARG(TOP(2)) = n_e = AP(I, X). One honest caveat: C does not specify the evaluation order of +'s operands, so which of the two evalint calls runs first is the compiler's choice β€” and the sharing conclusion doesn't depend on it. Both operands lead to the same cell X: the first directly, the second through n_e, which case I turns into an indirection to X (section 4). Whichever side is forced first fully reduces X β€” the inner addition's SETINT(n, r) (line 475) overwrites X's own tag/value fields to INT 5, in place. When the other side is then forced, it arrives at that same cell β€” directly, or via evali's while (TAG(n) == IND) loop (lines 335–336) through n_e's indirection β€” and finds tag INT already there: it returns immediately, no re-addition. In either order, 2 + 3 runs exactly once; the second consumer just follows a pointer to a cell someone else already finished computing.

Verified by running the actual term (shared2.comb, the same idea with a 20-deep addition chain in place of 2 + 3 to make the effect large enough to see in the reduction counter, appears below). The small case:

$ ./eval shared.comb        # (((S +) I) ((+ 2) 3))
(((S +) I) ((+ 2) 3))
10
node size=24, heap size=100000
16 reductions, 0 GCs
$ ./eval duplicated.comb    # ((+ ((+ 2) 3)) ((+ 2) 3)) -- two independent copies of (+ 2 3)
((+ ((+ 2) 3)) ((+ 2) 3))
10
node size=24, heap size=100000
13 reductions, 0 GCs

Both give 10, as they must. But the shared version does more reductions here (16 vs 13) β€” for a two-step addition, the constant overhead of the extra S and I rewrites outweighs the one addition it saves. Sharing isn't free; it costs an indirection and an extra application node. To see sharing actually win, the shared subterm has to be expensive enough that recomputing it costs more than the S/I overhead. Replacing 2 + 3 with a 20-deep nested addition (((...((+ 1) 1)...) 1), still x + x via the same S + I shape) makes the saving obvious:

$ ./eval shared2.comb
42
node size=24, heap size=100000
92 reductions, 0 GCs
$ ./eval dup2.comb          # x + x with x written out twice (no sharing)
42
node size=24, heap size=100000
165 reductions, 0 GCs

Same answer, 92 vs 165 reductions β€” the duplicated version pays for the 20-deep chain twice; the shared version pays once. That is "evaluate once," measured, not asserted.

4. Indirection nodes: yes, they exist at commit 1

It would be easy to guess that the first commit's evaluator is too crude for indirection nodes β€” it is not. IND is tag 1 in the enum quoted in section 1, and it is created and followed in several places.

Creation, via the SETIND macro (line 361):

#define SETIND(n, x) do { TAG((n)) = IND; INDIR((n)) = (x); } while(0)

used by case K, case T, case I (the identity rewrites β€” each turns its redex cell into a pointer to its result rather than copying the result in), and by the comparison primitives, whose shared macro (line 496) sets the result cell to indirect to the boolean combinators T/K:

#define CMP(op) do { CHECK(2); r = evalint(ARG(TOP(1))) op evalint(ARG(TOP(2))); n = TOP(2); SETIND(n, r ? combT : combK); POP(2); } while(0)

case I itself (lines 421–428):

    case I:
      CHECK(1);
      x = ARG(TOP(1));
      POP(1);
      n = TOP(0);
      SETIND(n, x);
      GOTO ind;
      break;

β€” I x doesn't allocate a fresh copy of x; it turns the I x application cell itself into an indirection to x. This is the same in-place-update trick as S (section 3), specialized to the case where the "new graph" is just the existing argument.

Following happens in three places: inside eval's own loop (case IND, quoted in section 2, n = INDIR(n); TOP(0) = n;), in evali (lines 327–338, while (TAG(n) == IND) n = INDIR(n); after eval returns β€” this is what callers use to get a "real" answer, never an indirection cell), and inside the garbage collector's mark (next section) β€” which also compresses indirection chains as a side effect, rewriting the root pointer past them (lines 165–171):

  if (TAG(n) == IND) {
    /* Skip indirections, and redirect start pointer */
    while (TAG(n) == IND) {
      n = INDIR(n);
    }
    *np = n;
  }

5. Memory: allocation and a real mark-and-scan GC

Cells come from one fixed-size array, allocated once (lines 11, 46):

#define HEAP_CELLS 100000
#define ALLOC_HEAP(n) do { cells = malloc(n * sizeof(node)); memset(cells, 0x55, n * sizeof(node)); } while(0)

100,000 cells, malloc'd up front and never grown β€” there's no free() of individual cells anywhere in the file; the only way a cell becomes available again is via the collector. alloc_node (lines 108–120) just pops the head of a free list:

NODEPTR
alloc_node(enum node_tag t)
{
  NODEPTR n = next_free;
  if (n == NIL) {
    ERR("out of memory");
  }
  if (TAG(n) != FREE)
    abort();
  next_free = NEXT(n);
  TAG(n) = t;
  return n;
}

And commit 1 genuinely does have a collector β€” mark-and-scan, called before allocation-heavy reductions via GCCHECK (e.g. case S, section 2: GCCHECK(2) before its two new_ap calls). gc_check only actually collects if the free list looks short (lines 212–222); gc, mark, and scan (lines 158–210) are a textbook stop-the-world mark/sweep: mark walks from every live root β€” every cell currently on stack[0..stack_ptr] β€” marking AP spines recursively (and compressing IND chains as shown above); scan then does one linear pass over the whole heap, threading every unmarked cell onto the free list and clearing marks on the rest (lines 198–210, three commented-out fprintf debug lines elided, marked ...):

void
gc(void)
{
  num_gc++;
  num_marked = 0;
  // ...
  for (int i = 0; i <= stack_ptr; i++)
    mark(&stack[i]);
  // ...
  scan();

  // ...
}

This is exercised for real, not just present in principle: running nfib 35 (see y-combinator-and-recursion.md) reports 1944 GCs over a billion-odd reductions.

What commit 1 does not have: no compaction or copying (cells never move β€” scan only relinks the free list, it doesn't shrink or defragment the heap array), no generations, no reference counting, and no growable heap (out of memory in alloc_node is a hard ERR/exit, not a request for more memory). The NODE_SPLIT and NODE_64 representations sketched in the #elif branches at the top of the file are unused alternatives β€” NODE_NAIVE (line 9: #define NODE_NAIVE) is the one #defined and compiled; struct node in section 1 is its representation.

6. Laziness falls out of this, it isn't added on top

Nothing above ever evaluates an argument before it's asked for. case S builds new_ap(f, x) and new_ap(g, x) and immediately GOTO aps back into the left spine β€” it never inspects x. x only gets forced when some later reduction step needs it as an INT (via evalint, e.g. inside ARITH2, line 476) or needs to pattern-match its outermost tag. An argument that's never demanded is simply never touched β€” it stays an unevaluated AP/combinator subgraph forever, at zero cost beyond the memory it occupies. Combine that with section 3's sharing and you get call-by-need for free: an argument is a graph, evaluated at most once, exactly when (and if) something forces it.

Next: y-combinator-and-recursion.md β€” the same in-place-update trick, applied to build a genuinely cyclic graph for recursion.

🌐 ν•œκ΅­μ–΄λ‘œ 읽기

Background for chapter 01.

The Y combinator and recursion

bracket-abstraction.md eliminates every Lam in a term. But nfib calls itself β€” how does a self-call survive translation into a language that, after compile, has no names left at all?

1. No names, no self-reference

abstract (see bracket-abstraction.md section

  1. only knows how to eliminate a bound variable from a term that contains it. A recursive definition like nfib n = ... nfib ... isn't naturally expressible this way: nfib isn't a parameter of anything β€” it's the name being defined, referring to itself, inside its own body. Once compile has erased Var/Lam, there is no environment and no name table at runtime (see lambda-calculus-and-ski.md section 3) for a name to even refer through. Combinator terms are closed by construction; something else has to supply the self-reference.

2. Y f = f (Y f), and what the reduction actually builds

The standard trick is the fixed-point combinator Y: Y f is defined to behave exactly like f (Y f) β€” apply f to "the very recursive value we're building." Turn nfib's body into an ordinary (non-recursive) function that takes its own recursive callee as an extra argument, nfibF = \nfib n -> ..., and Y nfibF is a value equal to nfibF (Y nfibF) β€” feed nfibF a copy of itself to call.

ccb31388:eval.c's reduction rule for Y (lines 429–437):

    case Y:
      CHECK(1);
      f = ARG(TOP(1));
      POP(1);
      n = TOP(0);
      FUN(n) = f;
      ARG(n) = n;
      GOTO ap;
      break;

n here is TOP(0) β€” the outermost cell of the redex, i.e. the AP(Y, f) node for Y f itself (compare case S in graph-reduction-and-laziness.md section 2, same pattern: read the argument, then reuse the outer cell as the answer). The rewrite sets FUN(n) = f and, critically, ARG(n) = n β€” the node's own argument field is set to point at itself. This is not "call Y f again to get another copy of f (Y f)" β€” it is one cell, mutated in place, that is now literally AP(f, n) where n is the very node holding that AP. Follow ARG from n and you arrive back at n. The graph for Y f is genuinely cyclic: a single self-referencing application cell, built once, with no bound on how many times it can be "unfolded" because unfolding it just means re-entering the same cell.

GOTO ap restarts the spine walk from n, now f (Y f) β€” exactly the fixed-point equation, but as a graph identity rather than a rewrite that has to be repeated. If f's body demands its second argument again deeper inside (the recursive call site), it dereferences the same self-looped cell n and finds it β€” by then β€” either still the same AP(f, n) (if the recursion hasn't been forced further) or already reduced further along the same self-link. Either way, f itself was fetched once and is never copied; only the spine around the self-reference grows as reduction proceeds.

3. eNfib: the recursive definition, and where Y enters it

ccb31388:Lam.hs (lines 193–194):

fix :: Exp -> Exp
fix e = App CY e

fix is nothing but "wrap in Y" β€” CY is the Comb "Y" pattern synonym (ccb31388:Lam.hs, line 72: pattern CY = Comb "Y"), and App CY e is the term Y e. eNfib (line 225):

eNfib = fix $ comp $ \ nfib n -> iF (n .< 2) 1 (nfib $$ (n-1) + nfib $$ (n-2) + 1)

\nfib n -> ... is the non-recursive nfibF from section 2, written with Haskell's own lambda syntax and turned into an Exp by the ToLam machinery (comp = improve . compile . toLam, line 219 β€” see bracket-abstraction.md for compile/improve). nfib $$ (n-1) and nfib $$ (n-2) are the two recursive calls, both against the parameter nfib, not a self-reference by name. fix applies Y to the whole compiled function, so eNfib = Y nfibF β€” and every occurrence of nfib inside the body ends up, after compile, referring to whatever Y's self-looped cell (section 2) resolves to when demanded. That compiled term, printed by ./eval nfib.comb before reduction, has Y right where the derivation predicts β€” wrapping the whole recursive function, applied to 35:

((Y (((C' C) ((B (S (> 2))) (((C' (C' +)) (((S' (S' +)) ((C B) (-' 1))) ((C B) (-' 2)))) 1))) 1)) 35)

(This is the same printed term bracket-abstraction.md section 4 already quotes as the running example of improve's output; the Y at the front is the part this doc is about.)

4. Recursion as sharing

Section 2's self-looped cell is the same in-place-update mechanism graph-reduction-and-laziness.md describes for ordinary combinators (S, K, I) β€” Y's reduction just uses it to point a cell at itself instead of at some other existing node. Every recursive call in a running nfib reuses the one f pointer case Y fetched at the start; nothing about f's code (its compiled combinator graph) is ever duplicated per call. Only the argument spine β€” the chain of pending n-1/n-2 applications β€” grows as recursion descends, and each of those is built and torn down by ordinary AP reduction, exactly as in the rest of the graph.

5. Verified: ./eval nfib.comb prints 29860703

$ cd /tmp/mhs-ccb31388 && nix develop $MICROHS -c make test
./eval nfib.comb
((Y (((C' C) ((B (S (> 2))) (((C' (C' +)) (((S' (S' +)) ((C B) (-' 1))) ((C B) (-' 2)))) 1))) 1)) 35)
29860703
node size=24, heap size=100000
1045124618 reductions, 1944 GCs

29860703 is nfib 35 β€” matching every earlier task's known-good result for this commit. The run also confirms graph-reduction-and-laziness.md's claim that GC is real and exercised, not just present in the source: 1944 GCs actually fired over the course of this one computation.

6. What changes later

This Y β€” one self-looped cell, reduced by case Y exactly as shown above β€” is what the first commit does. It is not the last word: commit d2956ee8, "Optimize Y better." (2023-06-02), revisits this implementation. What it changes is out of scope here; chapter 04 covers it.

🌐 ν•œκ΅­μ–΄λ‘œ 읽기

Background for chapter 02.

Parser combinators

Commit a9178695 ("Make a real compiler.", 2023-05-29) adds a new module, ParserComb.hs, 165 lines, and its name is not a metaphor: it is a small, self-contained parser-combinator library β€” a Prsr-style toolkit in the lineage of Wadler/Hutton parsing, built from ordinary Haskell type classes (Functor, Applicative, Monad, Alternative) rather than a parser generator or an external grammar file. Parse.hs, a second new module (204 lines), uses it to parse the surface syntax chapter 02 introduces. This doc walks the library itself; chapter 02 walks what it's used for.

1. The parser type

a9178695:ParserComb.hs, lines 22–44 (LastFail, Res, Prsr together, quoted whole):

data LastFail
  = LastFail Int [(String, [String])]
  deriving (Show)

noFail :: LastFail
noFail = LastFail maxBound []

longest :: LastFail -> LastFail -> LastFail
longest lf1@(LastFail l1 x1) lf2@(LastFail l2 x2) =
  case compare l1 l2 of
    LT -> lf1
    GT -> lf2
    EQ -> LastFail l1 (x1 ++ x2)

longests :: [LastFail] -> LastFail
longests [] = undefined
longests xs = foldl1 longest xs

data Res s a = Many [(a, (String, s))] LastFail
  deriving (Show)

newtype Prsr s a = P { runP :: (String, s) -> Res s a}
instance Show (Prsr s a) where show _ = "<<Prsr>>"

Strip away the error bookkeeping and the shape is the textbook one: a parser is a function from the remaining input to a result. Two things about this particular function are worth noticing before anything else:

  • It's not String -> Maybe (a, String). Res s a is Many [(a, (String, s))] LastFail β€” a list of (result, leftover) pairs, not one. This parser doesn't commit to the first successful parse; it can return several, and parseDie (a9178695:Parse.hs, lines 82–87) is the place that decides what to do if there's more than one: exactly one result is accepted, zero results becomes a formatted "found X, expected Y" message (formatFailed, a9178695:ParserComb.hs lines 101–113 β€” a live transcript of its output is in chapter 02's "Try it" exercise 2), and more than one becomes error $ "Ambiguous:\n" ++ ....
  • Prsr s a carries a second type parameter, s, threaded alongside the string. This is a stateful parser β€” s is arbitrary parser state, not fixed by the library. Parse.hs instantiates it as type P a = Prsr [Int] a, an indentation-column stack, and gets a full Haskell-style layout rule (section 4 below) without ParserComb.hs knowing anything about layout at all.
  • LastFail Int [(String, [String])] records, independent of whichever parse eventually succeeds, the furthest point the parser ever reached before failing, plus what it was expecting there (Int is a length β€” the parser measures "how far into the input" by how much string is left, so smaller remaining length means further progress; longest is misleadingly named β€” it's picking the shortest leftover, i.e. the parse that consumed the most). That's the raw material for a "found X, expected Y" error message, threaded through every combinator whether it succeeds or not.

2. Sequencing and choice: the type-class plumbing

Everything downstream β€” do-notation, <*>, <|> β€” comes from four instances, quoted whole (a9178695:ParserComb.hs, lines 46–75):

instance Functor (Prsr s) where
  fmap f p = P $ \ t ->
    case runP p t of
      Many aus lf -> Many [ (f a, u) | (a, u) <- aus ] lf

instance Applicative (Prsr s) where
  (<*>) = ap
  pure a = P $ \ t -> Many [(a, t)] noFail

instance Monad (Prsr s) where
  return = pure
  p >>= k = P $ \ t ->
    case runP p t of
      Many aus plf ->
        let xss = [ runP (k a) u | (a, u) <- aus ]
        in  let (rss, lfs) = unzip [ (rs, lf) | Many rs lf <- xss ]
            in  Many (concat rss) (longests $ plf : lfs)

instance MonadFail (Prsr s) where
  fail m = P $ \ (r, _) -> Many [] (LastFail (length r) [("fail", [m])])

instance MonadState s (Prsr s) where
  get = P $ \ t@(_, s) -> Many [(s, t)] noFail
  put s = P $ \ (f, _) -> Many [((), (f, s))] noFail

instance Alternative (Prsr s) where
  empty = P $ \ (r, _) -> Many [] (LastFail (length r) [("empty", ["<empty>"])])
  p <|> q = P $ \ t ->
    case (runP p t, runP q t) of
      (Many a lfa, Many b lfb) -> Many (a ++ b) (longest lfa lfb)

pure a succeeds without consuming input, returning a β€” the base case for sequencing. p >>= k is "parse with p, then for every result p produced, run k on it and parse with the resulting parser" β€” for a single-result parser this would just be function composition, but because Many can hold several (a, leftover) pairs, >>= runs k once per result and concatenates. Alternative's p <|> q runs both p and q on the same input and returns both sets of successes concatenated (a ++ b) β€” this is not "try p, fall back to q on failure" in the usual sense; both branches always run, both branches' results are kept (genuine ambiguity, not just backtracking), and only the two LastFail records get merged via longest.

That last point matters enough that the library ships a second, differently -behaved choice operator right next to it (a9178695:ParserComb.hs, lines 77–84):

-- Left biased choice
(<|<) :: Prsr s a -> Prsr s a -> Prsr s a
p <|< q = P $ \ t ->
  case runP p t of
    Many [] lfa ->
      case runP q t of
        Many b lfb -> Many b (longest lfa lfb)
    r -> r

<|< only tries q if p produced no results at all β€” ordinary committed choice, the behavior most parser-combinator tutorials call <|>. Parse.hs reaches for <|< exactly where determinism matters (a { for a real curly brace versus the indentation-triggered virtual one, section 4) and plain <|> everywhere the grammar is allowed to stay ambiguous and let parseDie sort it out later.

3. The primitives that touch characters

Above the type-class layer, ParserComb.hs defines the handful of functions that actually look at the input. satisfy, char, and eof (a9178695:ParserComb.hs, lines 119–141, string elided β€” same shape):

satisfy :: String -> (Char -> Bool) -> Prsr s Char
satisfy msg f = P $ \ t ->
  case t of
    (c:cs, s) | f c -> Many [(c, (cs, s))] noFail
    (cs, _) -> Many [] (LastFail (length cs) [("satisfy", [msg])])

char :: Char -> Prsr s Char
char c = P $ \ t ->
  case t of
    (c':cs, s) | c == c' -> Many [(c, (cs, s))] noFail
    (cs, _) -> Many [] (LastFail (length cs) [("char", [show c])])

...

eof :: Prsr s ()
eof = P $ \ t ->
  case t of
    ("", _) -> Many [((), t)] noFail
    (cs, _) -> Many [] (LastFail (length cs) [("eof", ["end-of-file"])])

Every one of them is the same four lines: look at the first character (or the whole string, for eof), succeed-and-consume or fail-and-record-why. satisfy takes an explicit msg string for its own failure record β€” the "lower case", "upper case", "digit", "symbol" strings visible in Parse.hs's error output (chapter 02's "Try it", exercise 2) all originate as this msg argument.

Above that, three more pieces of plumbing (a9178695:ParserComb.hs, lines 86–90 and 143–147, two separate spots in the file, quoted whole each):

emany :: Prsr s a -> Prsr s [a]
emany p = esome p <|< pure []

esome :: Prsr s a -> Prsr s [a]
esome p = (:) <$> p <*> emany p

...

(<?>) :: Prsr s a -> String -> Prsr s a
p <?> e = P $ \ t ->
--  trace ("<?> " ++ show e) $
  case runP p t of
    Many rs (LastFail l xs) -> Many rs (LastFail l [(m, e:es) | (m, es) <- xs ])

emany/esome are "zero-or-more" and "one-or-more" β€” the standard Kleene star and plus, mutually recursive in the usual way (esome p parses one p then emany p for the rest; emany p is esome p, or nothing). The library's export list also re-exports plain many and some unchanged from Control.Applicative (the --hiding (many, some) comment on the import Control.Applicative line is commented out β€” the import isn't actually restricted) β€” so Parse.hs has both the Alternative-derived many/some and this module's own eager emany/ esome in scope side by side, and in this range consistently reaches for the e-prefixed ones. <?> is the labeling combinator: run p, and if it produced any failure record, prepend e to every expectation string in it β€” this is how pKeyword, pSymbol, and friends attach a human-readable name ("module", "=", "int") to a failure that would otherwise just say "symbol" or "lower case".

4. One production, read aloud

Parse.hs builds a whole grammar this way β€” pModule, pDef, pExpr, pCase, and so on β€” each one a single <$>/<*>/<|> expression built from smaller parsers. Here is the top one, quoted whole (a9178695:Parse.hs, lines 94–95):

pModule :: P Module
pModule = Module <$> (pKeyword "module" *> pUIdent <* pKeyword' "where") <*> pBlock pDef

Read aloud: to parse a module, first parse the keyword module (*> discards its result β€” a keyword carries no data), then an uppercase identifier (pUIdent β€” this becomes the module name), then the keyword where (<* again discards it), then a block of definitions (pBlock pDef β€” a layout-delimited, semicolon-separated list of pDef, described below). Package the identifier and the definition list into a Module value with Module <$> ... <*> .... Nothing here is bespoke parsing code; it's four already-defined parsers (pKeyword, pUIdent, pKeyword', pBlock) glued with three already-defined combinators (<$>, *>/<*, <*>).

pBlock and its neighbors are worth a second look, because they show the s type parameter from section 1 earning its keep β€” a Haskell-style layout rule (real {/;/} or indentation-inferred ones) implemented entirely as user-level parsers over Prsr [Int] a, no changes to ParserComb.hs required (a9178695:Parse.hs, lines 176–204, quoted whole):

pLCurl :: P ()
pLCurl = pSym '{' <|< softLC
  where softLC = do
          msp <- pIndent
          case msp of
            Nothing -> fail "\\n"
            Just sp -> do
--              traceM ("push " ++ show (length sp))
              modify $ \ st -> length sp : st

pRCurl :: P ()
pRCurl = pSym '}' <|> softRC
  where softRC = do
          is <- get
          if null is then
            fail "}"
           else do
            put (tail is)
            eof

pSemi :: P ()
pSemi = pSym ';'

pBlock :: P a -> P [a]
pBlock p = do
  pLCurl
  as <- esepBy p pSemi
  pRCurl
  pure as

pLCurl opens a block: either a literal {, or β€” via <|<, committed choice, since a real { should never also try the indentation path β€” softLC, which reads the indentation of the next line (pIndent, not shown here) and pushes its column onto the state stack with modify. pRCurl closes one: a literal }, or softRC, which pops the stack β€” and, notably, only succeeds if that pop is followed immediately by eof, which is how the whole toy currently gets away with never emitting a virtual close-brace on dedent inside the file (pWhite, elsewhere in Parse.hs, injects } characters back into the input stream directly via inject, for the general case). esepBy (not shown; esepBy1 plus an empty-list fallback, both ordinary user code built from emany and <|>) handles the semicolon-separated middle. None of pLCurl, pRCurl, pSemi, or esepBy are ParserComb.hs exports with special status β€” they're Parse.hs-local functions built the same way pModule is, out of the same eleven-ish primitives from sections 2–3.

5. Why this style suits incremental growth

That's the actual payoff of writing the parser this way rather than, say, hand-rolling a state machine or reaching for a parser-generator tool: a new piece of syntax is a new combinator expression, built from the same building blocks, dropped in next to the others β€” not a rewrite of a recursive-descent function pushed to sixteen cases from twelve. It already happens once inside this very range: commit 4030f721 ("Parse more") adds a parenthesized-operator form ((+), to write an operator as an ordinary identifier) in twelve added lines total: a new pOper/ pOper' pair (nine of the twelve) built from esome/satisfy/guard exactly like every other parser in the file, pSymbol adjusted to reuse pOper' instead of repeating its definition, and one new alternative appended to pAExpr with <|>. chapter 03 does this repeatedly at larger scale β€” let, infix operators, tuples, list syntax, do-notation each land as a handful of new p-something definitions, not a restructuring of ParserComb.hs, which is untouched through all of it. The parser type from section 1 already has everything later syntax needs (sequencing, choice, state, error tracking); growing the language is purely additive at the grammar layer.

🌐 ν•œκ΅­μ–΄λ‘œ 읽기

Background for chapter 07.

Bootstrapping a compiler

"Self-hosting" means a compiler that can compile its own source code. It sounds circular, and the first time through it is: before any version of uhs exists, nothing can compile uhs's own sources β€” they're just Haskell text sitting on disk. MicroHs breaks the circle the ordinary way every self-hosting compiler does: use a different, already-working implementation of (a large-enough subset of) the same language to build the first working copy, then let that first copy take over. In this project the "different implementation" is GHC, already sitting in the nix develop shell every chapter of this book builds from.

The ladder, as it stands after chapter 07

By the time chapter 08 wires up dedicated make targets for it, the bootstrap ladder has three rungs, and the Makefile names them directly (456ce17c:Makefile lines 66–69, quoted whole):

bootcombtest:	$(BIN)/uhs uhs.comb
	$(BIN)/uhs -ilib -isrc -omain-uhs.comb  MicroHs.Main
	$(BIN)/eval -H10000000 -K1000000 -ruhs.comb --  -ilib -isrc -omain-comb.comb MicroHs.Main
	cmp main-uhs.comb main-comb.comb

Read as a ladder:

  1. GHC compiles the compiler. $(BIN)/uhs β€” an ordinary native binary β€” is built by GHC from src/MicroHs/Main.hs and everything it imports (456ce17c:Makefile line 23: $(GHCE) -package mtl -isrc -Wall -O src/MicroHs/Main.hs -main-is MicroHs.Main -o $(BIN)/uhs). Nothing self-referential here yet β€” it's the same "GHC builds a Haskell program" every earlier chapter's Try-it already does.
  2. bin/uhs compiles the same source. The uhs.comb rule (456ce17c:Makefile lines 80–81) runs $(BIN)/uhs -ilib -isrc -ouhs.comb MicroHs.Main β€” the GHC-built compiler reads its own src/MicroHs/*.hs sources and emits uhs.comb, a combinator file in the same textual format every chapter's own .comb output has used since chapter 02. This is the first genuinely self-hosting step: the compiler is compiling itself, just not running as itself yet.
  3. bin/eval runs uhs.comb, which compiles the source a second time. bootcombtest's middle line hands uhs.comb to the C runtime ($(BIN)/eval ... -ruhs.comb -- ... MicroHs.Main) β€” the runtime interprets the combinator file chapter 02 built the very first version of, and what it's interpreting is the compiler itself. That interpreted compiler then compiles MicroHs.Main from source again, writing main-comb.comb. Meanwhile the first line of the same target, $(BIN)/uhs -ilib -isrc -omain-uhs.comb MicroHs.Main, has the original GHC-built binary compile the identical source to main-uhs.comb, independently.
  4. Compare. cmp main-uhs.comb main-comb.comb β€” plain byte equality, no special-casing.

A second, parallel check exists alongside this one, built from the "local lib" infrastructure this chapter's own July 1st commits add (see chapter 07's "What happened"): bootboottest (456ce17c:Makefile lines 61–64, quoted whole):

bootboottest:	$(BIN)/uhs $(BIN)/bootuhs
	$(BIN)/uhs     -ilib -isrc -omain-uhs.comb  MicroHs.Main
	$(BIN)/bootuhs -ilib -isrc -omain-boot.comb MicroHs.Main
	cmp main-uhs.comb main-boot.comb

$(BIN)/bootuhs is a second native binary β€” still built by GHC, but compiled module-by-module against MicroHs's own hand-rolled lib/ sources (through the ghc/ primitives shim, -XNoImplicitPrelude, and the convertY.sh preprocessor described below) instead of against GHC's own base package. bootboottest checks that this alternate, library-swapped GHC build agrees with the ordinary one β€” a different axis of "does this still mean the same thing," orthogonal to running the combinator file through the interpreter.

Why byte-identical output is the oracle

Neither test asks "does the output look right." Both just cmp two files. That's deliberate, and it's the only oracle that scales here: a compiler bug β€” say, a case arm handled in the wrong order, or an operator compiled with the wrong associativity β€” doesn't have to crash anything to be a bug. It can produce a .comb file that runs fine but computes a different value from what the other build of the same compiler, compiling the same source, produces. Left uncaught, that kind of bug would happily compile itself into every future build. Byte-for-byte comparison at each rung turns "the compiler is subtly wrong" into "these two files differ" β€” a diff cmp reports immediately, instead of a bug that only surfaces if someone happens to write a test that exercises the exact miscompiled path. Chapter 07's whole grind β€” everything short of actually running bootcombtest for real, which is chapter 08's story β€” is the work of getting the compiler's own source into a state where rung 2 above (bin/uhs compiling itself) even produces a uhs.comb that parses and runs at all.

The constraint: uhs may only use uhs

For the ladder above to prove anything, the compiler's own source has to be legal input to both GHC and, eventually, itself. That's a much narrower dialect than ordinary Haskell β€” no type classes (grep -rn "class \|instance " across src/MicroHs/ and src/Text/ParserComb.hs in this chapter's own Try-it turns up nothing live, and neither does the same search over lib/), no deriving, no fixity declarations. Chapter 07 is largely the diet this constraint forces, three examples of it made concrete:

  • Int, not Integer. a721e046 ("Use Int instead of Integer") narrows EInt's payload from Integer to Int throughout src/MicroHs/Parse.hs/Desugar.hs β€” uhs's own Exp type had already committed to machine-width Int back in chapter 06's Translate.hs (its arithmetic table is typed Int -> Int -> Int), and the front end catches up to match.
  • A hand-rolled Map, because Data.Map needs an Ord instance and uhs has no instances at all: f113210f adds src/MicroHs/Map.hs, 33 lines, admitting its own limits in a comment right next to the type (f113210f:src/MicroHs/Map.hs line 5): -- This is a pretty bad implementation. Every lookup and merge function takes its equality test as an explicit argument (lookup :: (k -> k -> Bool) -> k -> Map k v -> Maybe v) instead of reaching for (==) through a type-class constraint.
  • No fixity mechanism, so uhs's own parser resolves every operator's precedence from one hand-written ladder of pLeftAssoc/ pRightAssoc/pNonAssoc calls in pExpr (f113210f:src/MicroHs/Parse.hs lines 450–462) rather than reading infixl/infixr declarations β€” there are none to read; a project-wide search for infix in src/MicroHs/Parse.hs across this chapter's whole commit range comes up empty. The --Yinfixr 2 ||-style comments that do show up in lib/*.hs in this range (chapter 07's "What happened" quotes one) are for GHC's benefit on the "local lib" build path below, not uhs's own parser.

The --X/--Y trick

The same source files have to satisfy two different compilers with two different needs β€” GHC wants real hiding clauses, real fixity declarations, real deriving; uhs doesn't understand any of them and would choke if it tried. The fix is textual: write the GHC-only syntax as an ordinary comment with a two-character marker prefix, and let a GHC preprocessor (-F -pgmF, a stage that rewrites source before compilation) strip the marker right before GHC parses the file. uhs itself never runs this preprocessor, so it just sees an ordinary comment and skips it. Both scripts are two lines (b71e0e90:convert.sh, split into 67715b16:convertX.sh and bea8283d:convertY.sh; quoted here as they read once both exist, f113210f:convertX.sh and f113210f:convertY.sh, each whole):

#!/bin/sh
( echo "{-# LINE 1 \"$1\" #-}" ; sed -e 's/--X//' $2 ) > $3
#!/bin/sh
( echo "{-# LINE 1 \"$1\" #-}" ; sed -e 's/--Y//' $2 ) > $3

convertX.sh feeds the $(BIN)/uhs build itself (GHCE, rung 1 above): a line like import Prelude --Xhiding (Monad(..), ...) becomes, once GHC sees it, import Prelude hiding (Monad(..), ...) β€” needed because uhs's source hand-rolls its own >>=/pure/<*> as plain top-level functions (see chapter 07) and GHC's real Prelude would otherwise fight it for those names. convertY.sh feeds the "local lib" build (GHCC, the machinery bootboottest's $(BIN)/bootuhs depends on): a line like --Yinfixr 2 || in lib/Data/Bool.hs becomes a real infixr 2 || declaration, needed because that build compiles lib/'s hand-rolled (||) under GHC's own expression grammar, which does care about declared fixity even though uhs's own parser doesn't. Same source, two markers, two audiences.

One more name for this

This two-rung self-check β€” compiler output matching compiler output, one level removed β€” is the same shape of trust question Ken Thompson's 1983 Turing-award lecture "Reflections on Trusting Trust" (published in CACM in 1984) raises: a cmp that passes proves the two builds agree with each other, not that either one is correct, so a bug (or something worse) baked into the very first GHC-built bin/uhs could in principle survive every later bootstrap unnoticed.

🌐 ν•œκ΅­μ–΄λ‘œ 읽기

Background for chapter 09.

Hindley-Milner, gently

This is not a tutorial on Hindley-Milner in general β€” textbook HM comes with generalization, an occurs check, and let-polymorphism baked in. This describes the specific checker MicroHs actually shipped first, src/MicroHs/TypeCheck.hs as of cc57346e ("First version with type checking.", 2023-08-06). Every claim below is checked against that file, at that commit, or against a program actually run through the cc57346e build.

Types are terms

The hook, and the thing every other section depends on: MicroHs does not have a separate type-syntax AST. 33b009fa ("Use Expr for EType.", 2023-07-21) deletes the three-constructor EType that had existed since chapter 08's db418344 (TVar/TConstr/TUVar) and replaces it with a type synonym for the same Expr that already represents terms (33b009fa:src/MicroHs/Parse.hs lines 99–111, quoted whole):

-- Expr restricted to
--  * after desugaring: EApp and EVar
--  * before desugaring: EApp, EVar, ETuple, EList
type EType = Expr

validType :: Expr -> Bool
validType ae =
  case ae of
    EVar _ -> True
    EApp f a -> validType f && validType a
    EList es -> length es <= 1 && all validType (take 1 es)
    ETuple es -> all validType es
    _ -> False

A type is just an Expr that satisfies validType β€” a type variable and a type constructor are both EVar, a type application (Maybe Int, [a], a -> b) is EApp, and there's no separate parser production for types at all: pType runs the ordinary expression parser and checks validType on what comes back (33b009fa:src/MicroHs/Parse.hs lines 450–454). This carries all the way into the checker β€” a metavariable (textbook HM's Ξ±, Ξ², ...) is one more constructor on the same Expr type (cc57346e:src/MicroHs/Parse.hs lines 56–78, quoted in part, ... marking constructors omitted from the middle):

data Expr
  = EVar Ident
  | EApp Expr Expr
  ...
  | EBad
  -- Only while type checking
  | EUVar Int
  -- Constructors after type checking
  | ECon ConTyInfo Ident
  --Xderiving (Show, Eq)

There is no Type module and not even a separate Kind type β€” type EKind = EType (cc57346e:src/MicroHs/Parse.hs line 133). Terms, types, and kinds are one recursive datatype throughout.

Metavariables and substitution, as implemented

An unknown type is EUVar Int, just an integer tag. The substitution that resolves those tags is a single IM.IntMap EType in the checker's own state (cc57346e:src/MicroHs/TypeCheck.hs line 132):

data TCState = TC IdentModule Int TypeTable ValueTable (IM.IntMap EType)

The Int next to it is a fresh-name counter; newUVar bumps it and returns EUVar n (cc57346e:src/MicroHs/TypeCheck.hs lines 338–342). Recording a solution is a plain map insert, addUVar (cc57346e:src/MicroHs/TypeCheck.hs lines 254–262, quoted whole):

addUVar :: Int -> EType -> T ()
addUVar i t = T.do
  let
    add = T.do
      TC mn n tenv venv sub <- get
      put (TC mn n tenv venv (IM.insert i t sub))
  case t of
    EUVar j -> if i == j then T.return () else add
    _ -> add

The one check here is i == j β€” refuse to insert i ↦ EUVar i. That is the entire safety net; there is no deeper occurs check, and "What this checker leaves out" below shows what that costs. Reading a solution back out chases the map recursively (derefUVar, cc57346e:src/MicroHs/TypeCheck.hs lines 291–300, quoted whole):

derefUVar :: EType -> T EType
derefUVar at =
  case at of
    EApp f a -> EApp <$> expandType f <*> expandType a
    EUVar i -> T.do
      sub <- gets uvarSubst
      case IM.lookup i sub of
        Nothing -> T.return at
        Just t -> derefUVar t
    _ -> T.return at

expandType (lines 270–273) is derefUVar followed by expandSyn, which does the same kind of lookup against a different map β€” type synonyms. And replacing a named type variable (what forall-instantiation needs) is separate again: subst, a plain syntactic walk (cc57346e:src/MicroHs/Parse.hs lines 759–768, quoted whole):

subst :: [(Ident, Expr)] -> Expr -> Expr
subst s =
  let
    sub ae =
      case ae of
        EVar i -> fromMaybe ae $ lookupBy eqIdent i s
        EApp f a -> EApp (sub f) (sub a)
        EUVar _ -> ae
        _ -> error "subst unimplemented"
  in sub

Three mechanisms, three pieces of state, all on the one Expr type.

Unification, traced by hand

unify/unifyR (cc57346e:src/MicroHs/TypeCheck.hs lines 302–330, quoted whole) is the whole algorithm β€” dereference both sides, then compare structurally:

unify :: HasCallStack => EType -> EType -> T ()
unify a b = T.do
  aa <- expandType a
  bb <- expandType b
  unifyR aa bb
  
unifyR :: HasCallStack => EType -> EType -> T ()
unifyR a b = T.do
  venv <- gets valueTable
  tenv <- gets typeTable
  let bad = error $ "Cannot unify " ++ showExpr a ++ " and " ++ showExpr b ++ "\n" ++
                    show a ++ " - " ++ show b ++ "\n" ++
                    show tenv ++ "\n" ++
                    show venv
  case a of
    EVar ia ->
      case b of
        EVar ib  -> if eqIdent ia ib then T.return () else bad
        EApp _ _ -> bad
        EUVar i  -> addUVar i a
        _        -> impossible
    EApp fa xa ->
      case b of
        EVar _     -> bad
        EApp fb xb -> T.do { unify fa fb; unify xa xb }
        EUVar i    -> addUVar i a
        _          -> impossible
    EUVar i -> addUVar i b
    _ -> impossible

Four cases, because a desugared EType only contains EVar/EApp/EUVar: two constructor names must match exactly by identifier; two applications unify head and argument separately (this is how a -> b against Int -> Char splits in two, since tArrow a r = EApp (EApp (EVar "Primitives.->") a) r); a metavariable on either side just records a binding.

Trace 1 β€” the call const 5 'x', where const :: forall a b . a -> b -> a has been freshened at the use site to EUVar 2 -> EUVar 3 -> EUVar 2 (see tInst below) β€” the application's result metavariable Ο„ (EUVar 1) is allocated before tInst freshens the scheme, so it carries the lowest fresh number, not the highest. Checking the application unifies the freshened scheme against Int -> (Char -> Ο„): unify (EUVar 2) Int β†’ addUVar 2 Int; unify (EUVar 3) Char β†’ addUVar 3 Char; unify (EUVar 1) (EUVar 2) β†’ unify runs expandType on both sides first, and 2 ↦ Int is already in the map, so the binding recorded is addUVar 1 Int directly β€” no two-hop chase. Dereferencing Ο„ afterward reads 1 ↦ Int β€” the call's result type is Int.

Trace 2 β€” a failing unification, run for real: bad2 :: forall a b . a -> b -> a; bad2 x y = x + 1 fails with Cannot unify a and Primitives.Int, because inside bad2's own body the signature's a is a literal, un-instantiated EVar name (why: next section) β€” unifyR's EVar/EVar case runs with ia = "a", ib = "Primitives.Int", eqIdent is false, and bad fires. No metavariable involved at all.

forall, instantiation, and rigid type variables

f7db30f1 ("Add lots of type signatues and forall.", 2023-07-23) adds forall across the standard library β€” 9 files, 134 insertions, 74 deletions, all under lib/ plus the Makefile. Most touched functions already had a signature; what's new is the quantifier (f7db30f1:lib/Data/Function.hs, quoted in part: . and id omitted after the first ..., flip omitted after the second, uncurry omitted after fix β€” each of those four just gains a forall the same way ($) does):

-($) :: (a -> b) -> a -> b
+($) :: forall a b . (a -> b) -> a -> b
...
-const :: a -> b -> a
+const :: forall a b . a -> b -> a
...
+fix :: forall a . (a -> a) -> a
 fix = primFix

Why the quantifier matters is mechanical. ETypeScheme [Ident] EType carries an explicit list of bound names (cc57346e:src/MicroHs/Parse.hs line 130), and the parser only populates it when it sees the keyword (cc57346e:src/MicroHs/Parse.hs lines 484–488, quoted whole):

pTypeScheme :: P ETypeScheme
pTypeScheme = P.do
  vs <- (pKeyword "forall" *> esome pLIdent <* pSym '.') <|< pure []
  t <- pType
  pure $ ETypeScheme vs t

Checking a signature (tcTypeScheme, lines 485–488 of TypeCheck.hs) brings exactly the names in vs into scope; a name never quantified is simply not in scope. Confirmed by deleting the quantifier from const's signature in the cc57346e worktree (const :: a -> b -> a, no forall) and compiling a program that uses it:

mhs: undefined variable a
CallStack (from HasCallStack):
  error, called at src/MicroHs/TypeCheck.hs:354:16 in main:MicroHs.TypeCheck

β€” the checker fails on const's signature, never reaching its body. Without forall a polymorphic-looking signature is ill-formed, which is why f7db30f1 had to sweep the library two weeks before cc57346e could accept any of it.

What makes a definition polymorphic, then, given that there is no generalization step anywhere in this file? tInst freshens a scheme's bound names to new metavariables only at a use site β€” every EVar reference goes through tLookupInst (cc57346e:src/MicroHs/TypeCheck.hs lines 364–371, quoted whole):

tInst :: ETypeScheme -> T EType
tInst as =
  case as of
    ETypeScheme vs t ->
      if null vs then T.return t
      else T.do
        us <- mapM (const newUVar) (replicate (length vs) ())
        T.return (subst (zip vs us) t)

But when the checker verifies the definition itself, tcDefValue (lines 526–536) checks the body against the declared type verbatim β€” still containing the literal identifiers a/b, not fresh metavariables. Since unifyR's EVar/EVar case demands identical names, a behaves as a rigid, opaque constant for that one definition's check β€” exactly what the bad2 failure above shows. Two consequences: (1) polymorphism = rigid check at the definition plus fresh instantiation at every call site (verified: one program calling const 5 'x' and const "hi" 42 compiles and prints 5 then "hi"); (2) with no generalization, every top-level binding needs an explicit signature to be checked at all β€” the next section shows what a missing one costs.

What this checker leaves out

Checked directly against the file, not assumed from familiarity with "real" Hindley-Milner:

  • No occurs check. addUVar's only guard is i ↦ EUVar i itself; it never checks whether t contains EUVar i nested deeper. \x -> x x unifies x's metavariable with x's-metavariable -> Ο„, a cycle addUVar happily records β€” no "infinite type" error. Run for real (cc57346e build, ulimit -v 2000000): a program with that lambda in an unused let dies with mhs: out of memory, derefUVar chasing the substitution's own cycle. (Unbounded, the same run consumed tens of gigabytes of host RAM before being killed β€” genuine non-termination, not a hypothetical.)
  • No let-polymorphism for local bindings. tcBinds gives each local binding one fresh metavariable in an empty scheme, ETypeScheme [] (cc57346e:src/MicroHs/TypeCheck.hs line 708), so tInst on it is the null vs case β€” no freshening, ever. A local function used at two different types β€” textbook let-generalization's whole reason to exist β€” is a hard "Cannot unify" error here.
  • Mutual recursion is an open gap, by the code's own admission. tcBinds carries -- XXX No mutual recursion yet (line 703). Tested at cc57346e: a single self-recursive local function works (let countdown n = if n == 0 then 0 else countdown (n - 1) runs to 0), but two local functions calling each other fail β€” one stage after type checking, at name resolution: mhs: undefined: isOdd, from src/MicroHs/Main.hs's linking pass.
  • No type classes. grep -in "class" over Parse.hs and TypeCheck.hs at cc57346e returns nothing β€” no AST case, no dictionaries. do-notation still resolves (>>=)/(>>) by explicit module qualifier (chapter 03), not a Monad constraint.
  • Kinds exist, but only just. EKind is EType again, checked against the single sentinel kType = EVar "Type" (lines 228–229) β€” enough to reject an arity mismatch like data Foo a = Foo (a Int), but every type variable has kind Type and there is no * -> *-and- beyond machinery.

Worked example: const

lib/Data/Function.hs (cc57346e):

const :: forall a b . a -> b -> a
const x _ = x

Hand-inferring the way tcDefValue does β€” walking ELam "x" (ELam "_" (EVar "x")) against the declared a -> b -> a, with a/b in scope as rigid names:

  1. Outer ELam "x" e, expected a -> (b -> a). A fresh metavariable τ₁ stands for x's type; the body is checked with no expected type (ELam's case recurses with Nothing, unifying afterward), with x : τ₁ in the environment.
  2. Inner ELam "_" (EVar "x"): fresh Ο„β‚‚ for _; the body looks up x and returns τ₁. The inner lambda's type is Ο„β‚‚ -> τ₁.
  3. The outer lambda's constructed type, τ₁ -> (Ο„β‚‚ -> τ₁), is unified against a -> (b -> a): τ₁ ↦ a, Ο„β‚‚ ↦ b, and the return-position unify τ₁ a resolves to unifyR (EVar "a") (EVar "a") β€” succeeds by eqIdent.

Every metavariable resolves to the signature's own a/b, no contradiction β€” the body checks against forall a b . a -> b -> a, matching the source. Against the real checker (cc57346e worktree, -ilib on the actual lib/ tree):

$ cat > ConstDemo.hs <<'EOF'
module ConstDemo(module ConstDemo) where
import Prelude

main :: IO ()
main = do
  putStrLn (showInt (const 5 'x'))
  putStrLn (const "hi" 42)
EOF
$ nix develop $MICROHS -c ./bin/mhs -ilib ConstDemo
$ nix develop $MICROHS -c ./bin/eval
5
hi

const 5 'x' instantiates a ↦ Int, b ↦ Char; const "hi" 42 instantiates a ↦ [Char], b ↦ Int β€” two independent instantiations of the one rigid-checked definition, exactly as tInst predicts.

🌐 ν•œκ΅­μ–΄λ‘œ 읽기

Appendix β€” How this book was made

This book was written by an AI (Anthropic's Claude) at the direction of a human who wanted to learn from MicroHs's history β€” and who, reasonably, did not want to trust an AI's unchecked account of someone else's work. This appendix describes the production process, because the process is itself a reusable technique: point it at any repository with a rich history and it produces a verified teaching book. The design spec and implementation plan for this instance are preserved in this repository's docs/superpowers/ directory.

The shape of the process

  1. Scope from milestones. Survey git log --reverse and find the natural arc: here, "First version" (two files, one evening) β†’ "Make a real compiler" β†’ self-hosting β†’ "Now bootstraps with type checking." Chapters are thematic phases of that arc β€” not one commit per chapter, and not one subsystem per chapter, because chronology is where the working style lives, and the style was the point.

  2. Spec, then plan, then fresh eyes per chapter. A short design spec fixed the reader (Haskell-literate, compiler-newcomer), the chapter map, and the rules. A plan turned that into fourteen tasks. Each chapter was then written by a fresh AI agent that saw only its own brief: the commit range, the commits requiring depth, the global constraints verbatim, and the facts established by earlier chapters. Fresh eyes cannot lean on context they do not have β€” everything they claim, they must pull from the history itself.

  3. Adversarial review, every chapter. A different agent reviewed each chapter against its brief, explicitly instructed to treat the writer's report as unverified claims, and given a short list of named risks to check by re-deriving them from the diffs β€” never by re-reading the writer's account. Findings needed file:line evidence. The writer fixed; the reviewer re-reviewed the fix. Ten of fourteen tasks needed at least one fix round.

  4. A final whole-book review (on the strongest available model) checked what per-chapter gates cannot: continuity between chapters, the uhs→mhs naming timeline, the hard boundary (nothing narrated past a38c64ce), and fresh fact samples across chapters.

The rules that mattered

Most quality machinery is obvious in hindsight β€” quote code byte-for-byte, run what you claim. The rules below are the ones that were learned, each from a review actually catching a failure in this book's drafts:

  • Quotes are byte-exact, or they are marked. An early draft of the overview quoted git show --stat output with the columns re-padded after dropping two rows. Byte-comparison caught it. The rule became: quote the real bytes, mark every elision with an explicit ..., cite commit:file with the path as it exists at that commit (a later chapter cited eval.c at a commit where the file had already moved to src/runtime/eval.c β€” the citation didn't resolve).

  • Never state an unverified mechanism as fact. One draft asserted which operand C evaluates first in x + y. C doesn't say, and no transcript showed it; the passage was rewritten to hold in either order. Another draft described a bug fix with a plausible mechanism β€” right commit, wrong file, wrong kind of bug β€” that only fell apart when the reviewer re-derived it from the diff. Plausible-and-wrong survives checklists; it does not survive re-derivation.

  • The printed text is the test artifact. Every "Try it" block was executed before publication β€” and that was not enough. One chapter's exercises each worked individually, but the printed sequence omitted a rebuild between them, so a reader following the text in order would diff a stale artifact. Another placed a worktree cleanup block before the exercises that still needed the worktree. The fix: a reviewer paper-walks the exercises exactly as printed, as a naive reader.

  • Commit titles under-describe; audit the diffs. A commit titled as a pure file move quietly introduced an entire new module (an early experiment in native evaluation) β€” with an unterminated string literal, no less. The narrative initially repeated the title's story. The rule: for every narrated commit, read the whole diff and disclose what the title doesn't say. The book's own chapters now practice this on Augustsson's history, and the reviews practiced it on the book.

  • Say what doesn't reproduce. A documented 2023 stack-overflow crash refused to fire under a modern system's default limits. The chapter says so, and shows what was observed instead. A book about honest engineering cannot smooth over its own environment's differences.

  • Verify the negative claims too. Chapter 08 checks whether the profiling flag feeds the combinator frequency ordering β€” the diffs show no connection, and the chapter says exactly that, having looked.

Numbers, for calibration

Roughly: 28 commits of book production; 10 of 14 tasks required a fix round; review findings ranged from byte-level (a re-padded stat column) to structural (a missing rebuild step that invalidated an exercise's premise). Every fix was itself re-reviewed. The final whole-book review found eight further issues β€” commands that assumed a convention two aux docs had drifted from, one range mischaracterization β€” and its fix was reviewed again. None of this guarantees correctness; it does mean every claim you can check has already survived one adversary checking it.

Reusing the process

The condensed, executable form of this process β€” the chapter-brief and reviewer-dispatch templates, and the failure-derived rules above β€” ships with this book as an agent skill file: skills/writing-teaching-books-from-git-history/. It is plain markdown; that directory's README explains what a skill file is and how to install it, and nothing in the method is AI-specific. The spec and plan in docs/superpowers/ show the concrete instance that produced this book.

To apply it elsewhere: find a repository whose history you want to learn from, fix an endpoint, and hold every chapter to the same bargain this book makes with you β€” everything quoted is real, everything claimed was run, and everything else is labeled.