Build a tiny JavaScript interpreter
Build a small interpreter for a piece of JavaScript: a tokenizer, a recursive-descent parser and a tree-walking evaluator, with scopes and closures.
- 01Build the three partsWrite a tokenizer, a parser and an evaluator, and say what each one takes and gives back.
- 02Explain precedence and scopesShow why
*ends up lower in the tree than+, and how a closure keeps the scope it was made in. - 03Compare with real enginesSay what V8 and other engines do that a tree-walking interpreter doesn't, such as bytecode and JIT compilers.
What an interpreter does
An interpreter is a program that runs other programs. In this lesson you build one for a small piece of JavaScript. We'll call it TinyJS: numbers, strings, let, + - * /, comparisons, if, functions and a built-in print. It is three small files you can read in full.
import { run } from "./evaluator.js";
run(`
function factorial(n) {
if (n <= 1) {
return 1;
}
return n * factorial(n - 1);
}
let price = 4;
print("total", price * 3 + 1);
print("5! =", factorial(5));
`);Lines 4 to 12 are a TinyJS program, in a string. Line 3 passes it to run, from the interpreter's evaluator.js file. It prints total 13 and 5! = 120, the same as JavaScript would.
run does three things, one after another. The tokenizer splits the text into words and symbols. The parser builds a tree from them. The evaluator walks the tree and works out values.
Picture reading a recipe in a language you're still learning. First you pick out the words. Then you work out which words belong to which step. Only then do you start cooking, keeping track of what is already in the bowl.
- In real life: Picking out the words and numbers
- In JavaScript: The tokenizer: text into tokens
- In real life: Working out which words make up each step
- In JavaScript: The parser: tokens into a tree
- In real life: Doing the steps, in order
- In JavaScript: The evaluator: walking the tree
- In real life: Remembering what's already in the bowl
- In JavaScript: A scope: names and their values
An interpreter turns text into tokens, tokens into a tree, and then walks the tree to work out values. Only that last part runs anything.
Step 1: split the text into tokens
A token is the smallest piece of code that means something: a number, a string, a name, a keyword such as let, or a symbol such as =. The tokenizer turns the text into a flat list of tokens. Here is all of it:
const pattern = /(\d+(?:\.\d+)?)|("[^"]*")|([A-Za-z_]\w*)|(===|!==|<=|>=|[-+*/<>=(){},;])/y;
const keywords = new Set(["let", "function", "return", "if", "else"]);
export function tokenize(source) {
const tokens = [];
let position = 0;
while (position < source.length) {
if (/\s/.test(source[position])) {
position += 1;
continue;
}
pattern.lastIndex = position;
const match = pattern.exec(source);
if (!match) throw new SyntaxError(`Unexpected "${source[position]}" at ${position}`);
const [text, number, string, name] = match;
if (number) tokens.push({ type: "number", value: Number(number) });
else if (string) tokens.push({ type: "string", value: string.slice(1, -1) });
else if (name) tokens.push({ type: keywords.has(name) ? "keyword" : "name", value: name });
else tokens.push({ type: "punct", value: text });
position = pattern.lastIndex;
}
return tokens;
}Line 1 is one regular expression with four groups: numbers, strings, names, and punctuation. The y flag makes it sticky: it only matches exactly at lastIndex, never further ahead.
Lines 8 to 11 skip spaces and line breaks. Line 12 says where to match, line 13 matches, and line 14 throws if no group fits. Lines 16 to 19 make a token from whichever group matched; line 18 tells keywords from other names.
import { tokenize } from "./tokenizer.js";
const tokens = tokenize("let total = price * 2;");
console.log(tokens.map((token) => token.type + ":" + token.value).join(" "));Line 3 tokenizes let total = price * 2;, and line 4 prints each token as type:value: keyword:let name:total punct:= name:price punct:* number:2 punct:;. Seven tokens. Nothing checks yet that they make sense together.
The order inside line 1's last group matters: === comes before =, because the first choice that matches wins. Exercise 6 shows what goes wrong otherwise. Real engines write their scanner by hand; the scanner and tokens lesson shows V8's.
Step 2: build a tree
The parser reads the tokens and builds a tree of nodes, one for each part of the program. This one is a recursive-descent parser: one function for each grammar rule, and the functions call each other.
import { tokenize } from "./tokenizer.js";
export function parse(source) {
const tokens = tokenize(source);
let index = 0;
let functionDepth = 0;
const peek = () => tokens[index];
const next = () => tokens[index++];
const isNext = (value) => peek()?.type !== "string" && peek()?.value === value;
const found = () => (peek() ? `"${peek().value}"` : "the end");
function expect(value) {
if (!isNext(value)) throw new SyntaxError(`Expected "${value}" but found ${found()}`);
next();
}
function name() {
if (peek()?.type !== "name") throw new SyntaxError(`Expected a name but found ${found()}`);
return next().value;
}
function list(item) {
expect("(");
const items = [];
while (!isNext(")")) {
items.push(item());
if (!isNext(")")) expect(",");
}
expect(")");
return items;
}
function block() {
expect("{");
const body = [];
while (!isNext("}")) body.push(statement());
expect("}");
return body;
}
function statement() {
if (isNext("let")) {
next();
const target = name();
expect("=");
const value = expression();
expect(";");
return { type: "Let", name: target, value };
}
if (isNext("function")) {
next();
const target = name();
const params = list(name);
functionDepth += 1;
const body = block();
functionDepth -= 1;
return { type: "Function", name: target, params, body };
}
if (isNext("return")) {
if (functionDepth === 0) throw new SyntaxError("return is only allowed inside a function");
next();
const value = expression();
expect(";");
return { type: "Return", value };
}
if (isNext("if")) {
next();
expect("(");
const test = expression();
expect(")");
const then = block();
const otherwise = isNext("else") ? (next(), block()) : null;
return { type: "If", test, then, otherwise };
}
const value = expression();
expect(";");
return { type: "Expression", value };
}
function expression() {
const left = binary(0);
if (isNext("=") && left.type === "Name") {
next();
return { type: "Assign", name: left.name, value: expression() };
}
return left;
}
const levels = [["===", "!==", "<", ">", "<=", ">="], ["+", "-"], ["*", "/"]];
function binary(level) {
if (level === levels.length) return call();
let left = binary(level + 1);
while (peek() && peek().type === "punct" && levels[level].includes(peek().value)) {
const operator = next().value;
left = { type: "Binary", operator, left, right: binary(level + 1) };
}
return left;
}
function call() {
let callee = primary();
while (isNext("(")) callee = { type: "Call", callee, args: list(expression) };
return callee;
}
function primary() {
const token = next();
if (!token) throw new SyntaxError("Unexpected end of input");
if (token.type === "number" || token.type === "string") return { type: "Literal", value: token.value };
if (token.type === "name") return { type: "Name", name: token.value };
if (token.value === "(") {
const value = expression();
expect(")");
return value;
}
throw new SyntaxError(`Unexpected "${token.value}"`);
}
const body = [];
while (index < tokens.length) body.push(statement());
return { type: "Program", body };
}Lines 7 to 10 look at the tokens: peek reads the next one, next takes it, and isNext checks it. Lines 11 to 18 take a token that must be there, such as ; or a name, and throw a SyntaxError if it isn't.
Lines 37 to 74 are statement. It looks at the first token to pick the kind of statement: let, function, return, if, or an expression followed by ;.
Lines 84 to 93 handle operators. Line 84 lists them in levels, loosest first. Each level asks the next, tighter level for its operands on lines 87 and 90. The while loop on line 88 puts each new operator above the one before.
import { parse } from "./parser.js";
function show(node) {
if (node.type === "Binary") return "(" + node.operator + " " + show(node.left) + " " + show(node.right) + ")";
if (node.type === "Call") return "(call " + show(node.callee) + " " + node.args.map(show).join(" ") + ")";
if (node.type === "Name") return node.name;
return String(node.value);
}
const program = parse("print(1 + 2 * 3);\nlet total = (1 + 2) * 3;\nlet left = 10 - 3 - 2;");
for (const statement of program.body) console.log(statement.type, show(statement.value));Lines 3 to 8 print a node as a short list: the operator first, then its operands. Line 11 prints one line per statement: Expression (call print (+ 1 (* 2 3))), Let (* (+ 1 2) 3) and Let (- (- 10 3) 2).
* sits lower in the tree, so the evaluator works it out first: 6, then 7. Right: the parser's levels. Each one asks the next, tighter level for its operands, which is why 2 * 3 becomes one operand of +.So the parser, not the evaluator, decides precedence: * ends up lower in the tree, and lower nodes are worked out first. In 10 - 3 - 2, the first - is lower, so it is (10 - 3) - 2. The parsing and ASTs lesson shows the full trees real parsers build.
Step 3: walk the tree
The evaluator walks the tree, node by node, and works out values. That makes this a tree-walking interpreter. It also keeps track of variables, in scopes.
import { parse } from "./parser.js";
class Scope {
constructor(parent = null) {
this.vars = new Map();
this.parent = parent;
}
declare(name, value) {
this.vars.set(name, value);
}
find(name) {
for (let scope = this; scope; scope = scope.parent) if (scope.vars.has(name)) return scope;
throw new ReferenceError(`${name} is not defined`);
}
}
class ReturnSignal {
constructor(value) {
this.value = value;
}
}
export function run(source, print = console.log) {
const globals = new Scope();
globals.declare("print", print);
execBlock(parse(source).body, globals);
}
function execBlock(statements, scope) {
for (const statement of statements) exec(statement, scope);
}
function exec(node, scope) {
if (node.type === "Let") scope.declare(node.name, evaluate(node.value, scope));
else if (node.type === "Function") scope.declare(node.name, makeFunction(node, scope));
else if (node.type === "Return") throw new ReturnSignal(evaluate(node.value, scope));
else if (node.type === "If") {
if (evaluate(node.test, scope)) execBlock(node.then, new Scope(scope));
else if (node.otherwise) execBlock(node.otherwise, new Scope(scope));
} else evaluate(node.value, scope);
}
function makeFunction(node, closure) {
return (...args) => {
const scope = new Scope(closure);
node.params.forEach((param, i) => scope.declare(param, args[i]));
try {
execBlock(node.body, scope);
} catch (signal) {
if (signal instanceof ReturnSignal) return signal.value;
throw signal;
}
return undefined;
};
}
function evaluate(node, scope) {
if (node.type === "Literal") return node.value;
if (node.type === "Name") return scope.find(node.name).vars.get(node.name);
if (node.type === "Assign") {
const value = evaluate(node.value, scope);
scope.find(node.name).vars.set(node.name, value);
return value;
}
if (node.type === "Call") {
const fn = evaluate(node.callee, scope);
if (typeof fn !== "function") throw new TypeError(`${fn} is not a function`);
return fn(...node.args.map((arg) => evaluate(arg, scope)));
}
const left = evaluate(node.left, scope);
const right = evaluate(node.right, scope);
const operations = {
"+": () => left + right, "-": () => left - right, "*": () => left * right, "/": () => left / right,
"<": () => left < right, ">": () => left > right, "<=": () => left <= right, ">=": () => left >= right,
"===": () => left === right, "!==": () => left !== right,
};
return operations[node.operator]();
}Lines 3 to 15 are a Scope: a Map of names and values, plus a parent scope. Lines 23 to 27 are run: it makes the global scope, puts print in it, parses the text and runs each statement.
Lines 33 to 41 run statements. Line 34 stores a let, line 35 stores a function, lines 37 to 39 run one block or the other, and line 40 works out an expression. Lines 57 to 77 work out expressions: a literal is its own value (line 58), a name is looked up (line 59), and an operator works out both sides, then applies itself (lines 70 to 77).
return can be inside several blocks, so line 36 throws a ReturnSignal. The throw leaves every block at once, and the function made on lines 43 to 55 catches it on line 50 and returns its value.
import { run } from "./evaluator.js";
for (const program of ["let a = 5 # 2;", "let = 5;", "print(price);", "print(1 + 2)"]) {
try {
run(program);
} catch (error) {
console.log(error.name + ": " + error.message);
}
}Line 3 lists four broken programs, line 5 runs each one, and line 7 prints its error:
SyntaxError: Unexpected "#" at 10: the tokenizer has no rule for#.SyntaxError: Expected a name but found "=": the parser needs a name afterlet.ReferenceError: price is not defined: the evaluator finds nopricein any scope.SyntaxError: Expected ";" but found the end: the parser needs a;.
Read each row of the table as: "this part takes this, gives back this, and stops on this."
| Part | Takes | Gives back | Stops on |
|---|---|---|---|
| Tokenizer | Text | A flat list of tokens | A character it has no rule for: # |
| Parser | Tokens | A tree of nodes | Tokens in the wrong order: a missing ; |
| Evaluator | A tree | Values, and what print shows | A name no scope has: price is not defined |
Scopes and closures
Every function call gets a new scope. The question is which scope becomes its parent. In TinyJS, as in JavaScript, it is the scope where the function was written. That scope is the function's closure.
import { run } from "./evaluator.js";
run(`
function makeCounter() {
let count = 0;
function add() {
count = count + 1;
return count;
}
return add;
}
let counter = makeCounter();
print(counter());
print(counter());
let other = makeCounter();
print(other());
`);Lines 4 to 11 define makeCounter, which returns add. Lines 13 and 14 print 1 and 2; line 16 prints 1. In evaluator.js, line 45 makes each call's scope with the closure as its parent.
A replay of what the lesson's evaluator does for this program. The printed values come from running it.
script
import { run } from "./evaluator.js"; function makeCounter() { let count = 0; function add() { count = count + 1; return count; } return add;}let counter = makeCounter();print(counter());print(counter());let other = makeCounter();print(other());`);Press Step to follow the program. This replays what evaluator.js does; the printed values come from running it.
makeCounter call made its own scope with its own count. A call to counter starts in an empty scope; find follows the parent arrows until it reaches a scope that has the name.import { run } from "./evaluator.js";
run(`
let x = "outer";
function show() {
return x;
}
function test() {
let x = "inner";
return show();
}
print(test());
`);Line 12 calls test, which has its own x on line 9 and calls show. But show was written at the top level, so it finds the top-level x, and line 12 prints outer. This is lexical scope. The closures lesson shows the same in JavaScript.
Where real engines differ
V8, SpiderMonkey and JavaScriptCore start the same way: tokens, then a tree. After that, they do much more. Read each row of the table as: "for this job, TinyJS does this, and a real engine does this."
| Job | TinyJS | A real engine, such as V8 |
|---|---|---|
| Tokens | One regular expression; ASCII names only. | A hand-written scanner for all of Unicode, every number format, templates and regular expressions. |
| Tree | The whole program, before it runs. | Pre-parses most function bodies quickly, and builds their full tree when they are first called. |
| Running | Walks the tree, every time. | Turns the tree into bytecode; compiles hot functions to machine code. |
| Variables | A Map per scope, searched by name on every read. | Works out where most variables live before running, then reads them by position. |
| Errors | A message, with a position only from the tokenizer. | A line and column for every syntax error, and a stack trace for errors while running. |
The biggest difference is how the tree is run. Walking a tree means jumping from node to node for every operation. Real engines turn the tree into bytecode, a flat list of small instructions, and run that. Functions that run often are compiled to machine code while the program runs, by a JIT (just-in-time) compiler.
The bytecode and interpreters lesson shows real bytecode, and the engines overview names each engine's tiers.
Run your own TinyJS
Write a TinyJS program, and see its tree, its tokens, and what it prints. The playground runs this lesson's three files in this page.
- (function square (n) (return (* n n)))
- (let side 4)
- (call print "area" (call square side))
runningRunning…
Your program is running in a worker, so a slow program can't freeze this page.
Pick Too slow: slow(40) would make over a trillion calls, so the playground stops it after 1 second. Pick An error, then fix the name on line 2.
Ideas to try next, each one touching all three parts:
- Add
%: a token rule, a place in a parser level, and an entry inoperations. - Add
while: a keyword, a branch instatement, and a loop inexec. - Keep each token's position, so every error can say where it happened.
- Splitting
let totalinto two tokens - Throwing
Unexpected "#" - Putting
2 * 3under+ - Throwing
Expected ";" - Adding 2 and 3
- Finding
countin a parent scope - Turning the tree into bytecode
- Compiling a hot function to machine code
Put each job with the part that does it: the tokenizer, the parser, the evaluator, or only real engines.
Common mix-ups
Most mix-ups come from mixing up what each part does, or where a function looks for its variables.
- "An interpreter runs the text directly." No. It runs a tree, or bytecode, made from the text.
- "The evaluator decides that
*goes first." No. The parser does, by putting*lower in the tree. - "A function looks up names where it is called." No. It looks where it was written: in its closure.
- "An engine is either an interpreter or a compiler." No. Real engines interpret bytecode first, then compile hot functions.
- "The tokenizer catches every syntax error." No. It only knows single tokens. A missing
;is found by the parser.
The table below puts three of these side by side. Read each row as: "people often think this, but this is true."
| Topic | What people think | What is true |
|---|---|---|
| Interpreters | An interpreter runs the text directly. | It runs a tree, or bytecode, made from the text. |
| Precedence | The evaluator decides that * goes first. | The parser does: * ends up lower in the tree. |
| Closures | A function finds variables where it is called. | It finds them where it was written: in its closure. |
Practice exercises
Each exercise is a small piece of one part, on its own. Guess first, then run it.
What does line 16 print?
const pattern = /(\d+)|([A-Za-z_]\w*)|([-+*/=;()])/y;
function tokenize(source) {
const tokens = [];
let position = 0;
while (position < source.length) {
if (source[position] === " ") {
position += 1;
continue;
}
pattern.lastIndex = position;
tokens.push(pattern.exec(source)[0]);
position = pattern.lastIndex;
}
return tokens;
}
console.log(tokenize("let area = (w + 2) * h;").length);const pattern = /(\d+)|([A-Za-z_]\w*)|([-+*/=;()])/y;
function tokenize(source) {
const tokens = [];
let position = 0;
while (position < source.length) {
if (source[position] === " ") {
position += 1;
continue;
}
pattern.lastIndex = position;
tokens.push(pattern.exec(source)[0]);
position = pattern.lastIndex;
}
return tokens;
}
console.log(tokenize("let area = (w + 2) * h;").length);let, area, =, (, w, +, 2, ), *, h and ; are 11 tokens.
What does line 13 print?
const tree = {
type: "Binary",
operator: "-",
left: { type: "Binary", operator: "-", left: { type: "Literal", value: 10 }, right: { type: "Literal", value: 3 } },
right: { type: "Literal", value: 2 },
};
function evaluate(node) {
if (node.type === "Literal") return node.value;
const left = evaluate(node.left);
const right = evaluate(node.right);
return node.operator === "-" ? left - right : left + right;
}
console.log(evaluate(tree));const tree = {
type: "Binary",
operator: "-",
left: { type: "Binary", operator: "-", left: { type: "Literal", value: 10 }, right: { type: "Literal", value: 3 } },
right: { type: "Literal", value: 2 },
};
function evaluate(node) {
if (node.type === "Literal") return node.value;
const left = evaluate(node.left);
const right = evaluate(node.right);
return node.operator === "-" ? left - right : left + right;
}
console.log(evaluate(tree));The left node is 10 - 3, which is 7. Then 7 - 2 is 5.
This throws. Add % to the evaluator, so 10 % 3 works. What does your program print?
function evaluate(node) {
if (node.type === "Literal") return node.value;
const left = evaluate(node.left);
const right = evaluate(node.right);
const operations = {
"+": () => left + right,
"*": () => left * right,
};
return operations[node.operator]();
}
const tree = { type: "Binary", operator: "%", left: { type: "Literal", value: 10 }, right: { type: "Literal", value: 3 } };
console.log(evaluate(tree));function evaluate(node) {
if (node.type === "Literal") return node.value;
const left = evaluate(node.left);
const right = evaluate(node.right);
const operations = {
"+": () => left + right,
"*": () => left * right,
"%": () => left % right,
};
return operations[node.operator]();
}
const tree = { type: "Binary", operator: "%", left: { type: "Literal", value: 10 }, right: { type: "Literal", value: 3 } };
console.log(evaluate(tree));With a % entry, the evaluator works out 10 % 3, which is 1.
This prints 10 not found. Change line 8 so get looks in the parent scope. What does your program print?
class Scope {
constructor(parent = null) {
this.vars = new Map();
this.parent = parent;
}
get(name) {
if (this.vars.has(name)) return this.vars.get(name);
return "not found";
}
}
const globals = new Scope();
globals.vars.set("tax", 0.2);
const inner = new Scope(globals);
inner.vars.set("price", 10);
console.log(inner.get("price"), inner.get("tax"));class Scope {
constructor(parent = null) {
this.vars = new Map();
this.parent = parent;
}
get(name) {
if (this.vars.has(name)) return this.vars.get(name);
return this.parent ? this.parent.get(name) : "not found";
}
}
const globals = new Scope();
globals.vars.set("tax", 0.2);
const inner = new Scope(globals);
inner.vars.set("price", 10);
console.log(inner.get("price"), inner.get("tax"));get now asks the parent when the name isn't here, so tax is found: 10 0.2.
This prints inner. Change line 15 so show finds the x from where it was written. What does your program print?
class Scope {
constructor(parent = null) {
this.vars = new Map();
this.parent = parent;
}
get(name) {
return this.vars.has(name) ? this.vars.get(name) : this.parent.get(name);
}
}
const globals = new Scope();
globals.vars.set("x", "outer");
// show was written at the top level, so it should see the top-level scope.
const show = { definedIn: globals, body: (scope) => scope.get("x") };
function call(fn, callerScope) {
return fn.body(new Scope(callerScope));
}
const testScope = new Scope(globals);
testScope.vars.set("x", "inner");
console.log(call(show, testScope));class Scope {
constructor(parent = null) {
this.vars = new Map();
this.parent = parent;
}
get(name) {
return this.vars.has(name) ? this.vars.get(name) : this.parent.get(name);
}
}
const globals = new Scope();
globals.vars.set("x", "outer");
// show was written at the top level, so it should see the top-level scope.
const show = { definedIn: globals, body: (scope) => scope.get("x") };
function call(fn, callerScope) {
return fn.body(new Scope(fn.definedIn));
}
const testScope = new Scope(globals);
testScope.vars.set("x", "inner");
console.log(call(show, testScope));The call's scope now gets show's closure, the global scope, as its parent, so it finds outer.
This prints a | = | = | = | 1. Change line 1 so === is one token. What does your program print?
const pattern = /([A-Za-z_]\w*)|(\d+)|(=|===|!==)/y;
function tokenize(source) {
const tokens = [];
let position = 0;
while (position < source.length) {
if (source[position] === " ") {
position += 1;
continue;
}
pattern.lastIndex = position;
tokens.push(pattern.exec(source)[0]);
position = pattern.lastIndex;
}
return tokens;
}
console.log(tokenize("a === 1").join(" | "));const pattern = /([A-Za-z_]\w*)|(\d+)|(===|!==|=)/y;
function tokenize(source) {
const tokens = [];
let position = 0;
while (position < source.length) {
if (source[position] === " ") {
position += 1;
continue;
}
pattern.lastIndex = position;
tokens.push(pattern.exec(source)[0]);
position = pattern.lastIndex;
}
return tokens;
}
console.log(tokenize("a === 1").join(" | "));Now === is tried before =, so it becomes one token: a | === | 1.
Check your understanding
For each question, ask which part does the work: the tokenizer, the parser or the evaluator. Each answer explains why it's right or wrong.
Question 1 of 7What does the tokenizer give the parser?
Choose an answer to see the explanation.
Question 2 of 7What does this tree evaluate to?
Read the code, then predictconst tree = { op: "*", left: { op: "+", left: 1, right: 2 }, right: 4 }; const value = (node) => (typeof node === "number" ? node : node.op === "+" ? value(node.left) + value(node.right) : value(node.left) * value(node.right)); console.log(value(tree));Choose an answer to see the explanation.
Question 3 of 7In this lesson's parser, why does
2 * 3end up as one operand of+in1 + 2 * 3?Choose an answer to see the explanation.
Question 4 of 7What does this print?
Read the code, then predictlet x = 1; function show() { return x; } function test() { let x = 2; return show(); } console.log(test());Choose an answer to see the explanation.
Question 5 of 7How does
returnin TinyJS leave a function from inside anifblock?Choose an answer to see the explanation.
Question 6 of 7Why does each call to
makeCounterget its owncount?Choose an answer to see the explanation.
Question 7 of 7What does V8 do that TinyJS doesn't?
Choose an answer to see the explanation.
Key takeaways
Every JavaScript engine starts the way TinyJS does; the rest is making it fast and complete.
- The tokenizer turns text into tokens; it only knows single words and symbols.
- The parser turns tokens into a tree, and its levels decide precedence.
- The evaluator walks the tree;
returncan be a throw that the call catches. - Each call gets a new scope whose parent is the function's closure: where it was written.
- Real engines make bytecode from the tree, and compile hot functions to machine code.
Remember the one-liner.
Split the text, build the tree, walk the tree, and look up names where the function was written.
This is the last lesson of the JavaScript course. Go back to the course overview to revisit any lesson, or keep building TinyJS.