author | Alan Dipert
<alan@dipert.org> 2025-09-16 21:38:48 UTC |
committer | Alan Dipert
<alan@dipert.org> 2025-09-16 21:38:48 UTC |
parent | 67804b680afb309c774ce1f6eaee2ac1f5a2d4b7 |
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); });