Building a Lisp Interpreter From Scratch

January 29, 2024

What Is This?

A complete Lisp interpreter built from scratch in plain JavaScript with zero external dependencies. It parses and evaluates a subset of Lisp/Scheme, supports lambdas, closures, recursion, higher-order functions, and comes with an interactive REPL and a comprehensive test suite.

Supported Features

  • Arithmetic & comparisons: (+ 1 2 3), (> 5 3), (equal? x y)
  • Variables: (define x 10), (set! x 20)
  • Conditionals: (if condition then else)
  • Lambda & closures: (lambda (x) (* x x))
  • Higher-order functions: ((repeat twice) 10)
  • Recursion: (define fact (lambda (n) (if (<= n 1) 1 (* n (fact (- n 1))))))
  • List operations: list, car, cdr
  • Quoting, booleans, strings, sequencing with begin

How It Works

Recursive Descent Parser

The interpreter has no separate tokenizer/lexer phase. It's a pure recursive descent parser that operates character-by-character on raw strings. Each sub-parser tries to match its pattern and returns [value, remainingInput] or null on failure:

parser  expressionParser | numberParser | booleanParser | valueParser | stringParser

Special Forms

S-expressions are intercepted for special Lisp keywords before being treated as function calls — define, begin, if, lambda, quote, and set! each have their own parser that handles their unique evaluation semantics.

Lambda & Closures

The lambdaParser implements lexical scoping by shallow-cloning the environment at definition time. This means closures correctly capture their surrounding scope:

(define repeat (lambda (f) (lambda (x) (f (f x)))))
((repeat twice) 10)  ; => 40

Lambdas create actual JavaScript functions stored in the environment, making higher-order functions and recursion work naturally.

Global Environment

A built-in standard library maps Lisp symbols to JavaScript functions — arithmetic, comparison, math builtins (pi, abs, sqrt, pow), and list operations (car, cdr, list).

Interactive REPL

A readline-based REPL with the prompt lisp>>. The environment persists across inputs, so variables defined in one line are available in the next. Type exit to quit.

Key Design Decisions

  • No tokenizer — the recursive descent parser works directly on raw strings, keeping the implementation simple and self-contained
  • Environment as a plain JS object — enables lexical scoping via object spread ({ ...env })
  • Functions are first-class JS closureslambda produces real JavaScript functions, so higher-order patterns work naturally
  • Multi-expression input — the main() evaluator handles multiple top-level expressions in a single input string
  • 38 test cases covering arithmetic, recursion, closures, higher-order functions, quoting, mutation, and syntax error detection

What I Learned

Building a language interpreter from scratch deepened my understanding of how programming languages work at a fundamental level — from parsing and evaluation to scope chains and closure semantics. Implementing features like lambda, set!, and recursive functions revealed the elegance behind Lisp's minimal syntax and powerful abstractions.

View the source code →