Skip to content

toprakdeviren/MiniJS

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

15 Commits
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

MiniJS

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.

How it was built

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

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/const block scoping and var/function hoisting. Closures capture their defining environment by reference. A Completion type carries break/continue/return up 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=1 collects 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. new and classes build the prototype chain; methods resolve through it.
  • Proxy/Reflect — full Proxy constructor with revocable proxies and the complete Reflect namespace. Proxy traps for get, set, has, deleteProperty, ownKeys, getOwnPropertyDescriptor, defineProperty, getPrototypeOf, setPrototypeOf, isExtensible, preventExtensions, apply, and construct.
  • Exceptionsthrow/try/catch/finally propagate via setjmp/longjmp; engine errors (TypeError-ish) are thrown as catchable Error objects.
  • 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-Unicode toUpperCase/toLowerCase with 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, Iterator helpers, 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 in docs/gaps.md.
  • Modulesimport/export with named exports, default exports, namespace imports, and re-exports. Module resolution from the file system.
  • Event loop — a microtask queue for Promise resolution and queueMicrotask, plus setTimeout/setInterval support.

The parser

The parser (src/parser/, src/lexer.c) reproduces twelve design patterns from JavaScriptCore:

  1. 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.
  2. Two-stack Pratt parsingparse_binary() uses an operand stack and an operator stack instead of recursion, so a + b + … + z is O(1) stack space.
  3. Right-associative operators2 ** 3 ** 2 === 2 ** (3 ** 2).
  4. Arena allocator — AST nodes come from 8 KB pools and are freed in bulk when parsing ends (no per-node free), matching JSC's ParserArena.
  5. Tagged-union AST — a NodeType tag + a union of per-type fields is the C equivalent of JSC's Nodes.h class hierarchy (~70 node types).
  6. CodeFeatures flags — which features a script uses (eval, typeof, closure, this, …) are detected at parse time.
  7. SyntaxChecker (dual TreeBuilder) — the same parse code runs in two modes: ASTBuilder builds nodes, SyntaxChecker validates with zero allocation.
  8. VariableEnvironment — per-variable bit-packed flags; closure capture is detected when an inner function references an outer binding across a function boundary.
  9. Lazy function parsing--lazy validates function bodies without building their AST, recording offsets for later re-parse.
  10. SourceProvider — wraps the source with URL/length for file:line:col: diagnostics.
  11. Error recovery--recover resynchronizes at statement boundaries and reports up to MAX_PARSE_ERRORS at once.
  12. Source Map v3 emitter--sourcemap emits 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 runs

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/const with block scope and hoisting; assignment (= and all compound forms including &&=, ||=, ??=)
  • if/else, while, do-while, C-style for, 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 (Int8Array through Float64Array, Uint8Array hex/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 real ID_Start/ ID_Continue tables; 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

Building

make

A C11 compiler (gcc/clang) and nothing else. Zero warnings under -Wall -Wextra -std=c11.

Usage

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)
  ...

Project structure

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.

What I learned

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-walkerJSValues live in C locals mid-expression (a() + b() holds a()'s result while b() runs). A precise collector would need every temporary registered as a root; scanning the C stack conservatively makes them all roots for free. The -O0 default keeps locals on the stack, which makes the scan reliable.
  • Why scope tracking has to know function vs block boundaries — adding block scopes for let quietly broke var hoisting and closure-capture detection until scopes learned which ones are function boundaries.
  • Why early errors should be catchable — making runtime_error throw a real Error (instead of exit) is what lets a TypeError be caught by user try/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.

License

A study project. Use it however you like.

About

A small JavaScript engine in C

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages