git » fjorth.git » commit e21a23a

errors

author Alan Dipert
2025-09-16 21:38:48 UTC
committer Alan Dipert
2025-09-16 21:38:48 UTC
parent 67804b680afb309c774ce1f6eaee2ac1f5a2d4b7

errors

fjorth.html +87 -51

diff --git a/fjorth.html b/fjorth.html
index d84818d..2da4837 100644
--- a/fjorth.html
+++ b/fjorth.html
@@ -10,7 +10,7 @@
   <div id="qunit"></div>
   <div id="qunit-fixture"></div>
 
-  <!-- Interpreter (unchanged) -->
+  <!-- Interpreter (callback errors) -->
   <script>
   (function(){
   'use strict';
@@ -19,8 +19,9 @@
       constructor(opts={}){
         this.print = opts.print || (s=>{});
         this.println = opts.println || (s=>{});
-        this.sliceMs = opts.sliceMs ?? 0;          // delay between timeslices
-        this.stepBudget = opts.stepBudget ?? 2000; // instructions per slice
+        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,
@@ -36,20 +37,35 @@
         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.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) throw Error('stack underflow'); return this.D.pop()|0; }
-      peek(){ if(!this.D.length) throw Error('stack underflow'); return this.D[this.D.length-1]|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;
@@ -83,17 +99,17 @@
         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(); this.push((a*b)|0); });
-        this.defineJS('1+', function(){ const a=this.pop(); this.push((a+1)|0); });
-        this.defineJS('1-', function(){ const a=this.pop(); this.push((a-1)|0); });
+        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) throw Error('rstack underflow'); this.push(this.R.pop()|0); });
-        this.defineJS('R@', function(){ if(!this.R.length) throw Error('rstack underflow'); this.push(this.R[this.R.length-1]|0); });
+        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(); this.push(a===0?1:0); });
+        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);
@@ -105,16 +121,34 @@
       }
 
       // ===== Outer interpreter (tokenizer) =====
-      isNumber(tok){ return /^-?\d+$/.test(tok); }
+      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){
-        // Strip traditional Forth comments: backslash-to-EOL and ( ... ) blocks
-        // If embedding in a JS string, write \\ to get a single backslash, and \n for newline.
-        src = src.replace(/\\.*$/gm, '')
-                 .replace(/\([^)]*\)/g, ' ');
-        this.toks = src.split(/\s+/).filter(s=>s.length>0);
-        this.ti = 0;
+        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(){ return this.ti < this.toks.length ? this.toks[this.ti++] : null; }
+      nextToken(){ const t = (this.ti < this.toks.length ? this.toks[this.ti++] : null); this.lastTok = t; return t; }
 
       interpretNextToken(){
         const tok = this.nextToken();
@@ -126,7 +160,7 @@
           return true;
         }
         const w = this.find(tok);
-        if(!w) throw Error('undefined word: '+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); }
@@ -138,7 +172,8 @@
 
       queueWord(xt){
         if(typeof xt !== 'number' || xt < 0 || xt >= this.here || this.H[xt] === undefined){
-          throw Error('invalid execution token '+xt);
+          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); }
@@ -153,6 +188,7 @@
           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: {
@@ -160,24 +196,24 @@
             const js = this.jsHandlers.get(target);
             if(js){ js(); }
             else {
-              if(target==null || target<0 || target>=this.here || this.H[target]===undefined) throw Error('invalid call target '+target);
+              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) throw Error('invalid jump '+dest); ip = dest; } break;
-          case OP.JZ: { const dest=this.H[ip++]; const flag=this.pop(); if(flag===0){ if(dest==null || dest<0 || dest>=this.here || this.H[dest]===undefined) throw Error('invalid jump '+dest); ip=dest; } } 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: this.push(this.peek()); break;
           case OP.DROP: this.pop(); break;
-          case OP.SWAP: { const a=this.pop(), b=this.pop(); this.push(a); this.push(b); } break;
-          case OP.OVER: { const n=this.D.length; if(n<2) throw Error('stack underflow'); this.push(this.D[n-2]); } break;
-          case OP.FETCH: { const a=this.pop(); this.push(this.H[a]|0); } break;
-          case OP.STORE: { const a=this.pop(), v=this.pop(); this.H[a]=v|0; } break;
-          case OP.ADD: { const a=this.pop(), b=this.pop(); this.push((a+b)|0); } break;
-          case OP.SUB: { const a=this.pop(), b=this.pop(); this.push((b-a)|0); } break;
-          case OP.DOT: { const v=this.pop(); this.print(String(v)+' '); } break;
-          case OP.EMIT: { const v=this.pop(); this.print(String.fromCharCode(v&0xFF)); } break;
-          default: throw Error('bad opcode '+op+' at '+(ip-1));
+          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;
       }
@@ -186,16 +222,17 @@
       run(onIdle){
         if(onIdle) this.onIdleCbs.push(onIdle);
         if(this.running){ return; }
-        this.running = true; this.paused = false;
+        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){
-                this.interpretNextToken();
+                if(!this.interpretNextToken()){ if(this.lastError){ this.running = false; return; } }
               } else if(this.pending.length){
                 this.currentIp = this.pending.shift();
               } else {
@@ -207,9 +244,8 @@
               }
             }
           } catch(e){
-            this.running = false;
-            const cbs = this.onIdleCbs.splice(0);
-            cbs.forEach(cb=>{ try{ cb(e); }catch(_){} });
+            // Safety net — convert unexpected exceptions to structured error
+            this.fail({code:'exception', message:e?.message||String(e)});
             return;
           }
           setTimeout(tick, this.sliceMs);
@@ -223,25 +259,25 @@
       // ===== Compiler word impls (bound via defineJS) =====
       wCOLON(){
         const name = this.nextToken();
-        if(!name) throw Error('expected name after :');
+        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) throw Error('no current definition'); if(this.ctrl.length) throw Error('unbalanced control flow'); this.emitCell(this.OP.RET); this.current=null; this.STATE=0; }
+      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) throw Error('no word'); this.Dict[this.Dict.length-1].immediate=true; }
-      wTICK(){ const name=this.nextToken(); const w=this.find(name); if(!w) throw Error("undefined '"+name+"'"); this.push(w.xt); }
+      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) throw Error('IF outside compile'); this.emitCell(this.OP.JZ); const hole=this.here; this.emitCell(0); this.ctrl.push({k:'IF', hole}); }
-      wELSE(){ if(!this.STATE) throw Error('ELSE outside compile'); const top=this.ctrl[this.ctrl.length-1]; if(!top || top.k!=='IF') throw Error('ELSE without IF'); 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) throw Error('THEN outside compile'); const top=this.ctrl.pop(); if(!top || (top.k!=='IF' && top.k!=='ELSE')) throw Error('THEN without IF/ELSE'); this.H[top.hole]=this.here; }
-      wBEGIN(){ if(!this.STATE) throw Error('BEGIN outside compile'); this.ctrl.push({k:'BEGIN', addr:this.here}); }
-      wUNTIL(){ if(!this.STATE) throw Error('UNTIL outside compile'); const top=this.ctrl.pop(); if(!top || top.k!=='BEGIN') throw Error('UNTIL without BEGIN'); this.emitCell(this.OP.JZ); this.emitCell(top.addr); }
-      wAGAIN(){ if(!this.STATE) throw Error('AGAIN outside compile'); const top=this.ctrl.pop(); if(!top || top.k!=='BEGIN') throw Error('AGAIN without BEGIN'); this.emitCell(this.OP.JUMP); this.emitCell(top.addr); }
+      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.Forth = Forth;
@@ -301,7 +337,7 @@
       });
 
       QUnit.test('backslash to EOL comment', async assert => {
-        const { vm } = await runCode(R(String.raw`2 3 \ line\n +`));
+        const { vm } = await runCode(R(String.raw`2 3 \\ line\n +`));
         assert.strictEqual(vm.D.length, 1);
         assert.strictEqual(vm.D[0], 5);
       });
@@ -409,7 +445,7 @@
       QUnit.module('Tokenizer & Comments');
 
       QUnit.test('mixed comments (paren + backslash)', async assert => {
-        const { vm } = await runCode(R(String.raw`(foo) 1 \ bar\n 2 +`));
+        const { vm } = await runCode(R(String.raw`(foo) 1 \\ bar\n 2 +`));
         assert.strictEqual(vm.D[0], 3);
       });