From d0f6e51ddd4cac1f91db2e307822e81c7d07a1b0 Mon Sep 17 00:00:00 2001 From: LadyCailin Date: Mon, 9 Mar 2026 22:07:26 +0100 Subject: [PATCH 1/3] Add debugger backend. This adds on a debugger to the evaluation loop, that allows for pausing, saving state, then resuming from saved state. --- .../com/laytonsmith/core/CallbackYield.java | 2 +- .../core/MethodScriptCompiler.java | 2 +- .../java/com/laytonsmith/core/Procedure.java | 15 +- .../java/com/laytonsmith/core/Script.java | 271 ++++++++++++++- .../laytonsmith/core/constructs/CClosure.java | 9 +- .../core/environments/Breakpoint.java | 70 ++++ .../core/environments/DebugContext.java | 276 +++++++++++++++ .../core/environments/DebugListener.java | 35 ++ .../exceptions/CRE/AbstractCREException.java | 13 +- .../exceptions/ConfigRuntimeException.java | 101 +----- .../core/exceptions/StackTraceFrame.java | 89 +++++ .../core/exceptions/StackTraceManager.java | 10 +- .../core/functions/DataHandling.java | 8 +- .../core/functions/Exceptions.java | 4 +- .../com/laytonsmith/core/functions/Web.java | 3 +- .../core/DebugInfrastructureTest.java | 322 ++++++++++++++++++ 16 files changed, 1105 insertions(+), 125 deletions(-) create mode 100644 src/main/java/com/laytonsmith/core/environments/Breakpoint.java create mode 100644 src/main/java/com/laytonsmith/core/environments/DebugContext.java create mode 100644 src/main/java/com/laytonsmith/core/environments/DebugListener.java create mode 100644 src/main/java/com/laytonsmith/core/exceptions/StackTraceFrame.java create mode 100644 src/test/java/com/laytonsmith/core/DebugInfrastructureTest.java diff --git a/src/main/java/com/laytonsmith/core/CallbackYield.java b/src/main/java/com/laytonsmith/core/CallbackYield.java index d1e1b9f3b..800915abd 100644 --- a/src/main/java/com/laytonsmith/core/CallbackYield.java +++ b/src/main/java/com/laytonsmith/core/CallbackYield.java @@ -191,7 +191,7 @@ private void cleanupCurrentStep(CallbackState state, Environment env) { if(step != null) { if(step.preparedEnv != null) { // Pop the stack trace element that prepareExecution pushed - step.preparedEnv.getEnv(GlobalEnv.class).GetStackTraceManager().popStackTraceElement(); + step.preparedEnv.getEnv(GlobalEnv.class).GetStackTraceManager().popStackTraceFrame(); step.preparedEnv = null; } if(step.cleanupAction != null) { diff --git a/src/main/java/com/laytonsmith/core/MethodScriptCompiler.java b/src/main/java/com/laytonsmith/core/MethodScriptCompiler.java index 136b3b164..a2e68b739 100644 --- a/src/main/java/com/laytonsmith/core/MethodScriptCompiler.java +++ b/src/main/java/com/laytonsmith/core/MethodScriptCompiler.java @@ -3064,7 +3064,7 @@ public static Mixed execute(ParseTree root, Environment env, MethodScriptComplet } result = script.eval(root, env); } - if(done != null) { + if(done != null && !Script.isDebuggerPaused(result)) { done.done(result.val().trim()); } return result; diff --git a/src/main/java/com/laytonsmith/core/Procedure.java b/src/main/java/com/laytonsmith/core/Procedure.java index d04fdd009..37671f070 100644 --- a/src/main/java/com/laytonsmith/core/Procedure.java +++ b/src/main/java/com/laytonsmith/core/Procedure.java @@ -20,6 +20,7 @@ import com.laytonsmith.core.exceptions.CRE.CREStackOverflowError; import com.laytonsmith.core.exceptions.ConfigCompileException; import com.laytonsmith.core.exceptions.ConfigRuntimeException; +import com.laytonsmith.core.exceptions.StackTraceFrame; import com.laytonsmith.core.exceptions.StackTraceManager; import com.laytonsmith.core.exceptions.UnhandledFlowControlException; import com.laytonsmith.core.functions.ControlFlow; @@ -198,7 +199,7 @@ public Mixed execute(List args, Environment oldEnv, Target t) { Script fakeScript = Script.GenerateScript(tree, env.getEnv(GlobalEnv.class).GetLabel(), null); StackTraceManager stManager = env.getEnv(GlobalEnv.class).GetStackTraceManager(); - stManager.addStackTraceElement(new ConfigRuntimeException.StackTraceElement("proc " + name, getTarget())); + stManager.addStackTraceFrame(new StackTraceFrame("proc " + name, getTarget())); try { Mixed result = fakeScript.eval(tree, env); if(result == null) { @@ -223,7 +224,7 @@ public Mixed execute(List args, Environment oldEnv, Target t) { } catch(StackOverflowError e) { throw new CREStackOverflowError(null, t, e); } finally { - stManager.popStackTraceElement(); + stManager.popStackTraceFrame(); } } @@ -261,8 +262,8 @@ public void definitelyNotConstant() { public Callable.PreparedCallable prepareCall(List args, Environment callerEnv, Target callTarget) { Environment env = prepareEnvironment(args, callerEnv, callTarget); StackTraceManager stManager = env.getEnv(GlobalEnv.class).GetStackTraceManager(); - stManager.addStackTraceElement( - new ConfigRuntimeException.StackTraceElement("proc " + name, getTarget())); + stManager.addStackTraceFrame( + new StackTraceFrame("proc " + name, getTarget())); return new Callable.PreparedCallable(tree, env); } @@ -469,14 +470,14 @@ private StepAction startBody(Environment callerEnv) { bodyStarted = true; procEnv = prepareEnvironment(evaluatedArgs, callerEnv, callTarget); StackTraceManager stManager = procEnv.getEnv(GlobalEnv.class).GetStackTraceManager(); - stManager.addStackTraceElement( - new ConfigRuntimeException.StackTraceElement("proc " + name, getTarget())); + stManager.addStackTraceFrame( + new StackTraceFrame("proc " + name, getTarget())); return new StepAction.Evaluate(tree, procEnv); } private void popStackTrace() { if(procEnv != null) { - procEnv.getEnv(GlobalEnv.class).GetStackTraceManager().popStackTraceElement(); + procEnv.getEnv(GlobalEnv.class).GetStackTraceManager().popStackTraceFrame(); } } } diff --git a/src/main/java/com/laytonsmith/core/Script.java b/src/main/java/com/laytonsmith/core/Script.java index 6df42b33d..0588a1ac2 100644 --- a/src/main/java/com/laytonsmith/core/Script.java +++ b/src/main/java/com/laytonsmith/core/Script.java @@ -5,11 +5,13 @@ import com.laytonsmith.PureUtilities.SimpleVersion; import com.laytonsmith.PureUtilities.SmartComment; import com.laytonsmith.PureUtilities.TermColors; +import com.laytonsmith.PureUtilities.Version; import com.laytonsmith.abstraction.MCCommandSender; import com.laytonsmith.abstraction.MCPlayer; import com.laytonsmith.core.compiler.FileOptions; import com.laytonsmith.core.compiler.TokenStream; import com.laytonsmith.core.compiler.analysis.StaticAnalysis; +import com.laytonsmith.core.constructs.CClassType; import com.laytonsmith.core.constructs.CFunction; import com.laytonsmith.core.constructs.CString; import com.laytonsmith.core.constructs.CVoid; @@ -17,11 +19,15 @@ import com.laytonsmith.core.constructs.Construct; import com.laytonsmith.core.constructs.Construct.ConstructType; import com.laytonsmith.core.constructs.IVariable; +import com.laytonsmith.core.constructs.IVariableList; import com.laytonsmith.core.constructs.Target; import com.laytonsmith.core.constructs.Token; import com.laytonsmith.core.constructs.Token.TType; import com.laytonsmith.core.constructs.Variable; +import com.laytonsmith.core.constructs.generics.GenericParameters; +import com.laytonsmith.core.constructs.generics.LeftHandGenericUse; import com.laytonsmith.core.environments.CommandHelperEnvironment; +import com.laytonsmith.core.environments.DebugContext; import com.laytonsmith.core.environments.Environment; import com.laytonsmith.core.environments.GlobalEnv; import com.laytonsmith.core.environments.StaticRuntimeEnv; @@ -31,16 +37,23 @@ import com.laytonsmith.core.exceptions.ConfigCompileException; import com.laytonsmith.core.exceptions.ConfigCompileGroupException; import com.laytonsmith.core.exceptions.ConfigRuntimeException; +import com.laytonsmith.core.exceptions.StackTraceFrame; +import com.laytonsmith.core.exceptions.StackTraceManager; import com.laytonsmith.core.exceptions.UnhandledFlowControlException; import com.laytonsmith.core.functions.ControlFlow; import com.laytonsmith.core.functions.Exceptions; import com.laytonsmith.core.functions.Function; import com.laytonsmith.core.natives.interfaces.Mixed; +import com.laytonsmith.core.objects.AccessModifier; +import com.laytonsmith.core.objects.ObjectModifier; +import com.laytonsmith.core.objects.ObjectType; +import java.net.URL; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.IdentityHashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; @@ -59,6 +72,131 @@ */ public class Script { + /** + * Sentinel value returned by eval/evalLoop when the debugger pauses execution. + * Callers should check with {@link #isDebuggerPaused(Mixed)} before using the result. + */ + private static final Mixed DEBUGGER_PAUSED +// + = new Mixed() { + @Override + public String val() { + throw new UnsupportedOperationException("Not supported yet."); + } + + @Override + public void setTarget(Target target) { + throw new UnsupportedOperationException("Not supported yet."); + } + + @Override + public Target getTarget() { + throw new UnsupportedOperationException("Not supported yet."); + } + + @Override + public Mixed clone() throws CloneNotSupportedException { + throw new UnsupportedOperationException("Not supported yet."); + } + + @Override + public String getName() { + throw new UnsupportedOperationException("Not supported yet."); + } + + @Override + public String docs() { + throw new UnsupportedOperationException("Not supported yet."); + } + + @Override + public Version since() { + throw new UnsupportedOperationException("Not supported yet."); + } + + @Override + public CClassType[] getSuperclasses() { + throw new UnsupportedOperationException("Not supported yet."); + } + + @Override + public CClassType[] getInterfaces() { + throw new UnsupportedOperationException("Not supported yet."); + } + + @Override + public ObjectType getObjectType() { + throw new UnsupportedOperationException("Not supported yet."); + } + + @Override + public Set getObjectModifiers() { + throw new UnsupportedOperationException("Not supported yet."); + } + + @Override + public AccessModifier getAccessModifier() { + throw new UnsupportedOperationException("Not supported yet."); + } + + @Override + public CClassType getContainingClass() { + throw new UnsupportedOperationException("Not supported yet."); + } + + @Override + public boolean isInstanceOf(CClassType type) { + throw new UnsupportedOperationException("Not supported yet."); + } + + @Override + public boolean isInstanceOf(CClassType type, LeftHandGenericUse lhsGenericParameters, Environment env) { + throw new UnsupportedOperationException("Not supported yet."); + } + + @Override + public boolean isInstanceOf(Class type) { + throw new UnsupportedOperationException("Not supported yet."); + } + + @Override + public CClassType typeof() { + throw new UnsupportedOperationException("Not supported yet."); + } + + @Override + public CClassType typeof(Environment env) { + throw new UnsupportedOperationException("Not supported yet."); + } + + @Override + public GenericParameters getGenericParameters() { + throw new UnsupportedOperationException("Not supported yet."); + } + + @Override + public URL getSourceJar() { + throw new UnsupportedOperationException("Not supported yet."); + } + + @Override + public Class[] seeAlso() { + throw new UnsupportedOperationException("Not supported yet."); + } + }; +// + + /** + * Returns true if the given result indicates the debugger paused execution + * rather than completing normally. + * + * @param result The return value from eval or execute + * @return true if execution was paused by the debugger + */ + public static boolean isDebuggerPaused(Mixed result) { + return result == DEBUGGER_PAUSED; + } + // See set_debug_output() public static boolean debugOutput = false; @@ -289,13 +427,37 @@ public Mixed seval(ParseTree c, final Environment env) { * @param env The environment * @return The result of evaluation */ - @SuppressWarnings("unchecked") private Mixed iterativeEval(ParseTree root, Environment env) { EvalStack stack = new EvalStack(); stack.push(new StackFrame(root, env, null, null)); - Mixed lastResult = null; - boolean hasResult = false; - StepAction.FlowControl pendingFlowControl = null; + return evalLoop(stack, null, false, null, env); + } + + /** + * Resumes execution from a previously frozen {@link DebugSnapshot}. The snapshot is + * created by the interpreter when a debug pause is triggered, and is passed to the + * {@link DebugListener#onPaused} callback. The caller must set the desired + * {@link DebugContext.StepMode} on the DebugContext before calling this method. + * + * @param snapshot The frozen state to resume from + * @return The result of evaluation, or null if execution pauses again + */ + public static Mixed resumeEval(DebugSnapshot snapshot) { + DebugContext debugCtx = snapshot.env.hasEnv(DebugContext.class) + ? snapshot.env.getEnv(DebugContext.class) : null; + if(debugCtx != null) { + debugCtx.setPaused(false, null); + debugCtx.getListener().onResumed(); + } + return evalLoop(snapshot.stack, snapshot.lastResult, snapshot.hasResult, + snapshot.pendingFlowControl, snapshot.env); + } + + @SuppressWarnings("unchecked") + private static Mixed evalLoop(EvalStack stack, Mixed lastResult, boolean hasResult, + StepAction.FlowControl pendingFlowControl, Environment env) { + DebugContext debugCtx = env.hasEnv(DebugContext.class) + ? env.getEnv(DebugContext.class) : null; while(!stack.isEmpty()) { GlobalEnv gEnv = env.getEnv(GlobalEnv.class); @@ -349,6 +511,21 @@ private Mixed iterativeEval(ParseTree root, Environment env) { ParseTree node = frame.getNode(); Mixed data = node.getData(); + // Debug pause check (after flow control is resolved, only on first visit to function nodes) + if(debugCtx != null && !debugCtx.isDisconnected() + && data instanceof CFunction + && !frame.hasBegun()) { + Target currentTarget = node.getTarget(); + int userDepth = gEnv.GetStackTraceManager().getDepth(); + if(debugCtx.shouldPause(currentTarget, userDepth)) { + DebugSnapshot snapshot = new DebugSnapshot( + stack, lastResult, hasResult, pendingFlowControl, env, currentTarget); + debugCtx.setPaused(true, currentTarget); + debugCtx.getListener().onPaused(snapshot); + return DEBUGGER_PAUSED; + } + } + // Literal / variable nodes (no children) if(data instanceof Construct co && co.getCType() != Construct.ConstructType.FUNCTION && node.numberOfChildren() == 0) { @@ -501,6 +678,10 @@ private Mixed iterativeEval(ParseTree root, Environment env) { } } + if(debugCtx != null) { + debugCtx.getListener().onCompleted(); + } + return lastResult; } @@ -979,4 +1160,86 @@ public boolean doLog() { return !nolog; } + /** + * A frozen snapshot of the interpreter's execution state, created when the debugger pauses. + * Contains everything needed to inspect the current state (variables, call stack, source + * location) and to resume execution later. + * + *

The raw interpreter state (eval stack, pending flow control, etc.) is private and + * accessible only to {@link Script}. External callers (e.g. a DAP server) can use the + * public inspection methods to read variables, call stack, and source location.

+ */ + public static final class DebugSnapshot { + + private final EvalStack stack; + private final Mixed lastResult; + private final boolean hasResult; + private final StepAction.FlowControl pendingFlowControl; + private final Environment env; + private final Target pauseTarget; + + private DebugSnapshot(EvalStack stack, Mixed lastResult, boolean hasResult, + StepAction.FlowControl pendingFlowControl, Environment env, Target pauseTarget) { + this.stack = stack; + this.lastResult = lastResult; + this.hasResult = hasResult; + this.pendingFlowControl = pendingFlowControl; + this.env = env; + this.pauseTarget = pauseTarget; + } + + /** + * Returns the source location where execution paused. + */ + public Target getPauseTarget() { + return pauseTarget; + } + + /** + * Returns the user-visible call stack (proc/closure/include frames) at the point + * of the pause. The list is ordered from innermost (most recent) to outermost. + * + * @return A list of stack trace frames + */ + public List getCallStack() { + StackTraceManager stm = env.getEnv(GlobalEnv.class).GetStackTraceManager(); + return stm.getCurrentStackTrace(); + } + + /** + * Returns the user-visible call depth at the point of the pause. This is the + * count of proc/closure/include frames, not the raw eval stack size. + * + * @return The user-visible call depth + */ + public int getUserCallDepth() { + StackTraceManager stm = env.getEnv(GlobalEnv.class).GetStackTraceManager(); + return stm.getDepth(); + } + + /** + * Returns the variables visible at the point of the pause as a name-to-value map. + * The map preserves insertion order. + * + * @return A map from variable name (including the @ prefix) to its current value + */ + public Map getVariables() { + IVariableList varList = env.getEnv(GlobalEnv.class).GetVarList(); + Map result = new LinkedHashMap<>(); + for(String name : varList.keySet()) { + IVariable iv = varList.get(name); + if(iv != null) { + result.put(name, iv.ival()); + } + } + return result; + } + + @Override + public String toString() { + return "DebugSnapshot{target=" + pauseTarget + ", depth=" + getUserCallDepth() + + ", vars=" + getVariables().size() + ", hasResult=" + hasResult + "}"; + } + } + } diff --git a/src/main/java/com/laytonsmith/core/constructs/CClosure.java b/src/main/java/com/laytonsmith/core/constructs/CClosure.java index 713b42d8b..845a664ab 100644 --- a/src/main/java/com/laytonsmith/core/constructs/CClosure.java +++ b/src/main/java/com/laytonsmith/core/constructs/CClosure.java @@ -22,6 +22,7 @@ import com.laytonsmith.core.exceptions.CRE.CREStackOverflowError; import com.laytonsmith.core.exceptions.CancelCommandException; import com.laytonsmith.core.exceptions.ConfigRuntimeException; +import com.laytonsmith.core.exceptions.StackTraceFrame; import com.laytonsmith.core.exceptions.StackTraceManager; import com.laytonsmith.core.exceptions.UnhandledFlowControlException; import com.laytonsmith.core.functions.Exceptions; @@ -125,7 +126,7 @@ public ParseTree getNode() { * instead of recursing into a new eval() call. * *

The caller is responsible for evaluating the body (via {@link #getNode()}) in the - * returned environment, and for calling {@link StackTraceManager#popStackTraceElement()} + * returned environment, and for calling {@link StackTraceManager#popStackTraceFrame()} * when done (or ensuring it's done via a cleanup mechanism).

* * @param values The argument values to bind, or null for no arguments @@ -146,7 +147,7 @@ public PreparedExecution prepareExecution(Mixed... values) throws ConfigRuntimeE return null; } StackTraceManager stManager = env.getEnv(GlobalEnv.class).GetStackTraceManager(); - stManager.addStackTraceElement(new ConfigRuntimeException.StackTraceElement("<>", getTarget())); + stManager.addStackTraceFrame(new StackTraceFrame("<>", getTarget())); CArray arguments = new CArray(node.getData().getTarget()); CArray vararg = null; @@ -335,7 +336,7 @@ protected Mixed execute(Mixed... values) throws ConfigRuntimeException, return CVoid.VOID; } StackTraceManager stManager = env.getEnv(GlobalEnv.class).GetStackTraceManager(); - stManager.addStackTraceElement(new ConfigRuntimeException.StackTraceElement("<>", getTarget())); + stManager.addStackTraceFrame(new StackTraceFrame("<>", getTarget())); try { CArray arguments = new CArray(node.getData().getTarget()); CArray vararg = null; @@ -431,7 +432,7 @@ protected Mixed execute(Mixed... values) throws ConfigRuntimeException, Logger.getLogger(CClosure.class.getName()).log(Level.SEVERE, null, ex); return CVoid.VOID; } finally { - stManager.popStackTraceElement(); + stManager.popStackTraceFrame(); } } diff --git a/src/main/java/com/laytonsmith/core/environments/Breakpoint.java b/src/main/java/com/laytonsmith/core/environments/Breakpoint.java new file mode 100644 index 000000000..a6a82246b --- /dev/null +++ b/src/main/java/com/laytonsmith/core/environments/Breakpoint.java @@ -0,0 +1,70 @@ +package com.laytonsmith.core.environments; + +import com.laytonsmith.PureUtilities.ObjectHelpers; +import com.laytonsmith.PureUtilities.ObjectHelpers.StandardField; + +import java.io.File; + +/** + * An immutable breakpoint at a specific file and line. Used for O(1) lookup in + * {@link DebugContext}'s breakpoint set via {@link #equals(Object)} and {@link #hashCode()}. + * + *

Line numbers are 1-indexed, matching {@link com.laytonsmith.core.constructs.Target#line()}. + * Currently only file+line are used for identity, but the class is designed to support + * conditional breakpoints in the future.

+ */ +public class Breakpoint { + + @StandardField + private final File file; + + @StandardField + private final int line; + + /** + * Creates a breakpoint at the given file and line. + * + * @param file The source file. Must not be null. + * @param line The 1-indexed line number. Must be positive. + */ + public Breakpoint(File file, int line) { + if(file == null) { + throw new IllegalArgumentException("Breakpoint file must not be null"); + } + if(line <= 0) { + throw new IllegalArgumentException("Breakpoint line must be positive, got " + line); + } + this.file = file; + this.line = line; + } + + /** + * Returns the source file. + */ + public File file() { + return file; + } + + /** + * Returns the 1-indexed line number. + */ + public int line() { + return line; + } + + @Override + @SuppressWarnings("EqualsWhichDoesntCheckParameterClass") + public boolean equals(Object obj) { + return ObjectHelpers.DoEquals(this, obj); + } + + @Override + public int hashCode() { + return ObjectHelpers.DoHashCode(this); + } + + @Override + public String toString() { + return file.getName() + ":" + line; + } +} diff --git a/src/main/java/com/laytonsmith/core/environments/DebugContext.java b/src/main/java/com/laytonsmith/core/environments/DebugContext.java new file mode 100644 index 000000000..d00f2be5b --- /dev/null +++ b/src/main/java/com/laytonsmith/core/environments/DebugContext.java @@ -0,0 +1,276 @@ +package com.laytonsmith.core.environments; + +import com.laytonsmith.core.constructs.Target; + +import java.io.File; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +/** + * An {@link Environment.EnvironmentImpl} that holds debugger state. When present in the + * environment, the interpreter loop checks it at the top of each iteration to determine + * whether execution should pause (breakpoint hit or step condition met). + * + *

This class is the core of the asynchronous debugging model. When the interpreter + * decides to pause, it freezes its state into a {@link DebugSnapshot}, calls + * {@link DebugListener#onPaused(DebugSnapshot)}, and returns. The caller (cmdline or + * Minecraft server) decides whether to block or return control. When the debugger sends + * a continue/step command, the caller feeds the snapshot back to the interpreter to resume.

+ */ +public class DebugContext implements Environment.EnvironmentImpl { + + /** + * The step mode determines when the interpreter next pauses. + */ + public enum StepMode { + /** Running freely - only pause on breakpoints. */ + NONE, + /** Step Into - pause on the very next frame with a source location. */ + INTO, + /** Step Over - pause on the next frame at the same or lower stack depth. */ + OVER, + /** Step Out - pause when the stack depth drops below the reference depth. */ + OUT + } + + private final Set breakpoints = new HashSet<>(); + private StepMode stepMode = StepMode.NONE; + private int stepReferenceDepth = 0; + private Target stepReferenceTarget = Target.UNKNOWN; + private Target resumeTarget = Target.UNKNOWN; + private boolean paused = false; + private boolean disconnected = false; + private boolean skippingResume = false; + private DebugListener listener; + + /** + * Creates a new DebugContext with no breakpoints and the given listener. + * + * @param listener The listener to notify when execution pauses or resumes. + * Must not be null. + */ + public DebugContext(DebugListener listener) { + if(listener == null) { + throw new IllegalArgumentException("DebugListener must not be null"); + } + this.listener = listener; + } + + /** + * Adds a breakpoint. If the breakpoint already exists, this is a no-op. + * + * @param bp The breakpoint to add + */ + public void addBreakpoint(Breakpoint bp) { + breakpoints.add(bp); + } + + /** + * Removes a breakpoint. If the breakpoint doesn't exist, this is a no-op. + * + * @param bp The breakpoint to remove + */ + public void removeBreakpoint(Breakpoint bp) { + breakpoints.remove(bp); + } + + /** + * Replaces all breakpoints for a given file. Used by DAP's setBreakpoints + * which sends the full set of breakpoints for a file at once. + * + * @param file The file whose breakpoints to replace + * @param lines The new set of line numbers + */ + public void setBreakpointsForFile(File file, Set lines) { + breakpoints.removeIf(bp -> bp.file().equals(file)); + for(int line : lines) { + breakpoints.add(new Breakpoint(file, line)); + } + } + + /** + * Returns an unmodifiable view of the current breakpoints. + */ + public Set getBreakpoints() { + return Collections.unmodifiableSet(breakpoints); + } + + /** + * Clears all breakpoints. + */ + public void clearBreakpoints() { + breakpoints.clear(); + } + + /** + * Returns the current step mode. + */ + public StepMode getStepMode() { + return stepMode; + } + + /** + * Sets the step mode and reference depth. Called when the user issues a + * step command (into/over/out) or continue. + * + * @param mode The new step mode + * @param currentDepth The user-visible call depth at the time of the command + * (count of proc/closure/include frames, not raw eval stack size) + * @param currentTarget The source target at the time of the command + */ + public void setStepMode(StepMode mode, int currentDepth, Target currentTarget) { + this.stepMode = mode; + this.stepReferenceDepth = currentDepth; + this.stepReferenceTarget = currentTarget; + } + + /** + * Returns the stack depth when the current step command was issued. + */ + public int getStepReferenceDepth() { + return stepReferenceDepth; + } + + /** + * Returns true if the debugger is currently in a paused state. + */ + public boolean isPaused() { + return paused; + } + + /** + * Sets the paused state. Called by the interpreter when it freezes. + * When pausing, also records the pause location so that on resume, + * the breakpoint check can be skipped until the interpreter moves + * past the pause point. + * + * @param paused true if pausing, false if resuming + * @param pauseTarget The source location where execution paused, + * or null when resuming + */ + public void setPaused(boolean paused, Target pauseTarget) { + this.paused = paused; + if(paused && pauseTarget != null) { + this.resumeTarget = pauseTarget; + this.skippingResume = true; + } + } + + /** + * Marks the debugger as disconnected. The interpreter will clear the + * DebugContext and run to completion. + */ + public void disconnect() { + this.disconnected = true; + this.paused = false; + this.stepMode = StepMode.NONE; + } + + /** + * Returns true if the debugger has been disconnected. + */ + public boolean isDisconnected() { + return disconnected; + } + + /** + * Returns the debug listener. + */ + public DebugListener getListener() { + return listener; + } + + /** + * Determines whether the interpreter should pause at the given source location + * and user-visible call depth. This is called at the top of each interpreter loop + * iteration. + * + *

The {@code userCallDepth} is not the raw eval stack size. It is the count + * of user-visible call boundaries on the stack - proc calls, closure calls, and includes. + * Internal functions (if, for, array_push, etc.) do not count. This matches the call + * stack a user would see in a debugger's stack trace panel.

+ * + * @param source The source location of the current frame + * @param userCallDepth The user-visible call depth (proc/closure/include nesting) + * @return true if the interpreter should pause + */ + public boolean shouldPause(Target source, int userCallDepth) { + if(disconnected) { + return false; + } + + if(source == null || source == Target.UNKNOWN + || source.file() == null || source.line() <= 0) { + return false; + } + + // Check if we would normally pause here (breakpoint or step condition). + boolean shouldStop = false; + + if(breakpoints.contains(new Breakpoint(source.file(), source.line()))) { + shouldStop = true; + } + + if(!shouldStop) { + switch(stepMode) { + case NONE: + break; + case INTO: + shouldStop = !sameSourceLine(source, stepReferenceTarget); + break; + case OVER: + shouldStop = userCallDepth <= stepReferenceDepth + && !sameSourceLine(source, stepReferenceTarget); + break; + case OUT: + shouldStop = userCallDepth < stepReferenceDepth; + break; + default: + break; + } + } + + // After resuming, suppress pauses while still at the resume source line. + // This prevents breakpoints and step conditions from re-firing before + // the interpreter has advanced. The flag is only cleared when we would + // pause at a genuinely new location. + if(shouldStop && skippingResume) { + if(sameSourceLine(source, resumeTarget)) { + return false; + } + skippingResume = false; + } + + return shouldStop; + } + + private static boolean sameSourceLine(Target a, Target b) { + if(a == b) { + return true; + } + if(a == null || b == null) { + return false; + } + if(a.line() != b.line()) { + return false; + } + if(a.file() == null || b.file() == null) { + return a.file() == b.file(); + } + return a.file().equals(b.file()); + } + + @Override + public Environment.EnvironmentImpl clone() throws CloneNotSupportedException { + // Debug context is shared across environment clones - all frames in a single + // execution unit share the same debugger state. + return this; + } + + @Override + public String toString() { + return "DebugContext{stepMode=" + stepMode + ", paused=" + paused + + ", breakpoints=" + breakpoints.size() + ", disconnected=" + disconnected + "}"; + } +} diff --git a/src/main/java/com/laytonsmith/core/environments/DebugListener.java b/src/main/java/com/laytonsmith/core/environments/DebugListener.java new file mode 100644 index 000000000..946eddf38 --- /dev/null +++ b/src/main/java/com/laytonsmith/core/environments/DebugListener.java @@ -0,0 +1,35 @@ +package com.laytonsmith.core.environments; + +import com.laytonsmith.core.Script; + +/** + * Callback interface for debugger events. Implementations control what happens when + * the interpreter pauses (e.g., blocking on a latch for cmdline mode, or returning + * control to the host application in embedded mode). + */ +public interface DebugListener { + + /** + * Called when the interpreter has paused at a breakpoint or step condition. + * The snapshot contains the frozen execution state and can be used to inspect + * variables and stack frames. + * + *

For cmdline mode, this method typically blocks until the DAP server + * sends a continue/step command. For embedded mode, this method returns + * immediately and the snapshot is stored for later resumption.

+ * + * @param snapshot The frozen execution state + */ + void onPaused(Script.DebugSnapshot snapshot); + + /** + * Called when the interpreter resumes execution after being paused. + */ + void onResumed(); + + /** + * Called when script execution completes (normally or via exception) + * while a debugger is attached. + */ + void onCompleted(); +} diff --git a/src/main/java/com/laytonsmith/core/exceptions/CRE/AbstractCREException.java b/src/main/java/com/laytonsmith/core/exceptions/CRE/AbstractCREException.java index 1a6693491..03cf1c4ab 100644 --- a/src/main/java/com/laytonsmith/core/exceptions/CRE/AbstractCREException.java +++ b/src/main/java/com/laytonsmith/core/exceptions/CRE/AbstractCREException.java @@ -18,6 +18,7 @@ import com.laytonsmith.core.constructs.generics.LeftHandGenericUse; import com.laytonsmith.core.environments.Environment; import com.laytonsmith.core.exceptions.ConfigRuntimeException; +import com.laytonsmith.core.exceptions.StackTraceFrame; import com.laytonsmith.core.exceptions.StackTraceManager; import com.laytonsmith.core.natives.interfaces.ArrayAccess; import com.laytonsmith.core.natives.interfaces.Mixed; @@ -37,7 +38,7 @@ public abstract class AbstractCREException extends ConfigRuntimeException implem private static final Class[] EMPTY_CLASS = new Class[0]; - private List stackTrace = null; + private List stackTrace = null; public AbstractCREException(String msg, Target t) { super(msg, t); @@ -120,7 +121,7 @@ public CArray getExceptionObject() { ret.set("message", this.getMessage()); CArray stackTrace = new CArray(Target.UNKNOWN); ret.set("stackTrace", stackTrace, Target.UNKNOWN); - for(StackTraceElement e : this.getCREStackTrace()) { + for(StackTraceFrame e : this.getCREStackTrace()) { CArray element = e.getObjectFor(); stackTrace.push(element, Target.UNKNOWN); } @@ -140,13 +141,13 @@ public static AbstractCREException getFromCArray(CArray exception, Target t, Env cause = new CRECausedByWrapper((CArray) exception.get("causedBy", t)); } String message = exception.get("message", t).val(); - List st = new ArrayList<>(); + List st = new ArrayList<>(); for(Mixed consStElement : ArgumentValidation.getArray(exception.get("stackTrace", t), t).asList()) { CArray stElement = ArgumentValidation.getArray(consStElement, t); int line = ArgumentValidation.getInt32(stElement.get("line", t), t); File f = new File(stElement.get("file", t).val()); int col = ArgumentValidation.getInt32(stElement.get("col", t), t); - st.add(new StackTraceElement(stElement.get("id", t).val(), new Target(line, f, col))); + st.add(new StackTraceFrame(stElement.get("id", t).val(), new Target(line, f, col))); } // Now we have parsed everything into POJOs Class[] types = new Class[]{String.class, Target.class, Throwable.class}; @@ -297,14 +298,14 @@ public void freezeStackTraceElements(StackTraceManager manager) { * If the stacktrace was already set, this is an Error, because this should never happen in the usual case. * @param st */ - public void setStackTraceElements(List st) { + public void setStackTraceElements(List st) { if(this.stackTrace != null) { throw new RuntimeException("The stacktrace was already set, and it cannot be set again"); } this.stackTrace = st; } - public List getCREStackTrace() { + public List getCREStackTrace() { if(this.stackTrace == null) { return new ArrayList<>(); } diff --git a/src/main/java/com/laytonsmith/core/exceptions/ConfigRuntimeException.java b/src/main/java/com/laytonsmith/core/exceptions/ConfigRuntimeException.java index 02e315dd0..02782c235 100644 --- a/src/main/java/com/laytonsmith/core/exceptions/ConfigRuntimeException.java +++ b/src/main/java/com/laytonsmith/core/exceptions/ConfigRuntimeException.java @@ -14,7 +14,6 @@ import com.laytonsmith.core.Static; import com.laytonsmith.core.constructs.CArray; import com.laytonsmith.core.constructs.CClosure; -import com.laytonsmith.core.constructs.CInt; import com.laytonsmith.core.constructs.CNull; import com.laytonsmith.core.constructs.CVoid; import com.laytonsmith.core.constructs.Target; @@ -25,7 +24,6 @@ import com.laytonsmith.core.exceptions.CRE.CRECausedByWrapper; import com.laytonsmith.core.natives.interfaces.Mixed; import java.io.File; -import java.io.IOException; import java.util.ArrayList; import java.util.List; @@ -191,14 +189,14 @@ private static void HandleUncaughtException(ConfigRuntimeException e, Environmen } } - private static void PrintMessage(StringBuilder log, StringBuilder console, StringBuilder player, String type, String message, Throwable ex, List st, Target top) { + private static void PrintMessage(StringBuilder log, StringBuilder console, StringBuilder player, String type, String message, Throwable ex, List st, Target top) { log.append(type).append(message).append("\n"); console.append(TermColors.RED).append(type).append(TermColors.WHITE).append(message).append("\n"); player.append(MCChatColor.RED).append(type).append(MCChatColor.WHITE).append(message).append("\n"); if(st.isEmpty()) { - st.add(new StackTraceElement("<
>", top)); + st.add(new StackTraceFrame("<
>", top)); } - for(StackTraceElement e : st) { + for(StackTraceFrame e : st) { Target t = e.getDefinedAt(); String proc = e.getProcedureName(); File file = t.file(); @@ -239,12 +237,12 @@ private static void PrintMessage(StringBuilder log, StringBuilder console, Strin * @param optionalMessage */ @SuppressWarnings("ThrowableResultIgnored") - private static void DoReport(String message, String exceptionType, ConfigRuntimeException ex, List stacktrace, MCPlayer currentPlayer) { + private static void DoReport(String message, String exceptionType, ConfigRuntimeException ex, List stacktrace, MCPlayer currentPlayer) { String type = exceptionType; if(exceptionType == null) { type = "FATAL"; } - List st = new ArrayList<>(stacktrace); + List st = new ArrayList<>(stacktrace); if(message == null) { message = ""; } @@ -256,7 +254,7 @@ private static void DoReport(String message, String exceptionType, ConfigRuntime if(ex != null) { top = ex.getTarget(); } - for(StackTraceElement e : st) { + for(StackTraceFrame e : st) { Target t = e.getDefinedAt(); if(top == Target.UNKNOWN) { top = t; @@ -279,14 +277,14 @@ private static void DoReport(String message, String exceptionType, ConfigRuntime player.append(MCChatColor.AQUA).append("Caused by:\n"); CArray exception = ((CRECausedByWrapper) ex).getException(); CArray stackTrace = ArgumentValidation.getArray(exception.get("stackTrace", t), t); - List newSt = new ArrayList<>(); + List newSt = new ArrayList<>(); for(Mixed consElement : stackTrace.asList()) { CArray element = ArgumentValidation.getArray(consElement, t); int line = ArgumentValidation.getInt32(element.get("line", t), t); File file = new File(element.get("file", t).val()); int col = ArgumentValidation.getInt32(element.get("col", t), t); Target stElementTarget = new Target(line, file, col); - newSt.add(new StackTraceElement(element.get("id", t).val(), stElementTarget)); + newSt.add(new StackTraceFrame(element.get("id", t).val(), stElementTarget)); } String nType = exception.get("classType", t).val(); @@ -316,7 +314,7 @@ private static void DoReport(ConfigRuntimeException e, Environment env) { && e.getEnv().getEnv(CommandHelperEnvironment.class).GetPlayer() != null) { p = e.getEnv().getEnv(CommandHelperEnvironment.class).GetPlayer(); } - List st = new ArrayList<>(); + List st = new ArrayList<>(); if(e instanceof AbstractCREException) { st = ((AbstractCREException) e).getCREStackTrace(); } @@ -339,8 +337,8 @@ private static void DoReport(ConfigRuntimeException e, Environment env) { } private static void DoReport(ConfigCompileException e, MCPlayer player) { - List st = new ArrayList(); - st.add(0, new StackTraceElement("", e.getTarget())); + List st = new ArrayList(); + st.add(0, new StackTraceFrame("", e.getTarget())); DoReport(e.getMessage(), "COMPILE ERROR", null, st, player); } @@ -465,81 +463,4 @@ public String getSimpleFile() { return null; } } - - /** - * A stacktrace contains 1 or more stack trace elements. A new stacktrace element is added each time an exception - * bubbles up past a procedure. - */ - public static class StackTraceElement { - - private final String procedureName; - private Target definedAt; - - /** - * Creates a new StackTraceElement. - * - * @param procedureName The name of the procedure - * @param definedAt The code target where the procedure is defined at. - */ - public StackTraceElement(String procedureName, Target definedAt) { - this.procedureName = procedureName; - this.definedAt = definedAt; - } - - /** - * Gets the name of the procedure. - * - * @return - */ - public String getProcedureName() { - return procedureName; - } - - /** - * Gets the code target where the procedure is defined at. - * - * @return - */ - public Target getDefinedAt() { - return definedAt; - } - - @Override - public String toString() { - return procedureName + " (Defined at " + definedAt + ")"; - } - - public CArray getObjectFor() { - CArray element = CArray.GetAssociativeArray(Target.UNKNOWN); - element.set("id", getProcedureName()); - try { - String name = "Unknown file"; - if(getDefinedAt().file() != null) { - name = getDefinedAt().file().getCanonicalPath(); - } - element.set("file", name); - } catch (IOException ex) { - // This shouldn't happen, but if it does, we want to fall back to something marginally useful - String name = "Unknown file"; - if(getDefinedAt().file() != null) { - name = getDefinedAt().file().getAbsolutePath(); - } - element.set("file", name); - } - element.set("line", new CInt(getDefinedAt().line(), Target.UNKNOWN), Target.UNKNOWN); - element.set("col", new CInt(getDefinedAt().col(), Target.UNKNOWN), Target.UNKNOWN); - return element; - } - - /** - * In general, only the core elements should change this - * - * @param target - */ - void setDefinedAt(Target target) { - definedAt = target; - } - - } - } diff --git a/src/main/java/com/laytonsmith/core/exceptions/StackTraceFrame.java b/src/main/java/com/laytonsmith/core/exceptions/StackTraceFrame.java new file mode 100644 index 000000000..89ab61bff --- /dev/null +++ b/src/main/java/com/laytonsmith/core/exceptions/StackTraceFrame.java @@ -0,0 +1,89 @@ +package com.laytonsmith.core.exceptions; + +import com.laytonsmith.core.constructs.CArray; +import com.laytonsmith.core.constructs.CInt; +import com.laytonsmith.core.constructs.Target; + +import java.io.IOException; + +/** + * A single frame in a MethodScript stack trace. Each frame corresponds to a user-visible + * call boundary, such as a procedure call, closure call, or include. Internal function calls + * (if, for, array_push, etc.) do not produce stack trace frames. + */ +public class StackTraceFrame { + + private final String procedureName; + private Target definedAt; + + /** + * Creates a new StackTraceFrame. + * + * @param procedureName The name of the procedure + * @param definedAt The code target where the procedure is defined at. + */ + public StackTraceFrame(String procedureName, Target definedAt) { + this.procedureName = procedureName; + this.definedAt = definedAt; + } + + /** + * Gets the name of the procedure. + * + * @return + */ + public String getProcedureName() { + return procedureName; + } + + /** + * Gets the code target where the procedure is defined at. + * + * @return + */ + public Target getDefinedAt() { + return definedAt; + } + + @Override + public String toString() { + return procedureName + " (Defined at " + definedAt + ")"; + } + + /** + * Returns a CArray representation of this stack trace frame, + * suitable for use in MethodScript code. + * + * @return + */ + public CArray getObjectFor() { + CArray element = CArray.GetAssociativeArray(Target.UNKNOWN); + element.set("id", getProcedureName()); + try { + String name = "Unknown file"; + if(getDefinedAt().file() != null) { + name = getDefinedAt().file().getCanonicalPath(); + } + element.set("file", name); + } catch(IOException ex) { + // This shouldn't happen, but if it does, we want to fall back to something marginally useful + String name = "Unknown file"; + if(getDefinedAt().file() != null) { + name = getDefinedAt().file().getAbsolutePath(); + } + element.set("file", name); + } + element.set("line", new CInt(getDefinedAt().line(), Target.UNKNOWN), Target.UNKNOWN); + element.set("col", new CInt(getDefinedAt().col(), Target.UNKNOWN), Target.UNKNOWN); + return element; + } + + /** + * In general, only the core elements should change this. + * + * @param target + */ + void setDefinedAt(Target target) { + definedAt = target; + } +} diff --git a/src/main/java/com/laytonsmith/core/exceptions/StackTraceManager.java b/src/main/java/com/laytonsmith/core/exceptions/StackTraceManager.java index 74258ae09..5afb036c1 100644 --- a/src/main/java/com/laytonsmith/core/exceptions/StackTraceManager.java +++ b/src/main/java/com/laytonsmith/core/exceptions/StackTraceManager.java @@ -30,7 +30,7 @@ public class StackTraceManager { private static final CInt DEFAULT_MAX_DEPTH_MIXED = new CInt(DEFAULT_MAX_CALL_DEPTH, Target.UNKNOWN); - private final Stack elements = new Stack<>(); + private final Stack elements = new Stack<>(); private final GlobalEnv gEnv; /** @@ -48,7 +48,7 @@ public StackTraceManager(GlobalEnv gEnv) { * * @param element The element to be pushed on */ - public void addStackTraceElement(ConfigRuntimeException.StackTraceElement element) { + public void addStackTraceFrame(StackTraceFrame element) { elements.add(element); Mixed setting = gEnv.GetRuntimeSetting(MAX_CALL_DEPTH_SETTING, DEFAULT_MAX_DEPTH_MIXED); int maxDepth = ArgumentValidation.getInt32(setting, element.getDefinedAt(), null); @@ -60,7 +60,7 @@ public void addStackTraceElement(ConfigRuntimeException.StackTraceElement elemen /** * Pops the top stack trace trail element off. */ - public void popStackTraceElement() { + public void popStackTraceFrame() { elements.pop(); } @@ -69,8 +69,8 @@ public void popStackTraceElement() { * * @return */ - public List getCurrentStackTrace() { - List l = new ArrayList<>(elements); + public List getCurrentStackTrace() { + List l = new ArrayList<>(elements); Collections.reverse(l); return l; } diff --git a/src/main/java/com/laytonsmith/core/functions/DataHandling.java b/src/main/java/com/laytonsmith/core/functions/DataHandling.java index 6522a8f12..66925f911 100644 --- a/src/main/java/com/laytonsmith/core/functions/DataHandling.java +++ b/src/main/java/com/laytonsmith/core/functions/DataHandling.java @@ -97,6 +97,7 @@ import com.laytonsmith.core.exceptions.ConfigCompileException; import com.laytonsmith.core.exceptions.ConfigCompileGroupException; import com.laytonsmith.core.exceptions.ConfigRuntimeException; +import com.laytonsmith.core.exceptions.StackTraceFrame; import com.laytonsmith.core.exceptions.StackTraceManager; import com.laytonsmith.core.functions.ArrayHandling.array_get; import com.laytonsmith.core.functions.ArrayHandling.array_push; @@ -2347,9 +2348,8 @@ public StepResult childCompleted(Target t, IncludeState state, if(include != null) { StackTraceManager stManager = env.getEnv(GlobalEnv.class).GetStackTraceManager(); - stManager.addStackTraceElement( - new ConfigRuntimeException.StackTraceElement( - "<>", t)); + stManager.addStackTraceFrame( + new StackTraceFrame("<>", t)); state.phase = IncludeState.Phase.EVAL_INCLUDE; return new StepResult<>(new Evaluate(include.getChildAt(0)), state); } @@ -2382,7 +2382,7 @@ public StepResult childInterrupted(Target t, IncludeState state, public void cleanup(Target t, IncludeState state, Environment env) { if(state != null && state.phase == IncludeState.Phase.EVAL_INCLUDE) { - env.getEnv(GlobalEnv.class).GetStackTraceManager().popStackTraceElement(); + env.getEnv(GlobalEnv.class).GetStackTraceManager().popStackTraceFrame(); } } diff --git a/src/main/java/com/laytonsmith/core/functions/Exceptions.java b/src/main/java/com/laytonsmith/core/functions/Exceptions.java index a6e263260..5f89518f5 100644 --- a/src/main/java/com/laytonsmith/core/functions/Exceptions.java +++ b/src/main/java/com/laytonsmith/core/functions/Exceptions.java @@ -863,9 +863,9 @@ public Boolean runAsync() { @Override public Mixed exec(Target t, Environment env, GenericParameters generics, Mixed... args) throws ConfigRuntimeException { StackTraceManager stManager = env.getEnv(GlobalEnv.class).GetStackTraceManager(); - List elements = stManager.getCurrentStackTrace(); + List elements = stManager.getCurrentStackTrace(); CArray ret = new CArray(t); - for(ConfigRuntimeException.StackTraceElement e : elements) { + for(com.laytonsmith.core.exceptions.StackTraceFrame e : elements) { ret.push(e.getObjectFor(), Target.UNKNOWN); } return ret; diff --git a/src/main/java/com/laytonsmith/core/functions/Web.java b/src/main/java/com/laytonsmith/core/functions/Web.java index 8d84f4c75..21286b9a0 100644 --- a/src/main/java/com/laytonsmith/core/functions/Web.java +++ b/src/main/java/com/laytonsmith/core/functions/Web.java @@ -48,6 +48,7 @@ import com.laytonsmith.core.exceptions.CancelCommandException; import com.laytonsmith.core.exceptions.ConfigCompileException; import com.laytonsmith.core.exceptions.ConfigRuntimeException; +import com.laytonsmith.core.exceptions.StackTraceFrame; import com.laytonsmith.core.natives.interfaces.ArrayAccess; import com.laytonsmith.core.natives.interfaces.Mixed; import com.laytonsmith.tools.docgen.DocGenTemplates; @@ -447,7 +448,7 @@ public Mixed exec(final Target t, final Environment env, GenericParameters gener settings.setAuthenticationDetails(username, password); } - List st + List st = env.getEnv(GlobalEnv.class).GetStackTraceManager().getCurrentStackTrace(); env.getEnv(StaticRuntimeEnv.class).GetDaemonManager().activateThread(null); Runnable task = new Runnable() { diff --git a/src/test/java/com/laytonsmith/core/DebugInfrastructureTest.java b/src/test/java/com/laytonsmith/core/DebugInfrastructureTest.java new file mode 100644 index 000000000..64168174c --- /dev/null +++ b/src/test/java/com/laytonsmith/core/DebugInfrastructureTest.java @@ -0,0 +1,322 @@ +package com.laytonsmith.core; + +import com.laytonsmith.abstraction.Implementation; +import com.laytonsmith.core.compiler.TokenStream; +import com.laytonsmith.core.compiler.analysis.StaticAnalysis; +import com.laytonsmith.core.constructs.Target; +import com.laytonsmith.core.environments.Breakpoint; +import com.laytonsmith.core.environments.CommandHelperEnvironment; +import com.laytonsmith.core.environments.DebugContext; +import com.laytonsmith.core.environments.DebugListener; +import com.laytonsmith.core.environments.Environment; +import com.laytonsmith.core.environments.GlobalEnv; +import com.laytonsmith.core.exceptions.StackTraceFrame; +import com.laytonsmith.core.natives.interfaces.Mixed; +import com.laytonsmith.testing.StaticTest; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Before; +import org.junit.Test; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.Assert.*; + +/** + * Tests for the debug infrastructure: breakpoints, step modes, variable inspection, + * call stack inspection, and disconnect. + */ +public class DebugInfrastructureTest { + + private static Environment baseEnv; + private static Set> envs; + private static final File TEST_FILE = new File("test.ms"); + + @BeforeClass + public static void setUpClass() throws Exception { + Implementation.setServerType(Implementation.Type.TEST); + StaticTest.InstallFakeServerFrontend(); + baseEnv = Static.GenerateStandaloneEnvironment(); + baseEnv = baseEnv.cloneAndAdd(new CommandHelperEnvironment()); + envs = Environment.getDefaultEnvClasses(); + envs.add(CommandHelperEnvironment.class); + } + + @AfterClass + public static void tearDownClass() { + Implementation.forceServerType(null); + } + + @Before + public void setUp() { + baseEnv.getEnv(GlobalEnv.class).GetVarList().clear(); + } + + /** + * Compiles and executes a script with the given DebugContext attached. + * Returns null if the debugger paused execution. + */ + private Mixed executeWithDebugger(String script, DebugContext debugCtx) throws Exception { + Environment testEnv = baseEnv.cloneAndAdd(debugCtx); + testEnv.getEnv(GlobalEnv.class).GetVarList().clear(); + StaticAnalysis analysis = new StaticAnalysis(true); + analysis.setLocalEnable(false); + TokenStream tokens = MethodScriptCompiler.lex(script, testEnv, TEST_FILE, true); + ParseTree tree = MethodScriptCompiler.compile(tokens, testEnv, envs, analysis); + return MethodScriptCompiler.execute(tree, testEnv, null, null); + } + + /** + * Test DebugListener that records all events for later assertion. + */ + private static class TestDebugListener implements DebugListener { + final List pauses = new ArrayList<>(); + boolean completed = false; + int resumeCount = 0; + + @Override + public void onPaused(Script.DebugSnapshot snapshot) { + pauses.add(snapshot); + } + + @Override + public void onResumed() { + resumeCount++; + } + + @Override + public void onCompleted() { + completed = true; + } + } + + @Test + public void testBreakpointHitAndContinue() throws Exception { + String script = "@x = 1\n@y = @x + 1\n@z = @y + 1"; + TestDebugListener listener = new TestDebugListener(); + DebugContext debugCtx = new DebugContext(listener); + debugCtx.addBreakpoint(new Breakpoint(TEST_FILE, 2)); + + Mixed result = executeWithDebugger(script, debugCtx); + + // Should have paused + assertTrue(Script.isDebuggerPaused(result)); + assertEquals(1, listener.pauses.size()); + assertEquals(2, listener.pauses.get(0).getPauseTarget().line()); + + // Resume with continue (NONE = run freely) + debugCtx.setStepMode(DebugContext.StepMode.NONE, 0, Target.UNKNOWN); + Script.resumeEval(listener.pauses.get(0)); + + assertTrue(listener.completed); + assertEquals(1, listener.pauses.size()); + } + + @Test + public void testVariableInspection() throws Exception { + String script = "@x = 42\n@y = 'hello'\n@z = true"; + TestDebugListener listener = new TestDebugListener(); + DebugContext debugCtx = new DebugContext(listener); + debugCtx.addBreakpoint(new Breakpoint(TEST_FILE, 3)); + + executeWithDebugger(script, debugCtx); + + assertEquals(1, listener.pauses.size()); + Map vars = listener.pauses.get(0).getVariables(); + // @x and @y should be set; @z has not executed yet + assertEquals("42", vars.get("@x").val()); + assertEquals("hello", vars.get("@y").val()); + + // Clean up: continue to completion + debugCtx.setStepMode(DebugContext.StepMode.NONE, 0, Target.UNKNOWN); + Script.resumeEval(listener.pauses.get(0)); + assertTrue(listener.completed); + } + + @Test + public void testMultipleBreakpoints() throws Exception { + String script = "@x = 1\n@y = 2\n@z = 3"; + TestDebugListener listener = new TestDebugListener(); + DebugContext debugCtx = new DebugContext(listener); + debugCtx.addBreakpoint(new Breakpoint(TEST_FILE, 1)); + debugCtx.addBreakpoint(new Breakpoint(TEST_FILE, 3)); + + executeWithDebugger(script, debugCtx); + + // First breakpoint + assertEquals(1, listener.pauses.size()); + assertEquals(1, listener.pauses.get(0).getPauseTarget().line()); + + // Continue to next breakpoint + debugCtx.setStepMode(DebugContext.StepMode.NONE, 0, Target.UNKNOWN); + Script.resumeEval(listener.pauses.get(0)); + + assertEquals(2, listener.pauses.size()); + assertEquals(3, listener.pauses.get(1).getPauseTarget().line()); + + // Continue to completion + debugCtx.setStepMode(DebugContext.StepMode.NONE, 0, Target.UNKNOWN); + Script.resumeEval(listener.pauses.get(1)); + assertTrue(listener.completed); + } + + @Test + public void testDisconnect() throws Exception { + String script = "@x = 1\n@y = 2\n@z = 3"; + TestDebugListener listener = new TestDebugListener(); + DebugContext debugCtx = new DebugContext(listener); + debugCtx.addBreakpoint(new Breakpoint(TEST_FILE, 1)); + debugCtx.addBreakpoint(new Breakpoint(TEST_FILE, 2)); + debugCtx.addBreakpoint(new Breakpoint(TEST_FILE, 3)); + + executeWithDebugger(script, debugCtx); + + // Hit first breakpoint + assertEquals(1, listener.pauses.size()); + + // Disconnect and resume - should run to completion without more pauses + debugCtx.disconnect(); + Script.resumeEval(listener.pauses.get(0)); + + assertTrue(listener.completed); + assertEquals(1, listener.pauses.size()); + } + + @Test + public void testStepIntoProc() throws Exception { + String script = "proc _foo() {\n\t@a = 10\n\treturn(@a)\n}\n@result = _foo()\n@done = true"; + TestDebugListener listener = new TestDebugListener(); + DebugContext debugCtx = new DebugContext(listener); + debugCtx.addBreakpoint(new Breakpoint(TEST_FILE, 5)); + + executeWithDebugger(script, debugCtx); + + // Should pause at line 5 (_foo() call) + assertEquals(1, listener.pauses.size()); + Script.DebugSnapshot pause1 = listener.pauses.get(0); + assertEquals(5, pause1.getPauseTarget().line()); + + // Step into + debugCtx.setStepMode(DebugContext.StepMode.INTO, + pause1.getUserCallDepth(), pause1.getPauseTarget()); + Script.resumeEval(pause1); + + // Should pause inside the proc body (line 2) + assertEquals(2, listener.pauses.size()); + Script.DebugSnapshot pause2 = listener.pauses.get(1); + assertEquals(2, pause2.getPauseTarget().line()); + + // Continue to completion + debugCtx.setStepMode(DebugContext.StepMode.NONE, 0, Target.UNKNOWN); + Script.resumeEval(pause2); + assertTrue(listener.completed); + } + + @Test + public void testStepOverProc() throws Exception { + String script = "proc _foo() {\n\t@a = 10\n\treturn(@a)\n}\n@result = _foo()\n@done = true"; + TestDebugListener listener = new TestDebugListener(); + DebugContext debugCtx = new DebugContext(listener); + debugCtx.addBreakpoint(new Breakpoint(TEST_FILE, 5)); + + executeWithDebugger(script, debugCtx); + + Script.DebugSnapshot pause1 = listener.pauses.get(0); + assertEquals(5, pause1.getPauseTarget().line()); + + // Step over + debugCtx.setStepMode(DebugContext.StepMode.OVER, + pause1.getUserCallDepth(), pause1.getPauseTarget()); + Script.resumeEval(pause1); + + // Should pause at line 6, having skipped into the proc + assertEquals(2, listener.pauses.size()); + Script.DebugSnapshot pause2 = listener.pauses.get(1); + assertEquals(6, pause2.getPauseTarget().line()); + + // Continue to completion + debugCtx.setStepMode(DebugContext.StepMode.NONE, 0, Target.UNKNOWN); + Script.resumeEval(pause2); + assertTrue(listener.completed); + } + + @Test + public void testStepOut() throws Exception { + String script = "proc _foo() {\n\t@a = 10\n\treturn(@a)\n}\n@result = _foo()\n@done = true"; + TestDebugListener listener = new TestDebugListener(); + DebugContext debugCtx = new DebugContext(listener); + debugCtx.addBreakpoint(new Breakpoint(TEST_FILE, 2)); + + executeWithDebugger(script, debugCtx); + + // Should pause inside proc at line 2 + assertEquals(1, listener.pauses.size()); + Script.DebugSnapshot pause1 = listener.pauses.get(0); + assertEquals(2, pause1.getPauseTarget().line()); + int depthInside = pause1.getUserCallDepth(); + assertTrue("Should be inside proc (depth > 0)", depthInside > 0); + + // Step out + debugCtx.setStepMode(DebugContext.StepMode.OUT, + depthInside, pause1.getPauseTarget()); + Script.resumeEval(pause1); + + // Should pause after returning from the proc + assertEquals(2, listener.pauses.size()); + Script.DebugSnapshot pause2 = listener.pauses.get(1); + assertTrue("Should be at lower depth after step out", + pause2.getUserCallDepth() < depthInside); + + // Continue to completion + debugCtx.setStepMode(DebugContext.StepMode.NONE, 0, Target.UNKNOWN); + Script.resumeEval(pause2); + assertTrue(listener.completed); + } + + @Test + public void testCallStackInspection() throws Exception { + String script = "proc _foo() {\n\t@a = 10\n\treturn(@a)\n}\n@result = _foo()"; + TestDebugListener listener = new TestDebugListener(); + DebugContext debugCtx = new DebugContext(listener); + debugCtx.addBreakpoint(new Breakpoint(TEST_FILE, 2)); + + executeWithDebugger(script, debugCtx); + + assertEquals(1, listener.pauses.size()); + Script.DebugSnapshot pause = listener.pauses.get(0); + List callStack = pause.getCallStack(); + assertFalse("Call stack should not be empty inside proc", callStack.isEmpty()); + // Procedure names in the stack trace include the "proc " prefix + assertTrue("Expected proc name to contain _foo", + callStack.get(0).getProcedureName().contains("_foo")); + + // Clean up + debugCtx.setStepMode(DebugContext.StepMode.NONE, 0, Target.UNKNOWN); + Script.resumeEval(pause); + } + + @Test + public void testResumeCount() throws Exception { + String script = "@x = 1\n@y = 2\n@z = 3"; + TestDebugListener listener = new TestDebugListener(); + DebugContext debugCtx = new DebugContext(listener); + debugCtx.addBreakpoint(new Breakpoint(TEST_FILE, 1)); + debugCtx.addBreakpoint(new Breakpoint(TEST_FILE, 3)); + + executeWithDebugger(script, debugCtx); + assertEquals(0, listener.resumeCount); + + debugCtx.setStepMode(DebugContext.StepMode.NONE, 0, Target.UNKNOWN); + Script.resumeEval(listener.pauses.get(0)); + assertEquals(1, listener.resumeCount); + + debugCtx.setStepMode(DebugContext.StepMode.NONE, 0, Target.UNKNOWN); + Script.resumeEval(listener.pauses.get(1)); + assertEquals(2, listener.resumeCount); + assertTrue(listener.completed); + } +} From f3138c99f6ce5bb96a27d4b0938e9b2a9a064e20 Mon Sep 17 00:00:00 2001 From: LadyCailin Date: Sat, 14 Mar 2026 00:16:47 +0100 Subject: [PATCH 2/3] DAP debug server, multi-thread debugging, and iterative interpreter debug support - Add MSDebugServer implementing the Debug Adapter Protocol over TCP, with launch/attach modes, breakpoints, step-over/step-in/step-out, variable inspection, exception breakpoints, and watch expressions - Add multi-thread DAP support: register/unregister threads, per-thread pause states, sync and async stepping modes (sync blocks in place, async snapshots state and resumes on a new thread) - Refactor DebugContext into a full thread-aware debug state manager with per-thread StepMode, ThreadDebugState, and a thread registry for DAP - Add DaemonManager lifecycle listeners and thread-aware waitForThreads, so the debug session stays alive while background threads run - Extract spawnExecutionThread() to centralize execution thread lifecycle (run, await daemons, signal completion) in one place - Fix StackTraceManager thread affinity: remove isDebugAdopted flag so background threads (x_new_thread) get their own STM instead of sharing the main thread's, which was corrupting call depth for step-over - Fix skippingResume flag: clear unconditionally on source line change rather than requiring shouldStop=true, which blocked step-over returns - Add StackTraceFrame.getTarget() for debugger source mapping - Add Breakpoint condition/hitCount/logMessage support - Wire up cmdline interpreter (--debug flag) and lang server for DAP - Add DAPTestHarness and dual sync/async integration tests for step-over and multi-thread step-over scenarios - Add debugger dependency (lsp4j.debug) to pom.xml --- pom.xml | 25 + .../PureUtilities/DaemonManager.java | 80 +- .../com/laytonsmith/core/FlowFunction.java | 18 + src/main/java/com/laytonsmith/core/Main.java | 82 +- .../java/com/laytonsmith/core/Procedure.java | 21 +- .../java/com/laytonsmith/core/Script.java | 147 ++- .../java/com/laytonsmith/core/StackFrame.java | 9 +- .../laytonsmith/core/constructs/CClosure.java | 98 +- .../core/environments/Breakpoint.java | 81 +- .../core/environments/DebugContext.java | 509 ++++++++-- .../core/environments/DebugListener.java | 32 +- .../core/environments/GlobalEnv.java | 37 +- .../core/environments/LivePausedState.java | 100 ++ .../core/environments/PausedState.java | 59 ++ .../core/environments/ThreadDebugState.java | 81 ++ .../core/exceptions/StackTraceFrame.java | 24 + .../core/exceptions/StackTraceManager.java | 1 + .../core/functions/Exceptions.java | 62 +- .../com/laytonsmith/core/functions/Meta.java | 51 + .../laytonsmith/core/functions/Threading.java | 4 +- .../core/natives/interfaces/Callable.java | 15 +- .../com/laytonsmith/tools/Interpreter.java | 178 +++- .../tools/debugger/MSDebugServer.java | 907 ++++++++++++++++++ .../laytonsmith/tools/langserv/LangServ.java | 10 + .../tools/langserv/LangServModel.java | 8 +- .../core/DebugInfrastructureTest.java | 395 +++++++- 26 files changed, 2741 insertions(+), 293 deletions(-) create mode 100644 src/main/java/com/laytonsmith/core/environments/LivePausedState.java create mode 100644 src/main/java/com/laytonsmith/core/environments/PausedState.java create mode 100644 src/main/java/com/laytonsmith/core/environments/ThreadDebugState.java create mode 100644 src/main/java/com/laytonsmith/tools/debugger/MSDebugServer.java diff --git a/pom.xml b/pom.xml index 71290718c..c45a46c49 100644 --- a/pom.xml +++ b/pom.xml @@ -376,6 +376,11 @@ org.eclipse.lsp4j 0.22.0 + + org.eclipse.lsp4j + org.eclipse.lsp4j.debug + 0.22.0 + io.swagger.core.v3 swagger-annotations @@ -569,6 +574,8 @@ org.brotli:dec:jar:* org.eclipse.lsp4j:org.eclipse.lsp4j:jar:* org.eclipse.lsp4j:org.eclipse.lsp4j.jsonrpc:jar:* + org.eclipse.lsp4j:org.eclipse.lsp4j.debug:jar:* + org.eclipse.lsp4j:org.eclipse.lsp4j.jsonrpc.debug:jar:*