git » fjorth.git » master » tree

[master] / fjorth.js

(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;
})();