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.
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.
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
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.
(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.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.
total = prise * 2, where prise is a
misspelling of price. Which stage complains?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.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.
12 + and watch nothing change. Then click any chip to see which characters it came from.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.
>= 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.** operator for powers. The tokeniser is not changed. What
happens to 2 ** 3?** 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.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.
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.
>= 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.
false. Then press Check against hidden inputs and read the first failure before you change anything.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.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.
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.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.let, then x, then =, then 1. The verdict underneath updates after every single token.let x = 1 / 0 without complaint. Why?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.
1 + 2 + 3 and see which of the two plus signs ends up on top.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.
2 * 3 + 4 and watch them agree, which is the part that makes this bug hard to spot.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.* 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.10 - 2 - 3, where going right to left gives eleven instead of
five.^ operator for powers, and you want 2 + 3 ^ 2 to be
eleven. Where does it go?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.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.
parseFactor meets a ( and calls parseExpr, which is
three levels above it. Does that ever end?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.
* node in the tree and watch the source light up. Then click a character in the source and watch a node light up.* button to pick that node, then press make it +, and watch the answer change without the text changing.+ to * without touching the
source text. What does the evaluator do?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.
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.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.- it works out the right child first and
subtracts the left from it. Which test finds it?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.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.
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.let x = 2 when there is already an x outside it
holding 1. After the block finishes, what is the outer x holding?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.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.
n come down and the condition be worked out again at the top of each pass.if pick one branch and never
so much as look at the other. Then load the loop that never ends.while loop's body never changes anything its condition looks at. What
happens?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.
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.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.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.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.
JZ line in the listing and click it. The piece of source it came from lights up above.if: there is no IF instruction anywhere, only a comparison and
a jump, because a jump is all any machine has ever had.2 + 3 * 4 gives PUSH 2, PUSH 3, PUSH 4, MUL, ADD. Where did the
precedence go?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.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.
no rule for %. That second line is the whole task.%, 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.% 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.** for powers, binding tighter than *. Which
parts of the pipeline have to change?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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.