refactor(interp): move control-flow signals and label search out of Interpreter
authorSvjatoslav Agejenko <svjatoslav@svjatoslav.eu>
Thu, 3 Sep 2026 20:55:40 +0000 (23:55 +0300)
committerSvjatoslav Agejenko <svjatoslav@svjatoslav.eu>
Thu, 3 Sep 2026 20:55:40 +0000 (23:55 +0300)
The GOTO/GOSUB plumbing (jump exception types, the GosubFrame chain,
nested-label search) sat inline in Interpreter between the variable
cell model and the statement dispatch, where it read as noise around
statement semantics.

- Jumps.java groups the seven stackless control-flow signals
  (ExitJump, GotoJump, GosubJump, GosubReturnJump, ResumeJump,
  ProgramEnd, GosubFrame) with their semantics documented once, up
  front
- LabelSearch.java holds the pure Ast utilities (findNestedLabel,
  childListsOf, indexOfIdentity) — no interpreter state involved

resumeNestedLabel/resumeChain/continueLoopAfterResume stay in
Interpreter: they are interlocked with execBlockFrom's pc loop and
the GOSUB rebuild state, and moving them would distort rather than
clarify. Also fixes a doubled/orphan javadoc above the old GosubFrame.

Verification: 93/93 tests green (the suite covers nested-label GOTO
and cross-block GOSUB/RETURN); EDPAT (EXIT DO) and BAR re-run
pixel-identical.

src/main/java/eu/svjatoslav/crtbasic/interp/Interpreter.java
src/main/java/eu/svjatoslav/crtbasic/interp/Jumps.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/crtbasic/interp/LabelSearch.java [new file with mode: 0644]

index 81e6900..454fabb 100644 (file)
@@ -131,77 +131,6 @@ public final class Interpreter {
         }
     }
 
-    /**
-     * A GOSUB return address: the statement list the GOSUB sits in and the
-     * pc of the statement after it. RETURN only resolves when it executes in
-     * that same block — a GOSUB whose label lives in an outer block unwinds
-     * the originating block, making its continuation unreachable.
-     */
-    /**
-     * Signals an EXIT FOR/DO/WHILE/SUB/FUNCTION: unwinds to the innermost
-     * matching construct, which catches it; everything else rethrows.
-     */
-    private static final class ExitJump extends RuntimeException {
-        private final String what;
-
-        ExitJump(final String construct) {
-            super(null, null, false, false);
-            what = construct;
-        }
-    }
-
-    private record GosubFrame(List<Ast.Stmt> block, int pc) {
-    }
-
-    /**
-     * Signals a GOTO jump. Each enclosing block that does not contain the
-     * target label rethrows it, which gives procedure-wide label scope.
-     */
-    private static class GotoJump extends RuntimeException {
-        private final String label;
-        private final int line;
-
-        GotoJump(final String targetLabel, final int sourceLine) {
-            super(null, null, false, false);
-            label = targetLabel;
-            line = sourceLine;
-        }
-    }
-
-    /**
-     * A GOSUB in flight: a GOTO that additionally collects its origin
-     * context chain as it unwinds. Every execBlockFrom the jump passes
-     * through prepends its (block, pc-after-current-statement) frame, so
-     * when the label resolves the chain reads outermost → innermost and
-     * RETURN can rebuild the exact nested execution state (truth.bas:
-     * GOSUB mkneg inside FOR → SELECT → IF, label at SUB body level).
-     */
-    private static final class GosubJump extends GotoJump {
-        private final List<GosubFrame> chain = new ArrayList<>();
-
-        GosubJump(final String targetLabel, final int sourceLine) {
-            super(targetLabel, sourceLine);
-        }
-    }
-
-    /**
-     * A RETURN heading for a GOSUB origin in an ENCLOSING block of the
-     * same procedure. Block pc loops rethrow it until the block that
-     * resolved the GOSUB label (frame 0 of the chain) catches it and
-     * rebuilds the origin inline — keeping its own pc loop on the stack
-     * so jumps issued by the continued execution still resolve.
-     */
-    private static final class GosubReturnJump extends RuntimeException {
-        private final List<GosubFrame> frames;
-        private final int line;
-
-        GosubReturnJump(final List<GosubFrame> frames, final int line) {
-            super(null, null, false, false);
-            this.frames = frames;
-            this.line = line;
-        }
-    }
-
     private final VgaDevice vga;
     private final KeyboardQueue keys;
     private final SoundQueue sound;
@@ -215,7 +144,7 @@ public final class Interpreter {
     /** Scope stack: bottom is the global scope, top is the current SUB. */
     private final Deque<Map<String, Cell>> scopes = new ArrayDeque<>();
     /** GOSUB return addresses: context chain snapshots for RETURN. */
-    private final Deque<List<GosubFrame>> gosubStack = new ArrayDeque<>();
+    private final Deque<List<Jumps.GosubFrame>> gosubStack = new ArrayDeque<>();
     /**
      * While a cross-block GOSUB origin is being rebuilt in a block's pc
      * loop: that block, and the pc its frame should carry in any new GOSUB
@@ -498,9 +427,9 @@ public final class Interpreter {
                 }
             }
             execBlock(main);
-        } catch (final ProgramEnd e) {
+        } catch (final Jumps.ProgramEnd e) {
             // END statement: normal termination.
-        } catch (final GotoJump jump) {
+        } catch (final Jumps.GotoJump jump) {
             throw error("Label not found: " + jump.label, jump.line);
         }
     }
@@ -533,7 +462,7 @@ public final class Interpreter {
         Map<String, Integer> labels = null;
         int pc = startPc;
         int stmtLine = 0;
-        List<GosubFrame> pendingGosubResume = null;
+        List<Jumps.GosubFrame> pendingGosubResume = null;
         while (pc < stmts.size() || pendingGosubResume != null) {
             try {
                 if (pendingGosubResume != null) {
@@ -542,7 +471,7 @@ public final class Interpreter {
                     // try: jumps from the continued execution (e.g. the
                     // FOR's next iterations hitting GOSUB again) are
                     // caught by this pc loop, which stays on the stack.
-                    final List<GosubFrame> frames = pendingGosubResume;
+                    final List<Jumps.GosubFrame> frames = pendingGosubResume;
                     pendingGosubResume = null;
                     if (frames.size() > 1) {
                         // While the rebuild runs, a new GOSUB chain's frame
@@ -564,20 +493,20 @@ public final class Interpreter {
                 final Ast.Stmt stmt = stmts.get(pc++);
                 stmtLine = stmt.line();
                 if (stmt instanceof Ast.GotoStmt g) {
-                    throw new GotoJump(g.label(), g.line());
+                    throw new Jumps.GotoJump(g.label(), g.line());
                 }
                 if (stmt instanceof Ast.GosubStmt g) {
                     // The jump collects the origin context chain as it
                     // unwinds (each block's catch prepends its frame,
                     // including this one); the snapshot is pushed where
                     // the label resolves, and RETURN rebuilds from it.
-                    throw new GosubJump(g.label(), g.line());
+                    throw new Jumps.GosubJump(g.label(), g.line());
                 }
                 if (stmt instanceof Ast.ReturnStmt r) {
                     if (++executed > maxStatements) {
                         throw new StepsLimitException(executed);
                     }
-                    final List<GosubFrame> snapshot = gosubStack.pollLast();
+                    final List<Jumps.GosubFrame> snapshot = gosubStack.pollLast();
                     if (snapshot == null) {
                         throw error("RETURN without GOSUB", r.line());
                     }
@@ -589,14 +518,14 @@ public final class Interpreter {
                     if (snapshot.get(0).block() != stmts) {
                         // The RETURN sits in a block nested inside the
                         // subroutine stretch: unwind to the resolving block.
-                        throw new GosubReturnJump(snapshot, r.line());
+                        throw new Jumps.GosubReturnJump(snapshot, r.line());
                     }
                     // Resume at the top of the loop, inside the try.
                     pendingGosubResume = snapshot;
                     continue;
                 }
                 exec(stmt);
-            } catch (final GotoJump caught) {
+            } catch (final Jumps.GotoJump caught) {
                 if (++executed > maxStatements) {
                     throw new StepsLimitException(executed);
                 }
@@ -608,7 +537,7 @@ public final class Interpreter {
                         }
                     }
                 }
-                GotoJump jump = caught;
+                Jumps.GotoJump jump = caught;
                 while (true) {
                     // A GOSUB issued while this block was rebuilding a
                     // RETURN origin: this block's frame belongs to the
@@ -623,10 +552,10 @@ public final class Interpreter {
                     }
                     final Integer target = labels.get(jump.label.toLowerCase());
                     if (target != null) {
-                        if (jump instanceof GosubJump gosub) {
+                        if (jump instanceof Jumps.GosubJump gosub) {
                             // Label resolved at this level: this block's
                             // frame is the outermost of the return chain.
-                            gosub.chain.add(0, new GosubFrame(stmts, originPc));
+                            gosub.chain.add(0, new Jumps.GosubFrame(stmts, originPc));
                             gosubStack.addLast(gosub.chain);
                         }
                         pc = target;
@@ -634,16 +563,16 @@ public final class Interpreter {
                     }
                     // Flat QBasic semantics: the label may be nested inside
                     // IF/FOR/DO bodies of this block — jump INTO it.
-                    final List<NestedLabelStep> path = findNestedLabel(stmts, jump.label);
+                    final List<LabelSearch.NestedLabelStep> path = LabelSearch.findNestedLabel(stmts, jump.label);
                     if (path == null) {
-                        if (jump instanceof GosubJump gosub) {
+                        if (jump instanceof Jumps.GosubJump gosub) {
                             // Not here either: record this level's frame and
                             // keep unwinding toward the enclosing block.
-                            gosub.chain.add(0, new GosubFrame(stmts, originPc));
+                            gosub.chain.add(0, new Jumps.GosubFrame(stmts, originPc));
                         }
                         throw jump;
                     }
-                    if (jump instanceof GosubJump) {
+                    if (jump instanceof Jumps.GosubJump) {
                         // RETURN from a nested-resume stretch can't rebuild
                         // the origin: mark the snapshot unsupported.
                         gosubStack.addLast(List.of());
@@ -651,13 +580,13 @@ public final class Interpreter {
                     try {
                         pc = resumeNestedLabel(stmts, path);
                         break;
-                    } catch (final GotoJump again) {
+                    } catch (final Jumps.GotoJump again) {
                         // A GOTO issued from inside the resumed stretch:
                         // resolve it against this same block.
                         jump = again;
                     }
                 }
-            } catch (final GosubReturnJump ret) {
+            } catch (final Jumps.GosubReturnJump ret) {
                 // A RETURN aiming for an origin that passes through an
                 // ENCLOSING block. Only the block that resolved the GOSUB
                 // label (frame 0) may resume the chain — everyone else
@@ -694,7 +623,7 @@ public final class Interpreter {
                 }
                 resumePc = pc - 1;
                 pc = target;
-            } catch (final ResumeJump resume) {
+            } catch (final Jumps.ResumeJump resume) {
                 if (++executed > maxStatements) {
                     throw new StepsLimitException(executed);
                 }
@@ -709,7 +638,7 @@ public final class Interpreter {
                     }
                     final Integer target = labels.get(resume.label.toLowerCase());
                     if (target == null) {
-                        throw new GotoJump(resume.label, 0);
+                        throw new Jumps.GotoJump(resume.label, 0);
                     }
                     pc = target;
                 } else if (resumePc < 0) {
@@ -722,64 +651,6 @@ public final class Interpreter {
         }
     }
 
-    /**
-     * One step on the path to a label nested inside a block statement:
-     * the statement of the enclosing list whose child list leads to the
-     * label. In the path's last step, {@code labelIndex} is the label's
-     * position inside {@code childList} (-1 for the other steps).
-     */
-    private record NestedLabelStep(Ast.Stmt stmt, List<Ast.Stmt> childList, int labelIndex) {
-    }
-
-    /**
-     * Finds a label nested inside the statements of {@code stmts} (any
-     * depth), returning the chain of enclosing statements from this
-     * block down to the list that directly holds the label — or null
-     * when the label is nowhere below this block.
-     */
-    private List<NestedLabelStep> findNestedLabel(final List<Ast.Stmt> stmts, final String label) {
-        for (final Ast.Stmt stmt : stmts) {
-            for (final List<Ast.Stmt> child : childListsOf(stmt)) {
-                for (int i = 0; i < child.size(); i++) {
-                    if (child.get(i) instanceof Ast.LabelStmt l
-                            && l.label().equalsIgnoreCase(label)) {
-                        final List<NestedLabelStep> path = new ArrayList<>();
-                        path.add(new NestedLabelStep(stmt, child, i));
-                        return path;
-                    }
-                }
-                final List<NestedLabelStep> sub = findNestedLabel(child, label);
-                if (sub != null) {
-                    sub.add(0, new NestedLabelStep(stmt, child, -1));
-                    return sub;
-                }
-            }
-        }
-        return null;
-    }
-
-    /** Statement lists nested directly inside a statement. */
-    private static List<List<Ast.Stmt>> childListsOf(final Ast.Stmt stmt) {
-        return switch (stmt) {
-            case Ast.IfStmt s -> s.elseBody().isEmpty()
-                    ? List.of(s.thenBody()) : List.of(s.thenBody(), s.elseBody());
-            case Ast.ForStmt s -> List.of(s.body());
-            case Ast.WhileStmt s -> List.of(s.body());
-            case Ast.DoStmt s -> List.of(s.body());
-            case Ast.SelectStmt s -> {
-                final List<List<Ast.Stmt>> lists = new ArrayList<>();
-                for (final Ast.CaseBranch branch : s.cases()) {
-                    lists.add(branch.body());
-                }
-                if (!s.elseBody().isEmpty()) {
-                    lists.add(s.elseBody());
-                }
-                yield lists;
-            }
-            default -> List.of();
-        };
-    }
-
     /**
      * Executes a GOTO whose target label is nested inside block
      * statements of {@code stmts}, with flat QBasic semantics: enclosing
@@ -791,14 +662,14 @@ public final class Interpreter {
      * @return the pc at which this block continues once the outermost
      *         enclosing statement finishes
      */
-    private int resumeNestedLabel(final List<Ast.Stmt> stmts, final List<NestedLabelStep> path) {
-        final NestedLabelStep last = path.get(path.size() - 1);
+    private int resumeNestedLabel(final List<Ast.Stmt> stmts, final List<LabelSearch.NestedLabelStep> path) {
+        final LabelSearch.NestedLabelStep last = path.get(path.size() - 1);
         execBlockFrom(last.childList(), last.labelIndex());
         for (int level = path.size() - 1; level >= 0; level--) {
             final Ast.Stmt enclosing = path.get(level).stmt();
             continueLoopAfterResume(enclosing);
             final List<Ast.Stmt> parentList = level == 0 ? stmts : path.get(level - 1).childList();
-            final int enclosingIndex = indexOfIdentity(parentList, enclosing);
+            final int enclosingIndex = LabelSearch.indexOfIdentity(parentList, enclosing);
             if (level == 0) {
                 return enclosingIndex + 1;
             }
@@ -809,7 +680,7 @@ public final class Interpreter {
 
     /**
      * Rebuilds a cross-block GOSUB origin on RETURN, starting at the given
-     * chain level (1 from the resolving block's GosubReturnJump catch —
+     * chain level (1 from the resolving block's Jumps.GosubReturnJump catch —
      * frame 0 is that block itself, resumed by its pc loop). The chain
      * (outermost → innermost) was captured while the GOSUB jump unwound.
      * The innermost block resumes at the statement after the GOSUB; each
@@ -817,8 +688,8 @@ public final class Interpreter {
      * iterating via {@link #continueLoopAfterResume}) and runs the rest
      * of its block — mirroring {@link #resumeNestedLabel}.
      */
-    private void resumeChain(final List<GosubFrame> chain, final int level) {
-        final GosubFrame frame = chain.get(level);
+    private void resumeChain(final List<Jumps.GosubFrame> chain, final int level) {
+        final Jumps.GosubFrame frame = chain.get(level);
         if (level == chain.size() - 1) {
             execBlockFrom(frame.block(), frame.pc());
             return;
@@ -876,14 +747,7 @@ public final class Interpreter {
         }
     }
 
-    private static int indexOfIdentity(final List<Ast.Stmt> stmts, final Ast.Stmt needle) {
-        for (int i = 0; i < stmts.size(); i++) {
-            if (stmts.get(i) == needle) {
-                return i;
-            }
-        }
-        throw new IllegalStateException("statement not found in its own block");
-    }
+
 
     private void exec(final Ast.Stmt stmt) {
         if (++executed > maxStatements) {
@@ -974,7 +838,7 @@ public final class Interpreter {
                 // ON ERROR GOTO 0 disables trapping.
                 errorHandler = "0".equals(s.label()) ? null : s.label().toLowerCase();
             }
-            case Ast.ResumeStmt s -> throw new ResumeJump(s.label(), s.next());
+            case Ast.ResumeStmt s -> throw new Jumps.ResumeJump(s.label(), s.next());
             case Ast.ColorStmt s -> {
                 final int fg = toInt(eval(s.foreground()), s.line());
                 final int bg = s.background() == null ? currentBackground()
@@ -999,7 +863,7 @@ public final class Interpreter {
                 }
             }
             case Ast.SwapStmt s -> execSwap(s);
-            case Ast.ExitStmt s -> throw new ExitJump(s.what());
+            case Ast.ExitStmt s -> throw new Jumps.ExitJump(s.what());
             case Ast.StaticStmt s -> {
                 // STATIC name, ...: these locals persist across calls of
                 // the current procedure.
@@ -1217,7 +1081,7 @@ public final class Interpreter {
             case Ast.IgnoredStmt s -> {
                 // Parsed but intentionally not modeled.
             }
-            case Ast.GotoStmt s -> throw new GotoJump(s.label(), s.line());
+            case Ast.GotoStmt s -> throw new Jumps.GotoJump(s.label(), s.line());
             case Ast.GosubStmt s ->
                 // Reached only through paths that bypass execBlock's pc loop.
                     throw error("GOSUB is only supported as a standalone statement", s.line());
@@ -1312,7 +1176,7 @@ public final class Interpreter {
                         }
                         execBlock(s.body());
                     }
-                } catch (final ExitJump exit) {
+                } catch (final Jumps.ExitJump exit) {
                     if (!exit.what.equals("WHILE")) {
                         throw exit;
                     }
@@ -1336,7 +1200,7 @@ public final class Interpreter {
                             break;
                         }
                     }
-                } catch (final ExitJump exit) {
+                } catch (final Jumps.ExitJump exit) {
                     if (!exit.what.equals("DO")) {
                         throw exit;
                     }
@@ -1344,7 +1208,7 @@ public final class Interpreter {
             }
             case Ast.EndStmt s -> {
                 // Program end: run() simply returns after this.
-                throw new ProgramEnd();
+                throw new Jumps.ProgramEnd();
             }
         }
     }
@@ -1554,7 +1418,7 @@ public final class Interpreter {
                 execBlock(s.body());
                 cell.set(num(cell.value, s.line()) + step);
             }
-        } catch (final ExitJump exit) {
+        } catch (final Jumps.ExitJump exit) {
             if (!exit.what.equals("FOR")) {
                 throw exit;
             }
@@ -1575,11 +1439,11 @@ public final class Interpreter {
         pushProcScope(sub.name(), sub.isStatic(), scope);
         try {
             execBlock(sub.body());
-        } catch (final ExitJump exit) {
+        } catch (final Jumps.ExitJump exit) {
             if (!exit.what.equals("SUB")) {
                 throw exit;
             }
-        } catch (final GotoJump jump) {
+        } catch (final Jumps.GotoJump jump) {
             throw error("Label not found: " + jump.label, jump.line);
         } finally {
             popProcScope();
@@ -1690,11 +1554,11 @@ public final class Interpreter {
         pushProcScope(function.name(), function.isStatic(), scope);
         try {
             execBlock(function.body());
-        } catch (final ExitJump exit) {
+        } catch (final Jumps.ExitJump exit) {
             if (!exit.what.equals("FUNCTION")) {
                 throw exit;
             }
-        } catch (final GotoJump jump) {
+        } catch (final Jumps.GotoJump jump) {
             throw error("Label not found: " + jump.label, jump.line);
         } finally {
             popProcScope();
@@ -3227,26 +3091,4 @@ public final class Interpreter {
         }
     }
 
-    /**
-     * Signals a RESUME jump out of an error handler: retry the trapped
-     * statement (label null, next false), continue after it (next true),
-     * or jump to a label.
-     */
-    private static final class ResumeJump extends RuntimeException {
-        private final String label;
-        private final boolean next;
-
-        ResumeJump(final String targetLabel, final boolean resumeNext) {
-            super(null, null, false, false);
-            label = targetLabel;
-            next = resumeNext;
-        }
-    }
-
-    /** Control-flow signal for END. */
-    private static final class ProgramEnd extends RuntimeException {
-        ProgramEnd() {
-            super(null, null, false, false);
-        }
-    }
 }
diff --git a/src/main/java/eu/svjatoslav/crtbasic/interp/Jumps.java b/src/main/java/eu/svjatoslav/crtbasic/interp/Jumps.java
new file mode 100644 (file)
index 0000000..cc70907
--- /dev/null
@@ -0,0 +1,115 @@
+package eu.svjatoslav.crtbasic.interp;
+
+import eu.svjatoslav.crtbasic.ast.Ast;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Control-flow signals. Classic BASIC control flow (GOTO into nested
+ * blocks, GOSUB/RETURN across blocks, EXIT, RESUME, END) unwinds the
+ * interpreter's Java call stack with these exceptions; each enclosing
+ * block pc loop catches the ones it resolves and rethrows the rest.
+ * All are stackless (no fillInStackTrace) — they are control flow, not
+ * failures. Grouped here so Interpreter.java reads as statement
+ * semantics, not jump plumbing.
+ */
+final class Jumps {
+
+    private Jumps() {
+        // Holder class; see the nested signals.
+    }
+
+    /**
+     * Signals an EXIT FOR/DO/WHILE/SUB/FUNCTION: unwinds to the innermost
+     * matching construct, which catches it; everything else rethrows.
+     */
+    static final class ExitJump extends RuntimeException {
+        final String what;
+
+        ExitJump(final String construct) {
+            super(null, null, false, false);
+            what = construct;
+        }
+    }
+
+    /**
+     * A GOSUB return address: the statement list the GOSUB sits in and the
+     * pc of the statement after it. RETURN only resolves when it executes in
+     * that same block — a GOSUB whose label lives in an outer block unwinds
+     * the originating block, making its continuation unreachable.
+     */
+    record GosubFrame(List<Ast.Stmt> block, int pc) {
+    }
+
+    /**
+     * Signals a GOTO jump. Each enclosing block that does not contain the
+     * target label rethrows it, which gives procedure-wide label scope.
+     */
+    static class GotoJump extends RuntimeException {
+        final String label;
+        final int line;
+
+        GotoJump(final String targetLabel, final int sourceLine) {
+            super(null, null, false, false);
+            label = targetLabel;
+            line = sourceLine;
+        }
+    }
+
+    /**
+     * A GOSUB in flight: a GOTO that additionally collects its origin
+     * context chain as it unwinds. Every execBlockFrom the jump passes
+     * through prepends its (block, pc-after-current-statement) frame, so
+     * when the label resolves the chain reads outermost → innermost and
+     * RETURN can rebuild the exact nested execution state (truth.bas:
+     * GOSUB mkneg inside FOR → SELECT → IF, label at SUB body level).
+     */
+    static final class GosubJump extends GotoJump {
+        final List<GosubFrame> chain = new ArrayList<>();
+
+        GosubJump(final String targetLabel, final int sourceLine) {
+            super(targetLabel, sourceLine);
+        }
+    }
+
+    /**
+     * A RETURN heading for a GOSUB origin in an ENCLOSING block of the
+     * same procedure. Block pc loops rethrow it until the block that
+     * resolved the GOSUB label (frame 0 of the chain) catches it and
+     * rebuilds the origin inline — keeping its own pc loop on the stack
+     * so jumps issued by the continued execution still resolve.
+     */
+    static final class GosubReturnJump extends RuntimeException {
+        final List<GosubFrame> frames;
+        final int line;
+
+        GosubReturnJump(final List<GosubFrame> frames, final int line) {
+            super(null, null, false, false);
+            this.frames = frames;
+            this.line = line;
+        }
+    }
+
+    /**
+     * Signals a RESUME jump out of an error handler: retry the trapped
+     * statement (label null, next false), continue after it (next true),
+     * or jump to a label.
+     */
+    static final class ResumeJump extends RuntimeException {
+        final String label;
+        final boolean next;
+
+        ResumeJump(final String targetLabel, final boolean resumeNext) {
+            super(null, null, false, false);
+            label = targetLabel;
+            next = resumeNext;
+        }
+    }
+
+    /** Control-flow signal for END. */
+    static final class ProgramEnd extends RuntimeException {
+        ProgramEnd() {
+            super(null, null, false, false);
+        }
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/crtbasic/interp/LabelSearch.java b/src/main/java/eu/svjatoslav/crtbasic/interp/LabelSearch.java
new file mode 100644 (file)
index 0000000..5ca0fa8
--- /dev/null
@@ -0,0 +1,86 @@
+package eu.svjatoslav.crtbasic.interp;
+
+import eu.svjatoslav.crtbasic.ast.Ast;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Label search over the statement tree: a GOTO/GOSUB target may live in
+ * the current block or nested inside IF/FOR/WHILE/DO/SELECT bodies of
+ * the same procedure (flat QBasic line semantics). Pure functions over
+ * the Ast — no interpreter state.
+ */
+final class LabelSearch {
+
+    private LabelSearch() {
+        // Utility class.
+    }
+
+    /**
+     * One step on the path to a label nested inside a block statement:
+     * the statement of the enclosing list whose child list leads to the
+     * label. In the path's last step, {@code labelIndex} is the label's
+     * position inside {@code childList} (-1 for the other steps).
+     */
+    record NestedLabelStep(Ast.Stmt stmt, List<Ast.Stmt> childList, int labelIndex) {
+    }
+
+    /**
+     * Finds a label nested inside the statements of {@code stmts} (any
+     * depth), returning the chain of enclosing statements from this
+     * block down to the list that directly holds the label — or null
+     * when the label is nowhere below this block.
+     */
+    static List<NestedLabelStep> findNestedLabel(final List<Ast.Stmt> stmts, final String label) {
+        for (final Ast.Stmt stmt : stmts) {
+            for (final List<Ast.Stmt> child : childListsOf(stmt)) {
+                for (int i = 0; i < child.size(); i++) {
+                    if (child.get(i) instanceof Ast.LabelStmt l
+                            && l.label().equalsIgnoreCase(label)) {
+                        final List<NestedLabelStep> path = new ArrayList<>();
+                        path.add(new NestedLabelStep(stmt, child, i));
+                        return path;
+                    }
+                }
+                final List<NestedLabelStep> sub = findNestedLabel(child, label);
+                if (sub != null) {
+                    sub.add(0, new NestedLabelStep(stmt, child, -1));
+                    return sub;
+                }
+            }
+        }
+        return null;
+    }
+
+    /** Statement lists nested directly inside a statement. */
+    static List<List<Ast.Stmt>> childListsOf(final Ast.Stmt stmt) {
+        return switch (stmt) {
+            case Ast.IfStmt s -> s.elseBody().isEmpty()
+                    ? List.of(s.thenBody()) : List.of(s.thenBody(), s.elseBody());
+            case Ast.ForStmt s -> List.of(s.body());
+            case Ast.WhileStmt s -> List.of(s.body());
+            case Ast.DoStmt s -> List.of(s.body());
+            case Ast.SelectStmt s -> {
+                final List<List<Ast.Stmt>> lists = new ArrayList<>();
+                for (final Ast.CaseBranch branch : s.cases()) {
+                    lists.add(branch.body());
+                }
+                if (!s.elseBody().isEmpty()) {
+                    lists.add(s.elseBody());
+                }
+                yield lists;
+            }
+            default -> List.of();
+        };
+    }
+
+    /** Identity-based index lookup (records never implement equals). */
+    static int indexOfIdentity(final List<Ast.Stmt> stmts, final Ast.Stmt needle) {
+        for (int i = 0; i < stmts.size(); i++) {
+            if (stmts.get(i) == needle) {
+                return i;
+            }
+        }
+        throw new IllegalStateException("statement not found in its own block");
+    }
+}