author | Alan Dipert
<alan@dipert.org> 2025-10-04 20:48:01 UTC |
committer | Alan Dipert
<alan@dipert.org> 2025-10-04 20:48:01 UTC |
parent | 36f3c7b38813a052d5d7a36b6cf97d51c616bf4e |
fjorth.html | +5 | -521 |
fjorth.js | +270 | -0 |
test.js | +244 | -0 |
diff --git a/fjorth.html b/fjorth.html index 8632daf..3671c12 100644 --- a/fjorth.html +++ b/fjorth.html @@ -2,7 +2,7 @@ <html> <head> <meta charset="utf-8" /> - <title>Hyper‑Minimal Forth — QUnit Tests</title> + <title>Fjorth — QUnit Tests</title> <!-- Prefer local QUnit assets to avoid network prompts; if unavailable, swap to CDN manually --> <link rel="stylesheet" href="qunit.css"> </head> @@ -11,529 +11,13 @@ <div id="qunit-fixture"></div> <!-- Interpreter (callback errors) --> - <script> - (function(){ - 'use strict'; - - class Forth { - 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.Forth = Forth; - })(); - </script> + <script src="fjorth.js"></script> <!-- QUnit (local preferred) --> <script id="qunit-js" src="qunit.js"></script> <!-- Tests (ported to QUnit) --> - <script> - (function(){ - function startTests(){ - // Helper to feed exact Forth source while avoiding JS escape confusion. - // Usage: runCode(R(String.raw`2 3 \ line\n +`)) - // R() converts the two characters "\\n" into a real newline so the interpreter sees backslash-to-EOL then the next line. - const R = (raw) => raw.replace(/\\n/g, '\n'); - - function runCode(code) { - return new Promise((resolve, reject) => { - const out = []; - const vm = new Forth({ - print: s => { out.push(s); }, - println: s => { out.push(s + '\n'); }, - stepBudget: 2000, - sliceMs: 0 - }); - try { - vm.reset(); - vm.load(code); - vm.run((err) => { - if (err) reject(err); - else resolve({ vm, out: out.join('') }); - }); - } catch (e) { reject(e); } - }); - } - - QUnit.module('Hyper‑Minimal Forth'); - - QUnit.test('add', async assert => { - const { vm } = await runCode('2 3 +'); - assert.strictEqual(vm.D.length, 1, 'stack has one item'); - assert.strictEqual(vm.D[0], 5, '2 3 + => 5'); - }); - - QUnit.test('square via colon + call + *', async assert => { - const { vm } = await runCode(': SQUARE DUP * ; 7 SQUARE'); - assert.strictEqual(vm.D.length, 1, 'stack has one item'); - assert.strictEqual(vm.D[0], 49, '7 SQUARE => 49'); - }); - - QUnit.test('paren comment', async assert => { - const { vm } = await runCode('( hi ) 2 3 +'); - assert.strictEqual(vm.D.length, 1); - assert.strictEqual(vm.D[0], 5); - }); - - QUnit.test('backslash to EOL comment', async assert => { - const { vm } = await runCode(R(String.raw`2 3 \\ line\n +`)); - assert.strictEqual(vm.D.length, 1); - assert.strictEqual(vm.D[0], 5); - }); - - QUnit.test('backslash at end of line only', async assert => { - const { vm } = await runCode(R(String.raw`2 \n 3 +`)); - assert.strictEqual(vm.D.length, 1); - assert.strictEqual(vm.D[0], 5); - }); - - QUnit.test('1- then 1+', async assert => { - const { vm } = await runCode('10 1- 1+'); - assert.strictEqual(vm.D.length, 1); - assert.strictEqual(vm.D[0], 10); - }); - - QUnit.test('return stack accessors', async assert => { - const { vm } = await runCode(': SAVE >R R@ R> ; 123 SAVE'); - assert.strictEqual(vm.D.length, 2, 'two values on stack'); - assert.strictEqual(vm.D[0], 123); - assert.strictEqual(vm.D[1], 123); - }); - - QUnit.test('return stack roundtrip', async assert => { - const { vm } = await runCode(': PASS >R R> ; 7 PASS'); - assert.strictEqual(vm.D.length, 1); - assert.strictEqual(vm.D[0], 7); - }); - - QUnit.test('nested calls', async assert => { - const { vm } = await runCode(': B 1+ ; : A 2 + B ; 39 A'); - assert.strictEqual(vm.D.length, 1); - assert.strictEqual(vm.D[0], 42); - }); - - QUnit.test('IF/ELSE/THEN true branch', async assert => { - const { vm } = await runCode(': T 1 IF 42 ELSE 99 THEN ; T'); - assert.strictEqual(vm.D.length, 1); - assert.strictEqual(vm.D[0], 42); - }); - - QUnit.test('IF/ELSE/THEN false branch', async assert => { - const { vm } = await runCode(': F 0 IF 42 ELSE 99 THEN ; F'); - assert.strictEqual(vm.D.length, 1); - assert.strictEqual(vm.D[0], 99); - }); - - QUnit.test('BEGIN/UNTIL countdown with 0=', async assert => { - const { vm } = await runCode(': CD ( n -- ) BEGIN 1- DUP 0= UNTIL DROP ; 3 CD'); - assert.strictEqual(vm.D.length, 0, 'stack empty after loop'); - }); - - // Additional negative tests to lock semantics - QUnit.test('ELSE outside compile errors', async assert => { - await assert.rejects(runCode('ELSE'), /ELSE outside compile/); - }); - - QUnit.test('BEGIN outside compile errors', async assert => { - await assert.rejects(runCode('BEGIN'), /BEGIN outside compile/); - }); - - QUnit.test('unbalanced control flow at ;', async assert => { - await assert.rejects(runCode(': X IF 1 ;'), /unbalanced control flow/); - }); - - // ===== Additional Coverage ===== - QUnit.module('Stack & Arithmetic'); - - QUnit.test('DUP, DROP, SWAP, OVER', async assert => { - const { vm } = await runCode('1 2 DUP SWAP OVER DROP'); - assert.deepEqual(vm.D, [1,2,2]); - }); - - QUnit.test('add/sub/mul with negatives', async assert => { - const { vm: vm1 } = await runCode('10 3 -'); - assert.strictEqual(vm1.D[0], 7); - const { vm: vm2 } = await runCode('3 10 -'); - assert.strictEqual(vm2.D[0], -7); - const { vm: vm3 } = await runCode('6 7 *'); - assert.strictEqual(vm3.D[0], 42); - const { vm: vm4 } = await runCode('-7 3 +'); - assert.strictEqual(vm4.D[0], -4); - }); - - QUnit.module('Memory & Dictionary'); - - QUnit.test('HERE , @ roundtrip', async assert => { - const { vm } = await runCode('HERE 0 , DUP 99 SWAP ! @'); - assert.strictEqual(vm.D.length, 1); - assert.strictEqual(vm.D[0], 99); - }); - - QUnit.test("' tick returns execution token", async assert => { - const { vm } = await runCode(': SQUARE DUP * ; \' SQUARE'); - const xt = vm.D[0]; - assert.strictEqual(xt, vm.find('SQUARE').xt, 'xt on stack matches dictionary entry'); - }); - - QUnit.test('IMMEDIATE marks last word immediate', async assert => { - const { vm } = await runCode(': I 123 ; IMMEDIATE'); - const w = vm.find('I'); - assert.ok(w && w.immediate === true, 'I is immediate'); - }); - - QUnit.module('Tokenizer & Comments'); - - QUnit.test('mixed comments (paren + backslash)', async assert => { - const { vm } = await runCode(R(String.raw`(foo) 1 \\ bar\n 2 +`)); - assert.strictEqual(vm.D[0], 3); - }); - - QUnit.module('Output'); - - QUnit.test('EMIT + CR output', async assert => { - const { out } = await runCode('65 EMIT 66 EMIT CR'); - assert.strictEqual(out, 'AB\n'); - }); - - QUnit.test('. prints with a trailing space', async assert => { - const { out } = await runCode('42 .'); - assert.strictEqual(out, '42 '); - }); - - QUnit.test('.S prints stack contents', async assert => { - const { out } = await runCode('1 2 3 .S'); - assert.strictEqual(out, '<3> 1 2 3\n'); - }); - - QUnit.module('Errors'); - - QUnit.test('undefined word throws', async assert => { - await assert.rejects(runCode('NOPE'), /undefined word: NOPE/); - }); - - QUnit.test('data stack underflow', async assert => { - await assert.rejects(runCode('DUP'), /stack underflow/); - }); - - QUnit.test('return stack underflow', async assert => { - await assert.rejects(runCode('R>'), /rstack underflow/); - }); - - QUnit.test('colon with no name', async assert => { - await assert.rejects(runCode(':'), /expected name after/); - }); - - QUnit.module('API'); - - function runWithVM(setup, code){ - return new Promise((resolve, reject)=>{ - const out=[]; - const vm=new Forth({ print:s=>out.push(s), println:s=>out.push(s+'\n'), stepBudget:200, sliceMs:0 }); - try{ vm.reset(); if(setup) setup(vm); vm.load(code); vm.run(err=>{ if(err) reject(err); else resolve({vm, out:out.join('')}); }); } - catch(e){ reject(e); } - }); - } - - QUnit.test('defineJS adds external word', async assert => { - const { vm } = await runWithVM(vm=>{ - vm.defineJS('TWICE', function(){ const a=this.pop(); this.push(a*2); }); - }, '21 TWICE'); - assert.strictEqual(vm.D[0], 42); - }); - - QUnit.test('defineJS immediate flag is recorded', async assert => { - const { vm } = await runWithVM(vm=>{ - vm.defineJS('IWORD', function(){ this.push(9); }, true); - }, ': T IWORD ;'); - const w = vm.find('IWORD'); - assert.ok(w && w.immediate===true); - }); - - QUnit.test('pause/resume on long program still completes', async assert => { - // Build a long program: 0 then N times (1 +) - const N = 1000; - const code = '0' + ' 1 +'.repeat(N); - const { vm } = await new Promise((resolve, reject)=>{ - const out=[]; const vm=new Forth({ print:s=>out.push(s), println:s=>out.push(s+'\n'), stepBudget:10, sliceMs:0 }); - try{ vm.reset(); vm.load(code); vm.run(err=>{ if(err) reject(err); else resolve({vm, out:out.join('')}); }); setTimeout(()=>{ vm.pause(); setTimeout(()=> vm.resume(), 0); }, 0); } - catch(e){ reject(e); } - }); - assert.strictEqual(vm.D[0], 1000, 'result equals N after resume'); - }); - - } - - // Start after QUnit loads (local or CDN) - if (window.QUnit) { - startTests(); - } else { - const s = document.getElementById('qunit-js'); - if (s) s.addEventListener('load', () => { if (window.QUnit) startTests(); }); - window.addEventListener('load', () => { if (window.QUnit) startTests(); }); - } - })(); - </script> + <script src="test.js"> </script> <!-- Prefer local QUnit script; provide CDN fallback comment if needed --> <!-- If local qunit.js isn't available in your environment, replace the line above with: @@ -545,7 +29,7 @@ function runCodeExpectErr(code, vmOpts={}) { return new Promise((resolve) => { const seen=[]; - const vm = new Forth(Object.assign({ onError: e => seen.push(e), stepBudget: 2000, sliceMs: 0 }, vmOpts)); + const vm = new Fjorth(Object.assign({ onError: e => seen.push(e), stepBudget: 2000, sliceMs: 0 }, vmOpts)); try { vm.reset(); vm.load(code); @@ -603,7 +87,7 @@ QUnit.test('errors do not throw synchronously from run()', assert => { assert.expect(1); - const vm = new Forth({}); + const vm = new Fjorth({}); vm.reset(); vm.load('NOPE'); let threw = false; diff --git a/fjorth.js b/fjorth.js new file mode 100644 index 0000000..4291098 --- /dev/null +++ b/fjorth.js @@ -0,0 +1,270 @@ +(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; +})(); diff --git a/test.js b/test.js new file mode 100644 index 0000000..e39a620 --- /dev/null +++ b/test.js @@ -0,0 +1,244 @@ +(function(){ + function startTests(){ + // Helper to feed exact Forth source while avoiding JS escape confusion. + // Usage: runCode(R(String.raw`2 3 \ line\n +`)) + // R() converts the two characters "\\n" into a real newline so the interpreter sees backslash-to-EOL then the next line. + const R = (raw) => raw.replace(/\\n/g, '\n'); + + function runCode(code) { + return new Promise((resolve, reject) => { + const out = []; + const vm = new Fjorth({ + print: s => { out.push(s); }, + println: s => { out.push(s + '\n'); }, + stepBudget: 2000, + sliceMs: 0 + }); + try { + vm.reset(); + vm.load(code); + vm.run((err) => { + if (err) reject(err); + else resolve({ vm, out: out.join('') }); + }); + } catch (e) { reject(e); } + }); + } + + QUnit.module('Fjorth'); + + QUnit.test('add', async assert => { + const { vm } = await runCode('2 3 +'); + assert.strictEqual(vm.D.length, 1, 'stack has one item'); + assert.strictEqual(vm.D[0], 5, '2 3 + => 5'); + }); + + QUnit.test('square via colon + call + *', async assert => { + const { vm } = await runCode(': SQUARE DUP * ; 7 SQUARE'); + assert.strictEqual(vm.D.length, 1, 'stack has one item'); + assert.strictEqual(vm.D[0], 49, '7 SQUARE => 49'); + }); + + QUnit.test('paren comment', async assert => { + const { vm } = await runCode('( hi ) 2 3 +'); + assert.strictEqual(vm.D.length, 1); + assert.strictEqual(vm.D[0], 5); + }); + + QUnit.test('backslash to EOL comment', async assert => { + const { vm } = await runCode(R(String.raw`2 3 \\ line\n +`)); + assert.strictEqual(vm.D.length, 1); + assert.strictEqual(vm.D[0], 5); + }); + + QUnit.test('backslash at end of line only', async assert => { + const { vm } = await runCode(R(String.raw`2 \n 3 +`)); + assert.strictEqual(vm.D.length, 1); + assert.strictEqual(vm.D[0], 5); + }); + + QUnit.test('1- then 1+', async assert => { + const { vm } = await runCode('10 1- 1+'); + assert.strictEqual(vm.D.length, 1); + assert.strictEqual(vm.D[0], 10); + }); + + QUnit.test('return stack accessors', async assert => { + const { vm } = await runCode(': SAVE >R R@ R> ; 123 SAVE'); + assert.strictEqual(vm.D.length, 2, 'two values on stack'); + assert.strictEqual(vm.D[0], 123); + assert.strictEqual(vm.D[1], 123); + }); + + QUnit.test('return stack roundtrip', async assert => { + const { vm } = await runCode(': PASS >R R> ; 7 PASS'); + assert.strictEqual(vm.D.length, 1); + assert.strictEqual(vm.D[0], 7); + }); + + QUnit.test('nested calls', async assert => { + const { vm } = await runCode(': B 1+ ; : A 2 + B ; 39 A'); + assert.strictEqual(vm.D.length, 1); + assert.strictEqual(vm.D[0], 42); + }); + + QUnit.test('IF/ELSE/THEN true branch', async assert => { + const { vm } = await runCode(': T 1 IF 42 ELSE 99 THEN ; T'); + assert.strictEqual(vm.D.length, 1); + assert.strictEqual(vm.D[0], 42); + }); + + QUnit.test('IF/ELSE/THEN false branch', async assert => { + const { vm } = await runCode(': F 0 IF 42 ELSE 99 THEN ; F'); + assert.strictEqual(vm.D.length, 1); + assert.strictEqual(vm.D[0], 99); + }); + + QUnit.test('BEGIN/UNTIL countdown with 0=', async assert => { + const { vm } = await runCode(': CD ( n -- ) BEGIN 1- DUP 0= UNTIL DROP ; 3 CD'); + assert.strictEqual(vm.D.length, 0, 'stack empty after loop'); + }); + + // Additional negative tests to lock semantics + QUnit.test('ELSE outside compile errors', async assert => { + await assert.rejects(runCode('ELSE'), /ELSE outside compile/); + }); + + QUnit.test('BEGIN outside compile errors', async assert => { + await assert.rejects(runCode('BEGIN'), /BEGIN outside compile/); + }); + + QUnit.test('unbalanced control flow at ;', async assert => { + await assert.rejects(runCode(': X IF 1 ;'), /unbalanced control flow/); + }); + + // ===== Additional Coverage ===== + QUnit.module('Stack & Arithmetic'); + + QUnit.test('DUP, DROP, SWAP, OVER', async assert => { + const { vm } = await runCode('1 2 DUP SWAP OVER DROP'); + assert.deepEqual(vm.D, [1,2,2]); + }); + + QUnit.test('add/sub/mul with negatives', async assert => { + const { vm: vm1 } = await runCode('10 3 -'); + assert.strictEqual(vm1.D[0], 7); + const { vm: vm2 } = await runCode('3 10 -'); + assert.strictEqual(vm2.D[0], -7); + const { vm: vm3 } = await runCode('6 7 *'); + assert.strictEqual(vm3.D[0], 42); + const { vm: vm4 } = await runCode('-7 3 +'); + assert.strictEqual(vm4.D[0], -4); + }); + + QUnit.module('Memory & Dictionary'); + + QUnit.test('HERE , @ roundtrip', async assert => { + const { vm } = await runCode('HERE 0 , DUP 99 SWAP ! @'); + assert.strictEqual(vm.D.length, 1); + assert.strictEqual(vm.D[0], 99); + }); + + QUnit.test("' tick returns execution token", async assert => { + const { vm } = await runCode(': SQUARE DUP * ; \' SQUARE'); + const xt = vm.D[0]; + assert.strictEqual(xt, vm.find('SQUARE').xt, 'xt on stack matches dictionary entry'); + }); + + QUnit.test('IMMEDIATE marks last word immediate', async assert => { + const { vm } = await runCode(': I 123 ; IMMEDIATE'); + const w = vm.find('I'); + assert.ok(w && w.immediate === true, 'I is immediate'); + }); + + QUnit.module('Tokenizer & Comments'); + + QUnit.test('mixed comments (paren + backslash)', async assert => { + const { vm } = await runCode(R(String.raw`(foo) 1 \\ bar\n 2 +`)); + assert.strictEqual(vm.D[0], 3); + }); + + QUnit.module('Output'); + + QUnit.test('EMIT + CR output', async assert => { + const { out } = await runCode('65 EMIT 66 EMIT CR'); + assert.strictEqual(out, 'AB\n'); + }); + + QUnit.test('. prints with a trailing space', async assert => { + const { out } = await runCode('42 .'); + assert.strictEqual(out, '42 '); + }); + + QUnit.test('.S prints stack contents', async assert => { + const { out } = await runCode('1 2 3 .S'); + assert.strictEqual(out, '<3> 1 2 3\n'); + }); + + QUnit.module('Errors'); + + QUnit.test('undefined word throws', async assert => { + await assert.rejects(runCode('NOPE'), /undefined word: NOPE/); + }); + + QUnit.test('data stack underflow', async assert => { + await assert.rejects(runCode('DUP'), /stack underflow/); + }); + + QUnit.test('return stack underflow', async assert => { + await assert.rejects(runCode('R>'), /rstack underflow/); + }); + + QUnit.test('colon with no name', async assert => { + await assert.rejects(runCode(':'), /expected name after/); + }); + + QUnit.module('API'); + + function runWithVM(setup, code){ + return new Promise((resolve, reject)=>{ + const out=[]; + const vm=new Fjorth({ print:s=>out.push(s), println:s=>out.push(s+'\n'), stepBudget:200, sliceMs:0 }); + try{ vm.reset(); if(setup) setup(vm); vm.load(code); vm.run(err=>{ if(err) reject(err); else resolve({vm, out:out.join('')}); }); } + catch(e){ reject(e); } + }); + } + + QUnit.test('defineJS adds external word', async assert => { + const { vm } = await runWithVM(vm=>{ + vm.defineJS('TWICE', function(){ const a=this.pop(); this.push(a*2); }); + }, '21 TWICE'); + assert.strictEqual(vm.D[0], 42); + }); + + QUnit.test('defineJS immediate flag is recorded', async assert => { + const { vm } = await runWithVM(vm=>{ + vm.defineJS('IWORD', function(){ this.push(9); }, true); + }, ': T IWORD ;'); + const w = vm.find('IWORD'); + assert.ok(w && w.immediate===true); + }); + + QUnit.test('pause/resume on long program still completes', async assert => { + // Build a long program: 0 then N times (1 +) + const N = 1000; + const code = '0' + ' 1 +'.repeat(N); + const { vm } = await new Promise((resolve, reject)=>{ + const out=[]; const vm=new Fjorth({ print:s=>out.push(s), println:s=>out.push(s+'\n'), stepBudget:10, sliceMs:0 }); + try{ vm.reset(); vm.load(code); vm.run(err=>{ if(err) reject(err); else resolve({vm, out:out.join('')}); }); setTimeout(()=>{ vm.pause(); setTimeout(()=> vm.resume(), 0); }, 0); } + catch(e){ reject(e); } + }); + assert.strictEqual(vm.D[0], 1000, 'result equals N after resume'); + }); + + } + + // Start after QUnit loads (local or CDN) + if (window.QUnit) { + startTests(); + } else { + const s = document.getElementById('qunit-js'); + if (s) s.addEventListener('load', () => { if (window.QUnit) startTests(); }); + window.addEventListener('load', () => { if (window.QUnit) startTests(); }); + } +})();