src/nsengine/executor.ts
JSExecutor is the reference implementation of the nanoscript VM: a stack machine
whose stack slots hold raw JavaScript values.
const executor = new JSExecutor(1024); // the size argument is currently ignored
const result = executor.execute(program);
const result = executor.executeDebug(program); // traces each instruction + stackclass JSExecutor {
stack: any[] = []; // operand stack AND call frames AND locals
heap: any[] = []; // reserved; no opcode currently emitted touches it
ip = 0; // instruction pointer
sp = 0; // tracked but not authoritative — stack.length is
fp = 0; // frame pointer (an index into stack)
ret: any; // the return-value register
ops: Function[]; // opcode → bound handler
}The stack is a plain Array used with push/pop, so stack.length is the real
stack pointer. sp is maintained alongside it by OP_ALLOC_STACK/OP_POP_STACK
but nothing reads it.
while (program.instructions[this.ip].opcode != prg.OP_TERM) {
this.ops[program.instructions[this.ip].opcode]();
}ops is built in the constructor as an array of this.op_x.bind(this) in exact
opcode order. Each handler is responsible for advancing ip itself — which is
what lets jumps and branches be ordinary handlers.
Adding an opcode means editing three parallel structures: the constant in
program.ts, its name in OP_NAMES, and its handler position in ops. The
ops.test.ts suite exists to catch drift between the first two.
Several opcodes share one handler:
| Handler | Opcodes |
|---|---|
op_add |
OP_ADDi, OP_ADDf, OP_ADDs |
op_load_local |
OP_LOAD_LOCAL8/32/64 |
op_store_local |
OP_STORE_LOCAL8/32/64 |
op_equal |
OP_EQUALi/f/b/s/o |
op_return32 |
OP_STACKPOP_RET8/32/64 (three identical methods) |
op_increment_local_post |
OP_INCREMENT_LOCALi32, OP_INCREMENT_LOCALf64 |
if (this.stack.length > 0) return this.stack.pop();
return 0;OP_TERM therefore returns whatever is on top of the stack. A top-level return x
compiles to "push x; OP_TERM", and a trailing expression statement leaves its
value there too.
execute() and executeDebug() clear stack, heap, ret, ip, sp and
fp before they start, so one executor can run any number of programs and the
long-lived executor NSEngine holds cannot accumulate a previous script's frame.
<arg1> … <argN> pushed left-to-right
<callee load>
OP_CALL_INTERNAL target
OP_POP_STACK N
OP_PUSH_RETURN64 (only if the value is wanted)
this.stack.push(this.fp); // save caller frame pointer
this.stack.push(this.ip + 1); // return address
this.fp = this.stack.length; // new frame starts here
this.ip = operand;index contents
──────────────────────────────────────────
fp - 3 - k argument (k = 0 is the last argument)
…
fp - 3 last argument
fp - 2 saved fp
fp - 1 return address
fp + 0 first local ← OP_ALLOC_STACK reserves these
fp + 1 second local
… operand scratch space
Arguments live below the saved fp/return-address pair, which is why the compiler
numbers them from -3 downwards. Locals are at non-negative offsets.
this.ret = this.stack.pop(); // the return value → register
while (this.stack.length > this.fp) this.stack.pop(); // discard locals
this.ip = this.stack.pop(); // return address
this.fp = this.stack.pop(); // restore caller frameArguments are not popped here — OP_POP_STACK N at the call site does that.
this.ret = null;
while (this.stack.length > this.fp) this.stack.pop();
this.ip = this.stack.pop();
this.fp = this.stack.pop();Identical to the RET variants apart from not producing a value. The unwinding
loop is what keeps a function that declares locals and falls off the end from
reading a local as its return address, and clearing ret keeps a void call from
handing back the previous call's value.
let fn = this.stack.pop();
if (argCount < 1) {
this.ret = fn();
} else {
let args = [];
for (let i = 0; i < argCount; i++) args.push(this.stack.at(-(i+1)));
this.ret = fn(...args.reverse());
}Arguments are read, not popped — OP_POP_STACK at the call site removes them.
The callee was pushed above the arguments, so it is popped first and the args are
then read from the new top downwards and reversed.
op_load_member() {
let obj = this.stack.pop();
if (obj === null || obj === undefined) throw new Error(`Cannot read member ${name} of ${obj}`);
const member = obj[name];
if (member === undefined) { this.stack.push(null); return; }
this.stack.push(typeof member === 'function' ? member.bind(obj) : member);
}Binding is what makes console.log(x) and arr.push(x) work: the method is
detached from its receiver by the time OP_CALL_EXTERNAL invokes it.
An absent member reads as null, matching the language's null-not-undefined
model; a property whose value is undefined is indistinguishable from a missing
one. Reading a member of null is still an error, since that is a mistake
rather than a lookup miss.
op_wrap_collection() {
this.stack.push(new CollectionIterator(this.stack.pop()));
}
op_increment_iterator() {
const it = this.stack.at(-1); // peek — the iterator stays
if (!it.hasNext()) {
this.stack.pop(); // drop the iterator
this.ip = operand; // exit the loop
} else {
this.stack.push(it.next());
this.ip++;
}
}CollectionIterator (src/utilities/collection_iterator.ts) chooses its
strategy once, at construction: an index cursor for arrays and strings, the value
iterator for Set/Map/anything with Symbol.iterator, and Object.values for
a plain object. Anything else throws Value of type … is not iterable rather
than iterating zero times.
Generator in the same file implements ICollectionIterator for lazy sequences
but is never instantiated.
Per instruction the JS backend pays for:
- an array index into
program.instructions - a property read for
.opcode - an array index into
ops - a megamorphic function call through a bound closure
- a second
program.instructions[this.ip]lookup inside the handler to read the operand push/popon a polymorphicany[]
Steps 1–5 are pure overhead — roughly 10–40× the cost of the arithmetic they surround. This is precisely what the WebAssembly backend removes: it compiles the instruction stream into straight-line native code so that only step 6's equivalent (a typed-array load/store) remains. See wasm-engine.md.