engineeringtooling 5 min read

Emoji AST Interpreter REPL Stage - Part 5

May 22, 2026 ยท 03:24 UTC

Emoji AST Interpreter REPL Stage - Part 5

One thing still separates the emoji language from a real tool, a proper sit-down conversation with it is not yet possible at all, every run so far has been a hardcoded string sitting in a source file, so this final part adds two things that change everything, a small set of built-in functions and a read-eval-print loop that accepts typed expressions and runs them live.

Built-in functions extend the language without touching its grammar much, the parser learns to read a name followed by parentheses as a call, and the evaluator then looks that name up in a function table, so adding a new capability becomes trivial.

The read-eval-print loop is really the front door, it reads a single line, runs it, and then prints the result over and over.

Note

This is a five-part series building one emoji language end to end.

  1. Part 1 - Lexer
  2. Part 2 - Parser
  3. Part 3 - Walker
  4. Part 4 - Scope
  5. Part 5 - REPL (you are here)

Teaching the Parser to Call

Calling a function is just another primary expression, so the change to the parser is small and local, when the parser reads an identifier and then immediately sees an opening parenthesis, it switches from treating that name as a variable to treating it as a call, gathers the arguments by parsing a fresh expression for each, and packages them into a single call node.

Its call node carries two pieces, the function name and an array of argument nodes, and because each argument is itself a full expression node, the result of one call can pass straight into another and nest as deeply as the whole problem demands.

Only one tiny lexer change is needed here, the comma now joins the operator set so calls can separate their arguments cleanly.

// src/types.ts (add to the Node union)
export type Node =
  // ...existing shapes...
  { type: 'call'; name: string; args: Node[] }

// src/parser.ts (inside parsePrimary, after reading an ident)
if (peek().value === '(') {
  next() // consume '('
  const args: Node[] = []
  while (peek().value !== ')') {
    args.push(parseExpression(0))
    if (peek().value === ',') {
      next()
    }
  }
  next() // consume ')'
  return { type: 'call', name, args }
}

A Table of Built-ins

Every built-in lives in a plain table that maps a name to a function taking an array of already-evaluated numbers, and this design keeps the evaluator blissfully ignorant of what any given function actually does, it just evaluates every argument, looks the name up, and hands the values straight over, so the whole library is nothing more than ordinary plain functions.

A handful of useful built-ins reaches surprisingly far, a max that returns the largest fruit value, a min that returns the smallest, and a sum that adds a basket together, and each one is a single line because the hard work already happened first.

Evaluating a call node then becomes almost trivial, evaluate each argument first, then simply look up and apply the function.

// src/eval.ts
const BUILTINS: Record<string, (args: number[]) => number> = {
  max: (args) => Math.max(...args),
  min: (args) => Math.min(...args),
  sum: (args) => args.reduce((total, n) => total + n, 0),
}

// inside evaluate(), a new case:
case 'call': {
  const fn = BUILTINS[node.name]
  if (!fn) {
    throw new Error(`Unknown function: ${node.name}`)
  }
  return fn(node.args.map((arg) => evaluate(arg, scope)))
}

The Read-Eval-Print Loop

Running the loop is the piece that finally makes the whole language feel alive, it reads one line of input at a time, runs it through the full pipeline of lexer, parser, and evaluator, prints whatever comes back, and then loops around to await the next line, and it reuses a single scope across iterations so a variable assigned on one line is still there on the next.

Wrapping each iteration in a try-catch turns a fatal crash into a friendly message, so a typo or an unknown fruit prints an error and the loop simply keeps going instead of dying outright, which is the whole difference between a toy and a real tool.

Reusing a single scope is what makes an entire session feel truly continuous rather than a series of disconnected one-liners here.

// main.ts
import { tokenize } from './src/lexer.ts'
import { parse } from './src/parser.ts'
import { evaluate } from './src/eval.ts'
import type { Scope } from './src/types.ts'

const scope: Scope = {}

console.log('emoji repl - type an expression, ctrl+c to quit')
for await (const line of console) {
  const source = line.trim()
  if (source === '') {
    continue
  }
  try {
    console.log('=>', evaluate(parse(tokenize(source)), scope))
  } catch (error) {
    console.log('error:', (error as Error).message)
  }
}

Where the Fruit Leads

Five parts ago the emoji language was a single string with no meaning at all, and now it lexes, parses with correct precedence, evaluates a tree, remembers variables, chooses between branches, and calls built-in functions from a live prompt, and every one of those capabilities arrived through the same tiny loop of extending a node and teaching the two later stages.

That repeatable rhythm is the real lesson hiding under the fruit, a compiler is not one intimidating monolith but a short pipeline of small honest stages, and once those stages become clear a language for almost anything at all becomes buildable.

Fruit was only ever a friendly disguise, the real machinery underneath is the genuine article well worth taking along afterward.

// a full session
๐ŸŽ + ๐ŸŒ * ๐Ÿ’ // => 7
basket = sum(๐ŸŽ, ๐ŸŒ, ๐Ÿ’) // => 6
max(basket, ๐Ÿ“) // => 6
basket > ๐Ÿ“ ? ๐Ÿ’ : ๐ŸŽ  // => 3
Share
On this page
No results found.