Interactive course · about 8 hours

Build a Language

Every program you have run started as text that a computer had no way to understand. Something split it into words, arranged the words into a shape, worked out what the shape meant, and turned it into instructions. Here you build that something: a small language called Sprout, one stage at a time, with every stage on screen and every stage yours to break.

How this works

Sprout gets built in front of you. By Step 3 it has a tokeniser, by Step 8 a tree, by Step 11 loops and conditions, and by Step 13 a compiler and a small machine to run its output on. The second half grows that machine into the architecture of a typed, optimising, toolable and safely hosted language. Every widget drives the real thing, so when you change a program the tokens, the tree, the bytecode and the answer all change with it, and the last step hands you a piece of the language that was deliberately left unfinished.

What the first Sprout keeps small

Numbers, strings, true and false, four arithmetic operators plus a fifth left unfinished on purpose, six comparisons, let, if, else, while, print, return, and functions. No arrays, no objects, no classes. Everything left out is left out so that the first working language stays a few hundred lines. Steps 15 through 26 then add the engineering ideas behind those missing pieces without hiding them inside a framework. The unfinished operator in Step 14 is yours to write. You need to have read a few lines of JavaScript before, which is what Writing an Application covers. No compiler background is assumed, and no maths beyond arithmetic.

The steps

Step 1

Turn the text 2 + 3 * 4 into the number fourteen

Take the text 2 + 3 * 4. You know it is fourteen. A computer holding that text has nine characters and no opinion at all: no numbers in it, no plus sign, nothing that could be added to anything. There is a character whose code happens to be the one for the digit two, and there is a character usually drawn as a cross.

Getting from those nine characters to fourteen takes four moves, and every language ever built makes the same four. First, split the text into words. The part that does this is the tokeniser and the words it makes are called tokens. Second, arrange the tokens into a shape. That part is the parser and the shape it builds is called a tree. Third, work the shape out. That part is the evaluator. What comes out is the answer. Fourth, and only if the language compiles rather than interprets: turn the shape into instructions instead of working it out on the spot. That part is the compiler. Four names for four jobs. This course builds them in that order, so if a name means nothing to you yet, it will by the step that builds it.

What is the difference between a compiler and an interpreter?

Both do the first three moves: split the text into words, arrange them, and work out what they mean. They part company at the end. An interpreter walks the shape and does what it says, now. A compiler turns the shape into instructions and stops, and something else runs those instructions later, possibly on a different machine, possibly next year.

Sprout does both, because the gap is smaller than the two words suggest. Steps 9 to 12 build the interpreter, Step 13 builds the compiler, and they share the first eight steps completely. Real systems blur the line further: your browser interprets JavaScript at first and compiles the parts that turn out to be worth compiling.

Lab 1 · One expression, four stages
Start here Press 2 · Tokens, then 3 · Tree, then 4 · Value. The same nine characters turn into something different at each one, and the last one is a number.
Click each stage in turn. Then change the expression and click through again. Try (2 + 3) * 4 and watch where the brackets make a difference: the tokens are almost the same list, and the tree is a different shape, and the shape is the whole answer.
Each stage can only see what the one before it made

The parser never sees a character. The evaluator never sees a token. Each stage hands the next one a single kind of thing and then has nothing more to do with it. That is why the four stages can be understood one at a time, and it is why this course can build them one at a time.

Lab 2 · Which stage says no?
Start here Pick one of the four buttons for the first case. The stage that really rejects it appears underneath, along with the message that stage produces.
Predict before you press. Three stages can reject a program and they reject it for completely different reasons. Getting a feel for which is which is most of learning to read an error message that somebody else wrote.
A program contains total = prise * 2, where prise is a misspelling of price. Which stage complains?
The evaluator. To the tokeniser, prise is a perfectly ordinary name. To the parser, name = name * number is a perfectly ordinary shape. Only the stage that knows what names refer to can possibly notice. This is why a typo in a variable name blows up at run time in some languages, and why others add an entire extra stage between parsing and running whose only job is to catch it earlier.
Step 2

Break a line of characters into tokens

The first stage has one job: turn a run of characters into a list of tokens. A token is a small piece with a type attached. This is a number. This is a name. This is an operator. This is an opening bracket.

And this is a string: the word everyone uses for a piece of text written between quote marks, "hello", as opposed to a name or a number. It is called that because it is a string of characters threaded one after another. The tokeniser is where a string gets recognised and where its quote marks are removed, because it is the only stage that ever sees them.

It is also the only stage that ever sees a space, and it throws every one of them away.

Why bother? Why not work straight from the characters?

Because otherwise every later stage does the same work again. The parser constantly wants to ask "is the next thing a number", and without tokens that question becomes "read characters while they are digits, then check the character after them is not a letter, then convert". Ask it once, write the answer down, and everything downstream gets shorter.

The second reason is positions. Each token remembers which characters it came from, and those two numbers travel all the way through the rest of the pipeline. They are the difference between an error that says line 4, column 12 and one that says something is wrong somewhere.

Lab 3 · Watch the chips appear
Start here Delete the space in the middle of 12 + and watch nothing change. Then click any chip to see which characters it came from.
Type in the box and click any chip. Each one says its type and the exact characters it came from. Add and remove spaces and watch the token list stay the same, then remove the space in let total and watch two tokens become one.
Longest match, said another way

Faced with >=, the tokeniser could take the > and be done with it. It does not, because it always tries the longest thing that fits. You do the same reading English without noticing: meeting "therefore" you do not stop after "the".

Whenever a language adds a longer operator, update the tokeniser before the parser. Otherwise the characters arrive as separate tokens and the parser reports an error after the actual source of the problem.

Lab 4 · Seven awkward cases
Start here Read the first case, press the number you think is right, then read why. Press Next for the following one.
Commit to a count before you look. Is >= one token or two? Is 42abc legal? What does a string with no closing quote become? Each of these is a decision somebody had to make, and being surprised is how you find out that a decision was made.
A language adds a ** operator for powers. The tokeniser is not changed. What happens to 2 ** 3?
Two stars. The tokeniser has no idea a ** was meant: it takes the longest thing in its table, and the longest thing in its table is a single star. The parser then meets a multiply with a multiply on its right and complains, pointing at the second star. The error is real, the position is right, and the cause is one stage upstream, which is a shape of bug worth recognising.
Step 3

Build the lexer

The tokeniser has a second name you will meet everywhere, including in the code below: a lexer, from the Greek word for a word. Same stage, same job, shorter to type, and the function that does it is almost always called lex. The whole lexer is one loop over a cursor. A cursor is a position in the text, like the blinking bar in a typing box, except that this one only ever moves forward. Look at the character the cursor is on. That single character decides what kind of token is starting. Then a small loop runs the cursor forward while the characters still belong to it.

Written out, with the awkward cases left in, it is about forty lines. Here is the shape of all of them. One word in it is worth knowing before you read it. continue jumps straight back to the top of the while. So each branch handles one kind of token completely and then hands control back, rather than nesting the next test inside itself.

Two languages on this page, and it matters which is which

From here there are two, and they look alike enough to catch you. Sprout is the language being built. It says fun, let and print and every program in Steps 10 to 14 is written in it. JavaScript is the language Sprout is built out of. It says function, let and say and every grey code listing here, including the one below, is written in it. The rule of thumb: a lab whose code starts function wants JavaScript and a lab whose code has fun or print in it wants Sprout. Each lab's editor says at the top which one it is expecting. The whole course is one language being used to build another, which is exactly how every real language got started.

function lex(src) {
  const tokens = [];
  let i = 0;

  while (i < src.length) {
    const c = src[i];

    if (c === ' ' || c === '\n') { i++; continue; }        // separators, kept by nobody

    if (c === '/' && src[i + 1] === '/') {                  // a comment, kept by nobody either
      while (i < src.length && src[i] !== '\n') i++;
      continue;
    }

    if (isDigit(c)) {                                       // a number
      const start = i;
      while (i < src.length && isDigit(src[i])) i++;
      tokens.push({ type: 'num', text: src.slice(start, i), from: start });
      continue;
    }

    if (isLetter(c)) {                                      // a name, or a keyword
      const start = i;
      while (i < src.length && isLetterOrDigit(src[i])) i++;
      const word = src.slice(start, i);
      tokens.push({ type: KEYWORDS[word] ? 'kw' : 'name', text: word, from: start });
      continue;
    }

    if (TWO_CHAR.includes(src.slice(i, i + 2))) {           // >= and friends, longest first
      tokens.push({ type: 'op', text: src.slice(i, i + 2), from: i });
      i += 2;
      continue;
    }

    tokens.push({ type: 'op', text: c, from: i });
    i++;
  }
  return tokens;
}
Eight small pieces of JavaScript in that listing that I have not seen before

In the order they appear. && between two tests means both of them have to be true; || means at least one of them. while (test) { ... } repeats the body for as long as the test is true, which is a for loop with the starting value moved above it and the change moved inside it. i += 2 is short for i = i + 2. And '\n' is how you write the invisible character at the end of a line, the one the Enter key puts there.

src.slice(start, i) hands back the piece of text from position start up to but not including position i, which is exactly the run of characters this token covers. TWO_CHAR.includes(x) answers true when x is one of the items in that list. And KEYWORDS[word] ? 'kw' : 'name' is a whole question written on one line: if KEYWORDS[word] finds something the answer is 'kw', and otherwise it is 'name'. Written the long way that is four lines of if and else for one small choice, which is why people write it this way, and why you should recognise it.

What are isDigit, KEYWORDS and TWO_CHAR? Those came out of nowhere.

They are five small things kept out of the listing so that the shape of the loop stays visible. isDigit(c), isLetter(c) and isLetterOrDigit(c) are one-line functions that answer true or false about a single character: isDigit("7") is true and isDigit("x") is false. Giving them names is worth it because the loop asks those three questions eleven times between them.

KEYWORDS and TWO_CHAR are in capitals by convention, which is how a programmer says "this is a fixed table, set up once and never changed again". KEYWORDS lists the words Sprout has reserved for itself: let, if, else, while, print, fun, return, true and false. So KEYWORDS[word] finds something for if and finds nothing for total. That is the entire difference between a keyword and an ordinary name, and it is one table lookup. TWO_CHAR lists the operators written with two characters, ==, !=, <= and >=, so the lexer can try those before it tries single ones. Adding a keyword to Sprout means adding one word to the first table and nothing else at all.

Why every branch ends in continue

continue jumps straight back to the top of the while. Every branch ends with one, and that is what keeps the loop flat: each branch handles one kind of token completely and then hands control back, instead of the next test being nested inside the last.

The other thing every branch does is leave the cursor further along than it found it. That is the contract of the whole loop. Every hang a lexer has ever had is a branch that broke it, usually a branch handling something malformed, going round and round on the same character forever.

Why no compiler has ever seen one of your comments

The comment branch is the one above that reads to the end of the line and pushes nothing. Not an empty token, nothing at all. So the parser has never met a comment, the evaluator has never met a comment, and the machine at the end of this course could not tell you whether the program it is running had any.

Which is exactly why a comment can say anything at all, including something that is no longer true. Nothing checks it, because by the time anything could, it is gone.

Lab 5 · Walk the cursor, one character at a time
Start here Press Forward four or five times to see the cursor move, then press Play and watch the rest go by.
Step it, or press Play and watch. The cursor commits at the first character of each token and then runs forward while the characters fit. Type something containing >= and watch two characters go into one token, then add a space between them and watch the same characters become two.
What is a while loop, and how do you ask whether a character is a digit?

for (let i = 0; i < 10; i++) packs three things onto one line: where to start, when to stop, and what changes each pass. A while loop keeps only the middle one. while (j < src.length) { j = j + 1 } means: look at the test, and if it is true run the body and look again. Everything a for loop does, a while loop does with the start written on the line above and the change written inside the body. A lexer wants while because it never knows in advance how many characters a token is going to take.

The other new piece is &&, which sits between two tests and means both of them have to be true. So c >="0" && c <="9" asks whether a character sits between the character 0 and the character 9. A computer compares two pieces of text the way you compare two words in a dictionary, and in that ordering the ten digits sit in a row with nothing else among them. That is the whole of isDigit. The lab writes it out at the top for you, because naming it once is what a real lexer does too.

Lab 6 · Write the number scanner
Start here Press Run first: it says the number ends at 0, which is wrong, because the loop test still says false. Then press Check against hidden inputs and read the first failure before you change anything.
Graded on behaviour, against strings you cannot see. Including 12. where the dot is not part of the number, abc where there is no number at all, and a scan that does not start at position zero.
A lexer branch reads a string. When the closing quote is missing, it leaves the cursor where it started and lets the loop go round again. What happens?
It hangs. Every branch has to leave the cursor further along than it found it, with no exceptions, and the branch where somebody forgets is nearly always the one handling something broken, because that is the path nobody tested. The loop in this course counts its own turns and gives up, which is the difference between an interpreter that teaches you something and one that freezes the tab.
Step 4

Write the rules that say which lines are legal

A grammar is a list of rules saying which sequences of tokens are allowed. Sprout's whole grammar fits on a postcard, and nothing outside it is a Sprout program.

Two words from those rules are worth having straight away, because everything from here uses them. An expression is anything that has a value: 4, price, 2 + 3 * 4, n > 0. Ask an expression what it is worth and there is an answer. A statement is a whole instruction, something the program does: let x = 1, print x, a whole if, a whole while. Ask a statement what it is worth and the question makes no sense. A Sprout program is a list of statements, and most statements have an expression somewhere inside them. That is why 2 + 3 on its own is refused: it is a perfectly good expression and not a statement, an answer nobody asked for.

The notation is small, and five marks do all of the work in it. Anything in quotes is a token typed exactly as it appears. A bar means "or". Square brackets mean "optional". Curly braces mean "none or more of whatever sits between them", and a star after a single item means none or more of that one item. Round brackets group things so that a bar or a star covers all of them at once. A name on the left of an arrow can be used on the right of any rule, including its own. That last part is how nine rules cover an endless number of programs.

What NAME, NUMBER and STRING mean in those rules

They are token types, not literal words. NUMBER matches any number token at all and NAME matches any name token, while a quoted piece such as 'let' or '(' matches that one token and nothing else. STRING matches any string token, whatever was written between the quotes. The tokeniser already sorted out which is which, which is exactly why the grammar can be this short. The two rules at the bottom, args and params, are the lists inside a call and inside a definition: both are allowed to be empty, which is why each one is wrapped in square brackets.

Rules written this way are called BNF, after the two people who first used the notation for a language definition in 1959. A version of it sits at the front of the reference manual for nearly every language you can name. The one for JavaScript runs to a few hundred rules and is the same idea.

Lab 7 · Accepted, or rejected and where
Start here Press the if x < 3 { print x } button and read the rejection, then put the round brackets back in the box and watch it flip to accepted.
Type anything and watch the verdict. The near misses are the interesting ones: if x < 3 { } with the round brackets left off, and 2 + 3 on its own, which is a perfectly good expression and not a statement. The rule the parser was inside when it gave up is highlighted.
Lab 8 · Build a sentence the rules allow
Start here Press let, then x, then =, then 1. The verdict underneath updates after every single token.
Assemble tokens from the buttons. Nothing can go wrong at the tokeniser here, so every rejection is the grammar talking. Three goals, each one needing a little more of the grammar than the last.
The grammar accepts let x = 1 / 0 without complaint. Why?
A grammar only answers one question: are these tokens in an order the rules allow. Whether the names exist, whether the division is safe, whether the program means anything at all, none of those are shape questions. Passing the grammar is the beginning of the checking and never the end of it, which is why languages that catch more mistakes early do it with extra stages rather than bigger grammars.
Step 5

Build a tree from a flat list of tokens

A tree here is a picture drawn upside down. At the top sits one box. Lines run down from it to the boxes under it, and lines run down from those to more boxes, for as long as you like. Each box is a node. The single node at the top is the root. A node with lines going down from it is the parent of the nodes at the other end, and those are its children. A node with nothing at all underneath it is a leaf. In Sprout every leaf is a number or a name, because those are the only things that need nothing else worked out first. How far down a node sits is its depth: the root is at depth nought, its children at depth one, and so on.

The parser reads the token list once, left to right, and builds a tree out of it. The cursor never goes backwards.

Nodes get made from the bottom up, which has a consequence worth holding on to: the last node made is always the root, because a parent cannot be built until it has children to hold.

Why a tree, and not just a list

Because a list cannot say which operation happens first, and a tree can, by depth. 2 + 3 * 4 and (2 + 3) * 4 are almost the same tokens in the same order and the difference between fourteen and twenty lives entirely in which node ended up above which.

From here on, nothing looks at the text again. The evaluator is handed a tree. The compiler is handed a tree. If you replace the parser with one for a different language and it produces the same kind of tree, everything downstream carries on working, which is how one compiler can accept several languages.

Why is the root at the top? That is not what trees do.

Nobody knows who drew the first one this way and everybody has copied it since. Turn the page upside down and it is a family tree with one ancestor at the bottom. It is worth two seconds of annoyance and then forgetting about: root means the one node with nothing above it, wherever it happens to be printed.

One rule matters more than the picture. Every node except the root has exactly one parent, and no line ever loops back round to somewhere it has already been. That is what makes "work out all the children first, then the parent" a plan that always finishes, rather than a plan that might go round forever. From any node you can only walk downwards so far before you run out of tree.

Lab 9 · Watch the tree grow, node by node
Start here Press Forward once and one node appears. Keep pressing and watch the loose pieces get joined together.
Step forwards and backwards through the parse. Watch the moment where two finished pieces are sitting on the bench with nothing joining them, and then an operator arrives and joins them. Then try 1 + 2 + 3 and see which of the two plus signs ends up on top.
Lab 10 · Which operation runs first?
Start here Pick one of the operations listed, then look at the tree that appears and where the marked node sits.
Commit, then see the tree. The answer is never about reading order. It is about depth, and depth was decided by the parser long before anything was worked out.
The parser builds nodes from the bottom up. What does that tell you about the very last node it makes?
The root. A node can only be built once its children exist, so the outermost one is necessarily last. Evaluation runs the same fact backwards: the root is the last thing worked out and the only thing that ends up holding the answer. Building bottom up and evaluating bottom up are the same observation seen from two ends.
Step 6

Make 2 + 3 * 4 come out fourteen, not twenty

2 + 3 * 4 is fourteen. Nothing in the tokens says so. The rule that settles it has a name: precedence. An operator with higher precedence takes hold of its neighbours before a lower one gets near them, and people also say it binds tighter. So 3 * 4 is grabbed as one lump, and the plus only ever sees the twelve. Multiply and divide bind tighter than plus and minus, in Sprout and in nearly every language and in the arithmetic you already do without thinking about it. The token list is identical whichever answer you want and the difference is entirely which node ends up above which.

A parser with one level of rules, one function taking every operator as equally important, reads left to right and confidently produces twenty. Splitting those rules into levels, one per tightness, with multiply on a tighter level than add, produces fourteen without one line of special-case code.

Where the words tighter and higher come from

An operator that binds tighter grabs its neighbours before a looser one gets to them, so it ends up deeper in the tree and is worked out sooner. People also say "higher precedence" for the same idea, which is a little confusing, because higher precedence means lower down the tree.

In a recursive-descent parser the levels are functions. parseSum calls parseTerm for each of its pieces, so anything parseTerm is allowed to grab is already grabbed before parseSum ever sees it. Adding a precedence level means adding a function, and nothing else.

Lab 11 · The wrong parser and the right one, side by side
Start here Look at the two answers underneath the trees. Then press 2 * 3 + 4 and watch them agree, which is the part that makes this bug hard to spot.
Both trees, both answers, same expression. Try 2 * 3 + 4, where they agree, before 10 - 2 * 3, where they do not. The cases where a broken parser accidentally agrees are the reason this bug survives so long in a language somebody is writing for fun.
Lab 12 · Precedence without a tree
Start here Press Run: it says 20, and the right answer is 14. Press Check against hidden expressions to see which ones it gets wrong before you change anything. Then do it in two passes. Pass one: walk the list. Each time you meet a * or a /, work it straight into the number you are already holding. Each time you meet a + or a -, put that operator into one new list and the number into another. After that pass, [2,"+", 3,"*", 4] has become the numbers [2, 12] and the operators ["+"], and all the multiplying is done. Pass two: add and subtract your way along those two lists, left to right. Press Show me one that works if you get stuck, then change one line of it and watch which hidden expression complains.
Two passes over a flat list will do it. Graded against expressions you cannot see, including 10 - 2 - 3, where going right to left gives eleven instead of five.
You add a ^ operator for powers, and you want 2 + 3 ^ 2 to be eleven. Where does it go?
Below term. Then parseTerm calls the power level for each of its pieces and the power grabs first. Put it on the same level as multiply and 2 * 3 ^ 2 quietly becomes thirty-six instead of eighteen. Precedence is not a table the parser looks things up in; it is the order in which the functions call each other.
Step 7

Write one function per rule, and let them call each other

One function per rule. parseExpr calls parseSum, which calls parseTerm, which calls parseFactor. And parseFactor, the moment it meets an opening bracket, calls parseExpr again.

That last call is what makes it recursive, and it is why brackets nest as deeply as you like without a single line of code anywhere about nesting.

function parseSum() {                       // sum → term { ( '+' | '-' ) term }
  let left = parseTerm();
  while (at('+') || at('-')) {
    const op = next();
    const right = parseTerm();
    left = { type: 'Bin', op: op.text, left, right };
  }
  return left;
}

function parseTerm() {                      // term → factor { ( '*' | '/' | '%' ) factor }
  let left = parseFactor();
  while (at('*') || at('/') || at('%')) {
    const op = next();
    const right = parseFactor();
    left = { type: 'Bin', op: op.text, left, right };
  }
  return left;
}

function parseFactor() {                    // factor → NUMBER | NAME | '(' expr ')'
  const t = peek();
  if (t.type === 'num')  { next(); return { type: 'Num', value: Number(t.text) }; }
  if (t.type === 'name') { next(); return { type: 'Var', name: t.text }; }
  if (t.text === '(') {
    next();                                 // step past the bracket, then start again at the top
    const inner = parseExpr();
    expect(')');
    return inner;
  }
  fail('expected a number, a name, or an opening bracket');
}

Something has to remember where each of those calls had got to. When parseSum calls parseTerm, parseSum is not finished: it is waiting, and when the answer comes back it has to carry on from the exact line it stopped on. So every call that is in progress gets a small note to itself, saying which function it is and how far through it has got. That note is called a frame. Frames pile up newest on top, and the pile is the call stack. The frame on top is the one actually running. Everything under it is a call that is waiting for an answer. When a function returns, its frame comes off and the one underneath carries on. Step 12 uses the very same stack for Sprout's own functions. Here it belongs to the parser.

peek, next, at, expect and fail: where did those five come from?

They are the parser's hands, and every recursive-descent parser ever written has the same five. peek() looks at the token the cursor is sitting on without moving it. next() takes that token and moves the cursor on by one. at('+') is a shortcut for "is the token we are on a plus?" and answers true or false without moving anything, which is what lets a while ask before it commits. expect(')') takes the next token if it is the one named and stops the whole parse with an error if it is not, which is where "expected a closing bracket" comes from. fail(message) stops the parse and says why.

One more piece of shorthand. { type: 'Bin', op: op.text, left, right } writes left where you would expect left: left. JavaScript lets you drop the repeat when the field and the variable have the same name. So that really is a node whose left field holds whatever the variable left is holding. Nothing clever is happening, and it is worth recognising because it is written that way nearly everywhere.

A function that calls itself: why does that not go round forever?

Because each call is handed a smaller job than the call that made it. parseFactor meets a (, swallows that bracket, and asks parseExpr to deal with whatever is inside. What is inside is shorter than what parseFactor was given, by at least that bracket. So the jobs shrink, and a piece of text of some fixed length can only be made shorter a fixed number of times. Sooner or later a call gets something with no bracket in it at all, answers on its own without calling anybody and the whole pile unwinds back up.

That is the shape of every recursion that works, and it has only two parts. A case that answers without calling anything and a call that moves towards that case. Nesting is where it earns its keep. ((((1)))) needs no code anywhere about four levels of bracket, because the code for one level, applied to itself, is already the code for any number of levels. Leave out the first half, the case that answers on its own. You get a pile that only ever grows, which is the mistake Step 12 lets you make on purpose.

Why this style is used by real compilers, not only small ones

Because the code and the grammar are the same thing written twice. A rule with a repeat in it becomes a while. A rule with a choice becomes an if. A rule that mentions another rule becomes a call. When the language changes you change the rule and the function together, and you can see at a glance that they still match.

There is a whole industry of tools that generate parsers from a grammar file, and they are widely used and the parsers inside GCC, Clang and most modern compilers are hand-written recursive descent anyway. The usual reason given is error messages: a hand-written parser knows what it was expecting and can say so.

Lab 13 · The call stack, rising and falling
Start here Press Forward repeatedly. Frames appear as rules are called, and the token cursor moves only on the steps that take a token.
Step through the parse. While the parse is happening, the depth of that stack is the only place the nesting of the expression is written down anywhere. When the stack empties, the nesting has moved into the tree.
Lab 14 · How deep does it get?
Start here Guess 4 for the first one, then read what actually happened and why the number is what it is.
Predict the depth. There is a pattern here, and once you have seen it you can read the depth straight off the expression without running anything. The surprise is which expressions cost nothing extra.
parseFactor meets a ( and calls parseExpr, which is three levels above it. Does that ever end?
It ends because something got smaller. The bracket is consumed before the call, so the recursion always works on a shorter piece of input, and an input of finite length can only be shortened so many times. That is the same argument that makes any recursion finish, and the missing version of it is exactly what overflows the stack in Step 12.
Step 8

Point at the exact character that caused an error

Every node kept the character positions it was built from. Two small numbers per node, costing almost nothing, and they buy a surprising amount.

They are why a compiler can say line 4, column 12. Why an editor can highlight the whole expression under your cursor when you double-click it. Why a debugger can show you which line you are stopped on. All of that is two integers that somebody remembered to keep.

What happened to the brackets?

There is no bracket node in the tree, because brackets are not something a program does. Their entire job was to tell the parser which node goes above which, and once the shape is right they have nothing left to say. Sprout widens the span of the node inside them so that highlighting still covers them, and that is all that survives.

Which makes printing a tree back out as text harder than it sounds: whoever writes that has to work out where brackets are needed to get the same shape back. Code formatters do exactly this, and it is why running one over your code sometimes adds or removes brackets you had opinions about.

Lab 15 · Click a node, light up the source
Start here Click the * node in the tree and watch the source light up. Then click a character in the source and watch a node light up.
It works in both directions. Click a node and the characters light up; click a character and the smallest node covering it lights up. Try clicking a space between two things and see which node claims it, and which does not.
Lab 16 · Operate on the tree, leave the text alone
Start here Press the * button to pick that node, then press make it +, and watch the answer change without the text changing.
Change an operator inside the tree. The text still says one thing and the answer changes anyway. Then swap the children of a minus and watch the sign flip, which is what left and right actually mean.
You change a node's operator from + to * without touching the source text. What does the evaluator do?
It multiplies. Nothing after the parser ever looks at the source again. That sounds like a small technical detail and it is the reason a compiler can have several front ends: point a parser for a different language at the same shape of tree, and every stage after it works unchanged. It is also how source-to-source tools work, changing the tree and printing it back out.
Step 9

Walk the tree and get an answer out

To find out what a tree is worth, ask the root. It cannot answer without its children, so it asks them, and they ask theirs, until something is asked that already knows the answer: a number.

Then the values come back up, one per node, until the root is holding the only one left.

function evalNode(node, env) {
  if (node.type === 'Num') return node.value;
  if (node.type === 'Var') return lookUp(env, node.name);

  if (node.type === 'Bin') {
    const a = evalNode(node.left, env);      // ask the left child
    const b = evalNode(node.right, env);     // ask the right child
    if (node.op === '+') return a + b;       // then do one small thing
    if (node.op === '-') return a - b;
    if (node.op === '*') return a * b;
    if (node.op === '/') return a / b;
  }
  fail('no rule for a ' + node.type + ' node');
}

One thing in there is borrowed from the next step and can be taken on trust for now. env is where the variables live: every name that is currently in reach, with the value it is holding. lookUp(env, name) fetches one of them, or stops with an error if nothing is called that. Step 10 is about nothing else and pulls it apart properly. For this step, read those two lines as "a number already knows its own value, and a name has to be looked up somewhere". The other nine lines are the whole idea. They need nothing you have not got.

The same idea without any trees in it

Think of it as a chain of questions. What is 2 + 3 * 4? I do not know, what is 2? Two. What is 3 * 4? I do not know, what is 3? Three. What is 4? Four. So 3 * 4 is twelve, so 2 + 12 is fourteen.

Nothing there needed a plan or a list of steps. Every question is answered by asking smaller questions of exactly the same kind, and the smallest ones answer themselves. That is the entire trick. It is why the evaluator above is twelve lines rather than a hundred.

Lab 17 · Watch the values come back up
Start here Press Forward until a small number appears under a node. That number is the value that node handed to its parent.
Forwards to go down, backwards to come up. Each node lights twice: once on the way down when it is asked, and once on the way back when it answers. Nothing is ever asked twice.
Lab 18 · Write the evaluator
Start here Press Run: it says the tree is worth 0, because the function still says return 0. Build it in three goes, running after each one. One: if (node.num !== undefined) { return node.num }, which makes a single leaf such as { num: 7 } work. Two: ask the children, with let a = evalTree(node.left) and let b = evalTree(node.right). Yes, the function is calling itself; that is the whole idea, and it stops because every call is handed a smaller tree. Three: one if per operator, using a and b. Then press Check against hidden trees.
Nine lines is enough. If it hangs or complains about a step budget, the first if is missing or wrong. Without a case that answers on its own, the calls never stop, which is Step 7's argument run backwards. Graded against trees you cannot see, including ones that nest on the right, where getting the two children the wrong way round changes the answer and nesting on the left would have hidden it.
An evaluator has a bug: for - it works out the right child first and subtracts the left from it. Which test finds it?
10 - 4. The bug is invisible for 2 - 2, and invisible for every + and every *, because those give the same answer either way round. A test suite made only of symmetric cases passes a broken program with full marks. This is exactly why the graded lab above tests trees that nest to the right.
Step 10

Give a value a name, and decide where the name lives

A variable is a name and a box. The evaluator needs somewhere to keep those pairs, and that somewhere has a name: an environment.

One environment is not enough, because two parts of a program may want the same name for different things. A pair of curly braces on their own, { ... }, is a block. Every block gets an environment of its own: empty when the block opens, thrown away when it closes. The stretch of program in which a particular variable can be seen is that variable's scope, and scope is the word everybody uses. An environment is the box; a scope is the reach. So environments come in chains: the block's, then whatever that block sits inside, out to the outermost one of all. That outermost one is the global scope, because every part of the program can see it. A lookup starts at the innermost environment and walks outwards until it finds the name, or runs out and reports that nothing is called that.

What let does that a plain assignment does not

let x = 1 makes a new box in the current environment, whatever is already out there. x = 1 looks for a box that exists already, starting inside and working outwards, and writes into the first one it finds. If there is none, Sprout stops and says so rather than quietly inventing one.

That difference is the whole of shadowing. A let inside a block makes a second box with the same name that hides the first for as long as the block lasts, and the first one sits there untouched the entire time. Languages that let you skip the let and create a variable by assigning to it turn a misspelling into a brand new variable, which is a bug that produces no error at all.

Lab 19 · Watch the environment fill and empty
Start here The program has already run once, and the slider under the buttons is parked at its last step. Drag that slider all the way to the left, then press Forward ▶ until a second scope box appears above the global one, and keep going until it vanishes again.
Step through and watch the boxes. A block opens and a second scope appears above the global one; the block closes and it is gone, along with everything in it. Then load the three variants and change one word at a time.
Lab 20 · Four programs, one word apart
Start here Read the first program, decide what it prints, then press an answer. Being wrong here is more useful than being right.
Predict each one before you look. The difference between these programs is usually a single let. The difference in what they print is not small at all, and one of them stops with an error, which is the environment doing its job.
A block runs let x = 2 when there is already an x outside it holding 1. After the block finishes, what is the outer x holding?
1. let never writes into a box that already exists; it makes a new one in the environment it is standing in. For as long as the block lasts there are two boxes called x, and a lookup finds the inner one first because lookups start innermost. When the block ends, that environment goes and the inner box goes with it, and the outer x is exactly as it was, because nothing ever touched it. Drop the let and it is a completely different program: an assignment walks outwards, finds the outer box, and does write into it.
Step 11

Add if and while, and turn a calculator into a language

Two node types and about ten lines of evaluator between them, and they are the difference between a calculator and a language.

The one thing worth watching is that while works out its condition again at the top of every pass. Everywhere else in the tree, a node is visited once. Here the same nodes are visited over and over, and that is where all the running time in any program goes.

Why Sprout insists a condition is true or false

Sprout refuses while (n) with a number in it. Plenty of languages accept it and treat zero as false, which is convenient until the day something returns an empty string or an empty list and a branch you were relying on quietly stops running.

Refusing is a design decision, not a limitation, and it is worth noticing that these decisions are now yours. Somebody sat down and made this exact call for every language you have ever used and the ones who chose convenience and the ones who chose strictness both had good reasons.

Lab 21 · Step a real Sprout program
Start here The program has already run once. Drag the slider under the buttons all the way to the left. Then press Forward ▶ a dozen times, and watch n come down and the condition be worked out again at the top of each pass.
Every statement, in order, with the variables beside it. Watch the condition being worked out fresh at the top of each pass, and watch the if pick one branch and never so much as look at the other. Then load the loop that never ends.
Lab 22 · Write a Sprout program that works for any limit
Start here Press Run and record to see what it does now, then press Check to find out which hidden limit it fails on.
Graded by running it with limits you cannot see. One of them is zero, and a loop whose condition is false the first time round has to run its body no times at all.
A while loop's body never changes anything its condition looks at. What happens?
It runs forever. No interpreter can spot this in general. Deciding whether an arbitrary program ever stops is the halting problem, and it has been known to be impossible since 1936. What an interpreter can do is count steps and give up, which is what Sprout does, and what your browser does when it offers to stop a page that is not responding.
Step 12

Call a function, and watch the frames stack up

A call has to remember two things: the values of its own parameters, and where to carry on once it finishes. Both of them live in a frame, and frames stack up.

Calling the same function twice makes two frames with two separate copies of everything, which is what makes a function reusable, and what makes it possible for a function to call itself without the two calls treading on each other.

Where the stack in "stack overflow" comes from

You met frames and the call stack in Step 7, when the parser was calling itself. Sprout's functions use the same pile. Every call pushes a frame and every return pops one, so the frames form a stack in the strict sense: last on, first off. A function that calls itself with no way to stop pushes frames until whatever holds them runs out, and the message you get names the structure that overflowed.

In a real program that structure is a block of memory handed out when the program started, usually about eight megabytes, which is a few tens of thousands of frames. Sprout uses a much smaller limit so that you can reach it in a second and see what it looks like on the way.

Lab 23 · Frames going on, frames coming off
Start here The program has already run once. Drag the slider under the buttons all the way to the left. Then press Forward ▶ until the frames panel under the slider shows two frames stacked, and keep going to watch them come off again.
Step through and watch the frames panel. Then load the recursive one, where five frames pile up each with its own copy of n: four that print a number, and a fifth for n = 0, the call that answers without calling anything. Watch them unwind in the opposite order, then overflow it deliberately and read what Sprout says.
Lab 24 · Write a function that calls itself
Start here Press Check against hidden numbers before you change anything. It reports that total(1) gave 0 and should give 1, the smallest case the empty function gets wrong, and worth noticing that total(0) was already right. Then write the function.
Graded by calling it with numbers you cannot see. Including zero, which is the case that has to answer without calling anything, and twenty, which needs twenty-one frames on the stack at once.
A recursive function is missing its stopping case. Where does it fail?
When the frames run out. The error names a place deep inside the recursion rather than the missing if at the top of it, which is the general shape of the bugs that take longest to find: the symptom lands a long way from the cause. The habit that avoids it is writing the stopping case first, before the call, every single time.
A Sprout function can see the global variables and its own, and never the variables of whoever called it. Why is that a deliberate rule rather than a missing feature?
Because it would tie every function to the private details of every place it is called from. Rename a local variable in one function and another function silently changes behaviour. Some early languages did work this way, it is called dynamic scoping, and nearly everything designed since has chosen the other rule. The choice is yours to make when the language is yours.
Step 13

Compile it once instead of walking the tree every time

Walking the tree works. It also asks the same questions over and over: what kind of node is this, where does this name live, which child comes first. Every one of those answers was already fixed when the tree was built, and none of them changes between one run and the next.

A compiler answers them once and writes down what to do. What it writes is a flat list of tiny instructions, and running that list needs a machine far simpler than a tree walker. The list has a name you will see everywhere: bytecode. A byte is the small fixed-size lump that computers count memory in. The name stuck because in the early systems each instruction was written down as exactly one byte: one number standing for ADD, a different number standing for PUSH. Sprout does not bother squeezing its instructions down to numbers, and the word is used anyway, for any list of instructions meant to be run by a machine that is itself a program rather than a chip.

Sprout's machine has about twenty instructions and two piles. The first is the value stack: a pile of values where the only place you may add one or take one is the top. PUSH 3 drops a three on the top. ADD lifts the top two off, adds them, and drops the single answer back. No instruction ever says where its inputs are, because the answer is always "the top", and a machine built that way is called a stack machine. The second pile is the frames from Step 12, unchanged. And the instructions themselves are kept in chunks: one chunk per function, plus one called main for everything outside any function. That is why some of the labs below have a row of chunk buttons above the listing.

Where the order comes from, and what a real processor does instead

2 + 3 * 4 becomes: push 2, push 3, push 4, multiply, add. Five instructions, and no instruction mentions any other. Read it in order and the three and the four meet at the multiply, while the two is still sitting underneath them waiting. That is the tree's shape written out as a sequence.

Turning the tree shape into that order is the compiler's whole job, and it does it in exactly the same walk the evaluator does, except that at each node it writes an instruction instead of computing a value. Real processors work differently, with numbered registers rather than a stack and the Java and Python virtual machines both use a value stack much like this one.

Lab 25 · Emit the code, and trace it back
Start here Find the JZ line in the listing and click it. The piece of source it came from lights up above.
Edit the program and click any instruction. It lights up the piece of source it came from. Look for the if: there is no IF instruction anywhere, only a comparison and a jump, because a jump is all any machine has ever had.
Lab 26 · Step the machine, one instruction at a time
Start here Press Step one instruction five times and watch the stack grow, then keep going and watch an operator shrink it.
Watch the stack rise and fall. It grows by one for every value pushed and shrinks by one every time an operator fires. Then follow a function call: arguments go on the stack, a frame appears, and RET drops the frame and puts a single answer back.
Lab 27 · Count the work both ways
Start here The slider starts at one run, where compiling is behind. Drag it to two and watch the compiled total overtake the other one.
Every number here is counted by doing it. The short loop and the program with no loop both pay their compiling back on the second run, which is sooner than most people guess. The longer loop never does, and the reason is worth reading: this machine takes more steps per pass than the tree walker, so on this counter no amount of repetition rescues it. Real bytecode wins anyway, because a machine step is far cheaper than a tree step, and a counter cannot show you that.
Compiling 2 + 3 * 4 gives PUSH 2, PUSH 3, PUSH 4, MUL, ADD. Where did the precedence go?
Into the order. MUL has no idea it binds tighter than anything, and the machine never wonders: it runs the list. Every question about precedence was settled by the parser, written down as the shape of a tree, and then flattened into a sequence. That is what compiling means, and it is why the machine at the end can be so simple.
Step 14

Run the language you built, end to end

All of it is here now. Characters in at one end, an answer out at the other, and five places along the way where you can stop and look at what is being carried.

What follows is the whole pipeline in one view, and then a piece of the language that was left unfinished on purpose, waiting for you.

What the small Sprout does not have yet

Arrays and objects: a new kind of value, a node type for indexing, and rules for what happens when the index is out of range. Closures: a function must retain the bindings used by its free variables, which moves some environment cells onto a heap. Types checked before running: name resolution and a checking pass sit between parsing and execution.

The next twelve steps build those bridges, then continue through memory management, compiler IR, optimisation, targets, editor tooling, concurrency, isolation, packages and constrained AI output.

Lab 28 · The whole pipeline, live
Start here Click along the strip: 2 · Tokens, then 3 · Tree, then 4 · Bytecode, then 5 · Machine, and press Step a few times.
Edit the program and everything below it changes. Click along the strip to follow one program through all five stages, then load the syntax error and watch the last three stages go dark, because a stage with nothing handed to it has nothing to do.
Lab 29 · Add the missing operator
Start here Press Run: the first line prints 5 and the second prints no rule for %. That second line is the whole task.
The tokeniser already knows the character %, and the parser already gives it the same tightness as multiply. Nothing has ever said what it computes. Write that rule, and eleven hidden Sprout programs get run through your function, including one that uses it to print only the even numbers. Once they all pass, Sprout keeps your rule: % works in the scratch pad below, and in the machine in Lab 28, for the rest of the session.
Lab 30 · Scratch pad
Start here Press Run and record, then change a number in the program and run it again.
Nothing here is marked. The whole language, four ideas above it, and a step budget that will stop you rather than the page. If you finished the lab above, your % is part of the language here too, so print 7 % 3 answers 1. Write something that does not work and find out why: that is the part that sticks.
You want to add ** for powers, binding tighter than *. Which parts of the pipeline have to change?
All three, in that order. The tokeniser has to stop producing two separate stars. The parser needs a new level below term, or 2 * 3 ** 2 comes out as thirty-six. And the evaluator needs a rule, or the node it builds means nothing. The lab above needed only the last of those because the first two were already done for %, which is precisely why % was the one left unfinished.

Checkpoint: what the core language can do

  • Turn text into tokens, and say why keeping positions matters more than it looks.
  • Read a grammar, and tell a syntax error from an error about meaning.
  • Write a recursive-descent parser, and place an operator at the right precedence by choosing which function calls which.
  • Walk a tree to work out what a program means, with variables, scopes, conditions, loops and functions.
  • Say what a call frame holds, and why recursion needs a fresh one every call.
  • Compile a tree into bytecode and run it on a stack machine, one instruction at a time.
  • Add an operator to a language, and know which stages you have to touch to do it.

Connections from the core pipeline

  • Reading the Machine's Mind. The last stage again, on a real processor: numbered registers instead of a value stack, and instructions that a chip carries out rather than a loop in JavaScript. The bytecode you just emitted is the halfway house between the two.
  • Build a Query Engine. A database takes text, tokenises it, parses it into a tree and runs the tree. Same four stages, and then a fifth one that is unique to databases: choosing between several correct ways to answer the same question.
  • Computability and Complexity. The halting problem from Step 11, properly: why no program can look at another program and say whether it stops, and what else follows from that once you accept it.
Step 15

Write down what every program is allowed to mean

The evaluator gives Sprout a meaning, but its JavaScript body is not a specification. A language specification describes observable behaviour: results, printed output, errors and effects. Two implementations conform when every accepted program has the same required observations.

Small-step semantics writes one legal machine step at a time. Big-step semantics relates a program and starting state directly to a result. Either style must settle details such as evaluation order, numeric overflow, short-circuit conditions and whether an error happens before or after an effect.

Must every implementation use the same algorithm?

No. An interpreter, VM and native compiler can use different representations. They must agree on the behaviour the specification exposes. Timing and memory layout are usually not observable unless the language deliberately promises them.

How does a conformance suite help?

Each test names a rule, a program and the required observation. Run the same corpus through every implementation. Also test errors and effects, because comparing only final numbers misses evaluation order.

Lab 31 · Compare two legal-looking evaluators
Try this firstSwitch evaluation order and overflow rules. Run the conformance cases and locate the first observable disagreement.
The failing case belongs beside the rule it tests. A prose rule and an executable example catch different mistakes, so keep both.
Two compilers use different register layouts but print the same required output and errors. Are they conforming?
Conformance is about promised observations. Internal representations may differ.
Step 16

Resolve each name, then check each operation

Name resolution connects every use such as score to one declaration. Do this before type checking. Text equality is not enough: two declarations can have the same spelling in different scopes, while an imported name may be renamed locally.

A type checker assigns a type to each expression and verifies operations. The rule for addition may accept two numbers and produce a number. A conditional needs a Boolean condition and compatible branch results. Inference fills types only when the constraints determine one answer.

Does static typing remove runtime checks?

It removes checks already proved for trusted code. Array bounds, division by zero, dynamic casts and data from files or networks can still need runtime checks. Unsafe foreign code can also violate a declaration.

What are unification and type variables?

A type variable stands for an unknown type. Constraints such as T = Number and List<T> = List<Number> are unified until the checker finds one substitution or a conflict.

Lab 32 · Bind names and solve type constraints
Try this firstInspect four short programs. Resolve shadowed names, then ask the checker to explain the exact conflicting constraints.
Diagnostics should point to the use and the declaration that supplied the expectation. “Types differ” is not enough information to repair a program.
Why does binding happen before type checking?
A name's declaration determines its type and scope.
Step 17

Keep captured values without keeping the whole call stack

A closure is executable code plus the environment needed by its free variables. If a function returned from makeCounter uses count, that binding must outlive the call that created it. Capture the needed binding, not an accidental copy of every local variable.

Records group named fields. Modules group declarations behind an exported interface. The compiler can check each module separately when an interface records the types and identities that other modules may use.

Is a closure just its source code?

No. Two closures made from the same function can capture different cells. If the language permits mutation, decide whether closures share one captured cell or receive copied values.

Why give symbols identities?

A stable symbol identity distinguishes two declarations named open. Renaming changes text, not identity. Separate compilation and editor tools rely on that distinction.

Lab 33 · Build two independent closures
Try this firstCreate two counters from one function. Choose copied values or shared cells, call them in turn, then move the function across a module boundary.
The closure record should show its code pointer and each captured cell. This is the bridge from lexical scope to heap memory.
Two counters come from the same function body. Why can their answers differ?
Code can be shared while captured state remains separate.
Step 18

Trace which heap objects are still reachable

Call frames usually live on a stack because they leave in reverse order. Closures, records and growing collections can outlive one call, so their storage lives on a heap. A value representation must distinguish numbers, Booleans and references, often with a tag beside the stored bits.

A tracing collector starts from roots such as globals and live frames, follows references, marks every reachable object and reclaims the rest. Reference counting reclaims promptly but needs help with cycles. Ownership systems try to prove lifetimes before running. Each choice changes pauses, throughput and complexity.

Can a collector free an object that will be used later?

A correct collector cannot. If it does, a root or pointer update was missed. Moving collectors must also update every reference or use a forwarding mechanism.

What causes a collection pause?

The runtime may stop application threads to find a consistent graph. Generational, incremental and concurrent collectors reduce different parts of that cost, but require write barriers and more bookkeeping.

Lab 34 · Mark a cyclic heap
Try this firstChoose the roots, mark reachable objects one edge at a time, remove a root and collect again. Watch a cycle become unreachable as a group.
Reachability, not age or reference count alone, decides the result in this collector.
Two unreachable objects point at each other. Can tracing collection reclaim them?
A cycle does not create a root.
Step 19

Lower structured code into blocks and explicit edges

An intermediate representation sits between the language and its targets. A control-flow graph divides instructions into basic blocks: execution enters at the top, leaves at the bottom and does not branch in the middle. Edges show every possible next block.

Static single assignment form gives each temporary value one definition. Where control-flow paths join, a phi operation selects the value that arrived on the executed edge. Dominators and use-definition links make questions such as “which definition can reach this use?” mechanical.

Does SSA mean a source variable can never change?

No. Assigning x twice creates versions such as x1 and x2 in the IR. The source language can remain mutable.

Why keep source locations after lowering?

Diagnostics, coverage and debuggers must connect generated instructions back to source. An optimisation can combine or remove source operations, so the mapping may be a range or an inlining stack.

Lab 35 · Place a phi value at a join
Try this firstLower an if/else expression into four blocks. Remove the phi, follow each path, then restore the value selected at the join.
The graph preserves possible order; SSA preserves where each value came from.
Why is a phi operation placed at a control-flow join?
The join has several possible incoming definitions.
Step 20

Change the program without changing its promised behaviour

Constant folding replaces a known expression such as 3 * 4 with 12. Dead-code elimination removes an instruction whose result and effects are unused. Common-subexpression elimination reuses a prior result only when intervening operations cannot change what it reads.

An optimisation is correct when the transformed program refines the original semantics. Integer overflow, floating-point NaN, exceptions, volatile access and aliases can make an algebraic rewrite invalid. Measure real workloads too: larger code can lose instruction-cache time even when it executes fewer operations.

Is x * 0 always zero?

Not in every language. Evaluating x might raise an error or perform an effect, and IEEE floating point includes NaN and infinity. The language's semantics decides which rewrite is legal.

How is an optimiser tested?

Unit-test each pass, verify IR invariants, compare original and transformed programs, fuzz both and run end-to-end conformance tests. Translation validation checks a particular result rather than trusting the pass.

Lab 36 · Accept or reject four rewrites
Try this firstToggle overflow, effects and aliasing assumptions. Apply constant folding and dead-code removal, then compare observations before keeping a rewrite.
A faster wrong program is a compiler bug. Save every rejected case as an optimiser regression.
When may an unused-looking call be deleted?
Unused result does not imply effect-free operation.
Step 21

Keep one language while changing the machine underneath

A backend lowers IR into a target's instructions. Ahead-of-time compilation writes an object before the program runs. A just-in-time compiler produces code during execution and can specialise from measurements. WebAssembly provides a portable validated instruction format; native code exposes more target detail.

A calling convention says where arguments, return values and saved registers go. Object files carry code, data, symbols and relocations. A linker resolves symbols and lays out the result. The application binary interface also fixes sizes, alignment and ownership rules across a compiled boundary.

Does compiling to WebAssembly make host access automatic?

No. A module receives host functions through imports. The embedder decides which files, clocks, network operations or other capabilities exist. Read the current WebAssembly core specification when implementing a target.

Where would LLVM fit?

A frontend can lower its language to LLVM IR and reuse optimisation, object generation and JIT layers. The official Kaleidoscope tutorials show IR, JIT, object code and debug information. Match the tutorial to the LLVM release you install. Continue with Build a Microprocessor for the registers, pipelines, caches and memory system beneath generated native code.

Lab 37 · Lower one call to three targets
Try this firstChoose Sprout VM, WebAssembly or a small native convention. Place two arguments and a result, then introduce one ABI mismatch.
Both sides of a boundary must agree on the same convention. Correct code on each side can still fail when their ABIs differ.
What does a linker resolve?
Compilation can produce separate objects; linking connects them.
Step 22

Keep useful answers while the program is half typed

An editor sees incomplete text most of the time. Error recovery inserts or skips a bounded piece so the parser can continue and produce a partial tree. Incremental parsing reuses unaffected parts after an edit. Incremental name and type analysis should invalidate facts that depend on the changed declaration, not everything.

A language server separates language knowledge from one editor. It publishes diagnostics and answers completion, hover, definition, references and rename requests. A correct rename follows symbol identities; a text replacement can alter comments, unrelated scopes or a different exported symbol.

What does current editor parsing look like?

Tree-sitter maintains concrete syntax trees across edits and represents recoverable errors in the tree. The Language Server Protocol defines messages between editors and language tools. These are examples, not requirements for Sprout.

Should a formatter preserve every character?

It changes layout but should preserve syntax and behaviour. Test idempotence: formatting twice should equal formatting once. Preserve comments and use a stable rule for line breaks.

Lab 38 · Reanalyse only what one edit affects
Try this firstRename one declaration, make a temporary syntax error and close it again. Compare a whole-file rebuild with dependency-based invalidation.
Recovery nodes are provisional. Do not publish a confident type or destructive rename through uncertain syntax.
Why should rename use symbol identities rather than matching text?
Binding, not spelling alone, determines a reference.
Step 23

Specify what concurrent programs may observe

Concurrency adds more than a task API. The language must say which operations are atomic, when writes become visible and which orderings a program may rely on. Without those rules, a compiler optimisation and a processor can each reorder operations in ways that surprise source-level reasoning.

A happens-before relation orders synchronization events. If two conflicting accesses have no required order and at least one writes, the program has a data race under many memory models. Languages may forbid that case, define limited outcomes or provide atomics with explicit ordering.

Does one source line run atomically?

Not unless the language promises it. A line can lower to several loads, calculations and stores. Protect the complete invariant with the language's synchronization mechanism.

How can a runtime test concurrency?

Run small litmus programs across many schedules, save failing schedules and use model checking for bounded state spaces. A stress test is useful evidence but cannot enumerate every interleaving. Continue with Concurrent Data Structures for atomics, memory order, lock-free algorithms and safe reclamation.

Lab 39 · Explore a message-passing litmus test
Try this firstSchedule two writes and two reads. Add a release and acquire edge, then see which observations the model still permits.
A memory model defines permitted observations. It does not promise the scheduler will choose a particular task next.
What does a release/acquire pair establish when it synchronizes?
Synchronization orders memory effects; it does not choose a schedule.
Step 24

Give untrusted programs specific powers and firm budgets

A sandbox starts with no ambient authority. The host supplies explicit capabilities such as reading one directory or calling one service. Validate arguments at that boundary. A safe bytecode verifier also checks instruction targets, stack effects and types before execution.

Time is not the only resource. Limit instructions, call depth, heap bytes, output, open handles and host-call rates. Cancellation must reach long host operations. Isolation in another process or WebAssembly instance adds a boundary when one runtime bug must not expose the host.

Is a step counter enough?

No. One step could allocate a huge value or call a host function that blocks. Charge work where it occurs and set independent budgets for memory, recursion, output and imported operations.

What is deterministic execution useful for?

A controlled clock, random seed and import set make tests and replay predictable. It does not by itself provide security; authority and resource isolation remain necessary.

Lab 40 · Stop four different resource attacks
Try this firstRun a loop, recursive call, output flood and denied file import. Adjust one budget or capability at a time.
Report which budget stopped the program and what cleanup ran. Do not turn every limit into the same “runtime error.”
Why pass a file capability instead of exposing the host filesystem?
Authority should be explicit and narrow.
Step 25

Evolve packages without changing what old code means

A package needs an identity, version and dependency graph. A module name inside a package is not globally unique. Locking exact versions makes a build repeatable; a compatibility policy tells maintainers what an update may change. Public syntax, types, effects and data formats all form part of a contract.

A foreign-function interface crosses representation and ownership rules. Both sides must agree on calling convention, string encoding, layout, error transfer and who frees memory. Wrap that boundary in a small adapter and test it from both languages.

Can a package manager always choose one newest version?

Constraints can conflict, and two packages may need incompatible major versions. A resolver needs a stated policy for duplicate versions, features and platform conditions, plus an explanation when no solution exists.

What is separate compilation?

Compile a module from its source and imported interfaces rather than every dependency body. Record enough interface and compiler-version information to invalidate the result when an assumption changes.

Lab 41 · Resolve versions and check an FFI boundary
Try this firstChoose package constraints, inspect the resolved graph, then pass a string across two ownership conventions and repair the mismatch.
The lock records a chosen graph. It does not prove semantic compatibility or safe ownership at a foreign boundary.
What must an FFI string contract say besides its address?
Representation and ownership cross the boundary with the value.
Step 26

Constrain generated code, then run the ordinary gates

In 2026, a model can draft a grammar rule, compiler pass, test or migration, but plausible text is not a language implementation. Grammar-constrained decoding can restrict the token sequences it emits. Structured output schemas can require fields. Neither proves that names resolve, types check, effects are permitted or the transformation preserves behaviour.

Use compiler stages as a verification pipeline: parse the output, resolve and type it, check requested capabilities, run focused tests, compare transformed programs and review the diff. Feed precise diagnostics back for another attempt. Keep the model outside release authority and record the accepted evidence. The Software Construction patch-review lesson supplies the repository scope and authority checks around these language-specific gates.

Can a grammar guarantee a safe program?

No. It guarantees only permitted syntax. deleteAllFiles() can be perfectly grammatical. Binding, types, effects, capabilities and tests address different layers of correctness.

Where can learned models help language tools?

They can rank completions, suggest repairs, translate diagnostics or propose optimisations. Keep a deterministic compiler as the authority. Evaluate suggestions on held-out projects and measure incorrect edits, not only accepted suggestions.

Lab 42 · Pass a generated patch through five gates
Try this firstCompare four generated patches. A schema-valid response may still fail parsing, type checking, capability review or semantic tests.
The accepted artifact is the patch plus its evidence. A fluent explanation is not one of the mechanical gates.
What does grammar-constrained generation establish?
Syntax is one gate. The normal compiler and engineering checks still run.