Skip to content

ForthInterpreter.kt

src/jvmMain/kotlin/afield/lang/forth/ForthInterpreter.kt · 1636 lines

package afield.lang.forth

import afield.runtime.kotlin.classesWithPrefix
import afield.runtime.kotlin.findClassesNamed
import afield.geometry.path.FLine
import afield.ui.visualization.h
import afield.math.linalg.Vec2
import afield.math.linalg.Vec3
import afield.math.linalg.Vec4
import kotlin.math.*
import afield.ui.visualization.Table
import afield.ui.visualization.CellRole

ForthField — a compact, extensible Forth used by Field4 for live coding.

It is host-driven: - it executes ForthField source and keeps its own data stack/dictionary - it ships with creative-coding oriented builtins (vectors + FLine) - it can be extended at runtime from Kotlin via defineWord - it can interoperate with host objects using @? / @?! reflection calls

The host loop

Typical host loop: 1. Construct an interpreter once (val forth = ForthInterpreter()). 2. Register host bridge words with defineWord as needed. 3. Push host objects (buffers, context values, services) onto stack. 4. Run eval(...). 5. Pop resulting values (for example an FLine) and hand them to rendering/output code.

Example: basic execution and stack access:

val forth = ForthInterpreter()
val diagnostics = forth.eval("2 3 +")
check(diagnostics.isEmpty())
println(forth.popNum()) // 5.0

Example: defining words in ForthField:

: sqr ( n -- n2 ) dup * ;
: hypot2 ( x y -- d2 ) sqr swap sqr + ;
3 4 hypot2 .

Example: create an FLine shape in ForthField:

: stroke-ribbon ( -- f )
  fline
  0   0   moveto
  120 20  lineto
  160 90  210 90  260 20 cubicto
  320 0   lineto
  true  fline-stroked
  false fline-filled
  1.0 0.55 0.2 1.0 fline-color
  2.5 fline-thickness ;

stroke-ribbon

Host-side consumption of the resulting FLine:

val forth = ForthInterpreter()
val source = """
  : stroke-ribbon ( -- f )
    fline
    0 0 moveto
    120 20 lineto
    160 90 210 90 260 20 cubicto
    true fline-stroked
    1 0.55 0.2 1 fline-color
    2.5 fline-thickness ;
  stroke-ribbon
""".trimIndent()

val diagnostics = forth.eval(source)
if (diagnostics.isEmpty()) {
  val line = forth.pop() as FLine
  // Forward `line` into your output channel / renderer.
}

Example: use vector helpers with FLine convenience words:

: zig ( -- f )
  fline
  0   0   vec2 fline-moveto-v2
  40  20  vec2 fline-lineto-v2
  80 -10  vec2 fline-lineto-v2
  120 25  vec2 fline-lineto-v2
  1 0 0 1 fline-node-color ;
zig

The reflection bridge

Example: resolve classes without full FQNs. class-for consults Field's class cache (importHelp.kt) so short names usually work.

s"FLine" class-for @? getName println
s"Vec3"  class-for @? getName println

s"Fli" class-matches print   \ returns a list of FQNs
s"Fli" class-help            \ prints one-per-line suggestions

Example: reflection bridge (@? / @?!) with FLine, built entirely by class lookup. This demonstrates construction + method calls without using the fline helper.

variable f
s"FLine" class-for @? newInstance f !

\ Explicit arity call: f.moveTo(Number, Number, Number)
f @ 0   0   0   3 @?! moveTo drop

\ Explicit arity call: f.lineTo(Number, Number, Number)
f @ 120 30  0   3 @?! lineTo drop
f @ 220 10  0   3 @?! lineTo drop
f @ 260 80 0  320 80 0  380 20 0  9 @?! cubicTo drop

\ Properties are just a map on FLine
f @ @? properties  s"thickness" 2.5 2 @?! put
f @ @? properties  s"stroked" true 2 @?! put

\ Heuristic call for zero-arg methods works well with @?
f @ @? close drop

\ Leave the final FLine on the stack
f @

Example: @? reads data too (field/getter/method fallback):

f @ @? nodes   \ push internal node list
@? size .      \ call size() on that list and print node count

Example: constructor with explicit args via @?! new:

variable sb
s"StringBuilder" class-for s"hello" 1 @?! new sb !
sb @ @? toString println

Example: extending ForthField with Kotlin bridge words. Here we expose a helper that appends a vec4 worth of floats into any DynamicallyWriteable buffer (for example VulkanBuffer or FrameBufferGroup).

import afield.graphics.vulkan.buffers.DynamicallyWriteable
import afield.math.linalg.Vec4

val forth = ForthInterpreter()
forth.defineWord("append-vec4", "( buf x y z w -- buf )") {
  val w = popNum().toFloat()
  val z = popNum().toFloat()
  val y = popNum().toFloat()
  val x = popNum().toFloat()
  val buf = pop() as DynamicallyWriteable

  buf.append {
    put(Vec4(x, y, z, w))
  }
  push(buf)
}

Example: call that bridge word from ForthField after pushing a host buffer:

forth.push(myDynamicBuffer) // host object: VulkanBuffer / FrameBufferGroup / similar
forth.eval("""
  0.0 1.0 0.0 1.0 append-vec4
  1.0 0.0 0.0 1.0 append-vec4
  0.0 0.0 1.0 1.0 append-vec4
""".trimIndent())

Example: long-form output areas from ForthField:

s"<b>Inline-ish output area content</b>" html
s"<pre>Detailed log text...</pre>" s"debug" html-channel

Example: launchable animation with cooperative yielding via frame. Run this in Launch mode (or call begin("default") on the box), so spinner executes every RunLoop tick. frame yields immediately, letting the rest of Field proceed. Store evolving state in variables, because each strand tick re-enters the word from the top.

variable tick
variable line
0 tick !

: spinner ( -- )
  s"FLine" class-for @? newInstance line !

  \ center point
  line @ 0 0 0 3 @?! moveTo drop

  \ animated endpoint (simple circular motion)
  line @
  tick @ 0.05 * dup cos 120 *          ( x )
             swap sin 120 *            ( x y )
  0 3 @?! lineTo drop

  \ style via reflected property map
  line @ @? properties s"thickness" 2.0 2 @?! put
  line @ @? properties s"stroked" true 2 @?! put
  line @ s"geom" display-channel

  \ show a debug line in output area
  s"tick=" tick @ str str+ s"debug" display-channel

  1 tick +!
  frame
;

spinner

Example: same pattern with an explicit ForthField loop. frame exits the current tick immediately; on next tick the word starts again.

variable phase
0 phase !

: pulse-loop ( -- )
  begin
    s"phase=" phase @ str str+ println
    1 phase +!
    frame
  again ;

pulse-loop

Example: full overwrite of float data each tick, since write replaces the payload rather than adding to it:

import afield.graphics.vulkan.buffers.DynamicallyWriteable

forth.defineWord("write-triangle", "( buf -- buf )") {
  val buf = pop() as DynamicallyWriteable
  buf.write {
    // XYZW packed per vertex (3 vertices)
    put(-0.5f); put(-0.5f); put(0f); put(1f)
    put( 0.5f); put(-0.5f); put(0f); put(1f)
    put( 0.0f); put( 0.5f); put(0f); put(1f)
  }
  push(buf)
}

Notes for dynamic-buffer bridging: - defineWord closures execute on the interpreter thread/context used by your host. - Prefer write { ... } when replacing the payload; append { ... } when accumulating. - DynamicBuffer.put(Vec4) follows std430-friendly alignment rules used by Field's buffer code. - If you need explicit control, call putScalar(...) repeatedly instead of vector helpers.

Builtins

Builtins included by default: - stack ops (dup, swap, over, pick, roll, ...) - math/logic/control flow (if/else/then, begin/until, do/loop, ...) - variables/constants (variable, !, @, constant) - strings/output (."...", s"...", ., println, ...) - dictionary tools (words, see, forget, recurse) - return-stack transfer (>r, r>, r@) - vectors (vec2, vec3, vec4 + arithmetic helpers) - FLine primitives (fline, moveto, lineto, cubicto, styling words) - small visualization words (html, watch, stack-display, bar, plot-stack) - frame/yield primitive (frame) for cooperative launch-mode animations - host reflection hooks (@?, @?!, class-for, class-matches, class-help)

Dictionary, recursion and the return stack

Dictionary / debugging examples:

: sqr dup * ;
words              \ print all currently known words
see sqr            \ print ": sqr dup * ;"
forget sqr         \ remove the user-defined word

Recursion example:

: countdown ( n -- )
  dup 0> if
    dup println
    1- recurse
  else
    drop
  then ;
5 countdown

Infinite loop example. Use again mainly in launched words where frame yields back to Field each tick:

variable tick
: ticker begin tick @ println 1 tick +! frame again ;
ticker

Return-stack examples. >r moves a temporary value away from the data stack, r@ copies it back, and r> restores/removes it:

: add-twice ( a b -- a+b+b )
  >r        \ return stack: b ; data stack: a
  r@ +      \ data stack: a+b, b is still saved
  r> + ;    \ data stack: a+b+b

Visualization words

Visualization examples:

s"<b>Hello from Forth</b>" html
0.72 1.0 s"progress" bar
42 s"answer" watch
stack-display
1 4 9 16 plot-stack

class ForthInterpreter

The interpreter: the data stack, the dictionary, the tokenizer and eval.

One instance is one ForthField environment. eval returns a list of ForthDiagnostic rather than throwing, so a live-coding host can report a bad word and keep the environment; the stack and the dictionary survive an eval that failed.

class ForthInterpreter {

data class Token(val text: String, val line: Int)

data class CallFrame(val word: String, val line: Int)

var currentLine: Int

    var currentLine: Int = -1
        private set

var currentToken: String

    var currentToken: String = ""
        private set

val stack = ArrayDeque<Any>()

Data stack

val returnStack = ArrayDeque<Any>()

Return stack (for do/loop)

val callStack = ArrayDeque<CallFrame>()

Word call stack, used for diagnostics and recurse.

    private var lastErrorCallStack: List<CallFrame> = emptyList()

val dictionary = LinkedHashMap<String, ForthWord>()

Dictionary: word name (lowercase) -> compiled word

What each name meant before a :, variable or constant replaced it, so forget can put it back.

    private val shadowed = HashMap<String, ArrayDeque<ForthWord>>()

Install word under name, remembering what it replaces. Replacing a builtin is allowed (it is how a program tunes a word) but never silent: it is announced on onOutput.

    private fun defineUserWord(name: String, word: ForthWord, line: Int) {
        val previous = dictionary[name]
        if (previous != null) {
            shadowed.getOrPut(name) { ArrayDeque() }.addLast(previous)
            if (previous is ForthWord.Builtin || previous is ForthWord.Lambda) {
                onOutput("warning: line $line redefines the builtin '$name'; `forget $name` restores it\n")
            }
        }
        dictionary[name] = word
    }

val variables = HashMap<String, Any?>()

Variables storage

var onOutput: (String) -> Unit = { print(it) }

Output callback

var onDisplay: (String, Any?) -> Unit = { _, _ -> }

var onFrame: () -> Unit = { throw ForthError("'frame' is only available while a ForthField strand is running") }

    var onFrame: () -> Unit = { throw ForthError("'frame' is only available while a ForthField strand is running") }

val strands = HashMap<String, String>()

Per-box run-loop strands: strandName -> word name to execute each frame

var onTopLevelStep: ((firstLine: Int, lastLine: Int, defined: String?, endsLine: Boolean) -> Unit)? = null

Called after each top-level step of eval — a word, a literal, a whole : … ; definition or if … then — with the first and last source lines the step's tokens sat on (1-based, in the evaluated text), the name it defined, if it was a definition, and whether it is the last step to end on its line (the next step starts on a later line, or there is none) — the moment a line's note is about. Steps inside a word's body do not count. The box records the stack here for its line notes; null costs nothing.

    var onTopLevelStep: ((firstLine: Int, lastLine: Int, defined: String?, endsLine: Boolean) -> Unit)? = null

init

    init {
        installBuiltins()
    }

Stack helpers

fun push(v: Any) = stack.addLast(v)

fun pop(): Any = stack.removeLastOrNull() ?: throw ForthError("Stack underflow", currentLine)

fun peek(): Any = stack.lastOrNull() ?: throw ForthError("Stack underflow", currentLine)

fun popNum(): Double = anyToDouble(pop())

fun popInt(): Int = popNum().toInt()

fun anyToDouble(v: Any): Double

    fun anyToDouble(v: Any): Double = when (v) {
        is Double -> v
        is Float -> v.toDouble()
        is Int -> v.toDouble()
        is Long -> v.toDouble()
        is Number -> v.toDouble()
        is Boolean -> if (v) -1.0 else 0.0
        else -> throw ForthError("Expected number, got ${v::class.simpleName}: $v", currentLine)
    }

fun anyToBool(v: Any): Boolean

    fun anyToBool(v: Any): Boolean = when (v) {
        is Boolean -> v
        is Int -> v != 0
        is Double -> v != 0.0
        is Float -> v != 0.0f
        else -> true
    }

    private fun stackPreview(values: Iterable<Any>, limit: Int = 8): String {
        val list = values.toList()
        val shown = list.takeLast(limit).joinToString(" ") { shortValue(it) }
        val prefix = if (list.size > limit) "... " else ""
        return "<${list.size}> $prefix$shown"
    }

    private fun returnStackPreview(): String {
        return stackPreview(returnStack.map {
            when (it) {
                is LoopState -> "loop(i=${it.index},limit=${it.limit})"
                else -> it
            }
        })
    }

fun stackSnapshot(): List<String> = stack.map { shortValue(it) }

The data stack, bottom first, each value as a diagnostic shows it.

fun stackTop(n: Int): List<String>

The top n values of the data stack, bottom first, as stackSnapshot shows them: cheap however deep the stack is.

    fun stackTop(n: Int): List<String> {
        val from = maxOf(0, stack.size - n)
        return (from until stack.size).map { shortValue(stack[it]) }
    }

    private fun shortValue(v: Any?): String = when (v) {
        null -> "null"
        is String -> "\"" + v.take(48) + (if (v.length > 48) "..." else "") + "\""
        is Class<*> -> "Class(${v.simpleName})"
        else -> {
            val raw = v.toString()
            if (raw.length > 64) raw.take(61) + "..." else raw
        }
    }

    private fun forthEq(a: Any, b: Any): Boolean {
        if (a is Number && b is Number) return a.toDouble() == b.toDouble()
        return a == b
    }

Interpreter entry point

fun eval(source: String): List<ForthDiagnostic>

Evaluate a string of ForthField code. Returns a list of diagnostics (empty = success).

    fun eval(source: String): List<ForthDiagnostic> {
        val diagnostics = mutableListOf<ForthDiagnostic>()
        currentLine = -1
        currentToken = ""
        lastErrorCallStack = emptyList()
        try {
            val tokens = tokenize(source)
            execTokens(tokens, 0, tokens.size, topLevel = true)
        } catch (_: ForthFrameYield) {
            // Cooperative yield from `frame` inside launch/strand execution.
        } catch (e: ForthError) {
            diagnostics += diagnosticFor(e)
        } catch (e: Throwable) {
            val line = if (currentLine >= 0) currentLine else -1
            val summary = e.message ?: e.javaClass.simpleName
            diagnostics += ForthDiagnostic(
                formatDiagnosticMessage("${e.javaClass.simpleName}: $summary"),
                line
            )
        }
        return diagnostics
    }

fun diagnosticFor(e: ForthError): ForthDiagnostic

    fun diagnosticFor(e: ForthError): ForthDiagnostic {
        val line = when {
            e.line >= 0 -> e.line
            currentLine >= 0 -> currentLine
            else -> -1
        }
        return ForthDiagnostic(formatDiagnosticMessage(e.message ?: "Unknown error"), line)
    }

    private fun formatDiagnosticMessage(message: String): String {
        val pieces = mutableListOf(message)
        if (currentToken.isNotBlank()) pieces += "while reading '$currentToken'"
        if (stack.isNotEmpty()) pieces += "data stack: ${stackPreview(stack)}"
        if (returnStack.isNotEmpty()) pieces += "return stack: ${returnStackPreview()}"
        val diagnosticStack = if (callStack.isNotEmpty()) callStack.toList() else lastErrorCallStack
        if (diagnosticStack.isNotEmpty()) {
            pieces += "call stack: " + diagnosticStack
                .asReversed()
                .joinToString(" -> ") { frame ->
                    if (frame.line >= 0) "${frame.word}@${frame.line}" else frame.word
                }
        }
        return pieces.joinToString("\n")
    }

Tokenizer

fun tokenize(source: String): List<Token>

body — 50 lines, from line 536
    fun tokenize(source: String): List<Token> {
        val tokens = mutableListOf<Token>()
        var lineNum = 1
        var i = 0
        while (i < source.length) {
            val c = source[i]
            when {
                c == '\n' -> { lineNum++; i++ }
                c.isWhitespace() -> i++
                c == '\\' -> { while (i < source.length && source[i] != '\n') i++ }
                c == '(' -> {
                    i++
                    while (i < source.length && source[i] != ')') {
                        if (source[i] == '\n') lineNum++
                        i++
                    }
                    if (i < source.length) i++
                }
                source.startsWith(".\"", i) -> {
                    i += 2
                    val start = i
                    while (i < source.length && source[i] != '"') {
                        if (source[i] == '\n') lineNum++
                        i++
                    }
                    val str = source.substring(start, i)
                    if (i < source.length) i++
                    tokens += Token(".\"$str\"", lineNum)
                }
                source.startsWith("s\"", i) -> {
                    i += 2
                    if (i < source.length && source[i] == ' ') i++
                    val start = i
                    while (i < source.length && source[i] != '"') {
                        if (source[i] == '\n') lineNum++
                        i++
                    }
                    val str = source.substring(start, i)
                    if (i < source.length) i++
                    tokens += Token("s\"$str\"", lineNum)
                }
                else -> {
                    val start = i
                    while (i < source.length && !source[i].isWhitespace()) i++
                    tokens += Token(source.substring(start, i), lineNum)
                }
            }
        }
        return tokens
    }

Token execution

fun execTokens(tokens: List<Token>, start: Int, end: Int, topLevel: Boolean = false): Int

Execute tokens[start..end) — the core interpreter loop. Returns the index one past the last token consumed (= end, normally).

body — 152 lines, from line 595
    fun execTokens(tokens: List<Token>, start: Int, end: Int, topLevel: Boolean = false): Int {
        var i = start
        while (i < end) {
            val tok = tokens[i]
            val word = tok.text
            val lw = word.lowercase()
            val line = tok.line
            currentLine = line
            currentToken = word

            val stepStart = i
            i = when {
                // -------- word definition --------
                lw == ":" -> {
                    if (i + 1 >= tokens.size) throw ForthError("Expected name after ':'", line)
                    val name = tokens[i + 1].text.lowercase()
                    var j = i + 2
                    val body = mutableListOf<Token>()
                    var depth = 0
                    while (j < tokens.size) {
                        val t = tokens[j]
                        when (t.text.lowercase()) {
                            ";" -> if (depth == 0) break else { depth--; body += t }
                            ":" -> { depth++; body += t }
                            else -> body += t
                        }
                        j++
                    }
                    if (j >= tokens.size) throw ForthError("Missing ';' for '$name'", line)
                    defineUserWord(name, ForthWord.Compiled(name, body.toList(), line), line)
                    j + 1
                }

                lw == "variable" -> {
                    if (i + 1 >= tokens.size) throw ForthError("Expected variable name", line)
                    val name = tokens[i + 1].text.lowercase()
                    if (!variables.containsKey(name)) variables[name] = 0.0
                    defineUserWord(name, ForthWord.Variable(name), line)
                    i + 2
                }

                lw == "constant" -> {
                    if (i + 1 >= tokens.size) throw ForthError("Expected constant name", line)
                    val name = tokens[i + 1].text.lowercase()
                    defineUserWord(name, ForthWord.Constant(name, pop()), line)
                    i + 2
                }

                // -------- if/else/then --------
                lw == "if" -> {
                    val cond = anyToBool(pop())
                    execIf(tokens, i + 1, end, cond)
                }

                // -------- begin loops --------
                lw == "begin" -> execBegin(tokens, i + 1, end)

                // -------- do/loop --------
                lw == "do" -> {
                    val idx = popInt(); val limit = popInt()
                    execDo(tokens, i + 1, end, idx, limit)
                }

                // -------- leave --------
                lw == "leave" -> throw LeaveException()

                // -------- reflection bridge --------
                // @? : ( arg1...argN obj -- result? ) next-token is member name
                lw == "@?" -> {
                    if (i + 1 >= end) throw ForthError("@? expects a member name after it", tok.line)
                    val memberName = tokens[i + 1].text
                    val obj = pop()
                    reflectMember(obj, memberName, tok.line)
                    i + 2
                }

                // @?! : ( obj arg1...argN n -- result? ) explicit arg count, obj pushed first
                lw == "@?!" -> {
                    if (i + 1 >= end) throw ForthError("@?! expects a member name after it", tok.line)
                    val memberName = tokens[i + 1].text
                    val n = popInt()
                    val args = (0 until n).map { pop() }.reversed()
                    val obj = pop()
                    reflectCallN(obj, memberName, args, tok.line)
                    i + 2
                }

                // -------- dictionary tools --------
                lw == "see" -> {
                    if (i + 1 >= end) throw ForthError("see expects a word name after it", tok.line)
                    onOutput(describeWord(tokens[i + 1].text.lowercase()) + "\n")
                    i + 2
                }

                lw == "forget" -> {
                    if (i + 1 >= end) throw ForthError("forget expects a word name after it", tok.line)
                    forgetWord(tokens[i + 1].text.lowercase(), tok.line)
                    i + 2
                }

                lw == "recurse" -> {
                    val frame = callStack.lastOrNull()
                        ?: throw ForthError("'recurse' outside a word definition", tok.line)
                    val fw = dictionary[frame.word]
                        ?: throw ForthError("'recurse' cannot find current word '${frame.word}'", tok.line)
                    execWord(fw)
                    i + 1
                }

                // -------- loop index words (only valid inside do/loop) --------
                lw == "i" -> {
                    val ls = returnStack.lastOrNull() as? LoopState
                        ?: throw ForthError("'i' outside do/loop", line)
                    push(ls.index.toDouble()); i + 1
                }

                lw == "j" -> {
                    val ls = returnStack.reversed().drop(1).filterIsInstance<LoopState>().firstOrNull()
                        ?: throw ForthError("'j' outside nested do/loop", line)
                    push(ls.index.toDouble()); i + 1
                }

                // -------- string literals --------
                word.startsWith(".\"") -> {
                    onOutput(word.removePrefix(".\"").removeSuffix("\""))
                    i + 1
                }
                word.startsWith("s\"") -> {
                    push(word.removePrefix("s\"").removeSuffix("\""))
                    i + 1
                }

                // -------- number literals --------
                isNum(word) -> { push(parseNum(word)); i + 1 }

                // -------- dictionary lookup --------
                else -> {
                    val fw = dictionary[lw] ?: throw ForthError("Unknown word: '$word'", line)
                    execWord(fw)
                    i + 1
                }
            }
            if (topLevel) onTopLevelStep?.let { step ->
                val defined = if (lw == ":" && stepStart + 1 < tokens.size) tokens[stepStart + 1].text.lowercase()
                    else if ((lw == "variable" || lw == "constant") && stepStart + 1 < tokens.size) tokens[stepStart + 1].text.lowercase()
                    else null
                val lastLine = tokens[minOf(i, tokens.size) - 1].line
                step(line, lastLine, defined, i >= end || tokens[i].line > lastLine)
            }
        }
        return i
    }

fun execWord(fw: ForthWord)

    fun execWord(fw: ForthWord) {
        when (fw) {
            is ForthWord.Builtin -> fw.impl(this)
            is ForthWord.Lambda -> fw.impl(this)
            is ForthWord.Compiled -> {
                callStack.addLast(CallFrame(fw.name, fw.sourceLine))
                try {
                    execTokens(fw.body, 0, fw.body.size)
                } catch (t: Throwable) {
                    if (t !is ForthFrameYield && callStack.size >= lastErrorCallStack.size) {
                        lastErrorCallStack = callStack.toList()
                    }
                    throw t
                } finally {
                    callStack.removeLastOrNull()
                }
            }
            is ForthWord.Variable -> push(fw.name)
            is ForthWord.Constant -> push(fw.value)
        }
    }

Control flow helpers

source — 119 lines, from line 774
    private fun execIf(tokens: List<Token>, start: Int, end: Int, cond: Boolean): Int {
        var depth = 0
        var elseAt = -1
        var thenAt = -1
        var j = start
        while (j < end) {
            when (tokens[j].text.lowercase()) {
                "if" -> depth++
                "else" -> if (depth == 0) elseAt = j
                "then" -> if (depth == 0) { thenAt = j; break } else depth--
            }
            j++
        }
        if (thenAt == -1) throw ForthError("Missing 'then' for 'if'")
        return if (cond) {
            execTokens(tokens, start, if (elseAt != -1) elseAt else thenAt)
            thenAt + 1
        } else {
            if (elseAt != -1) execTokens(tokens, elseAt + 1, thenAt)
            thenAt + 1
        }
    }

    private fun execBegin(tokens: List<Token>, start: Int, end: Int): Int {
        var depth = 0
        var untilAt = -1
        var whileAt = -1
        var repeatAt = -1
        var againAt = -1
        var j = start
        while (j < end) {
            when (tokens[j].text.lowercase()) {
                "begin" -> depth++
                "until" -> if (depth == 0) { untilAt = j; break } else depth--
                "while" -> if (depth == 0) whileAt = j
                "repeat" -> if (depth == 0) { repeatAt = j; break } else depth--
                "again" -> if (depth == 0) { againAt = j; break } else depth--
            }
            j++
        }
        return when {
            untilAt != -1 -> {
                while (true) {
                    try {
                        execTokens(tokens, start, untilAt)
                    } catch (_: LeaveException) {
                        break
                    }
                    if (anyToBool(pop())) break
                }
                untilAt + 1
            }
            whileAt != -1 && repeatAt != -1 -> {
                while (true) {
                    try {
                        execTokens(tokens, start, whileAt)
                    } catch (_: LeaveException) {
                        break
                    }
                    if (!anyToBool(pop())) break
                    try {
                        execTokens(tokens, whileAt + 1, repeatAt)
                    } catch (_: LeaveException) {
                        break
                    }
                }
                repeatAt + 1
            }
            againAt != -1 -> {
                while (true) {
                    try {
                        execTokens(tokens, start, againAt)
                    } catch (_: LeaveException) {
                        break
                    }
                }
                againAt + 1
            }
            else -> throw ForthError("'begin' without 'until', 'again', or 'while/repeat'")
        }
    }

    private data class LoopState(var index: Int, val limit: Int)
    private class LeaveException : Throwable()

    private fun execDo(tokens: List<Token>, start: Int, end: Int, startIdx: Int, limit: Int): Int {
        var depth = 0
        var loopAt = -1
        var plusLoopAt = -1
        var j = start
        while (j < end) {
            when (tokens[j].text.lowercase()) {
                "do" -> depth++
                "loop" -> if (depth == 0) { loopAt = j; break } else depth--
                "+loop" -> if (depth == 0) { plusLoopAt = j; break } else depth--
            }
            j++
        }
        val loopEnd = when {
            loopAt != -1 -> loopAt
            plusLoopAt != -1 -> plusLoopAt
            else -> throw ForthError("'do' without 'loop' or '+loop'")
        }
        val state = LoopState(startIdx, limit)
        returnStack.addLast(state)
        try {
            while (state.index != state.limit) {
                try {
                    execTokens(tokens, start, loopEnd)
                } catch (_: LeaveException) {
                    break
                }
                state.index += if (plusLoopAt != -1) popInt() else 1
            }
        } finally {
            returnStack.removeLastOrNull()
        }
        return loopEnd + 1
    }

Number helpers

source — 80 lines, from line 898
    private fun isNum(s: String): Boolean {
        if (s.isEmpty()) return false
        val t = if (s[0] == '-' || s[0] == '+') s.drop(1) else s
        if (t.isEmpty()) return false
        if (t.startsWith("0x", ignoreCase = true)) return t.drop(2).all { it.isDigit() || it in 'a'..'f' || it in 'A'..'F' }
        return t.any { it.isDigit() } && t.all { it.isDigit() || it == '.' }
    }

    private fun parseNum(s: String): Double {
        if (s.startsWith("0x", ignoreCase = true)) return s.drop(2).toLong(16).toDouble()
        return s.toDoubleOrNull() ?: throw ForthError("Not a number: '$s'")
    }

    private fun classNameVariants(raw: String): List<String> {
        val q = raw.trim()
        if (q.isEmpty()) return emptyList()
        val cased = q.replaceFirstChar { ch ->
            if (ch.isLowerCase()) ch.titlecase() else ch.toString()
        }
        return listOf(q, cased).distinct()
    }

    private fun classMatches(raw: String, limit: Int = 24): List<String> {
        val variants = classNameVariants(raw)
        if (variants.isEmpty()) return emptyList()

        val exact = variants
            .flatMap { findClassesNamed(it).orEmpty() }
            .distinct()

        val prefix = variants
            .flatMap { classesWithPrefix(it).map { pair -> pair.second } }
            .distinct()

        return (exact + prefix)
            .distinct()
            .sorted()
            .take(limit.coerceAtLeast(1))
    }

    private fun resolveClassByName(raw: String): Class<*> {
        val variants = classNameVariants(raw)
        if (variants.isEmpty()) throw ForthError("class-for: expected a class name")

        // First allow direct FQNs (or direct binary names) as-is.
        variants.forEach { candidate ->
            try {
                return Class.forName(candidate)
            } catch (_: Throwable) {
            }
        }

        // Then resolve short/simple names from importHelp cache.
        val exactClasses = variants
            .flatMap { findClassesNamed(it).orEmpty() }
            .distinct()
            .mapNotNull { fqn ->
                try {
                    Class.forName(fqn)
                } catch (_: Throwable) {
                    null
                }
            }
            .distinctBy { it.name }

        if (exactClasses.size == 1) return exactClasses.first()
        if (exactClasses.size > 1) {
            val matches = exactClasses.map { it.name }.sorted()
            val shown = matches.take(8).joinToString(", ")
            val suffix = if (matches.size > 8) ", ..." else ""
            throw ForthError("class-for: '${variants.first()}' is ambiguous; use FQN ($shown$suffix)")
        }

        val suggestions = classMatches(variants.first(), limit = 8)
        if (suggestions.isNotEmpty()) {
            throw ForthError("class-for: no exact class '${variants.first()}'. Try: ${suggestions.joinToString(", ")}")
        }

        throw ForthError("class-for: no class '${variants.first()}'")
    }

Completion support

fun complete(code: String, cursor: Int): List<ForthCompletion>

    fun complete(code: String, cursor: Int): List<ForthCompletion> {
        val pos = cursor.coerceIn(0, code.length)
        var start = pos
        while (start > 0 && !code[start - 1].isWhitespace()) start--
        val prefix = code.substring(start, pos).lowercase()
        return dictionary.keys
            .filter { it.startsWith(prefix) }
            .map { name ->
                val fw = dictionary[name]!!
                ForthCompletion(
                    word = name,
                    kind = when (fw) {
                        is ForthWord.Builtin -> "builtin"
                        is ForthWord.Compiled -> "word"
                        is ForthWord.Variable -> "variable"
                        is ForthWord.Constant -> "constant"
                        is ForthWord.Lambda -> "lambda"
                    },
                    documentation = when (fw) {
                        is ForthWord.Builtin -> fw.doc
                        is ForthWord.Compiled -> ": ${fw.name} ${fw.body.joinToString(" ") { it.text }} ;"
                        is ForthWord.Variable -> "variable ${fw.name}"
                        is ForthWord.Constant -> "constant ${fw.name} = ${fw.value}"
                        is ForthWord.Lambda -> fw.doc
                    }
                )
            }
            .sortedBy { it.word }
    }

fun describeWord(name: String): String

    fun describeWord(name: String): String {
        val key = name.lowercase()
        val fw = dictionary[key] ?: throw ForthError("see: unknown word '$name'")
        return when (fw) {
            is ForthWord.Builtin -> "${fw.name} ${fw.doc}".trim()
            is ForthWord.Compiled -> ": ${fw.name} ${fw.body.joinToString(" ") { it.text }} ;"
            is ForthWord.Variable -> "variable ${fw.name} = ${variables[fw.name]}"
            is ForthWord.Constant -> "constant ${fw.name} = ${fw.value}"
            is ForthWord.Lambda -> "${fw.name} ${fw.doc}".trim()
        }
    }

fun forgetWord(name: String, line: Int = -1)

    fun forgetWord(name: String, line: Int = -1) {
        val key = name.lowercase()
        val fw = dictionary[key] ?: throw ForthError("forget: unknown word '$name'", line)
        if (fw is ForthWord.Builtin || fw is ForthWord.Lambda) {
            throw ForthError("forget: '$name' is host-defined and cannot be forgotten", line)
        }
        // Forgetting a word brings back whatever it replaced, a builtin included.
        val previous = shadowed[key]?.removeLastOrNull()
        if (previous != null) dictionary[key] = previous else dictionary.remove(key)
        if (fw is ForthWord.Variable && previous !is ForthWord.Variable) variables.remove(fw.name)
    }

Public API: Kotlin-bridged words and run-loop strands

fun defineWord(name: String, doc: String = "", impl: ForthInterpreter.() -> Unit) {

    fun defineWord(name: String, doc: String = "", impl: ForthInterpreter.() -> Unit) {
        dictionary[name.lowercase()] = ForthWord.Lambda(name.lowercase(), doc, impl)
    }

fun addStrand(strandName: String, wordName: String)

    fun addStrand(strandName: String, wordName: String) {
        strands[strandName] = wordName.lowercase()
    }

fun removeStrand(strandName: String)

    fun removeStrand(strandName: String) {
        strands.remove(strandName)
    }

fun tickStrands()

    fun tickStrands() {
        strands.forEach { (_, wordName) ->
            val fw = dictionary[wordName]
            if (fw != null) execWord(fw)
        }
    }

JVM Reflection bridge

fun reflectMember(obj: Any, memberName: String, line: Int = -1)

@? implementation: access a field, property, or method on obj by name.

Stack convention: ( arg1 ... argN obj -- result? ) — obj is popped from TOS before this is called; remaining stack holds args (deepest = first arg, TOS = last arg).

  • Public fields and 0-arg methods: no args consumed.
  • Single non-vararg overload: auto-pops the required number of args.
  • Multiple overloads: picks the one whose param count best matches the current stack depth.
  • Static access: if obj is a Class, searches static fields/methods.
body — 61 lines, from line 1075
    fun reflectMember(obj: Any, memberName: String, line: Int = -1) {
        if (obj is Class<*> && (memberName == "new" || memberName == "newInstance")) {
            constructClass(obj, emptyList(), line)
            return
        }
        val klass = if (obj is Class<*>) obj else obj::class.java
        val instance = if (obj is Class<*>) null else obj
        val isStatic = obj is Class<*>

        // 1. Public fields (instance or static)
        klass.fields.firstOrNull { it.name == memberName && (isStatic == java.lang.reflect.Modifier.isStatic(it.modifiers)) }
            ?.let { field ->
                val result = field.get(instance)
                if (result != null) push(result)
                return
            }

        // 2. Methods matching name (exclude bridge/synthetic/vararg)
        val methods = klass.methods
            .filter { it.name == memberName && !it.isBridge && !it.isSynthetic && !it.isVarArgs
                && (isStatic == java.lang.reflect.Modifier.isStatic(it.modifiers)) }
            .sortedBy { it.parameterCount }

        if (methods.isNotEmpty()) {
            val method = when {
                methods.first().parameterCount == 0 -> methods.first()
                methods.size == 1 -> methods.first()
                else -> methods.firstOrNull { it.parameterCount <= stack.size } ?: methods.first()
            }
            val n = method.parameterCount
            if (n > stack.size) throw ForthError(
                "Not enough args for ${klass.simpleName}.$memberName (needs $n, have ${stack.size})", line)
            val args = (0 until n).map { pop() }.reversed()
            val coerced = args.zip(method.parameterTypes).map { (a, t) -> coerceArg(t, a) }
            val result = method.invoke(instance, *coerced.toTypedArray())
            if (method.returnType != Void.TYPE && result != null) push(result)
            return
        }

        // 3. Kotlin property getter: getXxx()
        klass.methods.firstOrNull { it.name == "get${memberName.replaceFirstChar { c -> c.uppercase() }}"
            && it.parameterCount == 0 }
            ?.let { getter ->
                val result = getter.invoke(instance)
                if (result != null) push(result)
                return
            }

        // 4. Declared (possibly private) fields
        generateSequence<Class<*>>(klass) { it.superclass }.forEach { c ->
            try {
                val field = c.getDeclaredField(memberName)
                field.isAccessible = true
                val result = field.get(instance)
                if (result != null) push(result)
                return
            } catch (_: NoSuchFieldException) {}
        }

        throw ForthError("No member '$memberName' on ${klass.simpleName}", line)
    }

fun reflectCallN(obj: Any, memberName: String, args: List<Any>, line: Int = -1)

@?! implementation: call a method on obj with an explicit argument list.

Picks the first non-vararg overload of memberName with exactly args.size params. For 0-arg calls, also falls back to Kotlin property getters (getXxx()) and fields.

body — 94 lines, from line 1143
    fun reflectCallN(obj: Any, memberName: String, args: List<Any>, line: Int = -1) {
        if (obj is Class<*> && (memberName == "new" || memberName == "newInstance")) {
            constructClass(obj, args, line)
            return
        }
        val klass = if (obj is Class<*>) obj else obj::class.java
        val instance = if (obj is Class<*>) null else obj
        val isStatic = obj is Class<*>
        val n = args.size
        val method = klass.methods.firstOrNull {
            it.name == memberName && it.parameterCount == n && !it.isVarArgs
                && (isStatic == java.lang.reflect.Modifier.isStatic(it.modifiers))
        }
        if (method != null) {
            val coerced = args.zip(method.parameterTypes).map { (a, t) -> coerceArg(t, a) }
            val result = method.invoke(instance, *coerced.toTypedArray())
            if (method.returnType != Void.TYPE && result != null) push(result)
            return
        }
        // For 0-arg: also try field access and Kotlin getter fallback (same as reflectMember)
        if (n == 0) {
            klass.fields.firstOrNull { it.name == memberName }?.let { field ->
                val result = field.get(instance); if (result != null) push(result); return
            }
            klass.methods.firstOrNull { it.name == "get${memberName.replaceFirstChar { c -> c.uppercase() }}"
                && it.parameterCount == 0 }?.let { getter ->
                val result = getter.invoke(instance); if (result != null) push(result); return
            }
            generateSequence<Class<*>>(klass) { it.superclass }.forEach { c ->
                try { val f = c.getDeclaredField(memberName); f.isAccessible = true
                    val result = f.get(instance); if (result != null) push(result); return
                } catch (_: NoSuchFieldException) {}
            }
        }
        throw ForthError(
            "No ${if (isStatic) "static " else ""}method '${klass.simpleName}.$memberName' with $n params",
            line
        )
    }

    private fun constructClass(klass: Class<*>, args: List<Any>, line: Int = -1) {
        val ctors = klass.constructors
            .filter { it.parameterCount == args.size }
            .sortedBy { it.parameterCount }
        if (ctors.isEmpty()) throw ForthError(
            "No constructor '${klass.simpleName}(${args.size} args)'",
            line
        )

        var lastFailure: Throwable? = null
        for (ctor in ctors) {
            val coerced = args.zip(ctor.parameterTypes).map { (a, t) -> coerceArg(t, a) }.toTypedArray()
            try {
                val result = ctor.newInstance(*coerced)
                if (result != null) push(result)
                return
            } catch (t: Throwable) {
                lastFailure = t
            }
        }

        val details = lastFailure?.cause?.message ?: lastFailure?.message
        throw ForthError(
            "Could not construct '${klass.simpleName}' with ${args.size} args" + (details?.let { ": $it" } ?: ""),
            line
        )
    }

    private fun coerceArg(targetType: Class<*>, value: Any): Any? = when {
        targetType == Float::class.java || targetType == java.lang.Float.TYPE ->
            (value as? Number)?.toFloat() ?: value
        targetType == Double::class.java || targetType == java.lang.Double.TYPE ->
            (value as? Number)?.toDouble() ?: value
        targetType == Int::class.java || targetType == java.lang.Integer.TYPE ->
            (value as? Number)?.toInt() ?: (value as? Boolean)?.let { if (it) 1 else 0 } ?: value
        targetType == Long::class.java || targetType == java.lang.Long.TYPE ->
            (value as? Number)?.toLong() ?: value
        targetType == Short::class.java || targetType == java.lang.Short.TYPE ->
            (value as? Number)?.toShort() ?: value
        targetType == Byte::class.java || targetType == java.lang.Byte.TYPE ->
            (value as? Number)?.toByte() ?: value
        targetType == Boolean::class.java || targetType == java.lang.Boolean.TYPE ->
            anyToBool(value)
        targetType == String::class.java -> value.toString()
        targetType.isAssignableFrom(value.javaClass) -> value
        else -> value
    }

    private fun escapeHtml(raw: String): String = raw
        .replace("&", "&amp;")
        .replace("<", "&lt;")
        .replace(">", "&gt;")
        .replace("\"", "&quot;")
        .replace("'", "&#39;")

depth is an Int, so Table right-aligns it without being told; the cells are plain text, so Table escapes them and the local escapeHtml is not needed here any more.

source — 373 lines, from line 1240
    private fun stackHtml(): String = Table(listOf("depth", "value", "type"))
        .rows(
            stack.toList().mapIndexed { index, value ->
                listOf(stack.size - index - 1, shortValue(value), value::class.simpleName ?: "value")
            }.asReversed()
        )
        .role("type", CellRole.MUTED)
        .toHTML()

    private fun barHtml(value: Double, max: Double): String {
        val denom = if (max == 0.0) 1.0 else max
        val pct = (value / denom).coerceIn(0.0, 1.0)
        val label = "${round(pct * 1000.0) / 10.0}%"
        return "<div style='min-width:220px;padding:6px 0;font:12px/1.3 sans-serif'>" +
            "<div style='height:10px;background:#222;border-radius:2px;overflow:hidden'>" +
            "<div style='height:100%;width:${pct * 100.0}%;background:#4da3ff'></div></div>" +
            "<div style='margin-top:4px;color:#777'>${escapeHtml(label)} (${escapeHtml(value.toString())}/${escapeHtml(max.toString())})</div></div>"
    }

    private fun plotStackHtml(): String {
        val nums = stack.toList().mapNotNull { (it as? Number)?.toDouble() }
        if (nums.isEmpty()) return "<div style='font:12px sans-serif;color:#777'>plot-stack: no numeric values on stack</div>"
        val maxAbs = nums.maxOf { abs(it) }.takeIf { it > 0.0 } ?: 1.0
        val bars = nums.takeLast(64).joinToString("") { n ->
            val h = (abs(n) / maxAbs * 80.0).coerceIn(1.0, 80.0)
            val color = if (n >= 0.0) "#4da3ff" else "#ff6b6b"
            "<div title='${escapeHtml(n.toString())}' style='width:8px;height:${h}px;background:$color'></div>"
        }
        return "<div style='display:flex;align-items:flex-end;gap:2px;height:88px;padding:4px 0'>$bars</div>"
    }

    private fun builtin(name: String, doc: String = "", impl: ForthInterpreter.() -> Unit) {
        dictionary[name.lowercase()] = ForthWord.Builtin(name, doc, impl)
    }

    private fun installBuiltins() {
        // --- Stack ---
        builtin("dup",   "( a -- a a ) Duplicate top") { push(peek()) }
        builtin("drop",  "( a -- ) Remove top") { pop() }
        builtin("swap",  "( a b -- b a )") { val b = pop(); val a = pop(); push(b); push(a) }
        builtin("over",  "( a b -- a b a )") { val b = pop(); val a = pop(); push(a); push(b); push(a) }
        builtin("rot",   "( a b c -- b c a )") { val c = pop(); val b = pop(); val a = pop(); push(b); push(c); push(a) }
        builtin("-rot",  "( a b c -- c a b )") { val c = pop(); val b = pop(); val a = pop(); push(c); push(a); push(b) }
        builtin("nip",   "( a b -- b )") { val b = pop(); pop(); push(b) }
        builtin("tuck",  "( a b -- b a b )") { val b = pop(); val a = pop(); push(b); push(a); push(b) }
        builtin("2dup",  "( a b -- a b a b )") { val b = pop(); val a = pop(); push(a); push(b); push(a); push(b) }
        builtin("2drop", "( a b -- )") { pop(); pop() }
        builtin("2swap", "( a b c d -- c d a b )") {
            val d = pop(); val c = pop(); val b = pop(); val a = pop()
            push(c); push(d); push(a); push(b)
        }
        builtin("depth", "( -- n ) Stack depth") { push(stack.size.toDouble()) }
        builtin("clear", "( ... -- ) Clear stack") { stack.clear() }
        builtin("?dup",  "( n -- n n | 0 ) Dup if nonzero") { val v = pop(); push(v); if (anyToBool(v)) push(v) }
        builtin("pick",  "( n -- item ) Copy item at depth n to TOS (0=TOS copy=dup)") {
            val n = popInt()
            if (n < 0 || n >= stack.size) throw ForthError("pick: index $n out of range (stack depth ${stack.size})")
            push(stack[stack.size - 1 - n])
        }
        builtin("roll",  "( n -- item ) Move item at depth n to TOS") {
            val n = popInt()
            if (n < 0 || n >= stack.size) throw ForthError("roll: index $n out of range (stack depth ${stack.size})")
            val idx = stack.size - 1 - n
            val item = stack.removeAt(idx)
            push(item)
        }

        // --- Return stack ---
        builtin(">r", "( x -- ) Move x from data stack to return stack") { returnStack.addLast(pop()) }
        builtin("r>", "( -- x ) Move x from return stack to data stack") {
            val v = returnStack.removeLastOrNull() ?: throw ForthError("Return stack underflow")
            if (v is LoopState) {
                returnStack.addLast(v)
                throw ForthError("r>: top of return stack is loop state; this would corrupt do/loop")
            }
            push(v)
        }
        builtin("r@", "( -- x ) Copy top of return stack to data stack") {
            val v = returnStack.lastOrNull() ?: throw ForthError("Return stack underflow")
            if (v is LoopState) throw ForthError("r@: top of return stack is loop state")
            push(v)
        }

        // --- Arithmetic ---
        builtin("+",      "( a b -- a+b )") { val b = popNum(); push(popNum() + b) }
        builtin("-",      "( a b -- a-b )") { val b = popNum(); push(popNum() - b) }
        builtin("*",      "( a b -- a*b )") { val b = popNum(); push(popNum() * b) }
        builtin("/",      "( a b -- a/b )") { val b = popNum(); push(popNum() / b) }
        builtin("mod",    "( a b -- a%b )") { val b = popNum(); push(popNum() % b) }
        builtin("/mod",   "( a b -- rem quot )") { val b = popNum(); val a = popNum(); push(a % b); push(floor(a / b)) }
        builtin("1+",     "( n -- n+1 )") { push(popNum() + 1) }
        builtin("1-",     "( n -- n-1 )") { push(popNum() - 1) }
        builtin("2+",     "( n -- n+2 )") { push(popNum() + 2) }
        builtin("2-",     "( n -- n-2 )") { push(popNum() - 2) }
        builtin("2*",     "( n -- n*2 )") { push(popNum() * 2) }
        builtin("2/",     "( n -- n/2 )") { push(popNum() / 2) }
        builtin("negate", "( n -- -n )") { push(-popNum()) }
        builtin("abs",    "( n -- |n| )") { push(abs(popNum())) }
        builtin("max",    "( a b -- max )") { val b = popNum(); push(max(popNum(), b)) }
        builtin("min",    "( a b -- min )") { val b = popNum(); push(min(popNum(), b)) }
        builtin("**",     "( a b -- a^b )") { val b = popNum(); push(popNum().pow(b)) }

        // --- Math ---
        builtin("sqrt",  "( n -- sqrt )") { push(sqrt(popNum())) }
        builtin("sin",   "( n -- sin )") { push(sin(popNum())) }
        builtin("cos",   "( n -- cos )") { push(cos(popNum())) }
        builtin("tan",   "( n -- tan )") { push(tan(popNum())) }
        builtin("asin",  "( n -- asin )") { push(asin(popNum())) }
        builtin("acos",  "( n -- acos )") { push(acos(popNum())) }
        builtin("atan",  "( n -- atan )") { push(atan(popNum())) }
        builtin("atan2", "( y x -- atan2 )") { val x = popNum(); push(atan2(popNum(), x)) }
        builtin("exp",   "( n -- e^n )") { push(exp(popNum())) }
        builtin("ln",    "( n -- ln )") { push(ln(popNum())) }
        builtin("log",   "( n -- log10 )") { push(log10(popNum())) }
        builtin("floor", "( n -- floor )") { push(floor(popNum())) }
        builtin("ceil",  "( n -- ceil )") { push(ceil(popNum())) }
        builtin("round", "( n -- round )") { push(round(popNum())) }
        builtin("pi",    "( -- pi )") { push(PI) }
        builtin("e",     "( -- e )") { push(E) }

        // --- Comparison ---
        builtin("=",  "( a b -- flag )") { val b = pop(); push(if (forthEq(pop(), b)) -1.0 else 0.0) }
        builtin("<>", "( a b -- flag )") { val b = pop(); push(if (!forthEq(pop(), b)) -1.0 else 0.0) }
        builtin("<",  "( a b -- flag )") { val b = popNum(); push(if (popNum() < b) -1.0 else 0.0) }
        builtin(">",  "( a b -- flag )") { val b = popNum(); push(if (popNum() > b) -1.0 else 0.0) }
        builtin("<=", "( a b -- flag )") { val b = popNum(); push(if (popNum() <= b) -1.0 else 0.0) }
        builtin(">=", "( a b -- flag )") { val b = popNum(); push(if (popNum() >= b) -1.0 else 0.0) }
        builtin("0=", "( n -- flag )") { push(if (popNum() == 0.0) -1.0 else 0.0) }
        builtin("0<", "( n -- flag )") { push(if (popNum() < 0.0) -1.0 else 0.0) }
        builtin("0>", "( n -- flag )") { push(if (popNum() > 0.0) -1.0 else 0.0) }

        // --- Logic ---
        builtin("and",    "( a b -- a&b )") { val b = popInt(); push((popInt() and b).toDouble()) }
        builtin("or",     "( a b -- a|b )") { val b = popInt(); push((popInt() or b).toDouble()) }
        builtin("xor",    "( a b -- a^b )") { val b = popInt(); push((popInt() xor b).toDouble()) }
        builtin("invert", "( a -- ~a )") { push(popInt().inv().toDouble()) }
        builtin("not",    "( f -- !f )") { push(if (anyToBool(pop())) 0.0 else -1.0) }
        builtin("true",   "( -- -1 )") { push(-1.0) }
        builtin("false",  "( -- 0 )") { push(0.0) }

        // --- Variables ---
        builtin("!", "( val addr -- ) Store") {
            val addr = pop().toString(); variables[addr] = pop()
        }
        builtin("@", "( addr -- val ) Fetch") {
            val addr = pop().toString()
            push(variables[addr] ?: throw ForthError("Undefined variable: $addr"))
        }
        builtin("+!", "( n addr -- ) Add to variable") {
            val addr = pop().toString()
            val n = popNum()
            variables[addr] = anyToDouble(variables[addr] ?: 0.0) + n
        }

        // --- Output ---
        builtin(".",       "( n -- ) Print number") { onOutput("${popNum()} ") }
        builtin(".s",      "( -- ) Print stack") { onOutput("${stack.h}") }
        builtin("cr",      "( -- ) Newline") { onOutput("\n") }
        builtin("space",   "( -- ) Space") { onOutput(" ") }
        builtin("spaces",  "( n -- ) N spaces") { onOutput(" ".repeat(popInt().coerceAtLeast(0))) }
        builtin("emit",    "( c -- ) Print char") { onOutput(popInt().toChar().toString()) }
        builtin("type",    "( str -- ) Print string") { onOutput(pop().toString()) }
        builtin("print",   "( val -- ) Print") { onOutput("${pop().h} ") }
        builtin("println", "( val -- ) Print+newline") { onOutput("${pop().h}\n") }
        builtin("display", "( val -- ) Send to output area channel 'output'") { onDisplay("output", pop().h) }
        builtin("display-channel", "( val channel -- ) Send to output area channel") {
            val channel = pop().toString()
            val value = pop()
            onDisplay(channel, value.h)
        }

        // --- Dictionary / introspection ---
        builtin("words", "( -- ) Print all known dictionary words") {
            onOutput(dictionary.keys.sorted().joinToString(" ") + "\n")
        }
        builtin("word-list", "( -- words ) Push all known dictionary words as a list") {
            push(dictionary.keys.sorted())
        }
        builtin("see-word", "( name -- source ) Push a printable definition for name") {
            push(describeWord(pop().toString()))
        }
        builtin("forget-word", "( name -- ) Remove a user-defined word, variable, or constant") {
            forgetWord(pop().toString(), currentLine)
        }
        builtin("see", "( -- ) Immediate-style: `see name` prints a word definition") {
            throw ForthError("see must be followed by a word name")
        }
        builtin("forget", "( -- ) Immediate-style: `forget name` removes a user-defined word, restoring what it replaced") {
            throw ForthError("forget must be followed by a word name")
        }
        builtin("recurse", "( ... -- ... ) Re-run the current word") {
            throw ForthError("'recurse' is only valid while a word is executing")
        }

        // --- Small Field-native visualization layer ---
        builtin("html", "( html -- ) Display raw HTML in channel 'output'") {
            onDisplay("output", pop().toString())
        }
        builtin("html-channel", "( html channel -- ) Display raw HTML in channel") {
            val channel = pop().toString()
            onDisplay(channel, pop().toString())
        }
        builtin("watch", "( value name -- ) Display a named value in its own channel") {
            val name = pop().toString()
            val value = pop()
            onDisplay(name, "<div style='font:12px/1.4 monospace'><b>${escapeHtml(name)}</b> = ${escapeHtml(shortValue(value))}</div>")
        }
        builtin("stack-display", "( -- ) Display the current data stack as an HTML table") {
            onDisplay("stack", stackHtml())
        }
        builtin("bar", "( value max channel -- ) Display value/max as a progress bar") {
            val channel = pop().toString()
            val max = popNum()
            val value = popNum()
            onDisplay(channel, barHtml(value, max))
        }
        builtin("plot-stack", "( n1 n2 ... -- ) Display numeric stack values as simple bars") {
            onDisplay("plot", plotStackHtml())
        }

        // --- Control flow placeholders (only valid inside word bodies) ---
        for (cfWord in listOf("if", "else", "then", "begin", "until", "while", "repeat", "again", "do", "loop", "+loop", "leave")) {
            val captured = cfWord
            dictionary[captured] = ForthWord.Builtin(captured, "control flow") {
                throw ForthError("'$captured' is only valid inside a word definition")
            }
        }

        // --- Utility ---
        builtin("noop",         "( -- ) No-op") {}
        builtin("between",      "( n lo hi -- f ) True if lo<=n<=hi") {
            val hi = popNum(); val lo = popNum(); val n = popNum()
            push(if (n in lo..hi) -1.0 else 0.0)
        }
        builtin("clamp",        "( n lo hi -- n' )") {
            val hi = popNum(); val lo = popNum(); push(popNum().coerceIn(lo, hi))
        }
        builtin("lerp",         "( a b t -- lerp )") {
            val t = popNum(); val b = popNum(); val a = popNum(); push(a + (b - a) * t)
        }
        builtin("random",       "( -- n ) [0,1)") { push(Math.random()) }
        builtin("random-range", "( lo hi -- n )") { val hi = popNum(); val lo = popNum(); push(lo + Math.random() * (hi - lo)) }
        builtin("int",          "( n -- int )") { push(floor(popNum())) }
        builtin("float",        "( n -- f )") { push(popNum()) }
        builtin("str",          "( n -- str )") { push(pop().toString()) }
        builtin("str+",         "( a b -- a++b )") { val b = pop().toString(); push(pop().toString() + b) }
        builtin("frame",        "( -- ) Yield until the next RunLoop tick (launch mode only)") { onFrame() }

        // --- JVM reflection bridge (see also @? and @?! handled in execTokens) ---
        builtin("class-for", "( className -- class ) Resolve a JVM Class (FQN or simple name via importHelp cache)") {
            val name = pop().toString()
            push(resolveClassByName(name))
        }
        builtin("class-matches", "( prefix -- matches ) List FQNs matching a class-name prefix") {
            push(classMatches(pop().toString(), limit = 64))
        }
        builtin("class-help", "( prefix -- ) Print matching class FQNs, one per line") {
            val prefix = pop().toString()
            val matches = classMatches(prefix, limit = 24)
            if (matches.isEmpty()) {
                onOutput("(no class matches for '$prefix')\n")
            } else {
                onOutput(matches.joinToString(separator = "\n", postfix = "\n"))
            }
        }
        // Placeholder entries so @? / @?! appear in completions; real logic is in execTokens.
        builtin("@?",  "( arg1...argN obj -- result? ) Reflection: next token is member name") {
            throw ForthError("@? must be followed by a member name token")
        }
        builtin("@?!", "( obj arg1...argN n -- result? ) Reflection: next token is member name, explicit arity") {
            throw ForthError("@?! must be followed by a method name token")
        }

        // --- Vec2 ---
        builtin("vec2",     "( x y -- v )") { val y = popNum().toFloat(); push(Vec2(popNum().toFloat(), y)) }
        builtin("vec2@x",   "( v -- x )") { push((pop() as Vec2).x.toDouble()) }
        builtin("vec2@y",   "( v -- y )") { push((pop() as Vec2).y.toDouble()) }
        builtin("vec2+",    "( a b -- a+b )") { val b = pop() as Vec2; push(Vec2(pop() as Vec2).add(b)) }
        builtin("vec2-",    "( a b -- a-b )") { val b = pop() as Vec2; push(Vec2(pop() as Vec2).sub(b)) }
        builtin("vec2*",    "( v s -- v*s )") { val s = popNum().toFloat(); push(Vec2(pop() as Vec2).mul(s)) }
        builtin("vec2len",  "( v -- len )") { push((pop() as Vec2).length().toDouble()) }
        builtin("vec2norm", "( v -- v )") { push(Vec2(pop() as Vec2).normalize()) }
        builtin("vec2dot",  "( a b -- dot )") { val b = pop() as Vec2; push((pop() as Vec2).dot(b).toDouble()) }

        // --- Vec3 ---
        builtin("vec3",     "( x y z -- v )") {
            val z = popNum().toFloat(); val y = popNum().toFloat(); push(Vec3(popNum().toFloat(), y, z))
        }
        builtin("vec3@x",   "( v -- x )") { push((pop() as Vec3).x.toDouble()) }
        builtin("vec3@y",   "( v -- y )") { push((pop() as Vec3).y.toDouble()) }
        builtin("vec3@z",   "( v -- z )") { push((pop() as Vec3).z.toDouble()) }
        builtin("vec3+",    "( a b -- a+b )") { val b = pop() as Vec3; push(Vec3(pop() as Vec3).add(b)) }
        builtin("vec3-",    "( a b -- a-b )") { val b = pop() as Vec3; push(Vec3(pop() as Vec3).sub(b)) }
        builtin("vec3*",    "( v s -- v*s )") { val s = popNum().toFloat(); push(Vec3(pop() as Vec3).mul(s)) }
        builtin("vec3len",  "( v -- len )") { push((pop() as Vec3).length().toDouble()) }
        builtin("vec3norm", "( v -- v )") { push(Vec3(pop() as Vec3).normalize()) }
        builtin("vec3dot",  "( a b -- dot )") { val b = pop() as Vec3; push((pop() as Vec3).dot(b).toDouble()) }
        builtin("vec3cross","( a b -- a×b )") { val b = pop() as Vec3; push(Vec3(pop() as Vec3).cross(b)) }

        // --- Vec4 ---
        builtin("vec4",   "( x y z w -- v )") {
            val w = popNum().toFloat(); val z = popNum().toFloat(); val y = popNum().toFloat()
            push(Vec4(popNum().toFloat(), y, z, w))
        }
        builtin("vec4@x", "( v -- x )") { push((pop() as Vec4).x.toDouble()) }
        builtin("vec4@y", "( v -- y )") { push((pop() as Vec4).y.toDouble()) }
        builtin("vec4@z", "( v -- z )") { push((pop() as Vec4).z.toDouble()) }
        builtin("vec4@w", "( v -- w )") { push((pop() as Vec4).w.toDouble()) }
        builtin("vec4+",  "( a b -- a+b )") { val b = pop() as Vec4; push(Vec4(pop() as Vec4).add(b)) }
        builtin("vec4*",  "( v s -- v*s )") { val s = popNum().toFloat(); push(Vec4(pop() as Vec4).mul(s)) }
        builtin("vec4len","( v -- len )") { push((pop() as Vec4).length().toDouble()) }

        // --- FLine ---
        builtin("fline",          "( -- f ) New FLine") { push(FLine()) }
        builtin("moveto",         "( f x y -- f )") {
            val y = popNum(); val x = popNum(); val f = pop() as FLine; f.moveTo(x, y); push(f)
        }
        builtin("moveto3",        "( f x y z -- f )") {
            val z = popNum(); val y = popNum(); val x = popNum(); val f = pop() as FLine; f.moveTo(x, y, z); push(f)
        }
        builtin("lineto",         "( f x y -- f )") {
            val y = popNum(); val x = popNum(); val f = pop() as FLine; f.lineTo(x, y); push(f)
        }
        builtin("lineto3",        "( f x y z -- f )") {
            val z = popNum(); val y = popNum(); val x = popNum(); val f = pop() as FLine; f.lineTo(x, y, z); push(f)
        }
        builtin("cubicto",        "( f cx1 cy1 cx2 cy2 x y -- f )") {
            val y = popNum(); val x = popNum()
            val cy2 = popNum(); val cx2 = popNum(); val cy1 = popNum(); val cx1 = popNum()
            val f = pop() as FLine; f.cubicTo(cx1, cy1, cx2, cy2, x, y); push(f)
        }
        builtin("cubicto3",       "( f cx1 cy1 cz1 cx2 cy2 cz2 x y z -- f )") {
            val z = popNum(); val y = popNum(); val x = popNum()
            val cz2 = popNum(); val cy2 = popNum(); val cx2 = popNum()
            val cz1 = popNum(); val cy1 = popNum(); val cx1 = popNum()
            val f = pop() as FLine; f.cubicTo(cx1, cy1, cz1, cx2, cy2, cz2, x, y, z); push(f)
        }
        builtin("fline-close",    "( f -- f ) Close path") {
            val f = pop() as FLine; f.close(); push(f)
        }
        builtin("fline-filled",   "( f b -- f ) Set filled") {
            val b = anyToBool(pop()); val f = pop() as FLine; f.properties["filled"] = b; push(f)
        }
        builtin("fline-stroked",  "( f b -- f ) Set stroked") {
            val b = anyToBool(pop()); val f = pop() as FLine; f.properties["stroked"] = b; push(f)
        }
        builtin("fline-color",    "( f r g b a -- f ) Set color") {
            val a = popNum().toFloat(); val b = popNum().toFloat()
            val g = popNum().toFloat(); val r = popNum().toFloat()
            val f = pop() as FLine; f.properties["color"] = Vec4(r, g, b, a); push(f)
        }
        builtin("fline-thickness","( f n -- f ) Set thickness") {
            val t = popNum(); val f = pop() as FLine; f.properties["thickness"] = t; push(f)
        }
        builtin("fline-moveto-v2","( f v2 -- f ) MoveTo vec2") {
            val v = pop() as Vec2; val f = pop() as FLine; f.moveTo(v); push(f)
        }
        builtin("fline-lineto-v2","( f v2 -- f ) LineTo vec2") {
            val v = pop() as Vec2; val f = pop() as FLine; f.lineTo(v); push(f)
        }
        builtin("fline-moveto-v3","( f v3 -- f ) MoveTo vec3") {
            val v = pop() as Vec3; val f = pop() as FLine; f.moveTo(v); push(f)
        }
        builtin("fline-lineto-v3","( f v3 -- f ) LineTo vec3") {
            val v = pop() as Vec3; val f = pop() as FLine; f.lineTo(v); push(f)
        }
        builtin("fline-node-color","( f r g b a -- f ) Color last node") {
            val a = popNum().toFloat(); val b = popNum().toFloat()
            val g = popNum().toFloat(); val r = popNum().toFloat()
            val f = pop() as FLine; f.here.properties["color"] = Vec4(r, g, b, a); push(f)
        }
    }
}

Supporting types

sealed class ForthWord

data class Builtin(val name: String, val doc: String, val impl: ForthInterpreter.() -> Unit) : ForthWord()

    data class Builtin(val name: String, val doc: String, val impl: ForthInterpreter.() -> Unit) : ForthWord()

data class Compiled(val name: String, val body: List<ForthInterpreter.Token>, val sourceLine: Int = -1) : ForthWord()

    data class Compiled(val name: String, val body: List<ForthInterpreter.Token>, val sourceLine: Int = -1) : ForthWord()

data class Variable(val name: String) : ForthWord()

data class Constant(val name: String, val value: Any) : ForthWord()

data class Lambda(val name: String, val doc: String, val impl: ForthInterpreter.() -> Unit) : ForthWord()

    data class Lambda(val name: String, val doc: String, val impl: ForthInterpreter.() -> Unit) : ForthWord()
}

class ForthError(message: String, val line: Int = -1) : RuntimeException(message)

class ForthFrameYield : Throwable()

data class ForthDiagnostic(val message: String, val line: Int)

data class ForthCompletion

data class ForthCompletion(
    val word: String,
    val kind: String,
    val documentation: String
)