-
Notifications
You must be signed in to change notification settings - Fork 1
Journey 01 Genesis
November 7, 2025 - The day I decided JavaScript engines couldn't be that hard
I was messing around in the OTelMCP project when the thought hit me: what if I built a JavaScript engine?
Not a wrapper around V8 or anything sensible like that. A real interpreter. From scratch. In C#.
My thinking was simple. Lisp interpreters are elegant - cons cells and recursion. JavaScript is just... syntax sugar over objects and functions, right?
So, what if we:
- Parse JavaScript
- Convert it to S-expressions
- Evaluate it like Lisp
Three sentences into Codex:
"I want to create a JS execution engine using C#
- it will need a js parser/grammar.
- I want to represent the result of the parser as S-expressions / CDR/Cons
- the execution engine can then treat the code similar to lisp"
That's it. That's the prompt that started everything.
I hit enter and waited. Codex came back with:
Summary
Introduced the new Asynkron.JsEngine library with cons-based S-expression primitives, a lexer/parser, and an evaluator so JavaScript-like code can run with Lisp-style semantics and host interop hooks.
Files: 23
23 files. One prompt. A working JavaScript engine with tests.
Nice, right?
It even built and the tests passed. The original was called JsLispEngine and used Esprima for parsing:
src/Asynkron.JsEngine/
├── Parsing/JsSExpressionBuilder.cs ← Esprima AST → S-expressions
├── Runtime/JsLispEngine.cs ← The original evaluator
├── SExpressions/SExpression.cs ← Cons, Symbol, Nil
└── context.md
But: I wanted more control. Esprima is nice but it's someone else's parser. If I'm gonna build an engine, I want to understand every piece of it.
So, I rewrote it. Threw out Esprima, wrote my own lexer and parser.
| Codex Version | My Version |
|---|---|
| Esprima (external) | Handwritten Lexer.cs/Parser.cs
|
JsLispEngine class |
Evaluator static class |
SExpression.cs |
Split: Cons.cs, Symbol.cs
|
| Basic objects |
JsObject, JsArray, JsFunction
|
| No control flow |
BreakSignal, ContinueSignal, etc. |
The "first" commit landed with 4,270 lines across 26 files. All working. All tested.
Here's the core idea that still makes me happy about this design.
JavaScript like this:
let x = 1 + 2;Becomes an S-expression like this:
(let x (+ 1 2))Which is basically just cons cells:
Cons(Symbol("let"),
Cons(Symbol("x"),
Cons(Cons(Symbol("+"), Cons(1, Cons(2, Nil))), Nil)))And the evaluator? It's basically just a Lisp eval:
public object Evaluate(object expr, Environment env)
{
if (expr is double or string or bool)
return expr; // Self-evaluating
if (expr is Symbol sym)
return env.Lookup(sym.Name); // Variable lookup
if (expr is Cons cons)
{
var head = cons.Head;
if (head == JsSymbols.Let)
return EvaluateLet(cons, env);
if (head == JsSymbols.If)
return EvaluateIf(cons, env);
if (head == JsSymbols.Add)
return EvaluateAdd(cons, env);
return EvaluateCall(cons, env); // Function call
}
throw new Exception($"What is this: {expr}");
}Pattern match on the head symbol, dispatch to the right handler. Elegant.
This is either clever or stupid, depending on who you ask.
I used exceptions for control flow:
public class BreakSignal : Exception { }
public class ContinueSignal : Exception { }
public class ReturnSignal : Exception
{
public object Value { get; }
}When you hit a return statement, throw a ReturnSignal. The function catches it and extracts the value. Same for break and continue in loops.
Is it idiomatic C#? No. Does it work? Yes. Did I change it later? Also yes. :-)
From day one: 932 tests. 565 for the evaluator, 367 for the parser.
I've learned the hard way (Akka.NET, Proto.Actor) that tests are everything. When you refactor, tests tell you what you broke. When you're exhausted at 2am, tests tell you to go to bed because nothing passes anyway.
Within hours of that "first" commit, the copilot-swe-agent started hammering out PRs:
| PR | What |
|---|---|
| #1 | README, solution structure |
| #2 | Ternary operator, template literals, getters/setters |
| #3 | Spread/rest operators |
| #4 | Array methods, Math object |
| #5 | Date, JSON objects |
| #6-7 | CPS transformer (here we go...) |
The foundation was solid. The Lisp idea was working.
And I had no idea how complicated things were about to get.
Next: Chapter 2: CPS for Async - Where I learn that async/await is not "just syntax sugar"
//Roger