A small JavaScript engine in C — a faithful ES2020+ parser (modeled on
JavaScriptCore's parser/ directory) plus a bytecode VM, a conservative
garbage collector, an object/prototype model, and an extensive standard
library. It started as a study of how JSC parses JavaScript and grew,
milestone by milestone, into something that actually runs code.
class Shape {
constructor(name) { this.name = name; }
area() { throw new Error("abstract"); }
toString() { return this.name + " (area " + Math.round(this.area()) + ")"; }
}
class Circle extends Shape {
constructor(r) { super("circle"); this.r = r; }
area() { return Math.PI * this.r * this.r; }
}
let shapes = [new Circle(2), new Circle(5)];
let report = shapes.map(s => s.toString()).join("; ");
console.log(report);
console.log(JSON.stringify({ total: Math.round(shapes.reduce((a, s) => a + s.area(), 0)) }));$ ./build/minijs --run demo.js
circle (area 13); circle (area 79)
{"total":92}
Classes, extends/super, try/throw, closures, generators, async/await,
Proxy/Reflect, RegExp, Promises, Map/Set, modules, template literals — all from
scratch, no dependencies beyond the C standard library, zero warnings under
-Wall -Wextra -std=c11.
Status: a working interpreter for a large, useful subset of JavaScript — not a conformant engine. The exact coverage and remaining gaps live in
docs/gaps.md.
Each milestone was implemented, then put through an adversarial multi-agent code
review, and the confirmed bugs fixed before moving on. The engine has grown well
past the original six milestones — a bytecode VM, modules, async/await, Proxy,
RegExp, typed arrays, WeakRef, and more. The full list of remaining gaps lives in
docs/gaps.md.
| Milestone | What it added |
|---|---|
| M0 | Tree-walking evaluator: numbers, strings, operators, console.log |
| M1 | Variables (var/let/const), scope chain, control flow, a Completion model |
| M2 | Functions, arrows, closures (capture by reference), recursion |
| M3 | A conservative mark-sweep garbage collector |
| M4 | Objects, arrays, member/index access, this, new, prototype chains |
| M5 | Standard library: Array/String/Math/Object/JSON/console + globals |
| M6 | Classes (extends/super), exceptions (try/catch/finally/throw), for-of/for-in |
| Post-M6 | Bytecode VM, generators, async/await, Proxy/Reflect, RegExp, Promises, modules, Map/Set/WeakMap/WeakSet, Symbol, TypedArray, ArrayBuffer/DataView, Date, Iterator helpers, Atomics, WeakRef/FinalizationRegistry, event loop |
The runtime lives in src/interp/ and runs under --run. It is
a bytecode pipeline: AST → IR → bytecode → VM, with the object model, GC,
environments and evaluator support split into focused modules.
- Value model — a tagged union (
JSValue): undefined, null, boolean, number (double), string, function, native (C builtin), object, symbol, and BigInt. (NaN-boxing is a future optimization.) - Environments — a runtime scope chain with
let/constblock scoping andvar/function hoisting. Closures capture their defining environment by reference. A Completion type carriesbreak/continue/returnup the tree. - Garbage collection — a conservative mark-sweep collector. Environments,
strings and objects carry a GC header; collection scans the C stack and
saved registers for roots, so a value held only in a C-stack temporary stays
alive without a handle API.
fib(28)holds at ~12 MB; a 1,000,000-closure loop stays bounded.MINIJS_GC_STRESS=1collects on every allocation (used to validate the collector under AddressSanitizer). - Objects & prototypes — objects have a prototype link and a property list;
arrays add a dense element vector. Member/index get/set walk the prototype
chain.
newand classes build the prototype chain; methods resolve through it. - Proxy/Reflect — full
Proxyconstructor with revocable proxies and the completeReflectnamespace. Proxy traps forget,set,has,deleteProperty,ownKeys,getOwnPropertyDescriptor,defineProperty,getPrototypeOf,setPrototypeOf,isExtensible,preventExtensions,apply, andconstruct. - Exceptions —
throw/try/catch/finallypropagate viasetjmp/longjmp; engine errors (TypeError-ish) are thrown as catchableErrorobjects. - Standard library — implemented as native C built-ins under
src/builtin/:Array.prototype(push/pop/map/filter/reduce/ forEach/find/indexOf/includes/join/slice/sort/…),String.prototype(slice/split/indexOf/includes/trim/repeat/match/replace/search/…, plus full-UnicodetoUpperCase/toLowerCasewith Turkic-I tailoring),RegExp(Perl-compatible engine with capture groups, lookahead, character classes),Math,Object(keys/values/entries/assign/create/defineProperty/freeze/seal/ getOwnPropertyDescriptor/…),JSON(stringify + a recursive-descent parse),Map/Set/WeakMap/WeakSet,Symbol(well-known symbols),Promise(with async/await),Date,TypedArray/ArrayBuffer/DataView,Iteratorhelpers,Atomics,WeakRef/FinalizationRegistry,console, and globals (parseInt,parseFloat,isNaN,encodeURIComponent,eval, …). All as C builtins; no external deps. Unicode case data is a generated UCD 17.0 table (tools/gen_unicode_tables.py). The full ECMA-262 built-in support matrix (what's done vs missing) lives indocs/gaps.md. - Modules —
import/exportwith named exports, default exports, namespace imports, and re-exports. Module resolution from the file system. - Event loop — a microtask queue for
Promiseresolution andqueueMicrotask, plussetTimeout/setIntervalsupport.
The parser (src/parser/, src/lexer.c)
reproduces twelve design patterns from JavaScriptCore:
- Bit-packed operator precedence — precedence is encoded in the token enum
via bit shifts, so the Pratt parser reads it with
GET_PREC(t)instead of a table lookup. Right-associativity (**) is just another bit. - Two-stack Pratt parsing —
parse_binary()uses an operand stack and an operator stack instead of recursion, soa + b + … + zis O(1) stack space. - Right-associative operators —
2 ** 3 ** 2 === 2 ** (3 ** 2). - Arena allocator — AST nodes come from 8 KB pools and are freed in bulk
when parsing ends (no per-node
free), matching JSC'sParserArena. - Tagged-union AST — a
NodeTypetag + a union of per-type fields is the C equivalent of JSC'sNodes.hclass hierarchy (~70 node types). - CodeFeatures flags — which features a script uses (
eval,typeof,closure,this, …) are detected at parse time. - SyntaxChecker (dual TreeBuilder) — the same parse code runs in two modes:
ASTBuilderbuilds nodes,SyntaxCheckervalidates with zero allocation. - VariableEnvironment — per-variable bit-packed flags; closure capture is detected when an inner function references an outer binding across a function boundary.
- Lazy function parsing —
--lazyvalidates function bodies without building their AST, recording offsets for later re-parse. - SourceProvider — wraps the source with URL/length for
file:line:col:diagnostics. - Error recovery —
--recoverresynchronizes at statement boundaries and reports up toMAX_PARSE_ERRORSat once. - Source Map v3 emitter —
--sourcemapemits valid VLQ Base64 mappings from the position info every node carries.
The parser accepts essentially all ES2020+ syntax (classes with
getters/setters and private #fields, generators, async/await, modules,
destructuring, template literals, regex, BigInt, …) and performs early
semantic checks (duplicate let/const, const reassignment, return outside
a function, strict-mode reserved words, and so on).
The lexer uses a table-driven maximal-munch approach for operators — all
multi-character operators (>>>=, ===, **=, etc.) are declared in a single
data table, avoiding deeply nested switch/match logic.
What the interpreter actually executes under --run covers the great
majority of what it parses:
Executes ✅
- All primitive values; arithmetic, comparison, logical (
&& || ?? !), ternary, comma,typeof, unary+/-,++/-- - Bitwise operators (
& | ^ ~ << >> >>>) var/let/constwith block scope and hoisting; assignment (=and all compound forms including&&=,||=,??=)if/else,while,do-while, C-stylefor,for-of(iterables),for-in(objects & array indices),switch/case/default,break/continue(incl. labelled),return- Functions, arrow functions, closures, default & rest parameters, recursion
- Destructuring (arrays, objects) in declarations, parameters, and assignments;
spread (
[...a],f(...a),{...o}) - Template literals (incl. tagged templates and nested interpolation)
- Objects (literals incl. shorthand / computed keys / method shorthand / getters
& setters), member & index get/set, prototype chains; arrays (literals with
holes, index,
length);this,new - Classes: constructor, instance & static methods, instance & static fields,
getters/setters,
extends,super(...),super.method(),new.target - Private
#fields(instance & static, methods & accessors) try/catch/finally,throw,Error/TypeError/RangeError/SyntaxError/ReferenceError/URIError/EvalError/AggregateError- Generators (
function*,yield,yield*,.next(),.throw(),.return()) async/await, async generators,for await…of- Promises (
new Promise,.then/.catch/.finally,Promise.all/allSettled/any/race/resolve/reject) - Proxy/Reflect (all traps,
Proxy.revocable) - Symbol (well-known symbols:
Symbol.iterator,Symbol.toPrimitive,Symbol.hasInstance,Symbol.toStringTag, etc.) - RegExp (character classes, quantifiers, groups, backreferences, lookahead/ lookbehind, Unicode mode)
- Map, Set, WeakMap, WeakSet
- TypedArray (
Int8ArraythroughFloat64Array,Uint8Arrayhex/base64), ArrayBuffer, SharedArrayBuffer, DataView Date(construction, parsing, formatting, getters/setters)- Iterator helpers (
map,filter,take,drop,flatMap,toArray, etc.) - Atomics (
load,store,add,sub,and,or,xor,exchange,compareExchange,isLockFree) - WeakRef, FinalizationRegistry
- BigInt (literals, arithmetic, comparisons)
delete,void,in,instanceof- Optional chaining (
?.,?.[],?.()); nullish coalescing (??) - Modules (
import/export, named, default, namespace, re-export) eval()(direct and indirect)- String escapes:
\n \t …,\xHH,\uHHHH(incl. surrogate pairs) and\u{…}, decoded to UTF-8; source is validated as well-formed UTF-8 on load - Unicode identifiers (
let café,Ωmega, CJK) via realID_Start/ID_Continuetables; non-ASCII whitespace/line-terminators (NBSP, BOM, U+2028/9, …) skipped per spec String.prototype.normalize(NFC/NFD/NFKC/NFKD, incl. Hangul)- The standard library and a conservative garbage collector
make
A C11 compiler (gcc/clang) and nothing else. Zero warnings under
-Wall -Wextra -std=c11.
minijs [--run] [--lazy] [--recover] [--sourcemap] <file.js>
| Flag | Effect |
|---|---|
--run |
Execute the program with the interpreter |
--lazy |
Defer function-body AST construction; report the count at exit |
--recover |
Collect multiple parse errors instead of stopping at the first |
--sourcemap |
Emit a Source Map v3 JSON instead of the AST |
$ ./build/minijs --run tests/test_m5.js # run it
$ ./build/minijs tests/test.js # parse only: print the AST + scope
Without a flag, MiniJS runs the parser's two passes — SyntaxChecker
(validation) then ASTBuilder — and prints the AST plus the
VariableEnvironment scope dump (note which variables are captured):
$ ./build/minijs tests/test.js
[SyntaxChecker] OK — CodeFeatures: typeof call member assign ternary comparison array object
Program(count=18)
VarDecl(var, count=1)
x:
init:
Binary(*)
Binary(+)
Number(10)
Number(20)
Number(3)
...
Scope(17 vars)
x: var captured (line 4)
...
include/
minijs.h Public API: tokens, AST, arena, scope, JSValue types
src/
vm_internal.h Engine-private interface (not a public header)
unicode.h Unicode property tables (internal)
arena.c Bump allocator for the AST (8 KB pools, bulk free)
lexer.c Tokenizer (table-driven operators, regex/template context)
main.c Entry point: parse, then --run or print the AST
printer.c AST pretty printer + CodeFeatures formatter
scope.c Parse-time VariableEnvironment (bit-packed flags)
sourcemap.c Source Map v3 emitter (VLQ Base64)
unicode.c Unicode: UTF-8 codec, case mapping, ID_Start/Continue,
NFC/NFD/NFKC/NFKD normalization (data: unicode_data.h)
parser/ Recursive descent + Pratt parsing + SyntaxChecker
common.c Shared parser utilities
expression.c Expression parser (Pratt + prefix/postfix)
statement.c Statement / declaration parser
pattern.c Destructuring pattern parser
internal.h Parser-private shared declarations
interp/ Runtime core
access.c Property access (get/set/delete) + prototype walk
compile.c AST → IR compilation
env.c Runtime environment / scope chain
gc.c Conservative mark-sweep garbage collector
ir_to_bytecode.c IR → bytecode lowering
module.c ES module loader and linker
object.c Object/array creation and manipulation
stdlib.c Built-in registration (install_* dispatch)
value.c JSValue operations, type conversions, comparison
vm.c Bytecode interpreter loop
eval/ AST evaluator (expression, statement, call, async, pattern)
irgen/ IR generation from AST (expression, statement, pattern, helpers)
builtin/ Native standard-library modules
array.c Array constructor + prototype methods
arraybuffer.c ArrayBuffer, SharedArrayBuffer, DataView
atomics.c Atomics namespace
collections.c Map, Set, WeakMap, WeakSet
date.c Date constructor + prototype
error.c Error subtypes (TypeError, RangeError, …)
function.c Function.prototype (bind, call, apply)
iterator.c Iterator helpers + generator protocol
json.c JSON.parse / JSON.stringify
numeric.c Number, Math, BigInt, globals (parseInt, isNaN, …)
object.c Object.* statics + Object.prototype
promise.c Promise constructor + combinators + microtask queue
reflect.c Reflect namespace + Proxy constructor + proxy traps
regexp.c RegExp engine + prototype methods
string.c String constructor + prototype methods
timer.c setTimeout / setInterval / queueMicrotask
typedarray.c TypedArray constructors + prototype methods
weak.c WeakRef, FinalizationRegistry
tools/
gen_unicode_tables.py Generates unicode_data.h from UCD 17.0 (run offline)
docs/
gaps.md ECMA-262 conformance gaps and remaining work
tests/
test.js, test_phase*.js Parser coverage (syntax)
test_run.js M0 — first executed program
test_m1*.js … test_m6*.js Runtime coverage per milestone
test_*.js Feature-specific runtime tests (85+ test files)
expected/*.out Golden output checks for behavior-sensitive tests
make test runs parser tests, runtime smoke/regression tests, normal-vs-GC-stress
output comparisons, and any golden output checks in tests/expected/. The
MINIJS_GC_STRESS=1 pass collects on every allocation, catching missed roots and
stale VM frame state.
The parser side made several JSC decisions concrete (bit-packed precedence makes the Pratt loop trivial; arenas suit write-once parse trees; dual-mode parsing turns the same code into a zero-allocation validator). Building the runtime added more:
- Why a tree-walker first — the AST already exists, so a tree-walking evaluator is the shortest path from "parses" to "runs". Correctness and DOM-style glue matter more than throughput for a first engine; a bytecode VM can come later.
- Why the parser arena can't be the runtime heap — parse trees are bulk-freed and acyclic; runtime objects, closures and environments have dynamic, cyclic lifetimes. They need a real collector. Reference counting can't reclaim the cycles closures create, so: mark-sweep.
- Why conservative GC fits a C tree-walker —
JSValues live in C locals mid-expression (a() + b()holdsa()'s result whileb()runs). A precise collector would need every temporary registered as a root; scanning the C stack conservatively makes them all roots for free. The-O0default keeps locals on the stack, which makes the scan reliable. - Why scope tracking has to know function vs block boundaries — adding block
scopes for
letquietly brokevarhoisting and closure-capture detection until scopes learned which ones are function boundaries. - Why early errors should be catchable — making
runtime_errorthrow a realError(instead ofexit) is what lets aTypeErrorbe caught by usertry/catch, and turns a stack overflow into a catchable error instead of a crash. - Why modular code scales — splitting a monolithic 12k-line interpreter into
focused modules (parser, builtin, interp) with a clean internal header
(
src/vm_internal.h) made it possible to add features like Proxy, RegExp, and modules without the codebase becoming unmaintainable.
A study project. Use it however you like.