(function(){
'use strict';
class Fjorth {
constructor(opts={}){
this.print = opts.print || (s=>{});
this.println = opts.println || (s=>{});
this.onError = opts.onError || null; // structured error callback
this.sliceMs = opts.sliceMs ?? 0; // delay between timeslices
this.stepBudget = opts.stepBudget ?? 2000; // instructions per slice
this.onIdleCbs = [];
this.OP = { LIT:1, CALL:2, RET:3, JUMP:4, JZ:5,
DUP:6, DROP:7, SWAP:8, OVER:9,
FETCH:10, STORE:11, ADD:12, SUB:13,
DOT:14, EMIT:15 };
this.reset();
}
// ===== VM & Core State =====
reset(){
this.D = []; this.R = []; this.H = []; this.here = 0;
this.Dict = []; this.jsHandlers = new Map();
this.STATE = 0; this.current = null;
this.toks = []; this.ti = 0; this.lastTok = null; this.src='';
this.currentIp = null; this.pending = [];
this.running = false; this.paused = false;
this.ctrl = [];
this.lastError = null;
this.installPrims();
}
addPrim(name, opcode){ const xt = this.here; this.emitCell(opcode); this.emitCell(this.OP.RET); this.Dict.push({name, immediate:false, xt}); return xt; }
emitCell(x){ this.H[this.here++] = x|0; }
push(x){ this.D.push(x|0); }
pop(){ if(!this.D.length){ this.fail({code:'stack_underflow', message:'stack underflow', phase:'exec', stackDepth:this.D.length, rstackDepth:this.R.length, token:this.lastTok}); return 0; } return this.D.pop()|0; }
peek(){ if(!this.D.length){ this.fail({code:'stack_underflow', message:'stack underflow', phase:'exec', stackDepth:this.D.length, rstackDepth:this.R.length, token:this.lastTok}); return 0; } return this.D[this.D.length-1]|0; }
find(name){ for(let i=this.Dict.length-1;i>=0;--i){ if(this.Dict[i].name===name) return this.Dict[i]; } return null; }
// Structured error dispatcher (no throws)
fail(info){
const err = new Error(info?.message || info?.code || 'error');
if(info && typeof info==='object'){
for(const k of Object.keys(info)) err[k]=info[k];
}
this.lastError = err;
if(this.onError){ try{ this.onError(err); }catch(_){} }
// stop run loop and notify waiting callbacks with the same error
this.running = false;
const cbs = this.onIdleCbs.splice(0);
cbs.forEach(cb=>{ try{ cb(err); }catch(_){} });
}
// Public extension API: fn called with interpreter as `this`
defineJS(name, fn, immediate=false){
const xt = this.here;
const self = this;
this.jsHandlers.set(xt, function(){ return fn.call(self); });
this.emitCell(this.OP.CALL); this.emitCell(xt);
this.emitCell(this.OP.RET);
this.Dict.push({name, immediate, xt});
}
installPrims(){
const OP=this.OP;
// Core opcodes as words
this.addPrim('LIT',OP.LIT); this.addPrim('CALL',OP.CALL); this.addPrim('RET',OP.RET);
this.addPrim('JUMP',OP.JUMP); this.addPrim('JZ',OP.JZ);
this.addPrim('DUP',OP.DUP); this.addPrim('DROP',OP.DROP); this.addPrim('SWAP',OP.SWAP); this.addPrim('OVER',OP.OVER);
this.addPrim('@',OP.FETCH); this.addPrim('!',OP.STORE);
this.addPrim('+',OP.ADD); this.addPrim('-',OP.SUB);
this.addPrim('.',OP.DOT); this.addPrim('EMIT',OP.EMIT);
// Compiler words
this.defineJS(':', this.wCOLON);
this.defineJS(';', this.wSEMICOLON, true);
this.defineJS(',', this.wCOMMA);
this.defineJS('HERE', this.wWHERE);
this.defineJS('IMMEDIATE', this.wIMMEDIATE);
this.defineJS("'", this.wTICK);
// QoL
this.defineJS('CR', function(){ this.print('\n'); });
this.defineJS('.S', function(){ this.print('<'+this.D.length+'> ' + this.D.join(' ') + '\n'); });
// Arithmetic helpers
this.defineJS('*', function(){ const a=this.pop(), b=this.pop(); if(this.lastError) return; this.push((a*b)|0); });
this.defineJS('1+', function(){ const a=this.pop(); if(this.lastError) return; this.push((a+1)|0); });
this.defineJS('1-', function(){ const a=this.pop(); if(this.lastError) return; this.push((a-1)|0); });
// Return stack accessors
this.defineJS('>R', function(){ this.R.push(this.pop()); });
this.defineJS('R>', function(){ if(!this.R.length){ this.fail({code:'rstack_underflow', message:'rstack underflow', phase:'exec'}); return; } this.push(this.R.pop()|0); });
this.defineJS('R@', function(){ if(!this.R.length){ this.fail({code:'rstack_underflow', message:'rstack underflow', phase:'exec'}); return; } this.push(this.R[this.R.length-1]|0); });
// Comparisons / flags (tiny)
this.defineJS('0=', function(){ const a=this.pop(); if(this.lastError) return; this.push(a===0?1:0); });
// Control structures (immediate)
this.defineJS('IF', this.wIF, true);
this.defineJS('ELSE', this.wELSE, true);
this.defineJS('THEN', this.wTHEN, true);
this.defineJS('BEGIN', this.wBEGIN, true);
this.defineJS('UNTIL', this.wUNTIL, true);
this.defineJS('AGAIN', this.wAGAIN, true);
}
// ===== Outer interpreter (tokenizer) =====
isNumber(tok){
if(!tok || tok.length===0) return false;
let i=0; if(tok[0]==='-'){ if(tok.length===1) return false; i=1; }
for(; i<tok.length; i++){ const c=tok.charCodeAt(i); if(c<48||c>57) return false; }
return true;
}
load(src){
this.src = String(src);
this.toks = [];
let s = this.src; let i=0; let inParen=false; let t='';
while(i < s.length){
const ch = s[i];
if(inParen){ if(ch===')'){ inParen=false; } i++; continue; }
if(ch==='('){ inParen=true; i++; continue; }
if(ch==='\\'){
// backslash comment to end of line
while(i < s.length && s[i] !== '\n') i++;
continue;
}
// whitespace is a separator
if(ch <= ' '){ if(t){ this.toks.push(t); t=''; } i++; continue; }
// accumulate
t += ch; i++;
}
if(t) this.toks.push(t);
this.ti = 0; this.lastTok = null;
}
nextToken(){ const t = (this.ti < this.toks.length ? this.toks[this.ti++] : null); this.lastTok = t; return t; }
interpretNextToken(){
const tok = this.nextToken();
if(tok==null) return false;
if(this.isNumber(tok)){
const n = (tok|0);
if(this.STATE){ this.emitCell(this.OP.LIT); this.emitCell(n); }
else this.push(n);
return true;
}
const w = this.find(tok);
if(!w){ this.fail({code:'undefined_word', message:'undefined word: '+tok, phase:'interpret', token:tok, state:this.STATE, stackDepth:this.D.length, rstackDepth:this.R.length}); return false; }
if(this.STATE){
if(w.immediate){ this.queueWord(w.xt); }
else { this.emitCell(this.OP.CALL); this.emitCell(w.xt); }
} else {
this.queueWord(w.xt);
}
return true;
}
queueWord(xt){
if(typeof xt !== 'number' || xt < 0 || xt >= this.here || this.H[xt] === undefined){
this.fail({code:'bad_xt', message:'invalid execution token '+xt, phase:'schedule', token:this.lastTok});
return;
}
if(this.currentIp==null){ this.currentIp = xt; }
else { this.pending.push(xt); }
}
// ===== Single VM instruction =====
stepVM(){
const OP=this.OP; let ip=this.currentIp;
let op = this.H[ip++];
// Guard: off-the-end treated like implicit RET
if(op===undefined){
if(this.R.length){ ip = this.R.pop(); op = this.H[ip++]; if(op===undefined){ this.currentIp = null; return; } }
else { this.currentIp = null; return; }
}
const errBase = { phase:'exec', ip:ip-1 };
switch(op){
case OP.LIT: this.push(this.H[ip++]); break;
case OP.CALL: {
const target = this.H[ip++];
const js = this.jsHandlers.get(target);
if(js){ js(); }
else {
if(target==null || target<0 || target>=this.here || this.H[target]===undefined){ this.fail({...errBase, code:'bad_call', message:'invalid call target '+target}); return; }
this.R.push(ip); ip = target;
}
} break;
case OP.RET: if(this.R.length){ ip = this.R.pop(); } else { this.currentIp=null; } break;
case OP.JUMP: { const dest = this.H[ip++]; if(dest==null || dest<0 || dest>=this.here || this.H[dest]===undefined){ this.fail({...errBase, code:'bad_jump', message:'invalid jump '+dest}); return; } ip = dest; } break;
case OP.JZ: { const dest=this.H[ip++]; const flag=this.pop(); if(this.lastError) return; if(flag===0){ if(dest==null || dest<0 || dest>=this.here || this.H[dest]===undefined){ this.fail({...errBase, code:'bad_jump', message:'invalid jump '+dest}); return; } ip=dest; } } break;
case OP.DUP: { const v=this.peek(); if(this.lastError) return; this.push(v); } break;
case OP.DROP: this.pop(); break;
case OP.SWAP: { const a=this.pop(), b=this.pop(); if(this.lastError) return; this.push(a); this.push(b); } break;
case OP.OVER: { const n=this.D.length; if(n<2){ this.fail({...errBase, code:'stack_underflow', message:'stack underflow'}); return; } this.push(this.D[n-2]); } break;
case OP.FETCH: { const a=this.pop(); if(this.lastError) return; this.push(this.H[a]|0); } break;
case OP.STORE: { const a=this.pop(), v=this.pop(); if(this.lastError) return; this.H[a]=v|0; } break;
case OP.ADD: { const a=this.pop(), b=this.pop(); if(this.lastError) return; this.push((a+b)|0); } break;
case OP.SUB: { const a=this.pop(), b=this.pop(); if(this.lastError) return; this.push((b-a)|0); } break;
case OP.DOT: { const v=this.pop(); if(this.lastError) return; this.print(String(v)+' '); } break;
case OP.EMIT: { const v=this.pop(); if(this.lastError) return; this.print(String.fromCharCode(v&0xFF)); } break;
default: this.fail({...errBase, code:'bad_opcode', message:'bad opcode '+op+' at '+(ip-1)}); return;
}
if(this.currentIp!=null) this.currentIp = ip;
}
// ===== Scheduler =====
run(onIdle){
if(onIdle) this.onIdleCbs.push(onIdle);
if(this.running){ return; }
this.running = true; this.paused = false; this.lastError = null;
const tick = ()=>{
if(this.paused){ this.running = false; return; }
let budget = this.stepBudget;
try{
while(budget-- > 0){
if(this.lastError){ this.running = false; return; }
if(this.currentIp!=null){
this.stepVM();
} else if(this.ti < this.toks.length){
if(!this.interpretNextToken()){ if(this.lastError){ this.running = false; return; } }
} else if(this.pending.length){
this.currentIp = this.pending.shift();
} else {
// idle
this.running = false;
const cbs = this.onIdleCbs.splice(0);
cbs.forEach(cb=>{ try{ cb(); }catch(_){} });
return;
}
}
} catch(e){
// Safety net — convert unexpected exceptions to structured error
this.fail({code:'exception', message:e?.message||String(e)});
return;
}
setTimeout(tick, this.sliceMs);
};
setTimeout(tick, this.sliceMs);
}
pause(){ this.paused = true; }
resume(){ if(!this.running){ this.run(); } else { this.paused = false; } }
// ===== Compiler word impls (bound via defineJS) =====
wCOLON(){
const name = this.nextToken();
if(!name){ this.fail({code:'colon_no_name', message:'expected name after :', phase:'compile'}); return; }
const xt = this.here;
this.current = { name, xt };
this.Dict.push({ name, immediate:false, xt });
this.STATE = 1;
}
wSEMICOLON(){ if(!this.current){ this.fail({code:'no_current_def', message:'no current definition', phase:'compile'}); return; } if(this.ctrl.length){ this.fail({code:'unbalanced_control', message:'unbalanced control flow', phase:'compile'}); return; } this.emitCell(this.OP.RET); this.current=null; this.STATE=0; }
wCOMMA(){ this.emitCell(this.pop()); }
wWHERE(){ this.push(this.here); }
wIMMEDIATE(){ if(!this.Dict.length){ this.fail({code:'no_word', message:'no word', phase:'compile'}); return; } this.Dict[this.Dict.length-1].immediate=true; }
wTICK(){ const name=this.nextToken(); const w=this.find(name); if(!w){ this.fail({code:'undefined_word', message:"undefined '"+name+"'", phase:'interpret', token:name}); return; } this.push(w.xt); }
// ===== Control structure implementations =====
wIF(){ if(!this.STATE){ this.fail({code:'if_outside_compile', message:'IF outside compile', phase:'compile'}); return; } this.emitCell(this.OP.JZ); const hole=this.here; this.emitCell(0); this.ctrl.push({k:'IF', hole}); }
wELSE(){ if(!this.STATE){ this.fail({code:'else_outside_compile', message:'ELSE outside compile', phase:'compile'}); return; } const top=this.ctrl[this.ctrl.length-1]; if(!top || top.k!=='IF'){ this.fail({code:'else_without_if', message:'ELSE without IF', phase:'compile'}); return; } this.emitCell(this.OP.JUMP); const hole2=this.here; this.emitCell(0); this.H[top.hole]=this.here; this.ctrl[this.ctrl.length-1]={k:'ELSE', hole:hole2}; }
wTHEN(){ if(!this.STATE){ this.fail({code:'then_outside_compile', message:'THEN outside compile', phase:'compile'}); return; } const top=this.ctrl.pop(); if(!top || (top.k!=='IF' && top.k!=='ELSE')){ this.fail({code:'then_without_if', message:'THEN without IF/ELSE', phase:'compile'}); return; } this.H[top.hole]=this.here; }
wBEGIN(){ if(!this.STATE){ this.fail({code:'begin_outside_compile', message:'BEGIN outside compile', phase:'compile'}); return; } this.ctrl.push({k:'BEGIN', addr:this.here}); }
wUNTIL(){ if(!this.STATE){ this.fail({code:'until_outside_compile', message:'UNTIL outside compile', phase:'compile'}); return; } const top=this.ctrl.pop(); if(!top || top.k!=='BEGIN'){ this.fail({code:'until_without_begin', message:'UNTIL without BEGIN', phase:'compile'}); return; } this.emitCell(this.OP.JZ); this.emitCell(top.addr); }
wAGAIN(){ if(!this.STATE){ this.fail({code:'again_outside_compile', message:'AGAIN outside compile', phase:'compile'}); return; } const top=this.ctrl.pop(); if(!top || top.k!=='BEGIN'){ this.fail({code:'again_without_begin', message:'AGAIN without BEGIN', phase:'compile'}); return; } this.emitCell(this.OP.JUMP); this.emitCell(top.addr); }
}
window.Fjorth = Fjorth;
})();