diff --git a/AGENTS.md b/AGENTS.md
index 4f32d3a48..60333b186 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -17,3 +17,15 @@ release notes, version metadata, tags, or release workflows.
If the task involves building, running, diagnosing, or transferring files to the
Windows product through a Parallels guest VM, additionally load
`.agents/skills/debug-windows-on-parallels/SKILL.md` before proceeding.
+
+## Test process lifecycle and cleanup
+
+Unless the user gives a specific instruction to keep a process running, any
+Lithe application started for building, testing, debugging, previewing, or
+verification must be shut down when the task or test run is complete. Clean up
+all child processes, helper processes, temporary app instances, and related
+resources, then verify that no Lithe processes remain before handing the work
+back. Do not launch duplicate Lithe instances during repeated checks, and do
+not leave test-built applications open in the user's application list. If a
+process cannot be stopped cleanly, report it explicitly and make a bounded
+best-effort cleanup before continuing.
diff --git a/docs/architecture/language-tooling.md b/docs/architecture/language-tooling.md
index 75f033eb6..bdb476041 100644
--- a/docs/architecture/language-tooling.md
+++ b/docs/architecture/language-tooling.md
@@ -196,6 +196,14 @@ marker 结果。新版本会取消旧批次并使缓存失效,因此频繁输
CodeLens、implementation 或 `java/findLinks` 请求。没有语义目标的声明不显示
图标;一个目标直接跳转,多个目标由平台 UI 显示选择列表。
+Java 测试类与方法同样不能由 UI 猜测。Tests 面板打开或刷新时,
+`LanguageToolingSessionManager` 直接调用 JDT LS 已注册的 Java Test 扩展命令
+`vscode.java.test.findTestTypesAndMethods`,将 JDT 返回的类、方法、框架和
+全限定标识投影为平台无关的测试项。UI 只展示并回传稳定标识;JUnit/TestNG 的
+Debug 启动继续由 JDT 生成项目参数,再交给 Rust Debug Core 归一化。测试发现本身
+不会创建 Debug 会话、回环 socket 或目标 JVM,关闭面板、切换项目和重载 Java
+runtime 都会取消尚未完成的发现任务并丢弃晚到结果。
+
### 当前限制
- 只支持 stdio transport,尚无 socket/TCP 或服务器自定义握手 adapter。
diff --git a/macos/Resources/IDEAIcons/debugger/attachToProcess.svg b/macos/Resources/IDEAIcons/debugger/attachToProcess.svg
new file mode 100644
index 000000000..a68a71630
--- /dev/null
+++ b/macos/Resources/IDEAIcons/debugger/attachToProcess.svg
@@ -0,0 +1,7 @@
+
+
diff --git a/macos/Resources/IDEAIcons/debugger/attachToProcess_dark.svg b/macos/Resources/IDEAIcons/debugger/attachToProcess_dark.svg
new file mode 100644
index 000000000..d498d92f8
--- /dev/null
+++ b/macos/Resources/IDEAIcons/debugger/attachToProcess_dark.svg
@@ -0,0 +1,7 @@
+
+
diff --git a/macos/Resources/IDEAIcons/debugger/db_disabled_breakpoint.svg b/macos/Resources/IDEAIcons/debugger/db_disabled_breakpoint.svg
new file mode 100644
index 000000000..931866f06
--- /dev/null
+++ b/macos/Resources/IDEAIcons/debugger/db_disabled_breakpoint.svg
@@ -0,0 +1,9 @@
+
+
diff --git a/macos/Resources/IDEAIcons/debugger/db_disabled_breakpoint_dark.svg b/macos/Resources/IDEAIcons/debugger/db_disabled_breakpoint_dark.svg
new file mode 100644
index 000000000..da739eb16
--- /dev/null
+++ b/macos/Resources/IDEAIcons/debugger/db_disabled_breakpoint_dark.svg
@@ -0,0 +1,9 @@
+
+
diff --git a/macos/Resources/IDEAIcons/debugger/db_muted_breakpoint.svg b/macos/Resources/IDEAIcons/debugger/db_muted_breakpoint.svg
new file mode 100644
index 000000000..dd1d81092
--- /dev/null
+++ b/macos/Resources/IDEAIcons/debugger/db_muted_breakpoint.svg
@@ -0,0 +1,9 @@
+
+
diff --git a/macos/Resources/IDEAIcons/debugger/db_muted_breakpoint_dark.svg b/macos/Resources/IDEAIcons/debugger/db_muted_breakpoint_dark.svg
new file mode 100644
index 000000000..906317144
--- /dev/null
+++ b/macos/Resources/IDEAIcons/debugger/db_muted_breakpoint_dark.svg
@@ -0,0 +1,9 @@
+
+
diff --git a/macos/Resources/IDEAIcons/debugger/db_set_breakpoint.svg b/macos/Resources/IDEAIcons/debugger/db_set_breakpoint.svg
new file mode 100644
index 000000000..ae82e6429
--- /dev/null
+++ b/macos/Resources/IDEAIcons/debugger/db_set_breakpoint.svg
@@ -0,0 +1,9 @@
+
+
diff --git a/macos/Resources/IDEAIcons/debugger/db_set_breakpoint_dark.svg b/macos/Resources/IDEAIcons/debugger/db_set_breakpoint_dark.svg
new file mode 100644
index 000000000..f7a5c29fb
--- /dev/null
+++ b/macos/Resources/IDEAIcons/debugger/db_set_breakpoint_dark.svg
@@ -0,0 +1,9 @@
+
+
diff --git a/macos/Resources/IDEAIcons/debugger/db_verified_breakpoint.svg b/macos/Resources/IDEAIcons/debugger/db_verified_breakpoint.svg
new file mode 100644
index 000000000..8729ad6ad
--- /dev/null
+++ b/macos/Resources/IDEAIcons/debugger/db_verified_breakpoint.svg
@@ -0,0 +1,7 @@
+
+
diff --git a/macos/Resources/IDEAIcons/debugger/db_verified_breakpoint_dark.svg b/macos/Resources/IDEAIcons/debugger/db_verified_breakpoint_dark.svg
new file mode 100644
index 000000000..4df608d51
--- /dev/null
+++ b/macos/Resources/IDEAIcons/debugger/db_verified_breakpoint_dark.svg
@@ -0,0 +1,7 @@
+
+
diff --git a/macos/Resources/IDEAIcons/debugger/debug.svg b/macos/Resources/IDEAIcons/debugger/debug.svg
new file mode 100644
index 000000000..c1279337a
--- /dev/null
+++ b/macos/Resources/IDEAIcons/debugger/debug.svg
@@ -0,0 +1,6 @@
+
+
diff --git a/macos/Resources/IDEAIcons/debugger/debug_dark.svg b/macos/Resources/IDEAIcons/debugger/debug_dark.svg
new file mode 100644
index 000000000..757d0b50d
--- /dev/null
+++ b/macos/Resources/IDEAIcons/debugger/debug_dark.svg
@@ -0,0 +1,6 @@
+
+
diff --git a/macos/Resources/IDEAIcons/debugger/evaluateExpression.svg b/macos/Resources/IDEAIcons/debugger/evaluateExpression.svg
new file mode 100644
index 000000000..20f9922cf
--- /dev/null
+++ b/macos/Resources/IDEAIcons/debugger/evaluateExpression.svg
@@ -0,0 +1,11 @@
+
+
diff --git a/macos/Resources/IDEAIcons/debugger/evaluateExpression_dark.svg b/macos/Resources/IDEAIcons/debugger/evaluateExpression_dark.svg
new file mode 100644
index 000000000..07cb20913
--- /dev/null
+++ b/macos/Resources/IDEAIcons/debugger/evaluateExpression_dark.svg
@@ -0,0 +1,11 @@
+
+
diff --git a/macos/Resources/IDEAIcons/debugger/frame.svg b/macos/Resources/IDEAIcons/debugger/frame.svg
new file mode 100644
index 000000000..f875eb8f6
--- /dev/null
+++ b/macos/Resources/IDEAIcons/debugger/frame.svg
@@ -0,0 +1,7 @@
+
+
diff --git a/macos/Resources/IDEAIcons/debugger/frame_dark.svg b/macos/Resources/IDEAIcons/debugger/frame_dark.svg
new file mode 100644
index 000000000..65bdd6ad0
--- /dev/null
+++ b/macos/Resources/IDEAIcons/debugger/frame_dark.svg
@@ -0,0 +1,7 @@
+
+
diff --git a/macos/Resources/IDEAIcons/debugger/muteBreakpoints.svg b/macos/Resources/IDEAIcons/debugger/muteBreakpoints.svg
new file mode 100644
index 000000000..547008381
--- /dev/null
+++ b/macos/Resources/IDEAIcons/debugger/muteBreakpoints.svg
@@ -0,0 +1,6 @@
+
+
diff --git a/macos/Resources/IDEAIcons/debugger/muteBreakpoints_dark.svg b/macos/Resources/IDEAIcons/debugger/muteBreakpoints_dark.svg
new file mode 100644
index 000000000..189e09c70
--- /dev/null
+++ b/macos/Resources/IDEAIcons/debugger/muteBreakpoints_dark.svg
@@ -0,0 +1,6 @@
+
+
diff --git a/macos/Resources/IDEAIcons/debugger/pause.svg b/macos/Resources/IDEAIcons/debugger/pause.svg
new file mode 100644
index 000000000..4d12f24f2
--- /dev/null
+++ b/macos/Resources/IDEAIcons/debugger/pause.svg
@@ -0,0 +1,5 @@
+
+
diff --git a/macos/Resources/IDEAIcons/debugger/pause_dark.svg b/macos/Resources/IDEAIcons/debugger/pause_dark.svg
new file mode 100644
index 000000000..1907d98f0
--- /dev/null
+++ b/macos/Resources/IDEAIcons/debugger/pause_dark.svg
@@ -0,0 +1,5 @@
+
+
diff --git a/macos/Resources/IDEAIcons/debugger/rerun.svg b/macos/Resources/IDEAIcons/debugger/rerun.svg
new file mode 100644
index 000000000..316cbd98a
--- /dev/null
+++ b/macos/Resources/IDEAIcons/debugger/rerun.svg
@@ -0,0 +1,6 @@
+
+
diff --git a/macos/Resources/IDEAIcons/debugger/rerun_dark.svg b/macos/Resources/IDEAIcons/debugger/rerun_dark.svg
new file mode 100644
index 000000000..51e754cae
--- /dev/null
+++ b/macos/Resources/IDEAIcons/debugger/rerun_dark.svg
@@ -0,0 +1,6 @@
+
+
diff --git a/macos/Resources/IDEAIcons/debugger/restartDebug.svg b/macos/Resources/IDEAIcons/debugger/restartDebug.svg
new file mode 100644
index 000000000..81c12c9a3
--- /dev/null
+++ b/macos/Resources/IDEAIcons/debugger/restartDebug.svg
@@ -0,0 +1,14 @@
+
+
diff --git a/macos/Resources/IDEAIcons/debugger/restartDebug_dark.svg b/macos/Resources/IDEAIcons/debugger/restartDebug_dark.svg
new file mode 100644
index 000000000..affff369b
--- /dev/null
+++ b/macos/Resources/IDEAIcons/debugger/restartDebug_dark.svg
@@ -0,0 +1,19 @@
+
+
diff --git a/macos/Resources/IDEAIcons/debugger/resume.svg b/macos/Resources/IDEAIcons/debugger/resume.svg
new file mode 100644
index 000000000..1ff41e9c3
--- /dev/null
+++ b/macos/Resources/IDEAIcons/debugger/resume.svg
@@ -0,0 +1,5 @@
+
+
diff --git a/macos/Resources/IDEAIcons/debugger/resume_dark.svg b/macos/Resources/IDEAIcons/debugger/resume_dark.svg
new file mode 100644
index 000000000..bf2da043e
--- /dev/null
+++ b/macos/Resources/IDEAIcons/debugger/resume_dark.svg
@@ -0,0 +1,5 @@
+
+
diff --git a/macos/Resources/IDEAIcons/debugger/run.svg b/macos/Resources/IDEAIcons/debugger/run.svg
new file mode 100644
index 000000000..dd11f50d3
--- /dev/null
+++ b/macos/Resources/IDEAIcons/debugger/run.svg
@@ -0,0 +1,4 @@
+
+
diff --git a/macos/Resources/IDEAIcons/debugger/runToCursor.svg b/macos/Resources/IDEAIcons/debugger/runToCursor.svg
new file mode 100644
index 000000000..b84cd8523
--- /dev/null
+++ b/macos/Resources/IDEAIcons/debugger/runToCursor.svg
@@ -0,0 +1,10 @@
+
+
diff --git a/macos/Resources/IDEAIcons/debugger/runToCursor_dark.svg b/macos/Resources/IDEAIcons/debugger/runToCursor_dark.svg
new file mode 100644
index 000000000..a3e1ed69d
--- /dev/null
+++ b/macos/Resources/IDEAIcons/debugger/runToCursor_dark.svg
@@ -0,0 +1,10 @@
+
+
diff --git a/macos/Resources/IDEAIcons/debugger/run_dark.svg b/macos/Resources/IDEAIcons/debugger/run_dark.svg
new file mode 100644
index 000000000..0c199c7de
--- /dev/null
+++ b/macos/Resources/IDEAIcons/debugger/run_dark.svg
@@ -0,0 +1,4 @@
+
+
diff --git a/macos/Resources/IDEAIcons/debugger/smartStepInto.svg b/macos/Resources/IDEAIcons/debugger/smartStepInto.svg
new file mode 100644
index 000000000..bf66ecd06
--- /dev/null
+++ b/macos/Resources/IDEAIcons/debugger/smartStepInto.svg
@@ -0,0 +1,8 @@
+
+
diff --git a/macos/Resources/IDEAIcons/debugger/smartStepInto_dark.svg b/macos/Resources/IDEAIcons/debugger/smartStepInto_dark.svg
new file mode 100644
index 000000000..bdc256067
--- /dev/null
+++ b/macos/Resources/IDEAIcons/debugger/smartStepInto_dark.svg
@@ -0,0 +1,8 @@
+
+
diff --git a/macos/Resources/IDEAIcons/debugger/stepInto.svg b/macos/Resources/IDEAIcons/debugger/stepInto.svg
new file mode 100644
index 000000000..16de2906c
--- /dev/null
+++ b/macos/Resources/IDEAIcons/debugger/stepInto.svg
@@ -0,0 +1,5 @@
+
+
diff --git a/macos/Resources/IDEAIcons/debugger/stepInto_dark.svg b/macos/Resources/IDEAIcons/debugger/stepInto_dark.svg
new file mode 100644
index 000000000..5cdeefbf4
--- /dev/null
+++ b/macos/Resources/IDEAIcons/debugger/stepInto_dark.svg
@@ -0,0 +1,5 @@
+
+
diff --git a/macos/Resources/IDEAIcons/debugger/stepOut.svg b/macos/Resources/IDEAIcons/debugger/stepOut.svg
new file mode 100644
index 000000000..dc21a3fe1
--- /dev/null
+++ b/macos/Resources/IDEAIcons/debugger/stepOut.svg
@@ -0,0 +1,5 @@
+
+
diff --git a/macos/Resources/IDEAIcons/debugger/stepOut_dark.svg b/macos/Resources/IDEAIcons/debugger/stepOut_dark.svg
new file mode 100644
index 000000000..9bd18dd72
--- /dev/null
+++ b/macos/Resources/IDEAIcons/debugger/stepOut_dark.svg
@@ -0,0 +1,5 @@
+
+
diff --git a/macos/Resources/IDEAIcons/debugger/stepOver.svg b/macos/Resources/IDEAIcons/debugger/stepOver.svg
new file mode 100644
index 000000000..9d6141252
--- /dev/null
+++ b/macos/Resources/IDEAIcons/debugger/stepOver.svg
@@ -0,0 +1,5 @@
+
+
diff --git a/macos/Resources/IDEAIcons/debugger/stepOver_dark.svg b/macos/Resources/IDEAIcons/debugger/stepOver_dark.svg
new file mode 100644
index 000000000..134a761a3
--- /dev/null
+++ b/macos/Resources/IDEAIcons/debugger/stepOver_dark.svg
@@ -0,0 +1,5 @@
+
+
diff --git a/macos/Resources/IDEAIcons/debugger/stop.svg b/macos/Resources/IDEAIcons/debugger/stop.svg
new file mode 100644
index 000000000..845ab7622
--- /dev/null
+++ b/macos/Resources/IDEAIcons/debugger/stop.svg
@@ -0,0 +1,4 @@
+
+
diff --git a/macos/Resources/IDEAIcons/debugger/stop_dark.svg b/macos/Resources/IDEAIcons/debugger/stop_dark.svg
new file mode 100644
index 000000000..d9ee43c30
--- /dev/null
+++ b/macos/Resources/IDEAIcons/debugger/stop_dark.svg
@@ -0,0 +1,4 @@
+
+
diff --git a/macos/Resources/IDEAIcons/debugger/threadAtBreakpoint.svg b/macos/Resources/IDEAIcons/debugger/threadAtBreakpoint.svg
new file mode 100644
index 000000000..699019dab
--- /dev/null
+++ b/macos/Resources/IDEAIcons/debugger/threadAtBreakpoint.svg
@@ -0,0 +1,4 @@
+
+
diff --git a/macos/Resources/IDEAIcons/debugger/threadCurrent.svg b/macos/Resources/IDEAIcons/debugger/threadCurrent.svg
new file mode 100644
index 000000000..cd2d85e0b
--- /dev/null
+++ b/macos/Resources/IDEAIcons/debugger/threadCurrent.svg
@@ -0,0 +1,4 @@
+
+
diff --git a/macos/Resources/IDEAIcons/debugger/threadFrozen.svg b/macos/Resources/IDEAIcons/debugger/threadFrozen.svg
new file mode 100644
index 000000000..46d578ee9
--- /dev/null
+++ b/macos/Resources/IDEAIcons/debugger/threadFrozen.svg
@@ -0,0 +1,7 @@
+
+
diff --git a/macos/Resources/IDEAIcons/debugger/threadRunning.svg b/macos/Resources/IDEAIcons/debugger/threadRunning.svg
new file mode 100644
index 000000000..24283035f
--- /dev/null
+++ b/macos/Resources/IDEAIcons/debugger/threadRunning.svg
@@ -0,0 +1,4 @@
+
+
diff --git a/macos/Resources/IDEAIcons/debugger/threadSuspended.svg b/macos/Resources/IDEAIcons/debugger/threadSuspended.svg
new file mode 100644
index 000000000..b8950ce01
--- /dev/null
+++ b/macos/Resources/IDEAIcons/debugger/threadSuspended.svg
@@ -0,0 +1,9 @@
+
+
diff --git a/macos/Resources/IDEAIcons/debugger/threads.svg b/macos/Resources/IDEAIcons/debugger/threads.svg
new file mode 100644
index 000000000..685d0333b
--- /dev/null
+++ b/macos/Resources/IDEAIcons/debugger/threads.svg
@@ -0,0 +1,9 @@
+
+
diff --git a/macos/Resources/IDEAIcons/debugger/threads_dark.svg b/macos/Resources/IDEAIcons/debugger/threads_dark.svg
new file mode 100644
index 000000000..c84342a86
--- /dev/null
+++ b/macos/Resources/IDEAIcons/debugger/threads_dark.svg
@@ -0,0 +1,9 @@
+
+
diff --git a/macos/Resources/IDEAIcons/debugger/viewBreakpoints.svg b/macos/Resources/IDEAIcons/debugger/viewBreakpoints.svg
new file mode 100644
index 000000000..e2773b687
--- /dev/null
+++ b/macos/Resources/IDEAIcons/debugger/viewBreakpoints.svg
@@ -0,0 +1,6 @@
+
+
diff --git a/macos/Resources/IDEAIcons/debugger/viewBreakpoints_dark.svg b/macos/Resources/IDEAIcons/debugger/viewBreakpoints_dark.svg
new file mode 100644
index 000000000..31c760581
--- /dev/null
+++ b/macos/Resources/IDEAIcons/debugger/viewBreakpoints_dark.svg
@@ -0,0 +1,6 @@
+
+
diff --git a/macos/Resources/IDEAIcons/debugger/watch.svg b/macos/Resources/IDEAIcons/debugger/watch.svg
new file mode 100644
index 000000000..dca586dad
--- /dev/null
+++ b/macos/Resources/IDEAIcons/debugger/watch.svg
@@ -0,0 +1,4 @@
+
+
diff --git a/macos/Resources/IDEAIcons/nodes/field.svg b/macos/Resources/IDEAIcons/nodes/field.svg
new file mode 100644
index 000000000..d1dce9d88
--- /dev/null
+++ b/macos/Resources/IDEAIcons/nodes/field.svg
@@ -0,0 +1,5 @@
+
+
diff --git a/macos/Resources/IDEAIcons/nodes/variable.svg b/macos/Resources/IDEAIcons/nodes/variable.svg
new file mode 100644
index 000000000..23a35d513
--- /dev/null
+++ b/macos/Resources/IDEAIcons/nodes/variable.svg
@@ -0,0 +1,5 @@
+
+
diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings
index ae6948fa9..63afb8db4 100644
--- a/macos/Resources/en.lproj/Localizable.strings
+++ b/macos/Resources/en.lproj/Localizable.strings
@@ -109,5 +109,22 @@
"No notifications" = "No notifications";
"Log: %@" = "Log: %@";
"Console" = "Console";
+"Debugger" = "Debugger";
+"Breakpoints" = "Breakpoints";
+"Mute breakpoints" = "Mute breakpoints";
+"Enable breakpoints" = "Enable breakpoints";
+"Resume" = "Resume";
+"Rerun" = "Rerun";
+"Stop debugging" = "Stop debugging";
+"Choose Step Target" = "Choose Step Target";
+"No callable target at this location" = "No callable target at this location";
+"Evaluate expression while paused" = "Evaluate expression while paused";
+"Connect to running JVM" = "Connect to running JVM";
+"Smart step into" = "Smart step into";
+"Clear output" = "Clear output";
+"Send input to debuggee" = "Send input to debuggee";
+"Send program input" = "Send program input";
+"No running debug process accepts standard input" = "No running debug process accepts standard input";
+"Retry debugging" = "Retry debugging";
"Pull Requests integration is under development" = "Pull Requests integration is under development";
"GitHub sign-in and pull request management are temporarily unavailable." = "GitHub sign-in and pull request management are temporarily unavailable.";
diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings
index ef34fe405..34e576d97 100644
--- a/macos/Resources/zh-Hans.lproj/Localizable.strings
+++ b/macos/Resources/zh-Hans.lproj/Localizable.strings
@@ -880,6 +880,69 @@
"Stop the current run" = "停止当前运行";
"Stop Debug" = "停止调试";
"Stop the current debug session" = "停止当前调试会话";
+"Debug: Resume" = "调试:继续";
+"Resume the paused debug session" = "继续已暂停的调试会话";
+"Debug: Step Over" = "调试:步过";
+"Execute the next source line" = "执行下一行源代码";
+"Debug: Step Into" = "调试:步入";
+"Enter the next function call" = "进入下一个函数调用";
+"Debug: Step Out" = "调试:步出";
+"Return from the current function" = "从当前函数返回";
+"View Breakpoints" = "查看断点";
+"Manage all project breakpoints" = "管理项目中的所有断点";
+"View breakpoints (⌘⇧F8)" = "查看断点(⌘⇧F8)";
+"View breakpoints" = "查看断点";
+"Mute breakpoints" = "暂停断点";
+"Enable breakpoints" = "启用断点";
+"Resume" = "恢复执行";
+"Rerun" = "重新运行";
+"Stop debugging" = "停止调试";
+"Choose Step Target" = "选择步进目标";
+"No callable target at this location" = "当前位置没有可调用目标";
+"Evaluate expression while paused" = "暂停时计算表达式";
+"Connect to running JVM" = "连接到正在运行的 JVM";
+"Smart step into" = "智能步入";
+"Clear output" = "清除输出";
+"Send input to debuggee" = "向调试进程发送输入";
+"Send program input" = "发送程序输入";
+"No running debug process accepts standard input" = "没有正在运行且接受标准输入的调试进程";
+"Retry debugging" = "重试调试";
+"Loading breakpoints…" = "正在加载断点…";
+"Manage project breakpoints without starting a debug session" = "无需启动调试会话即可管理项目断点";
+"Line Breakpoints" = "行断点";
+"Exception Breakpoints" = "异常断点";
+"Method Breakpoints" = "方法断点";
+"Field Breakpoints" = "字段断点";
+"Mute Line Breakpoints" = "暂停行断点";
+"Unmute Line Breakpoints" = "恢复行断点";
+"Click the editor gutter to add a breakpoint" = "点击编辑器左侧行号区域添加断点";
+"Add a class or method name" = "添加类名或方法名";
+"Right-click a field while paused to add a breakpoint" = "暂停时右键单击字段以添加断点";
+"Remove All" = "全部移除";
+"Disable breakpoint" = "禁用断点";
+"Enable breakpoint" = "启用断点";
+"Edit…" = "编辑…";
+"Edit exception breakpoint" = "编辑异常断点";
+"Add method breakpoint" = "添加方法断点";
+"Breakpoint actions" = "断点操作";
+"Line breakpoint actions" = "行断点操作";
+"Open %@" = "打开 %@";
+"Actions for %@" = "%@ 的操作";
+"Disable %@ exception breakpoint" = "禁用异常断点 %@";
+"Enable %@ exception breakpoint" = "启用异常断点 %@";
+"Edit %@ exception breakpoint" = "编辑异常断点 %@";
+"Disable %@ method breakpoint" = "禁用方法断点 %@";
+"Enable %@ method breakpoint" = "启用方法断点 %@";
+"Edit %@ method breakpoint" = "编辑方法断点 %@";
+"Actions for %@ method breakpoint" = "方法断点 %@ 的操作";
+"Disable %@ field breakpoint" = "禁用字段断点 %@";
+"Enable %@ field breakpoint" = "启用字段断点 %@";
+"Edit %@ field breakpoint" = "编辑字段断点 %@";
+"Actions for %@ field breakpoint" = "字段断点 %@ 的操作";
+"If: %@" = "条件:%@";
+"Hit: %@" = "命中次数:%@";
+"Verified" = "已验证";
+"Pending verification" = "等待验证";
"Open Project" = "打开项目";
"Where would you like to open the project ‘%@’?" = "你想在哪里打开项目“%@”?";
"Don't ask again" = "不再询问";
@@ -906,6 +969,8 @@
"Toggle Run" = "切换运行窗口";
"Show or hide run output" = "显示或隐藏运行输出";
"Toggle Debug" = "切换调试窗口";
+"Toggle Line Breakpoint" = "切换行断点";
+"Add or remove a breakpoint at the caret" = "在光标所在行添加或移除断点";
"Show or hide the Debug tool window" = "显示或隐藏调试工具窗口";
"Search text across the workspace" = "搜索整个工作区的文本";
"Find in File" = "在文件中查找";
@@ -944,6 +1009,8 @@
"Run configurations may be out of date" = "运行配置可能已过期";
"Project toolchain needs attention" = "项目工具链需要处理";
"Different JDK vendor selected" = "选择了不同的 JDK 发行版";
+"Project is still loading" = "项目仍在加载中";
+"Wait for the project to finish loading, then identify it again." = "请等待项目加载完成,然后重新识别。";
"Project identification complete" = "项目识别完成";
"Generated 1 runnable project entry." = "已生成 1 个可运行的项目入口。";
"Generated %lld runnable project entries." = "已生成 %lld 个可运行的项目入口。";
@@ -1231,3 +1298,5 @@
"Closing this terminal will stop its shell and any running command." = "关闭此终端将停止其 Shell 和所有正在运行的命令。";
"Log: %@" = "日志:%@";
"Console" = "控制台";
+"Debugger" = "调试器";
+"Breakpoints" = "断点";
diff --git a/macos/Sources/Lithe/Application/Composition/AppServices.swift b/macos/Sources/Lithe/Application/Composition/AppServices.swift
index 3ee615dd3..bf380afce 100644
--- a/macos/Sources/Lithe/Application/Composition/AppServices.swift
+++ b/macos/Sources/Lithe/Application/Composition/AppServices.swift
@@ -1,6 +1,7 @@
import Foundation
import LitheApplicationKernel
import LitheCoreContracts
+import LitheDebugModule
/// Platform-neutral service graph consumed by application orchestration.
/// Platform composition roots construct this graph with their own adapters.
@@ -19,6 +20,9 @@ final class AppServices {
/// Metadata-only provider catalog; providers are activated on demand.
let languageProviderCatalog: LanguageProviderCatalog
let debugLaunchConfigurationResolver: DebugLaunchConfigurationResolver
+ let debugPortAvailabilityChecker: any DebugPortAvailabilityChecking
+ let javaTestDebugLaunchService: JavaTestDebugLaunchService
+ let debugBreakpointPersistence: (any DebugBreakpointPersisting)?
let workspaceOperations: any WorkspaceOperations
let documentLifecycleDecider: any DocumentLifecycleDeciding
let javaMavenOperations: any JavaMavenOperations
@@ -52,6 +56,9 @@ final class AppServices {
languageProviderCatalogSource: any LanguageProviderCatalogSource,
languageProviderCatalogSnapshot: LanguageProviderCatalogSnapshot? = nil,
debugLaunchConfigurationResolver: DebugLaunchConfigurationResolver? = nil,
+ debugPortAvailabilityChecker: (any DebugPortAvailabilityChecking)? = nil,
+ javaTestResultServerFactory: @escaping @MainActor () -> any JavaTestResultServing,
+ debugBreakpointPersistence: (any DebugBreakpointPersisting)? = nil,
workspaceOperations: any WorkspaceOperations,
documentLifecycleDecider: any DocumentLifecycleDeciding,
javaMavenOperations: any JavaMavenOperations,
@@ -88,6 +95,13 @@ final class AppServices {
self.languageProviderCatalog = resolvedCatalog
self.debugLaunchConfigurationResolver = debugLaunchConfigurationResolver
?? DebugLaunchConfigurationResolver(fileStorage: fileStorage)
+ self.debugPortAvailabilityChecker = debugPortAvailabilityChecker
+ ?? AlwaysAvailableDebugPortChecker()
+ self.javaTestDebugLaunchService = JavaTestDebugLaunchService(
+ configurationResolver: self.debugLaunchConfigurationResolver,
+ resultServerFactory: javaTestResultServerFactory
+ )
+ self.debugBreakpointPersistence = debugBreakpointPersistence
self.workspaceOperations = workspaceOperations
self.documentLifecycleDecider = documentLifecycleDecider
self.javaMavenOperations = javaMavenOperations
diff --git a/macos/Sources/Lithe/Application/Composition/DebugFeatureGraph.swift b/macos/Sources/Lithe/Application/Composition/DebugFeatureGraph.swift
index 444708cff..f07601c69 100644
--- a/macos/Sources/Lithe/Application/Composition/DebugFeatureGraph.swift
+++ b/macos/Sources/Lithe/Application/Composition/DebugFeatureGraph.swift
@@ -1,26 +1,36 @@
import Combine
import Foundation
+import LitheCoreContracts
import LitheDebugModule
import LitheModuleAPI
@MainActor
final class DebugFeatureGraph: NSObject, DebugServiceGraph {
- let java: JavaDebugService
let adapterSessions: DebugAdapterSessionManager
- let javaFeature: JavaDebugFeatureModel
let genericFeature: GenericDebugFeatureModel
private var activityObservers: Set = []
- private var javaLease: ModuleLease?
private var adapterLease: ModuleLease?
- init(java: JavaDebugService, adapterSessions: DebugAdapterSessionManager) {
- self.java = java; self.adapterSessions = adapterSessions
- javaFeature = JavaDebugFeatureModel(service: java)
- genericFeature = GenericDebugFeatureModel(sessions: adapterSessions)
+ init(
+ adapterSessions: DebugAdapterSessionManager,
+ breakpointPersistence: (any DebugBreakpointPersisting)? = nil,
+ breakpointRelocator: (any DebugBreakpointRelocating)? = nil,
+ steppingFilterResolver: (any DebugSteppingFilterResolving)? = nil,
+ steppingFilterPersistence: (any DebugSteppingFilterPersisting)? = nil
+ ) {
+ self.adapterSessions = adapterSessions
+ genericFeature = GenericDebugFeatureModel(
+ sessions: adapterSessions,
+ breakpointPersistence: breakpointPersistence,
+ breakpointRelocator: breakpointRelocator,
+ steppingFilterResolver: steppingFilterResolver,
+ steppingFilterPersistence: steppingFilterPersistence
+ )
}
- var isActive: Bool { java.state != .idle || !adapterSessions.activeAdapterIDs.isEmpty }
- var javaFeatureTarget: any JavaDebugFeatureTarget { javaFeature }
+ var isActive: Bool {
+ adapterSessions.sessionSummaries.contains(where: \.isRunning)
+ }
var genericFeatureTarget: any GenericDebugFeatureTarget { genericFeature }
var hasActiveDebugWork: Bool { isActive }
func activate(context: ModuleContext) {
@@ -31,12 +41,14 @@ final class DebugFeatureGraph: NSObject, DebugServiceGraph {
}
func configureModuleLeases(acquire: @escaping @MainActor (String) -> ModuleLease) {
- java.$state.map { $0 != .idle }.removeDuplicates().sink { [weak self] active in
- guard let self else { return }
- if active, javaLease == nil { javaLease = acquire("Java debug session is active") }
- if !active { javaLease?.release(); javaLease = nil }
- }.store(in: &activityObservers)
- genericFeature.$state.map { ![.idle, .terminated, .failed].contains($0) }
+ let activeFeature = genericFeature.$state.map {
+ ![.idle, .terminated, .failed].contains($0)
+ }
+ let activeSessions = adapterSessions.$sessionSummaries.map { summaries in
+ summaries.contains(where: \.isRunning)
+ }
+ Publishers.CombineLatest(activeFeature, activeSessions)
+ .map { $0 || $1 }
.removeDuplicates().sink { [weak self] active in
guard let self else { return }
if active, adapterLease == nil { adapterLease = acquire("Debug adapter session is active") }
@@ -45,8 +57,7 @@ final class DebugFeatureGraph: NSObject, DebugServiceGraph {
}
func stop() {
- java.stop(); adapterSessions.stopAll()
- javaLease?.release(); javaLease = nil
+ adapterSessions.stopAll()
adapterLease?.release(); adapterLease = nil
activityObservers.removeAll()
}
diff --git a/macos/Sources/Lithe/Application/Features/DebugAutomaticExpressionProjection.swift b/macos/Sources/Lithe/Application/Features/DebugAutomaticExpressionProjection.swift
new file mode 100644
index 000000000..fcdcb4c09
--- /dev/null
+++ b/macos/Sources/Lithe/Application/Features/DebugAutomaticExpressionProjection.swift
@@ -0,0 +1,104 @@
+import Foundation
+
+/// Extracts source-referenced Java expressions for automatic debugger
+/// inspection. The projection is intentionally deterministic and bounded so
+/// selecting a stack frame cannot trigger an unbounded batch of evaluations.
+enum DebugAutomaticExpressionProjection {
+ static let maximumExpressions = 8
+
+ private static let javaKeywords: Set = [
+ "abstract", "assert", "boolean", "break", "byte", "case", "catch",
+ "char", "class", "const", "continue", "default", "do", "double",
+ "else", "enum", "extends", "false", "final", "finally", "float",
+ "for", "goto", "if", "implements", "import", "instanceof", "int",
+ "interface", "long", "native", "new", "null", "package", "private",
+ "protected", "public", "record", "return", "short", "static", "strictfp",
+ "super", "switch", "synchronized", "this", "throw", "throws", "transient",
+ "true", "try", "var", "void", "volatile", "while", "yield"
+ ]
+
+ static func javaExpressions(forLine line: Int, in source: NSString) -> [String] {
+ guard let lineRange = lineRange(for: line, in: source) else { return [] }
+ let lineSource = source.substring(with: lineRange) as NSString
+ var values: [String] = []
+ var known = Set()
+ var location = 0
+ while location < lineSource.length, values.count < maximumExpressions {
+ guard isIdentifierStart(at: location, in: lineSource) else {
+ location += 1
+ continue
+ }
+ let start = location
+ location += 1
+ while location < lineSource.length,
+ isIdentifierCharacter(at: location, in: lineSource) {
+ location += 1
+ }
+ let range = NSRange(location: start, length: location - start)
+ let token = lineSource.substring(with: range)
+ let previous = previousNonWhitespaceCharacter(before: start, in: lineSource)
+ let next = nextNonWhitespaceCharacter(after: location, in: lineSource)
+ guard !javaKeywords.contains(token), previous != ".", next != "(" else { continue }
+ if known.insert(token).inserted { values.append(token) }
+ }
+ return values
+ }
+
+ private static func isIdentifierStart(at location: Int, in source: NSString) -> Bool {
+ guard let scalar = scalar(at: location, in: source) else { return false }
+ return CharacterSet.letters.union(CharacterSet(charactersIn: "_$"))
+ .contains(scalar)
+ }
+
+ private static func isIdentifierCharacter(at location: Int, in source: NSString) -> Bool {
+ guard let scalar = scalar(at: location, in: source) else { return false }
+ return CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "_$"))
+ .contains(scalar)
+ }
+
+ private static func scalar(at location: Int, in source: NSString) -> UnicodeScalar? {
+ guard location >= 0, location < source.length else { return nil }
+ return UnicodeScalar(source.character(at: location))
+ }
+
+ private static func previousNonWhitespaceCharacter(
+ before location: Int,
+ in source: NSString
+ ) -> Character? {
+ var cursor = location - 1
+ while cursor >= 0 {
+ guard let scalar = scalar(at: cursor, in: source) else { return nil }
+ if !CharacterSet.whitespacesAndNewlines.contains(scalar) { return Character(scalar) }
+ cursor -= 1
+ }
+ return nil
+ }
+
+ private static func nextNonWhitespaceCharacter(
+ after location: Int,
+ in source: NSString
+ ) -> Character? {
+ var cursor = location
+ while cursor < source.length {
+ guard let scalar = scalar(at: cursor, in: source) else { return nil }
+ if !CharacterSet.whitespacesAndNewlines.contains(scalar) { return Character(scalar) }
+ cursor += 1
+ }
+ return nil
+ }
+
+ private static func lineRange(for line: Int, in source: NSString) -> NSRange? {
+ guard line >= 0, source.length > 0 else { return nil }
+ var location = 0
+ var currentLine = 0
+ while currentLine < line, location < source.length {
+ let range = source.lineRange(for: NSRange(location: location, length: 0))
+ let next = NSMaxRange(range)
+ guard next > location else { return nil }
+ location = next
+ currentLine += 1
+ }
+ guard currentLine == line, location < source.length else { return nil }
+ return source.lineRange(for: NSRange(location: location, length: 0))
+ }
+}
diff --git a/macos/Sources/Lithe/Application/Features/JavaDebugFeatureModel.swift b/macos/Sources/Lithe/Application/Features/JavaDebugFeatureModel.swift
deleted file mode 100644
index 48898f397..000000000
--- a/macos/Sources/Lithe/Application/Features/JavaDebugFeatureModel.swift
+++ /dev/null
@@ -1,114 +0,0 @@
-import Combine
-import Foundation
-import LitheDebugModule
-
-/// UI-facing projection for Java debugger state and commands.
-@MainActor
-final class JavaDebugFeatureModel: ObservableObject, JavaDebugFeatureTarget {
- private let service: JavaDebugService
- private var observation: AnyCancellable?
-
- @Published var targetKind: JavaDebugTargetKind {
- didSet {
- guard targetKind != service.targetKind else { return }
- service.targetKind = targetKind
- }
- }
-
- @Published var remoteHost: String {
- didSet {
- guard remoteHost != service.remoteHost else { return }
- service.remoteHost = remoteHost
- }
- }
-
- @Published var remotePort: String {
- didSet {
- guard remotePort != service.remotePort else { return }
- service.remotePort = remotePort
- }
- }
-
- @Published var remoteJavaHomePath: String {
- didSet {
- guard remoteJavaHomePath != service.remoteJavaHomePath else { return }
- service.remoteJavaHomePath = remoteJavaHomePath
- }
- }
-
- init(service: JavaDebugService) {
- self.service = service
- _targetKind = Published(initialValue: service.targetKind)
- _remoteHost = Published(initialValue: service.remoteHost)
- _remotePort = Published(initialValue: service.remotePort)
- _remoteJavaHomePath = Published(initialValue: service.remoteJavaHomePath)
- observation = service.objectWillChange.sink { [weak self] _ in
- guard let self else { return }
- if self.targetKind != self.service.targetKind { self.targetKind = self.service.targetKind }
- if self.remoteHost != self.service.remoteHost { self.remoteHost = self.service.remoteHost }
- if self.remotePort != self.service.remotePort { self.remotePort = self.service.remotePort }
- if self.remoteJavaHomePath != self.service.remoteJavaHomePath {
- self.remoteJavaHomePath = self.service.remoteJavaHomePath
- }
- self.objectWillChange.send()
- }
- }
-
- var state: JavaDebugSessionState { service.state }
- var output: String { service.output }
- var inspectionTitle: String? { service.inspectionTitle }
- var inspectionOutput: String { service.inspectionOutput }
- var variables: [JavaDebugVariable] { service.variables }
- var threads: [JavaDebugThread] { service.threads }
- var callStack: [JavaDebugStackFrame] { service.callStack }
- var expandingVariableID: String? { service.expandingVariableID }
- var exceptionMessage: String? { service.exceptionMessage }
- var port: Int? { service.port }
- var breakpoints: [JavaDebugBreakpoint] { service.breakpoints }
- var runningTargetTitle: String? { service.runningTargetTitle }
- var isSessionActive: Bool { service.isSessionActive }
- var canControl: Bool { service.canControl }
-
- func pause() { service.pause() }
- func continueExecution() { service.continueExecution() }
- func stepInto() { service.stepInto() }
- func stepOver() { service.stepOver() }
- func stepOut() { service.stepOut() }
- func inspectThreads() { service.inspectThreads() }
- func inspectStack() { service.inspectStack() }
- func inspectVariables() { service.inspectVariables() }
- func evaluate(_ expression: String) { service.evaluate(expression) }
- func toggleVariable(_ variable: JavaDebugVariable) { service.toggleVariable(variable) }
- func clearOutput() { service.clearOutput() }
-
- func reset() { service.reset() }
- func start(
- fileURL: URL,
- sourceText: String,
- projectURL: URL?,
- options: RunOptions
- ) { service.start(fileURL: fileURL, sourceText: sourceText, projectURL: projectURL, options: options) }
- func startMaven(
- configuration: RunConfiguration,
- project: MavenProject,
- projectURL: URL,
- options: RunOptions,
- mavenContext: MavenLaunchContext?
- ) {
- service.startMaven(
- configuration: configuration,
- project: project,
- projectURL: projectURL,
- options: options,
- mavenContext: mavenContext
- )
- }
- func attachRemote() { service.attachRemote() }
- func toggleBreakpoint(fileURL: URL, line: Int, className: String) {
- service.toggleBreakpoint(fileURL: fileURL, line: line, className: className)
- }
- func className(for fileURL: URL, sourceText: String) -> String {
- service.className(for: fileURL, sourceText: sourceText)
- }
- func stop() { service.stop() }
-}
diff --git a/macos/Sources/Lithe/Application/Features/JavaFeatureModel.swift b/macos/Sources/Lithe/Application/Features/JavaFeatureModel.swift
index 0d3df7a5a..8f06a7485 100644
--- a/macos/Sources/Lithe/Application/Features/JavaFeatureModel.swift
+++ b/macos/Sources/Lithe/Application/Features/JavaFeatureModel.swift
@@ -66,7 +66,7 @@ enum JavaLanguageServerWorkspaceState {
}
}
-/// Owns Java-only code vision, Maven integration, and legacy Java debug behavior.
+/// Owns Java-only code vision and source structure behavior.
/// Java LSP navigation and editing are delegated to the Rust host.
@MainActor
final class JavaFeatureModel: ObservableObject {
@@ -75,11 +75,7 @@ final class JavaFeatureModel: ObservableObject {
private let operations: any JavaMavenOperations
private var documentProvider: (@MainActor () -> EditorDocument?)?
- private var caretProvider: (@MainActor () -> EditorCaret?)?
- private var notify: (@MainActor (String) -> Void)?
private var loadBlame: (@MainActor (URL) async -> [GitBlameLine])?
- private var mavenFeature: MavenFeatureModel?
- private var debugFeature: JavaDebugFeatureModel?
init(operations: any JavaMavenOperations) {
self.operations = operations
@@ -87,34 +83,18 @@ final class JavaFeatureModel: ObservableObject {
func configure(
documentProvider: @escaping @MainActor () -> EditorDocument?,
- caretProvider: @escaping @MainActor () -> EditorCaret?,
- notify: @escaping @MainActor (String) -> Void,
loadBlame: @escaping @MainActor (URL) async -> [GitBlameLine]
) {
self.documentProvider = documentProvider
- self.caretProvider = caretProvider
- self.notify = notify
self.loadBlame = loadBlame
}
- func configureRuntime(
- mavenFeature: MavenFeatureModel?,
- debugFeature: JavaDebugFeatureModel?
- ) {
- self.mavenFeature = mavenFeature
- self.debugFeature = debugFeature
- }
-
/// Explicit boundary for Java-only editor adornments and legacy services.
/// Callers can avoid scheduling Java work for every supported language.
func handles(fileURL: URL) -> Bool {
fileURL.pathExtension.lowercased() == "java"
}
- func supportsLegacyDebugging(fileURL: URL) -> Bool {
- handles(fileURL: fileURL)
- }
-
func stop() {
cancelLanguageServerPreparation()
javaCodeVisionHints = [:]
@@ -187,87 +167,6 @@ final class JavaFeatureModel: ObservableObject {
return false
}
- @discardableResult
- func startDebugging(
- currentDocument: EditorDocument?,
- workspaceURL: URL?,
- runFeature: RunFeatureModel,
- saveDocument: @escaping @MainActor (EditorDocument) throws -> Void,
- recordSave: @escaping @MainActor (EditorDocument, String) -> Void
- ) -> Bool {
- guard let debugFeature else { return false }
- if debugFeature.targetKind != .remote, let currentDocument, currentDocument.isDirty {
- do {
- let previousText = currentDocument.savedText
- try saveDocument(currentDocument)
- recordSave(currentDocument, previousText)
- } catch {
- notify?("Could not save \(currentDocument.url.lastPathComponent)")
- return false
- }
- }
- switch debugFeature.targetKind {
- case .currentFile:
- guard let currentDocument,
- currentDocument.url.pathExtension.lowercased() == "java" else {
- notify?("Open a Java file before starting Debug")
- return false
- }
- debugFeature.start(
- fileURL: currentDocument.url,
- sourceText: currentDocument.text,
- projectURL: workspaceURL,
- options: runFeature.options(for: .currentFile)
- )
- case .runConfiguration:
- guard let configuration = runFeature.selectedConfiguration,
- configuration.kind.isMavenBacked else {
- notify?("Select a Spring Boot or Maven Module configuration before starting Debug")
- return false
- }
- guard let workspaceURL, let mavenProject = mavenFeature?.project else {
- notify?("No Maven project is available for Debug")
- return false
- }
- debugFeature.startMaven(
- configuration: configuration,
- project: mavenProject,
- projectURL: workspaceURL,
- options: runFeature.options(for: configuration),
- mavenContext: mavenFeature?.launchContext
- )
- case .remote:
- debugFeature.attachRemote()
- }
- return true
- }
-
- func toggleDebugBreakpoint(
- at fileURL: URL,
- line: Int,
- documents: [EditorDocument]
- ) {
- guard let debugFeature,
- let document = documents.first(where: {
- $0.url.standardizedFileURL == fileURL.standardizedFileURL
- }),
- document.url.pathExtension.lowercased() == "java",
- line > 0 else { return }
- let className = debugFeature.className(for: document.url, sourceText: document.text)
- debugFeature.toggleBreakpoint(fileURL: document.url, line: line, className: className)
- }
-
- func toggleDebugBreakpointAtCaret() {
- guard let document = documentProvider?(),
- let caret = caretProvider?(),
- document.url.standardizedFileURL == caret.url.standardizedFileURL,
- document.url.pathExtension.lowercased() == "java" else {
- notify?("Place the caret in a Java file to set a breakpoint")
- return
- }
- toggleDebugBreakpoint(at: document.url, line: caret.line + 1, documents: [document])
- }
-
func close(_ document: EditorDocument) {
javaCodeVisionHints[document.url.standardizedFileURL] = nil
}
diff --git a/macos/Sources/Lithe/Application/Features/SpringFeatureModel.swift b/macos/Sources/Lithe/Application/Features/SpringFeatureModel.swift
index 2bd9a0d6c..89588918b 100644
--- a/macos/Sources/Lithe/Application/Features/SpringFeatureModel.swift
+++ b/macos/Sources/Lithe/Application/Features/SpringFeatureModel.swift
@@ -51,6 +51,30 @@ final class SpringFeatureModel: ObservableObject {
isIndexing = false
}
+ /// Starts a workspace index without making the caller wait for it. Opening a
+ /// project must not block build-system and run state behind Spring indexing,
+ /// which scales with the number of Java sources in the workspace.
+ func scheduleLoad(
+ workspaceURL: URL,
+ files: [URL],
+ textOverrides: [URL: String] = [:],
+ refreshDependencyMetadata: Bool = true
+ ) {
+ reloadTask?.cancel()
+ reloadTask = Task { @MainActor [weak self] in
+ // Cancellation is cooperative, so a schedule that was superseded
+ // before it started must return here instead of running a second
+ // full workspace index whose result the generation token discards.
+ guard !Task.isCancelled, let self else { return }
+ await self.load(
+ workspaceURL: workspaceURL,
+ files: files,
+ textOverrides: textOverrides,
+ refreshDependencyMetadata: refreshDependencyMetadata
+ )
+ }
+ }
+
func reset() {
reloadTask?.cancel()
reloadTask = nil
@@ -92,6 +116,14 @@ final class SpringFeatureModel: ObservableObject {
}
}
+ func scheduleLoad(workspaceURL: URL, files: [URL], textOverrides: [URL: String]) {
+ reloadTask?.cancel()
+ reloadTask = Task { @MainActor [weak self] in
+ guard let self else { return }
+ await self.load(workspaceURL: workspaceURL, files: files, textOverrides: textOverrides)
+ }
+ }
+
func handles(_ url: URL) -> Bool {
let name = url.lastPathComponent.lowercased()
return name == "application.properties"
diff --git a/macos/Sources/Lithe/Application/Features/WorkspaceFeatureModel.swift b/macos/Sources/Lithe/Application/Features/WorkspaceFeatureModel.swift
index dc411bcb6..2545af1ea 100644
--- a/macos/Sources/Lithe/Application/Features/WorkspaceFeatureModel.swift
+++ b/macos/Sources/Lithe/Application/Features/WorkspaceFeatureModel.swift
@@ -42,7 +42,7 @@ extension LitheWorkspaceModule.WorkspaceFeatureModel {
reloadProjectServices: @escaping @MainActor @Sendable () async -> Void,
refreshGit: @escaping @MainActor @Sendable () async -> Void,
updateHistoryVisibilityRules: @escaping @MainActor @Sendable (FileVisibilityRules) async -> Void,
- onSnapshotLoaded: @escaping @MainActor @Sendable (WorkspaceSnapshot, Bool) async -> Void
+ onSnapshotLoaded: @escaping @MainActor @Sendable (URL, WorkspaceSnapshot, Bool) async -> Void
) {
configureProjection(
documentsProvider: {
@@ -65,7 +65,9 @@ extension LitheWorkspaceModule.WorkspaceFeatureModel {
reloadProjectServices: reloadProjectServices,
refreshGit: refreshGit,
updateHistoryVisibilityRules: updateHistoryVisibilityRules,
- onSnapshotLoaded: onSnapshotLoaded,
+ onSnapshotLoaded: { snapshot, isInitialLoad in
+ await onSnapshotLoaded(snapshot.root.url, snapshot, isInitialLoad)
+ },
warmSearchIndex: { _, _ in },
updateSearchIndex: { _, _, _ in },
invalidateSearchIndex: { _, _ in }
diff --git a/macos/Sources/Lithe/Core/Debug/JavaTestResultServer.swift b/macos/Sources/Lithe/Core/Debug/JavaTestResultServer.swift
new file mode 100644
index 000000000..1ffd45ccb
--- /dev/null
+++ b/macos/Sources/Lithe/Core/Debug/JavaTestResultServer.swift
@@ -0,0 +1,8 @@
+import Foundation
+
+/// Platform port for the short-lived loopback server required by Java test runners.
+@MainActor
+protocol JavaTestResultServing: AnyObject {
+ func start() async throws -> UInt16
+ func stop()
+}
diff --git a/macos/Sources/Lithe/Core/Ports/PlatformUI.swift b/macos/Sources/Lithe/Core/Ports/PlatformUI.swift
index 50b47a20d..565ede549 100644
--- a/macos/Sources/Lithe/Core/Ports/PlatformUI.swift
+++ b/macos/Sources/Lithe/Core/Ports/PlatformUI.swift
@@ -4,6 +4,7 @@ import Foundation
/// Implementations may use AppKit, Qt, or another native UI toolkit.
@MainActor
protocol PlatformUI: AnyObject {
+ func activateApplication()
func chooseDirectory(title: String, prompt: String) -> URL?
func chooseFile(title: String, prompt: String) -> URL?
func revealInFileBrowser(_ url: URL)
@@ -13,6 +14,7 @@ protocol PlatformUI: AnyObject {
}
extension PlatformUI {
+ func activateApplication() {}
func startAccessingProject(_ url: URL) -> Bool { false }
func stopAccessingProject(_ url: URL) {}
}
diff --git a/macos/Sources/Lithe/Core/Ports/RuntimeLocator.swift b/macos/Sources/Lithe/Core/Ports/RuntimeLocator.swift
index 5ea4a4a91..0084f45a2 100644
--- a/macos/Sources/Lithe/Core/Ports/RuntimeLocator.swift
+++ b/macos/Sources/Lithe/Core/Ports/RuntimeLocator.swift
@@ -74,7 +74,6 @@ protocol RuntimeLocator: Sendable {
func systemMavenExecutable() -> URL?
func mavenExecutable(forHomePath path: String) -> URL?
func mavenRuntime(at executableURL: URL) -> MavenRuntimeCandidate?
- func systemJDBExecutable() -> URL?
/// The JDTLS runtime JDK bundled with the application, if present.
/// Returns the home directory URL (containing `bin/java`). Returns `nil`
/// in development builds or on platforms that do not bundle a JDK.
diff --git a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift
index f047f369a..c544c1f7f 100644
--- a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift
+++ b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift
@@ -1467,6 +1467,8 @@ struct RustCoreBridge: Sendable {
let launcherJarPath: String
let configurationDirectory: String
let lombokAgentPath: String
+ let javaDebugBundlePath: String?
+ let javaExtensionBundlePaths: [String]
}
private struct LspSessionIdentifierRequest: Encodable {
@@ -3045,7 +3047,9 @@ struct RustCoreBridge: Sendable {
LspJdtlsLaunchResourcesRequest(
launcherJarPath: $0.launcherJarURL.path,
configurationDirectory: $0.configurationDirectoryURL.path,
- lombokAgentPath: $0.lombokAgentURL.path
+ lombokAgentPath: $0.lombokAgentURL.path,
+ javaDebugBundlePath: $0.javaDebugBundleURL?.path,
+ javaExtensionBundlePaths: $0.javaExtensionBundleURLs.map(\.path)
)
},
cacheDirectory: cacheDirectoryURL?.standardizedFileURL.path,
diff --git a/macos/Sources/Lithe/Core/Rust/RustDebugProtocolCore.swift b/macos/Sources/Lithe/Core/Rust/RustDebugProtocolCore.swift
new file mode 100644
index 000000000..a2692fbd6
--- /dev/null
+++ b/macos/Sources/Lithe/Core/Rust/RustDebugProtocolCore.swift
@@ -0,0 +1,549 @@
+import Foundation
+import LitheCoreContracts
+
+extension RustCoreBridge: JavaTestDebugLaunchResolving {
+ func resolveJavaTestDebugLaunch(
+ target: JavaTestDebugLaunchTarget,
+ resultPort: UInt16
+ ) throws -> DebugLaunchConfiguration {
+ try executeResult(
+ command: "debug.javaTestLaunch",
+ payload: JavaTestDebugLaunchPayload(target: target, resultPort: resultPort)
+ ).get()
+ }
+}
+
+extension RustCoreBridge: DebugBreakpointRelocating {
+ func relocateDebugBreakpoints(
+ source: String,
+ edit: DebugSourceEdit,
+ breakpoints: [DebugSourceBreakpoint]
+ ) throws -> [DebugSourceBreakpoint] {
+ let result: DebugBreakpointRelocationResult = try executeResult(
+ command: "debug.relocateBreakpoints",
+ payload: DebugBreakpointRelocationPayload(
+ source: source,
+ edit: edit,
+ breakpoints: breakpoints
+ )
+ ).get()
+ return result.breakpoints
+ }
+}
+
+extension RustCoreBridge: DebugProtocolCore {
+ func resolveDebugSteppingFilters(
+ adapterID: String,
+ filters: DebugSteppingFilters?
+ ) throws -> DebugSteppingFilters {
+ try executeResult(
+ command: "debug.steppingFilters",
+ payload: DebugSteppingFiltersPayload(adapterID: adapterID, filters: filters)
+ ).get()
+ }
+
+ func createDebugSession(
+ sessionID: String,
+ adapterID: String,
+ rootPath: String,
+ supportsRunInTerminalRequest: Bool
+ ) throws -> DebugCoreUpdate {
+ try executeResult(
+ command: "debug.createSession",
+ payload: DebugCreateSessionPayload(
+ sessionID: sessionID,
+ adapterID: adapterID,
+ rootPath: rootPath,
+ supportsRunInTerminalRequest: supportsRunInTerminalRequest
+ )
+ ).get()
+ }
+
+ func launchDebugSession(
+ sessionID: String,
+ operationID: String,
+ configuration: DebugLaunchConfiguration
+ ) throws -> DebugCoreUpdate {
+ try executeResult(
+ command: "debug.launch",
+ payload: DebugLaunchPayload(
+ sessionID: sessionID,
+ operationID: operationID,
+ configuration: configuration
+ ),
+ operationID: operationID
+ ).get()
+ }
+
+ func setDebugBreakpoints(
+ sessionID: String,
+ sourcePath: String,
+ breakpoints: [DebugSourceBreakpoint]
+ ) throws -> DebugCoreUpdate {
+ try executeResult(
+ command: "debug.setBreakpoints",
+ payload: DebugBreakpointsPayload(
+ sessionID: sessionID,
+ sourcePath: sourcePath,
+ breakpoints: breakpoints
+ )
+ ).get()
+ }
+
+ func setDebugExceptionBreakpoints(
+ sessionID: String,
+ breakpoints: [DebugExceptionBreakpoint]
+ ) throws -> DebugCoreUpdate {
+ try executeResult(
+ command: "debug.setExceptionBreakpoints",
+ payload: DebugExceptionBreakpointsPayload(
+ sessionID: sessionID,
+ breakpoints: breakpoints
+ )
+ ).get()
+ }
+
+ func setDebugFunctionBreakpoints(
+ sessionID: String,
+ breakpoints: [DebugFunctionBreakpoint]
+ ) throws -> DebugCoreUpdate {
+ try executeResult(
+ command: "debug.setFunctionBreakpoints",
+ payload: DebugFunctionBreakpointsPayload(
+ sessionID: sessionID,
+ breakpoints: breakpoints
+ )
+ ).get()
+ }
+
+ func debugDataBreakpointInfo(
+ sessionID: String,
+ operationID: String,
+ name: String,
+ variablesReference: Int?,
+ frameID: Int?
+ ) throws -> DebugCoreUpdate {
+ try executeResult(
+ command: "debug.dataBreakpointInfo",
+ payload: DebugDataBreakpointInfoPayload(
+ sessionID: sessionID,
+ operationID: operationID,
+ name: name,
+ variablesReference: variablesReference,
+ frameID: frameID
+ ),
+ operationID: operationID
+ ).get()
+ }
+
+ func setDebugDataBreakpoints(
+ sessionID: String,
+ breakpoints: [DebugDataBreakpoint]
+ ) throws -> DebugCoreUpdate {
+ try executeResult(
+ command: "debug.setDataBreakpoints",
+ payload: DebugDataBreakpointsPayload(
+ sessionID: sessionID,
+ breakpoints: breakpoints
+ )
+ ).get()
+ }
+
+ func setDebugVariable(
+ sessionID: String,
+ operationID: String,
+ variablesReference: Int,
+ name: String,
+ value: String
+ ) throws -> DebugCoreUpdate {
+ try executeResult(
+ command: "debug.setVariable",
+ payload: DebugSetVariablePayload(
+ sessionID: sessionID,
+ operationID: operationID,
+ variablesReference: variablesReference,
+ name: name,
+ value: value
+ ),
+ operationID: operationID
+ ).get()
+ }
+
+ func cancelDebugOperation(
+ sessionID: String,
+ operationID: String,
+ reason: String
+ ) throws -> DebugCoreUpdate {
+ try executeResult(
+ command: "debug.cancelOperation",
+ payload: DebugCancelOperationPayload(
+ sessionID: sessionID,
+ operationID: operationID,
+ reason: reason
+ ),
+ operationID: operationID
+ ).get()
+ }
+
+ func executeDebugCommand(
+ sessionID: String,
+ operationID: String,
+ command: DebugExecutionCommand,
+ threadID: Int?,
+ targetID: Int?,
+ singleThread: Bool
+ ) throws -> DebugCoreUpdate {
+ try executeResult(
+ command: "debug.execute",
+ payload: DebugExecutePayload(
+ sessionID: sessionID,
+ operationID: operationID,
+ command: command.rawValue,
+ threadID: threadID,
+ targetID: targetID,
+ singleThread: singleThread
+ ),
+ operationID: operationID
+ ).get()
+ }
+
+ func inspectDebugSession(
+ sessionID: String,
+ operationID: String,
+ kind: String,
+ threadID: Int?,
+ frameID: Int?,
+ variablesReference: Int?,
+ variableFilter: DebugVariableFilter?,
+ start: Int?,
+ count: Int?,
+ expression: String?,
+ sourcePath: String?,
+ line: Int?,
+ column: Int?
+ ) throws -> DebugCoreUpdate {
+ try executeResult(
+ command: "debug.inspect",
+ payload: DebugInspectPayload(
+ sessionID: sessionID,
+ operationID: operationID,
+ kind: kind,
+ threadID: threadID,
+ frameID: frameID,
+ variablesReference: variablesReference,
+ variableFilter: variableFilter,
+ start: start,
+ count: count,
+ expression: expression,
+ sourcePath: sourcePath,
+ line: line,
+ column: column
+ ),
+ operationID: operationID
+ ).get()
+ }
+
+ func receiveDebugData(sessionID: String, data: Data) throws -> DebugCoreUpdate {
+ try executeResult(
+ command: "debug.receive",
+ payload: DebugReceivePayload(
+ sessionID: sessionID,
+ dataBase64: data.base64EncodedString()
+ )
+ ).get()
+ }
+
+ func completeDebugRunInTerminalRequest(
+ sessionID: String,
+ requestID: String,
+ result: Result
+ ) throws -> DebugCoreUpdate {
+ let payload: DebugRunInTerminalResponsePayload
+ switch result {
+ case .success(let response):
+ payload = DebugRunInTerminalResponsePayload(
+ sessionID: sessionID,
+ requestID: requestID,
+ success: true,
+ processID: response.processID,
+ shellProcessID: response.shellProcessID,
+ message: nil
+ )
+ case .failure(let error):
+ payload = DebugRunInTerminalResponsePayload(
+ sessionID: sessionID,
+ requestID: requestID,
+ success: false,
+ processID: nil,
+ shellProcessID: nil,
+ message: error.localizedDescription
+ )
+ }
+ return try executeResult(
+ command: "debug.runInTerminalResponse",
+ payload: payload
+ ).get()
+ }
+
+ func disconnectDebugSession(sessionID: String) throws -> DebugCoreUpdate {
+ try executeResult(
+ command: "debug.disconnect",
+ payload: DebugSessionPayload(sessionID: sessionID)
+ ).get()
+ }
+
+ func destroyDebugSession(sessionID: String) {
+ let result: Result = executeResult(
+ command: "debug.destroySession",
+ payload: DebugSessionPayload(sessionID: sessionID)
+ )
+ _ = result
+ }
+}
+
+private struct JavaTestDebugLaunchPayload: Encodable {
+ let name: String
+ let framework: JavaTestDebugFramework
+ let workingDirectory: String
+ let mainClass: String
+ let projectName: String?
+ let classPaths: [String]
+ let modulePaths: [String]
+ let vmArguments: [String]
+ let programArguments: [String]
+ let resultPort: UInt16
+ let testNGRunnerPath: String?
+ let testNGTestNames: [String]
+
+ init(target: JavaTestDebugLaunchTarget, resultPort: UInt16) {
+ name = target.name
+ framework = target.framework
+ workingDirectory = target.workingDirectory
+ mainClass = target.mainClass
+ projectName = target.projectName
+ classPaths = target.classPaths
+ modulePaths = target.modulePaths
+ vmArguments = target.vmArguments
+ programArguments = target.programArguments
+ self.resultPort = resultPort
+ testNGRunnerPath = target.testNGRunnerPath
+ testNGTestNames = target.testNGTestNames
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case name, framework, workingDirectory, mainClass, projectName
+ case classPaths, modulePaths, vmArguments, programArguments, resultPort
+ case testNGRunnerPath = "testngRunnerPath"
+ case testNGTestNames = "testngTestNames"
+ }
+}
+
+private struct DebugBreakpointRelocationPayload: Encodable {
+ let source: String
+ let edit: DebugSourceEdit
+ let breakpoints: [DebugSourceBreakpoint]
+}
+
+private struct DebugBreakpointRelocationResult: Decodable {
+ let breakpoints: [DebugSourceBreakpoint]
+}
+
+private struct DebugCreateSessionPayload: Encodable {
+ let sessionID: String
+ let adapterID: String
+ let rootPath: String
+ let supportsRunInTerminalRequest: Bool
+
+ private enum CodingKeys: String, CodingKey {
+ case sessionID = "sessionId"
+ case adapterID = "adapterId"
+ case rootPath, supportsRunInTerminalRequest
+ }
+}
+
+private struct DebugRunInTerminalResponsePayload: Encodable {
+ let sessionID: String
+ let requestID: String
+ let success: Bool
+ let processID: Int?
+ let shellProcessID: Int?
+ let message: String?
+
+ private enum CodingKeys: String, CodingKey {
+ case sessionID = "sessionId"
+ case requestID = "requestId"
+ case success
+ case processID = "processId"
+ case shellProcessID = "shellProcessId"
+ case message
+ }
+}
+
+private struct DebugSteppingFiltersPayload: Encodable {
+ let adapterID: String
+ let filters: DebugSteppingFilters?
+
+ private enum CodingKeys: String, CodingKey {
+ case adapterID = "adapterId"
+ case filters
+ }
+}
+
+private struct DebugSessionPayload: Encodable {
+ let sessionID: String
+
+ private enum CodingKeys: String, CodingKey {
+ case sessionID = "sessionId"
+ }
+}
+
+private struct DebugLaunchPayload: Encodable {
+ let sessionID: String
+ let operationID: String
+ let configuration: DebugLaunchConfiguration
+
+ private enum CodingKeys: String, CodingKey {
+ case sessionID = "sessionId"
+ case operationID = "operationId"
+ case configuration
+ }
+}
+
+private struct DebugBreakpointsPayload: Encodable {
+ let sessionID: String
+ let sourcePath: String
+ let breakpoints: [DebugSourceBreakpoint]
+
+ private enum CodingKeys: String, CodingKey {
+ case sessionID = "sessionId"
+ case sourcePath
+ case breakpoints
+ }
+}
+
+private struct DebugExceptionBreakpointsPayload: Encodable {
+ let sessionID: String
+ let breakpoints: [DebugExceptionBreakpoint]
+
+ private enum CodingKeys: String, CodingKey {
+ case sessionID = "sessionId"
+ case breakpoints
+ }
+}
+
+private struct DebugFunctionBreakpointsPayload: Encodable {
+ let sessionID: String
+ let breakpoints: [DebugFunctionBreakpoint]
+
+ private enum CodingKeys: String, CodingKey {
+ case sessionID = "sessionId"
+ case breakpoints
+ }
+}
+
+private struct DebugDataBreakpointInfoPayload: Encodable {
+ let sessionID: String
+ let operationID: String
+ let name: String
+ let variablesReference: Int?
+ let frameID: Int?
+
+ private enum CodingKeys: String, CodingKey {
+ case sessionID = "sessionId"
+ case operationID = "operationId"
+ case name, variablesReference
+ case frameID = "frameId"
+ }
+}
+
+private struct DebugDataBreakpointsPayload: Encodable {
+ let sessionID: String
+ let breakpoints: [DebugDataBreakpoint]
+
+ private enum CodingKeys: String, CodingKey {
+ case sessionID = "sessionId"
+ case breakpoints
+ }
+}
+
+private struct DebugSetVariablePayload: Encodable {
+ let sessionID: String
+ let operationID: String
+ let variablesReference: Int
+ let name: String
+ let value: String
+
+ private enum CodingKeys: String, CodingKey {
+ case sessionID = "sessionId"
+ case operationID = "operationId"
+ case variablesReference, name, value
+ }
+}
+
+private struct DebugCancelOperationPayload: Encodable {
+ let sessionID: String
+ let operationID: String
+ let reason: String
+
+ private enum CodingKeys: String, CodingKey {
+ case sessionID = "sessionId"
+ case operationID = "operationId"
+ case reason
+ }
+}
+
+private struct DebugExecutePayload: Encodable {
+ let sessionID: String
+ let operationID: String
+ let command: String
+ let threadID: Int?
+ let targetID: Int?
+ let singleThread: Bool
+
+ private enum CodingKeys: String, CodingKey {
+ case sessionID = "sessionId"
+ case operationID = "operationId"
+ case command
+ case threadID = "threadId"
+ case targetID = "targetId"
+ case singleThread
+ }
+}
+
+private struct DebugInspectPayload: Encodable {
+ let sessionID: String
+ let operationID: String
+ let kind: String
+ let threadID: Int?
+ let frameID: Int?
+ let variablesReference: Int?
+ let variableFilter: DebugVariableFilter?
+ let start: Int?
+ let count: Int?
+ let expression: String?
+ let sourcePath: String?
+ let line: Int?
+ let column: Int?
+
+ private enum CodingKeys: String, CodingKey {
+ case sessionID = "sessionId"
+ case operationID = "operationId"
+ case kind
+ case threadID = "threadId"
+ case frameID = "frameId"
+ case variablesReference
+ case variableFilter, start, count
+ case expression
+ case sourcePath, line, column
+ }
+}
+
+private struct DebugReceivePayload: Encodable {
+ let sessionID: String
+ let dataBase64: String
+
+ private enum CodingKeys: String, CodingKey {
+ case sessionID = "sessionId"
+ case dataBase64
+ }
+}
diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift
index f1f5f3ef5..36d1277d5 100644
--- a/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift
+++ b/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift
@@ -1,8 +1,59 @@
import Foundation
import LitheCoreContracts
+import LitheDebugModule
import LitheExecutionModule
import LitheModuleAPI
+/// One opening of one workspace.
+///
+/// The path alone repeats when the same project is closed and reopened, so a
+/// task that started before the reopen would still compare equal to the current
+/// workspace. Pairing the path with the opening's generation is what lets such a
+/// task be recognized as belonging to a session that is over.
+struct WorkspaceIdentity: Equatable {
+ let url: URL
+ let generation: Int
+}
+
+/// An action deferred until the run feature holds the current workspace snapshot.
+///
+/// The opening it was deferred for is part of the value so a snapshot applied for
+/// a different workspace — or for a later opening of the same one — cannot resume
+/// it. Direct launch entry points also keep the concrete configuration (or the
+/// all-services intent) so resume can re-issue the same action the UI asked for.
+struct PendingRunAction: Equatable {
+ enum Kind: Equatable {
+ case run
+ case debug
+ case startConfiguration(RunConfiguration)
+ case runAllServices
+ case restart
+ }
+
+ let kind: Kind
+ let identity: WorkspaceIdentity
+}
+
+/// Result of bringing the run feature up to a specific opening's snapshot.
+///
+/// Distinguishing "still waiting on this opening" from "this entry task is
+/// stale" is what stops an await that outlived a project switch or a reopen from
+/// re-recording the old action against the current pending slot.
+private enum RunProjectReadiness: Equatable {
+ case ready
+ case waitingForSnapshot(identity: WorkspaceIdentity)
+ case stale
+}
+
+@MainActor
+final class JavaTestWorkflowState {
+ var resultServer: (any JavaTestResultServing)?
+ var debugLaunchTask: Task?
+ var debugLaunchOperationID: UUID?
+ var discoveryTask: Task?
+ var discoveryOperationID: UUID?
+}
+
@MainActor
extension AppModel {
func toggleSpringEndpoints() {
@@ -33,7 +84,7 @@ extension AppModel {
guard let self else { return }
guard await activateExecutionModule() != nil else { return }
if let workspaceURL {
- await loadProjectServices(at: workspaceURL, files: projectFiles)
+ await loadProjectServicesForAppliedSnapshot(at: workspaceURL)
}
}
isTestsVisible = false
@@ -56,7 +107,7 @@ extension AppModel {
Task { [weak self] in
guard let self, await activateExecutionModule() != nil,
let workspaceURL else { return }
- await loadProjectServices(at: workspaceURL, files: projectFiles)
+ await loadProjectServicesForAppliedSnapshot(at: workspaceURL)
}
isTestsVisible = false
isGitLogVisible = false
@@ -70,7 +121,7 @@ extension AppModel {
guard let self else { return }
let capability = await self.activateExecutionModule()
if capability?.mavenFeature.project == nil {
- await self.loadProjectServices(at: workspaceURL, files: self.projectFiles)
+ await self.loadProjectServicesForAppliedSnapshot(at: workspaceURL)
}
}
}
@@ -124,6 +175,28 @@ extension AppModel {
)
}
+ /// Reveals a stopped debugger frame without adding every step to the
+ /// user's editor back/forward history. Debug stepping is transient
+ /// inspection, unlike an explicit navigation from build output or a link.
+ func revealDebugLocation(url: URL, line: Int, column: Int?) {
+ let normalizedURL = url.standardizedFileURL
+ guard workspaceFeature.fileExists(at: normalizedURL) else {
+ showNotification("The stopped source file is no longer available: \(url.lastPathComponent)")
+ return
+ }
+ navigate(
+ to: EditorNavigationLocation(
+ url: normalizedURL,
+ line: max(0, line - 1),
+ utf16Column: max(0, (column ?? 1) - 1),
+ isReadOnly: false,
+ displayPath: nil,
+ virtualProviderID: nil
+ ),
+ recordsHistory: false
+ )
+ }
+
func toggleProblems() {
isProblemsVisible.toggle()
guard isProblemsVisible else { return }
@@ -149,6 +222,30 @@ extension AppModel {
runFeatureIfActive?.select(configuration)
}
+ /// The single entry point for identification.
+ ///
+ /// Routing it through here is what keeps the run service from scanning a
+ /// superseded snapshot: the service can only compare its own state, so the
+ /// caller has to bring it up to the current snapshot first. When that fails,
+ /// generation must stop — the service may still hold an older `.ready`
+ /// inventory, and scanning it would overwrite `generated.json` with stale
+ /// entry points.
+ func generateRunConfigurations() async {
+ guard let identity = currentWorkspaceIdentity else { return }
+ guard let runFeature = await activateExecutionModule()?.runFeature else { return }
+ guard isCurrentWorkspace(identity) else { return }
+ switch await ensureRunProjectReady(runFeature, for: identity) {
+ case .ready:
+ await runFeature.generateRunConfigurations()
+ case .waitingForSnapshot:
+ // Report the pending workspace through the generation state when the
+ // snapshot has not arrived, which the run panel surfaces as a notice.
+ runFeature.reportGenerationProjectNotReady()
+ case .stale:
+ return
+ }
+ }
+
func openRunConfiguration(relativePath: String?) {
guard let workspaceURL else { return }
let url = workspaceURL.appendingPathComponent(relativePath ?? ".lithe/run/generated.json")
@@ -160,8 +257,195 @@ extension AppModel {
Task { [weak self] in await self?.runSelectedConfigurationAfterActivation() }
}
+ /// Loads project services for the scan currently applied to the workspace.
+ ///
+ /// Callers that only want "whatever the workspace has now" use this so the
+ /// file list and its identity are captured in a single read.
+ func loadProjectServicesForAppliedSnapshot(at workspaceURL: URL) async {
+ let applied = workspaceFeature.appliedSnapshot
+ await loadProjectServices(
+ at: workspaceURL,
+ files: applied?.files ?? [],
+ snapshotID: applied?.id
+ )
+ }
+
+ /// Loads build-system and run state at the workspace boundary. The generic
+ /// run lifecycle is intentionally not owned by JavaFeatureModel.
+ ///
+ /// Spring indexing is scheduled rather than awaited. It scales with the
+ /// number of Java sources, and run configurations, test discovery, and the
+ /// Git refresh that follows this call must not wait for it.
+ ///
+ /// `files` and `snapshotID` must describe the same scan; the caller captures
+ /// them together. `resumesDeferredRunAction` is set only by the workspace
+ /// snapshot callback, because a deferred Run waits for a snapshot and
+ /// resuming from any other load would either re-enter through
+ /// `ensureRunProjectReady` or fire the action from an unrelated reload.
+ func loadProjectServices(
+ at workspaceURL: URL,
+ files: [URL],
+ snapshotID: UUID?,
+ resumesDeferredRunAction: Bool = false
+ ) async {
+ let target = workspaceURL.standardizedFileURL
+ // The caller established that this load belongs to the current opening,
+ // so the identity is captured here and re-checked after every await.
+ guard let identity = currentWorkspaceIdentity, identity.url == target else { return }
+ prepareJavaLanguageServerForWorkspaceIfNeeded(
+ at: target,
+ files: files
+ )
+ springFeature.scheduleLoad(
+ workspaceURL: target,
+ files: files,
+ textOverrides: Dictionary(uniqueKeysWithValues: openDocuments.map {
+ ($0.url.standardizedFileURL, $0.text)
+ })
+ )
+ guard let execution = await activateExecutionModule() else { return }
+ // Module activation suspends; a project switch or a reopen must not let
+ // this load write the captured inventory into the new opening's run
+ // service.
+ guard isCurrentWorkspace(identity) else { return }
+ execution.tests.discover(workspaceURL: target, files: files)
+ // `files` and `snapshotID` are captured together by the caller. Reading
+ // the applied snapshot here instead would pair this file list with a
+ // newer scan's identity, which the readiness comparison cannot detect.
+ await execution.projectDevelopment.loadProject(
+ at: target,
+ files: files,
+ snapshotID: snapshotID
+ )
+ guard isCurrentWorkspace(identity) else { return }
+ guard resumesDeferredRunAction else { return }
+ resumeDeferredRunAction(
+ execution.runFeature,
+ identity: identity,
+ snapshotID: snapshotID
+ )
+ }
+
+ /// The opening an entry task captures before its first await.
+ var currentWorkspaceIdentity: WorkspaceIdentity? {
+ guard let url = workspaceURL?.standardizedFileURL else { return nil }
+ return WorkspaceIdentity(url: url, generation: workspaceFeature.workspaceGeneration)
+ }
+
+ private func isCurrentWorkspace(_ identity: WorkspaceIdentity) -> Bool {
+ currentWorkspaceIdentity == identity
+ }
+
+ /// Brings the run feature up to the snapshot for a captured opening.
+ ///
+ /// Entry tasks capture the opening before any await so a project switch — or
+ /// a close and reopen of the same path — can be reported as `.stale` instead
+ /// of being re-deferred against whatever is current when the load finishes.
+ ///
+ /// When a newer snapshot is published but the run service still holds an
+ /// older `.ready` inventory for this workspace, the snapshot callback owns
+ /// the transition. Loading from the entry path would race that callback and
+ /// let Restart proceed from a half-applied refresh.
+ ///
+ /// When the run service is not already ready for this workspace, the entry
+ /// path applies the published scan itself (open-before-run, prune, tool
+ /// window) so readiness does not wait on a callback that may never arrive.
+ private func ensureRunProjectReady(
+ _ runFeature: RunFeatureModel,
+ for identity: WorkspaceIdentity
+ ) async -> RunProjectReadiness {
+ guard isCurrentWorkspace(identity) else { return .stale }
+ let target = identity.url
+ // One read, so the file list and the identity describe the same scan.
+ let applied = workspaceFeature.appliedSnapshot
+ if runFeature.isProjectReady(for: target, snapshotID: applied?.id) { return .ready }
+ if applied != nil, runFeature.hasReadyInventory(for: target) {
+ return .waitingForSnapshot(identity: identity)
+ }
+ // No matching ready inventory: bind provisionally, or apply the
+ // published scan when one already exists.
+ await loadProjectServices(
+ at: target,
+ files: applied?.files ?? [],
+ snapshotID: applied?.id
+ )
+ // A snapshot may land and be fully consumed while this load is in
+ // flight, including its deferred-run resume with nothing pending yet.
+ // Comparing against the pre-await capture would then treat a ready
+ // project as not ready, defer the action, and leave it stranded.
+ guard isCurrentWorkspace(identity) else { return .stale }
+ let current = workspaceFeature.appliedSnapshot
+ if runFeature.isProjectReady(for: target, snapshotID: current?.id) {
+ return .ready
+ }
+ return .waitingForSnapshot(identity: identity)
+ }
+
+ /// Continues an action that arrived before the snapshot did. The workspace
+ /// rebuild always finishes by applying a snapshot, so recording the intent is
+ /// enough to resume without polling or waiting.
+ ///
+ /// A load for one opening must not clear a pending action that belongs to
+ /// another: openProject already cleared the old pending on the switch, and a
+ /// stale callback arriving later would otherwise wipe the newly recorded
+ /// intent.
+ /// `snapshotID` is the scan this load just applied, not whatever the
+ /// workspace holds now. Reading the current identity here would compare the
+ /// run feature against a scan it has not consumed.
+ private func resumeDeferredRunAction(
+ _ runFeature: RunFeatureModel,
+ identity: WorkspaceIdentity,
+ snapshotID: UUID?
+ ) {
+ guard let action = pendingRunAction else { return }
+ guard action.identity == identity else { return }
+ guard runFeature.isProjectReady(for: identity.url, snapshotID: snapshotID) else {
+ return
+ }
+ setPendingRunAction(nil)
+ switch action.kind {
+ case .run: runSelectedConfiguration()
+ case .debug: startDebugging()
+ case .startConfiguration(let configuration): startRunConfiguration(configuration)
+ case .runAllServices: runAllServiceConfigurations()
+ case .restart: restartSelectedRun()
+ }
+ }
+
+ /// Records an action for the opening the entry task captured, not whatever
+ /// workspace happens to be current after an await.
+ private func clearPendingRunAction(for identity: WorkspaceIdentity) {
+ guard pendingRunAction?.identity == identity else { return }
+ setPendingRunAction(nil)
+ }
+
+ private func setPendingRunAction(_ action: PendingRunAction?) {
+ pendingRunAction = action
+ // pendingRunAction is not @Published; relay so tests and any UI that
+ // observes AppModel learn about defer/resume without a feature load.
+ scheduleObjectWillChangeRelay()
+ }
+
+ private func deferRunAction(_ kind: PendingRunAction.Kind, for identity: WorkspaceIdentity) {
+ guard isCurrentWorkspace(identity) else { return }
+ setPendingRunAction(PendingRunAction(kind: kind, identity: identity))
+ }
+
private func runSelectedConfigurationAfterActivation() async {
+ guard let identity = currentWorkspaceIdentity else { return }
guard let runFeature = await activateExecutionModule()?.runFeature else { return }
+ guard isCurrentWorkspace(identity) else { return }
+ switch await ensureRunProjectReady(runFeature, for: identity) {
+ case .ready:
+ clearPendingRunAction(for: identity)
+ case .waitingForSnapshot(let waitingIdentity):
+ // Launching from a provisional inventory resolves toolchains without
+ // the Maven project, so wait for the snapshot instead of running.
+ deferRunAction(.run, for: waitingIdentity)
+ return
+ case .stale:
+ return
+ }
guard runFeature.configurationStatus == .ready else {
runFeature.requestRunConfigurationGeneration(intent: .run)
return
@@ -174,6 +458,7 @@ extension AppModel {
)) {
return
}
+ guard isCurrentWorkspace(identity) else { return }
if configuration.usesCurrentEditorFile,
let activeDocument,
activeDocument.isDirty {
@@ -186,6 +471,7 @@ extension AppModel {
return
}
}
+ guard isCurrentWorkspace(identity) else { return }
runFeature.runSelected(currentFileURL: activeDocument?.url)
isRunVisible = true
isGitLogVisible = false
@@ -198,8 +484,20 @@ extension AppModel {
func restartSelectedRun() {
isRunVisible = true
Task { [weak self] in
- guard let self,
- let runFeature = await activateExecutionModule()?.runFeature else { return }
+ guard let self else { return }
+ guard let identity = currentWorkspaceIdentity else { return }
+ guard let runFeature = await activateExecutionModule()?.runFeature else { return }
+ guard isCurrentWorkspace(identity) else { return }
+ guard runFeature.lastConfiguration != nil else { return }
+ switch await ensureRunProjectReady(runFeature, for: identity) {
+ case .ready:
+ clearPendingRunAction(for: identity)
+ case .waitingForSnapshot(let waitingIdentity):
+ deferRunAction(.restart, for: waitingIdentity)
+ return
+ case .stale:
+ return
+ }
guard let configuration = runFeature.lastConfiguration else { return }
if !(await activateLanguageRunExtensionIfNeeded(
for: configuration,
@@ -208,33 +506,63 @@ extension AppModel {
)) {
return
}
+ guard isCurrentWorkspace(identity) else { return }
runFeature.restart()
}
}
func startRunConfiguration(_ configuration: RunConfiguration) {
Task { [weak self] in
- guard let self,
- let runFeature = await activateExecutionModule()?.runFeature,
- await activateLanguageRunExtensionIfNeeded(
- for: configuration,
- currentFileURL: activeDocument?.url,
- runFeature: runFeature
- ) else { return }
+ guard let self else { return }
+ guard let identity = currentWorkspaceIdentity else { return }
+ guard let runFeature = await activateExecutionModule()?.runFeature else { return }
+ guard isCurrentWorkspace(identity) else { return }
+ switch await ensureRunProjectReady(runFeature, for: identity) {
+ case .ready:
+ clearPendingRunAction(for: identity)
+ case .waitingForSnapshot(let waitingIdentity):
+ // Direct play buttons reach here without going through
+ // `runSelectedConfiguration`, so they need the same readiness
+ // gate and must remember which configuration to resume — bound
+ // to the opening this task started for, not whatever is current
+ // after an await.
+ deferRunAction(.startConfiguration(configuration), for: waitingIdentity)
+ return
+ case .stale:
+ return
+ }
+ guard await activateLanguageRunExtensionIfNeeded(
+ for: configuration,
+ currentFileURL: activeDocument?.url,
+ runFeature: runFeature
+ ) else { return }
+ guard isCurrentWorkspace(identity) else { return }
runFeature.startConfiguration(configuration)
}
}
func runAllServiceConfigurations() {
Task { [weak self] in
- guard let self,
- let runFeature = await activateExecutionModule()?.runFeature else { return }
+ guard let self else { return }
+ guard let identity = currentWorkspaceIdentity else { return }
+ guard let runFeature = await activateExecutionModule()?.runFeature else { return }
+ guard isCurrentWorkspace(identity) else { return }
+ switch await ensureRunProjectReady(runFeature, for: identity) {
+ case .ready:
+ clearPendingRunAction(for: identity)
+ case .waitingForSnapshot(let waitingIdentity):
+ deferRunAction(.runAllServices, for: waitingIdentity)
+ return
+ case .stale:
+ return
+ }
for configuration in runFeature.configurations where configuration.execution == .service {
guard await activateLanguageRunExtensionIfNeeded(
for: configuration,
currentFileURL: nil,
runFeature: runFeature
) else { return }
+ guard isCurrentWorkspace(identity) else { return }
}
runFeature.runAllServices()
}
@@ -311,7 +639,7 @@ extension AppModel {
guard let self else { return }
guard await activateExecutionModule() != nil else { return }
if let workspaceURL {
- await loadProjectServices(at: workspaceURL, files: projectFiles)
+ await loadProjectServicesForAppliedSnapshot(at: workspaceURL)
}
_ = await activateDebugModule()
}
@@ -324,71 +652,149 @@ extension AppModel {
isRunVisible = false
}
+ func showDebugBreakpointManager() {
+ guard let requestedWorkspaceURL = workspaceURL else { return }
+ Task { [weak self] in
+ guard let self,
+ await activateDebugModule() != nil,
+ workspaceURL == requestedWorkspaceURL else { return }
+ debugBreakpointPresentation.isManagerPresented = true
+ }
+ }
+
func startDebugging() {
Task { [weak self] in await self?.startDebuggingAfterActivation() }
}
+ func startOrRestartDebugging() {
+ guard let feature = genericDebugFeatureIfActive,
+ feature.isSessionActive else {
+ startDebugging()
+ return
+ }
+ showDebugToolWindow()
+ if feature.canRestart {
+ feature.execute(.restart)
+ }
+ }
+
+ func attachJavaDebugger(host: String, port: Int) {
+ Task { [weak self] in
+ await self?.attachJavaDebuggerAfterActivation(host: host, port: port)
+ }
+ }
+
+ private func attachJavaDebuggerAfterActivation(host: String, port: Int) async {
+ guard let workspaceURL,
+ await activateDebugModule() != nil else { return }
+ let sourceURL = ([activeDocument?.url].compactMap { $0 } + projectFiles)
+ .map(\.standardizedFileURL)
+ .first {
+ languageProviderCatalog.provider(for: $0)?.id == "java"
+ }
+ guard let sourceURL else {
+ showNotification("Open a Java project before connecting the debugger")
+ return
+ }
+ let configuration: DebugLaunchConfiguration
+ do {
+ configuration = try debugLaunchConfigurationResolver.resolveJavaAttach(
+ host: host,
+ port: port
+ )
+ } catch {
+ showNotification(error.localizedDescription)
+ return
+ }
+ guard let genericDebugFeature = genericDebugFeatureIfActive,
+ genericDebugFeature.start(
+ fileURL: sourceURL,
+ rootURL: workspaceURL,
+ configuration: configuration
+ ) else {
+ showNotification(
+ genericDebugFeatureIfActive?.errorMessage ?? "Could not connect to the JVM"
+ )
+ isDebugVisible = true
+ return
+ }
+ showDebugToolWindow()
+ }
+
private func startDebuggingAfterActivation() async {
+ guard let identity = currentWorkspaceIdentity else { return }
guard let execution = await activateExecutionModule(),
- let debug = await activateDebugModule() else { return }
+ isCurrentWorkspace(identity) else { return }
let runFeature = execution.runFeature
- let debugFeature = debug.javaFeature
- javaFeature.configureRuntime(
- mavenFeature: execution.mavenFeature,
- debugFeature: debugFeature
- )
- if let document = activeDocument,
- languageProviderCatalog.provider(for: document.url)?
- .capabilities.contains(.debugAdapter) == true {
- startGenericDebugging(document)
+ switch await ensureRunProjectReady(runFeature, for: identity) {
+ case .ready:
+ clearPendingRunAction(for: identity)
+ case .waitingForSnapshot(let waitingIdentity):
+ deferRunAction(.debug, for: waitingIdentity)
return
- }
- if debugFeature.targetKind == .currentFile,
- let document = activeDocument,
- languageProviderCatalog.provider(for: document.url)?.id != "java" {
- let language = languageProviderCatalog.provider(for: document.url)?.displayName
- ?? "This file type"
- showNotification("\(language) debugging is not available on this machine")
- isDebugVisible = true
+ case .stale:
return
}
- if debugFeature.targetKind == .runConfiguration,
- runFeature.configurationStatus != .ready {
+ let workspaceURL = identity.url
+ guard isCurrentWorkspace(identity) else { return }
+ guard await activateDebugModule() != nil,
+ isCurrentWorkspace(identity) else { return }
+ guard runFeature.configurationStatus == .ready else {
runFeature.requestRunConfigurationGeneration(intent: .debug)
return
}
- if runFeature.blockingToolchainDiagnostic != nil {
- isRunVisible = true
- isDebugVisible = false
- isGitLogVisible = false
- isTerminalVisible = false
- isReferencesVisible = false
- isProblemsVisible = false
- isMavenVisible = false
+ guard let selectedConfiguration = runFeature.selectedConfiguration else {
+ showNotification("Choose a Run configuration before starting Debug")
return
}
- guard javaFeature.startDebugging(
- currentDocument: activeDocument,
- workspaceURL: workspaceURL,
- runFeature: runFeature,
- saveDocument: { [weak self] document in try self?.saveDocument(document) },
- recordSave: { [weak self] document, previousText in
- self?.recordSave(document, previousText: previousText)
+ let configuration = DebugLaunchSourceResolver().configurationForDebug(
+ selected: selectedConfiguration,
+ activeDocumentText: activeDocument?.text,
+ configurations: runFeature.configurations
+ )
+ runFeature.select(configuration)
+
+ let sourceURL: URL
+ if configuration.usesCurrentEditorFile {
+ guard let document = activeDocument else {
+ showNotification("Open a source file or choose a project Run configuration")
+ return
}
- ) else { return }
- isDebugVisible = true
- isGitLogVisible = false
- isTerminalVisible = false
- isReferencesVisible = false
- isProblemsVisible = false
- isMavenVisible = false
- isRunVisible = false
+ sourceURL = document.url
+ } else if configuration.kind.capabilities.contains(.jdwpDebug) {
+ guard let resolved = DebugLaunchSourceResolver().resolve(
+ configuration: configuration,
+ activeDocumentURL: activeDocument?.url,
+ projectFiles: projectFiles,
+ workspaceURL: workspaceURL
+ ) else {
+ showNotification("Could not find the Java source for \(configuration.name)")
+ return
+ }
+ sourceURL = resolved
+ } else {
+ showNotification("\(configuration.name) does not support Debug yet")
+ return
+ }
+
+ guard languageProviderCatalog.provider(for: sourceURL)?
+ .capabilities.contains(.debugAdapter) == true else {
+ showNotification("Debug support is not available for \(sourceURL.lastPathComponent)")
+ isDebugVisible = true
+ return
+ }
+ let document = openDocuments.first {
+ $0.url.standardizedFileURL == sourceURL.standardizedFileURL
+ }
+ await startGenericDebuggingAfterActivation(fileURL: sourceURL, document: document)
}
func toggleTests() {
isTestsVisible.toggle()
- guard isTestsVisible else { return }
- Task { [weak self] in _ = await self?.activateExecutionModule() }
+ guard isTestsVisible else {
+ cancelLanguageTestDiscovery()
+ return
+ }
isGitLogVisible = false
isTerminalVisible = false
isReferencesVisible = false
@@ -397,30 +803,88 @@ extension AppModel {
isRunVisible = false
isDebugVisible = false
guard let workspaceURL else { return }
- Task { [weak self] in
- guard let self,
- let execution = await activateExecutionModule(),
- await activateLanguageTestExtensionsIfNeeded(
- for: projectFiles,
- testService: execution.tests
- ) else { return }
- execution.tests.discover(workspaceURL: workspaceURL, files: projectFiles)
- }
+ startLanguageTestDiscovery(workspaceURL: workspaceURL)
}
func refreshTests() {
guard let workspaceURL else { return }
- Task { [weak self] in
- guard let self,
- let execution = await activateExecutionModule(),
+ startLanguageTestDiscovery(workspaceURL: workspaceURL)
+ }
+
+ private func startLanguageTestDiscovery(workspaceURL: URL) {
+ cancelLanguageTestDiscovery()
+ let operationID = UUID()
+ javaTestWorkflowState.discoveryOperationID = operationID
+ javaTestWorkflowState.discoveryTask = Task { [weak self] in
+ guard let self else { return }
+ defer { finishLanguageTestDiscovery(operationID) }
+ guard let execution = await activateExecutionModule(),
await activateLanguageTestExtensionsIfNeeded(
for: projectFiles,
testService: execution.tests
- ) else { return }
+ ),
+ isCurrentLanguageTestDiscovery(operationID) else { return }
execution.tests.discover(workspaceURL: workspaceURL, files: projectFiles)
+
+ let baseItems = execution.tests.itemsByProviderID["java"] ?? []
+ let javaFiles = baseItems.filter { $0.kind == .file && $0.fileURL != nil }
+ guard !javaFiles.isEmpty else { return }
+ do {
+ let sessions = try await languageSessionsForWorkspaceMaintenance()
+ var projected = baseItems.filter { $0.kind == .workspace }
+ var completedFileCount = 0
+ for fileItem in javaFiles {
+ try Task.checkCancellation()
+ guard isCurrentLanguageTestDiscovery(operationID),
+ let fileURL = fileItem.fileURL else { return }
+ do {
+ let details = try await sessions.discoverJavaTestItems(
+ fileURL: fileURL,
+ rootURL: workspaceURL
+ )
+ completedFileCount += 1
+ if !details.isEmpty {
+ projected.append(fileItem)
+ projected.append(contentsOf: details)
+ }
+ } catch is CancellationError {
+ throw CancellationError()
+ } catch {
+ // Preserve the cheap file-level fallback when semantic
+ // discovery fails for only one source file.
+ projected.append(fileItem)
+ }
+ }
+ guard isCurrentLanguageTestDiscovery(operationID) else { return }
+ if projected.allSatisfy({ $0.kind == .workspace }), completedFileCount > 0 {
+ projected = []
+ }
+ execution.tests.replaceDiscoveredItems(projected, providerID: "java")
+ } catch is CancellationError {
+ return
+ } catch {
+ guard isCurrentLanguageTestDiscovery(operationID) else { return }
+ showNotification(error.localizedDescription)
+ }
}
}
+ func cancelLanguageTestDiscovery() {
+ javaTestWorkflowState.discoveryOperationID = nil
+ javaTestWorkflowState.discoveryTask?.cancel()
+ javaTestWorkflowState.discoveryTask = nil
+ }
+
+ private func isCurrentLanguageTestDiscovery(_ operationID: UUID) -> Bool {
+ javaTestWorkflowState.discoveryOperationID == operationID && !Task.isCancelled
+ }
+
+ private func finishLanguageTestDiscovery(_ operationID: UUID) {
+ guard javaTestWorkflowState.discoveryOperationID == operationID else { return }
+ javaTestWorkflowState.discoveryOperationID = nil
+ javaTestWorkflowState.discoveryTask = nil
+ }
+
func runTest(providerID: String, scope: LanguageTestScope) {
guard let workspaceURL else { return }
isTestsVisible = true
@@ -449,6 +913,89 @@ extension AppModel {
}
}
+ func debugTest(providerID: String, scope: LanguageTestScope) {
+ guard providerID == "java", let workspaceURL else {
+ showNotification("Java test debugging is currently available for Java projects only")
+ return
+ }
+ let fileURL: URL
+ let testIdentifier: String?
+ switch scope {
+ case .workspace:
+ showNotification("Select a Java test file or test case to debug")
+ return
+ case .file(let url):
+ fileURL = url.standardizedFileURL
+ testIdentifier = nil
+ case .testCase(let identifier, let url):
+ guard let url else {
+ showNotification("The selected Java test has no source file")
+ return
+ }
+ fileURL = url.standardizedFileURL
+ testIdentifier = identifier
+ }
+ cancelJavaTestDebugLaunch()
+ let operationID = UUID()
+ javaTestWorkflowState.debugLaunchOperationID = operationID
+ javaTestWorkflowState.debugLaunchTask = Task { [weak self] in
+ guard let self else { return }
+ defer { finishJavaTestDebugLaunch(operationID) }
+ guard let runFeature = await activateExecutionModule()?.runFeature,
+ let genericDebugFeature = await activateDebugModule()?.genericFeature,
+ isCurrentJavaTestDebugLaunch(operationID) else { return }
+ if let selectedConfiguration = runFeature.selectedConfiguration {
+ runFeature.select(selectedConfiguration)
+ }
+ if let document = openDocuments.first(where: {
+ $0.url.standardizedFileURL == fileURL
+ }),
+ document.isDirty {
+ do {
+ let previousText = document.savedText
+ try saveDocument(document)
+ recordSave(document, previousText: previousText)
+ } catch {
+ showNotification("Could not save \(document.url.lastPathComponent)")
+ return
+ }
+ }
+ do {
+ let sessions = try await languageSessionsForWorkspaceMaintenance()
+ let prepared = try await services.javaTestDebugLaunchService.prepare(
+ fileURL: fileURL,
+ testIdentifier: testIdentifier,
+ rootURL: workspaceURL,
+ targetResolver: sessions
+ )
+ guard isCurrentJavaTestDebugLaunch(operationID) else {
+ prepared.stop()
+ return
+ }
+ stopJavaTestResultServer()
+ javaTestWorkflowState.resultServer = prepared.resultServer
+ guard genericDebugFeature.start(
+ fileURL: prepared.target.fileURL,
+ rootURL: workspaceURL,
+ configuration: prepared.configuration
+ ) else {
+ let message = genericDebugFeature.errorMessage
+ ?? "Could not debug the Java test"
+ stopJavaTestResultServer()
+ showNotification(message)
+ return
+ }
+ showDebugToolWindow()
+ } catch is CancellationError {
+ finishJavaTestDebugLaunch(operationID, stopResultServer: true)
+ } catch {
+ guard isCurrentJavaTestDebugLaunch(operationID) else { return }
+ finishJavaTestDebugLaunch(operationID, stopResultServer: true)
+ showNotification(error.localizedDescription)
+ }
+ }
+ }
+
private func activateLanguageTestExtensionsIfNeeded(
for files: [URL],
testService: LanguageTestService
@@ -494,13 +1041,101 @@ extension AppModel {
}
func stopDebugging() {
- if genericDebugFeatureIfActive?.providerID != nil {
- genericDebugFeatureIfActive?.stop()
+ cancelJavaTestDebugLaunch()
+ guard let feature = genericDebugFeatureIfActive else {
+ stopDebugTerminalProcesses()
+ return
+ }
+ let activeSessionID = feature.activeSessionID
+ feature.stop()
+ if let activeSessionID {
+ stopDebugTerminalProcesses(for: activeSessionID)
+ } else {
+ stopDebugTerminalProcesses()
+ }
+ }
+
+ func cancelJavaTestDebugLaunch() {
+ javaTestWorkflowState.debugLaunchOperationID = nil
+ javaTestWorkflowState.debugLaunchTask?.cancel()
+ javaTestWorkflowState.debugLaunchTask = nil
+ stopJavaTestResultServer()
+ }
+
+ func stopJavaTestResultServer() {
+ javaTestWorkflowState.resultServer?.stop()
+ javaTestWorkflowState.resultServer = nil
+ }
+
+ func cancelJavaTestWorkflows() {
+ cancelLanguageTestDiscovery()
+ cancelJavaTestDebugLaunch()
+ }
+
+ func cancelJavaWorkspaceWorkflows() {
+ cancelJavaLanguageServerPreparation()
+ cancelJavaTestWorkflows()
+ }
+
+ func handleDebugSessionStateChange(_ state: DebugAdapterState) {
+ // A Java launch may request an integrated terminal before the adapter
+ // reaches `running`. Once the debug session is live, the debugger is
+ // the primary tool window, matching IDEA's launch behavior; the
+ // terminal session remains available as a separate session tab.
+ if state == .launching || state == .running {
+ showDebugToolWindow()
+ return
+ }
+ if state == .paused {
+ showDebugToolWindow()
+ platformUI.activateApplication()
+ return
+ }
+ guard state == .terminated || state == .failed else { return }
+ stopJavaTestResultServer()
+ if let activeSessionID = genericDebugFeatureIfActive?.activeSessionID {
+ stopDebugTerminalProcesses(for: activeSessionID)
} else {
- debugFeatureIfActive?.stop()
+ stopDebugTerminalProcesses()
+ }
+ }
+
+ private func isCurrentJavaTestDebugLaunch(_ operationID: UUID) -> Bool {
+ javaTestWorkflowState.debugLaunchOperationID == operationID && !Task.isCancelled
+ }
+
+ private func finishJavaTestDebugLaunch(
+ _ operationID: UUID,
+ stopResultServer: Bool = false
+ ) {
+ guard javaTestWorkflowState.debugLaunchOperationID == operationID else { return }
+ javaTestWorkflowState.debugLaunchOperationID = nil
+ javaTestWorkflowState.debugLaunchTask = nil
+ if stopResultServer {
+ stopJavaTestResultServer()
}
}
+ func resumeDebugging() {
+ guard let feature = genericDebugFeatureIfActive, feature.state == .paused else { return }
+ feature.execute(.continueExecution)
+ }
+
+ func stepOverDebugging() {
+ guard let feature = genericDebugFeatureIfActive, feature.state == .paused else { return }
+ feature.execute(.next)
+ }
+
+ func stepIntoDebugging() {
+ guard let feature = genericDebugFeatureIfActive, feature.state == .paused else { return }
+ feature.execute(.stepIn)
+ }
+
+ func stepOutDebugging() {
+ guard let feature = genericDebugFeatureIfActive, feature.state == .paused else { return }
+ feature.execute(.stepOut)
+ }
+
func toggleDebugBreakpointAtCaret() {
guard let document = activeDocument,
let caret = editorCaret,
@@ -512,46 +1147,151 @@ extension AppModel {
}
func toggleDebugBreakpoint(fileURL: URL, line: Int) {
+ if languageProviderCatalog.provider(for: fileURL)?.id == "java",
+ let document = openDocuments.first(where: {
+ $0.url.standardizedFileURL == fileURL.standardizedFileURL
+ }),
+ !DebugBreakpointLocationValidator.isExecutableJavaLine(
+ source: document.text,
+ line: line
+ ) {
+ showNotification("This line cannot hold a Java breakpoint")
+ return
+ }
if languageProviderCatalog.provider(for: fileURL)?
.capabilities.contains(.debugAdapter) == true {
Task { [weak self] in
guard let feature = await self?.activateDebugModule()?.genericFeature else { return }
feature.toggleBreakpoint(fileURL: fileURL, line: line)
}
- } else if javaFeature.supportsLegacyDebugging(fileURL: fileURL) {
- javaFeature.toggleDebugBreakpoint(at: fileURL, line: line, documents: openDocuments)
} else {
showNotification("Debugging is not supported for this file type")
}
}
- var prefersGenericDebugUI: Bool {
- if genericDebugFeatureIfActive?.providerID != nil { return true }
- guard let document = activeDocument else { return false }
- // Never show the Java/JDB panel for another language. A configured
- // Provider may still be unavailable locally; the generic panel can
- // then present the Provider's installation error without leaking a
- // Java-specific workflow into that project.
- guard let descriptor = languageProviderCatalog.provider(for: document.url) else {
- return true
+ func applyDebugSourceEdit(
+ fileURL: URL,
+ previousSource: String,
+ replacedRange: NSRange,
+ replacement: String
+ ) {
+ guard replacedRange.location != NSNotFound,
+ replacedRange.location >= 0,
+ replacedRange.length >= 0,
+ NSMaxRange(replacedRange) <= previousSource.utf16.count else { return }
+ genericDebugFeatureIfActive?.applySourceEdit(
+ fileURL: fileURL,
+ source: previousSource,
+ edit: DebugSourceEdit(
+ startUTF16Offset: replacedRange.location,
+ endUTF16Offset: NSMaxRange(replacedRange),
+ replacement: replacement
+ )
+ )
+ }
+
+ func editDebugBreakpoint(fileURL: URL, line: Int) {
+ let normalizedURL = fileURL.standardizedFileURL
+ debugBreakpointPresentation.pendingEditor = genericDebugFeatureIfActive?.breakpoints
+ .filter {
+ $0.fileURL.standardizedFileURL == normalizedURL && $0.line == line
+ }
+ .min { ($0.column ?? 0) < ($1.column ?? 0) }
+ }
+
+ func updateDebugBreakpoint(
+ _ breakpoint: GenericDebugBreakpoint,
+ enabled: Bool,
+ condition: String?,
+ hitCondition: String?,
+ logMessage: String?
+ ) {
+ debugBreakpointPresentation.pendingEditor = nil
+ guard let expectedWorkspaceURL = workspaceURL,
+ workspaceRelativePath(
+ for: breakpoint.fileURL,
+ root: expectedWorkspaceURL
+ ) != nil else { return }
+ Task { [weak self] in
+ guard let self,
+ self.workspaceURL == expectedWorkspaceURL,
+ let feature = await activateDebugModule()?.genericFeature,
+ self.workspaceURL == expectedWorkspaceURL else { return }
+ feature.updateBreakpoint(
+ fileURL: breakpoint.fileURL,
+ line: breakpoint.line,
+ enabled: enabled,
+ condition: condition,
+ hitCondition: hitCondition,
+ logMessage: logMessage
+ )
}
- return descriptor.id != "java"
- || descriptor.capabilities.contains(.debugAdapter)
}
- private func startGenericDebugging(_ document: EditorDocument) {
- Task { [weak self] in await self?.startGenericDebuggingAfterActivation(document) }
+ func runToCursor(fileURL: URL, line: Int, column: Int) {
+ guard let feature = genericDebugFeatureIfActive,
+ feature.state == .paused,
+ feature.capabilities.supportsGotoTargetsRequest else {
+ showNotification("Run to Cursor is unavailable for the active debug session")
+ return
+ }
+ feature.requestRunToCursor(
+ fileURL: fileURL,
+ line: line,
+ column: column
+ ) { [weak self, weak feature] result in
+ switch result {
+ case .success(let targets):
+ guard let target = targets.min(by: {
+ abs(($0.column ?? column) - column) < abs(($1.column ?? column) - column)
+ }) else {
+ self?.showNotification("No executable location was found at the cursor")
+ return
+ }
+ feature?.runToCursor(target)
+ case .failure(let error):
+ self?.showNotification(error.localizedDescription)
+ }
+ }
}
- private func startGenericDebuggingAfterActivation(_ document: EditorDocument) async {
+ func requestDebugHover(
+ expression: String,
+ completion: @escaping (String?) -> Void
+ ) {
+ guard let feature = genericDebugFeatureIfActive,
+ feature.state == .paused else {
+ completion(nil)
+ return
+ }
+ feature.evaluateForHover(expression) { variable in
+ guard let variable else {
+ completion(nil)
+ return
+ }
+ let type = variable.type.map { " : \($0)" } ?? ""
+ completion("\(expression)\(type) = \(variable.value)")
+ }
+ }
+
+ private func startGenericDebuggingAfterActivation(
+ fileURL: URL,
+ document: EditorDocument?
+ ) async {
guard let workspaceURL,
- let provider = languageProviderCatalog.provider(for: document.url),
+ let provider = languageProviderCatalog.provider(for: fileURL),
let runFeature = await activateExecutionModule()?.runFeature,
let genericDebugFeature = await activateDebugModule()?.genericFeature else {
showNotification("No language provider is available for this file")
return
}
- if document.isDirty {
+ // Debug is the second execution mode for the Run selection. Re-apply
+ // the selection here so its project-scoped Java runtime override is
+ // active even when the Run panel was never opened in this session.
+ if let selectedConfiguration = runFeature.selectedConfiguration {
+ runFeature.select(selectedConfiguration)
+ }
+ if let document, document.isDirty {
do {
let previousText = document.savedText
try saveDocument(document)
@@ -561,14 +1301,38 @@ extension AppModel {
return
}
}
+ // Reject an occupied service port before asking JDT LS to resolve the
+ // launch target. A failed preflight therefore creates no language
+ // service, Debug Adapter, terminal, or Java child process.
+ if provider.id == "java",
+ let selectedConfiguration = runFeature.selectedConfiguration,
+ let port = runFeature.configuredServerPort(for: selectedConfiguration),
+ !debugPortAvailabilityChecker.isPortAvailable(port) {
+ showNotification(
+ "Port \(port) is already in use. Stop the process using it or change server.port in the Run configuration."
+ )
+ isDebugVisible = true
+ return
+ }
let configuration: DebugLaunchConfiguration
do {
+ let javaTarget: JavaDebugLaunchTarget?
+ if provider.id == "java" {
+ let sessions = try await languageSessionsForWorkspaceMaintenance()
+ javaTarget = try await sessions.resolveJavaDebugLaunchTarget(
+ fileURL: fileURL,
+ rootURL: workspaceURL
+ )
+ } else {
+ javaTarget = nil
+ }
configuration = try debugLaunchConfigurationResolver.resolve(
provider: provider,
- documentURL: document.url,
+ documentURL: fileURL,
workspaceURL: workspaceURL,
configurations: runFeature.configurations,
selectedConfiguration: runFeature.selectedConfiguration,
+ javaTarget: javaTarget,
options: { [runFeature] in runFeature.options(for: $0) }
)
} catch {
@@ -576,7 +1340,7 @@ extension AppModel {
return
}
guard genericDebugFeature.start(
- fileURL: document.url,
+ fileURL: fileURL,
rootURL: workspaceURL,
configuration: configuration
) else {
@@ -584,6 +1348,10 @@ extension AppModel {
isDebugVisible = true
return
}
+ showDebugToolWindow()
+ }
+
+ private func showDebugToolWindow() {
isDebugVisible = true
isGitLogVisible = false
isTerminalVisible = false
diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+ExecutionModules.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+ExecutionModules.swift
index 9c95a36db..89483485b 100644
--- a/macos/Sources/Lithe/Models/AppModel/AppModel+ExecutionModules.swift
+++ b/macos/Sources/Lithe/Models/AppModel/AppModel+ExecutionModules.swift
@@ -6,7 +6,6 @@ import LitheExecutionModule
@MainActor
extension AppModel {
struct DebugFeatureAccess {
- let javaFeature: JavaDebugFeatureModel
let genericFeature: GenericDebugFeatureModel
}
struct ExecutionFeatureAccess {
@@ -18,9 +17,6 @@ extension AppModel {
var mavenFeatureIfActive: MavenFeatureModel? { executionCapability?.mavenFeature }
var runFeatureIfActive: RunFeatureModel? { executionCapability?.runFeature }
- var debugFeatureIfActive: JavaDebugFeatureModel? {
- debugCapability?.javaFeature as? JavaDebugFeatureModel
- }
var genericDebugFeatureIfActive: GenericDebugFeatureModel? {
debugCapability?.genericFeature as? GenericDebugFeatureModel
}
@@ -54,27 +50,117 @@ extension AppModel {
}
func activateDebugModule() async -> DebugFeatureAccess? {
- if let javaFeature = debugFeatureIfActive,
- let genericFeature = genericDebugFeatureIfActive {
- return DebugFeatureAccess(javaFeature: javaFeature, genericFeature: genericFeature)
+ if let genericFeature = genericDebugFeatureIfActive {
+ configureDebugHostHandlers(genericFeature)
+ if let workspaceURL { genericFeature.openWorkspace(at: workspaceURL) }
+ return DebugFeatureAccess(genericFeature: genericFeature)
}
do {
let value = try await services.moduleRuntime.activateCapability(.debugWorkspace)
guard let capability = value as? LitheDebugModule.DebugModuleCapability,
- let javaFeature = capability.javaFeature as? JavaDebugFeatureModel,
let genericFeature = capability.genericFeature as? GenericDebugFeatureModel else { return nil }
+ configureDebugHostHandlers(genericFeature)
cacheModuleCapability(capability, id: .debugWorkspace, moduleID: .debug)
- self.javaFeature.configureRuntime(
- mavenFeature: mavenFeatureIfActive,
- debugFeature: javaFeature
- )
- observeModuleFeature(.debug, observation: javaFeature.objectWillChange.sink { [weak self] _ in
+ if let workspaceURL { genericFeature.openWorkspace(at: workspaceURL) }
+ observeModuleFeature(.debug, observation: genericFeature.objectWillChange.sink { [weak self] _ in
self?.scheduleObjectWillChangeRelay()
})
- return DebugFeatureAccess(javaFeature: javaFeature, genericFeature: genericFeature)
+ observeModuleFeature(.debug, observation: genericFeature.$state
+ .removeDuplicates()
+ .sink { [weak self] state in
+ self?.handleDebugSessionStateChange(state)
+ })
+ return DebugFeatureAccess(genericFeature: genericFeature)
} catch {
showNotification(error.localizedDescription)
return nil
}
}
+
+ private func configureDebugHostHandlers(_ feature: GenericDebugFeatureModel) {
+ feature.onStoppedLocation = { [weak self] url, line, column in
+ self?.revealDebugLocation(url: url, line: line, column: column)
+ }
+ feature.onAutomaticVariableInspectionRequest = { [weak self, weak feature] frame in
+ guard let self, let feature else { return }
+ requestAutomaticDebugVariables(for: frame, feature: feature)
+ }
+ configureDebugRunInTerminalHandler(feature)
+ }
+
+ private func requestAutomaticDebugVariables(
+ for frame: DebugStackFrame,
+ feature: GenericDebugFeatureModel
+ ) {
+ guard feature.providerID == "java",
+ let sourceURL = frame.sourceURL?.standardizedFileURL,
+ let source = debugSourceText(at: sourceURL) else {
+ feature.requestAutomaticVariables([])
+ return
+ }
+ let expressions = DebugAutomaticExpressionProjection.javaExpressions(
+ forLine: max(0, frame.line - 1),
+ in: source as NSString
+ )
+ feature.requestAutomaticVariables(expressions)
+ }
+
+ private func debugSourceText(at sourceURL: URL) -> String? {
+ if let document = openDocuments.first(where: {
+ $0.url.standardizedFileURL == sourceURL
+ }) {
+ return document.text
+ }
+ guard let metadata = services.fileStorage.metadata(for: sourceURL),
+ metadata.isRegularFile,
+ let byteCount = metadata.byteCount,
+ byteCount <= 2_000_000,
+ let data = try? services.fileStorage.readData(from: sourceURL, options: []),
+ let source = String(data: data, encoding: .utf8) else { return nil }
+ return source
+ }
+
+ private func configureDebugRunInTerminalHandler(_ feature: GenericDebugFeatureModel) {
+ feature.onSessionSelectionChanged = { [weak self] debugSessionID in
+ guard let self else { return }
+ self.activeDebugTerminalSessionID = debugSessionID.flatMap {
+ self.activeDebugTerminalSessionIDsByDebugSession[$0]
+ }
+ }
+ feature.onSessionStopped = { [weak self] debugSessionID in
+ self?.stopDebugTerminalProcesses(for: debugSessionID)
+ }
+ feature.onSessionRunInTerminalRequest = { [weak self] debugSessionID, request, completion in
+ guard let self else {
+ completion(.failure(DebugAdapterCapabilityError.unsupported("run in terminal")))
+ return
+ }
+ handleDebugRunInTerminalRequest(
+ request,
+ debugSessionID: debugSessionID,
+ completion: completion
+ )
+ }
+ feature.onRunInTerminalRequest = { [weak self] request, completion in
+ guard let self else {
+ completion(.failure(DebugAdapterCapabilityError.unsupported("run in terminal")))
+ return
+ }
+ handleDebugRunInTerminalRequest(request, debugSessionID: nil, completion: completion)
+ }
+ }
+
+ func restoreDebugBreakpoints(for workspaceURL: URL) async {
+ guard self.workspaceURL == workspaceURL,
+ let persistence = services.debugBreakpointPersistence else { return }
+ do {
+ guard let snapshot = try persistence.loadBreakpoints(for: workspaceURL),
+ snapshot.version == DebugBreakpointSnapshot.currentVersion,
+ !snapshot.breakpoints.isEmpty,
+ self.workspaceURL == workspaceURL else { return }
+ _ = await activateDebugModule()
+ } catch {
+ showNotification(error.localizedDescription)
+ }
+ }
}
diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift
index 8e0adffa2..c41c41a4b 100644
--- a/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift
+++ b/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift
@@ -4,6 +4,7 @@ import LitheLocalHistoryModule
import LitheSearchModule
extension AppModel {
+ var workspaceSnapshotID: UUID? { workspaceFeature.appliedSnapshot?.id }
var springEndpoints: [SpringEndpoint] { springFeature.endpoints }
var springBeans: [SpringBean] { springFeature.beans }
var isIndexingSpring: Bool { springFeature.isIndexing }
@@ -334,7 +335,7 @@ extension AppModel {
switch id {
case "open-project", "settings":
true
- case "save", "find-in-file", "replace-in-file", "go-to-line", "local-history", "reveal-in-finder":
+ case "save", "find-in-file", "replace-in-file", "go-to-line", "local-history", "reveal-in-finder", "toggle-breakpoint":
activeDocument != nil
case "find-next", "find-previous":
isFindBarVisible && findMatchCount > 0
@@ -349,11 +350,13 @@ extension AppModel {
supportsLanguageServerFeature(.references)
case "go-to-implementation":
supportsLanguageServerFeature(.implementation)
+ case "debug-resume", "debug-step-over", "debug-step-into", "debug-step-out":
+ genericDebugFeatureIfActive?.state == .paused
case "close-project", "search-everywhere", "search-in-project",
"replace-in-project", "project-local-history", "run", "debug",
"stop-run", "stop-debug", "toggle-terminal", "toggle-problems",
"toggle-maven", "toggle-git-log", "toggle-run", "toggle-tests",
- "toggle-debug", "spring-endpoints":
+ "toggle-debug", "view-breakpoints", "spring-endpoints":
workspaceURL != nil
default:
false
diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift
index 8e8affaa7..fb73bed73 100644
--- a/macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift
+++ b/macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift
@@ -1,7 +1,34 @@
+import Combine
import Foundation
+import LitheDebugModule
+import LitheCoreContracts
import LitheTerminalModule
extension AppModel {
+ var terminalCapability: LitheTerminalModule.TerminalModuleCapability? {
+ cachedModuleCapability(.terminalWorkspace)
+ }
+
+ var terminalFeature: TerminalFeatureModel? { terminalCapability?.feature }
+ var availableTerminalShells: [String] { terminalFeature?.availableShells ?? [] }
+
+ @MainActor
+ func activateTerminalModule() async -> Bool {
+ guard terminalCapability == nil else { return true }
+ do {
+ let value = try await services.moduleRuntime.activateCapability(.terminalWorkspace)
+ guard let capability = value as? LitheTerminalModule.TerminalModuleCapability else { return false }
+ let feature = capability.feature
+ cacheModuleCapability(capability, id: .terminalWorkspace, moduleID: .terminal)
+ observeModuleFeature(.terminal, observation: feature.objectWillChange.sink { [weak self] _ in
+ self?.scheduleObjectWillChangeRelay()
+ })
+ return true
+ } catch {
+ return false
+ }
+ }
+
func toggleTerminal() {
isTerminalVisible.toggle()
guard isTerminalVisible else { return }
@@ -74,6 +101,150 @@ extension AppModel {
}
}
+ func handleDebugRunInTerminalRequest(
+ _ request: DebugRunInTerminalRequest,
+ debugSessionID: DebugSessionID? = nil,
+ completion: @escaping DebugRunInTerminalCompletion
+ ) {
+ Task { @MainActor [weak self] in
+ guard let self else {
+ completion(.failure(DebugTerminalLaunchError.hostUnavailable))
+ return
+ }
+ do {
+ completion(.success(try await startDebugProcessInTerminal(
+ request,
+ debugSessionID: debugSessionID
+ )))
+ } catch {
+ completion(.failure(error))
+ }
+ }
+ }
+
+ private func startDebugProcessInTerminal(
+ _ request: DebugRunInTerminalRequest,
+ debugSessionID: DebugSessionID?
+ ) async throws -> DebugRunInTerminalResponse {
+ guard request.kind == .integrated else {
+ throw DebugTerminalLaunchError.externalTerminalUnsupported
+ }
+ guard !request.argsCanBeInterpretedByShell else {
+ throw DebugTerminalLaunchError.shellInterpretationUnsupported
+ }
+ guard let executablePath = request.args.first, !executablePath.isEmpty else {
+ throw DebugTerminalLaunchError.missingExecutable
+ }
+ guard let workspaceURL else {
+ throw DebugTerminalLaunchError.workspaceUnavailable
+ }
+ guard await activateTerminalModule(), let feature = terminalFeature else {
+ throw DebugTerminalLaunchError.terminalUnavailable
+ }
+ let workingDirectory = request.cwd.isEmpty
+ ? workspaceURL.standardizedFileURL.path
+ : request.cwd
+ guard workingDirectory.hasPrefix("/") else {
+ throw DebugTerminalLaunchError.invalidWorkingDirectory
+ }
+ let launch = TerminalProcessLaunch(
+ title: request.title,
+ executablePath: executablePath,
+ arguments: Array(request.args.dropFirst()),
+ workingDirectory: workingDirectory,
+ environmentChanges: request.environment.map {
+ TerminalEnvironmentChange(name: $0.name, value: $0.value)
+ }
+ )
+ let created = try feature.createProcessSession(launch) { [weak self] output in
+ self?.genericDebugFeatureIfActive?.appendDebuggeeOutput(output)
+ }
+ configureTerminalSession(created.session)
+ terminalPlacementFeature.registerSession(created.session.id)
+ debugTerminalSessionIDs.insert(created.session.id)
+ activeDebugTerminalSessionID = created.session.id
+ if let debugSessionID {
+ debugTerminalSessionIDsByDebugSession[debugSessionID, default: []].insert(created.session.id)
+ activeDebugTerminalSessionIDsByDebugSession[debugSessionID] = created.session.id
+ }
+ isTerminalVisible = true
+ isTestsVisible = false
+ isGitLogVisible = false
+ isReferencesVisible = false
+ isProblemsVisible = false
+ isMavenVisible = false
+ isRunVisible = false
+ isDebugVisible = false
+ created.session.focus()
+ return DebugRunInTerminalResponse(processID: Int(created.processID))
+ }
+
+ func stopDebugTerminalProcesses() {
+ for sessionID in debugTerminalSessionIDs.sorted(by: { $0.uuidString < $1.uuidString }) {
+ terminalSessions.first(where: { $0.id == sessionID })?.stop()
+ }
+ debugTerminalSessionIDs.removeAll()
+ activeDebugTerminalSessionID = nil
+ debugTerminalSessionIDsByDebugSession.removeAll()
+ activeDebugTerminalSessionIDsByDebugSession.removeAll()
+ }
+
+ func stopDebugTerminalProcesses(for debugSessionID: DebugSessionID) {
+ let sessionIDs = debugTerminalSessionIDsByDebugSession.removeValue(forKey: debugSessionID) ?? []
+ for sessionID in sessionIDs.sorted(by: { $0.uuidString < $1.uuidString }) {
+ terminalSessions.first(where: { $0.id == sessionID })?.stop()
+ debugTerminalSessionIDs.remove(sessionID)
+ }
+ activeDebugTerminalSessionIDsByDebugSession.removeValue(forKey: debugSessionID)
+ if let activeDebugTerminalSessionID,
+ sessionIDs.contains(activeDebugTerminalSessionID) {
+ self.activeDebugTerminalSessionID = nil
+ }
+ }
+
+ var isDebugStandardInputAvailable: Bool {
+ guard let terminalFeature else { return false }
+ let debugSessionID = genericDebugFeatureIfActive?.activeSessionID
+ let scopedIDs = debugSessionID.flatMap { debugTerminalSessionIDsByDebugSession[$0] } ?? []
+ let candidateIDs = [debugSessionID.flatMap { activeDebugTerminalSessionIDsByDebugSession[$0] }]
+ .compactMap { $0 }
+ + scopedIDs.sorted(by: { $0.uuidString < $1.uuidString })
+ + [activeDebugTerminalSessionID].compactMap { $0 }
+ + debugTerminalSessionIDs.sorted(by: { $0.uuidString < $1.uuidString })
+ return candidateIDs.contains { sessionID in
+ guard let session = terminalFeature.terminalSessions.first(where: { $0.id == sessionID }) else {
+ return false
+ }
+ return session.isRunning && session.isReady
+ }
+ }
+
+ @discardableResult
+ func sendDebugStandardInput(_ input: String) -> Bool {
+ guard !input.isEmpty, let terminalFeature else {
+ showNotification("No running debug process accepts standard input")
+ return false
+ }
+ let debugSessionID = genericDebugFeatureIfActive?.activeSessionID
+ let scopedIDs = debugSessionID.flatMap { debugTerminalSessionIDsByDebugSession[$0] } ?? []
+ let candidateIDs = [debugSessionID.flatMap { activeDebugTerminalSessionIDsByDebugSession[$0] }]
+ .compactMap { $0 }
+ + scopedIDs.sorted(by: { $0.uuidString < $1.uuidString })
+ + [activeDebugTerminalSessionID].compactMap { $0 }
+ + debugTerminalSessionIDs.sorted(by: { $0.uuidString < $1.uuidString })
+ guard let sessionID = candidateIDs.first(where: { sessionID in
+ guard let session = terminalFeature.terminalSessions.first(where: { $0.id == sessionID }) else {
+ return false
+ }
+ return session.isRunning && session.isReady
+ }) else {
+ showNotification("No running debug process accepts standard input")
+ return false
+ }
+ let payload = input.hasSuffix("\n") ? input : input + "\n"
+ return terminalFeature.sendInput(payload, to: sessionID)
+ }
+
private func openTerminalLink(_ link: String, params: [String: String], sessionID: UUID) {
guard let session = terminalSessions.first(where: { $0.id == sessionID }),
let fallbackDirectory = session.currentDirectory ?? workspaceURL else { return }
@@ -187,8 +358,19 @@ extension AppModel {
private func closeTerminalSession(_ session: TerminalSession) {
guard terminalSessions.contains(where: { $0.id == session.id }) else { return }
- if pendingTerminalCloseSessionID == session.id {
- pendingTerminalCloseSessionID = nil
+ pendingTerminalCloseSessionID = nil
+ debugTerminalSessionIDs.remove(session.id)
+ for debugSessionID in debugTerminalSessionIDsByDebugSession.keys {
+ debugTerminalSessionIDsByDebugSession[debugSessionID]?.remove(session.id)
+ if debugTerminalSessionIDsByDebugSession[debugSessionID]?.isEmpty == true {
+ debugTerminalSessionIDsByDebugSession[debugSessionID] = nil
+ }
+ if activeDebugTerminalSessionIDsByDebugSession[debugSessionID] == session.id {
+ activeDebugTerminalSessionIDsByDebugSession[debugSessionID] = nil
+ }
+ }
+ if activeDebugTerminalSessionID == session.id {
+ activeDebugTerminalSessionID = nil
}
editorTabOrderFeature.remove(.terminal(session.id))
terminalPlacementFeature.removeSession(session.id)
@@ -202,7 +384,10 @@ extension AppModel {
func restartActiveTerminal() { terminalFeature?.restartActiveSession() }
func restartActiveTerminal(using shellPath: String) { terminalFeature?.restartActiveSession(using: shellPath) }
func stopTerminalSessions() {
- pendingTerminalCloseSessionID = nil
+ debugTerminalSessionIDs.removeAll()
+ activeDebugTerminalSessionID = nil
+ debugTerminalSessionIDsByDebugSession.removeAll()
+ activeDebugTerminalSessionIDsByDebugSession.removeAll()
editorTabOrderFeature.removeAllTerminals()
terminalPlacementFeature.reset()
terminalFeature?.stopAllSessions()
@@ -217,3 +402,32 @@ extension AppModel {
return sessionIDs.compactMap { sessionsByID[$0] }
}
}
+
+private enum DebugTerminalLaunchError: LocalizedError {
+ case hostUnavailable
+ case externalTerminalUnsupported
+ case shellInterpretationUnsupported
+ case missingExecutable
+ case workspaceUnavailable
+ case terminalUnavailable
+ case invalidWorkingDirectory
+
+ var errorDescription: String? {
+ switch self {
+ case .hostUnavailable:
+ "The application closed before the debug terminal could start."
+ case .externalTerminalUnsupported:
+ "This debug session requires an external terminal, which is not supported."
+ case .shellInterpretationUnsupported:
+ "This debug session requires shell-interpreted terminal arguments."
+ case .missingExecutable:
+ "The debug adapter did not provide a terminal executable."
+ case .workspaceUnavailable:
+ "Open a project before starting a debug terminal."
+ case .terminalUnavailable:
+ "The integrated terminal is unavailable."
+ case .invalidWorkingDirectory:
+ "The debug adapter provided an invalid terminal working directory."
+ }
+ }
+}
diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift
index 164c7bbf1..6d442cd4e 100644
--- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift
+++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift
@@ -8,7 +8,6 @@ import LitheLocalHistoryModule
import LitheLanguageIntelligenceModule
import LitheModuleAPI
import LitheSearchModule
-import LitheTerminalModule
import LitheWorkspaceModule
import LitheCoreContracts
@@ -108,11 +107,13 @@ final class AppModel: ObservableObject, Identifiable {
@Published var isGitLogVisible = false
@Published var isTerminalVisible = false
@Published var pendingTerminalCloseSessionID: UUID?
+ var pendingRunAction: PendingRunAction?
@Published var isReferencesVisible = false
@Published var isProblemsVisible = false
@Published var isMavenVisible = false
@Published var isSpringVisible = false
@Published var isDebugVisible = false
+ @Published var debugBreakpointPresentation = DebugBreakpointPresentationState()
@Published var isDiscourseCommunityVisible = false
@Published var isImplementationChooserVisible = false
var languageProviderCatalog: LanguageProviderCatalog { languageToolingFeature.catalog }
@@ -143,6 +144,7 @@ final class AppModel: ObservableObject, Identifiable {
private var requestProjectOpen: ((URL) -> Void)?
private var didCloseProject: (() -> Void)?
private var securityScopedWorkspaceURL: URL?
+ let javaTestWorkflowState = JavaTestWorkflowState()
let services: AppServices
let platformUI: any PlatformUI
let settings: AppSettings
@@ -151,11 +153,16 @@ final class AppModel: ObservableObject, Identifiable {
let runtimeFeature: RuntimeSettingsFeatureModel
let languageToolingFeature: LanguageToolingFeatureModel
let debugLaunchConfigurationResolver: DebugLaunchConfigurationResolver
+ let debugPortAvailabilityChecker: any DebugPortAvailabilityChecking
let workspaceFeature: WorkspaceFeatureModel
let githubFeature: GitHubFeatureModel
let discourseCommunityFeature: DiscourseCommunityFeatureModel
let editorTabOrderFeature = EditorTabOrderFeatureModel()
let terminalPlacementFeature: TerminalPlacementFeatureModel
+ var debugTerminalSessionIDs: Set = []
+ var activeDebugTerminalSessionID: UUID?
+ var debugTerminalSessionIDsByDebugSession: [DebugSessionID: Set] = [:]
+ var activeDebugTerminalSessionIDsByDebugSession: [DebugSessionID: UUID] = [:]
private struct CachedModuleCapability {
let moduleID: ModuleID
let value: AnyObject
@@ -174,28 +181,6 @@ final class AppModel: ObservableObject, Identifiable {
var searchCapability: LitheSearchModule.SearchModuleCapability? {
cachedModuleCapability(.searchWorkspace)
}
- var terminalCapability: LitheTerminalModule.TerminalModuleCapability? {
- cachedModuleCapability(.terminalWorkspace)
- }
- var terminalFeature: TerminalFeatureModel? { terminalCapability?.feature }
- var availableTerminalShells: [String] { terminalFeature?.availableShells ?? [] }
-
- @MainActor
- func activateTerminalModule() async -> Bool {
- guard terminalCapability == nil else { return true }
- do {
- let value = try await services.moduleRuntime.activateCapability(.terminalWorkspace)
- guard let capability = value as? LitheTerminalModule.TerminalModuleCapability else { return false }
- let feature = capability.feature
- cacheModuleCapability(capability, id: .terminalWorkspace, moduleID: .terminal)
- observeModuleFeature(.terminal, observation: feature.objectWillChange.sink { [weak self] _ in
- self?.scheduleObjectWillChangeRelay()
- })
- return true
- } catch {
- return false
- }
- }
var historyCapability: LitheLocalHistoryModule.HistoryModuleCapability? {
cachedModuleCapability(.historyWorkspace)
}
@@ -375,6 +360,7 @@ final class AppModel: ObservableObject, Identifiable {
sessionsProvider: { nil }
)
debugLaunchConfigurationResolver = services.debugLaunchConfigurationResolver
+ debugPortAvailabilityChecker = services.debugPortAvailabilityChecker
documentFeature = DocumentFeatureModel(
operations: services.workspaceOperations,
documentLifecycleDecider: services.documentLifecycleDecider,
@@ -586,8 +572,6 @@ final class AppModel: ObservableObject, Identifiable {
.sink { [weak self] ids in self?.editorTabOrderFeature.reconcileDocuments(orderedIDs: ids) }
javaFeature.configure(
documentProvider: { [weak self] in self?.activeDocument },
- caretProvider: { [weak self] in self?.editorCaret },
- notify: { [weak self] message in self?.showNotification(message) },
loadBlame: { [weak self] fileURL in
guard let self else { return [] }
guard let feature = await self.activateGitModule() else { return [] }
@@ -712,6 +696,10 @@ final class AppModel: ObservableObject, Identifiable {
func shutdownProjectSession() async {
shortcutDetector?.stop()
+ Task { [weak self] in
+ await self?.services.moduleRuntime.shutdownAll()
+ }
+ cancelJavaTestWorkflows()
languageToolingSessionsIfActive?.stopAll()
languageTestServiceIfActive?.stop()
stopTerminalSessions()
@@ -742,7 +730,8 @@ final class AppModel: ObservableObject, Identifiable {
}
private func reloadJavaRuntimeServices() {
- debugFeatureIfActive?.stop()
+ cancelJavaTestWorkflows()
+ genericDebugFeatureIfActive?.stop()
mavenFeatureIfActive?.stop()
languageToolingSessionsIfActive?.stopLanguageServer(providerID: "java")
javaFeature.stop()
@@ -762,10 +751,6 @@ final class AppModel: ObservableObject, Identifiable {
/// Loads build-system and run state at the workspace boundary. The generic
/// run lifecycle is intentionally not owned by JavaFeatureModel.
func loadProjectServices(at workspaceURL: URL, files: [URL]) async {
- let execution = await activateExecutionModule()
- if let execution {
- await execution.projectDevelopment.loadProject(at: workspaceURL, files: files)
- }
prepareJavaLanguageServerForWorkspaceIfNeeded(
at: workspaceURL,
files: files
@@ -777,7 +762,9 @@ final class AppModel: ObservableObject, Identifiable {
($0.url.standardizedFileURL, $0.text)
})
)
- execution?.tests.discover(workspaceURL: workspaceURL, files: files)
+ guard let execution = await activateExecutionModule() else { return }
+ execution.tests.discover(workspaceURL: workspaceURL, files: files)
+ await execution.projectDevelopment.loadProject(at: workspaceURL, files: files)
}
var projectName: String {
@@ -933,7 +920,7 @@ final class AppModel: ObservableObject, Identifiable {
// every provider session before replacing the catalog or clearing the
// document projection so no old-root documents, diagnostics, or
// responses can survive into the next workspace.
- cancelJavaLanguageServerPreparation()
+ cancelJavaWorkspaceWorkflows()
languageToolingSessionsIfActive?.stopAll()
reloadLanguageProviderCatalog(for: normalizedURL)
stopTerminalSessions()
@@ -942,8 +929,8 @@ final class AppModel: ObservableObject, Identifiable {
runtimeFeature.openProject(at: normalizedURL)
mavenFeatureIfActive?.reset()
runFeatureIfActive?.reset()
- debugFeatureIfActive?.reset()
genericDebugFeatureIfActive?.reset()
+ debugBreakpointPresentation.reset()
clearLanguageNavigationProjection()
javaFeature.stop()
springFeature.reset()
@@ -977,6 +964,8 @@ final class AppModel: ObservableObject, Identifiable {
recentProjects = recentProjectsStore.record(normalizedURL, in: recentProjects)
Task {
+ await restoreDebugBreakpoints(for: normalizedURL)
+ guard workspaceURL == normalizedURL else { return }
_ = await workspaceFeature.rebuild(
at: normalizedURL,
rules: visibilityRules,
@@ -1044,13 +1033,14 @@ final class AppModel: ObservableObject, Identifiable {
isTestsVisible = false
isDebugVisible = false
stopTerminalSessions()
+ cancelJavaWorkspaceWorkflows()
languageToolingSessionsIfActive?.stopAll()
languageTestServiceIfActive?.reset()
runtimeFeature.closeProject()
mavenFeatureIfActive?.reset()
runFeatureIfActive?.reset()
- debugFeatureIfActive?.reset()
genericDebugFeatureIfActive?.reset()
+ debugBreakpointPresentation.reset()
javaFeature.stop()
springFeature.reset()
editorChrome.reset()
diff --git a/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift b/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift
index 16157e9ec..530c543c7 100644
--- a/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift
+++ b/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift
@@ -1,5 +1,6 @@
import Foundation
import LitheCoreContracts
+import LitheDebugModule
/// Product-level availability switches for integrations that require external
/// credentials or services. Keeping these switches in one place lets the UI
@@ -30,6 +31,16 @@ struct WorkbenchNotification: Identifiable, Equatable {
}
}
+struct DebugBreakpointPresentationState {
+ var isManagerPresented = false
+ var pendingEditor: GenericDebugBreakpoint?
+
+ mutating func reset() {
+ isManagerPresented = false
+ pendingEditor = nil
+ }
+}
+
enum SidebarDestination: String, CaseIterable, Identifiable {
case project
case changes
diff --git a/macos/Sources/Lithe/Models/Java/JavaDebugModels.swift b/macos/Sources/Lithe/Models/Java/JavaDebugModels.swift
deleted file mode 100644
index bdb298499..000000000
--- a/macos/Sources/Lithe/Models/Java/JavaDebugModels.swift
+++ /dev/null
@@ -1,84 +0,0 @@
-import Foundation
-
-enum JavaDebugTargetKind: String, CaseIterable, Identifiable, Sendable {
- case currentFile
- case runConfiguration
- case remote
-
- var id: String { rawValue }
-
- var title: String {
- switch self {
- case .currentFile: "Current File"
- case .runConfiguration: "Maven / Spring Boot"
- case .remote: "Remote JVM / Tomcat"
- }
- }
-
- var systemImage: String {
- switch self {
- case .currentFile: "doc.text"
- case .runConfiguration: "shippingbox"
- case .remote: "network"
- }
- }
-}
-
-enum JavaDebugSessionState: String, Sendable {
- case idle
- case launching
- case running
- case paused
- case finished
- case failed
-
- var title: String {
- switch self {
- case .idle: "Ready"
- case .launching: "Launching"
- case .running: "Running"
- case .paused: "Paused"
- case .finished: "Finished"
- case .failed: "Failed"
- }
- }
-}
-
-struct JavaDebugBreakpoint: Identifiable, Hashable, Sendable {
- let id: String
- let fileURL: URL
- let line: Int
- let className: String
-
- var title: String {
- "\(fileURL.lastPathComponent):\(line)"
- }
-}
-
-struct JavaDebugVariable: Identifiable, Hashable, Sendable {
- let id: String
- let name: String
- let expression: String
- var value: String
- var children: [JavaDebugVariable]
- var isExpanded: Bool
- let isExpandable: Bool
-
- var canExpand: Bool {
- isExpandable || !children.isEmpty
- }
-}
-
-struct JavaDebugThread: Identifiable, Hashable, Sendable {
- let id: String
- let name: String
- let status: String
- let isCurrent: Bool
-}
-
-struct JavaDebugStackFrame: Identifiable, Hashable, Sendable {
- let level: Int
- let description: String
-
- var id: String { "\(level):\(description)" }
-}
diff --git a/macos/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift b/macos/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift
index a19e9868e..3c721360f 100644
--- a/macos/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift
+++ b/macos/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift
@@ -21,6 +21,12 @@ enum LitheCommandCatalog {
command("debug", "Debug", "Start debugging", .run, "d", [.control]),
command("stop-run", "Stop Run", "Stop the current run", .run),
command("stop-debug", "Stop Debug", "Stop the current debug session", .run),
+ command("debug-resume", "Debug: Resume", "Resume the paused debug session", .run, "f9"),
+ command("debug-step-over", "Debug: Step Over", "Execute the next source line", .run, "f8"),
+ command("debug-step-into", "Debug: Step Into", "Enter the next function call", .run, "f7"),
+ command("debug-step-out", "Debug: Step Out", "Return from the current function", .run, "f8", [.shift]),
+ command("toggle-breakpoint", "Toggle Line Breakpoint", "Add or remove a breakpoint at the caret", .run, "f8", [.command]),
+ command("view-breakpoints", "View Breakpoints", "Manage all project breakpoints", .run, "f8", [.shift, .command]),
LitheCommandDefinition(
id: "search-everywhere",
diff --git a/macos/Sources/Lithe/Models/LitheAction.swift b/macos/Sources/Lithe/Models/LitheAction.swift
index 7949535c9..9da18636f 100644
--- a/macos/Sources/Lithe/Models/LitheAction.swift
+++ b/macos/Sources/Lithe/Models/LitheAction.swift
@@ -59,6 +59,12 @@ enum LitheActionRegistry {
action("debug", model: model) { model.startDebugging() },
action("stop-run", model: model) { model.stopSelectedRun() },
action("stop-debug", model: model) { model.stopDebugging() },
+ action("debug-resume", model: model) { model.resumeDebugging() },
+ action("debug-step-over", model: model) { model.stepOverDebugging() },
+ action("debug-step-into", model: model) { model.stepIntoDebugging() },
+ action("debug-step-out", model: model) { model.stepOutDebugging() },
+ action("toggle-breakpoint", model: model) { model.toggleDebugBreakpointAtCaret() },
+ action("view-breakpoints", model: model) { model.showDebugBreakpointManager() },
action("open-project", model: model) { model.chooseProject() },
action("close-project", model: model) { model.closeProject() },
action("settings", model: model) { model.showSettings() },
diff --git a/macos/Sources/Lithe/Models/Runtime/ProjectRuntimeModels.swift b/macos/Sources/Lithe/Models/Runtime/ProjectRuntimeModels.swift
index c898ae41c..8beb153ed 100644
--- a/macos/Sources/Lithe/Models/Runtime/ProjectRuntimeModels.swift
+++ b/macos/Sources/Lithe/Models/Runtime/ProjectRuntimeModels.swift
@@ -80,7 +80,6 @@ enum JavaEnvironmentStatus: Equatable, Sendable {
case ready
case jdkMissing
case configuredJDKInvalid(path: String)
- case jdbMissing
var requiresAttention: Bool {
self != .checking && self != .ready
@@ -88,7 +87,7 @@ enum JavaEnvironmentStatus: Equatable, Sendable {
var blocksJavaRun: Bool {
switch self {
- case .jdkMissing, .configuredJDKInvalid, .jdbMissing: true
+ case .jdkMissing, .configuredJDKInvalid: true
case .checking, .ready: false
}
}
@@ -99,15 +98,13 @@ struct JavaEnvironmentReport: Equatable, Sendable {
let projectURL: URL
let javaHomePath: String?
let javaExecutablePath: String?
- let jdbExecutablePath: String?
static func checking(for projectURL: URL) -> Self {
Self(
status: .checking,
projectURL: projectURL.standardizedFileURL,
javaHomePath: nil,
- javaExecutablePath: nil,
- jdbExecutablePath: nil
+ javaExecutablePath: nil
)
}
@@ -117,22 +114,19 @@ struct JavaEnvironmentReport: Equatable, Sendable {
case .ready: "Java environment ready"
case .jdkMissing: "JDK not found"
case .configuredJDKInvalid: "Configured JDK is invalid"
- case .jdbMissing: "Java debugger is incomplete"
}
}
var message: String {
switch status {
case .checking:
- "Lithe is checking the JDK and Java debugger."
+ "Lithe is checking the project JDK."
case .ready:
- "JDK and JDB are available for this project."
+ "A usable JDK is available for this project."
case .jdkMissing:
"This project contains Java sources, but no usable JDK was detected."
case .configuredJDKInvalid(let path):
"The configured JDK path is not a valid JDK: \(path)"
- case .jdbMissing:
- "A JDK was found, but its bin/jdb debugger is unavailable."
}
}
@@ -143,8 +137,6 @@ struct JavaEnvironmentReport: Equatable, Sendable {
"Choose a JDK in the Java service settings or install a full JDK and set JAVA_HOME."
case .configuredJDKInvalid:
"Choose another JDK in the Java service settings or clear the invalid path."
- case .jdbMissing:
- "Use a full JDK distribution instead of a JRE or minimal runtime."
}
}
}
diff --git a/macos/Sources/Lithe/Platform/MacOS/Debug/MacDebugBreakpointStore.swift b/macos/Sources/Lithe/Platform/MacOS/Debug/MacDebugBreakpointStore.swift
new file mode 100644
index 000000000..1dfc66351
--- /dev/null
+++ b/macos/Sources/Lithe/Platform/MacOS/Debug/MacDebugBreakpointStore.swift
@@ -0,0 +1,45 @@
+import Foundation
+import LitheDebugModule
+
+enum MacDebugBreakpointStoreError: LocalizedError {
+ case invalidData
+
+ var errorDescription: String? {
+ switch self {
+ case .invalidData:
+ "Saved breakpoints could not be read."
+ }
+ }
+}
+
+final class MacDebugBreakpointStore: DebugBreakpointPersisting, @unchecked Sendable {
+ private static let keyPrefix = "lithe.debug.breakpoints."
+ private let store: any KeyValueStore
+ private let lock = NSLock()
+
+ init(store: any KeyValueStore) {
+ self.store = store
+ }
+
+ func loadBreakpoints(for workspaceURL: URL) throws -> DebugBreakpointSnapshot? {
+ lock.lock(); defer { lock.unlock() }
+ guard let data = store.data(forKey: key(for: workspaceURL)) else { return nil }
+ do {
+ return try JSONDecoder().decode(DebugBreakpointSnapshot.self, from: data)
+ } catch {
+ throw MacDebugBreakpointStoreError.invalidData
+ }
+ }
+
+ func saveBreakpoints(_ snapshot: DebugBreakpointSnapshot, for workspaceURL: URL) throws {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys]
+ let data = try encoder.encode(snapshot)
+ lock.lock(); defer { lock.unlock() }
+ store.set(data, forKey: key(for: workspaceURL))
+ }
+
+ private func key(for workspaceURL: URL) -> String {
+ Self.keyPrefix + workspaceURL.standardizedFileURL.path
+ }
+}
diff --git a/macos/Sources/Lithe/Platform/MacOS/Debug/MacDebugOperationDeadlineScheduler.swift b/macos/Sources/Lithe/Platform/MacOS/Debug/MacDebugOperationDeadlineScheduler.swift
new file mode 100644
index 000000000..fca0e7f7d
--- /dev/null
+++ b/macos/Sources/Lithe/Platform/MacOS/Debug/MacDebugOperationDeadlineScheduler.swift
@@ -0,0 +1,35 @@
+import Foundation
+import LitheCoreContracts
+
+@MainActor
+final class MacDebugOperationDeadlineScheduler: DebugOperationDeadlineScheduling {
+ func schedule(
+ afterMilliseconds: Int,
+ action: @escaping @MainActor () -> Void
+ ) -> any DebugOperationDeadline {
+ let item = DispatchWorkItem { action() }
+ DispatchQueue.main.asyncAfter(
+ deadline: .now() + .milliseconds(afterMilliseconds),
+ execute: item
+ )
+ return MacDebugOperationDeadline(item: item)
+ }
+}
+
+@MainActor
+private final class MacDebugOperationDeadline: DebugOperationDeadline {
+ private var item: DispatchWorkItem?
+
+ init(item: DispatchWorkItem) {
+ self.item = item
+ }
+
+ func cancel() {
+ item?.cancel()
+ item = nil
+ }
+
+ deinit {
+ item?.cancel()
+ }
+}
diff --git a/macos/Sources/Lithe/Platform/MacOS/Debug/MacDebugPortAvailabilityChecker.swift b/macos/Sources/Lithe/Platform/MacOS/Debug/MacDebugPortAvailabilityChecker.swift
new file mode 100644
index 000000000..f1ac435cc
--- /dev/null
+++ b/macos/Sources/Lithe/Platform/MacOS/Debug/MacDebugPortAvailabilityChecker.swift
@@ -0,0 +1,25 @@
+import Darwin
+import Foundation
+
+/// Probes loopback TCP ports without creating a long-lived listener.
+@MainActor
+final class MacDebugPortAvailabilityChecker: DebugPortAvailabilityChecking {
+ func isPortAvailable(_ port: Int) -> Bool {
+ guard (1...65_535).contains(port) else { return false }
+ let descriptor = socket(AF_INET, SOCK_STREAM, 0)
+ guard descriptor >= 0 else { return false }
+ defer { _ = close(descriptor) }
+
+ var address = sockaddr_in()
+ address.sin_len = UInt8(MemoryLayout.stride)
+ address.sin_family = sa_family_t(AF_INET)
+ address.sin_port = in_port_t(port).bigEndian
+ address.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1"))
+
+ return withUnsafePointer(to: &address) { pointer in
+ pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) {
+ bind(descriptor, $0, socklen_t(MemoryLayout.stride)) == 0
+ }
+ }
+ }
+}
diff --git a/macos/Sources/Lithe/Platform/MacOS/Debug/MacDebugSteppingFilterStore.swift b/macos/Sources/Lithe/Platform/MacOS/Debug/MacDebugSteppingFilterStore.swift
new file mode 100644
index 000000000..7911f2c4b
--- /dev/null
+++ b/macos/Sources/Lithe/Platform/MacOS/Debug/MacDebugSteppingFilterStore.swift
@@ -0,0 +1,46 @@
+import Foundation
+import LitheCoreContracts
+import LitheDebugModule
+
+enum MacDebugSteppingFilterStoreError: LocalizedError {
+ case invalidData
+
+ var errorDescription: String? {
+ switch self {
+ case .invalidData:
+ "Saved debugger stepping filters could not be read."
+ }
+ }
+}
+
+final class MacDebugSteppingFilterStore: DebugSteppingFilterPersisting, @unchecked Sendable {
+ private static let keyPrefix = "lithe.debug.steppingFilters."
+ private let store: any KeyValueStore
+ private let lock = NSLock()
+
+ init(store: any KeyValueStore) {
+ self.store = store
+ }
+
+ func loadSteppingFilters(adapterID: String) throws -> DebugSteppingFilters? {
+ lock.lock(); defer { lock.unlock() }
+ guard let data = store.data(forKey: key(adapterID)) else { return nil }
+ do {
+ return try JSONDecoder().decode(DebugSteppingFilters.self, from: data)
+ } catch {
+ throw MacDebugSteppingFilterStoreError.invalidData
+ }
+ }
+
+ func saveSteppingFilters(_ filters: DebugSteppingFilters, adapterID: String) throws {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys]
+ let data = try encoder.encode(filters)
+ lock.lock(); defer { lock.unlock() }
+ store.set(data, forKey: key(adapterID))
+ }
+
+ private func key(_ adapterID: String) -> String {
+ Self.keyPrefix + adapterID
+ }
+}
diff --git a/macos/Sources/Lithe/Platform/MacOS/Debug/MacJavaDebugAdapterTransport.swift b/macos/Sources/Lithe/Platform/MacOS/Debug/MacJavaDebugAdapterTransport.swift
new file mode 100644
index 000000000..a23b413b9
--- /dev/null
+++ b/macos/Sources/Lithe/Platform/MacOS/Debug/MacJavaDebugAdapterTransport.swift
@@ -0,0 +1,142 @@
+import Foundation
+import LitheCoreContracts
+
+/// Connects the macOS product to the Java Debug Server hosted inside JDT LS.
+/// JDT LS activation stays in the language module and DAP state stays in Core;
+/// this adapter owns only asynchronous port discovery and the native TCP socket.
+@MainActor
+final class MacJavaDebugAdapterTransport: DebugAdapterTransport {
+ enum TransportError: LocalizedError {
+ case languageIntelligenceUnavailable
+ case connectionFailed(String)
+ case stopped
+
+ var errorDescription: String? {
+ switch self {
+ case .languageIntelligenceUnavailable:
+ return "The Java language service is unavailable."
+ case .connectionFailed(let message):
+ return "Could not connect to the Java Debug Server: \(message)"
+ case .stopped:
+ return "The Java Debug Server connection is stopped."
+ }
+ }
+ }
+
+ typealias PortResolver = @MainActor (URL) async throws -> UInt16
+
+ private let portResolver: PortResolver
+ private let socketFactory: @MainActor (String, UInt16) -> any DebugAdapterSocketConnection
+ private var startupTask: Task?
+ private var socket: (any DebugAdapterSocketConnection)?
+ private var pendingWrites: [Data] = []
+ private var isSocketReady = false
+ private var generation = UUID()
+ private(set) var isRunning = false
+
+ var onData: ((Data) -> Void)?
+ var onErrorOutput: ((Data) -> Void)?
+ var onTermination: ((Int) -> Void)?
+
+ init(
+ portResolver: @escaping PortResolver,
+ socketFactory: @escaping @MainActor (String, UInt16) -> any DebugAdapterSocketConnection = {
+ NetworkDebugAdapterSocketConnection(host: $0, port: $1)
+ }
+ ) {
+ self.portResolver = portResolver
+ self.socketFactory = socketFactory
+ }
+
+ func start(rootURL: URL) throws {
+ guard !isRunning else { return }
+ isRunning = true
+ isSocketReady = false
+ pendingWrites = []
+ generation = UUID()
+ let currentGeneration = generation
+ let portResolver = portResolver
+ startupTask = Task { @MainActor [weak self] in
+ do {
+ let port = try await portResolver(rootURL.standardizedFileURL)
+ try Task.checkCancellation()
+ guard let self else { return }
+ guard isRunning, generation == currentGeneration else { return }
+ connect(port: port, generation: currentGeneration)
+ } catch is CancellationError {
+ return
+ } catch {
+ guard let self else { return }
+ guard isRunning, generation == currentGeneration else { return }
+ fail(error)
+ }
+ }
+ }
+
+ func send(_ data: Data) throws {
+ guard isRunning else { throw TransportError.stopped }
+ guard isSocketReady, let socket else {
+ pendingWrites.append(data)
+ return
+ }
+ socket.send(data)
+ }
+
+ func stop() {
+ generation = UUID()
+ startupTask?.cancel()
+ startupTask = nil
+ socket?.stop()
+ socket = nil
+ pendingWrites = []
+ isSocketReady = false
+ isRunning = false
+ }
+
+ private func connect(port: UInt16, generation: UUID) {
+ let socket = socketFactory("127.0.0.1", port)
+ self.socket = socket
+ socket.onReady = { [weak self] in
+ guard let self, self.generation == generation else { return }
+ self.socketDidBecomeReady()
+ }
+ socket.onData = { [weak self] data in
+ guard let self, self.generation == generation else { return }
+ self.onData?(data)
+ }
+ socket.onFailure = { [weak self] error in
+ guard let self, self.generation == generation else { return }
+ self.fail(TransportError.connectionFailed(error.localizedDescription))
+ }
+ socket.onComplete = { [weak self] in
+ guard let self, self.generation == generation else { return }
+ self.terminate(exitCode: 0)
+ }
+ socket.start()
+ }
+
+ private func socketDidBecomeReady() {
+ guard let socket, isRunning else { return }
+ isSocketReady = true
+ let writes = pendingWrites
+ pendingWrites = []
+ writes.forEach(socket.send)
+ }
+
+ private func fail(_ error: Error) {
+ onErrorOutput?(Data((error.localizedDescription + "\n").utf8))
+ terminate(exitCode: 1)
+ }
+
+ private func terminate(exitCode: Int) {
+ guard isRunning else { return }
+ startupTask?.cancel()
+ startupTask = nil
+ socket?.stop()
+ socket = nil
+ pendingWrites = []
+ isSocketReady = false
+ isRunning = false
+ onTermination?(exitCode)
+ }
+}
diff --git a/macos/Sources/Lithe/Platform/MacOS/Debug/MacJavaTestResultServer.swift b/macos/Sources/Lithe/Platform/MacOS/Debug/MacJavaTestResultServer.swift
new file mode 100644
index 000000000..6fc712dd2
--- /dev/null
+++ b/macos/Sources/Lithe/Platform/MacOS/Debug/MacJavaTestResultServer.swift
@@ -0,0 +1,228 @@
+import Foundation
+import Network
+
+enum MacJavaTestResultListenerState {
+ case ready(port: UInt16)
+ case failed(message: String)
+ case cancelled
+}
+
+protocol MacJavaTestResultListening: AnyObject {
+ var onStateChange: ((MacJavaTestResultListenerState) -> Void)? { get set }
+ var onConnection: ((NWConnection) -> Void)? { get set }
+
+ func start(queue: DispatchQueue)
+ func cancel()
+}
+
+final class MacJavaTestResultNetworkListener: MacJavaTestResultListening {
+ var onStateChange: ((MacJavaTestResultListenerState) -> Void)?
+ var onConnection: ((NWConnection) -> Void)?
+
+ private let listener: NWListener
+
+ init() throws {
+ let parameters = NWParameters.tcp
+ parameters.requiredLocalEndpoint = .hostPort(
+ host: NWEndpoint.Host("127.0.0.1"),
+ port: .any
+ )
+ listener = try NWListener(using: parameters)
+ listener.stateUpdateHandler = { [weak self] state in
+ guard let self else { return }
+ switch state {
+ case .ready:
+ guard let port = listener.port?.rawValue else {
+ onStateChange?(.failed(message: "No listening port was assigned."))
+ return
+ }
+ onStateChange?(.ready(port: port))
+ case .failed(let error):
+ onStateChange?(.failed(message: error.localizedDescription))
+ case .cancelled:
+ onStateChange?(.cancelled)
+ default:
+ break
+ }
+ }
+ listener.newConnectionHandler = { [weak self] connection in
+ self?.onConnection?(connection)
+ }
+ }
+
+ func start(queue: DispatchQueue) {
+ listener.start(queue: queue)
+ }
+
+ func cancel() {
+ listener.cancel()
+ }
+}
+
+/// Owns the loopback listener used by one Java test debug launch. The listener
+/// is created on demand, accepts one runner connection, drains it, and releases
+/// all native resources when the runner exits or the launch is cancelled.
+@MainActor
+final class MacJavaTestResultServer: JavaTestResultServing {
+ enum ServerError: LocalizedError {
+ case startupFailed(String)
+ case startupTimedOut
+
+ var errorDescription: String? {
+ switch self {
+ case .startupFailed(let message):
+ "Could not start the Java test result listener: \(message)"
+ case .startupTimedOut:
+ "The Java test result listener did not become ready in time."
+ }
+ }
+ }
+
+ private let queue = DispatchQueue(label: "app.lithe.debug.java-test-results")
+ private let startupTimeout: Duration
+ private let listenerFactory: () throws -> any MacJavaTestResultListening
+ private var listener: (any MacJavaTestResultListening)?
+ private var connections: [ObjectIdentifier: NWConnection] = [:]
+ private var startupContinuation: CheckedContinuation?
+ private var startupDeadlineTask: Task?
+ private var generation = UUID()
+
+ init(
+ startupTimeout: Duration = .seconds(5),
+ listenerFactory: @escaping () throws -> any MacJavaTestResultListening = {
+ try MacJavaTestResultNetworkListener()
+ }
+ ) {
+ self.startupTimeout = startupTimeout
+ self.listenerFactory = listenerFactory
+ }
+
+ func start() async throws -> UInt16 {
+ stop()
+ let listener: any MacJavaTestResultListening
+ do {
+ listener = try listenerFactory()
+ } catch {
+ throw ServerError.startupFailed(error.localizedDescription)
+ }
+ self.listener = listener
+ generation = UUID()
+ let currentGeneration = generation
+ listener.onStateChange = { [weak self] state in
+ Task { @MainActor [weak self] in
+ self?.consume(state, generation: currentGeneration)
+ }
+ }
+ listener.onConnection = { [weak self] connection in
+ Task { @MainActor [weak self] in
+ self?.accept(connection, generation: currentGeneration)
+ }
+ }
+ return try await withTaskCancellationHandler {
+ try await withCheckedThrowingContinuation { continuation in
+ startupContinuation = continuation
+ listener.start(queue: queue)
+ startDeadline(generation: currentGeneration)
+ }
+ } onCancel: { [weak self] in
+ Task { @MainActor [weak self] in self?.stop() }
+ }
+ }
+
+ func stop() {
+ generation = UUID()
+ startupDeadlineTask?.cancel()
+ startupDeadlineTask = nil
+ listener?.cancel()
+ listener = nil
+ for connection in connections.values { connection.cancel() }
+ connections = [:]
+ startupContinuation?.resume(throwing: CancellationError())
+ startupContinuation = nil
+ }
+
+ private func startDeadline(generation: UUID) {
+ let clock = ContinuousClock()
+ let deadline = clock.now.advanced(by: startupTimeout)
+ startupDeadlineTask = Task { [weak self] in
+ do {
+ try await clock.sleep(until: deadline)
+ } catch {
+ return
+ }
+ guard !Task.isCancelled else { return }
+ await MainActor.run { [weak self] in
+ guard let self, self.generation == generation else { return }
+ self.failStartup(ServerError.startupTimedOut)
+ }
+ }
+ }
+
+ private func consume(_ state: MacJavaTestResultListenerState, generation: UUID) {
+ guard self.generation == generation else { return }
+ switch state {
+ case .ready(let port):
+ startupDeadlineTask?.cancel()
+ startupDeadlineTask = nil
+ startupContinuation?.resume(returning: port)
+ startupContinuation = nil
+ case .failed(let message):
+ failStartup(ServerError.startupFailed(message))
+ case .cancelled:
+ break
+ }
+ }
+
+ private func failStartup(_ error: Error) {
+ startupDeadlineTask?.cancel()
+ startupDeadlineTask = nil
+ listener?.cancel()
+ listener = nil
+ startupContinuation?.resume(throwing: error)
+ startupContinuation = nil
+ }
+
+ private func accept(_ connection: NWConnection, generation: UUID) {
+ guard self.generation == generation else {
+ connection.cancel()
+ return
+ }
+ // One Java test process owns the result channel. Stop accepting new
+ // peers after it connects, but keep the accepted socket alive.
+ listener?.cancel()
+ listener = nil
+ let identifier = ObjectIdentifier(connection)
+ connections[identifier] = connection
+ connection.stateUpdateHandler = { [weak self, weak connection] state in
+ guard let connection else { return }
+ switch state {
+ case .failed, .cancelled:
+ Task { @MainActor [weak self] in self?.remove(connection) }
+ default:
+ break
+ }
+ }
+ connection.start(queue: queue)
+ receiveNext(from: connection)
+ }
+
+ private func receiveNext(from connection: NWConnection) {
+ connection.receive(minimumIncompleteLength: 1, maximumLength: 1_048_576) {
+ [weak self, weak connection] _, _, isComplete, error in
+ guard let self, let connection else { return }
+ Task { @MainActor [weak self] in
+ guard let self else { return }
+ if error != nil || isComplete {
+ self.remove(connection)
+ } else {
+ self.receiveNext(from: connection)
+ }
+ }
+ }
+ }
+
+ private func remove(_ connection: NWConnection) {
+ connections[ObjectIdentifier(connection)] = nil
+ connection.cancel()
+ }
+}
diff --git a/macos/Sources/Lithe/Platform/MacOS/Debug/MacServerDebugAdapterTransport.swift b/macos/Sources/Lithe/Platform/MacOS/Debug/MacServerDebugAdapterTransport.swift
index 52fe19678..18b1f9a98 100644
--- a/macos/Sources/Lithe/Platform/MacOS/Debug/MacServerDebugAdapterTransport.swift
+++ b/macos/Sources/Lithe/Platform/MacOS/Debug/MacServerDebugAdapterTransport.swift
@@ -23,7 +23,7 @@ protocol DebugAdapterSocketConnection: AnyObject {
typealias DlvSocketConnection = DebugAdapterSocketConnection
@MainActor
-private final class NetworkDebugAdapterSocketConnection: DebugAdapterSocketConnection {
+final class NetworkDebugAdapterSocketConnection: DebugAdapterSocketConnection {
private let connection: NWConnection
private let queue = DispatchQueue(label: "app.lithe.debug.adapter-tcp")
var onReady: (() -> Void)?
diff --git a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift
index 994067603..f80ddb0e7 100644
--- a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift
+++ b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift
@@ -52,8 +52,13 @@ final class MacServiceContainer {
processRegistry: ManagedProcessRegistry = ManagedProcessRegistry(),
moduleLaunchMode: ModuleLaunchMode = .normal,
moduleStore providedModuleStore: MacModuleConfigurationStore? = nil,
+ workspaceOperations providedWorkspaceOperations: (any WorkspaceOperations)? = nil,
+ runConfigurationOperations providedRunConfigurationOperations: (any RunConfigurationOperations)? = nil,
+ gitWatchContextProvider providedGitWatchContextProvider: (any GitWatchContextProviding)? = nil,
+ runExecutableResolver providedRunExecutableResolver: (any RunExecutableResolving)? = nil,
pluginRuntimeRecovery: MacPluginRuntimeRecoveryCoordinator? = nil,
- authorizationCallbackRouter providedAuthorizationCallbackRouter: MacExternalAuthorizationCallbackRouter? = nil
+ authorizationCallbackRouter providedAuthorizationCallbackRouter: MacExternalAuthorizationCallbackRouter? = nil,
+ platformUI providedPlatformUI: (any PlatformUI)? = nil
) {
let authorizationCallbackRouter = providedAuthorizationCallbackRouter
?? MacExternalAuthorizationCallbackRouter()
@@ -73,6 +78,8 @@ final class MacServiceContainer {
preferences: store
)
self.runConfigurationStore = runConfigurationStore
+ let debugBreakpointStore = MacDebugBreakpointStore(store: store)
+ let debugSteppingFilterStore = MacDebugSteppingFilterStore(store: store)
let fileOperations = MacWorkspaceFileOperations()
let processRunner = MacProcessRunner()
let secureStore = MacLocalSecretStore()
@@ -87,7 +94,7 @@ final class MacServiceContainer {
secureStore: MacKeychainSecureStore(service: "app.lithe.desktop.github"),
git: MacGitHubGitOperations(core: rustCore)
)
- let platformUI = MacPlatformUI()
+ let platformUI = providedPlatformUI ?? MacPlatformUI()
let discourseCommunityService = DiscourseCommunityService(
core: rustCore,
credentialStore: MacKeychainSecureStore(service: "app.lithe.desktop.linux-do"),
@@ -301,7 +308,7 @@ final class MacServiceContainer {
do {
try moduleRegistry.register(ModuleFactory(manifest: ExecutionModule.moduleManifest, contributions: ExecutionModule.moduleContributions) {
ExecutionModule(makeGraph: {
- let executableResolver = RunExecutableResolver(
+ let executableResolver = providedRunExecutableResolver ?? RunExecutableResolver(
runtimeService: runtimeService,
toolchainRegistry: runToolchainRegistry,
metadataResolver: ProcessRunToolchainMetadataResolver(processRunner: processRunner)
@@ -320,7 +327,7 @@ final class MacServiceContainer {
fileAccess: MacRunFileAccess(storage: fileStorage),
preferences: MacRunPreferenceStore(store: store),
serverPortParser: javaMavenOperations,
- runConfigurationOperations: runConfigurationStore,
+ runConfigurationOperations: providedRunConfigurationOperations ?? runConfigurationStore,
executableResolver: executableResolver,
languageProviderCatalog: languagePackRegistry.catalog,
languageRunProviders: languagePackRegistry.runProviders,
@@ -340,6 +347,26 @@ final class MacServiceContainer {
try moduleRegistry.register(ModuleFactory(manifest: DebugModule.moduleManifest, contributions: DebugModule.moduleContributions) {
DebugModule(makeGraph: {
let debugFactories: [String: () -> (any DebugAdapterSession)?] = [
+ "java": {
+ CoreDebugAdapterProtocolSession(
+ adapterID: "java",
+ transport: MacJavaDebugAdapterTransport(
+ portResolver: { rootURL in
+ guard let capability = try await moduleRuntime
+ .activateCapability(.languageIntelligence)
+ as? LanguageIntelligenceCapability else {
+ throw MacJavaDebugAdapterTransport.TransportError
+ .languageIntelligenceUnavailable
+ }
+ return try await capability.sessions.startJavaDebugServer(
+ rootURL: rootURL
+ )
+ }
+ ),
+ core: rustCore,
+ deadlineScheduler: MacDebugOperationDeadlineScheduler()
+ )
+ },
"go": {
guard let executable = runtimeService.executableOnPath("dlv") else { return nil }
return DebugAdapterProtocolSession(
@@ -390,14 +417,11 @@ final class MacServiceContainer {
}
)
let graph = DebugFeatureGraph(
- java: JavaDebugService(
- runtimeService: runtimeService,
- processFactory: { MacStreamingProcess(processRegistry: processRegistry, moduleID: .debug) },
- fileStorage: fileStorage,
- javaMavenOperations: javaMavenOperations,
- runConfigurationOperations: runConfigurationStore
- ),
- adapterSessions: adapterSessions
+ adapterSessions: adapterSessions,
+ breakpointPersistence: debugBreakpointStore,
+ breakpointRelocator: rustCore,
+ steppingFilterResolver: rustCore,
+ steppingFilterPersistence: debugSteppingFilterStore
)
return graph
})
@@ -406,7 +430,12 @@ final class MacServiceContainer {
preconditionFailure("Invalid execution/debug module graph: \(error.localizedDescription)")
}
let gitOperations = RustGitOperations(core: rustCore)
- let workspaceOperations = RustWorkspaceOperations(core: rustCore)
+ let defaultWorkspaceOperations = RustWorkspaceOperations(core: rustCore)
+ let workspaceOperations: any WorkspaceOperations = providedWorkspaceOperations ?? defaultWorkspaceOperations
+ let searchOperations: any SearchOperations =
+ (providedWorkspaceOperations as? any SearchOperations) ?? defaultWorkspaceOperations
+ let gitWatchContextProvider: any GitWatchContextProviding =
+ providedGitWatchContextProvider ?? RustGitWatchContextProvider(core: rustCore)
let localHistoryOperations = RustLocalHistoryOperations(core: rustCore)
let markdownRenderer = RustMarkdownRendering(core: rustCore)
let markdownImageImporter = MarkdownImageImportService(storage: fileStorage)
@@ -418,7 +447,7 @@ final class MacServiceContainer {
)
})
try moduleRegistry.register(ModuleFactory(manifest: SearchModule.moduleManifest, contributions: SearchModule.moduleContributions) {
- SearchModule(operations: workspaceOperations)
+ SearchModule(operations: searchOperations)
})
try moduleRegistry.register(ModuleFactory(manifest: HistoryModule.moduleManifest, contributions: HistoryModule.moduleContributions) {
HistoryModule(
@@ -483,6 +512,13 @@ final class MacServiceContainer {
pluginCatalog: pluginCatalog,
languageProviderCatalogSource: languageProviderCatalogSource,
languageProviderCatalogSnapshot: languageProviderCatalogSnapshot,
+ debugLaunchConfigurationResolver: DebugLaunchConfigurationResolver(
+ fileStorage: fileStorage,
+ javaTestLaunchResolver: rustCore
+ ),
+ debugPortAvailabilityChecker: MacDebugPortAvailabilityChecker(),
+ javaTestResultServerFactory: { MacJavaTestResultServer() },
+ debugBreakpointPersistence: debugBreakpointStore,
workspaceOperations: workspaceOperations,
documentLifecycleDecider: RustDocumentLifecycleDecider(core: rustCore),
javaMavenOperations: javaMavenOperations,
@@ -493,7 +529,7 @@ final class MacServiceContainer {
fileOperations: fileOperations,
binaryFileViewerRegistry: binaryFileViewerRegistry,
projectRuntimeService: runtimeService,
- gitWatchContextProvider: RustGitWatchContextProvider(core: rustCore),
+ gitWatchContextProvider: gitWatchContextProvider,
githubService: githubService,
secureStore: secureStore,
databaseSecureStore: databaseSecureStore,
diff --git a/macos/Sources/Lithe/Platform/MacOS/Process/MacRawProcessSession.swift b/macos/Sources/Lithe/Platform/MacOS/Process/MacRawProcessSession.swift
index 25f7ca290..a2ea6b504 100644
--- a/macos/Sources/Lithe/Platform/MacOS/Process/MacRawProcessSession.swift
+++ b/macos/Sources/Lithe/Platform/MacOS/Process/MacRawProcessSession.swift
@@ -16,9 +16,15 @@ final class MacRawProcessSession: RawProcessSession, @unchecked Sendable {
private var errorPipe: Pipe?
private var timeoutTask: Task?
private var activeOperationID: String?
+ // A stop followed immediately by a new start can leave the old
+ // termination callback queued on the process-source queue. Keep a
+ // generation token so that callback cannot clear or report the new run.
+ private var processGeneration = UUID()
func start(_ request: ProcessRequest) throws {
stop()
+ processGeneration = UUID()
+ let currentGeneration = processGeneration
activeOperationID = request.operationID
onStateChange?(ProcessLifecycleEvent(
operationID: request.operationID,
@@ -64,7 +70,9 @@ final class MacRawProcessSession: RawProcessSession, @unchecked Sendable {
self.onError?(data)
}
process.terminationHandler = { [weak self] terminatedProcess in
- guard let self, self.process === terminatedProcess else { return }
+ guard let self,
+ self.process === terminatedProcess,
+ self.processGeneration == currentGeneration else { return }
self.outputPipe?.fileHandleForReading.readabilityHandler = nil
self.errorPipe?.fileHandleForReading.readabilityHandler = nil
self.process = nil
@@ -146,6 +154,8 @@ final class MacRawProcessSession: RawProcessSession, @unchecked Sendable {
closePipes()
process = nil
activeOperationID = nil
+ // Invalidate callbacks that may still be queued for the stopped run.
+ processGeneration = UUID()
}
private func closePipes() {
diff --git a/macos/Sources/Lithe/Platform/MacOS/Process/MacStoppedChildProcessReaper.swift b/macos/Sources/Lithe/Platform/MacOS/Process/MacStoppedChildProcessReaper.swift
new file mode 100644
index 000000000..7b2f8b301
--- /dev/null
+++ b/macos/Sources/Lithe/Platform/MacOS/Process/MacStoppedChildProcessReaper.swift
@@ -0,0 +1,73 @@
+import Darwin
+import Foundation
+
+/// Reaps direct child processes after a platform-owned stop operation.
+///
+/// Some native terminal libraries send the termination signal themselves but
+/// can miss their eventual `waitpid` callback. Keeping a second process source
+/// prevents an exited debuggee from remaining as a zombie under Lithe.
+final class MacStoppedChildProcessReaper: @unchecked Sendable {
+ typealias Completion = @Sendable () -> Void
+
+ private let lock = NSLock()
+ private let queue = DispatchQueue(
+ label: "app.lithe.stopped-child-process-reaper",
+ qos: .utility
+ )
+ private var sources: [pid_t: DispatchSourceProcess] = [:]
+ private var completions: [pid_t: [Completion]] = [:]
+
+ func reapWhenExited(
+ _ processID: pid_t,
+ completion: @escaping Completion = {}
+ ) {
+ guard processID > 0 else {
+ completion()
+ return
+ }
+
+ var waitStatus: Int32 = 0
+ errno = 0
+ let immediateResult = Darwin.waitpid(processID, &waitStatus, WNOHANG)
+ if immediateResult == processID || (immediateResult == -1 && errno == ECHILD) {
+ completion()
+ return
+ }
+
+ let processSource = DispatchSource.makeProcessSource(
+ identifier: processID,
+ eventMask: .exit,
+ queue: queue
+ )
+ let shouldActivate = lock.withLock { () -> Bool in
+ completions[processID, default: []].append(completion)
+ guard sources[processID] == nil else { return false }
+ sources[processID] = processSource
+ return true
+ }
+ guard shouldActivate else { return }
+
+ // Retaining self until the event fires keeps reaping alive even when a
+ // terminal session is closed immediately after stop().
+ processSource.setEventHandler { [self] in
+ reap(processID)
+ }
+ processSource.activate()
+ }
+
+ private func reap(_ processID: pid_t) {
+ var waitStatus: Int32 = 0
+ var waitResult: pid_t
+ repeat {
+ waitResult = Darwin.waitpid(processID, &waitStatus, 0)
+ } while waitResult == -1 && errno == EINTR
+
+ let state = lock.withLock { () -> (DispatchSourceProcess?, [Completion]) in
+ let source = sources.removeValue(forKey: processID)
+ let callbacks = completions.removeValue(forKey: processID) ?? []
+ return (source, callbacks)
+ }
+ state.0?.cancel()
+ state.1.forEach { $0() }
+ }
+}
diff --git a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacJDTLSLaunchResourceResolver.swift b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacJDTLSLaunchResourceResolver.swift
index d1fe54cce..fb2f90597 100644
--- a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacJDTLSLaunchResourceResolver.swift
+++ b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacJDTLSLaunchResourceResolver.swift
@@ -9,6 +9,9 @@ enum MacJDTLSLaunchResourceResolution {
struct MacJDTLSLaunchResourceResolver {
private static let equinoxLauncherPrefix = "org.eclipse.equinox.launcher_"
+ private static let javaDebugBundlePrefix = "com.microsoft.java.debug.plugin-"
+ private static let javaTestBundlePrefix = "com.microsoft.java.test.plugin-"
+ private static let javaTestRunnerName = "com.microsoft.java.test.runner-jar-with-dependencies.jar"
private let bundledJdtlsRootURL: URL?
private let fileManager: FileManager
@@ -39,15 +42,32 @@ struct MacJDTLSLaunchResourceResolver {
let pluginsURL = rootURL.appendingPathComponent("plugins", isDirectory: true)
let configurationURL = configurationDirectory(in: rootURL)
let lombokURL = rootURL.appendingPathComponent("lombok/lombok.jar")
+ let javaDebugURL = try firstJavaDebugBundle(
+ in: rootURL.appendingPathComponent("java-debug", isDirectory: true)
+ )
+ let javaTestBundleURLs = try javaTestExtensionBundles(
+ in: rootURL.appendingPathComponent("java-test/extensions", isDirectory: true)
+ )
+ let javaTestRunnerURL = rootURL
+ .appendingPathComponent("java-test/runner", isDirectory: true)
+ .appendingPathComponent(Self.javaTestRunnerName)
guard let launcherURL = try firstEquinoxLauncher(in: pluginsURL),
let configurationURL,
+ let javaDebugURL,
+ javaTestBundleURLs.contains(where: {
+ $0.lastPathComponent.hasPrefix(Self.javaTestBundlePrefix)
+ }),
+ fileManager.fileExists(atPath: javaTestRunnerURL.path),
fileManager.fileExists(atPath: lombokURL.path) else {
continue
}
return JDTLSLaunchResources(
launcherJarURL: launcherURL,
configurationDirectoryURL: configurationURL,
- lombokAgentURL: lombokURL
+ lombokAgentURL: lombokURL,
+ javaDebugBundleURL: javaDebugURL,
+ javaExtensionBundleURLs: javaTestBundleURLs,
+ javaTestRunnerURL: javaTestRunnerURL
)
}
throw ResolutionError.incompleteInstallation
@@ -82,25 +102,55 @@ struct MacJDTLSLaunchResourceResolver {
}
private func firstEquinoxLauncher(in pluginsURL: URL) throws -> URL? {
+ try firstRegularFile(
+ in: pluginsURL,
+ prefix: Self.equinoxLauncherPrefix,
+ suffix: ".jar"
+ )
+ }
+
+ private func firstJavaDebugBundle(in directoryURL: URL) throws -> URL? {
+ try firstRegularFile(
+ in: directoryURL,
+ prefix: Self.javaDebugBundlePrefix,
+ suffix: ".jar"
+ )
+ }
+
+ private func firstRegularFile(
+ in directoryURL: URL,
+ prefix: String,
+ suffix: String
+ ) throws -> URL? {
+ try regularFiles(in: directoryURL, prefix: prefix, suffix: suffix).first
+ }
+
+ private func javaTestExtensionBundles(in directoryURL: URL) throws -> [URL] {
+ try regularFiles(in: directoryURL, prefix: "", suffix: ".jar")
+ }
+
+ private func regularFiles(
+ in directoryURL: URL,
+ prefix: String,
+ suffix: String
+ ) throws -> [URL] {
let entries: [URL]
do {
entries = try fileManager.contentsOfDirectory(
- at: pluginsURL,
+ at: directoryURL,
includingPropertiesForKeys: [.isRegularFileKey],
options: [.skipsHiddenFiles]
)
} catch let error as CocoaError where error.code == .fileReadNoSuchFile {
- return nil
+ return []
}
return try entries
.filter { url in
let name = url.lastPathComponent
- guard name.hasPrefix(Self.equinoxLauncherPrefix),
- name.hasSuffix(".jar") else { return false }
+ guard name.hasPrefix(prefix), name.hasSuffix(suffix) else { return false }
return try url.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile == true
}
.sorted { $0.lastPathComponent < $1.lastPathComponent }
- .first
}
private func isDirectory(_ url: URL) -> Bool {
@@ -120,7 +170,8 @@ struct MacJDTLSLaunchResourceResolver {
var errorDescription: String? {
"Expected an Equinox launcher JAR, a macOS configuration directory, "
- + "and lombok/lombok.jar in the selected JDTLS installation."
+ + "lombok/lombok.jar, Java Debug and Java Test extension bundles, and the TestNG runner "
+ + "in the selected JDTLS installation."
}
}
}
diff --git a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacJdtWorkspaceState.swift b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacJdtWorkspaceState.swift
index df475b6b4..e263e52b5 100644
--- a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacJdtWorkspaceState.swift
+++ b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacJdtWorkspaceState.swift
@@ -125,7 +125,7 @@ struct MacJdtWorkspaceState {
return try workspaceFingerprintResolver(
buildFiles,
modules,
- languageServerVersion(for: languageServerExecutableURL)
+ languageServerCacheIdentity(for: languageServerExecutableURL)
)
}
@@ -342,6 +342,24 @@ struct MacJdtWorkspaceState {
}
throw MacJdtWorkspaceStateError.languageServerVersionUnavailable
}
+
+ private func languageServerCacheIdentity(for executableURL: URL?) throws -> String {
+ let version = try languageServerVersion(for: executableURL)
+ let installationPath: String
+ if let executableURL {
+ installationPath = executableURL.standardizedFileURL.path
+ } else if let resourceURL = Bundle.main.resourceURL {
+ installationPath = resourceURL
+ .appendingPathComponent("LanguageServers", isDirectory: true)
+ .appendingPathComponent("jdtls", isDirectory: true)
+ .standardizedFileURL.path
+ } else {
+ throw MacJdtWorkspaceStateError.languageServerInstallationUnavailable
+ }
+ // JDT LS persists absolute JRE and source paths inside its workspace.
+ // Relocating an app or Preview build must therefore select a fresh cache.
+ return "\(version)|installation=\(installationPath)"
+ }
}
private struct JdtManifest: Decodable {
@@ -358,6 +376,7 @@ private enum MacJdtWorkspaceStateError: LocalizedError {
case invalidBuildFileMetadata(String)
case invalidLanguageServerManifest(String)
case languageServerVersionUnavailable
+ case languageServerInstallationUnavailable
case invalidWorkspaceKey
case cacheRetentionUnavailable
case invalidRetentionPlan
@@ -373,6 +392,8 @@ private enum MacJdtWorkspaceStateError: LocalizedError {
"The bundled JDT LS manifest is invalid at \(path)."
case .languageServerVersionUnavailable:
"The bundled JDT LS version could not be determined."
+ case .languageServerInstallationUnavailable:
+ "The bundled JDT LS installation could not be determined."
case .invalidWorkspaceKey:
"Rust Core returned an invalid Java workspace key."
case .cacheRetentionUnavailable:
diff --git a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeDiscovery.swift b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeDiscovery.swift
index e914df682..f7d093727 100644
--- a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeDiscovery.swift
+++ b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeDiscovery.swift
@@ -15,16 +15,6 @@ enum MacRuntimeDiscovery {
return RuntimeDiscoveryResult(javaRuntimes: javaRuntimes, mavenRuntimes: mavenRuntimes)
}
- static func systemJDBExecutable() -> URL? {
- [
- "/opt/homebrew/bin/jdb",
- "/usr/local/bin/jdb",
- "/usr/bin/jdb"
- ]
- .map(URL.init(fileURLWithPath:))
- .first(where: { FileManager.default.isExecutableFile(atPath: $0.path) })
- }
-
static func systemMavenExecutable(environment: [String: String]) -> URL? {
discoverMavenExecutables(environment: environment).first
}
diff --git a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeLocator.swift b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeLocator.swift
index c90c77c51..4cc1aadc8 100644
--- a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeLocator.swift
+++ b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeLocator.swift
@@ -68,10 +68,6 @@ struct MacRuntimeLocator: RuntimeLocator {
MacRuntimeDiscovery.probeMaven(executableURL)
}
- func systemJDBExecutable() -> URL? {
- MacRuntimeDiscovery.systemJDBExecutable()
- }
-
/// Returns the bundled JDK matching the current process architecture.
/// Universal apps carry separate runtimes because a JDK contains native
/// libraries throughout its installation. Single-architecture and legacy
diff --git a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeToolDiscovery.swift b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeToolDiscovery.swift
index 8c7c70bab..d864e619f 100644
--- a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeToolDiscovery.swift
+++ b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeToolDiscovery.swift
@@ -171,7 +171,7 @@ struct MacRuntimeToolDiscovery: RuntimeToolDiscovery {
command: command,
displayName: "Java Debug Adapter",
summary: "A Java DAP adapter was not found.",
- recovery: "Set LITHE_JAVA_DEBUG_PATH to a stdio DAP adapter; Lithe will keep using JDB until one is available."
+ recovery: "Reinstall Lithe's bundled Java language and Debug Adapter resources."
)
default:
return RuntimeToolGuidance(
diff --git a/macos/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalTransport.swift b/macos/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalTransport.swift
index 67739015d..3d9c9714f 100644
--- a/macos/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalTransport.swift
+++ b/macos/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalTransport.swift
@@ -7,6 +7,7 @@ import LitheTerminalModule
/// link event so workspace-relative paths can open in its own editor instead.
final class LitheTerminalView: LocalProcessTerminalView {
var onOpenLink: ((String, [String: String]) -> Void)?
+ var onProcessOutput: ((Data) -> Void)?
private var showsWorkbenchBackground = false
private weak var metalActivationFailedWindow: NSWindow?
@@ -109,6 +110,11 @@ final class LitheTerminalView: LocalProcessTerminalView {
override func requestOpenLink(source: SwiftTerm.TerminalView, link: String, params: [String: String]) {
onOpenLink?(link, params)
}
+
+ override func dataReceived(slice: ArraySlice) {
+ onProcessOutput?(Data(slice))
+ super.dataReceived(slice: slice)
+ }
}
extension LitheTerminalView: WorkbenchBackgroundRendering {}
@@ -136,17 +142,24 @@ final class MacTerminalTransport: NSObject, TerminalTransport, @preconcurrency L
let view: LitheTerminalView
var onTermination: ((Int32?) -> Void)?
+ var onOutput: ((Data) -> Void)?
var onTitle: ((String) -> Void)?
var onDirectoryUpdate: ((String?) -> Void)?
var onLink: ((String, [String: String]) -> Void)?
private var selectedShellPath: String?
private var suppressNextTermination = false
+ private let stoppedChildProcessReaper = MacStoppedChildProcessReaper()
var isRunning: Bool {
view.process.running
}
+ var processID: Int32? {
+ let processID = view.process.shellPid
+ return processID > 0 ? processID : nil
+ }
+
var shellName: String {
guard let selectedShellPath else { return "Shell" }
return URL(fileURLWithPath: selectedShellPath).lastPathComponent
@@ -162,6 +175,9 @@ final class MacTerminalTransport: NSObject, TerminalTransport, @preconcurrency L
view.onOpenLink = { [weak self] link, params in
self?.onLink?(link, params)
}
+ view.onProcessOutput = { [weak self] data in
+ self?.onOutput?(data)
+ }
view.font = Self.preferredTerminalFont()
view.applyThemeColors()
view.allowMouseReporting = true
@@ -207,9 +223,36 @@ final class MacTerminalTransport: NSObject, TerminalTransport, @preconcurrency L
shellPath: String,
environment: [String: String]
) throws {
+ _ = try startProcess(
+ TerminalProcessLaunch(
+ title: nil,
+ executablePath: shellPath,
+ arguments: ["-l"],
+ workingDirectory: workingDirectory
+ ),
+ environment: environment
+ )
+ }
+
+ func startProcess(
+ _ launch: TerminalProcessLaunch,
+ environment: [String: String]
+ ) throws -> Int32 {
stop()
suppressNextTermination = false
- selectedShellPath = shellPath
+ let executablePath = try resolveExecutablePath(
+ launch.executablePath,
+ workingDirectory: launch.workingDirectory,
+ environment: environment
+ )
+ var isDirectory: ObjCBool = false
+ guard FileManager.default.fileExists(
+ atPath: launch.workingDirectory,
+ isDirectory: &isDirectory
+ ), isDirectory.boolValue else {
+ throw terminalError("The terminal working directory does not exist: \(launch.workingDirectory)")
+ }
+ selectedShellPath = executablePath
view.terminal.resetToInitialState()
var options = view.terminal.options
@@ -222,21 +265,16 @@ final class MacTerminalTransport: NSObject, TerminalTransport, @preconcurrency L
}
view.startProcess(
- executable: shellPath,
- args: ["-l"],
+ executable: executablePath,
+ args: launch.arguments,
environment: environmentArray,
- currentDirectory: workingDirectory
+ currentDirectory: launch.workingDirectory
)
- guard view.process.running else {
- throw NSError(
- domain: "Lithe.Terminal",
- code: 1,
- userInfo: [
- NSLocalizedDescriptionKey: "Unable to start \(shellPath)"
- ]
- )
+ guard view.process.running, let processID else {
+ throw terminalError("Unable to start \(executablePath)")
}
+ return processID
}
func send(_ input: Data) throws {
@@ -259,9 +297,12 @@ final class MacTerminalTransport: NSObject, TerminalTransport, @preconcurrency L
}
func stop() {
- guard view.process.running else { return }
+ guard let processID else { return }
suppressNextTermination = true
- view.terminate()
+ if view.process.running {
+ view.terminate()
+ }
+ stoppedChildProcessReaper.reapWhenExited(processID)
}
func sizeChanged(source: LocalProcessTerminalView, newCols: Int, newRows: Int) {}
@@ -281,4 +322,42 @@ final class MacTerminalTransport: NSObject, TerminalTransport, @preconcurrency L
}
onTermination?(exitCode)
}
+
+ private func resolveExecutablePath(
+ _ executablePath: String,
+ workingDirectory: String,
+ environment: [String: String]
+ ) throws -> String {
+ let fileManager = FileManager.default
+ let candidate: String?
+ if executablePath.contains("/") {
+ let url = executablePath.hasPrefix("/")
+ ? URL(fileURLWithPath: executablePath)
+ : URL(fileURLWithPath: workingDirectory, isDirectory: true)
+ .appendingPathComponent(executablePath)
+ candidate = url.standardizedFileURL.path
+ } else {
+ candidate = environment["PATH"]?
+ .split(separator: ":", omittingEmptySubsequences: false)
+ .map(String.init)
+ .map { directory in
+ URL(fileURLWithPath: directory, isDirectory: true)
+ .appendingPathComponent(executablePath)
+ .standardizedFileURL.path
+ }
+ .first { fileManager.isExecutableFile(atPath: $0) }
+ }
+ guard let candidate, fileManager.isExecutableFile(atPath: candidate) else {
+ throw terminalError("The terminal executable is unavailable: \(executablePath)")
+ }
+ return candidate
+ }
+
+ private func terminalError(_ message: String) -> NSError {
+ NSError(
+ domain: "Lithe.Terminal",
+ code: 1,
+ userInfo: [NSLocalizedDescriptionKey: message]
+ )
+ }
}
diff --git a/macos/Sources/Lithe/Platform/MacOS/UI/MacPlatformUI.swift b/macos/Sources/Lithe/Platform/MacOS/UI/MacPlatformUI.swift
index 662b9b308..6509ebf71 100644
--- a/macos/Sources/Lithe/Platform/MacOS/UI/MacPlatformUI.swift
+++ b/macos/Sources/Lithe/Platform/MacOS/UI/MacPlatformUI.swift
@@ -3,6 +3,10 @@ import Foundation
import UniformTypeIdentifiers
final class MacPlatformUI: PlatformUI {
+ func activateApplication() {
+ NSApplication.shared.activate(ignoringOtherApps: true)
+ }
+
func chooseDirectory(title: String, prompt: String) -> URL? {
let panel = NSOpenPanel()
panel.title = title
diff --git a/macos/Sources/Lithe/Services/Debug/DebugLaunchConfigurationResolver.swift b/macos/Sources/Lithe/Services/Debug/DebugLaunchConfigurationResolver.swift
index dc11ed284..cbc3a100b 100644
--- a/macos/Sources/Lithe/Services/Debug/DebugLaunchConfigurationResolver.swift
+++ b/macos/Sources/Lithe/Services/Debug/DebugLaunchConfigurationResolver.swift
@@ -3,6 +3,9 @@ import LitheCoreContracts
enum DebugLaunchConfigurationResolutionError: LocalizedError, Equatable {
case unsupportedProvider(String)
+ case javaLaunchTargetUnavailable
+ case invalidJavaAttachHost
+ case invalidJavaAttachPort
case noRustBinaryConfiguration
case rustExecutableNotBuilt(URL, binary: String)
@@ -10,6 +13,12 @@ enum DebugLaunchConfigurationResolutionError: LocalizedError, Equatable {
switch self {
case .unsupportedProvider(let provider):
return "The \(provider) Debug Adapter is not installed yet."
+ case .javaLaunchTargetUnavailable:
+ return "The Java language service could not resolve a main class for this file."
+ case .invalidJavaAttachHost:
+ return "Enter the host name of the running JVM."
+ case .invalidJavaAttachPort:
+ return "Enter a JVM debug port between 1 and 65535."
case .noRustBinaryConfiguration:
return "No Cargo binary run configuration matches this Rust file."
case .rustExecutableNotBuilt(let url, let binary):
@@ -24,21 +33,40 @@ enum DebugLaunchConfigurationResolutionError: LocalizedError, Equatable {
struct DebugLaunchConfigurationResolver {
private let fileExists: (URL) -> Bool
private let executableSuffix: String
+ private let javaTestLaunchResolver: (any JavaTestDebugLaunchResolving)?
init(
fileStorage: any FileStorage,
- executableSuffix: String = ""
+ executableSuffix: String = "",
+ javaTestLaunchResolver: (any JavaTestDebugLaunchResolving)? = nil
) {
self.fileExists = { fileStorage.fileExists(at: $0) }
self.executableSuffix = executableSuffix
+ self.javaTestLaunchResolver = javaTestLaunchResolver
}
init(
executableSuffix: String = "",
- fileExists: @escaping (URL) -> Bool
+ fileExists: @escaping (URL) -> Bool,
+ javaTestLaunchResolver: (any JavaTestDebugLaunchResolving)? = nil
) {
self.fileExists = fileExists
self.executableSuffix = executableSuffix
+ self.javaTestLaunchResolver = javaTestLaunchResolver
+ }
+
+ @MainActor
+ func resolveJavaTest(
+ target: JavaTestDebugLaunchTarget,
+ resultPort: UInt16
+ ) throws -> DebugLaunchConfiguration {
+ guard let javaTestLaunchResolver else {
+ throw DebugLaunchConfigurationResolutionError.javaLaunchTargetUnavailable
+ }
+ return try javaTestLaunchResolver.resolveJavaTestDebugLaunch(
+ target: target,
+ resultPort: resultPort
+ )
}
func resolve(
@@ -47,15 +75,20 @@ struct DebugLaunchConfigurationResolver {
workspaceURL: URL,
configurations: [RunConfiguration],
selectedConfiguration: RunConfiguration?,
+ javaTarget: JavaDebugLaunchTarget? = nil,
options: (RunConfiguration) -> RunOptions
) throws -> DebugLaunchConfiguration {
switch provider.id {
case "java":
+ guard let javaTarget else {
+ throw DebugLaunchConfigurationResolutionError.javaLaunchTargetUnavailable
+ }
return javaConfiguration(
documentURL: documentURL,
workspaceURL: workspaceURL,
configurations: configurations,
selectedConfiguration: selectedConfiguration,
+ target: javaTarget,
options: options
)
case "python":
@@ -96,36 +129,77 @@ struct DebugLaunchConfigurationResolver {
}
}
+ func resolveJavaAttach(host: String, port: Int) throws -> DebugLaunchConfiguration {
+ let normalizedHost = host.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !normalizedHost.isEmpty else {
+ throw DebugLaunchConfigurationResolutionError.invalidJavaAttachHost
+ }
+ guard (1...65_535).contains(port) else {
+ throw DebugLaunchConfigurationResolutionError.invalidJavaAttachPort
+ }
+ return DebugLaunchConfiguration(
+ name: "\(normalizedHost):\(port)",
+ request: .attach,
+ arguments: [
+ "hostName": .string(normalizedHost),
+ "port": .integer(port)
+ ]
+ )
+ }
+
private func javaConfiguration(
documentURL: URL,
workspaceURL: URL,
configurations: [RunConfiguration],
selectedConfiguration: RunConfiguration?,
+ target: JavaDebugLaunchTarget,
options: (RunConfiguration) -> RunOptions
) -> DebugLaunchConfiguration {
+ // Debug is a second execution mode for the selected Run configuration.
+ // Keep every Java configuration eligible here so its JDK, Maven,
+ // working-directory, VM/program arguments, profiles, and environment
+ // overrides are carried over unchanged.
let configuration = selectedConfiguration.flatMap { selected in
- selected.kind.isMavenBacked ? selected : nil
+ selected.kind.providerID == "java" || selected.kind.isMavenBacked ? selected : nil
+ }
+ let runOptions = configuration.map(options)
+ let mainClass = configuration?.mainClass ?? target.mainClass
+ let configuredWorkingDirectory = runOptions?.workingDirectoryPath
+ .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ let workingDirectory: String
+ if configuredWorkingDirectory.isEmpty {
+ workingDirectory = workspaceURL.standardizedFileURL.path
+ } else {
+ workingDirectory = URL(
+ fileURLWithPath: configuredWorkingDirectory,
+ relativeTo: workspaceURL
+ ).standardizedFileURL.path
}
var arguments: [String: ToolingJSONValue] = [
- "mainClass": .string(inferJavaMainClass(documentURL: documentURL, workspaceURL: workspaceURL)),
- "cwd": .string(workspaceURL.standardizedFileURL.path),
- "console": .string("internalConsole")
+ "mainClass": .string(mainClass),
+ "cwd": .string(workingDirectory),
+ "console": .string("integratedTerminal")
]
- if let configuration {
- let runOptions = options(configuration)
- let programArguments = RunArgumentParser.parse(runOptions.arguments)
+ if let projectName = target.projectName {
+ arguments["projectName"] = .string(projectName)
+ }
+ if !target.modulePaths.isEmpty {
+ arguments["modulePaths"] = .array(target.modulePaths.map(ToolingJSONValue.string))
+ }
+ if !target.classPaths.isEmpty {
+ arguments["classPaths"] = .array(target.classPaths.map(ToolingJSONValue.string))
+ }
+ if let runOptions {
+ let programArguments = runOptions.arguments.trimmingCharacters(in: .whitespacesAndNewlines)
if !programArguments.isEmpty {
- arguments["args"] = .array(programArguments.map(ToolingJSONValue.string))
+ arguments["args"] = .string(programArguments)
}
if !runOptions.environment.isEmpty {
arguments["env"] = .object(runOptions.environment.mapValues(ToolingJSONValue.string))
}
- let vmArguments = RunArgumentParser.parse(runOptions.vmArguments)
+ let vmArguments = runOptions.vmArguments.trimmingCharacters(in: .whitespacesAndNewlines)
if !vmArguments.isEmpty {
- arguments["vmArgs"] = .array(vmArguments.map(ToolingJSONValue.string))
- }
- if let modulePath = configuration.modulePath, !modulePath.isEmpty {
- arguments["projectName"] = .string(modulePath)
+ arguments["vmArgs"] = .string(vmArguments)
}
}
return DebugLaunchConfiguration(
@@ -135,26 +209,6 @@ struct DebugLaunchConfigurationResolver {
)
}
- private func inferJavaMainClass(documentURL: URL, workspaceURL: URL) -> String {
- let file = documentURL.standardizedFileURL
- let root = workspaceURL.standardizedFileURL
- let relative = file.path.hasPrefix(root.path + "/")
- ? String(file.path.dropFirst(root.path.count + 1))
- : file.lastPathComponent
- let components = relative.split(separator: "/").map(String.init)
- let sourceRoots = ["src/main/java", "src/test/java", "src/main/kotlin"]
- let sourceRootIndex: Int? = sourceRoots.compactMap { sourceRoot -> Int? in
- let rootComponents = sourceRoot.split(separator: "/").map(String.init)
- guard components.count > rootComponents.count,
- Array(components.prefix(rootComponents.count)) == rootComponents else { return nil }
- return rootComponents.count
- }.first
- let classComponents = Array(components.dropFirst(sourceRootIndex ?? max(0, components.count - 1)))
- let className = classComponents.joined(separator: ".")
- .replacingOccurrences(of: ".java", with: "")
- return className.isEmpty ? file.deletingPathExtension().lastPathComponent : className
- }
-
private func nodeConfiguration(
documentURL: URL,
workspaceURL: URL,
diff --git a/macos/Sources/Lithe/Services/Debug/DebugLaunchSourceResolver.swift b/macos/Sources/Lithe/Services/Debug/DebugLaunchSourceResolver.swift
new file mode 100644
index 000000000..8dac652ea
--- /dev/null
+++ b/macos/Sources/Lithe/Services/Debug/DebugLaunchSourceResolver.swift
@@ -0,0 +1,102 @@
+import Foundation
+import LitheCoreContracts
+
+/// Selects the source file that anchors a Debug launch without making the
+/// selected Run configuration depend on whichever editor tab is currently open.
+struct DebugLaunchSourceResolver {
+ /// Chooses a project-backed Java target when the remembered Current File
+ /// entry cannot represent a launchable Java application. IDEA keeps the
+ /// editor shortcut useful in this situation instead of trying to compile
+ /// an arbitrary controller, repository, or configuration class alone.
+ func configurationForDebug(
+ selected: RunConfiguration,
+ activeDocumentText: String?,
+ configurations: [RunConfiguration]
+ ) -> RunConfiguration {
+ guard selected.usesCurrentEditorFile else {
+ return selected
+ }
+ if activeDocumentText.map(containsJavaMainMethod) == true {
+ return selected
+ }
+
+ return configurations.first {
+ !$0.usesCurrentEditorFile && $0.kind.mavenFramework != nil
+ && $0.kind.capabilities.contains(.jdwpDebug)
+ } ?? configurations.first {
+ !$0.usesCurrentEditorFile && $0.kind == .javaMain
+ && $0.kind.capabilities.contains(.jdwpDebug)
+ } ?? selected
+ }
+
+ func resolve(
+ configuration: RunConfiguration,
+ activeDocumentURL: URL?,
+ projectFiles: [URL],
+ workspaceURL: URL
+ ) -> URL? {
+ if configuration.usesCurrentEditorFile {
+ return activeDocumentURL?.standardizedFileURL
+ }
+
+ let javaFiles = projectFiles
+ .map(\.standardizedFileURL)
+ .filter { $0.pathExtension.lowercased() == "java" }
+ .sorted { $0.path < $1.path }
+ guard !javaFiles.isEmpty else { return nil }
+
+ let moduleFiles = filesInSelectedModule(
+ javaFiles,
+ modulePath: configuration.modulePath,
+ workspaceURL: workspaceURL
+ )
+ let preferredFiles = moduleFiles.isEmpty ? javaFiles : moduleFiles
+
+ if let sourceSuffix = sourceSuffix(for: configuration.mainClass),
+ let exactMatch = preferredFiles.first(where: { $0.path.hasSuffix(sourceSuffix) })
+ ?? javaFiles.first(where: { $0.path.hasSuffix(sourceSuffix) }) {
+ return exactMatch
+ }
+
+ if let activeDocumentURL = activeDocumentURL?.standardizedFileURL,
+ preferredFiles.contains(activeDocumentURL) {
+ return activeDocumentURL
+ }
+ return preferredFiles.first
+ }
+
+ private func filesInSelectedModule(
+ _ files: [URL],
+ modulePath: String?,
+ workspaceURL: URL
+ ) -> [URL] {
+ guard let modulePath = modulePath?.trimmingCharacters(in: .whitespacesAndNewlines),
+ !modulePath.isEmpty,
+ modulePath != "." else { return files }
+ let moduleURL = workspaceURL
+ .appendingPathComponent(modulePath, isDirectory: true)
+ .standardizedFileURL
+ let modulePrefix = moduleURL.path.hasSuffix("/") ? moduleURL.path : moduleURL.path + "/"
+ return files.filter { $0.path.hasPrefix(modulePrefix) }
+ }
+
+ private func sourceSuffix(for mainClass: String?) -> String? {
+ guard var mainClass = mainClass?.trimmingCharacters(in: .whitespacesAndNewlines),
+ !mainClass.isEmpty else { return nil }
+ if let moduleSeparator = mainClass.lastIndex(of: "/") {
+ mainClass = String(mainClass[mainClass.index(after: moduleSeparator)...])
+ }
+ if let nestedClassSeparator = mainClass.firstIndex(of: "$") {
+ mainClass = String(mainClass[.. Bool {
+ source.range(
+ of: #"(?m)\bstatic\s+(?:public\s+|protected\s+|private\s+)?void\s+main\s*\("#,
+ options: .regularExpression
+ ) != nil
+ }
+}
diff --git a/macos/Sources/Lithe/Services/Debug/DebugPortAvailabilityChecker.swift b/macos/Sources/Lithe/Services/Debug/DebugPortAvailabilityChecker.swift
new file mode 100644
index 000000000..dd3dc0b04
--- /dev/null
+++ b/macos/Sources/Lithe/Services/Debug/DebugPortAvailabilityChecker.swift
@@ -0,0 +1,14 @@
+import Foundation
+
+/// Answers whether a local service port can be claimed before a debug launch.
+/// The probing mechanism belongs to a platform adapter; application code only
+/// consumes this small synchronous capability.
+@MainActor
+protocol DebugPortAvailabilityChecking: AnyObject {
+ func isPortAvailable(_ port: Int) -> Bool
+}
+
+@MainActor
+final class AlwaysAvailableDebugPortChecker: DebugPortAvailabilityChecking {
+ func isPortAvailable(_: Int) -> Bool { true }
+}
diff --git a/macos/Sources/Lithe/Services/Debug/JavaTestDebugLaunchService.swift b/macos/Sources/Lithe/Services/Debug/JavaTestDebugLaunchService.swift
new file mode 100644
index 000000000..a483e2abc
--- /dev/null
+++ b/macos/Sources/Lithe/Services/Debug/JavaTestDebugLaunchService.swift
@@ -0,0 +1,62 @@
+import Foundation
+import LitheCoreContracts
+
+/// Prepared Java test launch plus the short-lived result channel it owns.
+@MainActor
+struct PreparedJavaTestDebugLaunch {
+ let target: JavaTestDebugLaunchTarget
+ let configuration: DebugLaunchConfiguration
+ let resultServer: any JavaTestResultServing
+
+ func stop() {
+ resultServer.stop()
+ }
+}
+
+/// Coordinates Java test target resolution with the native result listener and
+/// shared Rust launch configuration without owning UI or Debug Adapter state.
+@MainActor
+final class JavaTestDebugLaunchService {
+ private let configurationResolver: DebugLaunchConfigurationResolver
+ private let resultServerFactory: @MainActor () -> any JavaTestResultServing
+
+ init(
+ configurationResolver: DebugLaunchConfigurationResolver,
+ resultServerFactory: @escaping @MainActor () -> any JavaTestResultServing
+ ) {
+ self.configurationResolver = configurationResolver
+ self.resultServerFactory = resultServerFactory
+ }
+
+ func prepare(
+ fileURL: URL,
+ testIdentifier: String?,
+ rootURL: URL,
+ targetResolver: any JavaTestDebugLaunchTargetResolving
+ ) async throws -> PreparedJavaTestDebugLaunch {
+ let target = try await targetResolver.resolveJavaTestDebugLaunchTarget(
+ fileURL: fileURL,
+ testIdentifier: testIdentifier,
+ rootURL: rootURL
+ )
+ try Task.checkCancellation()
+
+ let resultServer = resultServerFactory()
+ do {
+ let resultPort = try await resultServer.start()
+ try Task.checkCancellation()
+ let configuration = try configurationResolver.resolveJavaTest(
+ target: target,
+ resultPort: resultPort
+ )
+ return PreparedJavaTestDebugLaunch(
+ target: target,
+ configuration: configuration,
+ resultServer: resultServer
+ )
+ } catch {
+ resultServer.stop()
+ throw error
+ }
+ }
+}
diff --git a/macos/Sources/Lithe/Services/Java/JavaDebugService.swift b/macos/Sources/Lithe/Services/Java/JavaDebugService.swift
deleted file mode 100644
index 5fe6cc221..000000000
--- a/macos/Sources/Lithe/Services/Java/JavaDebugService.swift
+++ /dev/null
@@ -1,867 +0,0 @@
-import Foundation
-
-@MainActor
-final class JavaDebugService: ObservableObject {
- @Published private(set) var state: JavaDebugSessionState = .idle
- @Published private(set) var output = ""
- @Published private(set) var inspectionTitle: String?
- @Published private(set) var inspectionOutput = ""
- @Published private(set) var variables: [JavaDebugVariable] = []
- @Published private(set) var threads: [JavaDebugThread] = []
- @Published private(set) var callStack: [JavaDebugStackFrame] = []
- @Published private(set) var expandingVariableID: String?
- @Published private(set) var exceptionMessage: String?
- @Published private(set) var port: Int?
- @Published private(set) var breakpoints: [JavaDebugBreakpoint] = []
- @Published var targetKind: JavaDebugTargetKind = .currentFile
- @Published var remoteHost = "127.0.0.1"
- @Published var remotePort = "5005"
- @Published var remoteJavaHomePath = ""
-
- private var debuggeeProcess: (any StreamingProcess)?
- private var jdbProcess: (any StreamingProcess)?
- private var sessionID = UUID()
- private var debugClassName: String?
- private var activeJDBURL: URL?
- private var activeJDBHost = "127.0.0.1"
- private var launchesDebuggee = false
- private var debuggeeOperationID: String?
- private var jdbOperationID: String?
- @Published private(set) var runningTargetTitle: String?
- private var didBootstrap = false
- private let maximumOutputCharacters = 400_000
- private let runtimeService: ProjectRuntimeService
- private let processFactory: () -> any StreamingProcess
- private let fileStorage: any FileStorage
- private let javaMavenOperations: any JavaMavenOperations
- private let runConfigurationOperations: any RunConfigurationOperations
-
- init(
- runtimeService: ProjectRuntimeService,
- processFactory: @escaping () -> any StreamingProcess,
- fileStorage: any FileStorage,
- javaMavenOperations: any JavaMavenOperations,
- runConfigurationOperations: any RunConfigurationOperations
- ) {
- self.runtimeService = runtimeService
- self.processFactory = processFactory
- self.fileStorage = fileStorage
- self.javaMavenOperations = javaMavenOperations
- self.runConfigurationOperations = runConfigurationOperations
- }
-
- private enum InspectionKind {
- case threads
- case stack
- case locals
- case dump(variableID: String)
- case evaluate
- }
-
- private var inspectionKind: InspectionKind?
-
- var isSessionActive: Bool { state != .idle }
- var canControl: Bool { jdbProcess?.isRunning == true }
-
- func start(fileURL: URL, sourceText: String, projectURL: URL?, options: RunOptions) {
- stop()
- guard fileURL.pathExtension.lowercased() == "java" else {
- fail("Select a Java file before starting Debug.")
- return
- }
- guard let projectURL else {
- fail("Open a project before starting Debug.")
- return
- }
- let debugPort = Self.nextPort()
- guard let currentFile = relativePath(for: fileURL, root: projectURL) else {
- fail("The selected Java file is outside the project.")
- return
- }
- let plan: SharedLaunchPlan
- do {
- plan = try runConfigurationOperations.launchPlan(
- at: projectURL,
- configurationID: RunConfiguration.currentFileID,
- currentFile: currentFile,
- classPath: nil,
- debugPort: debugPort
- )
- guard plan.toolchainID == "project-jdk" else {
- throw RunConfigurationOperationFailure(message: "Current File does not use the project JDK.")
- }
- } catch {
- fail(error.localizedDescription)
- return
- }
- guard let javaURL = runtimeService.javaExecutableURL(overridePath: options.javaHomePath),
- let jdbURL = runtimeService.jdbExecutableURL(overridePath: options.javaHomePath) else {
- fail("No JDK with jdb was found. Set JDK Home or JAVA_HOME.")
- return
- }
-
- let id = prepareSession(
- port: debugPort,
- host: "127.0.0.1",
- title: fileURL.lastPathComponent,
- launchesDebuggee: true
- )
- debugClassName = className(for: fileURL, sourceText: sourceText)
- startDebuggee(
- executable: javaURL,
- arguments: plan.arguments,
- workingDirectory: workingDirectory(
- plan.workingDirectory,
- fallback: fileURL.deletingLastPathComponent(),
- relativeTo: projectURL
- ),
- environment: runtimeService.environment(for: .java, javaHomeOverride: options.javaHomePath),
- jdbURL: jdbURL,
- host: "127.0.0.1",
- port: debugPort,
- sessionID: id
- )
- }
-
- func startMaven(
- configuration: RunConfiguration,
- project: MavenProject,
- projectURL: URL,
- options: RunOptions,
- mavenContext: MavenLaunchContext? = nil
- ) {
- stop()
- guard configuration.kind.isMavenBacked else {
- fail("Select a Spring Boot or Maven Module configuration before starting Debug.")
- return
- }
- var options = options
- if options.mavenExecutablePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
- options.mavenExecutablePath = mavenContext?.mavenExecutablePath ?? ""
- }
- if options.mavenJavaHomePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
- options.mavenJavaHomePath = mavenContext?.javaHomePath ?? ""
- }
- let debugPort = Self.nextPort()
- let plan: SharedLaunchPlan
- do {
- plan = try runConfigurationOperations.launchPlan(
- at: projectURL,
- configurationID: configuration.id,
- currentFile: nil,
- classPath: nil,
- debugPort: debugPort,
- mavenContext: mavenContext
- )
- guard plan.toolchainID == "project-maven" else {
- throw RunConfigurationOperationFailure(message: "The selected configuration does not use Maven.")
- }
- } catch {
- fail(error.localizedDescription)
- return
- }
- let mavenJavaHome = options.mavenJavaHomePath.isEmpty
- ? options.javaHomePath
- : options.mavenJavaHomePath
- guard runtimeService.mavenJavaHomeURL(overridePath: mavenJavaHome) != nil,
- let jdbURL = runtimeService.jdbExecutableURL(
- overridePath: mavenJavaHome,
- for: .maven
- ) else {
- fail("No JDK with jdb was found. Set JDK Home or JAVA_HOME.")
- return
- }
-
- let id = prepareSession(
- port: debugPort,
- host: "127.0.0.1",
- title: configuration.name,
- launchesDebuggee: true
- )
- guard let executable = runtimeService.mavenExecutable(
- for: project,
- overridePath: options.mavenExecutablePath
- ) else {
- fail("No Maven executable was found. Edit this service configuration.")
- return
- }
- append(
- "$ " + executable.lastPathComponent + " "
- + redactedMavenArgumentsForDisplay(plan.arguments).joined(separator: " ") + "\n\n"
- )
- startDebuggee(
- executable: executable,
- arguments: plan.arguments,
- workingDirectory: workingDirectory(
- plan.workingDirectory,
- fallback: project.rootURL,
- relativeTo: projectURL
- ),
- environment: runtimeService.environment(for: .maven, javaHomeOverride: mavenJavaHome),
- jdbURL: jdbURL,
- host: "127.0.0.1",
- port: debugPort,
- sessionID: id
- )
- }
-
- func attachRemote() {
- stop()
- let host = remoteHost.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !host.isEmpty else {
- fail("Enter a remote JVM host.")
- return
- }
- guard let port = Int(remotePort.trimmingCharacters(in: .whitespacesAndNewlines)),
- (1...65_535).contains(port) else {
- fail("Enter a valid JDWP port.")
- return
- }
- guard runtimeService.javaExecutableURL(overridePath: remoteJavaHomePath) != nil,
- let jdbURL = runtimeService.jdbExecutableURL(overridePath: remoteJavaHomePath) else {
- fail("No local JDK with jdb was found for the attach session.")
- return
- }
- let id = prepareSession(
- port: port,
- host: host,
- title: host + ":" + String(port),
- launchesDebuggee: false
- )
- append("Attach jdb to \(host):\(port)\n\n")
- attachJDB(jdbURL: jdbURL, host: host, port: port, sessionID: id)
- }
-
- func toggleBreakpoint(fileURL: URL, line: Int, className: String) {
- guard line > 0 else { return }
- let normalizedURL = fileURL.standardizedFileURL
- let id = normalizedURL.path + ":" + String(line)
- if let index = breakpoints.firstIndex(where: { $0.id == id }) {
- let breakpoint = breakpoints.remove(at: index)
- if canControl {
- send("clear \(breakpoint.className):\(breakpoint.line)")
- }
- return
- }
-
- let breakpoint = JavaDebugBreakpoint(
- id: id,
- fileURL: normalizedURL,
- line: line,
- className: className
- )
- breakpoints.append(breakpoint)
- breakpoints.sort { lhs, rhs in
- if lhs.fileURL != rhs.fileURL { return lhs.fileURL.path < rhs.fileURL.path }
- return lhs.line < rhs.line
- }
- if canControl {
- send("stop at \(className):\(line)")
- }
- }
-
- func continueExecution() {
- send("cont")
- state = .running
- }
-
- func pause() {
- send("halt")
- state = .paused
- }
-
- func stepInto() {
- send("step")
- state = .running
- }
-
- func stepOver() {
- send("next")
- state = .running
- }
-
- func stepOut() {
- send("step up")
- state = .running
- }
-
- func inspectThreads() {
- inspect(title: "Threads", command: "threads", kind: .threads)
- }
-
- func inspectStack() {
- inspect(title: "Call Stack", command: "where all", kind: .stack)
- }
-
- func inspectVariables() {
- inspect(title: "Local Variables", command: "locals", kind: .locals)
- }
-
- func evaluate(_ rawExpression: String) {
- let expression = rawExpression.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !expression.isEmpty else { return }
- guard canControl else {
- inspectionTitle = "Evaluate"
- inspectionOutput = "Start or pause a debug session before evaluating an expression.\n"
- inspectionKind = .evaluate
- return
- }
- inspectionTitle = "Evaluate"
- inspectionOutput = "> print \(expression)\n"
- inspectionKind = .evaluate
- expandingVariableID = nil
- send("print \(expression)")
- }
-
- func toggleVariable(_ variable: JavaDebugVariable) {
- guard variable.canExpand else { return }
- if variable.isExpanded {
- updateVariable(variable.id) { $0.isExpanded = false }
- return
- }
- guard canControl else { return }
- updateVariable(variable.id) { $0.isExpanded = true }
- expandingVariableID = variable.id
- inspectionTitle = "Local Variables"
- inspectionKind = .dump(variableID: variable.id)
- inspectionOutput = "> dump \(variable.expression)\n"
- send("dump \(variable.expression)")
- }
-
- func clearOutput() {
- output = ""
- inspectionOutput = ""
- variables = []
- threads = []
- callStack = []
- expandingVariableID = nil
- exceptionMessage = nil
- }
-
- func stop() {
- sessionID = UUID()
- if let jdbProcess, jdbProcess.isRunning {
- try? jdbProcess.send(Data("quit\n".utf8))
- jdbProcess.stop()
- }
- debuggeeProcess?.stop()
- debuggeeProcess = nil
- jdbProcess = nil
- debuggeeOperationID = nil
- jdbOperationID = nil
- didBootstrap = false
- debugClassName = nil
- activeJDBURL = nil
- activeJDBHost = "127.0.0.1"
- launchesDebuggee = false
- runningTargetTitle = nil
- port = nil
- inspectionTitle = nil
- inspectionOutput = ""
- variables = []
- threads = []
- callStack = []
- expandingVariableID = nil
- exceptionMessage = nil
- inspectionKind = nil
- state = .idle
- }
-
- func reset() {
- stop()
- output = ""
- breakpoints = []
- targetKind = .currentFile
- remoteHost = "127.0.0.1"
- remotePort = "5005"
- remoteJavaHomePath = ""
- }
-
- func className(for fileURL: URL, sourceText: String) -> String {
- let simpleName = fileURL.deletingPathExtension().lastPathComponent
- return javaMavenOperations.className(source: sourceText, simpleName: simpleName) ?? simpleName
- }
-
- private static func nextPort() -> Int {
- Int.random(in: 49_152...60_000)
- }
-
- private func relativePath(for fileURL: URL, root: URL) -> String? {
- let file = fileURL.standardizedFileURL.path
- let prefix = root.standardizedFileURL.path + "/"
- guard file.hasPrefix(prefix) else { return nil }
- return String(file.dropFirst(prefix.count))
- }
-
- private func prepareSession(
- port: Int?,
- host: String,
- title: String,
- launchesDebuggee: Bool
- ) -> UUID {
- let id = UUID()
- sessionID = id
- self.port = port
- activeJDBHost = host
- runningTargetTitle = title
- self.launchesDebuggee = launchesDebuggee
- activeJDBURL = nil
- output = ""
- inspectionTitle = nil
- inspectionOutput = ""
- variables = []
- threads = []
- callStack = []
- expandingVariableID = nil
- exceptionMessage = nil
- inspectionKind = nil
- didBootstrap = false
- state = .launching
- return id
- }
-
- private func startDebuggee(
- executable: URL,
- arguments: [String],
- workingDirectory: URL,
- environment: [String: String],
- jdbURL: URL,
- host: String,
- port: Int,
- sessionID: UUID
- ) {
- activeJDBURL = jdbURL
- let debuggee = processFactory()
- debuggee.onOutput = { [weak self] chunk in
- Task { @MainActor [weak self] in
- self?.appendDebuggeeOutput(chunk, sessionID: sessionID)
- }
- }
- debuggee.onTermination = { [weak self] exitCode in
- Task { @MainActor [weak self] in
- guard let self, self.sessionID == sessionID else { return }
- if self.state != .failed {
- self.state = exitCode == 0 ? .finished : .failed
- }
- self.append("[debuggee exited with code \(exitCode)]\n")
- }
- }
- let operationID = UUID().uuidString
- debuggeeOperationID = operationID
- debuggee.onStateChange = { [weak self] event in
- Task { @MainActor [weak self] in
- self?.consumeLifecycle(event, sessionID: sessionID, process: .debuggee)
- }
- }
-
- debuggeeProcess = debuggee
- append("$ " + executable.lastPathComponent + " " + arguments.joined(separator: " ") + "\n\n")
- do {
- try debuggee.start(ProcessRequest(
- operationID: operationID,
- executablePath: executable.path,
- arguments: arguments,
- workingDirectory: workingDirectory.path,
- environment: environment
- ))
- } catch {
- fail("Unable to start debuggee: \(error.localizedDescription)")
- return
- }
-
- // Maven can buffer the JDWP listener line, so keep a delayed attach fallback.
- Task { @MainActor [weak self, weak debuggee] in
- try? await Task.sleep(for: .seconds(5))
- guard let self,
- self.sessionID == sessionID,
- self.jdbProcess == nil,
- debuggee?.isRunning == true else { return }
- self.attachJDB(
- jdbURL: jdbURL,
- host: host,
- port: port,
- sessionID: sessionID
- )
- }
- }
-
- private func attachJDB(jdbURL: URL, host: String, port: Int, sessionID: UUID) {
- guard self.sessionID == sessionID,
- jdbProcess == nil else { return }
-
- let jdb = processFactory()
- jdb.onOutput = { [weak self] chunk in
- Task { @MainActor [weak self] in
- self?.appendJDBOutput(chunk, sessionID: sessionID)
- }
- }
- jdb.onTermination = { [weak self] exitCode in
- Task { @MainActor [weak self] in
- guard let self, self.sessionID == sessionID else { return }
- if self.state == .launching || self.state == .running {
- self.state = .failed
- self.append("[jdb exited with code \(exitCode)]\n")
- }
- self.jdbProcess = nil
- }
- }
- let operationID = UUID().uuidString
- jdbOperationID = operationID
- jdb.onStateChange = { [weak self] event in
- Task { @MainActor [weak self] in
- self?.consumeLifecycle(event, sessionID: sessionID, process: .jdb)
- }
- }
-
- jdbProcess = jdb
- do {
- try jdb.start(ProcessRequest(
- operationID: operationID,
- executablePath: jdbURL.path,
- arguments: ["-J-Duser.language=en", "-J-Duser.country=US", "-attach", "\(host):\(port)"],
- keepsStandardInputOpen: true
- ))
- } catch {
- fail("Unable to start jdb: \(error.localizedDescription)")
- return
- }
-
- Task { @MainActor [weak self, weak jdb] in
- try? await Task.sleep(for: .milliseconds(900))
- guard let self,
- self.sessionID == sessionID,
- jdb?.isRunning == true,
- !self.didBootstrap else { return }
- self.didBootstrap = true
- for breakpoint in self.breakpoints {
- self.send("stop at \(breakpoint.className):\(breakpoint.line)")
- }
- if self.launchesDebuggee {
- self.send("run")
- self.state = .running
- } else {
- self.state = .paused
- }
- }
- }
-
- private func appendDebuggeeOutput(_ chunk: String, sessionID: UUID) {
- guard self.sessionID == sessionID else { return }
- append("[debuggee] " + chunk)
- _ = detectException(in: chunk)
- if chunk.localizedCaseInsensitiveContains("Listening for transport") {
- guard let port, let activeJDBURL else { return }
- attachJDB(
- jdbURL: activeJDBURL,
- host: activeJDBHost,
- port: port,
- sessionID: sessionID
- )
- }
- }
-
- private func appendJDBOutput(_ chunk: String, sessionID: UUID) {
- guard self.sessionID == sessionID else { return }
- append("[jdb] " + chunk)
- let didDetectException = detectException(in: chunk)
- if inspectionTitle != nil {
- inspectionOutput.append(chunk)
- if inspectionOutput.count > 80_000 {
- inspectionOutput.removeFirst(inspectionOutput.count - 80_000)
- }
- refreshInspectionData()
- }
- if chunk.contains("Breakpoint hit:") || chunk.contains("Step completed:") || chunk.contains("Method entered:") || didDetectException {
- state = .paused
- }
- }
-
- private func inspect(title: String, command: String, kind: InspectionKind) {
- inspectionTitle = title
- inspectionOutput = "> \(command)\n"
- inspectionKind = kind
- expandingVariableID = nil
- switch kind {
- case .threads: threads = []
- case .stack: callStack = []
- case .locals: variables = []
- case .dump: break
- case .evaluate: break
- }
- send(command)
- }
-
- private func refreshInspectionData() {
- guard let inspectionKind else { return }
- switch inspectionKind {
- case .threads:
- threads = Self.parseThreads(inspectionOutput)
- case .stack:
- callStack = Self.parseStackFrames(inspectionOutput)
- case .locals:
- variables = Self.parseVariables(inspectionOutput)
- case .dump(let variableID):
- guard let variable = variable(with: variableID) else { return }
- let children = Self.parseDumpChildren(inspectionOutput, parent: variable)
- guard !children.isEmpty else { return }
- updateVariable(variableID) {
- $0.children = children
- $0.isExpanded = true
- }
- expandingVariableID = nil
- case .evaluate:
- break
- }
- }
-
- private func variable(with id: String, in values: [JavaDebugVariable]? = nil) -> JavaDebugVariable? {
- let values = values ?? variables
- for value in values {
- if value.id == id { return value }
- if let child = variable(with: id, in: value.children) { return child }
- }
- return nil
- }
-
- @discardableResult
- private func updateVariable(
- _ id: String,
- in values: inout [JavaDebugVariable],
- update: (inout JavaDebugVariable) -> Void
- ) -> Bool {
- for index in values.indices {
- if values[index].id == id {
- update(&values[index])
- return true
- }
- if updateVariable(id, in: &values[index].children, update: update) {
- return true
- }
- }
- return false
- }
-
- private func updateVariable(
- _ id: String,
- update: (inout JavaDebugVariable) -> Void
- ) {
- _ = updateVariable(id, in: &variables, update: update)
- }
-
- private static func parseVariables(_ text: String) -> [JavaDebugVariable] {
- var result: [JavaDebugVariable] = []
- for line in text.components(separatedBy: .newlines) {
- guard let assignment = parseAssignment(line) else { continue }
- let expression = assignment.name
- guard !result.contains(where: { $0.id == expression }) else { continue }
- result.append(JavaDebugVariable(
- id: expression,
- name: assignment.name,
- expression: expression,
- value: assignment.value,
- children: [],
- isExpanded: false,
- isExpandable: looksExpandable(assignment.value)
- ))
- }
- return result
- }
-
- private static func parseDumpChildren(
- _ text: String,
- parent: JavaDebugVariable
- ) -> [JavaDebugVariable] {
- var result: [JavaDebugVariable] = []
- for line in text.components(separatedBy: .newlines) {
- guard let assignment = parseAssignment(line),
- assignment.name != parent.name,
- assignment.name != parent.expression else { continue }
- let expression: String
- if assignment.name.hasPrefix("[") {
- expression = parent.expression + assignment.name
- } else {
- expression = parent.expression + "." + assignment.name
- }
- guard !result.contains(where: { $0.id == expression }) else { continue }
- result.append(JavaDebugVariable(
- id: expression,
- name: assignment.name,
- expression: expression,
- value: assignment.value,
- children: [],
- isExpanded: false,
- isExpandable: looksExpandable(assignment.value)
- ))
- }
- return result
- }
-
- private static func parseAssignment(_ line: String) -> (name: String, value: String)? {
- let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !trimmed.isEmpty,
- !trimmed.hasPrefix(">"),
- !trimmed.hasSuffix(":"),
- let separator = trimmed.range(of: " = ") else { return nil }
- let name = String(trimmed[.. Bool {
- if name.hasPrefix("[") && name.hasSuffix("]") { return true }
- guard let first = name.unicodeScalars.first,
- CharacterSet.letters.union(CharacterSet(charactersIn: "_$")).contains(first) else {
- return false
- }
- return name.unicodeScalars.dropFirst().allSatisfy {
- CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "_$")).contains($0)
- }
- }
-
- private static func looksExpandable(_ value: String) -> Bool {
- let lowercased = value.lowercased()
- return value.hasSuffix("{") ||
- lowercased.contains("instance of ") ||
- lowercased.contains("[length") ||
- lowercased.contains("array")
- }
-
- private static func parseThreads(_ text: String) -> [JavaDebugThread] {
- var result: [JavaDebugThread] = []
- for line in text.components(separatedBy: .newlines) {
- let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !trimmed.isEmpty,
- !trimmed.lowercased().hasPrefix("group ") else { continue }
-
- let id: String
- let name: String
- let status: String
- if let colon = trimmed.firstIndex(of: ":"),
- Int(trimmed[.. [JavaDebugStackFrame] {
- var result: [JavaDebugStackFrame] = []
- for line in text.components(separatedBy: .newlines) {
- let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
- guard trimmed.hasPrefix("[") else { continue }
- guard let closing = trimmed.firstIndex(of: "]"),
- let level = Int(trimmed[trimmed.index(after: trimmed.startIndex).. Bool {
- for line in text.components(separatedBy: .newlines) {
- let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
- let lowercased = trimmed.lowercased()
- guard lowercased.contains("exception") || lowercased.hasPrefix("caused by:") else { continue }
- if lowercased.contains("exception occurred") ||
- lowercased.hasPrefix("exception in thread") ||
- lowercased.hasPrefix("uncaught exception") ||
- lowercased.hasPrefix("caused by:") {
- exceptionMessage = trimmed
- return true
- }
- }
- return false
- }
-
- private func send(_ command: String) {
- guard let jdbProcess, jdbProcess.isRunning else { return }
- try? jdbProcess.send(Data((command + "\n").utf8))
- }
-
- private func append(_ value: String) {
- output.append(value.replacingOccurrences(of: "\r", with: ""))
- if output.count > maximumOutputCharacters {
- output.removeFirst(output.count - maximumOutputCharacters)
- }
- }
-
- private func fail(_ message: String) {
- output = message + "\n"
- state = .failed
- debuggeeProcess?.stop()
- }
-
- private enum ProcessKind: Equatable {
- case debuggee
- case jdb
- }
-
- private func consumeLifecycle(
- _ event: ProcessLifecycleEvent,
- sessionID: UUID,
- process: ProcessKind
- ) {
- guard self.sessionID == sessionID else { return }
- let expectedID = process == .debuggee ? debuggeeOperationID : jdbOperationID
- guard event.operationID == expectedID else { return }
- switch event.state {
- case .starting:
- state = .launching
- case .running:
- if process == .jdb, didBootstrap { state = launchesDebuggee ? .running : .paused }
- case .stopping:
- break
- case .finished:
- break
- case .failed:
- state = .failed
- if let message = event.message, !message.isEmpty {
- append("[" + (process == .jdb ? "jdb" : "debuggee") + ": " + message + "]\n")
- }
- }
- }
-
- private func workingDirectory(_ path: String, fallback: URL, relativeTo projectURL: URL?) -> URL {
- let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !trimmed.isEmpty else { return fallback }
- let expanded = (trimmed as NSString).expandingTildeInPath
- let url = (expanded.hasPrefix("/")
- ? URL(fileURLWithPath: expanded)
- : URL(fileURLWithPath: expanded, relativeTo: projectURL ?? fallback)
- ).standardizedFileURL
- guard fileStorage.metadata(for: url)?.isDirectory == true else {
- return fallback
- }
- return url
- }
-
-}
diff --git a/macos/Sources/Lithe/Services/Java/ProjectRuntimeService.swift b/macos/Sources/Lithe/Services/Java/ProjectRuntimeService.swift
index 16a5640b5..7bb8ab4eb 100644
--- a/macos/Sources/Lithe/Services/Java/ProjectRuntimeService.swift
+++ b/macos/Sources/Lithe/Services/Java/ProjectRuntimeService.swift
@@ -209,22 +209,6 @@ final class ProjectRuntimeService: ObservableObject {
return message
}
- func jdbExecutableURL(
- overridePath: String? = nil,
- for processKind: ProjectRuntimeProcessKind = .java
- ) -> URL? {
- let home = processKind == .maven
- ? mavenJavaHomeURL(overridePath: overridePath)
- : javaHomeURL(overridePath: overridePath)
- if let home {
- let candidate = home.appendingPathComponent("bin/jdb")
- if runtimeLocator.isExecutable(at: candidate) {
- return candidate
- }
- }
- return runtimeLocator.systemJDBExecutable()
- }
-
func mavenJavaHomeURL(overridePath: String? = nil) -> URL? {
if let overridePath {
let normalizedPath = normalizedOverridePath(overridePath)
@@ -317,34 +301,17 @@ final class ProjectRuntimeService: ObservableObject {
status: .jdkMissing,
projectURL: projectURL,
javaHomePath: nil,
- javaExecutablePath: nil,
- jdbExecutablePath: runtimeLocator.systemJDBExecutable()?.path
+ javaExecutablePath: nil
)
return
}
let javaExecutable = javaHome.appendingPathComponent("bin/java")
- let bundledJDB = javaHome.appendingPathComponent("bin/jdb")
- let jdbExecutable = runtimeLocator.isExecutable(at: bundledJDB)
- ? bundledJDB
- : runtimeLocator.systemJDBExecutable()
- guard let jdbExecutable else {
- javaEnvironmentReport = JavaEnvironmentReport(
- status: .jdbMissing,
- projectURL: projectURL,
- javaHomePath: javaHome.path,
- javaExecutablePath: javaExecutable.path,
- jdbExecutablePath: nil
- )
- return
- }
-
javaEnvironmentReport = JavaEnvironmentReport(
status: .ready,
projectURL: projectURL,
javaHomePath: javaHome.path,
- javaExecutablePath: javaExecutable.path,
- jdbExecutablePath: jdbExecutable.path
+ javaExecutablePath: javaExecutable.path
)
}
diff --git a/macos/Sources/Lithe/Services/Language/LanguagePackRegistry.swift b/macos/Sources/Lithe/Services/Language/LanguagePackRegistry.swift
index 689812299..d7aaa0414 100644
--- a/macos/Sources/Lithe/Services/Language/LanguagePackRegistry.swift
+++ b/macos/Sources/Lithe/Services/Language/LanguagePackRegistry.swift
@@ -87,12 +87,6 @@ final class LanguagePackRegistry {
private static func standardDebugAdapterDefinition(for id: String) -> StdioDebugAdapterLaunch? {
switch id {
- case "java":
- return StdioDebugAdapterLaunch(
- adapterID: "java",
- executableNames: ["java-debug-adapter", "java-debug"],
- arguments: ["--stdio"]
- )
case "go":
return StdioDebugAdapterLaunch(
adapterID: "go",
diff --git a/macos/Sources/Lithe/Theme/LitheIcons.swift b/macos/Sources/Lithe/Theme/LitheIcons.swift
index b136e0692..586fbb640 100644
--- a/macos/Sources/Lithe/Theme/LitheIcons.swift
+++ b/macos/Sources/Lithe/Theme/LitheIcons.swift
@@ -167,6 +167,33 @@ enum LitheIcons {
ideaAssetPathsBySystemImage[systemImage]
}
+ /// Returns the IntelliJ dark-theme sibling for an imported SVG path.
+ /// The caller still falls back to the base asset because not every
+ /// IntelliJ catalog entry ships a dedicated dark variant.
+ static func darkIdeaAssetPath(for resourcePath: String) -> String {
+ let path = resourcePath as NSString
+ let directory = path.deletingLastPathComponent
+ let filename = path.lastPathComponent as NSString
+ let resourceName = filename.deletingPathExtension
+ let darkFilename = "\(resourceName)_dark.\(filename.pathExtension)"
+ return directory.isEmpty ? darkFilename : "\(directory)/\(darkFilename)"
+ }
+
+ /// Maps the editor gutter breakpoint state to the matching IntelliJ
+ /// debugger glyph. The catalog keeps the red set/verified marks and the
+ /// muted/disabled state visually distinct, just like IDEA's gutter.
+ static func debuggerBreakpointAssetPath(
+ enabled: Bool,
+ verified: Bool,
+ muted: Bool
+ ) -> String {
+ if muted { return "debugger/db_muted_breakpoint.svg" }
+ if !enabled { return "debugger/db_disabled_breakpoint.svg" }
+ return verified
+ ? "debugger/db_verified_breakpoint.svg"
+ : "debugger/db_set_breakpoint.svg"
+ }
+
/// src/main/java、src/test/kotlin 之类的源码根。资源根同样按这个布局
/// 判断,避免把任意一个叫 resources 的目录标成资源根。
static func isSourceRootDirectory(_ url: URL) -> Bool {
@@ -520,18 +547,29 @@ struct LitheIcon: View {
/// A small SwiftUI bridge for the imported IntelliJ SVG catalog.
/// `fallbackSystemImage` keeps the UI usable in an unbundled debug preview.
+@MainActor
struct LitheIDEAIcon: View {
+ @Environment(\.colorScheme) private var colorScheme
let resourcePath: String
var size: CGFloat = 14
var fallbackSystemImage: String?
+ var preservesOriginalColors = false
var body: some View {
- if let image = LitheIcons.ideaImage(resourcePath: resourcePath) {
- Image(nsImage: image)
- .renderingMode(.template)
- .resizable()
- .interpolation(.high)
- .frame(width: size, height: size)
+ if let image = resolvedImage {
+ if preservesOriginalColors {
+ Image(nsImage: image)
+ .renderingMode(.original)
+ .resizable()
+ .interpolation(.high)
+ .frame(width: size, height: size)
+ } else {
+ Image(nsImage: image)
+ .renderingMode(.template)
+ .resizable()
+ .interpolation(.high)
+ .frame(width: size, height: size)
+ }
} else if let fallbackSystemImage {
Image(systemName: fallbackSystemImage)
.font(.system(size: size, weight: .medium))
@@ -540,6 +578,17 @@ struct LitheIDEAIcon: View {
Color.clear.frame(width: size, height: size)
}
}
+
+ @MainActor
+ private var resolvedImage: NSImage? {
+ if colorScheme == .dark,
+ let darkImage = LitheIcons.ideaImage(
+ resourcePath: LitheIcons.darkIdeaAssetPath(for: resourcePath)
+ ) {
+ return darkImage
+ }
+ return LitheIcons.ideaImage(resourcePath: resourcePath)
+ }
}
/// Compatibility wrapper for common existing SF Symbol call sites. It uses
diff --git a/macos/Sources/Lithe/Views/Debug/DebugToolbarPresentation.swift b/macos/Sources/Lithe/Views/Debug/DebugToolbarPresentation.swift
new file mode 100644
index 000000000..bf141cc51
--- /dev/null
+++ b/macos/Sources/Lithe/Views/Debug/DebugToolbarPresentation.swift
@@ -0,0 +1,107 @@
+import CoreGraphics
+import LitheCoreContracts
+
+/// Stable IDEA-aligned ordering and icon catalog for the macOS Debug toolbar.
+/// Keeping these values outside the view prevents platform symbols or ad-hoc
+/// reordering from silently changing the debugger's visual language.
+enum DebugToolbarActionID: String, CaseIterable, Identifiable {
+ case restartOrStart
+ case stop
+ case resume
+ case pause
+ case stepOver
+ case stepInto
+ case stepOut
+ case viewBreakpoints
+ case muteBreakpoints
+
+ var id: Self { self }
+}
+
+enum DebugToolbarPresentation {
+ static let primaryActions: [DebugToolbarActionID] = [
+ .restartOrStart,
+ .stop,
+ .resume,
+ .pause,
+ .stepOver,
+ .stepInto,
+ .stepOut,
+ .viewBreakpoints,
+ .muteBreakpoints
+ ]
+
+ static let separatorsAfter: Set = [.stop, .stepOut]
+ // Keep the primary controls legible at the compact tool-window scale;
+ // IDEA's debugger gives these actions a little more visual weight than
+ // ordinary tool-window buttons.
+ static let iconSize: CGFloat = 18
+ static let toolbarHeight: CGFloat = 36
+ static let sessionHeaderHeight: CGFloat = 34
+
+ static func statusText(
+ for state: DebugAdapterState,
+ stoppedReason: String?
+ ) -> String {
+ switch state {
+ case .paused:
+ let reason = stoppedReason?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ guard !reason.isEmpty else { return "Paused" }
+ return "Paused · \(stopReasonLabel(reason))"
+ case .running: return "Running"
+ case .launching: return "Launching"
+ case .initializing: return "Initializing"
+ case .ready: return "Ready"
+ case .terminated: return "Finished"
+ case .failed: return "Failed"
+ case .idle: return "Ready"
+ }
+ }
+
+ private static func stopReasonLabel(_ reason: String) -> String {
+ switch reason.lowercased() {
+ case "breakpoint": return "Breakpoint"
+ case "function breakpoint": return "Method breakpoint"
+ case "data breakpoint": return "Field breakpoint"
+ case "instruction breakpoint": return "Instruction breakpoint"
+ case "exception": return "Exception"
+ case "step": return "Step"
+ case "pause": return "Pause"
+ case "entry": return "Entry"
+ case "goto": return "Run to cursor"
+ default: return reason
+ }
+ }
+
+ static func ideaAssetPath(
+ for action: DebugToolbarActionID,
+ isSessionActive: Bool = true
+ ) -> String {
+ switch action {
+ case .restartOrStart:
+ isSessionActive ? "debugger/restartDebug.svg" : "debugger/debug.svg"
+ case .stop: "debugger/stop.svg"
+ case .resume: "debugger/resume.svg"
+ case .pause: "debugger/pause.svg"
+ case .stepOver: "debugger/stepOver.svg"
+ case .stepInto: "debugger/stepInto.svg"
+ case .stepOut: "debugger/stepOut.svg"
+ case .viewBreakpoints: "debugger/viewBreakpoints.svg"
+ case .muteBreakpoints: "debugger/muteBreakpoints.svg"
+ }
+ }
+
+ static func fallbackSystemImage(for action: DebugToolbarActionID) -> String {
+ switch action {
+ case .restartOrStart: "ladybug.fill"
+ case .stop: "stop.fill"
+ case .resume: "play.fill"
+ case .pause: "pause.fill"
+ case .stepOver: "arrow.right.to.line"
+ case .stepInto: "arrow.down.to.line"
+ case .stepOut: "arrow.up.to.line"
+ case .viewBreakpoints: "list.bullet.rectangle"
+ case .muteBreakpoints: "eye.slash"
+ }
+ }
+}
diff --git a/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift b/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift
index a80441d76..2657074c0 100644
--- a/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift
+++ b/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift
@@ -1,4 +1,5 @@
import SwiftUI
+import AppKit
import LitheCoreContracts
import LitheDebugModule
@@ -6,262 +7,1312 @@ struct GenericDebugView: View {
@EnvironmentObject private var model: AppModel
@ObservedObject var feature: GenericDebugFeatureModel
@State private var evaluateExpression = ""
+ @State private var editingVariable: DebugVariable?
+ @State private var watchEditor: WatchEditorContext?
+ @State private var smartStepTargets: [DebugStepInTarget] = []
+ @State private var isSmartStepPickerPresented = false
+ @State private var isJavaAttachPresented = false
+ @State private var isJavaSteppingSettingsPresented = false
+ @State private var selectedContent: DebugContent = .debugger
+ @State private var consoleExpression = ""
+ @State private var programInput = ""
+ @FocusState private var isConsoleInputFocused: Bool
var body: some View {
VStack(spacing: 0) {
header
Rectangle().fill(LitheTheme.divider).frame(height: 1)
+ debugToolbar
+ Rectangle().fill(LitheTheme.divider).frame(height: 1)
if feature.isSessionActive || !feature.output.isEmpty || feature.errorMessage != nil {
- HStack(spacing: 0) {
- inspector
- .frame(width: 300)
- Rectangle().fill(LitheTheme.divider).frame(width: 1)
- output
+ VStack(spacing: 0) {
+ contentTabs
+ Rectangle().fill(LitheTheme.divider).frame(height: 1)
+ activeContent
}
} else {
emptyState
}
}
.litheWorkbenchSurface(LitheTheme.editor)
+ .sheet(item: $editingVariable) { variable in
+ VariableValueEditorView(variable: variable) {
+ feature.setVariable(variable, value: $0)
+ }
+ }
+ .sheet(item: $watchEditor) { context in
+ WatchEditorView(watch: context.watch) { expression in
+ if let watch = context.watch {
+ feature.updateWatch(watch, expression: expression)
+ } else {
+ feature.addWatch(expression)
+ }
+ }
+ }
+ .sheet(isPresented: $isJavaAttachPresented) {
+ JavaAttachView { host, port in
+ model.attachJavaDebugger(host: host, port: port)
+ }
+ }
+ .sheet(isPresented: $isJavaSteppingSettingsPresented) {
+ if let filters = feature.javaSteppingFilters {
+ JavaSteppingFiltersView(
+ filters: filters,
+ onSave: feature.updateJavaSteppingFilters,
+ onReset: feature.resetJavaSteppingFilters
+ )
+ }
+ }
+ .onChange(of: feature.state) { state in
+ switch state {
+ case .paused:
+ selectedContent = .debugger
+ if isConsoleInputFocused {
+ isConsoleInputFocused = false
+ }
+ case .failed:
+ selectedContent = .console
+ default:
+ break
+ }
+ }
+ .onChange(of: selectedContent) { content in
+ if content == .console && feature.state == .paused {
+ isConsoleInputFocused = true
+ }
+ }
+ }
+
+ @ViewBuilder
+ private var activeContent: some View {
+ switch selectedContent {
+ case .debugger:
+ inspector
+ case .console:
+ debugConsole
+ }
+ }
+
+ private var contentTabs: some View {
+ HStack(spacing: 0) {
+ ForEach(DebugContent.allCases) { content in
+ Button {
+ selectedContent = content
+ } label: {
+ Text(content.title)
+ .font(.system(size: 11.5, weight: .medium))
+ .foregroundStyle(
+ selectedContent == content
+ ? LitheTheme.primaryText
+ : LitheTheme.secondaryText
+ )
+ .padding(.horizontal, 11)
+ .frame(height: 25)
+ .background(
+ RoundedRectangle(cornerRadius: 5)
+ .fill(selectedContent == content
+ ? LitheTheme.selection
+ : Color.clear)
+ )
+ .overlay {
+ if selectedContent == content {
+ RoundedRectangle(cornerRadius: 5)
+ .stroke(LitheTheme.accent.opacity(0.65), lineWidth: 1)
+ }
+ }
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .lithePointer()
+ }
+ Spacer(minLength: 0)
+ }
+ .padding(.horizontal, 8)
+ .frame(height: 32)
+ .litheWorkbenchSurface(LitheTheme.toolHeader)
}
private var header: some View {
- LitheToolWindowHeader(
- title: "Debug",
- systemImage: "ladybug",
- ideaAssetPath: "toolwindows/toolWindowDebugger.svg",
- subtitle: feature.state.title,
- onMinimize: { model.isDebugVisible = false }
- ) {
- if let providerID = feature.providerID {
- Text(providerID.uppercased())
- .font(.system(size: 10.5, weight: .semibold))
- .foregroundStyle(LitheTheme.secondaryText)
+ HStack(spacing: 10) {
+ Text("Debug")
+ .font(.system(size: 12.5, weight: .semibold))
+ .foregroundStyle(LitheTheme.toolWindowText)
+ if let sessionTitle = debugSessionTitle {
+ debugSessionTab(sessionTitle)
}
- if let targetTitle = feature.targetTitle {
- Text(targetTitle)
- .font(.system(size: 11.5, weight: .medium))
- .foregroundStyle(LitheTheme.secondaryText)
+ if feature.sessionSummaries.count > 1 {
+ sessionPicker
+ }
+ if !feature.isSessionActive {
+ debugConfigurationPicker
+ }
+ Spacer(minLength: 8)
+ debugOptionsMenu
+ Button { model.isDebugVisible = false } label: {
+ Image(systemName: "minus")
+ }
+ .litheIconButton()
+ .help("Hide Debug tool window")
+ }
+ .padding(.leading, 12)
+ .padding(.trailing, 7)
+ .frame(height: DebugToolbarPresentation.sessionHeaderHeight)
+ .litheWorkbenchSurface(LitheTheme.toolHeader)
+ }
+
+ private var debugSessionTitle: String? {
+ if let targetTitle = feature.targetTitle, !targetTitle.isEmpty {
+ return targetTitle
+ }
+ if let providerID = feature.providerID, !providerID.isEmpty {
+ return providerID.uppercased()
+ }
+ if let selectedConfiguration = model.runFeatureIfActive?.selectedConfiguration,
+ !selectedConfiguration.name.isEmpty {
+ return selectedConfiguration.name
+ }
+ return nil
+ }
+
+ private func debugSessionTab(_ title: String) -> some View {
+ HStack(spacing: 6) {
+ LitheIDEAIcon(
+ resourcePath: "debugger/debug.svg",
+ size: 14,
+ fallbackSystemImage: "ladybug.fill",
+ preservesOriginalColors: true
+ )
+ Text(title)
+ .font(.system(size: 11.5, weight: .medium))
+ .lineLimit(1)
+ if feature.isSessionActive {
+ Button(action: stopActiveDebugSession) {
+ Image(systemName: "xmark")
+ .font(.system(size: 8, weight: .semibold))
+ .foregroundStyle(LitheTheme.secondaryText)
+ .frame(width: 16, height: 16)
+ }
+ .buttonStyle(.plain)
+ .lithePointer()
+ .help("Stop debug session")
+ }
+ }
+ .foregroundStyle(LitheTheme.primaryText)
+ .padding(.leading, 8)
+ .padding(.trailing, feature.isSessionActive ? 4 : 8)
+ .frame(height: 25)
+ .background(RoundedRectangle(cornerRadius: 5).fill(LitheTheme.selection))
+ .overlay {
+ RoundedRectangle(cornerRadius: 5)
+ .stroke(LitheTheme.accent.opacity(0.7), lineWidth: 1)
+ }
+ }
+
+ private var sessionPicker: some View {
+ Menu {
+ Section("Sessions") {
+ ForEach(feature.sessionSummaries) { summary in
+ Button {
+ _ = feature.selectSession(summary.id)
+ } label: {
+ HStack(spacing: 6) {
+ Image(systemName: summary.id == feature.activeSessionID
+ ? "checkmark.circle.fill" : "circle")
+ VStack(alignment: .leading, spacing: 1) {
+ Text(sessionLabel(summary))
+ Text(summary.state.title)
+ .font(.system(size: 9))
+ .foregroundStyle(LitheTheme.secondaryText)
+ }
+ }
+ }
+ }
+ }
+ if feature.sessionSummaries.count > 1 {
+ Divider()
+ Section("Close other sessions") {
+ ForEach(feature.sessionSummaries.filter { $0.id != feature.activeSessionID }) { summary in
+ Button("Stop \(sessionLabel(summary))", role: .destructive) {
+ feature.stopSession(summary.id)
+ }
+ }
+ }
+ }
+ } label: {
+ LitheIDEAIcon(
+ resourcePath: "debugger/threads.svg",
+ size: 14,
+ fallbackSystemImage: "square.stack.3d.up",
+ preservesOriginalColors: true
+ )
+ }
+ .litheIconButton()
+ .help("Debug sessions")
+ .accessibilityLabel("Debug sessions")
+ }
+
+ private func sessionLabel(_ summary: DebugSessionSummary) -> String {
+ let rootName = summary.rootURL.lastPathComponent.isEmpty
+ ? summary.rootURL.path
+ : summary.rootURL.lastPathComponent
+ if let targetTitle = summary.targetTitle, !targetTitle.isEmpty {
+ return "\(targetTitle) · \(rootName)"
+ }
+ return "\(summary.providerDisplayName) · \(rootName)"
+ }
+
+ /// Keeps the Debug entry point visibly tied to the same Run configuration
+ /// used by the Run tool window. IDEA exposes this choice next to the
+ /// debugger session rather than hiding it behind a second, unrelated
+ /// launch flow.
+ private var debugConfigurationPicker: some View {
+ Menu {
+ if let runFeature = model.runFeatureIfActive,
+ !runFeature.configurations.isEmpty {
+ ForEach(runFeature.configurations) { configuration in
+ Button {
+ model.selectRunConfiguration(configuration)
+ } label: {
+ HStack(spacing: 7) {
+ RunConfigurationIcon(kind: configuration.kind, size: 14)
+ Text(configuration.name)
+ if configuration.id == runFeature.selectedConfiguration?.id {
+ Spacer(minLength: 8)
+ Image(systemName: "checkmark")
+ }
+ }
+ }
+ }
+ } else {
+ Button("Current File") {
+ model.selectRunConfiguration(.currentFile)
+ }
+ }
+ } label: {
+ HStack(spacing: 5) {
+ RunConfigurationIcon(
+ kind: model.runFeatureIfActive?.selectedConfiguration?.kind ?? .currentFile,
+ size: 13
+ )
+ Text(model.runFeatureIfActive?.selectedConfiguration?.name ?? "Current File")
+ .font(.system(size: 11, weight: .medium))
.lineLimit(1)
+ Image(systemName: "chevron.down")
+ .font(.system(size: 8, weight: .bold))
}
- Spacer()
- controlButton(
- feature.state == .running ? "pause.fill" : "play.fill",
- help: feature.state == .running ? "Pause" : "Continue",
- disabled: !feature.canControl
- ) {
- feature.execute(feature.state == .running ? .pause : .continueExecution)
+ .foregroundStyle(LitheTheme.primaryText)
+ .padding(.horizontal, 8)
+ .frame(maxWidth: 210, minHeight: 25)
+ .background(RoundedRectangle(cornerRadius: 5).fill(LitheTheme.selection.opacity(0.72)))
+ .contentShape(Rectangle())
+ }
+ .menuStyle(.borderlessButton)
+ .fixedSize(horizontal: true, vertical: false)
+ .help("Select the Run configuration used by Debug")
+ .accessibilityLabel("Debug run configuration")
+ .accessibilityIdentifier("debug-run-configuration-picker")
+ }
+
+ private var debugToolbar: some View {
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: 2) {
+ ForEach(DebugToolbarPresentation.primaryActions) { action in
+ debugToolbarActionButton(action)
+ if DebugToolbarPresentation.separatorsAfter.contains(action) {
+ toolbarDivider
+ }
+ }
+ debugOptionsMenu
+ debugExecutionStatus
+ Spacer(minLength: 8)
+ }
+ .padding(.horizontal, 8)
+ .frame(height: DebugToolbarPresentation.toolbarHeight)
+ }
+ .litheWorkbenchSurface(LitheTheme.toolHeader)
+ .popover(isPresented: $isSmartStepPickerPresented, arrowEdge: .bottom) {
+ smartStepPicker
+ }
+ }
+
+ private func debugToolbarActionButton(_ action: DebugToolbarActionID) -> some View {
+ let isDisabled = isDebugToolbarActionDisabled(action)
+ return Button {
+ performDebugToolbarAction(action)
+ } label: {
+ LitheIDEAIcon(
+ resourcePath: DebugToolbarPresentation.ideaAssetPath(
+ for: action,
+ isSessionActive: feature.isSessionActive
+ ),
+ size: DebugToolbarPresentation.iconSize,
+ fallbackSystemImage: DebugToolbarPresentation.fallbackSystemImage(for: action),
+ preservesOriginalColors: true
+ )
+ .frame(width: DebugToolbarPresentation.iconSize, height: DebugToolbarPresentation.iconSize)
+ }
+ .litheIconButton()
+ .frame(width: 32, height: 30)
+ .disabled(isDisabled)
+ .opacity(isDisabled ? 0.36 : 1)
+ .help(debugToolbarActionHelp(action))
+ .accessibilityLabel(debugToolbarActionHelp(action))
+ .accessibilityIdentifier("debug-toolbar-\(action.rawValue)")
+ }
+
+ private func performDebugToolbarAction(_ action: DebugToolbarActionID) {
+ switch action {
+ case .restartOrStart:
+ if feature.isSessionActive {
+ feature.execute(.restart)
+ } else if feature.canRetry {
+ _ = feature.retry()
+ } else {
+ model.startDebugging()
}
- controlButton("arrow.right.to.line", help: "Step over", disabled: feature.state != .paused) {
- feature.execute(.next)
+ case .stop:
+ stopActiveDebugSession()
+ case .resume:
+ feature.execute(.continueExecution)
+ case .pause:
+ feature.execute(.pause)
+ case .stepOver:
+ feature.execute(.next)
+ case .stepInto:
+ feature.execute(.stepIn)
+ case .stepOut:
+ feature.execute(.stepOut)
+ case .viewBreakpoints:
+ model.showDebugBreakpointManager()
+ case .muteBreakpoints:
+ feature.toggleBreakpointMute()
+ }
+ }
+
+ private func isDebugToolbarActionDisabled(_ action: DebugToolbarActionID) -> Bool {
+ switch action {
+ case .restartOrStart:
+ feature.isSessionActive && !feature.canRestart
+ case .stop:
+ !feature.isSessionActive
+ case .resume:
+ feature.state != .paused || feature.isExecutionRequestPending
+ case .pause:
+ feature.state != .running || feature.isExecutionRequestPending
+ case .stepOver, .stepInto, .stepOut:
+ // DAP step requests require a concrete stopped thread. Keep the
+ // toolbar disabled during the short inspection window after a
+ // stop event instead of sending a no-op request with no thread.
+ feature.state != .paused || feature.selectedThreadID == nil
+ || feature.isExecutionRequestPending
+ case .viewBreakpoints:
+ model.workspaceURL == nil
+ case .muteBreakpoints:
+ feature.breakpoints.isEmpty
+ }
+ }
+
+ private func debugToolbarActionHelp(_ action: DebugToolbarActionID) -> String {
+ let title: String
+ switch action {
+ case .restartOrStart:
+ title = feature.isSessionActive ? "Rerun" : feature.canRetry ? "Retry debugging" : "Start debugging"
+ case .stop: title = "Stop debugging"
+ case .resume: title = "Resume"
+ case .pause: title = "Pause"
+ case .stepOver: title = "Step over"
+ case .stepInto: title = "Step into"
+ case .stepOut: title = "Step out"
+ case .viewBreakpoints: title = "View breakpoints"
+ case .muteBreakpoints:
+ title = feature.areBreakpointsMuted ? "Enable breakpoints" : "Mute breakpoints"
+ }
+ guard let commandID = debugToolbarCommandID(for: action),
+ let shortcut = model.keyboardShortcutFeature.displayText(for: commandID),
+ !shortcut.isEmpty else {
+ return title
+ }
+ return "\(title) (\(shortcut))"
+ }
+
+ private func debugToolbarCommandID(for action: DebugToolbarActionID) -> String? {
+ switch action {
+ case .restartOrStart: "debug"
+ case .stop: "stop-debug"
+ case .resume: "debug-resume"
+ case .pause: nil
+ case .stepOver: "debug-step-over"
+ case .stepInto: "debug-step-into"
+ case .stepOut: "debug-step-out"
+ case .viewBreakpoints: "view-breakpoints"
+ case .muteBreakpoints: nil
+ }
+ }
+
+ private func stopActiveDebugSession() {
+ if feature.canTerminate {
+ feature.execute(.terminate)
+ } else {
+ model.stopDebugging()
+ }
+ }
+
+ private var toolbarDivider: some View {
+ Rectangle()
+ .fill(LitheTheme.divider)
+ .frame(width: 1, height: 18)
+ .padding(.horizontal, 3)
+ }
+
+ private var debugExecutionStatus: some View {
+ HStack(spacing: 5) {
+ Circle()
+ .fill(debugStatusColor)
+ .frame(width: 6, height: 6)
+ Text(debugStatusText)
+ .font(.system(size: 10.5, weight: .medium))
+ .foregroundStyle(LitheTheme.secondaryText)
+ .lineLimit(1)
+ if let frame = feature.selectedFrame,
+ let sourceURL = frame.sourceURL {
+ Button {
+ model.revealDebugLocation(
+ url: sourceURL,
+ line: frame.line,
+ column: frame.column
+ )
+ } label: {
+ Text("· \(sourceURL.lastPathComponent):\(frame.line)")
+ .font(.system(size: 9.5, design: .monospaced))
+ .foregroundStyle(LitheTheme.secondaryText.opacity(0.82))
+ .lineLimit(1)
+ }
+ .buttonStyle(.plain)
+ .help("Reveal stopped location in editor")
+ .accessibilityLabel("Reveal stopped location in editor")
}
- controlButton("arrow.down.to.line", help: "Step into", disabled: feature.state != .paused) {
- feature.execute(.stepIn)
+ }
+ .padding(.horizontal, 8)
+ .frame(height: 22)
+ .background(
+ Capsule()
+ .fill(LitheTheme.selection.opacity(0.58))
+ )
+ .help(debugStatusText)
+ .accessibilityElement(children: .combine)
+ .accessibilityLabel(debugStatusText)
+ }
+
+ private var debugStatusText: String {
+ DebugToolbarPresentation.statusText(
+ for: feature.state,
+ stoppedReason: feature.stoppedReason
+ )
+ }
+
+ private var debugStatusColor: Color {
+ switch feature.state {
+ case .paused: return LitheTheme.warning
+ case .failed: return LitheTheme.error
+ case .terminated, .idle: return LitheTheme.secondaryText
+ default: return LitheTheme.success
+ }
+ }
+
+ private var debugOptionsMenu: some View {
+ Menu {
+ if feature.capabilities.supportsStepInTargetsRequest {
+ Button("Smart Step Into") { requestSmartStepInto() }
+ .disabled(feature.state != .paused || feature.selectedFrameID == nil)
}
- controlButton("arrow.up.to.line", help: "Step out", disabled: feature.state != .paused) {
- feature.execute(.stepOut)
+ if feature.capabilities.supportsStepBack {
+ Button("Step Back") { feature.execute(.stepBack) }
+ .disabled(!feature.canStepBack)
}
- Button {
- if feature.isSessionActive {
- model.stopDebugging()
- } else {
- model.startDebugging()
+ if feature.javaSteppingFilters != nil {
+ Button("Java Stepping Filters…") {
+ isJavaSteppingSettingsPresented = true
}
- } label: {
- Image(systemName: feature.isSessionActive ? "stop.fill" : "play.fill")
+ .disabled(feature.isSessionActive)
}
- .litheIconButton()
- .foregroundStyle(feature.isSessionActive ? LitheTheme.warning : LitheTheme.success)
- .help(feature.isSessionActive ? "Stop debugging" : "Start debugging")
- controlButton("trash", help: "Clear output", disabled: false) {
- feature.clearOutput()
+ Divider()
+ Button("Connect to Running JVM…") { isJavaAttachPresented = true }
+ .disabled(feature.isSessionActive)
+ Button("Clear Console") { feature.clearOutput() }
+ .disabled(feature.output.isEmpty)
+ } label: {
+ LitheIDEAIcon(
+ resourcePath: "actions/moreVertical.svg",
+ size: 15,
+ fallbackSystemImage: "ellipsis"
+ )
+ }
+ .litheIconButton()
+ .help("More Debug actions")
+ .accessibilityLabel("More Debug actions")
+ }
+
+ private func requestSmartStepInto() {
+ feature.requestSmartStepInto { result in
+ guard case .success(let targets) = result else { return }
+ if targets.count == 1, let target = targets.first {
+ feature.smartStepInto(target)
+ } else {
+ smartStepTargets = targets
+ isSmartStepPickerPresented = true
}
}
}
- private func controlButton(
- _ image: String,
- help: String,
- disabled: Bool,
- action: @escaping () -> Void
- ) -> some View {
- Button(action: action) { Image(systemName: image) }
- .litheIconButton()
- .disabled(disabled)
- .help(help)
+ private var smartStepPicker: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ Text("Choose Step Target")
+ .font(.system(size: 11, weight: .semibold))
+ .padding(.horizontal, 8)
+ .padding(.top, 6)
+ if smartStepTargets.isEmpty {
+ Text("No callable target at this location")
+ .font(LitheTheme.smallFont)
+ .foregroundStyle(LitheTheme.secondaryText)
+ .padding(8)
+ } else {
+ ForEach(smartStepTargets) { target in
+ Button(target.label) {
+ feature.smartStepInto(target)
+ isSmartStepPickerPresented = false
+ }
+ .buttonStyle(.plain)
+ .font(.system(size: 11, design: .monospaced))
+ .padding(.horizontal, 8)
+ .padding(.vertical, 4)
+ }
+ }
+ }
+ .frame(minWidth: 230)
+ .padding(.vertical, 4)
}
private var inspector: some View {
- ScrollView {
- LazyVStack(alignment: .leading, spacing: 0) {
- Group {
- sectionHeader("Breakpoints", count: feature.breakpoints.count)
- if feature.breakpoints.isEmpty {
- placeholder("Click the editor gutter to add a breakpoint")
+ HSplitView {
+ executionInspector
+ .frame(minWidth: 240, idealWidth: 320, maxWidth: .infinity)
+ dataInspector
+ .frame(minWidth: 300, idealWidth: 480, maxWidth: .infinity)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .litheWorkbenchSurface(LitheTheme.sidebar)
+ }
+
+ private var executionInspector: some View {
+ VStack(spacing: 0) {
+ threadPicker
+ divider
+ ScrollView {
+ LazyVStack(alignment: .leading, spacing: 0) {
+ if feature.stackFrames.isEmpty {
+ placeholder("Pause the process to inspect frames")
} else {
- ForEach(feature.breakpoints) { breakpoint in
- HStack(spacing: 7) {
- Image(systemName: breakpoint.verified ? "circle.fill" : "circle")
- .font(.system(size: 8))
- .foregroundStyle(breakpoint.verified ? LitheTheme.error : LitheTheme.warning)
- Text(breakpoint.title)
- .font(.system(size: 11, design: .monospaced))
- .lineLimit(1)
- Spacer(minLength: 0)
+ if feature.areFilteredStackFramesExpanded,
+ feature.hiddenStackFrameCount > 0 {
+ Button {
+ feature.collapseFilteredStackFrames()
+ } label: {
+ Label("Collapse filtered frames", systemImage: "rectangle.compress.vertical")
+ .font(LitheTheme.smallFont)
+ .foregroundStyle(LitheTheme.secondaryText)
+ .padding(.horizontal, 10)
+ .frame(minHeight: 27)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .buttonStyle(.plain)
+ }
+ ForEach(feature.visibleStackFrameRows) { row in
+ if let frame = row.frame {
+ rowButton(selected: feature.selectedFrameID == frame.id) {
+ feature.selectFrame(frame)
+ if let sourceURL = frame.sourceURL {
+ model.revealDebugLocation(
+ url: sourceURL,
+ line: frame.line,
+ column: frame.column
+ )
+ }
+ } label: {
+ if frame.isFiltered {
+ Image(systemName: "ellipsis")
+ .foregroundStyle(LitheTheme.secondaryText)
+ } else if feature.selectedFrameID == frame.id {
+ LitheIDEAIcon(
+ resourcePath: "debugger/frame.svg",
+ size: 14,
+ fallbackSystemImage: "pause.fill",
+ preservesOriginalColors: true
+ )
+ } else {
+ Image(systemName: "chevron.right")
+ .foregroundStyle(LitheTheme.secondaryText)
+ }
+ VStack(alignment: .leading, spacing: 1) {
+ Text(frame.name).lineLimit(1)
+ if let sourceURL = frame.sourceURL {
+ Text("\(sourceURL.lastPathComponent):\(frame.line)")
+ .font(.system(size: 9.5, design: .monospaced))
+ .foregroundStyle(LitheTheme.secondaryText)
+ }
+ }
+ }
+ .opacity(frame.isFiltered ? 0.58 : 1)
+ .contextMenu {
+ Button("Copy Method Name") {
+ copyToPasteboard(frame.name)
+ }
+ if let sourceURL = frame.sourceURL {
+ Divider()
+ Button("Copy Source Location") {
+ copyToPasteboard(
+ "\(sourceURL.path):\(frame.line):\(frame.column)"
+ )
+ }
+ Button("Copy Relative Location") {
+ copyToPasteboard(
+ "\(sourceURL.lastPathComponent):\(frame.line):\(frame.column)"
+ )
+ }
+ }
+ }
+ } else {
+ Button {
+ feature.expandFilteredStackFrames()
+ } label: {
+ Label(
+ "\(row.hiddenFrameCount) hidden frames",
+ systemImage: "ellipsis.circle"
+ )
+ .font(LitheTheme.smallFont)
+ .foregroundStyle(LitheTheme.secondaryText)
+ .padding(.horizontal, 10)
+ .frame(minHeight: 27)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .buttonStyle(.plain)
+ .help("Show JDK, proxy, and framework frames")
}
- .help(breakpoint.message ?? breakpoint.title)
- .padding(.horizontal, 10)
- .frame(height: 27)
}
}
}
+ }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .litheWorkbenchSurface(LitheTheme.sidebar)
+ }
- Group {
- divider
- sectionHeader("Threads", count: feature.threads.count)
- if feature.threads.isEmpty {
- Button("Load threads") { feature.inspectThreads() }
- .buttonStyle(.plain)
- .font(LitheTheme.smallFont)
- .foregroundStyle(LitheTheme.accent)
- .padding(10)
- } else {
- ForEach(feature.threads) { thread in
- rowButton(selected: feature.selectedThreadID == thread.id) {
- feature.selectThread(thread)
- } label: {
- Image(systemName: "circle")
- Text(thread.name).lineLimit(1)
- }
+ private var threadPicker: some View {
+ Menu {
+ if feature.threads.isEmpty {
+ Button("Load threads") { feature.inspectThreads() }
+ } else {
+ ForEach(feature.threads) { thread in
+ Button {
+ feature.selectThread(thread)
+ } label: {
+ HStack(spacing: 7) {
+ LitheIDEAIcon(
+ resourcePath: threadIconResourcePath(thread),
+ size: 14,
+ fallbackSystemImage: threadIcon(thread),
+ preservesOriginalColors: true
+ )
+ Text(thread.name)
}
}
}
+ }
+ } label: {
+ HStack(spacing: 7) {
+ if let thread = selectedThread {
+ LitheIDEAIcon(
+ resourcePath: threadIconResourcePath(thread),
+ size: 14,
+ fallbackSystemImage: threadIcon(thread),
+ preservesOriginalColors: true
+ )
+ Text(thread.name)
+ .lineLimit(1)
+ Text(feature.state == .paused ? "Paused" : feature.state.title)
+ .font(.system(size: 9.5))
+ .foregroundStyle(LitheTheme.secondaryText)
+ } else {
+ LitheIDEAIcon(
+ resourcePath: "debugger/threadSuspended.svg",
+ size: 14,
+ fallbackSystemImage: "circle.dotted",
+ preservesOriginalColors: true
+ )
+ Text(feature.threads.isEmpty ? "Load threads" : "Select thread")
+ }
+ Spacer(minLength: 0)
+ Image(systemName: "chevron.down")
+ .font(.system(size: 8, weight: .semibold))
+ .foregroundStyle(LitheTheme.secondaryText)
+ }
+ .font(.system(size: 10.5, weight: .medium))
+ .foregroundStyle(LitheTheme.primaryText)
+ .padding(.horizontal, 9)
+ .frame(height: 28)
+ .contentShape(Rectangle())
+ }
+ .menuStyle(.borderlessButton)
+ .accessibilityLabel("Debugger thread")
+ .contextMenu {
+ if let thread = selectedThread {
+ Button("Copy Thread Name") { copyToPasteboard(thread.name) }
+ if feature.capabilities.supportsSingleThreadExecutionRequests {
+ Button(feature.state == .paused ? "Resume Thread" : "Pause Thread") {
+ feature.executeThread(
+ feature.state == .paused ? .continueExecution : .pause,
+ thread: thread
+ )
+ }
+ .disabled(feature.state != .paused && feature.state != .running)
+ }
+ }
+ }
+ }
- Group {
- divider
- sectionHeader("Call Stack", count: feature.stackFrames.count)
- if feature.stackFrames.isEmpty {
- placeholder("Pause the process to inspect frames")
+ private var selectedThread: DebugThread? {
+ feature.threads.first { $0.id == feature.selectedThreadID }
+ }
+
+ private var dataInspector: some View {
+ VStack(spacing: 0) {
+ evaluateRow
+ divider
+ ScrollView {
+ LazyVStack(alignment: .leading, spacing: 0) {
+ if let exceptionInfo = feature.exceptionInfo {
+ exceptionInspector(exceptionInfo)
+ divider
+ }
+ variablesHeader
+ if feature.visibleVariableRows.isEmpty {
+ placeholder("Select a stack frame to inspect variables")
} else {
- ForEach(feature.stackFrames) { frame in
- rowButton(selected: feature.selectedFrameID == frame.id) {
- feature.selectFrame(frame)
- if let sourceURL = frame.sourceURL {
- model.openSourceLocation(
- url: sourceURL,
- line: frame.line,
- column: frame.column
+ ForEach(feature.visibleVariableRows) { row in
+ switch row.content {
+ case .variable(let variable):
+ HStack(alignment: .firstTextBaseline, spacing: 6) {
+ Image(systemName: variableDisclosureSymbol(variable))
+ .font(.system(size: 8, weight: .semibold))
+ .foregroundStyle(LitheTheme.secondaryText)
+ .frame(width: 9)
+ .opacity(variable.isExpandable ? 1 : 0)
+ LitheIDEAIcon(
+ resourcePath: variableIconResourcePath(variable),
+ size: 13,
+ fallbackSystemImage: "circle.fill"
)
- }
- } label: {
- Image(systemName: "chevron.right")
- VStack(alignment: .leading, spacing: 1) {
- Text(frame.name).lineLimit(1)
- if let sourceURL = frame.sourceURL {
- Text("\(sourceURL.lastPathComponent):\(frame.line)")
+ Text(variable.name)
+ .font(.system(size: 10.5, design: .monospaced))
+ Text("=")
+ .foregroundStyle(LitheTheme.secondaryText)
+ Text(variable.value)
+ .font(.system(size: 10.5, design: .monospaced))
+ .foregroundStyle(LitheTheme.accent)
+ .lineLimit(2)
+ if let type = variable.type, !type.isEmpty {
+ Text(": \(type)")
.font(.system(size: 9.5, design: .monospaced))
.foregroundStyle(LitheTheme.secondaryText)
+ .lineLimit(1)
+ }
+ Spacer(minLength: 0)
+ }
+ .contentShape(Rectangle())
+ .onTapGesture {
+ feature.toggleVariableExpansion(variable)
+ }
+ .padding(.leading, 10 + CGFloat(row.depth * 14))
+ .padding(.trailing, 10)
+ .padding(.vertical, 5)
+ .contextMenu {
+ if feature.capabilities.supportsSetVariable,
+ variable.containerReference != nil {
+ Button("Set Value…") { editingVariable = variable }
+ }
+ if feature.capabilities.supportsDataBreakpoints,
+ variable.containerReference != nil {
+ Button("Break on Field Access…") {
+ feature.requestDataBreakpoint(for: variable)
+ }
+ }
+ Divider()
+ Button("Copy Value") { copyToPasteboard(variable.value) }
+ Button("Copy Expression") {
+ copyToPasteboard(variable.evaluateName ?? variable.name)
+ }
+ Button("Copy Name") { copyToPasteboard(variable.name) }
+ if let expression = variable.evaluateName,
+ !expression.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ Divider()
+ Button("Add to Watches") {
+ feature.addWatch(expression)
+ }
}
}
+ case .loadMore(let parentVariableID, let nextCount, let remainingCount):
+ variableLoadMoreRow(
+ parentVariableID: parentVariableID,
+ nextCount: nextCount,
+ remainingCount: remainingCount,
+ depth: row.depth
+ )
}
}
}
- }
- Group {
divider
- sectionHeader("Variables", count: feature.variables.count)
- if feature.variables.isEmpty {
- placeholder("Select a stack frame to inspect variables")
+ watchSectionHeader
+ if feature.watches.isEmpty {
+ placeholder("Add an expression to watch while paused")
} else {
- ForEach(feature.variables) { variable in
+ ForEach(feature.watches) { watch in
HStack(alignment: .firstTextBaseline, spacing: 6) {
- Image(systemName: variable.isExpandable ? "chevron.right" : "circle.fill")
- .font(.system(size: variable.isExpandable ? 8 : 4))
- .foregroundStyle(LitheTheme.secondaryText)
- Text(variable.name)
- .font(.system(size: 10.5, design: .monospaced))
- Text("=")
- .foregroundStyle(LitheTheme.secondaryText)
- Text(variable.value)
- .font(.system(size: 10.5, design: .monospaced))
- .foregroundStyle(LitheTheme.accent)
- .lineLimit(2)
- Spacer(minLength: 0)
- }
- .contentShape(Rectangle())
- .onTapGesture {
- if variable.isExpandable {
- feature.loadVariables(reference: variable.variablesReference)
+ LitheIDEAIcon(
+ resourcePath: "debugger/watch.svg",
+ size: 13,
+ fallbackSystemImage: "eye"
+ )
+ VStack(alignment: .leading, spacing: 2) {
+ Text(watch.expression)
+ .font(.system(size: 10.5, design: .monospaced))
+ .lineLimit(1)
+ if let error = watch.error {
+ Text(error)
+ .font(.system(size: 9.5))
+ .foregroundStyle(LitheTheme.error)
+ .lineLimit(2)
+ } else if let value = watch.value {
+ HStack(spacing: 4) {
+ Text(value)
+ .foregroundStyle(LitheTheme.accent)
+ if let type = watch.type {
+ Text(type).foregroundStyle(LitheTheme.secondaryText)
+ }
+ }
+ .font(.system(size: 9.5, design: .monospaced))
+ .lineLimit(2)
+ } else {
+ Text(feature.state == .paused ? "Evaluating…" : "Not available")
+ .font(.system(size: 9.5))
+ .foregroundStyle(LitheTheme.secondaryText)
+ }
}
+ Spacer(minLength: 0)
}
.padding(.horizontal, 10)
.padding(.vertical, 5)
+ .contextMenu {
+ Button("Refresh") { feature.refreshWatches() }
+ .disabled(feature.state != .paused)
+ Button("Edit…") {
+ watchEditor = WatchEditorContext(watch: watch)
+ }
+ Divider()
+ Button("Remove", role: .destructive) {
+ feature.removeWatch(watch)
+ }
+ }
}
}
}
-
- divider
- evaluateRow
}
}
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
.litheWorkbenchSurface(LitheTheme.sidebar)
}
- private var evaluateRow: some View {
- HStack(spacing: 6) {
- Image(systemName: "function")
+ private var variablesHeader: some View {
+ HStack(spacing: 7) {
+ Text("Variables")
+ .font(.system(size: 10.5, weight: .semibold))
.foregroundStyle(LitheTheme.secondaryText)
- TextField("Evaluate expression", text: $evaluateExpression)
- .textFieldStyle(.plain)
- .font(.system(size: 11, design: .monospaced))
- .onSubmit { feature.evaluate(evaluateExpression) }
- Button { feature.evaluate(evaluateExpression) } label: {
- Image(systemName: "arrow.right.circle")
+ Text(String(feature.presentedVariables.count))
+ .font(.system(size: 9.5, design: .monospaced))
+ .foregroundStyle(LitheTheme.secondaryText)
+ Spacer(minLength: 0)
+ if !feature.scopes.isEmpty {
+ Menu {
+ ForEach(feature.scopes) { scope in
+ Button {
+ feature.selectScope(scope)
+ } label: {
+ Label(
+ scope.name,
+ systemImage: feature.selectedScopeID == scope.id
+ ? "checkmark"
+ : "circle"
+ )
+ }
+ }
+ } label: {
+ HStack(spacing: 4) {
+ Text(selectedScopeName)
+ .lineLimit(1)
+ Image(systemName: "chevron.down")
+ .font(.system(size: 8, weight: .semibold))
+ }
+ .font(.system(size: 9.5))
+ .foregroundStyle(LitheTheme.secondaryText)
+ }
+ .menuStyle(.borderlessButton)
+ .accessibilityLabel("Variable scope")
}
- .litheIconButton()
}
.padding(.horizontal, 10)
- .frame(height: 32)
+ .frame(height: 27)
+ .litheWorkbenchSurface(LitheTheme.toolHeader)
}
- private var output: some View {
- ScrollView([.vertical, .horizontal]) {
- VStack(alignment: .leading, spacing: 8) {
- if let stoppedReason = feature.stoppedReason {
- Label(stoppedReason, systemImage: "pause.circle.fill")
- .font(.system(size: 11.5, weight: .medium))
- .foregroundStyle(LitheTheme.warning)
- }
- if let errorMessage = feature.errorMessage {
- Label(errorMessage, systemImage: "exclamationmark.triangle.fill")
- .font(.system(size: 11.5))
- .foregroundStyle(LitheTheme.error)
- }
- Text(feature.output.isEmpty ? "Waiting for Debug Adapter output…" : feature.output)
- .font(.system(size: 12, design: .monospaced))
- .foregroundStyle(LitheTheme.primaryText)
- .textSelection(.enabled)
- }
- .frame(maxWidth: .infinity, alignment: .topLeading)
- .padding(12)
- }
+ private var selectedScopeName: String {
+ feature.scopes.first { $0.id == feature.selectedScopeID }?.name
+ ?? feature.scopes.first?.name
+ ?? "Scope"
}
- private var emptyState: some View {
- VStack(spacing: 10) {
- LitheSystemIcon(systemImage: "ladybug")
- .font(.system(size: 30, weight: .light))
- .foregroundStyle(LitheTheme.secondaryText)
- Text("Debug the current \(currentLanguageName) file")
- .font(.system(size: 13, weight: .medium))
- Text("The Debug Adapter starts only when this action is used.")
- .font(LitheTheme.smallFont)
- .foregroundStyle(LitheTheme.secondaryText)
+ private func copyToPasteboard(_ value: String) {
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.setString(value, forType: .string)
+ }
+
+ private func exceptionInspector(_ info: DebugExceptionInfo) -> some View {
+ VStack(alignment: .leading, spacing: 7) {
+ HStack(spacing: 7) {
+ Label("Exception", systemImage: "exclamationmark.octagon.fill")
+ .font(.system(size: 10.5, weight: .semibold))
+ .foregroundStyle(LitheTheme.error)
+ Spacer(minLength: 8)
+ Text(exceptionBreakModeTitle(info.breakMode))
+ .font(.system(size: 9.5, weight: .medium))
+ .foregroundStyle(LitheTheme.secondaryText)
+ }
+ Text(info.exceptionID)
+ .font(.system(size: 11, weight: .semibold, design: .monospaced))
+ .foregroundStyle(LitheTheme.primaryText)
+ .textSelection(.enabled)
+ if let description = info.description,
+ !description.isEmpty,
+ description != info.exceptionID {
+ Text(description)
+ .font(.system(size: 10.5, design: .monospaced))
+ .foregroundStyle(LitheTheme.warning)
+ .textSelection(.enabled)
+ }
+ if let details = info.details {
+ if let message = details.message,
+ !message.isEmpty,
+ message != info.description {
+ Text(message)
+ .font(.system(size: 10.5))
+ .foregroundStyle(LitheTheme.secondaryText)
+ .textSelection(.enabled)
+ }
+ ForEach(Array(nestedExceptionDetails(details).enumerated()), id: \.offset) { _, cause in
+ HStack(alignment: .firstTextBaseline, spacing: 5) {
+ Image(systemName: "arrow.turn.down.right")
+ .font(.system(size: 8))
+ .foregroundStyle(LitheTheme.secondaryText)
+ VStack(alignment: .leading, spacing: 1) {
+ Text(cause.fullTypeName ?? cause.typeName ?? "Nested exception")
+ .font(.system(size: 10, weight: .medium, design: .monospaced))
+ if let message = cause.message, !message.isEmpty {
+ Text(message)
+ .font(.system(size: 9.5))
+ .foregroundStyle(LitheTheme.secondaryText)
+ }
+ }
+ }
+ }
+ if let stackTrace = details.stackTrace, !stackTrace.isEmpty {
+ Text(stackTrace)
+ .font(.system(size: 9.5, design: .monospaced))
+ .foregroundStyle(LitheTheme.secondaryText)
+ .lineLimit(12)
+ .textSelection(.enabled)
+ }
+ }
+ }
+ .padding(10)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(LitheTheme.error.opacity(0.06))
+ .accessibilityElement(children: .contain)
+ }
+
+ private func nestedExceptionDetails(
+ _ details: DebugExceptionDetails
+ ) -> [DebugExceptionDetails] {
+ details.innerExceptions.flatMap { [$0] + nestedExceptionDetails($0) }
+ }
+
+ private func exceptionBreakModeTitle(_ breakMode: String) -> String {
+ switch breakMode {
+ case "always": "Always break"
+ case "unhandled": "Unhandled"
+ case "userUnhandled": "User-unhandled"
+ case "never": "Never break"
+ default: breakMode
+ }
+ }
+
+ private var evaluateRow: some View {
+ HStack(spacing: 6) {
+ LitheIDEAIcon(
+ resourcePath: "debugger/evaluateExpression.svg",
+ size: 16,
+ fallbackSystemImage: "function",
+ preservesOriginalColors: true
+ )
+ TextField("Evaluate expression", text: $evaluateExpression)
+ .textFieldStyle(.plain)
+ .font(.system(size: 11, design: .monospaced))
+ .onSubmit { addWatchExpression() }
+ Button { addWatchExpression() } label: {
+ LitheIDEAIcon(
+ resourcePath: "actions/add.svg",
+ size: 14,
+ fallbackSystemImage: "plus.circle",
+ preservesOriginalColors: true
+ )
+ }
+ .litheIconButton()
+ .help("Add watch")
+ Button { feature.evaluate(evaluateExpression) } label: {
+ LitheIDEAIcon(
+ resourcePath: "actions/execute.svg",
+ size: 14,
+ fallbackSystemImage: "arrow.right.circle",
+ preservesOriginalColors: true
+ )
+ }
+ .litheIconButton()
+ .disabled(feature.state != .paused || evaluateExpression.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
+ .help("Evaluate expression")
+ }
+ .padding(.horizontal, 10)
+ .frame(height: 32)
+ }
+
+ private var watchSectionHeader: some View {
+ HStack {
+ Text("Watches")
+ .font(.system(size: 10.5, weight: .semibold))
+ .foregroundStyle(LitheTheme.secondaryText)
+ Spacer()
+ Text(String(feature.watches.count))
+ .font(.system(size: 9.5, design: .monospaced))
+ .foregroundStyle(LitheTheme.secondaryText)
+ Button { feature.refreshWatches() } label: {
+ LitheIDEAIcon(
+ resourcePath: "actions/refresh.svg",
+ size: 13,
+ fallbackSystemImage: "arrow.clockwise",
+ preservesOriginalColors: true
+ )
+ }
+ .buttonStyle(.plain)
+ .disabled(feature.state != .paused || feature.watches.isEmpty)
+ .help("Refresh watches")
+ Button { watchEditor = WatchEditorContext(watch: nil) } label: {
+ Image(systemName: "plus")
+ }
+ .buttonStyle(.plain)
+ .help("Add watch")
+ }
+ .padding(.horizontal, 10)
+ .frame(height: 27)
+ .litheWorkbenchSurface(LitheTheme.toolHeader)
+ }
+
+ private func addWatchExpression() {
+ feature.addWatch(evaluateExpression)
+ evaluateExpression = ""
+ }
+
+ private var debugConsole: some View {
+ GeometryReader { geometry in
+ VStack(spacing: 0) {
+ ScrollView([.vertical, .horizontal]) {
+ VStack(alignment: .leading, spacing: 8) {
+ if let stoppedReason = feature.stoppedReason {
+ Label(stoppedReason, systemImage: "pause.circle.fill")
+ .font(.system(size: 11.5, weight: .medium))
+ .foregroundStyle(LitheTheme.warning)
+ }
+ if let errorMessage = feature.errorMessage {
+ VStack(alignment: .leading, spacing: 8) {
+ Label(errorMessage, systemImage: "exclamationmark.triangle.fill")
+ .font(.system(size: 11.5))
+ .foregroundStyle(LitheTheme.error)
+
+ HStack(spacing: 8) {
+ if feature.canRetry {
+ Button {
+ _ = feature.retry()
+ } label: {
+ Label("Retry Debug", systemImage: "arrow.clockwise")
+ }
+ .buttonStyle(.bordered)
+ .controlSize(.small)
+ .accessibilityIdentifier("debug-error-retry")
+ }
+
+ if !model.isRunVisible {
+ Button {
+ model.toggleRun()
+ } label: {
+ Label("Open Run Configuration", systemImage: "slider.horizontal.3")
+ }
+ .buttonStyle(.bordered)
+ .controlSize(.small)
+ .accessibilityIdentifier("debug-error-open-run-configuration")
+ }
+ }
+ }
+ }
+ Text(feature.output.isEmpty ? "Waiting for Debug Adapter output…" : feature.output)
+ .font(.system(size: 12, design: .monospaced))
+ .foregroundStyle(LitheTheme.primaryText)
+ .textSelection(.enabled)
+ }
+ .frame(
+ minWidth: max(0, geometry.size.width - 24),
+ minHeight: max(0, geometry.size.height - 97),
+ alignment: .topLeading
+ )
+ .padding(12)
+ }
+ Rectangle().fill(LitheTheme.divider).frame(height: 1)
+ consoleInputRow
+ programInputRow
+ }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .litheWorkbenchSurface(LitheTheme.editor)
+ }
+
+ private var consoleInputRow: some View {
+ HStack(spacing: 7) {
+ Image(systemName: "chevron.right")
+ .font(.system(size: 10, weight: .semibold))
+ .foregroundStyle(LitheTheme.accent)
+ Button {
+ if let expression = feature.previousConsoleExpression(current: consoleExpression) {
+ consoleExpression = expression
+ }
+ } label: {
+ Image(systemName: "chevron.up")
+ }
+ .litheIconButton()
+ .disabled(feature.consoleHistory.isEmpty || feature.state != .paused)
+ .help("Previous console expression")
+ TextField("Evaluate expression while paused", text: $consoleExpression)
+ .textFieldStyle(.plain)
+ .font(.system(size: 11.5, design: .monospaced))
+ .focused($isConsoleInputFocused)
+ .disabled(feature.state != .paused)
+ .onSubmit { evaluateConsoleExpression() }
+ Button { evaluateConsoleExpression() } label: {
+ LitheIDEAIcon(
+ resourcePath: "debugger/evaluateExpression.svg",
+ size: 15,
+ fallbackSystemImage: "arrow.right.circle.fill",
+ preservesOriginalColors: true
+ )
+ }
+ .litheIconButton()
+ .disabled(feature.state != .paused || consoleExpression.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
+ .help("Evaluate expression")
+ Button {
+ if let expression = feature.nextConsoleExpression() {
+ consoleExpression = expression
+ }
+ } label: {
+ Image(systemName: "chevron.down")
+ }
+ .litheIconButton()
+ .disabled(feature.consoleHistory.isEmpty || feature.state != .paused)
+ .help("Next console expression")
+ }
+ .padding(.horizontal, 10)
+ .frame(height: 34)
+ .litheWorkbenchSurface(LitheTheme.toolHeader)
+ }
+
+ private func evaluateConsoleExpression() {
+ guard feature.state == .paused else { return }
+ let expression = consoleExpression.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !expression.isEmpty else { return }
+ feature.evaluate(expression)
+ consoleExpression = ""
+ }
+
+ private var programInputRow: some View {
+ HStack(spacing: 7) {
+ Image(systemName: "arrow.down.to.line")
+ .font(.system(size: 10, weight: .semibold))
+ .foregroundStyle(LitheTheme.warning)
+ TextField("Send input to debuggee", text: $programInput)
+ .textFieldStyle(.plain)
+ .font(.system(size: 11.5, design: .monospaced))
+ .disabled(!model.isDebugStandardInputAvailable)
+ .onSubmit { sendProgramInput() }
+ Button { sendProgramInput() } label: {
+ LitheIDEAIcon(
+ resourcePath: "debugger/run.svg",
+ size: 15,
+ fallbackSystemImage: "paperplane.fill",
+ preservesOriginalColors: true
+ )
+ }
+ .litheIconButton()
+ .disabled(!model.isDebugStandardInputAvailable || programInput.isEmpty)
+ .help("Send program input")
+ }
+ .padding(.horizontal, 10)
+ .frame(height: 34)
+ .litheWorkbenchSurface(LitheTheme.toolHeader)
+ }
+
+ private func sendProgramInput() {
+ guard !programInput.isEmpty,
+ model.sendDebugStandardInput(programInput) else { return }
+ programInput = ""
+ }
+
+ private var emptyState: some View {
+ VStack(spacing: 10) {
+ LitheIDEAIcon(
+ resourcePath: "debugger/debug.svg",
+ size: 32,
+ fallbackSystemImage: "ladybug",
+ preservesOriginalColors: true
+ )
+ .frame(width: 36, height: 36)
+ Text(emptyStateTitle)
+ .font(.system(size: 13, weight: .medium))
+ Text(emptyStateSubtitle)
+ .font(LitheTheme.smallFont)
+ .foregroundStyle(LitheTheme.secondaryText)
Button("Start Debugging") { model.startDebugging() }
.buttonStyle(.borderedProminent)
.controlSize(.small)
.tint(LitheTheme.accent)
+ Button("Connect to Running JVM") { isJavaAttachPresented = true }
+ .buttonStyle(.bordered)
+ .controlSize(.small)
+ Button("View Breakpoints") { model.showDebugBreakpointManager() }
+ .buttonStyle(.bordered)
+ .controlSize(.small)
+ .disabled(model.workspaceURL == nil)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
@@ -273,6 +1324,21 @@ struct GenericDebugView: View {
return descriptor.displayName
}
+ private var emptyStateTitle: String {
+ if let configuration = model.runFeatureIfActive?.selectedConfiguration,
+ !configuration.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ return "Debug \(configuration.name)"
+ }
+ return "Debug the current \(currentLanguageName) file"
+ }
+
+ private var emptyStateSubtitle: String {
+ if model.runFeatureIfActive?.selectedConfiguration != nil {
+ return "Uses the selected Run configuration and its project toolchain."
+ }
+ return "The Debug Adapter starts only when this action is used."
+ }
+
private func sectionHeader(_ title: String, count: Int) -> some View {
HStack {
Text(LocalizedStringKey(title))
@@ -288,6 +1354,83 @@ struct GenericDebugView: View {
.litheWorkbenchSurface(LitheTheme.toolHeader)
}
+ private func variableDisclosureSymbol(_ variable: DebugVariable) -> String {
+ if feature.isVariableLoading(variable) { return "hourglass" }
+ return feature.isVariableExpanded(variable) ? "chevron.down" : "chevron.right"
+ }
+
+ private func variableIconResourcePath(_ variable: DebugVariable) -> String {
+ feature.automaticVariables.contains(where: { $0.id == variable.id })
+ ? "debugger/watch.svg"
+ : variable.name == "this" ? "nodes/variable.svg" : "nodes/field.svg"
+ }
+
+ private func threadIcon(_ thread: DebugThread) -> String {
+ if feature.stoppedThreadIDs.contains(thread.id) {
+ return feature.selectedThreadID == thread.id
+ ? "pause.circle.fill"
+ : "pause.circle"
+ }
+ return "play.circle"
+ }
+
+ private func threadIconResourcePath(_ thread: DebugThread) -> String {
+ if feature.selectedThreadID == thread.id,
+ feature.stoppedThreadIDs.contains(thread.id) {
+ return "debugger/threadCurrent.svg"
+ }
+ return feature.stoppedThreadIDs.contains(thread.id)
+ ? "debugger/threadSuspended.svg"
+ : "debugger/threadRunning.svg"
+ }
+
+ private func threadColor(_ thread: DebugThread) -> Color {
+ feature.stoppedThreadIDs.contains(thread.id)
+ ? LitheTheme.warning
+ : LitheTheme.secondaryText
+ }
+
+ private func variableLoadMoreRow(
+ parentVariableID: String?,
+ nextCount: Int,
+ remainingCount: Int?,
+ depth: Int
+ ) -> some View {
+ let isLoading = feature.isVariablePageLoading(parentVariableID: parentVariableID)
+ return Button {
+ feature.loadMoreVariables(parentVariableID: parentVariableID)
+ } label: {
+ HStack(spacing: 6) {
+ if isLoading {
+ ProgressView().controlSize(.mini)
+ } else {
+ LitheIDEAIcon(
+ resourcePath: "actions/more.svg",
+ size: 12,
+ fallbackSystemImage: "ellipsis.circle",
+ preservesOriginalColors: true
+ )
+ }
+ Text(isLoading ? "Loading…" : "Load \(nextCount) more")
+ .font(LitheTheme.smallFont)
+ if let remainingCount, !isLoading {
+ Text("\(remainingCount) remaining")
+ .font(.system(size: 9.5, design: .monospaced))
+ .foregroundStyle(LitheTheme.secondaryText)
+ }
+ Spacer(minLength: 0)
+ }
+ .foregroundStyle(LitheTheme.secondaryText)
+ .padding(.leading, 10 + CGFloat(depth * 14))
+ .padding(.trailing, 10)
+ .frame(minHeight: 27)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .disabled(isLoading)
+ .accessibilityLabel(isLoading ? "Loading debugger variables" : "Load more debugger variables")
+ }
+
private func placeholder(_ text: String) -> some View {
Text(text)
.font(LitheTheme.smallFont)
@@ -320,6 +1463,1175 @@ struct GenericDebugView: View {
}
}
+struct DebugBreakpointManagerDialog: View {
+ @Environment(\.dismiss) private var dismiss
+ @ObservedObject var feature: GenericDebugFeatureModel
+
+ var body: some View {
+ VStack(spacing: 0) {
+ HStack(spacing: 9) {
+ LitheIDEAIcon(
+ resourcePath: "toolwindows/toolWindowDebugger.svg",
+ size: 18,
+ fallbackSystemImage: "circle.fill"
+ )
+ VStack(alignment: .leading, spacing: 1) {
+ Text("Breakpoints")
+ .font(.system(size: 14, weight: .semibold))
+ Text("Manage project breakpoints without starting a debug session")
+ .font(LitheTheme.smallFont)
+ .foregroundStyle(LitheTheme.secondaryText)
+ }
+ Spacer(minLength: 16)
+ Button(
+ feature.areBreakpointsMuted
+ ? "Unmute Line Breakpoints"
+ : "Mute Line Breakpoints"
+ ) {
+ feature.toggleBreakpointMute()
+ }
+ .disabled(feature.breakpoints.isEmpty)
+ Button("Done") { dismiss() }
+ .keyboardShortcut(.defaultAction)
+ }
+ .padding(.horizontal, 14)
+ .frame(height: 52)
+ .litheWorkbenchSurface(LitheTheme.toolHeader)
+ Rectangle().fill(LitheTheme.divider).frame(height: 1)
+ DebugBreakpointManagerView(feature: feature, onNavigate: { dismiss() })
+ }
+ .frame(minWidth: 720, idealWidth: 820, minHeight: 500, idealHeight: 580)
+ .litheWorkbenchSurface(LitheTheme.sidebar)
+ }
+}
+
+struct DebugBreakpointManagerView: View {
+ @EnvironmentObject private var model: AppModel
+ @ObservedObject var feature: GenericDebugFeatureModel
+ let onNavigate: (() -> Void)?
+
+ @State private var editingBreakpoint: GenericDebugBreakpoint?
+ @State private var editingExceptionBreakpoint: GenericDebugExceptionBreakpoint?
+ @State private var functionBreakpointEditor: FunctionBreakpointEditorContext?
+ @State private var editingDataBreakpoint: GenericDebugDataBreakpoint?
+
+ init(
+ feature: GenericDebugFeatureModel,
+ onNavigate: (() -> Void)? = nil
+ ) {
+ self.feature = feature
+ self.onNavigate = onNavigate
+ }
+
+ var body: some View {
+ ScrollView {
+ LazyVStack(alignment: .leading, spacing: 0) {
+ sourceBreakpointHeader
+ if feature.breakpoints.isEmpty {
+ placeholder("Click the editor gutter to add a breakpoint")
+ } else {
+ ForEach(feature.breakpoints) { breakpoint in
+ sourceBreakpointRow(breakpoint)
+ }
+ }
+
+ if !feature.exceptionBreakpoints.isEmpty {
+ divider
+ sectionHeader("Exception Breakpoints", count: feature.exceptionBreakpoints.count)
+ ForEach(feature.exceptionBreakpoints) { breakpoint in
+ exceptionBreakpointRow(breakpoint)
+ }
+ }
+
+ if feature.capabilities.supportsFunctionBreakpoints
+ || !feature.functionBreakpoints.isEmpty {
+ divider
+ functionBreakpointHeader
+ if feature.functionBreakpoints.isEmpty {
+ placeholder("Add a class or method name")
+ } else {
+ ForEach(feature.functionBreakpoints) { breakpoint in
+ functionBreakpointRow(breakpoint)
+ }
+ }
+ }
+
+ if feature.capabilities.supportsDataBreakpoints
+ || !feature.dataBreakpoints.isEmpty {
+ divider
+ sectionHeader("Field Breakpoints", count: feature.dataBreakpoints.count)
+ if feature.dataBreakpoints.isEmpty {
+ placeholder("Right-click a field while paused to add a breakpoint")
+ } else {
+ ForEach(feature.dataBreakpoints) { breakpoint in
+ dataBreakpointRow(breakpoint)
+ }
+ }
+ }
+ }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .litheWorkbenchSurface(LitheTheme.sidebar)
+ .sheet(item: $editingBreakpoint) { breakpoint in
+ BreakpointEditorView(
+ breakpoint: breakpoint,
+ supportsCondition: !feature.capabilities.negotiated
+ || feature.capabilities.supportsConditionalBreakpoints,
+ supportsHitCondition: !feature.capabilities.negotiated
+ || feature.capabilities.supportsHitConditionalBreakpoints,
+ supportsLogMessage: !feature.capabilities.negotiated
+ || feature.capabilities.supportsLogPoints
+ ) {
+ feature.updateBreakpoint(
+ fileURL: breakpoint.fileURL,
+ line: breakpoint.line,
+ enabled: $0.enabled,
+ condition: $0.condition,
+ hitCondition: $0.hitCondition,
+ logMessage: $0.logMessage
+ )
+ }
+ }
+ .sheet(item: $editingExceptionBreakpoint) { breakpoint in
+ ExceptionBreakpointEditorView(breakpoint: breakpoint) {
+ feature.updateExceptionBreakpoint(
+ breakpoint,
+ enabled: $0.enabled,
+ condition: $0.condition
+ )
+ }
+ }
+ .sheet(item: $functionBreakpointEditor) { context in
+ FunctionBreakpointEditorView(breakpoint: context.breakpoint) { value in
+ if let breakpoint = context.breakpoint {
+ feature.updateFunctionBreakpoint(
+ breakpoint,
+ name: value.name,
+ enabled: value.enabled,
+ condition: value.condition,
+ hitCondition: value.hitCondition
+ )
+ } else {
+ feature.addFunctionBreakpoint(
+ name: value.name,
+ condition: value.condition,
+ hitCondition: value.hitCondition
+ )
+ }
+ }
+ }
+ .sheet(item: $editingDataBreakpoint) { breakpoint in
+ DataBreakpointEditorView(breakpoint: breakpoint) { value in
+ feature.updateDataBreakpoint(
+ breakpoint,
+ enabled: value.enabled,
+ accessType: value.accessType,
+ condition: value.condition,
+ hitCondition: value.hitCondition
+ )
+ }
+ }
+ }
+
+ private var sourceBreakpointHeader: some View {
+ HStack {
+ Text("Line Breakpoints")
+ .font(.system(size: 10.5, weight: .semibold))
+ .foregroundStyle(LitheTheme.secondaryText)
+ Spacer()
+ Text(String(feature.breakpoints.count))
+ .font(.system(size: 9.5, design: .monospaced))
+ .foregroundStyle(LitheTheme.secondaryText)
+ Menu {
+ Button(
+ feature.areBreakpointsMuted
+ ? "Unmute Line Breakpoints"
+ : "Mute Line Breakpoints"
+ ) {
+ feature.toggleBreakpointMute()
+ }
+ Button("Remove All", role: .destructive) {
+ feature.removeAllBreakpoints()
+ }
+ .disabled(feature.breakpoints.isEmpty)
+ } label: {
+ LitheIDEAIcon(
+ resourcePath: feature.areBreakpointsMuted
+ ? "debugger/muteBreakpoints.svg"
+ : "actions/moreVertical.svg",
+ size: 14,
+ fallbackSystemImage: feature.areBreakpointsMuted
+ ? "speaker.slash.fill" : "ellipsis",
+ preservesOriginalColors: true
+ )
+ }
+ .menuStyle(.borderlessButton)
+ .fixedSize()
+ .help("Breakpoint actions")
+ .accessibilityLabel("Line breakpoint actions")
+ }
+ .padding(.horizontal, 10)
+ .frame(height: 29)
+ .litheWorkbenchSurface(LitheTheme.toolHeader)
+ }
+
+ private var functionBreakpointHeader: some View {
+ HStack {
+ Text("Method Breakpoints")
+ .font(.system(size: 10.5, weight: .semibold))
+ .foregroundStyle(LitheTheme.secondaryText)
+ Spacer()
+ Text(String(feature.functionBreakpoints.count))
+ .font(.system(size: 9.5, design: .monospaced))
+ .foregroundStyle(LitheTheme.secondaryText)
+ Button {
+ functionBreakpointEditor = FunctionBreakpointEditorContext(breakpoint: nil)
+ } label: {
+ Image(systemName: "plus")
+ }
+ .buttonStyle(.plain)
+ .help("Add method breakpoint")
+ .accessibilityLabel("Add method breakpoint")
+ }
+ .padding(.horizontal, 10)
+ .frame(height: 29)
+ .litheWorkbenchSurface(LitheTheme.toolHeader)
+ }
+
+ private func sourceBreakpointRow(_ breakpoint: GenericDebugBreakpoint) -> some View {
+ HStack(spacing: 7) {
+ Button {
+ feature.setBreakpointEnabled(breakpoint, enabled: !breakpoint.enabled)
+ } label: {
+ if breakpoint.isLogpoint {
+ Image(systemName: breakpointSymbol(breakpoint))
+ .font(.system(size: 9))
+ .foregroundStyle(breakpointColor(breakpoint))
+ } else {
+ let asset = LitheIcons.debuggerBreakpointAssetPath(
+ enabled: breakpoint.enabled,
+ verified: breakpoint.verified,
+ muted: feature.areBreakpointsMuted
+ )
+ LitheIDEAIcon(
+ resourcePath: asset,
+ size: 13,
+ fallbackSystemImage: breakpointSymbol(breakpoint),
+ preservesOriginalColors: true
+ )
+ }
+ }
+ .buttonStyle(.plain)
+ .help(breakpoint.enabled ? "Disable breakpoint" : "Enable breakpoint")
+ .accessibilityLabel(breakpoint.enabled ? "Disable breakpoint" : "Enable breakpoint")
+ Button {
+ model.openSourceLocation(
+ url: breakpoint.fileURL,
+ line: breakpoint.line,
+ column: breakpoint.column ?? 1
+ )
+ onNavigate?()
+ } label: {
+ VStack(alignment: .leading, spacing: 1) {
+ Text(breakpoint.title)
+ .font(.system(size: 11, design: .monospaced))
+ .lineLimit(1)
+ if let detail = breakpointDetail(breakpoint) {
+ Text(detail)
+ .font(.system(size: 9.5, design: .monospaced))
+ .foregroundStyle(LitheTheme.secondaryText)
+ .lineLimit(1)
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel("Open \(breakpoint.title)")
+ Menu {
+ Button("Edit…") { editingBreakpoint = breakpoint }
+ Button(breakpoint.enabled ? "Disable" : "Enable") {
+ feature.setBreakpointEnabled(breakpoint, enabled: !breakpoint.enabled)
+ }
+ Divider()
+ Button("Remove", role: .destructive) {
+ feature.removeBreakpoint(breakpoint)
+ }
+ } label: {
+ Image(systemName: "ellipsis")
+ }
+ .menuStyle(.borderlessButton)
+ .fixedSize()
+ .accessibilityLabel("Actions for \(breakpoint.title)")
+ }
+ .help(breakpoint.message ?? breakpoint.title)
+ .padding(.horizontal, 10)
+ .frame(minHeight: 33)
+ .opacity(breakpoint.enabled && !feature.areBreakpointsMuted ? 1 : 0.55)
+ .contextMenu {
+ Button("Edit…") { editingBreakpoint = breakpoint }
+ Button(breakpoint.enabled ? "Disable" : "Enable") {
+ feature.setBreakpointEnabled(breakpoint, enabled: !breakpoint.enabled)
+ }
+ Divider()
+ Button("Remove", role: .destructive) { feature.removeBreakpoint(breakpoint) }
+ }
+ }
+
+ private func exceptionBreakpointRow(
+ _ breakpoint: GenericDebugExceptionBreakpoint
+ ) -> some View {
+ HStack(spacing: 7) {
+ Button {
+ feature.updateExceptionBreakpoint(
+ breakpoint,
+ enabled: !breakpoint.enabled,
+ condition: breakpoint.condition
+ )
+ } label: {
+ Image(systemName: breakpoint.enabled ? "bolt.circle.fill" : "bolt.circle")
+ .font(.system(size: 10))
+ .foregroundStyle(breakpoint.enabled ? LitheTheme.error : LitheTheme.secondaryText)
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel(
+ breakpoint.enabled
+ ? "Disable \(breakpoint.label) exception breakpoint"
+ : "Enable \(breakpoint.label) exception breakpoint"
+ )
+ VStack(alignment: .leading, spacing: 1) {
+ Text(breakpoint.label).font(.system(size: 11)).lineLimit(1)
+ if let condition = breakpoint.condition {
+ Text("If: \(condition)")
+ .font(.system(size: 9.5, design: .monospaced))
+ .foregroundStyle(LitheTheme.secondaryText)
+ .lineLimit(1)
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ if breakpoint.supportsCondition {
+ Button { editingExceptionBreakpoint = breakpoint } label: {
+ Image(systemName: "ellipsis")
+ }
+ .buttonStyle(.plain)
+ .help("Edit exception breakpoint")
+ .accessibilityLabel("Edit \(breakpoint.label) exception breakpoint")
+ }
+ }
+ .help(breakpoint.description ?? breakpoint.label)
+ .padding(.horizontal, 10)
+ .frame(minHeight: 33)
+ .opacity(breakpoint.enabled ? 1 : 0.55)
+ }
+
+ private func functionBreakpointRow(
+ _ breakpoint: GenericDebugFunctionBreakpoint
+ ) -> some View {
+ HStack(spacing: 7) {
+ Button {
+ feature.setFunctionBreakpointEnabled(breakpoint, enabled: !breakpoint.enabled)
+ } label: {
+ Image(systemName: "function")
+ .font(.system(size: 10, weight: .semibold))
+ .foregroundStyle(
+ breakpoint.enabled
+ ? (breakpoint.verified ? LitheTheme.error : LitheTheme.warning)
+ : LitheTheme.secondaryText
+ )
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel(
+ breakpoint.enabled
+ ? "Disable \(breakpoint.name) method breakpoint"
+ : "Enable \(breakpoint.name) method breakpoint"
+ )
+ Button {
+ functionBreakpointEditor = FunctionBreakpointEditorContext(breakpoint: breakpoint)
+ } label: {
+ VStack(alignment: .leading, spacing: 1) {
+ Text(breakpoint.name)
+ .font(.system(size: 11, design: .monospaced))
+ .lineLimit(1)
+ if let detail = functionBreakpointDetail(breakpoint) {
+ Text(detail)
+ .font(.system(size: 9.5, design: .monospaced))
+ .foregroundStyle(LitheTheme.secondaryText)
+ .lineLimit(1)
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel("Edit \(breakpoint.name) method breakpoint")
+ Menu {
+ Button("Edit…") {
+ functionBreakpointEditor = FunctionBreakpointEditorContext(breakpoint: breakpoint)
+ }
+ Button(breakpoint.enabled ? "Disable" : "Enable") {
+ feature.setFunctionBreakpointEnabled(breakpoint, enabled: !breakpoint.enabled)
+ }
+ Divider()
+ Button("Remove", role: .destructive) {
+ feature.removeFunctionBreakpoint(breakpoint)
+ }
+ } label: {
+ Image(systemName: "ellipsis")
+ }
+ .menuStyle(.borderlessButton)
+ .fixedSize()
+ .accessibilityLabel("Actions for \(breakpoint.name) method breakpoint")
+ }
+ .padding(.horizontal, 10)
+ .frame(minHeight: 33)
+ .opacity(breakpoint.enabled ? 1 : 0.55)
+ }
+
+ private func dataBreakpointRow(_ breakpoint: GenericDebugDataBreakpoint) -> some View {
+ HStack(spacing: 7) {
+ Button {
+ feature.setDataBreakpointEnabled(breakpoint, enabled: !breakpoint.enabled)
+ } label: {
+ LitheIDEAIcon(
+ resourcePath: "nodes/field.svg",
+ size: 13,
+ fallbackSystemImage: "eye.circle.fill",
+ preservesOriginalColors: true
+ )
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel(
+ breakpoint.enabled
+ ? "Disable \(breakpoint.label) field breakpoint"
+ : "Enable \(breakpoint.label) field breakpoint"
+ )
+ Button { editingDataBreakpoint = breakpoint } label: {
+ VStack(alignment: .leading, spacing: 1) {
+ Text(breakpoint.label)
+ .font(.system(size: 11, design: .monospaced))
+ .lineLimit(1)
+ Text(dataBreakpointDetail(breakpoint))
+ .font(.system(size: 9.5, design: .monospaced))
+ .foregroundStyle(LitheTheme.secondaryText)
+ .lineLimit(1)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel("Edit \(breakpoint.label) field breakpoint")
+ Menu {
+ Button("Edit…") { editingDataBreakpoint = breakpoint }
+ Button(breakpoint.enabled ? "Disable" : "Enable") {
+ feature.setDataBreakpointEnabled(breakpoint, enabled: !breakpoint.enabled)
+ }
+ Divider()
+ Button("Remove", role: .destructive) { feature.removeDataBreakpoint(breakpoint) }
+ } label: {
+ Image(systemName: "ellipsis")
+ }
+ .menuStyle(.borderlessButton)
+ .fixedSize()
+ .accessibilityLabel("Actions for \(breakpoint.label) field breakpoint")
+ }
+ .padding(.horizontal, 10)
+ .frame(minHeight: 33)
+ .opacity(breakpoint.enabled ? 1 : 0.55)
+ }
+
+ private func sectionHeader(_ title: String, count: Int) -> some View {
+ HStack {
+ Text(LocalizedStringKey(title))
+ .font(.system(size: 10.5, weight: .semibold))
+ .foregroundStyle(LitheTheme.secondaryText)
+ Spacer()
+ Text(String(count))
+ .font(.system(size: 9.5, design: .monospaced))
+ .foregroundStyle(LitheTheme.secondaryText)
+ }
+ .padding(.horizontal, 10)
+ .frame(height: 29)
+ .litheWorkbenchSurface(LitheTheme.toolHeader)
+ }
+
+ private func breakpointSymbol(_ breakpoint: GenericDebugBreakpoint) -> String {
+ if breakpoint.isLogpoint { return breakpoint.enabled ? "diamond.fill" : "diamond" }
+ return breakpoint.enabled ? "circle.fill" : "circle"
+ }
+
+ private func breakpointColor(_ breakpoint: GenericDebugBreakpoint) -> Color {
+ guard breakpoint.enabled, !feature.areBreakpointsMuted else {
+ return LitheTheme.secondaryText
+ }
+ if breakpoint.isLogpoint { return LitheTheme.accent }
+ return breakpoint.verified ? LitheTheme.error : LitheTheme.warning
+ }
+
+ private func breakpointDetail(_ breakpoint: GenericDebugBreakpoint) -> String? {
+ if let logMessage = breakpoint.logMessage {
+ return String(format: String(localized: "Log: %@"), logMessage)
+ }
+ if let condition = breakpoint.condition {
+ return String(format: String(localized: "If: %@"), condition)
+ }
+ if let hitCondition = breakpoint.hitCondition {
+ return String(format: String(localized: "Hit: %@"), hitCondition)
+ }
+ return breakpoint.message
+ ?? String(localized: breakpoint.verified ? "Verified" : "Pending verification")
+ }
+
+ private func functionBreakpointDetail(
+ _ breakpoint: GenericDebugFunctionBreakpoint
+ ) -> String? {
+ if let condition = breakpoint.condition {
+ return String(format: String(localized: "If: %@"), condition)
+ }
+ if let hitCondition = breakpoint.hitCondition {
+ return String(format: String(localized: "Hit: %@"), hitCondition)
+ }
+ return breakpoint.message
+ ?? String(localized: breakpoint.verified ? "Verified" : "Pending verification")
+ }
+
+ private func dataBreakpointDetail(_ breakpoint: GenericDebugDataBreakpoint) -> String {
+ var parts = [breakpoint.accessType ?? "access"]
+ if let condition = breakpoint.condition { parts.append("if \(condition)") }
+ if let hitCondition = breakpoint.hitCondition { parts.append("hit \(hitCondition)") }
+ if let message = breakpoint.message { parts.append(message) }
+ if breakpoint.message == nil {
+ parts.append(breakpoint.verified ? "verified" : "pending verification")
+ }
+ return parts.joined(separator: " · ")
+ }
+
+ private func placeholder(_ text: LocalizedStringKey) -> some View {
+ Text(text)
+ .font(LitheTheme.smallFont)
+ .foregroundStyle(LitheTheme.secondaryText)
+ .padding(10)
+ }
+
+ private var divider: some View {
+ Rectangle().fill(LitheTheme.divider).frame(height: 1)
+ }
+}
+
+private enum DebugContent: CaseIterable, Identifiable {
+ case debugger
+ case console
+
+ var id: Self { self }
+
+ var title: LocalizedStringKey {
+ switch self {
+ case .debugger: "Threads & Variables"
+ case .console: "Console"
+ }
+ }
+}
+
+private struct JavaAttachView: View {
+ @Environment(\.dismiss) private var dismiss
+ @State private var host = "localhost"
+ @State private var port = "5005"
+ let onAttach: (String, Int) -> Void
+
+ private var parsedPort: Int? {
+ guard let value = Int(port), (1...65_535).contains(value) else { return nil }
+ return value
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 14) {
+ Text("Connect to Running JVM")
+ .font(.system(size: 14, weight: .semibold))
+ Grid(alignment: .leading, horizontalSpacing: 10, verticalSpacing: 10) {
+ GridRow {
+ Text("Host")
+ TextField("localhost", text: $host)
+ .textFieldStyle(.roundedBorder)
+ }
+ GridRow {
+ Text("Port")
+ TextField("5005", text: $port)
+ .textFieldStyle(.roundedBorder)
+ }
+ }
+ HStack {
+ Spacer()
+ Button("Cancel", role: .cancel) { dismiss() }
+ .keyboardShortcut(.cancelAction)
+ Button("Connect") {
+ guard let parsedPort else { return }
+ onAttach(host.trimmingCharacters(in: .whitespacesAndNewlines), parsedPort)
+ dismiss()
+ }
+ .keyboardShortcut(.defaultAction)
+ .disabled(
+ host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ || parsedPort == nil
+ )
+ }
+ }
+ .padding(18)
+ .frame(width: 360)
+ }
+}
+
+private struct JavaSteppingFiltersView: View {
+ @Environment(\.dismiss) private var dismiss
+ let onSave: (DebugSteppingFilters) -> Void
+ let onReset: () -> Void
+ @State private var skipJDK: Bool
+ @State private var skipLibraries: Bool
+ @State private var skipSynthetics: Bool
+ @State private var skipStaticInitializers: Bool
+ @State private var skipConstructors: Bool
+ @State private var hideFilteredStackFrames: Bool
+ @State private var classPatterns: String
+
+ init(
+ filters: DebugSteppingFilters,
+ onSave: @escaping (DebugSteppingFilters) -> Void,
+ onReset: @escaping () -> Void
+ ) {
+ self.onSave = onSave
+ self.onReset = onReset
+ _skipJDK = State(initialValue: filters.classNameFilters.contains("$JDK"))
+ _skipLibraries = State(initialValue: filters.classNameFilters.contains("$Libraries"))
+ _skipSynthetics = State(initialValue: filters.skipSynthetics)
+ _skipStaticInitializers = State(initialValue: filters.skipStaticInitializers)
+ _skipConstructors = State(initialValue: filters.skipConstructors)
+ _hideFilteredStackFrames = State(initialValue: filters.hideFilteredStackFrames)
+ _classPatterns = State(initialValue: filters.classNameFilters
+ .filter { $0 != "$JDK" && $0 != "$Libraries" }
+ .joined(separator: "\n"))
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 14) {
+ VStack(alignment: .leading, spacing: 3) {
+ Text("Java Stepping Filters")
+ .font(.system(size: 15, weight: .semibold))
+ Text("Controls where Step Into stops. Changes apply to the next Java debug session.")
+ .font(LitheTheme.smallFont)
+ .foregroundStyle(LitheTheme.secondaryText)
+ }
+
+ Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) {
+ GridRow {
+ Toggle("Skip JDK and reflection code", isOn: $skipJDK)
+ Toggle("Skip third-party libraries", isOn: $skipLibraries)
+ }
+ GridRow {
+ Toggle("Skip synthetic methods", isOn: $skipSynthetics)
+ Toggle("Skip static initializers", isOn: $skipStaticInitializers)
+ }
+ GridRow {
+ Toggle("Skip constructors", isOn: $skipConstructors)
+ Toggle("Collapse matching stack frames", isOn: $hideFilteredStackFrames)
+ }
+ }
+ .toggleStyle(.checkbox)
+ .font(.system(size: 11))
+
+ VStack(alignment: .leading, spacing: 6) {
+ Text("Additional class patterns")
+ .font(.system(size: 11, weight: .semibold))
+ Text("One pattern per line, for example org.mockito.* or com.example.generated.*")
+ .font(LitheTheme.smallFont)
+ .foregroundStyle(LitheTheme.secondaryText)
+ TextEditor(text: $classPatterns)
+ .font(.system(size: 11, design: .monospaced))
+ .scrollContentBackground(.hidden)
+ .padding(6)
+ .background(LitheTheme.sidebar)
+ .overlay {
+ RoundedRectangle(cornerRadius: 5)
+ .stroke(LitheTheme.divider, lineWidth: 1)
+ }
+ .frame(minHeight: 185)
+ }
+
+ HStack {
+ Button("Reset Defaults") {
+ onReset()
+ dismiss()
+ }
+ Spacer()
+ Button("Cancel", role: .cancel) { dismiss() }
+ .keyboardShortcut(.cancelAction)
+ Button("Save") {
+ var patterns = classPatterns
+ .split(whereSeparator: \Character.isNewline)
+ .map(String.init)
+ if skipJDK { patterns.append("$JDK") }
+ if skipLibraries { patterns.append("$Libraries") }
+ onSave(DebugSteppingFilters(
+ classNameFilters: patterns,
+ skipSynthetics: skipSynthetics,
+ skipStaticInitializers: skipStaticInitializers,
+ skipConstructors: skipConstructors,
+ hideFilteredStackFrames: hideFilteredStackFrames
+ ))
+ dismiss()
+ }
+ .keyboardShortcut(.defaultAction)
+ }
+ }
+ .padding(18)
+ .frame(width: 560, height: 470)
+ .litheWorkbenchSurface(LitheTheme.editor)
+ }
+}
+
+struct BreakpointEditorValue {
+ let enabled: Bool
+ let condition: String?
+ let hitCondition: String?
+ let logMessage: String?
+}
+
+private struct ExceptionBreakpointEditorValue {
+ let enabled: Bool
+ let condition: String?
+}
+
+private struct FunctionBreakpointEditorContext: Identifiable {
+ let id = UUID()
+ let breakpoint: GenericDebugFunctionBreakpoint?
+}
+
+private struct FunctionBreakpointEditorValue {
+ let name: String
+ let enabled: Bool
+ let condition: String?
+ let hitCondition: String?
+}
+
+private struct FunctionBreakpointEditorView: View {
+ @Environment(\.dismiss) private var dismiss
+ let breakpoint: GenericDebugFunctionBreakpoint?
+ let onSave: (FunctionBreakpointEditorValue) -> Void
+ @State private var name: String
+ @State private var enabled: Bool
+ @State private var condition: String
+ @State private var hitCondition: String
+
+ init(
+ breakpoint: GenericDebugFunctionBreakpoint?,
+ onSave: @escaping (FunctionBreakpointEditorValue) -> Void
+ ) {
+ self.breakpoint = breakpoint
+ self.onSave = onSave
+ _name = State(initialValue: breakpoint?.name ?? "")
+ _enabled = State(initialValue: breakpoint?.enabled ?? true)
+ _condition = State(initialValue: breakpoint?.condition ?? "")
+ _hitCondition = State(initialValue: breakpoint?.hitCondition ?? "")
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 14) {
+ HStack {
+ Text(breakpoint == nil ? "Add Method Breakpoint" : "Edit Method Breakpoint")
+ .font(.system(size: 14, weight: .semibold))
+ Spacer()
+ Toggle("Enabled", isOn: $enabled)
+ .toggleStyle(.checkbox)
+ }
+ Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 10) {
+ functionEditorRow("Class or method", text: $name)
+ functionEditorRow("Condition", text: $condition)
+ functionEditorRow("Hit count", text: $hitCondition)
+ }
+ Spacer(minLength: 0)
+ HStack {
+ Spacer()
+ Button("Cancel") { dismiss() }
+ .keyboardShortcut(.cancelAction)
+ Button("Save") {
+ onSave(FunctionBreakpointEditorValue(
+ name: name.trimmingCharacters(in: .whitespacesAndNewlines),
+ enabled: enabled,
+ condition: optionalFunctionText(condition),
+ hitCondition: optionalFunctionText(hitCondition)
+ ))
+ dismiss()
+ }
+ .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
+ .keyboardShortcut(.defaultAction)
+ }
+ }
+ .padding(18)
+ .frame(width: 440, height: 245)
+ .litheWorkbenchSurface(LitheTheme.editor)
+ }
+
+ private func functionEditorRow(_ title: String, text: Binding) -> some View {
+ GridRow {
+ Text(title)
+ .font(.system(size: 11))
+ .foregroundStyle(LitheTheme.secondaryText)
+ TextField("", text: text)
+ .textFieldStyle(.roundedBorder)
+ .font(.system(size: 11, design: .monospaced))
+ .frame(minWidth: 300)
+ }
+ }
+
+ private func optionalFunctionText(_ value: String) -> String? {
+ let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ return normalized.isEmpty ? nil : normalized
+ }
+}
+
+private struct ExceptionBreakpointEditorView: View {
+ @Environment(\.dismiss) private var dismiss
+ let breakpoint: GenericDebugExceptionBreakpoint
+ let onSave: (ExceptionBreakpointEditorValue) -> Void
+ @State private var enabled: Bool
+ @State private var condition: String
+
+ init(
+ breakpoint: GenericDebugExceptionBreakpoint,
+ onSave: @escaping (ExceptionBreakpointEditorValue) -> Void
+ ) {
+ self.breakpoint = breakpoint
+ self.onSave = onSave
+ _enabled = State(initialValue: breakpoint.enabled)
+ _condition = State(initialValue: breakpoint.condition ?? "")
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 14) {
+ HStack {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(breakpoint.label)
+ .font(.system(size: 14, weight: .semibold))
+ if let description = breakpoint.description {
+ Text(description)
+ .font(.system(size: 10.5))
+ .foregroundStyle(LitheTheme.secondaryText)
+ }
+ }
+ Spacer()
+ Toggle("Enabled", isOn: $enabled)
+ .toggleStyle(.checkbox)
+ }
+ TextField(
+ breakpoint.conditionDescription ?? "Exception condition",
+ text: $condition
+ )
+ .textFieldStyle(.roundedBorder)
+ .font(.system(size: 11, design: .monospaced))
+ Spacer(minLength: 0)
+ HStack {
+ Spacer()
+ Button("Cancel") { dismiss() }
+ .keyboardShortcut(.cancelAction)
+ Button("Save") {
+ let normalized = condition.trimmingCharacters(in: .whitespacesAndNewlines)
+ onSave(ExceptionBreakpointEditorValue(
+ enabled: enabled,
+ condition: normalized.isEmpty ? nil : normalized
+ ))
+ dismiss()
+ }
+ .keyboardShortcut(.defaultAction)
+ }
+ }
+ .padding(18)
+ .frame(width: 440, height: 190)
+ .litheWorkbenchSurface(LitheTheme.editor)
+ }
+}
+
+struct BreakpointEditorView: View {
+ @Environment(\.dismiss) private var dismiss
+ let breakpoint: GenericDebugBreakpoint
+ let supportsCondition: Bool
+ let supportsHitCondition: Bool
+ let supportsLogMessage: Bool
+ let onSave: (BreakpointEditorValue) -> Void
+ @State private var enabled: Bool
+ @State private var condition: String
+ @State private var hitCondition: String
+ @State private var logMessage: String
+
+ init(
+ breakpoint: GenericDebugBreakpoint,
+ supportsCondition: Bool = true,
+ supportsHitCondition: Bool = true,
+ supportsLogMessage: Bool = true,
+ onSave: @escaping (BreakpointEditorValue) -> Void
+ ) {
+ self.breakpoint = breakpoint
+ self.supportsCondition = supportsCondition
+ self.supportsHitCondition = supportsHitCondition
+ self.supportsLogMessage = supportsLogMessage
+ self.onSave = onSave
+ _enabled = State(initialValue: breakpoint.enabled)
+ _condition = State(initialValue: breakpoint.condition ?? "")
+ _hitCondition = State(initialValue: breakpoint.hitCondition ?? "")
+ _logMessage = State(initialValue: breakpoint.logMessage ?? "")
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 14) {
+ HStack {
+ VStack(alignment: .leading, spacing: 2) {
+ Text("Breakpoint")
+ .font(.system(size: 14, weight: .semibold))
+ Text(breakpoint.title)
+ .font(.system(size: 10.5, design: .monospaced))
+ .foregroundStyle(LitheTheme.secondaryText)
+ }
+ Spacer()
+ Toggle("Enabled", isOn: $enabled)
+ .toggleStyle(.checkbox)
+ }
+ Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 10) {
+ editorRow(
+ "Condition",
+ text: $condition,
+ isSupported: supportsCondition,
+ help: "The active debug adapter does not support conditional breakpoints."
+ )
+ editorRow(
+ "Hit count",
+ text: $hitCondition,
+ isSupported: supportsHitCondition,
+ help: "The active debug adapter does not support hit-count breakpoints."
+ )
+ editorRow(
+ "Log message",
+ text: $logMessage,
+ isSupported: supportsLogMessage,
+ help: "The active debug adapter does not support logpoints."
+ )
+ }
+ Spacer(minLength: 0)
+ HStack {
+ Spacer()
+ Button("Cancel") { dismiss() }
+ .keyboardShortcut(.cancelAction)
+ Button("Save") {
+ onSave(BreakpointEditorValue(
+ enabled: enabled,
+ condition: supportsCondition ? optional(condition) : nil,
+ hitCondition: supportsHitCondition ? optional(hitCondition) : nil,
+ logMessage: supportsLogMessage ? optional(logMessage) : nil
+ ))
+ dismiss()
+ }
+ .keyboardShortcut(.defaultAction)
+ }
+ }
+ .padding(18)
+ .frame(width: 440, height: 245)
+ .litheWorkbenchSurface(LitheTheme.editor)
+ }
+
+ private func editorRow(
+ _ title: String,
+ text: Binding,
+ isSupported: Bool,
+ help: String
+ ) -> some View {
+ GridRow {
+ Text(title)
+ .font(.system(size: 11))
+ .foregroundStyle(LitheTheme.secondaryText)
+ TextField("", text: text)
+ .textFieldStyle(.roundedBorder)
+ .font(.system(size: 11, design: .monospaced))
+ .frame(minWidth: 300)
+ .disabled(!isSupported)
+ .help(isSupported ? title : help)
+ }
+ .opacity(isSupported ? 1 : 0.55)
+ }
+
+ private func optional(_ value: String) -> String? {
+ let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ return normalized.isEmpty ? nil : normalized
+ }
+}
+
+private struct DataBreakpointEditorValue {
+ let enabled: Bool
+ let accessType: String?
+ let condition: String?
+ let hitCondition: String?
+}
+
+private struct DataBreakpointEditorView: View {
+ @Environment(\.dismiss) private var dismiss
+ let breakpoint: GenericDebugDataBreakpoint
+ let onSave: (DataBreakpointEditorValue) -> Void
+ @State private var enabled: Bool
+ @State private var accessType: String
+ @State private var condition: String
+ @State private var hitCondition: String
+
+ init(
+ breakpoint: GenericDebugDataBreakpoint,
+ onSave: @escaping (DataBreakpointEditorValue) -> Void
+ ) {
+ self.breakpoint = breakpoint
+ self.onSave = onSave
+ _enabled = State(initialValue: breakpoint.enabled)
+ _accessType = State(initialValue: breakpoint.accessType ?? breakpoint.accessTypes.first ?? "")
+ _condition = State(initialValue: breakpoint.condition ?? "")
+ _hitCondition = State(initialValue: breakpoint.hitCondition ?? "")
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 14) {
+ HStack {
+ VStack(alignment: .leading, spacing: 2) {
+ Text("Field Breakpoint")
+ .font(.system(size: 14, weight: .semibold))
+ Text(breakpoint.label)
+ .font(.system(size: 10.5, design: .monospaced))
+ .foregroundStyle(LitheTheme.secondaryText)
+ }
+ Spacer()
+ Toggle("Enabled", isOn: $enabled).toggleStyle(.checkbox)
+ }
+ Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 10) {
+ if !breakpoint.accessTypes.isEmpty {
+ GridRow {
+ Text("Access")
+ .font(.system(size: 11))
+ .foregroundStyle(LitheTheme.secondaryText)
+ Picker("", selection: $accessType) {
+ ForEach(breakpoint.accessTypes, id: \.self) { Text($0).tag($0) }
+ }
+ .labelsHidden()
+ }
+ }
+ dataEditorRow("Condition", text: $condition)
+ dataEditorRow("Hit count", text: $hitCondition)
+ }
+ Spacer(minLength: 0)
+ HStack {
+ Spacer()
+ Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction)
+ Button("Save") {
+ onSave(DataBreakpointEditorValue(
+ enabled: enabled,
+ accessType: optionalDataText(accessType),
+ condition: optionalDataText(condition),
+ hitCondition: optionalDataText(hitCondition)
+ ))
+ dismiss()
+ }
+ .keyboardShortcut(.defaultAction)
+ }
+ }
+ .padding(18)
+ .frame(width: 440, height: 245)
+ .litheWorkbenchSurface(LitheTheme.editor)
+ }
+
+ private func dataEditorRow(_ title: String, text: Binding) -> some View {
+ GridRow {
+ Text(title)
+ .font(.system(size: 11))
+ .foregroundStyle(LitheTheme.secondaryText)
+ TextField("", text: text)
+ .textFieldStyle(.roundedBorder)
+ .font(.system(size: 11, design: .monospaced))
+ .frame(minWidth: 300)
+ }
+ }
+
+ private func optionalDataText(_ value: String) -> String? {
+ let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ return normalized.isEmpty ? nil : normalized
+ }
+}
+
+private struct WatchEditorContext: Identifiable {
+ let id = UUID()
+ let watch: GenericDebugWatch?
+}
+
+private struct WatchEditorView: View {
+ @Environment(\.dismiss) private var dismiss
+ let watch: GenericDebugWatch?
+ let onSave: (String) -> Void
+ @State private var expression: String
+
+ init(watch: GenericDebugWatch?, onSave: @escaping (String) -> Void) {
+ self.watch = watch
+ self.onSave = onSave
+ _expression = State(initialValue: watch?.expression ?? "")
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 14) {
+ Text(watch == nil ? "Add Watch" : "Edit Watch")
+ .font(.system(size: 14, weight: .semibold))
+ TextField("Expression", text: $expression)
+ .textFieldStyle(.roundedBorder)
+ .font(.system(size: 11, design: .monospaced))
+ HStack {
+ Spacer()
+ Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction)
+ Button("Save") {
+ onSave(expression)
+ dismiss()
+ }
+ .disabled(expression.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
+ .keyboardShortcut(.defaultAction)
+ }
+ }
+ .padding(18)
+ .frame(width: 440, height: 150)
+ .litheWorkbenchSurface(LitheTheme.editor)
+ }
+}
+
+private struct VariableValueEditorView: View {
+ @Environment(\.dismiss) private var dismiss
+ let variable: DebugVariable
+ let onSave: (String) -> Void
+ @State private var value: String
+
+ init(variable: DebugVariable, onSave: @escaping (String) -> Void) {
+ self.variable = variable
+ self.onSave = onSave
+ _value = State(initialValue: variable.value)
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 14) {
+ VStack(alignment: .leading, spacing: 2) {
+ Text("Set Variable Value")
+ .font(.system(size: 14, weight: .semibold))
+ Text(variable.name)
+ .font(.system(size: 10.5, design: .monospaced))
+ .foregroundStyle(LitheTheme.secondaryText)
+ }
+ TextField("Value", text: $value)
+ .textFieldStyle(.roundedBorder)
+ .font(.system(size: 11, design: .monospaced))
+ HStack {
+ Spacer()
+ Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction)
+ Button("Set") {
+ onSave(value)
+ dismiss()
+ }
+ .keyboardShortcut(.defaultAction)
+ }
+ }
+ .padding(18)
+ .frame(width: 440, height: 170)
+ .litheWorkbenchSurface(LitheTheme.editor)
+ }
+}
+
private extension DebugAdapterState {
var title: String {
switch self {
diff --git a/macos/Sources/Lithe/Views/Debug/JavaDebugView.swift b/macos/Sources/Lithe/Views/Debug/JavaDebugView.swift
deleted file mode 100644
index e3ebe2ecf..000000000
--- a/macos/Sources/Lithe/Views/Debug/JavaDebugView.swift
+++ /dev/null
@@ -1,586 +0,0 @@
-import SwiftUI
-
-struct JavaDebugView: View {
- @EnvironmentObject private var model: AppModel
- @ObservedObject var service: JavaDebugFeatureModel
- @ObservedObject var runService: JavaRunFeatureModel
- @State private var evaluateExpression = ""
-
- init(feature: JavaDebugFeatureModel, runFeature: JavaRunFeatureModel) {
- service = feature
- runService = runFeature
- }
-
- var body: some View {
- VStack(spacing: 0) {
- header
- targetBar
- Rectangle().fill(LitheTheme.divider).frame(height: 1)
-
- if service.isSessionActive || !service.output.isEmpty {
- HStack(spacing: 0) {
- inspector
- .frame(width: 280)
- Rectangle().fill(LitheTheme.divider).frame(width: 1)
- outputView
- }
- } else {
- emptyState
- }
- }
- .litheWorkbenchSurface(LitheTheme.editor)
- }
-
- private var targetBar: some View {
- VStack(spacing: 7) {
- Picker("Debug target", selection: $service.targetKind) {
- ForEach(JavaDebugTargetKind.allCases) { target in
- Label(LocalizedStringKey(target.title), systemImage: target.systemImage)
- .tag(target)
- }
- }
- .pickerStyle(.segmented)
- .lithePointer()
- .labelsHidden()
- .disabled(service.isSessionActive)
-
- switch service.targetKind {
- case .currentFile:
- HStack(spacing: 7) {
- LitheSystemIcon(systemImage: "doc.text")
- .foregroundStyle(LitheTheme.secondaryText)
- Text(model.activeDocument?.url.lastPathComponent ?? "Open a Java file")
- .font(.system(size: 11.5))
- .foregroundStyle(LitheTheme.secondaryText)
- .lineLimit(1)
- Spacer(minLength: 0)
- }
- case .runConfiguration:
- HStack(spacing: 7) {
- if let selectedDebugConfiguration {
- RunConfigurationIcon(kind: selectedDebugConfiguration.kind, size: 16)
- } else {
- LitheSystemIcon(systemImage: "shippingbox")
- .foregroundStyle(LitheTheme.secondaryText)
- }
- Menu {
- if debugConfigurations.isEmpty {
- Text("No Spring Boot or Maven Module configurations")
- } else {
- ForEach(debugConfigurations) { configuration in
- Button {
- model.selectRunConfiguration(configuration)
- } label: {
- HStack {
- RunConfigurationIcon(kind: configuration.kind, size: 16)
- Text(configuration.name)
- }
- }
- }
- }
- } label: {
- HStack(spacing: 4) {
- Text(selectedDebugConfiguration?.name ?? "Select a Spring Boot or Maven Module configuration")
- .font(.system(size: 11.5))
- .foregroundStyle(LitheTheme.secondaryText)
- .lineLimit(1)
- Image(systemName: "chevron.down")
- .font(.system(size: 8, weight: .bold))
- .foregroundStyle(LitheTheme.secondaryText)
- }
- .frame(maxWidth: .infinity, alignment: .leading)
- .contentShape(Rectangle())
- }
- .menuStyle(.borderlessButton)
- .lithePointer()
- .menuIndicator(.hidden)
- Spacer(minLength: 0)
- }
- case .remote:
- remoteFields
- }
- }
- .padding(.horizontal, 12)
- .padding(.vertical, 8)
- .litheWorkbenchSurface(LitheTheme.toolHeader)
- }
-
- private var remoteFields: some View {
- HStack(spacing: 8) {
- TextField("Host", text: $service.remoteHost)
- .textFieldStyle(.roundedBorder)
- .frame(width: 170)
- TextField("JDWP port", text: $service.remotePort)
- .textFieldStyle(.roundedBorder)
- .frame(width: 90)
- TextField("Local JDK Home (optional)", text: $service.remoteJavaHomePath)
- .textFieldStyle(.roundedBorder)
- Image(systemName: "lock.shield")
- .foregroundStyle(LitheTheme.warning)
- .help("JDWP is not encrypted; prefer localhost or an SSH tunnel")
- }
- .font(.system(size: 11.5))
- .disabled(service.isSessionActive)
- }
-
- private var header: some View {
- LitheToolWindowHeader(
- title: "Debug",
- systemImage: "ladybug",
- ideaAssetPath: "toolwindows/toolWindowDebugger.svg",
- subtitle: service.state.title,
- onMinimize: { model.isDebugVisible = false }
- ) {
- if let runningTargetTitle = service.runningTargetTitle {
- Text(runningTargetTitle)
- .font(.system(size: 11.5, weight: .medium))
- .foregroundStyle(LitheTheme.secondaryText)
- .lineLimit(1)
- }
-
- if let port = service.port {
- Text("JDWP \(port)")
- .font(.system(size: 10.5, design: .monospaced))
- .foregroundStyle(LitheTheme.secondaryText)
- }
-
- Spacer()
-
- Group {
- Button {
- model.toggleDebugBreakpointAtCaret()
- } label: {
- Image(systemName: "smallcircle.filled.circle")
- }
- .litheIconButton()
- .help("Toggle breakpoint at caret")
-
- Button {
- if canStop {
- model.stopDebugging()
- } else {
- model.startDebugging()
- }
- } label: {
- Image(systemName: canStop ? "stop.fill" : "play.fill")
- }
- .litheIconButton()
- .foregroundStyle(canStop ? LitheTheme.warning : LitheTheme.success)
- .help(canStop ? "Stop debugging" : "Start debugging")
-
- Button {
- service.pause()
- } label: {
- Image(systemName: "pause.fill")
- }
- .litheIconButton()
- .disabled(!service.canControl || service.state != .running)
- .help("Pause")
- }
-
- Button {
- service.continueExecution()
- } label: {
- LitheSystemIcon(systemImage: "play.fill")
- }
- .litheIconButton()
- .disabled(!service.canControl || service.state != .paused)
- .help("Continue")
-
- Button {
- service.stepOver()
- } label: {
- Image(systemName: "arrow.right.to.line")
- }
- .litheIconButton()
- .disabled(!service.canControl || service.state != .paused)
- .help("Step over")
-
- Button {
- service.stepInto()
- } label: {
- Image(systemName: "arrow.down.to.line")
- }
- .litheIconButton()
- .disabled(!service.canControl || service.state != .paused)
- .help("Step into")
-
- Button {
- service.stepOut()
- } label: {
- Image(systemName: "arrow.up.to.line")
- }
- .litheIconButton()
- .disabled(!service.canControl || service.state != .paused)
- .help("Step out")
-
- Button {
- service.clearOutput()
- } label: {
- Image(systemName: "trash")
- }
- .litheIconButton()
- .help("Clear debug output")
-
- }
- }
-
- private var inspector: some View {
- VStack(alignment: .leading, spacing: 0) {
- sectionHeader("Breakpoints", count: service.breakpoints.count)
- if service.breakpoints.isEmpty {
- Text("No breakpoints")
- .font(LitheTheme.smallFont)
- .foregroundStyle(LitheTheme.secondaryText)
- .padding(12)
- } else {
- ScrollView {
- LazyVStack(spacing: 0) {
- ForEach(service.breakpoints) { breakpoint in
- HStack(spacing: 7) {
- Image(systemName: "circle.fill")
- .font(.system(size: 8))
- .foregroundStyle(LitheTheme.error)
- Text(breakpoint.title)
- .font(.system(size: 11.5, design: .monospaced))
- .foregroundStyle(LitheTheme.primaryText)
- .lineLimit(1)
- Spacer(minLength: 0)
- }
- .padding(.horizontal, 12)
- .frame(height: 28)
- }
- }
- }
- .frame(maxHeight: 150)
- }
-
- Rectangle().fill(LitheTheme.divider).frame(height: 1)
- Group {
- sectionHeader("Inspect", count: nil)
- inspectButton("Threads", icon: "person.3", action: service.inspectThreads)
- inspectButton("Call Stack", icon: "list.number", action: service.inspectStack)
- inspectButton("Local Variables", icon: "list.bullet.rectangle", action: service.inspectVariables)
- evaluateRow
- }
-
- if let exceptionMessage = service.exceptionMessage {
- exceptionBanner(exceptionMessage)
- }
-
- if let title = service.inspectionTitle {
- Rectangle().fill(LitheTheme.divider).frame(height: 1)
- Text(LocalizedStringKey(title))
- .font(.system(size: 11.5, weight: .semibold))
- .foregroundStyle(LitheTheme.primaryText)
- .padding(.horizontal, 12)
- .frame(height: 30, alignment: .leading)
- ScrollView([.vertical, .horizontal]) {
- VStack(alignment: .leading, spacing: 0) {
- structuredInspection
- if !service.inspectionOutput.isEmpty {
- DisclosureGroup("Raw jdb output") {
- Text(service.inspectionOutput)
- .font(.system(size: 10.5, design: .monospaced))
- .foregroundStyle(LitheTheme.secondaryText)
- .textSelection(.enabled)
- .frame(maxWidth: .infinity, alignment: .topLeading)
- .padding(.top, 7)
- }
- .font(.system(size: 10.5, weight: .medium))
- .foregroundStyle(LitheTheme.secondaryText)
- .lithePointer()
- .padding(10)
- }
- }
- .frame(maxWidth: .infinity, alignment: .topLeading)
- }
- }
-
- Spacer(minLength: 0)
- }
- .litheWorkbenchSurface(LitheTheme.sidebar)
- }
-
- private var evaluateRow: some View {
- HStack(spacing: 6) {
- Image(systemName: "function")
- .font(.system(size: 11))
- .foregroundStyle(LitheTheme.secondaryText)
- .frame(width: 16)
- TextField("Evaluate expression", text: $evaluateExpression)
- .textFieldStyle(.plain)
- .font(.system(size: 11.5, design: .monospaced))
- .onSubmit {
- service.evaluate(evaluateExpression)
- }
- Button {
- service.evaluate(evaluateExpression)
- } label: {
- Image(systemName: "arrow.right.circle")
- }
- .litheIconButton()
- .disabled(evaluateExpression.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
- .help("Evaluate expression")
- }
- .padding(.horizontal, 10)
- .frame(height: 30)
- .background(LitheTheme.inputBackground)
- .clipShape(RoundedRectangle(cornerRadius: 4))
- .padding(.horizontal, 10)
- .padding(.vertical, 5)
- }
-
- @ViewBuilder
- private var structuredInspection: some View {
- switch service.inspectionTitle {
- case "Threads":
- if service.threads.isEmpty {
- Text("Waiting for thread data…")
- .font(LitheTheme.smallFont)
- .foregroundStyle(LitheTheme.secondaryText)
- .padding(10)
- } else {
- VStack(alignment: .leading, spacing: 0) {
- ForEach(service.threads) { thread in
- HStack(spacing: 7) {
- Image(systemName: thread.isCurrent ? "play.circle.fill" : "circle")
- .foregroundStyle(thread.isCurrent ? LitheTheme.accent : LitheTheme.secondaryText)
- Text(thread.name)
- .font(.system(size: 11.5, weight: thread.isCurrent ? .medium : .regular))
- .foregroundStyle(LitheTheme.primaryText)
- .lineLimit(1)
- Spacer(minLength: 4)
- Text(thread.status.isEmpty ? thread.id : thread.status)
- .font(.system(size: 10.5))
- .foregroundStyle(LitheTheme.secondaryText)
- .lineLimit(1)
- }
- .padding(.horizontal, 10)
- .frame(minHeight: 28)
- }
- }
- }
- case "Call Stack":
- if service.callStack.isEmpty {
- Text("Waiting for stack data…")
- .font(LitheTheme.smallFont)
- .foregroundStyle(LitheTheme.secondaryText)
- .padding(10)
- } else {
- VStack(alignment: .leading, spacing: 0) {
- ForEach(service.callStack) { frame in
- HStack(alignment: .top, spacing: 8) {
- Text("#\(frame.level)")
- .font(.system(size: 10.5, design: .monospaced))
- .foregroundStyle(LitheTheme.secondaryText)
- .frame(width: 24, alignment: .trailing)
- Text(frame.description)
- .font(.system(size: 11, design: .monospaced))
- .foregroundStyle(LitheTheme.primaryText)
- .lineLimit(2)
- }
- .padding(.horizontal, 10)
- .padding(.vertical, 5)
- }
- }
- }
- case "Local Variables":
- if service.variables.isEmpty {
- Text("No local variables in the current frame")
- .font(LitheTheme.smallFont)
- .foregroundStyle(LitheTheme.secondaryText)
- .padding(10)
- } else {
- VStack(alignment: .leading, spacing: 0) {
- ForEach(service.variables) { variable in
- variableRow(variable, depth: 0)
- }
- }
- }
- default:
- EmptyView()
- }
- }
-
- private func variableRow(_ variable: JavaDebugVariable, depth: Int) -> JavaDebugVariableRow {
- JavaDebugVariableRow(service: service, variable: variable, depth: depth)
- }
-
- private func exceptionBanner(_ message: String) -> some View {
- HStack(alignment: .top, spacing: 8) {
- Image(systemName: "exclamationmark.triangle.fill")
- .foregroundStyle(LitheTheme.error)
- VStack(alignment: .leading, spacing: 2) {
- Text("Exception")
- .font(.system(size: 11.5, weight: .semibold))
- .foregroundStyle(LitheTheme.primaryText)
- Text(message)
- .font(.system(size: 10.5, design: .monospaced))
- .foregroundStyle(LitheTheme.secondaryText)
- .lineLimit(3)
- }
- Spacer(minLength: 0)
- }
- .padding(.horizontal, 10)
- .padding(.vertical, 8)
- .background(LitheTheme.error.opacity(0.10))
- }
-
- private var outputView: some View {
- ScrollView([.vertical, .horizontal]) {
- Text(service.output.isEmpty ? "Waiting for debugger output…" : service.output)
- .font(.system(size: 12, design: .monospaced))
- .foregroundStyle(LitheTheme.primaryText)
- .textSelection(.enabled)
- .frame(maxWidth: .infinity, alignment: .topLeading)
- .padding(12)
- }
- }
-
- private var emptyState: some View {
- VStack(spacing: 10) {
- LitheSystemIcon(systemImage: "ladybug")
- .font(.system(size: 30, weight: .light))
- .foregroundStyle(LitheTheme.secondaryText)
- Text(emptyStateTitle)
- .font(.system(size: 13, weight: .medium))
- .foregroundStyle(LitheTheme.primaryText)
- Button(emptyStateActionTitle) {
- model.startDebugging()
- }
- .buttonStyle(.borderedProminent)
- .lithePointer()
- .tint(LitheTheme.accent)
- .controlSize(.small)
- .disabled(
- runService.isLoadingProject ||
- (service.targetKind == .runConfiguration &&
- runService.configurationStatus == .ready &&
- selectedDebugConfiguration == nil)
- )
- }
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- }
-
- private var debugConfigurations: [JavaRunConfiguration] {
- runService.configurations.filter {
- $0.kind.isMavenBacked
- }
- }
-
- private var selectedDebugConfiguration: JavaRunConfiguration? {
- guard let configuration = runService.selectedConfiguration,
- configuration.kind.isMavenBacked else {
- return nil
- }
- return configuration
- }
-
- private var emptyStateTitle: String {
- switch service.targetKind {
- case .currentFile:
- "Start debugging the current Java file"
- case .runConfiguration:
- selectedDebugConfiguration.map { "Start debugging \($0.name)" }
- ?? "Select a Spring Boot or Maven Module configuration"
- case .remote:
- "Attach to a remote JVM or Tomcat"
- }
- }
-
- private var emptyStateActionTitle: String {
- service.targetKind == .remote ? "Attach" : "Start Debugging"
- }
-
- private func sectionHeader(_ title: String, count: Int?) -> some View {
- HStack {
- Text(LocalizedStringKey(title))
- .font(.system(size: 11.5, weight: .semibold))
- .foregroundStyle(LitheTheme.primaryText)
- Spacer()
- if let count {
- Text("\(count)")
- .font(.system(size: 10.5, weight: .medium))
- .foregroundStyle(LitheTheme.secondaryText)
- }
- }
- .padding(.horizontal, 12)
- .frame(height: 32)
- .litheWorkbenchSurface(LitheTheme.sidebar)
- }
-
- private func inspectButton(_ title: String, icon: String, action: @escaping () -> Void) -> some View {
- Button(action: action) {
- Label(LocalizedStringKey(title), systemImage: icon)
- .font(.system(size: 11.5))
- .foregroundStyle(LitheTheme.primaryText)
- .frame(maxWidth: .infinity, alignment: .leading)
- .padding(.horizontal, 12)
- .frame(height: 30)
- .contentShape(Rectangle())
- }
- .buttonStyle(.plain)
- .lithePointer()
- }
-
- private var canStop: Bool {
- service.isSessionActive
- }
-
- private var stateColor: Color {
- switch service.state {
- case .running: LitheTheme.success
- case .paused: LitheTheme.accent
- case .failed: LitheTheme.error
- case .launching: LitheTheme.warning
- default: LitheTheme.secondaryText
- }
- }
-}
-
-private struct JavaDebugVariableRow: View {
- @ObservedObject var service: JavaDebugFeatureModel
- let variable: JavaDebugVariable
- let depth: Int
-
- var body: some View {
- VStack(alignment: .leading, spacing: 0) {
- Button {
- service.toggleVariable(variable)
- } label: {
- HStack(spacing: 6) {
- if variable.canExpand {
- Image(systemName: service.expandingVariableID == variable.id ? "hourglass" : (variable.isExpanded ? "chevron.down" : "chevron.right"))
- .font(.system(size: 8, weight: .bold))
- .foregroundStyle(LitheTheme.secondaryText)
- .frame(width: 10)
- } else {
- Color.clear.frame(width: 10, height: 1)
- }
- Text(variable.name)
- .font(.system(size: 11.5, weight: .medium, design: .monospaced))
- .foregroundStyle(LitheTheme.primaryText)
- Text(variable.value)
- .font(.system(size: 11, design: .monospaced))
- .foregroundStyle(LitheTheme.secondaryText)
- .lineLimit(1)
- Spacer(minLength: 0)
- }
- .padding(.leading, CGFloat(depth * 14) + 10)
- .padding(.trailing, 10)
- .frame(maxWidth: .infinity, alignment: .leading)
- .frame(minHeight: 28)
- .contentShape(Rectangle())
- }
- .buttonStyle(.plain)
- .lithePointer()
-
- if variable.isExpanded {
- ForEach(variable.children) { child in
- JavaDebugVariableRow(service: service, variable: child, depth: depth + 1)
- }
- }
- }
- }
-}
diff --git a/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift b/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift
index 4547e6b06..1d7a93731 100644
--- a/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift
+++ b/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift
@@ -1,6 +1,7 @@
import AppKit
import SwiftUI
import LitheGitModule
+import LitheDebugModule
struct CodeEditorPalette {
private static let propertyRGB: (red: CGFloat, green: CGFloat, blue: CGFloat) = (79, 148, 250)
@@ -29,6 +30,12 @@ struct CodeEditorPalette {
var selection: NSColor { themeColor(.accent).withAlphaComponent(isDark ? 0.42 : 0.24) }
var selectionText: NSColor { themeColor(.primaryText) }
var currentLine: NSColor { color(light: (0, 0, 0, 0.035), dark: (1, 1, 1, 0.035)) }
+ var executionLine: NSColor {
+ color(
+ light: (0.22, 0.52, 0.91, 0.24),
+ dark: (0.18, 0.43, 0.78, 0.72)
+ )
+ }
var bracket: NSColor { color(light: (0.18, 0.43, 0.79, 0.19), dark: (0.72, 0.72, 0.72, 0.22)) }
var symbol: NSColor { color(light: (0.18, 0.43, 0.79, 0.11), dark: (0.68, 0.68, 0.68, 0.14)) }
var guide: NSColor { themeColor(.guide) }
@@ -105,6 +112,151 @@ enum EditorGutterHitTarget: Equatable {
case gitChange
}
+struct EditorDebugBreakpointState: Equatable {
+ let enabled: Bool
+ let verified: Bool
+}
+
+enum EditorDebugBreakpointAppearance {
+ static let markerSize: CGFloat = 14
+ static let enabledColor = NSColor(
+ srgbRed: 229.0 / 255.0,
+ green: 87.0 / 255.0,
+ blue: 101.0 / 255.0,
+ alpha: 1
+ )
+ static let verifiedCheckColor = NSColor(
+ srgbRed: 108.0 / 255.0,
+ green: 112.0 / 255.0,
+ blue: 126.0 / 255.0,
+ alpha: 1
+ )
+}
+
+struct EditorInlineDebugValue: Equatable {
+ let name: String
+ let value: String
+}
+
+enum EditorInlineDebugValueProjection {
+ static let maximumVisibleValues = 4
+ static let maximumValueCharacters = 80
+
+ static func values(
+ forLine line: Int,
+ in source: NSString,
+ variables: [EditorInlineDebugValue]
+ ) -> [EditorInlineDebugValue] {
+ guard let lineRange = lineRange(for: line, in: source) else { return [] }
+ let lineSource = source.substring(with: lineRange) as NSString
+ let candidates = Dictionary(
+ variables.map { ($0.name, $0) },
+ uniquingKeysWith: { first, _ in first }
+ )
+ var matched: [(location: Int, value: EditorInlineDebugValue)] = []
+ for (name, variable) in candidates {
+ guard isIdentifier(name) else { continue }
+ var searchLocation = 0
+ while searchLocation < lineSource.length {
+ let range = lineSource.range(
+ of: name,
+ options: [],
+ range: NSRange(
+ location: searchLocation,
+ length: lineSource.length - searchLocation
+ )
+ )
+ guard range.location != NSNotFound else { break }
+ if hasIdentifierBoundaries(range: range, in: lineSource) {
+ matched.append((range.location, normalized(variable)))
+ break
+ }
+ searchLocation = NSMaxRange(range)
+ }
+ }
+ return matched
+ .sorted { ($0.location, $0.value.name) < ($1.location, $1.value.name) }
+ .prefix(maximumVisibleValues)
+ .map(\.value)
+ }
+
+ private static func normalized(_ value: EditorInlineDebugValue) -> EditorInlineDebugValue {
+ let singleLine = value.value
+ .replacingOccurrences(of: "\r", with: " ")
+ .replacingOccurrences(of: "\n", with: " ")
+ guard singleLine.count > maximumValueCharacters else {
+ return EditorInlineDebugValue(name: value.name, value: singleLine)
+ }
+ return EditorInlineDebugValue(
+ name: value.name,
+ value: String(singleLine.prefix(maximumValueCharacters - 1)) + "…"
+ )
+ }
+
+ private static func isIdentifier(_ value: String) -> Bool {
+ guard let first = value.unicodeScalars.first,
+ CharacterSet.letters.union(CharacterSet(charactersIn: "_$")).contains(first)
+ else { return false }
+ let characters = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "_$"))
+ return value.unicodeScalars.dropFirst().allSatisfy(characters.contains)
+ }
+
+ private static func hasIdentifierBoundaries(range: NSRange, in source: NSString) -> Bool {
+ let characters = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "_$"))
+ func isIdentifierCharacter(at location: Int) -> Bool {
+ guard location >= 0,
+ location < source.length,
+ let scalar = UnicodeScalar(source.character(at: location)) else { return false }
+ return characters.contains(scalar)
+ }
+ return !isIdentifierCharacter(at: range.location - 1)
+ && !isIdentifierCharacter(at: NSMaxRange(range))
+ }
+
+ private static func lineRange(for line: Int, in source: NSString) -> NSRange? {
+ guard line >= 0, source.length > 0 else { return nil }
+ var location = 0
+ var currentLine = 0
+ while currentLine < line, location < source.length {
+ let range = source.lineRange(for: NSRange(location: location, length: 0))
+ let next = NSMaxRange(range)
+ guard next > location else { return nil }
+ location = next
+ currentLine += 1
+ }
+ guard currentLine == line, location < source.length else { return nil }
+ return source.lineRange(for: NSRange(location: location, length: 0))
+ }
+}
+
+enum EditorDebugBreakpointLocation {
+ static func productLine(forEditorLine line: Int) -> Int { line + 1 }
+}
+
+enum DebugHoverExpressionResolver {
+ static func expression(at location: Int, in source: NSString) -> (String, NSRange)? {
+ guard source.length > 0 else { return nil }
+ let characters = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "_$"))
+ let position = min(max(0, location), source.length - 1)
+ guard let scalar = UnicodeScalar(source.character(at: position)),
+ characters.contains(scalar) else { return nil }
+ var start = position
+ var end = position + 1
+ while start > 0,
+ let scalar = UnicodeScalar(source.character(at: start - 1)),
+ characters.contains(scalar) { start -= 1 }
+ while end < source.length,
+ let scalar = UnicodeScalar(source.character(at: end)),
+ characters.contains(scalar) { end += 1 }
+ let range = NSRange(location: start, length: end - start)
+ let value = source.substring(with: range)
+ guard value.first?.isLetter == true || value.first == "_" || value.first == "$" else {
+ return nil
+ }
+ return (value, range)
+ }
+}
+
struct EditorLanguageFeatureTransition: Equatable {
let refreshImplementationMarkers: Bool
let clearImplementationMarkers: Bool
@@ -134,16 +286,21 @@ struct EditorGutterLayout: Equatable {
let gitChangeRange: Range
let width: CGFloat
+ /// IDEA treats the line-number column and the adjacent breakpoint marker
+ /// column as one forgiving interaction target. The marker is still drawn
+ /// in `breakpointRange`, but users do not need to hit that narrow strip.
+ var breakpointInteractionRange: Range {
+ lineNumberRange.lowerBound..? = nil
let viewportStore: EditorViewportStore
@@ -315,7 +471,6 @@ struct CodeEditorView: NSViewRepresentable {
Coordinator(
document: document,
model: model,
- debugService: debugService,
markdownScrollPosition: markdownScrollPosition,
viewportStore: viewportStore
)
@@ -398,6 +553,16 @@ struct CodeEditorView: NSViewRepresentable {
textView.onGoToLineRequested = { [weak model] in model?.showGoToLine() }
textView.onFindNextRequested = { [weak model] in model?.navigateFind(offset: 1) }
textView.onFindPreviousRequested = { [weak model] in model?.navigateFind(offset: -1) }
+ textView.onRunToCursor = { [weak model] line, column in
+ model?.runToCursor(
+ fileURL: document.url,
+ line: line + 1,
+ column: column + 1
+ )
+ }
+ textView.onDebugHover = { [weak model] expression, completion in
+ model?.requestDebugHover(expression: expression, completion: completion)
+ }
textView.onFindStateChange = { [weak coordinator = context.coordinator] index, count in
coordinator?.scheduleFindStateUpdate(currentIndex: index, count: count)
}
@@ -458,6 +623,9 @@ struct CodeEditorView: NSViewRepresentable {
}
context.coordinator.attachMarkdownImagePasteMonitor(to: scrollView)
context.coordinator.codeVisionOverlay = CodeVisionOverlayController(textView: textView)
+ context.coordinator.debugInlineValueOverlay = DebugInlineValueOverlayController(
+ textView: textView
+ )
context.coordinator.isDarkAppearance = palette.isDark
context.coordinator.colorTheme = settings.colorTheme
context.coordinator.highlight()
@@ -469,6 +637,10 @@ struct CodeEditorView: NSViewRepresentable {
context.coordinator.updateDiagnostics()
context.coordinator.shouldFocus = shouldFocus
context.coordinator.requestInitialFocusIfNeeded()
+ let debugFeature = model.genericDebugFeatureIfActive
+ textView.isRunToCursorEnabled = debugFeature?.state == .paused
+ && debugFeature?.capabilities.supportsGotoTargetsRequest == true
+ textView.isDebugHoverEnabled = debugFeature?.state == .paused
context.coordinator.restoreViewportWhenReady()
return container
}
@@ -481,7 +653,6 @@ struct CodeEditorView: NSViewRepresentable {
|| context.coordinator.colorTheme != settings.colorTheme
context.coordinator.document = document
context.coordinator.model = model
- context.coordinator.debugService = debugService
context.coordinator.shouldFocus = shouldFocus
context.coordinator.markdownScrollPosition = markdownScrollPosition
container.displaysTransparentBackground = showsWorkbenchBackground
@@ -497,6 +668,13 @@ struct CodeEditorView: NSViewRepresentable {
context.coordinator.colorTheme = settings.colorTheme
context.coordinator.requestInitialFocusIfNeeded()
+ if let codeTextView = textView as? CodeTextView {
+ let debugFeature = model.genericDebugFeatureIfActive
+ codeTextView.isRunToCursorEnabled = debugFeature?.state == .paused
+ && debugFeature?.capabilities.supportsGotoTargetsRequest == true
+ codeTextView.isDebugHoverEnabled = debugFeature?.state == .paused
+ }
+
let languageFeatures = model.languageToolingSessionsIfActive?.features(for: document.url) ?? []
let fontSize = settings.editorFontSize
let tabWidth = settings.tabWidth
@@ -560,13 +738,13 @@ struct CodeEditorView: NSViewRepresentable {
final class Coordinator: NSObject, NSTextViewDelegate {
weak var document: EditorDocument?
weak var model: AppModel?
- weak var debugService: JavaDebugFeatureModel?
let fileName: String
let fileExtension: String
weak var textView: NSTextView?
weak var gutter: LineNumberGutterView?
weak var container: EditorContainerView?
var codeVisionOverlay: CodeVisionOverlayController?
+ var debugInlineValueOverlay: DebugInlineValueOverlayController?
var isApplyingEditorChange = false
var isDarkAppearance = true
var colorTheme: AppColorTheme = .lithe
@@ -594,6 +772,10 @@ struct CodeEditorView: NSViewRepresentable {
private var appliedLanguageFeatures: LanguageServerFeatureSet?
private var appliedReadOnly: Bool?
private var appliedCodeVisionHints: [JavaCodeVisionHint]?
+ private var appliedInlineDebugLine: Int?
+ private var appliedInlineDebugValues: [EditorInlineDebugValue] = []
+ private var requestedAutomaticDebugFrameID: Int?
+ private var requestedAutomaticDebugExpressions: [String] = []
private var editorOverlayLayoutRevision = 0
private var appliedEditorOverlayLayoutRevision = -1
private var editorOverlayRelayoutTask: Task?
@@ -601,6 +783,13 @@ struct CodeEditorView: NSViewRepresentable {
private var appliedBlameVisible = false
private var appliedBlameLines: [GitBlameLine] = []
private var appliedDebugBreakpointLines = Set()
+ // `nil` forces the first editor refresh to install gutter callbacks,
+ // even when the document starts with no breakpoints.
+ private var appliedDebugBreakpointStates: [Int: EditorDebugBreakpointState]?
+ private var appliedDebugBreakpointMessages: [Int: String] = [:]
+ private var appliedRunToCursorEnabled = false
+ private var appliedBreakpointsMuted = false
+ private var appliedCurrentExecutionLine: Int?
private var appliedGitMarkers: [GitLineChangeMarker]?
private var appliedDiagnostics: [EditorDiagnostic] = []
private var markdownImagePasteMonitor: Any?
@@ -617,13 +806,11 @@ struct CodeEditorView: NSViewRepresentable {
init(
document: EditorDocument,
model: AppModel,
- debugService: JavaDebugFeatureModel?,
markdownScrollPosition: Binding?,
viewportStore: EditorViewportStore
) {
self.document = document
self.model = model
- self.debugService = debugService
self.markdownScrollPosition = markdownScrollPosition
self.viewportStore = viewportStore
fileName = document.url.lastPathComponent
@@ -886,6 +1073,7 @@ struct CodeEditorView: NSViewRepresentable {
guard let textView else { return }
guard document?.isReadOnly != true else { return }
let codeTextView = textView as? CodeTextView
+ let previousSource = document?.text
if let replacedRange = pendingReplacedRange, let replacement = pendingReplacement {
codeTextView?.applyLineIndexEdit(replacedRange: replacedRange, replacement: replacement)
} else {
@@ -894,6 +1082,17 @@ struct CodeEditorView: NSViewRepresentable {
gutter?.refreshLineNumberLayout()
isApplyingEditorChange = true
document?.applyLiveEditorText(textView.string)
+ if let document,
+ let previousSource,
+ let replacedRange = pendingReplacedRange,
+ let replacement = pendingReplacement {
+ model?.applyDebugSourceEdit(
+ fileURL: document.url,
+ previousSource: previousSource,
+ replacedRange: replacedRange,
+ replacement: replacement
+ )
+ }
if let document {
scheduleDocumentChange(document)
}
@@ -1216,32 +1415,167 @@ struct CodeEditorView: NSViewRepresentable {
onAuthor: { [weak model] in model?.showBlame(for: url) }
)
}
+ let inlineDebugLine: Int?
+ let inlineDebugValues: [EditorInlineDebugValue]
+ if let feature = model.genericDebugFeatureIfActive,
+ feature.state == .paused,
+ feature.selectedFrame?.sourceURL?.standardizedFileURL == url,
+ let frame = feature.selectedFrame {
+ inlineDebugLine = max(0, frame.line - 1)
+ let source = (textView?.string ?? "") as NSString
+ let automaticExpressions = feature.providerID == "java"
+ ? DebugAutomaticExpressionProjection.javaExpressions(
+ forLine: inlineDebugLine ?? 0,
+ in: source
+ )
+ : []
+ if requestedAutomaticDebugFrameID != frame.id
+ || requestedAutomaticDebugExpressions != automaticExpressions {
+ requestedAutomaticDebugFrameID = frame.id
+ requestedAutomaticDebugExpressions = automaticExpressions
+ Task { @MainActor [weak feature] in
+ guard feature?.selectedFrameID == frame.id else { return }
+ feature?.requestAutomaticVariables(automaticExpressions)
+ }
+ }
+ inlineDebugValues = EditorInlineDebugValueProjection.values(
+ forLine: inlineDebugLine ?? 0,
+ in: source,
+ variables: feature.presentedVariables.map {
+ EditorInlineDebugValue(name: $0.name, value: $0.value)
+ }
+ )
+ } else {
+ inlineDebugLine = nil
+ inlineDebugValues = []
+ requestedAutomaticDebugFrameID = nil
+ requestedAutomaticDebugExpressions = []
+ }
+ if appliedInlineDebugLine != inlineDebugLine
+ || appliedInlineDebugValues != inlineDebugValues
+ || overlayLayoutChanged {
+ appliedInlineDebugLine = inlineDebugLine
+ appliedInlineDebugValues = inlineDebugValues
+ debugInlineValueOverlay?.update(
+ line: inlineDebugLine,
+ values: inlineDebugValues
+ )
+ }
appliedEditorOverlayLayoutRevision = editorOverlayLayoutRevision
let isBlameVisible = model.blameVisibleURL == url
let blameLines = model.gitBlameLines[url] ?? []
- let javaBreakpointLines = debugService?.breakpoints.filter {
- $0.fileURL.standardizedFileURL == url
- }.map(\.line) ?? []
let genericBreakpointLines = (model.genericDebugFeatureIfActive?.breakpoints ?? []).filter {
$0.fileURL.standardizedFileURL == url
}.map(\.line)
- let debugBreakpointLines = Set(javaBreakpointLines + genericBreakpointLines)
+ let debugBreakpointLines = Set(genericBreakpointLines)
+ let debugBreakpointStates = (model.genericDebugFeatureIfActive?.breakpoints ?? [])
+ .filter { $0.fileURL.standardizedFileURL == url }
+ .reduce(into: [Int: EditorDebugBreakpointState]()) { states, breakpoint in
+ // A source line can carry multiple column breakpoints;
+ // show it as confirmed when any adapter location is confirmed.
+ let previous = states[breakpoint.line]
+ states[breakpoint.line] = EditorDebugBreakpointState(
+ enabled: previous?.enabled == true || breakpoint.enabled,
+ verified: previous?.verified == true || breakpoint.verified
+ )
+ }
+ let debugBreakpointMessages = Dictionary(
+ model.genericDebugFeatureIfActive?.breakpoints
+ .filter { $0.fileURL.standardizedFileURL == url }
+ .compactMap { breakpoint in
+ breakpoint.message.map { (breakpoint.line, $0) }
+ } ?? [],
+ uniquingKeysWith: { first, _ in first }
+ )
+ let currentExecutionLine: Int? = {
+ guard let frame = model.genericDebugFeatureIfActive?.selectedFrame,
+ frame.sourceURL?.standardizedFileURL == url else { return nil }
+ return frame.line
+ }()
+ let isRunToCursorEnabled = model.genericDebugFeatureIfActive?.state == .paused
+ && model.genericDebugFeatureIfActive?.capabilities.supportsGotoTargetsRequest == true
+ let areBreakpointsMuted = model.genericDebugFeatureIfActive?.areBreakpointsMuted ?? false
if appliedBlameVisible != isBlameVisible
|| appliedBlameLines != blameLines
- || appliedDebugBreakpointLines != debugBreakpointLines {
+ || appliedDebugBreakpointLines != debugBreakpointLines
+ || appliedDebugBreakpointStates != debugBreakpointStates
+ || appliedDebugBreakpointMessages != debugBreakpointMessages
+ || appliedRunToCursorEnabled != isRunToCursorEnabled
+ || appliedBreakpointsMuted != areBreakpointsMuted
+ || appliedCurrentExecutionLine != currentExecutionLine {
appliedBlameVisible = isBlameVisible
appliedBlameLines = blameLines
appliedDebugBreakpointLines = debugBreakpointLines
+ appliedDebugBreakpointStates = debugBreakpointStates
+ appliedDebugBreakpointMessages = debugBreakpointMessages
+ appliedRunToCursorEnabled = isRunToCursorEnabled
+ appliedBreakpointsMuted = areBreakpointsMuted
+ appliedCurrentExecutionLine = currentExecutionLine
container?.gutterWidthConstraint?.constant = isBlameVisible
? EditorLayoutMetrics.blameMetadataWidth + standardGutterWidth
: standardGutterWidth
gutter?.update(blameLines: blameLines, isVisible: isBlameVisible) { [weak model] blame in
Task { await model?.showGitCommit(blame.commitHash) }
}
- gutter?.updateDebugBreakpointLines(debugBreakpointLines) { [weak model] line in
- model?.toggleDebugBreakpoint(fileURL: url, line: line)
- }
+ gutter?.updateDebugBreakpointLines(
+ debugBreakpointStates,
+ onToggle: { [weak model] line in
+ model?.toggleDebugBreakpoint(
+ fileURL: url,
+ line: EditorDebugBreakpointLocation.productLine(forEditorLine: line)
+ )
+ },
+ canAdd: { [weak textView, fileExtension] line in
+ guard fileExtension.lowercased() == "java",
+ let textView else { return false }
+ return DebugBreakpointLocationValidator.isExecutableJavaLine(
+ source: textView.string,
+ line: EditorDebugBreakpointLocation.productLine(forEditorLine: line)
+ )
+ },
+ onEdit: { [weak model] line in
+ model?.editDebugBreakpoint(
+ fileURL: url,
+ line: EditorDebugBreakpointLocation.productLine(forEditorLine: line)
+ )
+ },
+ onRemove: { [weak model] line in
+ let productLine = EditorDebugBreakpointLocation.productLine(
+ forEditorLine: line
+ )
+ guard let feature = model?.genericDebugFeatureIfActive,
+ let breakpoint = feature.breakpoints.first(where: {
+ $0.fileURL.standardizedFileURL == url && $0.line == productLine
+ }) else { return }
+ feature.removeBreakpoint(breakpoint)
+ },
+ onSetEnabled: { [weak model] line, enabled in
+ let productLine = EditorDebugBreakpointLocation.productLine(
+ forEditorLine: line
+ )
+ guard let feature = model?.genericDebugFeatureIfActive,
+ let breakpoint = feature.breakpoints.first(where: {
+ $0.fileURL.standardizedFileURL == url && $0.line == productLine
+ }) else { return }
+ feature.setBreakpointEnabled(breakpoint, enabled: enabled)
+ },
+ onToggleAll: { [weak model] in
+ model?.genericDebugFeatureIfActive?.toggleBreakpointMute()
+ },
+ onRunToCursor: { [weak model] line in
+ model?.runToCursor(
+ fileURL: url,
+ line: EditorDebugBreakpointLocation.productLine(forEditorLine: line),
+ column: 1
+ )
+ },
+ isRunToCursorEnabled: isRunToCursorEnabled,
+ areBreakpointsMuted: areBreakpointsMuted
+ )
+ gutter?.updateDebugBreakpointMessages(debugBreakpointMessages)
+ gutter?.updateCurrentExecutionLine(currentExecutionLine)
+ (textView as? CodeTextView)?.updateCurrentExecutionLine(currentExecutionLine)
}
}
@@ -1497,6 +1831,14 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate {
var onRenameRequested: ((Int, Int, String) -> Void)?
var onFormatRequested: (() -> Void)?
var onCodeActionsRequested: ((Int, Int) -> Void)?
+ var onRunToCursor: ((Int, Int) -> Void)?
+ var isRunToCursorEnabled = false
+ var onDebugHover: ((String, @escaping (String?) -> Void) -> Void)?
+ var isDebugHoverEnabled = false {
+ didSet {
+ if !isDebugHoverEnabled { clearDebugHover() }
+ }
+ }
var onPasteImage: (() -> Bool)?
private var findMatchRanges: [NSRange] = []
@@ -1508,8 +1850,13 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate {
private var lastCaretBackgroundRanges: [NSRange] = []
private var completionItemsByID: [String: LanguageServerCompletionItem] = [:]
private var languageHoverPopover: NSPopover?
+ private var debugHoverPopover: NSPopover?
+ private var debugHoverWorkItem: DispatchWorkItem?
+ private var pendingDebugHover: (expression: String, range: NSRange)?
private var currentLineColor = CodeEditorPalette.dark.currentLine
+ private var executionLineColor = CodeEditorPalette.dark.executionLine
+ private var currentExecutionLine: Int?
private var bracketColor = CodeEditorPalette.dark.bracket
private var symbolColor = CodeEditorPalette.dark.symbol
private var guideColor = CodeEditorPalette.dark.guide
@@ -1556,6 +1903,7 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate {
.foregroundColor: palette.selectionText
]
currentLineColor = palette.currentLine
+ executionLineColor = palette.executionLine
bracketColor = palette.bracket
symbolColor = palette.symbol
guideColor = palette.guide
@@ -2309,10 +2657,38 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate {
override func drawBackground(in rect: NSRect) {
super.drawBackground(in: rect)
+ drawExecutionLineBackground(in: rect)
drawCurrentLineBackground(in: rect)
drawIndentGuides(in: rect)
}
+ func updateCurrentExecutionLine(_ line: Int?) {
+ let normalizedLine = line.map { max(0, $0 - 1) }
+ guard currentExecutionLine != normalizedLine else { return }
+ currentExecutionLine = normalizedLine
+ needsDisplay = true
+ }
+
+ private func drawExecutionLineBackground(in rect: NSRect) {
+ guard let currentExecutionLine,
+ let layoutManager,
+ layoutManager.numberOfGlyphs > 0,
+ let lineRect = lineFragmentRect(
+ forLine: currentExecutionLine,
+ in: string as NSString,
+ layoutManager: layoutManager
+ ) else { return }
+ let executionRect = NSRect(
+ x: 0,
+ y: textContainerOrigin.y + lineRect.minY,
+ width: bounds.width,
+ height: lineRect.height
+ )
+ guard executionRect.intersects(rect) else { return }
+ executionLineColor.setFill()
+ executionRect.intersection(rect).fill()
+ }
+
private func drawCurrentLineBackground(in rect: NSRect) {
let source = string as NSString
let caret = min(selectedRange().location, source.length)
@@ -2558,18 +2934,24 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate {
let summaryRegion = foldSummaryRegion(at: point)
updateFoldHover(to: summaryRegion?.id)
if summaryRegion != nil {
+ clearDebugHover()
NSCursor.pointingHand.set()
return
}
if hitTest(point) is CodeVisionLinkButton {
+ clearDebugHover()
NSCursor.pointingHand.set()
return
}
if isLanguageNavigationEnabled,
hasNavigationModifier(event.modifierFlags) {
updateLinkHighlight(at: point)
- if linkRange != nil { return }
+ if linkRange != nil {
+ clearDebugHover()
+ return
+ }
}
+ updateDebugHover(at: point)
NSCursor.iBeam.set()
}
@@ -2590,12 +2972,14 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate {
super.mouseExited(with: event)
updateFoldHover(to: nil)
clearLinkHighlight()
+ clearDebugHover()
NSCursor.arrow.set()
}
override func resignFirstResponder() -> Bool {
updateFoldHover(to: nil)
clearLinkHighlight()
+ clearDebugHover()
return super.resignFirstResponder()
}
@@ -2811,6 +3195,105 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate {
return (text, range)
}
+ private func updateDebugHover(at point: NSPoint) {
+ guard isDebugHoverEnabled,
+ let characterIndex = characterIndex(at: point),
+ let resolved = DebugHoverExpressionResolver.expression(
+ at: characterIndex,
+ in: string as NSString
+ ),
+ let layoutManager,
+ let textContainer else {
+ clearDebugHover()
+ return
+ }
+ let glyphRange = layoutManager.glyphRange(
+ forCharacterRange: resolved.1,
+ actualCharacterRange: nil
+ )
+ let glyphRect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer)
+ let containerPoint = NSPoint(
+ x: point.x - textContainerOrigin.x,
+ y: point.y - textContainerOrigin.y
+ )
+ guard glyphRect.insetBy(dx: -2, dy: -2).contains(containerPoint) else {
+ clearDebugHover()
+ return
+ }
+ if pendingDebugHover?.expression == resolved.0,
+ pendingDebugHover?.range == resolved.1 { return }
+ debugHoverWorkItem?.cancel()
+ debugHoverPopover?.close()
+ pendingDebugHover = (resolved.0, resolved.1)
+ let workItem = DispatchWorkItem { [weak self] in
+ guard let self,
+ self.pendingDebugHover?.expression == resolved.0,
+ self.pendingDebugHover?.range == resolved.1 else { return }
+ self.onDebugHover?(resolved.0) { [weak self] value in
+ guard let self,
+ let value,
+ self.pendingDebugHover?.expression == resolved.0,
+ self.pendingDebugHover?.range == resolved.1 else { return }
+ self.presentDebugHover(value, range: resolved.1)
+ }
+ }
+ debugHoverWorkItem = workItem
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.45, execute: workItem)
+ }
+
+ private func presentDebugHover(_ value: String, range: NSRange) {
+ guard let layoutManager, let textContainer else { return }
+ let glyphRange = layoutManager.glyphRange(
+ forCharacterRange: range,
+ actualCharacterRange: nil
+ )
+ var anchor = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer)
+ anchor.origin.x += textContainerOrigin.x
+ anchor.origin.y += textContainerOrigin.y
+ let label = NSTextField(wrappingLabelWithString: value)
+ label.font = .monospacedSystemFont(ofSize: 12, weight: .regular)
+ label.textColor = NSColor(white: 0.9, alpha: 1)
+ label.maximumNumberOfLines = 6
+ label.preferredMaxLayoutWidth = 420
+ let controller = NSViewController()
+ let container = NSView()
+ label.translatesAutoresizingMaskIntoConstraints = false
+ container.addSubview(label)
+ NSLayoutConstraint.activate([
+ label.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 10),
+ label.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -10),
+ label.topAnchor.constraint(equalTo: container.topAnchor, constant: 8),
+ label.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -8)
+ ])
+ container.wantsLayer = true
+ container.layer?.backgroundColor = NSColor(
+ red: 0.105,
+ green: 0.11,
+ blue: 0.12,
+ alpha: 1
+ ).cgColor
+ controller.view = container
+ let fittingSize = label.fittingSize
+ controller.preferredContentSize = NSSize(
+ width: min(440, fittingSize.width + 20),
+ height: min(140, fittingSize.height + 16)
+ )
+ let popover = NSPopover()
+ popover.behavior = .transient
+ popover.animates = true
+ popover.contentViewController = controller
+ popover.show(relativeTo: anchor, of: self, preferredEdge: .maxY)
+ debugHoverPopover = popover
+ }
+
+ private func clearDebugHover() {
+ debugHoverWorkItem?.cancel()
+ debugHoverWorkItem = nil
+ pendingDebugHover = nil
+ debugHoverPopover?.close()
+ debugHoverPopover = nil
+ }
+
private func enclosingCodeScope(at caret: Int, in source: NSString) -> NSRange? {
var start: Int?
var depth = 0
@@ -2870,6 +3353,17 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate {
menu.insertItem(goToLineItem, at: 0)
menu.insertItem(.separator(), at: 1)
let languageItems = languageContextMenuItems()
+ if onRunToCursor != nil {
+ let runToCursor = NSMenuItem(
+ title: "Run to Cursor",
+ action: #selector(runToCursorFromMenu),
+ keyEquivalent: ""
+ )
+ runToCursor.target = self
+ runToCursor.isEnabled = isRunToCursorEnabled
+ menu.insertItem(.separator(), at: 0)
+ menu.insertItem(runToCursor, at: 0)
+ }
guard !languageItems.isEmpty else { return menu }
menu.insertItem(.separator(), at: 0)
for item in languageItems.reversed() { menu.insertItem(item, at: 0) }
@@ -2895,6 +3389,11 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate {
return languageItems
}
+ @objc private func runToCursorFromMenu() {
+ let position = languageServerPosition(at: selectedRange().location)
+ onRunToCursor?(position.line, position.utf16Column)
+ }
+
@objc private func goToDefinitionFromMenu() {
onGoToDefinition?()
}
@@ -3206,12 +3705,26 @@ final class LineNumberGutterView: NSView {
private var implementationMarkers: [JavaImplementationMarker] = []
private var onSelectImplementation: ((JavaImplementationMarker) -> Void)?
private var debugBreakpointLines: Set = []
+ private var debugBreakpointStatesByLine: [Int: EditorDebugBreakpointState] = [:]
+ private var debugBreakpointMessagesByLine: [Int: String] = [:]
+ private var currentExecutionLine: Int?
private var onToggleDebugBreakpoint: ((Int) -> Void)?
+ private var onEditDebugBreakpoint: ((Int) -> Void)?
+ private var onRemoveDebugBreakpoint: ((Int) -> Void)?
+ private var onSetDebugBreakpointEnabled: ((Int, Bool) -> Void)?
+ private var onToggleAllDebugBreakpoints: (() -> Void)?
+ private var onRunToCursor: ((Int) -> Void)?
+ private var canAddDebugBreakpoint: ((Int) -> Bool)?
+ private var isRunToCursorEnabled = false
+ private var areBreakpointsMuted = false
+ private var contextGutterLine: Int?
+ private var contextDebugBreakpointLine: Int?
private var scrollRefreshScheduled = false
private var hoveredFoldID: String?
private var foldIndicatorOpacities: [String: CGFloat] = [:]
private var foldIndicatorAnimationTimer: Timer?
private var trackingArea: NSTrackingArea?
+ private var hoveredDebugBreakpointLine: Int?
private var palette = CodeEditorPalette.dark
private var gitLineChangeMarkersByLine: [Int: GitLineChangeMarker] = [:]
private var onShowGitLineChange: ((GitLineChangeMarker) -> Void)?
@@ -3362,14 +3875,44 @@ final class LineNumberGutterView: NSView {
}
func updateDebugBreakpointLines(
- _ lines: Set,
- onToggle: @escaping (Int) -> Void
+ _ states: [Int: EditorDebugBreakpointState],
+ onToggle: @escaping (Int) -> Void,
+ canAdd: ((Int) -> Bool)? = nil,
+ onEdit: ((Int) -> Void)? = nil,
+ onRemove: ((Int) -> Void)? = nil,
+ onSetEnabled: ((Int, Bool) -> Void)? = nil,
+ onToggleAll: (() -> Void)? = nil,
+ onRunToCursor: ((Int) -> Void)? = nil,
+ isRunToCursorEnabled: Bool = false,
+ areBreakpointsMuted: Bool = false
) {
- debugBreakpointLines = Set(lines.map { max(0, $0 - 1) })
+ debugBreakpointLines = Set(states.keys.map { max(0, $0 - 1) })
+ debugBreakpointStatesByLine = Dictionary(
+ uniqueKeysWithValues: states.map { (max(0, $0.key - 1), $0.value) }
+ )
onToggleDebugBreakpoint = onToggle
+ canAddDebugBreakpoint = canAdd
+ onEditDebugBreakpoint = onEdit
+ onRemoveDebugBreakpoint = onRemove
+ onSetDebugBreakpointEnabled = onSetEnabled
+ onToggleAllDebugBreakpoints = onToggleAll
+ self.onRunToCursor = onRunToCursor
+ self.isRunToCursorEnabled = isRunToCursorEnabled
+ self.areBreakpointsMuted = areBreakpointsMuted
needsDisplay = true
}
+ func updateCurrentExecutionLine(_ line: Int?) {
+ currentExecutionLine = line.map { max(0, $0 - 1) }
+ needsDisplay = true
+ }
+
+ func updateDebugBreakpointMessages(_ messages: [Int: String]) {
+ debugBreakpointMessagesByLine = messages.reduce(into: [:]) {
+ $0[max(0, $1.key - 1)] = $1.value
+ }
+ }
+
func updateGitLineChanges(
_ markers: [GitLineChangeMarker],
onShow: @escaping (GitLineChangeMarker) -> Void,
@@ -3577,13 +4120,33 @@ final class LineNumberGutterView: NSView {
palette.currentLine.setFill()
NSRect(x: 0, y: y, width: bounds.width, height: lineRect.height).fill()
}
+ if !isBlameVisible,
+ hoveredDebugBreakpointLine == lineNumber - 1 {
+ // Keep the hover affordance attached to the forgiving IDEA-style
+ // breakpoint hit target, not only to the 14 px marker column.
+ palette.foldHover.withAlphaComponent(0.7).setFill()
+ NSRect(
+ x: editorGutterOriginX + gutterLayout.breakpointInteractionRange.lowerBound,
+ y: y,
+ width: EditorGutterLayout.width(of: gutterLayout.breakpointInteractionRange),
+ height: lineRect.height
+ ).fill()
+ }
+ if currentExecutionLine == lineNumber - 1 {
+ palette.executionLine.setFill()
+ NSRect(x: 0, y: y, width: bounds.width, height: lineRect.height).fill()
+ }
if isBlameVisible,
let blame = blameByLine[lineNumber - 1],
showsBlameMetadata(line: lineNumber - 1, firstVisibleLine: firstLine) {
drawBlame(blame, y: y, height: lineRect.height)
}
- if !isBlameVisible, debugBreakpointLines.contains(lineNumber - 1) {
- drawDebugBreakpoint(y: y, height: lineRect.height)
+ if !isBlameVisible, let state = debugBreakpointStatesByLine[lineNumber - 1] {
+ drawDebugBreakpoint(y: y, height: lineRect.height, state: state)
+ } else if !isBlameVisible,
+ hoveredDebugBreakpointLine == lineNumber - 1,
+ canAddDebugBreakpoint?(lineNumber - 1) == true {
+ drawDebugBreakpointHover(y: y, height: lineRect.height)
} else {
let markers = implementationMarkers.filter { $0.line == lineNumber - 1 }
for marker in markers {
@@ -3595,6 +4158,11 @@ final class LineNumberGutterView: NSView {
)
}
}
+ // Draw the current execution marker after the breakpoint marker so
+ // a stopped frame remains visually dominant when both share a line.
+ if currentExecutionLine == lineNumber - 1 {
+ drawCurrentExecutionLine(y: y, height: lineRect.height)
+ }
if let marker = gitLineChangeMarkersByLine[lineNumber - 1] {
drawGitLineChange(marker, y: y, height: lineRect.height)
}
@@ -3662,9 +4230,18 @@ final class LineNumberGutterView: NSView {
private func drawLineNumber(_ number: Int, y: CGFloat, height: CGFloat) {
let label = String(number) as NSString
let editorFont = textView?.font ?? LitheTheme.editorFont(size: 13)
+ let isExecutionLine = currentExecutionLine == number - 1
+ let isBreakpointLine = debugBreakpointStatesByLine[number - 1] != nil
let attributes: [NSAttributedString.Key: Any] = [
- .font: EditorGutterLayout.lineNumberFont(for: editorFont),
- .foregroundColor: palette.lineNumber
+ .font: isExecutionLine
+ ? LitheTheme.editorFont(
+ size: max(8, editorFont.pointSize - 1),
+ weight: .semibold
+ )
+ : EditorGutterLayout.lineNumberFont(for: editorFont),
+ .foregroundColor: isExecutionLine
+ ? palette.link
+ : (isBreakpointLine ? palette.text : palette.lineNumber)
]
let size = label.size(withAttributes: attributes)
let centeredY = y + max(0, (height - size.height) / 2)
@@ -3753,18 +4330,87 @@ final class LineNumberGutterView: NSView {
path.stroke()
}
- private func drawDebugBreakpoint(y: CGFloat, height: CGFloat) {
- let markerSize: CGFloat = 9
- NSColor(red: 0.92, green: 0.28, blue: 0.30, alpha: 0.96).setFill()
- NSBezierPath(
- ovalIn: NSRect(
- x: editorGutterOriginX + gutterLayout.breakpointRange.lowerBound
- + (EditorGutterLayout.width(of: gutterLayout.breakpointRange) - markerSize) / 2,
- y: y + max(0, (height - markerSize) / 2),
- width: markerSize,
- height: markerSize
- )
- ).fill()
+ private func drawDebugBreakpoint(
+ y: CGFloat,
+ height: CGFloat,
+ state: EditorDebugBreakpointState
+ ) {
+ let markerSize = EditorDebugBreakpointAppearance.markerSize
+ let rect = NSRect(
+ x: editorGutterOriginX + gutterLayout.breakpointRange.lowerBound
+ + (EditorGutterLayout.width(of: gutterLayout.breakpointRange) - markerSize) / 2,
+ y: y + max(0, (height - markerSize) / 2),
+ width: markerSize,
+ height: markerSize
+ )
+ let breakpointAsset = LitheIcons.debuggerBreakpointAssetPath(
+ enabled: state.enabled,
+ verified: state.verified,
+ muted: areBreakpointsMuted
+ )
+ let themedAsset = effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua
+ ? LitheIcons.darkIdeaAssetPath(for: breakpointAsset)
+ : breakpointAsset
+ if let image = LitheIcons.ideaImage(resourcePath: themedAsset)
+ ?? LitheIcons.ideaImage(resourcePath: breakpointAsset) {
+ image.draw(in: rect, from: .zero, operation: .sourceOver, fraction: 1)
+ return
+ }
+
+ // Keep a local fallback for an unbundled preview or a damaged asset.
+ let path = NSBezierPath(ovalIn: rect)
+ let isInactive = areBreakpointsMuted || !state.enabled
+ EditorDebugBreakpointAppearance.enabledColor
+ .withAlphaComponent(isInactive ? 0.42 : 1)
+ .setFill()
+ path.fill()
+ }
+
+ private func drawDebugBreakpointHover(y: CGFloat, height: CGFloat) {
+ let markerSize = EditorDebugBreakpointAppearance.markerSize
+ let rect = NSRect(
+ x: editorGutterOriginX + gutterLayout.breakpointRange.lowerBound
+ + (EditorGutterLayout.width(of: gutterLayout.breakpointRange) - markerSize) / 2,
+ y: y + max(0, (height - markerSize) / 2),
+ width: markerSize,
+ height: markerSize
+ )
+ let breakpointAsset = effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua
+ ? LitheIcons.darkIdeaAssetPath(for: "debugger/db_set_breakpoint.svg")
+ : "debugger/db_set_breakpoint.svg"
+ if let image = LitheIcons.ideaImage(resourcePath: breakpointAsset)
+ ?? LitheIcons.ideaImage(resourcePath: "debugger/db_set_breakpoint.svg") {
+ image.draw(in: rect, from: .zero, operation: .sourceOver, fraction: 0.82)
+ } else {
+ EditorDebugBreakpointAppearance.enabledColor.withAlphaComponent(0.82).setFill()
+ NSBezierPath(ovalIn: rect).fill()
+ }
+ }
+
+ private func drawCurrentExecutionLine(y: CGFloat, height: CGFloat) {
+ let markerSize: CGFloat = 14
+ let rect = NSRect(
+ // Keep the execution arrow in the right edge of the line-number
+ // column so a breakpoint on the same line remains visible. IDEA
+ // uses two distinct gutter signals for these states.
+ x: editorGutterOriginX + gutterLayout.lineNumberRange.upperBound - markerSize,
+ y: y + max(0, (height - markerSize) / 2),
+ width: markerSize,
+ height: markerSize
+ )
+ if let image = LitheIcons.ideaImage(resourcePath: "debugger/threadCurrent.svg") {
+ image.draw(in: rect, from: .zero, operation: .sourceOver, fraction: 1)
+ return
+ }
+ let centerY = y + height / 2
+ let left = rect.minX + 2
+ let path = NSBezierPath()
+ path.move(to: NSPoint(x: left, y: centerY))
+ path.line(to: NSPoint(x: left + 10, y: centerY - 5))
+ path.line(to: NSPoint(x: left + 10, y: centerY + 5))
+ path.close()
+ NSColor(calibratedRed: 0.32, green: 0.64, blue: 1, alpha: 1).setFill()
+ path.fill()
}
private func drawGitLineChange(
@@ -3822,15 +4468,25 @@ final class LineNumberGutterView: NSView {
override func mouseEntered(with event: NSEvent) {
super.mouseEntered(with: event)
- updateFoldHover(at: convert(event.locationInWindow, from: nil))
+ let point = convert(event.locationInWindow, from: nil)
+ updateFoldHover(at: point)
+ updateBreakpointHover(at: point)
+ updateBreakpointToolTip(at: point)
}
override func mouseMoved(with event: NSEvent) {
super.mouseMoved(with: event)
let point = convert(event.locationInWindow, from: nil)
updateFoldHover(at: point)
- if foldRegion(at: point) != nil {
+ updateBreakpointHover(at: point)
+ updateBreakpointToolTip(at: point)
+ if foldRegion(at: point) != nil || isBreakpointTarget(at: point) {
NSCursor.pointingHand.set()
+ } else {
+ // Tracking events do not always trigger `cursorUpdate` when the
+ // pointer moves between gutter columns. Reset explicitly so a
+ // stale pointing-hand cursor cannot leak into the editor.
+ NSCursor.arrow.set()
}
}
@@ -3840,12 +4496,66 @@ final class LineNumberGutterView: NSView {
NSCursor.pointingHand.set()
return
}
+ if isBreakpointTarget(at: point) {
+ NSCursor.pointingHand.set()
+ return
+ }
super.cursorUpdate(with: event)
}
override func mouseExited(with event: NSEvent) {
super.mouseExited(with: event)
updateFoldHover(at: nil)
+ updateBreakpointHover(at: nil)
+ toolTip = nil
+ }
+
+ private func updateBreakpointHover(at point: NSPoint?) {
+ let nextLine = point.flatMap { point -> Int? in
+ guard !isBlameVisible,
+ isBreakpointTarget(at: point),
+ let line = editorLine(at: point),
+ canAddDebugBreakpoint?(line) == true else { return nil }
+ return line
+ }
+ guard hoveredDebugBreakpointLine != nextLine else { return }
+ hoveredDebugBreakpointLine = nextLine
+ needsDisplay = true
+ }
+
+ private func isBreakpointTarget(at point: NSPoint) -> Bool {
+ let localX = point.x - editorGutterOriginX
+ guard gutterLayout.breakpointInteractionRange.contains(localX),
+ let line = editorLine(at: point) else { return false }
+ if debugBreakpointStatesByLine[line] != nil { return true }
+ return canAddDebugBreakpoint?(line) == true
+ }
+
+ private func updateBreakpointToolTip(at point: NSPoint) {
+ let localX = point.x - editorGutterOriginX
+ guard gutterLayout.breakpointInteractionRange.contains(localX),
+ let line = editorLine(at: point) else {
+ toolTip = nil
+ return
+ }
+ if let state = debugBreakpointStatesByLine[line] {
+ let stateLabel: String
+ if !state.enabled {
+ stateLabel = "Breakpoint disabled"
+ } else {
+ stateLabel = state.verified ? "Breakpoint verified" : "Breakpoint not verified"
+ }
+ let detail = debugBreakpointMessagesByLine[line].map { " — \($0)" } ?? ""
+ toolTip = "Line \(line + 1): \(stateLabel)\(detail)"
+ return
+ }
+ guard canAddDebugBreakpoint?(line) == true else {
+ // A tooltip here is intentional: it explains why the same gutter
+ // gesture works on a method line but not on a comment or brace.
+ toolTip = "Line \(line + 1): Cannot set a Java breakpoint here"
+ return
+ }
+ toolTip = "Line \(line + 1): Click to set breakpoint"
}
private func updateFoldHover(at point: NSPoint?) {
@@ -3932,6 +4642,10 @@ final class LineNumberGutterView: NSView {
for: NSPoint(x: textView.textContainerInset.width, y: documentY),
in: textContainer
)
+ guard glyphIndex < layoutManager.numberOfGlyphs else {
+ super.mouseDown(with: event)
+ return
+ }
let characterIndex = layoutManager.characterIndexForGlyph(at: glyphIndex)
let source = textView.string as NSString
let line = (textView as? CodeTextView)?.lineNumber(at: characterIndex, in: source)
@@ -3942,6 +4656,10 @@ final class LineNumberGutterView: NSView {
}
return
}
+ if !isBlameVisible, isBreakpointTarget(at: point) {
+ onToggleDebugBreakpoint?(line)
+ return
+ }
let gitMarker = gitLineChangeMarkersByLine[line]
let localX = point.x - editorGutterOriginX
switch gutterLayout.hitTarget(at: localX, hasGitChange: gitMarker != nil) {
@@ -3961,8 +4679,6 @@ final class LineNumberGutterView: NSView {
guard let marker = markers.first(where: { $0.direction == preferredDirection })
?? markers.first else { return }
onSelectImplementation?(marker)
- case .breakpoint where !isBlameVisible:
- onToggleDebugBreakpoint?(line)
case .lineNumber, .breakpoint, nil:
textView.window?.makeFirstResponder(textView)
textView.setSelectedRange(NSRange(location: characterIndex, length: 0))
@@ -3972,6 +4688,25 @@ final class LineNumberGutterView: NSView {
override func menu(for event: NSEvent) -> NSMenu? {
let point = convert(event.locationInWindow, from: nil)
let localX = point.x - editorGutterOriginX
+ if gutterLayout.breakpointInteractionRange.contains(localX),
+ let line = editorLine(at: point) {
+ return debugBreakpointContextMenu(forLine: line)
+ }
+ if gutterLayout.lineNumberRange.contains(localX),
+ let line = editorLine(at: point),
+ onRunToCursor != nil {
+ contextGutterLine = line
+ let menu = NSMenu(title: "Editor Line")
+ let item = NSMenuItem(
+ title: "Run to Cursor",
+ action: #selector(runToCursorFromGutterMenu),
+ keyEquivalent: ""
+ )
+ item.target = self
+ item.isEnabled = isRunToCursorEnabled
+ menu.addItem(item)
+ return menu
+ }
guard gutterLayout.gitChangeRange.contains(localX),
let line = editorLine(at: point),
let marker = gitLineChangeMarkersByLine[line] else {
@@ -3997,6 +4732,80 @@ final class LineNumberGutterView: NSView {
return menu
}
+ func debugBreakpointContextMenu(forLine line: Int) -> NSMenu? {
+ contextDebugBreakpointLine = line
+ guard let state = debugBreakpointStatesByLine[line] else {
+ guard canAddDebugBreakpoint?(line) == true else { return nil }
+ let menu = NSMenu(title: "Breakpoint")
+ menu.addItem(
+ withTitle: "Set Breakpoint",
+ action: #selector(addDebugBreakpointFromMenu),
+ keyEquivalent: ""
+ )
+ menu.items.last?.target = self
+ return menu
+ }
+ let menu = NSMenu(title: "Breakpoint")
+ if onEditDebugBreakpoint != nil {
+ menu.addItem(
+ withTitle: "Edit Breakpoint…",
+ action: #selector(editDebugBreakpointFromMenu),
+ keyEquivalent: ""
+ )
+ menu.items.last?.target = self
+ }
+ let toggleTitle = state.enabled ? "Disable Breakpoint" : "Enable Breakpoint"
+ menu.addItem(
+ withTitle: toggleTitle,
+ action: #selector(toggleDebugBreakpointFromMenu),
+ keyEquivalent: ""
+ )
+ menu.items.last?.target = self
+ menu.addItem(
+ withTitle: "Remove Breakpoint",
+ action: #selector(removeDebugBreakpointFromMenu),
+ keyEquivalent: ""
+ )
+ menu.items.last?.target = self
+ if onToggleAllDebugBreakpoints != nil {
+ menu.addItem(.separator())
+ menu.addItem(
+ withTitle: areBreakpointsMuted
+ ? "Unmute All Breakpoints" : "Mute All Breakpoints",
+ action: #selector(toggleAllDebugBreakpointsFromMenu),
+ keyEquivalent: ""
+ )
+ menu.items.last?.target = self
+ }
+ return menu
+ }
+
+ @objc func editDebugBreakpointFromMenu() {
+ if let line = contextDebugBreakpointLine { onEditDebugBreakpoint?(line) }
+ }
+
+ @objc func addDebugBreakpointFromMenu() {
+ if let line = contextDebugBreakpointLine { onToggleDebugBreakpoint?(line) }
+ }
+
+ @objc private func toggleDebugBreakpointFromMenu() {
+ guard let line = contextDebugBreakpointLine,
+ let state = debugBreakpointStatesByLine[line] else { return }
+ onSetDebugBreakpointEnabled?(line, !state.enabled)
+ }
+
+ @objc private func removeDebugBreakpointFromMenu() {
+ if let line = contextDebugBreakpointLine { onRemoveDebugBreakpoint?(line) }
+ }
+
+ @objc private func toggleAllDebugBreakpointsFromMenu() {
+ onToggleAllDebugBreakpoints?()
+ }
+
+ @objc private func runToCursorFromGutterMenu() {
+ if let contextGutterLine { onRunToCursor?(contextGutterLine) }
+ }
+
private func editorLine(at point: NSPoint) -> Int? {
guard let textView,
let scrollView,
@@ -4222,6 +5031,103 @@ final class CodeVisionOverlayController {
}
}
+@MainActor
+final class DebugInlineValueOverlayController {
+ private weak var textView: NSTextView?
+ private var label: NSTextField?
+ private(set) var renderedText: String?
+ private(set) var renderedFrame: NSRect?
+
+ init(textView: NSTextView) {
+ self.textView = textView
+ }
+
+ func update(line: Int?, values: [EditorInlineDebugValue]) {
+ label?.removeFromSuperview()
+ label = nil
+ renderedText = nil
+ renderedFrame = nil
+ guard let line,
+ !values.isEmpty,
+ let textView,
+ let layoutManager = textView.layoutManager,
+ let textContainer = textView.textContainer else { return }
+ layoutManager.ensureLayout(for: textContainer)
+ let source = textView.string as NSString
+ let lineStart = characterOffset(forLine: line, in: source)
+ guard lineStart < source.length else { return }
+ let lineRange = source.lineRange(for: NSRange(location: lineStart, length: 0))
+ var contentEnd = NSMaxRange(lineRange)
+ while contentEnd > lineRange.location {
+ let character = source.character(at: contentEnd - 1)
+ guard character == 10 || character == 13 else { break }
+ contentEnd -= 1
+ }
+ guard contentEnd > lineRange.location else { return }
+ let lastCharacter = max(lineRange.location, contentEnd - 1)
+ let lastGlyph = layoutManager.glyphIndexForCharacter(at: lastCharacter)
+ var visualLineGlyphRange = NSRange()
+ let lineRect = layoutManager.lineFragmentRect(
+ forGlyphAt: lastGlyph,
+ effectiveRange: &visualLineGlyphRange
+ )
+ let contentGlyphRange = layoutManager.glyphRange(
+ forCharacterRange: NSRange(
+ location: lineRange.location,
+ length: contentEnd - lineRange.location
+ ),
+ actualCharacterRange: nil
+ )
+ let visibleContentRange = NSIntersectionRange(contentGlyphRange, visualLineGlyphRange)
+ guard visibleContentRange.length > 0 else { return }
+ let contentRect = layoutManager.boundingRect(
+ forGlyphRange: visibleContentRange,
+ in: textContainer
+ )
+ let text = values.map { "\($0.name) = \($0.value)" }.joined(separator: " ")
+ let label = DebugInlineValueLabel(labelWithString: text)
+ label.font = .monospacedSystemFont(ofSize: 11, weight: .regular)
+ label.textColor = NSColor.secondaryLabelColor.withAlphaComponent(0.82)
+ label.lineBreakMode = .byTruncatingTail
+ label.maximumNumberOfLines = 1
+ label.toolTip = text
+ label.sizeToFit()
+ let originX = textView.textContainerOrigin.x + contentRect.maxX + 12
+ let availableWidth = max(0, textView.bounds.width - originX - 12)
+ guard availableWidth >= 24 else { return }
+ let height = max(16, label.fittingSize.height)
+ label.frame = NSRect(
+ x: originX,
+ y: textView.textContainerOrigin.y + lineRect.midY - height / 2,
+ width: min(label.fittingSize.width, availableWidth),
+ height: height
+ )
+ label.isSelectable = false
+ label.isEditable = false
+ textView.addSubview(label)
+ self.label = label
+ renderedText = text
+ renderedFrame = label.frame
+ }
+
+ private func characterOffset(forLine line: Int, in source: NSString) -> Int {
+ if let codeTextView = textView as? CodeTextView {
+ return codeTextView.characterOffset(forLine: line, in: source)
+ }
+ var currentLine = 0
+ var location = 0
+ while currentLine < line, location < source.length {
+ location = NSMaxRange(source.lineRange(for: NSRange(location: location, length: 0)))
+ currentLine += 1
+ }
+ return location
+ }
+}
+
+private final class DebugInlineValueLabel: NSTextField {
+ override func hitTest(_ point: NSPoint) -> NSView? { nil }
+}
+
@MainActor
private final class ClosureButton: NSButton {
private let handler: () -> Void
diff --git a/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift b/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift
index 3df8514f8..099598488 100644
--- a/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift
+++ b/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift
@@ -994,7 +994,6 @@ struct EditorAreaView: View {
if let document {
CodeEditorView(
document: document,
- debugService: model.debugFeatureIfActive,
shouldFocus: !showsHeader && document.id == model.activeDocumentID,
viewportStore: editorViewportStore
)
@@ -1160,7 +1159,6 @@ struct EditorAreaView: View {
) -> some View {
CodeEditorView(
document: document,
- debugService: model.debugFeatureIfActive,
shouldFocus: true,
markdownScrollPosition: markdownScrollPosition,
viewportStore: editorViewportStore
diff --git a/macos/Sources/Lithe/Views/Language/LanguageTestsView.swift b/macos/Sources/Lithe/Views/Language/LanguageTestsView.swift
index 6a126e80e..1d6f8460a 100644
--- a/macos/Sources/Lithe/Views/Language/LanguageTestsView.swift
+++ b/macos/Sources/Lithe/Views/Language/LanguageTestsView.swift
@@ -139,7 +139,9 @@ struct LanguageTestsView: View {
}
private var testCount: Int {
- service.itemsByProviderID.values.reduce(0) { $0 + $1.count }
+ service.itemsByProviderID.values.reduce(0) { count, items in
+ count + itemCount(items)
+ }
}
private var selectedItem: LanguageTestItem? {
@@ -206,7 +208,7 @@ struct LanguageTestsView: View {
Text(descriptor.displayName)
.lineLimit(1)
Spacer(minLength: 0)
- Text(String(items.count))
+ Text(String(itemCount(items)))
.font(.system(size: 10))
.foregroundStyle(LitheTheme.secondaryText)
}
@@ -233,16 +235,16 @@ struct LanguageTestsView: View {
selectedItemID = item.id
} label: {
HStack(spacing: 7) {
- Image(systemName: item.kind == .workspace ? "square.stack.3d.up" : "doc.text.magnifyingglass")
+ Image(systemName: testItemIcon(item))
.font(.system(size: 11))
- .foregroundStyle(item.kind == .workspace ? LitheTheme.accent : LitheTheme.secondaryText)
+ .foregroundStyle(item.depth > 0 ? LitheTheme.accent : LitheTheme.secondaryText)
.frame(width: 16)
Text(item.label)
.font(.system(size: 11.5))
.lineLimit(1)
Spacer(minLength: 0)
}
- .padding(.leading, 25)
+ .padding(.leading, 25 + CGFloat(item.depth * 14))
.padding(.trailing, 6)
.frame(height: 26)
.contentShape(Rectangle())
@@ -301,7 +303,7 @@ struct LanguageTestsView: View {
private func testDetail(_ item: LanguageTestItem) -> some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 10) {
- Image(systemName: item.kind == .workspace ? "square.stack.3d.up" : "doc.text.magnifyingglass")
+ Image(systemName: testItemIcon(item))
.font(.system(size: 20))
.foregroundStyle(LitheTheme.accent)
.frame(width: 34, height: 34)
@@ -324,6 +326,17 @@ struct LanguageTestsView: View {
.buttonStyle(.borderedProminent)
.controlSize(.small)
.disabled(service.isRunning)
+
+ if item.providerID == "java", item.kind != .workspace {
+ Button {
+ model.debugTest(providerID: item.providerID, scope: scope(for: item))
+ } label: {
+ Label("Debug", systemImage: "ladybug.fill")
+ }
+ .buttonStyle(.bordered)
+ .controlSize(.small)
+ .disabled(service.isRunning)
+ }
}
Rectangle()
@@ -335,7 +348,7 @@ struct LanguageTestsView: View {
.font(.system(size: 10.5, weight: .medium))
.foregroundStyle(LitheTheme.secondaryText)
.frame(width: 90, alignment: .trailing)
- Text(item.kind == .workspace ? "Workspace" : (item.fileURL?.path ?? item.label))
+ Text(scopeDescription(item))
.font(.system(size: 11.5, design: .monospaced))
.textSelection(.enabled)
.lineLimit(1)
@@ -368,7 +381,34 @@ struct LanguageTestsView: View {
case .file:
return .file(item.fileURL ?? model.workspaceURL ?? URL(fileURLWithPath: "."))
case .testCase:
- return .testCase(identifier: item.label, fileURL: item.fileURL)
+ return .testCase(
+ identifier: item.testIdentifier ?? item.label,
+ fileURL: item.fileURL
+ )
+ }
+ }
+
+ private func itemCount(_ items: [LanguageTestItem]) -> Int {
+ let exactTests = items.filter { $0.kind == .testCase }.count
+ return exactTests > 0 ? exactTests : items.filter { $0.kind != .workspace }.count
+ }
+
+ private func testItemIcon(_ item: LanguageTestItem) -> String {
+ switch item.kind {
+ case .workspace: "square.stack.3d.up"
+ case .file: "doc.text.magnifyingglass"
+ case .testCase: item.depth > 1 ? "function" : "cube"
+ }
+ }
+
+ private func scopeDescription(_ item: LanguageTestItem) -> String {
+ switch item.kind {
+ case .workspace:
+ return "Workspace"
+ case .file:
+ return item.fileURL?.path ?? item.label
+ case .testCase:
+ return item.testIdentifier ?? item.label
}
}
diff --git a/macos/Sources/Lithe/Views/Run/RunView.swift b/macos/Sources/Lithe/Views/Run/RunView.swift
index e5aedac77..f4b238db8 100644
--- a/macos/Sources/Lithe/Views/Run/RunView.swift
+++ b/macos/Sources/Lithe/Views/Run/RunView.swift
@@ -180,6 +180,12 @@ struct RunView: View {
)
}
switch feature.generationState {
+ case .projectNotReady:
+ return (
+ String(localized: "Project is still loading"),
+ String(localized: "Wait for the project to finish loading, then identify it again."),
+ "clock.fill"
+ )
case .succeeded(let entryCount):
return (
String(localized: "Project identification complete"),
@@ -201,6 +207,12 @@ struct RunView: View {
return (String(localized: "Project identification failed"), message, "xmark.octagon.fill")
case .idle:
return nil
+ case .projectNotReady:
+ return (
+ String(localized: "Project is still loading"),
+ String(localized: "Wait for the workspace scan to finish, then identify the project again."),
+ "hourglass"
+ )
}
}
@@ -526,6 +538,7 @@ struct RunView: View {
onToggle: nil
) {
selectedSessionID = nil
+ model.selectRunConfiguration(.currentFile)
}
ForEach(RunConfigurationExecution.displayOrder, id: \.self) { execution in
@@ -595,12 +608,14 @@ struct RunView: View {
if let session, session.isRunning {
feature.stopModule(session)
} else {
+ model.selectRunConfiguration(configuration)
model.startRunConfiguration(configuration)
selectedSessionID = configuration.id
}
}
) {
selectedSessionID = configuration.id
+ model.selectRunConfiguration(configuration)
}
}
diff --git a/macos/Sources/Lithe/Views/Terminal/TerminalView.swift b/macos/Sources/Lithe/Views/Terminal/TerminalView.swift
index 0df956a47..be8a52005 100644
--- a/macos/Sources/Lithe/Views/Terminal/TerminalView.swift
+++ b/macos/Sources/Lithe/Views/Terminal/TerminalView.swift
@@ -84,6 +84,7 @@ struct TerminalView: View {
session.restart()
session.focus()
}
+ .disabled(session.isManagedProcess)
Button("Clear", action: session.clear)
Divider()
Button("Move to Editor") {
diff --git a/macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift b/macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift
index e5ae9e404..9fbbf5c6d 100644
--- a/macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift
+++ b/macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift
@@ -133,15 +133,10 @@ enum WorkbenchModuleUIComposition {
isVisible: { _ in true },
isSelected: { $0.isDebugVisible },
content: { model in
- if model.prefersGenericDebugUI,
- let feature = model.genericDebugFeatureIfActive {
- return AnyView(GenericDebugView(feature: feature))
- }
- guard let feature = model.debugFeatureIfActive,
- let runFeature = model.runFeatureIfActive else {
+ guard let feature = model.genericDebugFeatureIfActive else {
return AnyView(WorkbenchModuleUIRegistry.moduleLoadingView)
}
- return AnyView(JavaDebugView(feature: feature, runFeature: runFeature))
+ return AnyView(GenericDebugView(feature: feature))
}
)
]
diff --git a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift
index d40a82f94..9e3365bb3 100644
--- a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift
+++ b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift
@@ -24,46 +24,6 @@ private enum WorkbenchWorkspaceMetrics {
static let paneCornerRadius: CGFloat = 10
}
-private enum WorkbenchPopoverLayoutMetrics {
- static let leadingOverlap: CGFloat = 10
- static let viewportMargin: CGFloat = 8
- static let arrowWidth: CGFloat = 22
- static let arrowHeight: CGFloat = 12
-}
-
-private struct WorkbenchPopoverArrow: Shape {
- func path(in rect: CGRect) -> Path {
- var path = Path()
- path.move(to: CGPoint(x: rect.minX, y: rect.maxY))
- path.addLine(to: CGPoint(x: rect.midX, y: rect.minY))
- path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
- path.closeSubpath()
- return path
- }
-}
-
-private struct ProjectSwitcherButtonBoundsPreferenceKey: PreferenceKey {
- static var defaultValue: Anchor?
-
- static func reduce(
- value: inout Anchor?,
- nextValue: () -> Anchor?
- ) {
- value = nextValue() ?? value
- }
-}
-
-private struct BranchSwitcherButtonBoundsPreferenceKey: PreferenceKey {
- static var defaultValue: Anchor?
-
- static func reduce(
- value: inout Anchor?,
- nextValue: () -> Anchor?
- ) {
- value = nextValue() ?? value
- }
-}
-
struct WorkbenchView: View {
private let moduleUIRegistry = WorkbenchModuleUIComposition.builtIn
@EnvironmentObject private var model: AppModel
@@ -128,6 +88,25 @@ struct WorkbenchView: View {
}
}
}
+ .sheet(item: $model.debugBreakpointPresentation.pendingEditor) { breakpoint in
+ BreakpointEditorView(breakpoint: breakpoint) { value in
+ model.updateDebugBreakpoint(
+ breakpoint,
+ enabled: value.enabled,
+ condition: value.condition,
+ hitCondition: value.hitCondition,
+ logMessage: value.logMessage
+ )
+ }
+ }
+ .sheet(isPresented: $model.debugBreakpointPresentation.isManagerPresented) {
+ if let feature = model.genericDebugFeatureIfActive {
+ DebugBreakpointManagerDialog(feature: feature)
+ } else {
+ ProgressView("Loading breakpoints…")
+ .frame(minWidth: 640, minHeight: 420)
+ }
+ }
.onAppear {
updateWorkbenchBackgroundImage(model.workbenchBackgroundFeature.imageData)
}
@@ -255,34 +234,26 @@ struct WorkbenchView: View {
} message: {
Text(model.pendingDiscardHunk?.change.path ?? "This action cannot be undone by Lithe.")
}
- .sheet(item: $pendingTopBarPushReference) { reference in
- GitPushDialog(
- projectName: model.projectName,
- reference: reference,
- onPush: {
- Task { await model.pushBranch(reference) }
- }
- )
- }
- .overlayPreferenceValue(ProjectSwitcherButtonBoundsPreferenceKey.self) { bounds in
- GeometryReader { geometry in
- if isProjectSwitcherPresented, let bounds {
- projectSwitcherOverlay(
- buttonFrame: geometry[bounds],
- viewportSize: geometry.size
- )
- }
+ .confirmationDialog(
+ "Push '\(pendingTopBarPushReference?.shortName ?? "")'?",
+ isPresented: Binding(
+ get: { pendingTopBarPushReference != nil },
+ set: { if !$0 { pendingTopBarPushReference = nil } }
+ ),
+ titleVisibility: .visible
+ ) {
+ Button("Push") {
+ guard let reference = pendingTopBarPushReference else { return }
+ pendingTopBarPushReference = nil
+ Task { await model.pushBranch(reference) }
}
- }
- .overlayPreferenceValue(BranchSwitcherButtonBoundsPreferenceKey.self) { bounds in
- GeometryReader { geometry in
- if isBranchSwitcherPresented, let bounds {
- branchSwitcherOverlay(
- buttonFrame: geometry[bounds],
- viewportSize: geometry.size
- )
- }
+ .lithePointer()
+ Button("Cancel", role: .cancel) {
+ pendingTopBarPushReference = nil
}
+ .lithePointer()
+ } message: {
+ Text("This sends the current branch to its configured remote.")
}
.overlay(alignment: .bottom) {
if let message = model.notificationMessage {
@@ -435,10 +406,7 @@ struct WorkbenchView: View {
private var topBar: some View {
HStack(spacing: 9) {
Button {
- updateSwitcherPresentation(
- project: !isProjectSwitcherPresented,
- branch: false
- )
+ isProjectSwitcherPresented.toggle()
} label: {
HStack(spacing: 8) {
LitheLogo(size: 24)
@@ -463,10 +431,28 @@ struct WorkbenchView: View {
.buttonStyle(.plain)
.lithePointer()
.accessibilityIdentifier("project-switcher-\(model.id.uuidString)")
- .anchorPreference(
- key: ProjectSwitcherButtonBoundsPreferenceKey.self,
- value: .bounds
- ) { $0 }
+ .popover(isPresented: $isProjectSwitcherPresented, arrowEdge: .bottom) {
+ ProjectSwitcherPopover(
+ isPresented: $isProjectSwitcherPresented,
+ onNewProject: {
+ isProjectSwitcherPresented = false
+ model.chooseProject(title: "New Project", prompt: "Choose Folder")
+ },
+ onOpenProject: {
+ isProjectSwitcherPresented = false
+ model.chooseProject()
+ },
+ onCloneRepository: {
+ isProjectSwitcherPresented = false
+ model.showCloneRepository()
+ },
+ onOpenRecentProject: { project in
+ isProjectSwitcherPresented = false
+ model.openProject(project.url)
+ }
+ )
+ .environmentObject(model)
+ }
Rectangle()
.fill(LitheTheme.divider)
@@ -474,10 +460,7 @@ struct WorkbenchView: View {
.padding(.horizontal, 5)
Button {
- updateSwitcherPresentation(
- project: false,
- branch: !isBranchSwitcherPresented
- )
+ isBranchSwitcherPresented.toggle()
if isBranchSwitcherPresented {
Task { await model.refreshGitHistory() }
}
@@ -507,135 +490,27 @@ struct WorkbenchView: View {
}
.buttonStyle(.plain)
.lithePointer()
- .anchorPreference(
- key: BranchSwitcherButtonBoundsPreferenceKey.self,
- value: .bounds
- ) { $0 }
-
- Spacer(minLength: 22)
-
- backgroundPickerButton
-
- }
- .padding(.leading, 76)
- .padding(.trailing, 10)
- .frame(height: LitheTheme.Metrics.toolbarHeight)
- .background {
- (model.workbenchBackgroundFeature.hasImage ? Color.clear : LitheTheme.titlebar)
- .contentShape(Rectangle())
- .onTapGesture(count: 2) {
- (NSApplication.shared.keyWindow?.delegate as? LitheWindowCoordinator)?
- .toggleWorkspaceZoom()
- }
- }
- }
-
- private func projectSwitcherOverlay(
- buttonFrame: CGRect,
- viewportSize: CGSize
- ) -> some View {
- let popupMetrics = ProjectSwitcherLayoutMetrics.self
- let chromeMetrics = WorkbenchPopoverLayoutMetrics.self
- let placement = workbenchPopoverPlacement(
- buttonFrame: buttonFrame,
- viewportWidth: viewportSize.width,
- popupWidth: popupMetrics.width
- )
-
- return ZStack(alignment: .topLeading) {
- Color.clear
- .contentShape(Rectangle())
- .onTapGesture { updateSwitcherPresentation(project: false) }
-
- ZStack(alignment: .topLeading) {
- WorkbenchPopoverArrow()
- .fill(LitheTheme.popupBackground)
- .overlay {
- WorkbenchPopoverArrow()
- .stroke(LitheTheme.panelBorder, lineWidth: 1)
- }
- .frame(width: chromeMetrics.arrowWidth, height: chromeMetrics.arrowHeight)
- .offset(x: placement.arrowCenterX - (chromeMetrics.arrowWidth / 2))
-
- ProjectSwitcherPopover(
- isPresented: instantProjectSwitcherPresentation,
- onNewProject: {
- updateSwitcherPresentation(project: false)
- model.chooseProject(title: "New Project", prompt: "Choose Folder")
- },
- onOpenProject: {
- updateSwitcherPresentation(project: false)
- model.chooseProject()
- },
- onCloneRepository: {
- updateSwitcherPresentation(project: false)
- model.showCloneRepository()
- },
- onOpenRecentProject: { project in
- updateSwitcherPresentation(project: false)
- model.openProject(project.url)
- }
- )
- .environmentObject(model)
- .lithePopupChrome()
- .padding(.top, chromeMetrics.arrowHeight - 1)
- }
- .offset(x: placement.popupX, y: buttonFrame.maxY)
- }
- .transaction { transaction in
- transaction.animation = nil
- transaction.disablesAnimations = true
- }
- .onExitCommand { updateSwitcherPresentation(project: false) }
- }
-
- private func branchSwitcherOverlay(
- buttonFrame: CGRect,
- viewportSize: CGSize
- ) -> some View {
- let popupMetrics = BranchSwitcherPopover.Metrics.self
- let chromeMetrics = WorkbenchPopoverLayoutMetrics.self
- let placement = workbenchPopoverPlacement(
- buttonFrame: buttonFrame,
- viewportWidth: viewportSize.width,
- popupWidth: popupMetrics.popupWidth
- )
-
- return ZStack(alignment: .topLeading) {
- Color.clear
- .contentShape(Rectangle())
- .onTapGesture { updateSwitcherPresentation(branch: false) }
-
- ZStack(alignment: .topLeading) {
- WorkbenchPopoverArrow()
- .fill(LitheTheme.popupBackground)
- .overlay {
- WorkbenchPopoverArrow()
- .stroke(LitheTheme.panelBorder, lineWidth: 1)
- }
- .frame(width: chromeMetrics.arrowWidth, height: chromeMetrics.arrowHeight)
- .offset(x: placement.arrowCenterX - (chromeMetrics.arrowWidth / 2))
-
+ .popover(isPresented: $isBranchSwitcherPresented, arrowEdge: .bottom) {
BranchSwitcherPopover(
- isPresented: instantBranchSwitcherPresentation,
+ isPresented: $isBranchSwitcherPresented,
onCommit: {
- updateSwitcherPresentation(branch: false)
+ isBranchSwitcherPresented = false
model.selectedSidebar = .changes
},
onPush: { reference in
- updateSwitcherPresentation(branch: false)
+ isBranchSwitcherPresented = false
pendingTopBarPushReference = reference
},
onNewBranch: { reference in
- updateSwitcherPresentation(branch: false)
+ isBranchSwitcherPresented = false
newBranchReference = reference
},
onCheckoutRevision: {
- updateSwitcherPresentation(branch: false)
+ isBranchSwitcherPresented = false
isCheckoutRevisionPresented = true
},
onManageBranches: {
- updateSwitcherPresentation(branch: false)
+ isBranchSwitcherPresented = false
if !model.isGitLogVisible {
model.selectedSidebar = .changes
Task { await model.toggleGitLog() }
@@ -643,64 +518,156 @@ struct WorkbenchView: View {
}
)
.environmentObject(model)
- .padding(.top, chromeMetrics.arrowHeight - 1)
}
- .offset(x: placement.popupX, y: buttonFrame.maxY)
+
+ Spacer(minLength: 22)
+
+ runConfigurationPicker
+ runLaunchButton
+ debugLaunchButton
+ if hasActiveExecution {
+ stopExecutionButton
+ }
+
+ backgroundPickerButton
+
}
- .transaction { transaction in
- transaction.animation = nil
- transaction.disablesAnimations = true
+ .padding(.leading, 76)
+ .padding(.trailing, 10)
+ .frame(height: LitheTheme.Metrics.toolbarHeight)
+ .background {
+ (model.workbenchBackgroundFeature.hasImage ? Color.clear : LitheTheme.titlebar)
+ .contentShape(Rectangle())
+ .onTapGesture(count: 2) {
+ (NSApplication.shared.keyWindow?.delegate as? LitheWindowCoordinator)?
+ .toggleWorkspaceZoom()
+ }
}
- .onExitCommand { updateSwitcherPresentation(branch: false) }
}
- private func workbenchPopoverPlacement(
- buttonFrame: CGRect,
- viewportWidth: CGFloat,
- popupWidth: CGFloat
- ) -> (popupX: CGFloat, arrowCenterX: CGFloat) {
- let metrics = WorkbenchPopoverLayoutMetrics.self
- let desiredX = buttonFrame.minX - metrics.leadingOverlap
- let maximumX = max(
- metrics.viewportMargin,
- viewportWidth - popupWidth - metrics.viewportMargin
- )
- let popupX = min(max(desiredX, metrics.viewportMargin), maximumX)
- let arrowCenterX = min(
- max(buttonFrame.midX - popupX, metrics.arrowWidth),
- popupWidth - metrics.arrowWidth
- )
- return (popupX, arrowCenterX)
+ private var runLaunchButton: some View {
+ Button {
+ if model.runFeatureIfActive?.isRunning == true {
+ model.restartSelectedRun()
+ } else {
+ model.runSelectedConfiguration()
+ }
+ } label: {
+ LitheIDEAIcon(
+ resourcePath: model.runFeatureIfActive?.isRunning == true
+ ? "debugger/rerun.svg"
+ : "debugger/run.svg",
+ size: 16,
+ fallbackSystemImage: model.runFeatureIfActive?.isRunning == true
+ ? "arrow.clockwise"
+ : "play.fill",
+ preservesOriginalColors: true
+ )
+ .frame(width: 28, height: 28)
+ .litheRowHover(isActive: false, cornerRadius: 6, activeBackground: LitheTheme.subtleSelection)
+ }
+ .buttonStyle(.plain)
+ .lithePointer()
+ .help(model.runFeatureIfActive?.isRunning == true ? "Rerun selected configuration" : "Run selected configuration")
+ .accessibilityLabel(model.runFeatureIfActive?.isRunning == true ? "Rerun selected configuration" : "Run selected configuration")
+ .accessibilityIdentifier("run-selected-run-configuration")
}
- private var instantProjectSwitcherPresentation: Binding {
- Binding(
- get: { isProjectSwitcherPresented },
- set: { updateSwitcherPresentation(project: $0) }
- )
+ private var debugLaunchButton: some View {
+ Button {
+ model.startOrRestartDebugging()
+ } label: {
+ LitheIDEAIcon(
+ resourcePath: isDebugSessionActive
+ ? "debugger/restartDebug.svg"
+ : "debugger/debug.svg",
+ size: 16,
+ fallbackSystemImage: "ladybug.fill",
+ preservesOriginalColors: true
+ )
+ .frame(width: 28, height: 28)
+ .litheRowHover(isActive: false, cornerRadius: 6, activeBackground: LitheTheme.subtleSelection)
+ }
+ .buttonStyle(.plain)
+ .lithePointer()
+ .help(isDebugSessionActive ? "Rerun or show Debug session" : "Debug selected run configuration")
+ .accessibilityLabel(isDebugSessionActive ? "Rerun or show Debug session" : "Debug selected run configuration")
+ .accessibilityIdentifier("debug-selected-run-configuration")
}
- private var instantBranchSwitcherPresentation: Binding {
- Binding(
- get: { isBranchSwitcherPresented },
- set: { updateSwitcherPresentation(branch: $0) }
- )
+ private var stopExecutionButton: some View {
+ Button {
+ if isDebugSessionActive {
+ model.stopDebugging()
+ } else {
+ model.stopSelectedRun()
+ }
+ } label: {
+ LitheIDEAIcon(
+ resourcePath: "debugger/stop.svg",
+ size: 16,
+ fallbackSystemImage: "stop.fill",
+ preservesOriginalColors: true
+ )
+ .frame(width: 28, height: 28)
+ .litheRowHover(isActive: false, cornerRadius: 6, activeBackground: LitheTheme.subtleSelection)
+ }
+ .buttonStyle(.plain)
+ .lithePointer()
+ .help("Stop active execution")
+ .accessibilityLabel("Stop active execution")
+ .accessibilityIdentifier("stop-active-execution")
}
- private func updateSwitcherPresentation(
- project: Bool? = nil,
- branch: Bool? = nil
- ) {
- var transaction = Transaction(animation: nil)
- transaction.disablesAnimations = true
- withTransaction(transaction) {
- if let project {
- isProjectSwitcherPresented = project
+ private var isDebugSessionActive: Bool {
+ model.genericDebugFeatureIfActive?.isSessionActive == true
+ }
+
+ private var hasActiveExecution: Bool {
+ isDebugSessionActive || model.runFeatureIfActive?.isRunning == true
+ }
+
+ private var runConfigurationPicker: some View {
+ Menu {
+ if let runFeature = model.runFeatureIfActive,
+ !runFeature.configurations.isEmpty {
+ ForEach(runFeature.configurations) { configuration in
+ Button {
+ model.selectRunConfiguration(configuration)
+ } label: {
+ HStack {
+ RunConfigurationIcon(kind: configuration.kind, size: 14)
+ Text(configuration.name)
+ if configuration.id == runFeature.selectedConfiguration?.id {
+ Spacer()
+ Image(systemName: "checkmark")
+ }
+ }
+ }
+ }
+ } else {
+ Button("Current File") {
+ model.selectRunConfiguration(.currentFile)
+ }
}
- if let branch {
- isBranchSwitcherPresented = branch
+ } label: {
+ HStack(spacing: 5) {
+ Text(model.runFeatureIfActive?.selectedConfiguration?.name ?? "Current File")
+ .font(.system(size: 11.5, weight: .medium))
+ .lineLimit(1)
+ Image(systemName: "chevron.down")
+ .font(.system(size: 8, weight: .bold))
}
+ .foregroundStyle(LitheTheme.primaryText)
+ .padding(.horizontal, 8)
+ .frame(maxWidth: 190, minHeight: 30)
+ .litheRowHover(isActive: false, cornerRadius: 6, activeBackground: LitheTheme.subtleSelection)
}
+ .menuStyle(.borderlessButton)
+ .fixedSize(horizontal: true, vertical: false)
+ .help("Select run configuration for Run or Debug")
+ .accessibilityLabel("Select run configuration for Run or Debug")
+ .accessibilityIdentifier("run-configuration-picker")
}
private var backgroundPickerButton: some View {
@@ -1247,9 +1214,7 @@ struct WorkbenchView: View {
private var detailedStatusItems: some View {
HStack(spacing: 14) {
- EditorCaretPositionLabel(chrome: model.editorChrome) {
- model.showGoToLine()
- }
+ EditorCaretPositionLabel(chrome: model.editorChrome) { model.showGoToLine() }
Text("UTF-8")
Text("\(settings.tabWidth) spaces")
Button {
@@ -1270,9 +1235,7 @@ struct WorkbenchView: View {
private var compactStatusItems: some View {
HStack(spacing: 10) {
- EditorCaretPositionLabel(chrome: model.editorChrome) {
- model.showGoToLine()
- }
+ EditorCaretPositionLabel(chrome: model.editorChrome) { model.showGoToLine() }
MemoryUsageStatusView()
FrameRateStatusView()
gitStatus
diff --git a/macos/Sources/LitheCoreContracts/Debug/DebugAdapterContracts.swift b/macos/Sources/LitheCoreContracts/Debug/DebugAdapterContracts.swift
index 0a9ebd43c..275e877bb 100644
--- a/macos/Sources/LitheCoreContracts/Debug/DebugAdapterContracts.swift
+++ b/macos/Sources/LitheCoreContracts/Debug/DebugAdapterContracts.swift
@@ -24,6 +24,19 @@ public protocol DebugAdapterChildTransportProviding: AnyObject {
func makeChildTransport() -> (any DebugAdapterTransport)?
}
+@MainActor
+public protocol DebugOperationDeadline: AnyObject {
+ func cancel()
+}
+
+@MainActor
+public protocol DebugOperationDeadlineScheduling: AnyObject {
+ func schedule(
+ afterMilliseconds: Int,
+ action: @escaping @MainActor () -> Void
+ ) -> any DebugOperationDeadline
+}
+
public extension DebugAdapterSession {
var state: DebugAdapterState { isRunning ? .running : .idle }
}
@@ -32,31 +45,249 @@ public enum DebugAdapterState: String, Equatable, Sendable {
case idle, initializing, ready, launching, running, paused, terminated, failed
}
-public enum DebugRequestKind: String, Equatable, Sendable {
+public enum DebugRequestKind: String, Codable, Equatable, Sendable {
case launch, attach
}
-public struct DebugLaunchConfiguration: Equatable, Sendable {
+public struct DebugLaunchConfiguration: Codable, Equatable, Sendable {
public let name: String
public let request: DebugRequestKind
public let arguments: [String: ToolingJSONValue]
+ public let steppingFilters: DebugSteppingFilters?
- public init(name: String, request: DebugRequestKind, arguments: [String: ToolingJSONValue]) {
+ public init(
+ name: String,
+ request: DebugRequestKind,
+ arguments: [String: ToolingJSONValue],
+ steppingFilters: DebugSteppingFilters? = nil
+ ) {
self.name = name
self.request = request
self.arguments = arguments
+ self.steppingFilters = steppingFilters
+ }
+
+ public func applying(steppingFilters: DebugSteppingFilters) -> Self {
+ Self(
+ name: name,
+ request: request,
+ arguments: arguments,
+ steppingFilters: steppingFilters
+ )
+ }
+}
+
+public enum DebugRunInTerminalKind: String, Codable, Equatable, Sendable {
+ case integrated, external
+}
+
+public struct DebugRunInTerminalEnvironmentVariable: Codable, Equatable, Sendable {
+ public let name: String
+ public let value: String?
+
+ public init(name: String, value: String?) {
+ self.name = name
+ self.value = value
+ }
+}
+
+/// A normalized DAP reverse request. `args.first` is the executable and the
+/// remaining values stay as an argument array unless shell interpretation was
+/// explicitly requested by the adapter.
+public struct DebugRunInTerminalRequest: Codable, Equatable, Sendable {
+ public let kind: DebugRunInTerminalKind
+ public let title: String?
+ public let cwd: String
+ public let args: [String]
+ public let environment: [DebugRunInTerminalEnvironmentVariable]
+ public let argsCanBeInterpretedByShell: Bool
+
+ public init(
+ kind: DebugRunInTerminalKind,
+ title: String?,
+ cwd: String,
+ args: [String],
+ environment: [DebugRunInTerminalEnvironmentVariable],
+ argsCanBeInterpretedByShell: Bool
+ ) {
+ self.kind = kind
+ self.title = title
+ self.cwd = cwd
+ self.args = args
+ self.environment = environment
+ self.argsCanBeInterpretedByShell = argsCanBeInterpretedByShell
+ }
+}
+
+public struct DebugRunInTerminalResponse: Equatable, Sendable {
+ public let processID: Int?
+ public let shellProcessID: Int?
+
+ public init(processID: Int?, shellProcessID: Int? = nil) {
+ self.processID = processID
+ self.shellProcessID = shellProcessID
+ }
+}
+
+public typealias DebugRunInTerminalCompletion = (
+ Result
+) -> Void
+public typealias DebugRunInTerminalRequestHandler = (
+ DebugRunInTerminalRequest,
+ @escaping DebugRunInTerminalCompletion
+) -> Void
+
+/// Optional reverse-request surface implemented only by sessions whose native
+/// host can create an integrated terminal before DAP initialization begins.
+@MainActor
+public protocol DebugAdapterRunInTerminalSession: AnyObject {
+ var onRunInTerminalRequest: DebugRunInTerminalRequestHandler? { get set }
+}
+
+public struct DebugSteppingFilters: Codable, Equatable, Sendable {
+ public let classNameFilters: [String]
+ public let skipSynthetics: Bool
+ public let skipStaticInitializers: Bool
+ public let skipConstructors: Bool
+ public let hideFilteredStackFrames: Bool
+
+ public init(
+ classNameFilters: [String],
+ skipSynthetics: Bool,
+ skipStaticInitializers: Bool,
+ skipConstructors: Bool,
+ hideFilteredStackFrames: Bool
+ ) {
+ self.classNameFilters = classNameFilters
+ self.skipSynthetics = skipSynthetics
+ self.skipStaticInitializers = skipStaticInitializers
+ self.skipConstructors = skipConstructors
+ self.hideFilteredStackFrames = hideFilteredStackFrames
+ }
+}
+
+/// JDT LS-owned identity for one Java launch target. `mainClass` may include
+/// the JPMS module prefix (`module/name.Type`) required by Java Debug Server.
+public struct JavaDebugLaunchTarget: Equatable, Sendable {
+ public let mainClass: String
+ public let projectName: String?
+ public let modulePaths: [String]
+ public let classPaths: [String]
+
+ public init(
+ mainClass: String,
+ projectName: String?,
+ modulePaths: [String] = [],
+ classPaths: [String] = []
+ ) {
+ self.mainClass = mainClass
+ self.projectName = projectName
+ self.modulePaths = modulePaths
+ self.classPaths = classPaths
}
}
-public struct DebugSourceBreakpoint: Hashable, Sendable {
+/// Java test runner family reported by the JDT LS Java Test extension.
+public enum JavaTestDebugFramework: String, Codable, Equatable, Sendable {
+ case junit
+ case testng
+}
+
+/// JDT LS-owned metadata required to launch one Java test selection through DAP.
+public struct JavaTestDebugLaunchTarget: Equatable, Sendable {
+ public let fileURL: URL
+ public let name: String
+ public let framework: JavaTestDebugFramework
+ public let workingDirectory: String
+ public let mainClass: String
+ public let projectName: String?
+ public let classPaths: [String]
+ public let modulePaths: [String]
+ public let vmArguments: [String]
+ public let programArguments: [String]
+ public let testNGRunnerPath: String?
+ public let testNGTestNames: [String]
+
+ public init(
+ fileURL: URL,
+ name: String,
+ framework: JavaTestDebugFramework,
+ workingDirectory: String,
+ mainClass: String,
+ projectName: String?,
+ classPaths: [String],
+ modulePaths: [String],
+ vmArguments: [String],
+ programArguments: [String],
+ testNGRunnerPath: String? = nil,
+ testNGTestNames: [String] = []
+ ) {
+ self.fileURL = fileURL.standardizedFileURL
+ self.name = name
+ self.framework = framework
+ self.workingDirectory = workingDirectory
+ self.mainClass = mainClass
+ self.projectName = projectName
+ self.classPaths = classPaths
+ self.modulePaths = modulePaths
+ self.vmArguments = vmArguments
+ self.programArguments = programArguments
+ self.testNGRunnerPath = testNGRunnerPath
+ self.testNGTestNames = testNGTestNames
+ }
+}
+
+/// Resolves a Java test selection through the active language-service project model.
+@MainActor
+public protocol JavaTestDebugLaunchTargetResolving: AnyObject {
+ func resolveJavaTestDebugLaunchTarget(
+ fileURL: URL,
+ testIdentifier: String?,
+ rootURL: URL
+ ) async throws -> JavaTestDebugLaunchTarget
+}
+
+/// One exact editor replacement using zero-based, document-relative UTF-16 offsets.
+public struct DebugSourceEdit: Codable, Equatable, Sendable {
+ public let startUTF16Offset: Int
+ public let endUTF16Offset: Int
+ public let replacement: String
+
+ public init(startUTF16Offset: Int, endUTF16Offset: Int, replacement: String) {
+ self.startUTF16Offset = startUTF16Offset
+ self.endUTF16Offset = endUTF16Offset
+ self.replacement = replacement
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case startUTF16Offset = "startUtf16Offset"
+ case endUTF16Offset = "endUtf16Offset"
+ case replacement
+ }
+}
+
+public struct DebugSourceBreakpoint: Codable, Hashable, Sendable {
public let line: Int
public let column: Int?
+ public let enabled: Bool
public let condition: String?
+ public let hitCondition: String?
+ public let logMessage: String?
- public init(line: Int, column: Int? = nil, condition: String? = nil) {
+ public init(
+ line: Int,
+ column: Int? = nil,
+ enabled: Bool = true,
+ condition: String? = nil,
+ hitCondition: String? = nil,
+ logMessage: String? = nil
+ ) {
self.line = line
self.column = column
+ self.enabled = enabled
self.condition = condition
+ self.hitCondition = hitCondition
+ self.logMessage = logMessage
}
}
@@ -64,20 +295,302 @@ public struct DebugBreakpoint: Identifiable, Equatable, Sendable {
public let id: Int
public let verified: Bool
public let message: String?
+ public let functionName: String?
+ public let dataID: String?
public let sourceURL: URL?
public let line: Int?
public let column: Int?
- public init(id: Int, verified: Bool, message: String?, sourceURL: URL?, line: Int?, column: Int?) {
+ public init(
+ id: Int,
+ verified: Bool,
+ message: String?,
+ sourceURL: URL?,
+ line: Int?,
+ column: Int?,
+ functionName: String? = nil,
+ dataID: String? = nil
+ ) {
self.id = id
self.verified = verified
self.message = message
+ self.functionName = functionName
+ self.dataID = dataID
self.sourceURL = sourceURL
self.line = line
self.column = column
}
}
+public struct DebugExceptionBreakpointFilter: Codable, Equatable, Sendable {
+ public let filter: String
+ public let label: String
+ public let description: String?
+ public let isDefault: Bool
+ public let supportsCondition: Bool
+ public let conditionDescription: String?
+
+ public init(
+ filter: String,
+ label: String,
+ description: String?,
+ isDefault: Bool,
+ supportsCondition: Bool,
+ conditionDescription: String?
+ ) {
+ self.filter = filter
+ self.label = label
+ self.description = description
+ self.isDefault = isDefault
+ self.supportsCondition = supportsCondition
+ self.conditionDescription = conditionDescription
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case filter, label, description
+ case isDefault = "default"
+ case supportsCondition, conditionDescription
+ }
+}
+
+public struct DebugExceptionBreakpoint: Codable, Hashable, Sendable {
+ public let filter: String
+ public let enabled: Bool
+ public let condition: String?
+
+ public init(filter: String, enabled: Bool = true, condition: String? = nil) {
+ self.filter = filter
+ self.enabled = enabled
+ self.condition = condition
+ }
+}
+
+public struct DebugFunctionBreakpoint: Codable, Hashable, Sendable {
+ public let name: String
+ public let enabled: Bool
+ public let condition: String?
+ public let hitCondition: String?
+
+ public init(
+ name: String,
+ enabled: Bool = true,
+ condition: String? = nil,
+ hitCondition: String? = nil
+ ) {
+ self.name = name
+ self.enabled = enabled
+ self.condition = condition
+ self.hitCondition = hitCondition
+ }
+}
+
+public struct DebugDataBreakpoint: Codable, Hashable, Sendable {
+ public let dataID: String
+ public let label: String?
+ public let enabled: Bool
+ public let accessType: String?
+ public let condition: String?
+ public let hitCondition: String?
+
+ public init(
+ dataID: String,
+ label: String? = nil,
+ enabled: Bool = true,
+ accessType: String? = nil,
+ condition: String? = nil,
+ hitCondition: String? = nil
+ ) {
+ self.dataID = dataID
+ self.label = label
+ self.enabled = enabled
+ self.accessType = accessType
+ self.condition = condition
+ self.hitCondition = hitCondition
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case dataID = "dataId"
+ case label, enabled, accessType, condition, hitCondition
+ }
+}
+
+public struct DebugDataBreakpointInfo: Equatable, Sendable {
+ public let dataID: String?
+ public let description: String
+ public let accessTypes: [String]
+ public let canPersist: Bool
+
+ public init(dataID: String?, description: String, accessTypes: [String], canPersist: Bool) {
+ self.dataID = dataID
+ self.description = description
+ self.accessTypes = accessTypes
+ self.canPersist = canPersist
+ }
+}
+
+public struct DebugExceptionInfo: Equatable, Sendable {
+ public let exceptionID: String
+ public let description: String?
+ public let breakMode: String
+ public let details: DebugExceptionDetails?
+
+ public init(
+ exceptionID: String,
+ description: String?,
+ breakMode: String,
+ details: DebugExceptionDetails?
+ ) {
+ self.exceptionID = exceptionID
+ self.description = description
+ self.breakMode = breakMode
+ self.details = details
+ }
+}
+
+public struct DebugExceptionDetails: Equatable, Sendable {
+ public let message: String?
+ public let typeName: String?
+ public let fullTypeName: String?
+ public let evaluateName: String?
+ public let stackTrace: String?
+ public let innerExceptions: [DebugExceptionDetails]
+
+ public init(
+ message: String?,
+ typeName: String?,
+ fullTypeName: String?,
+ evaluateName: String?,
+ stackTrace: String?,
+ innerExceptions: [DebugExceptionDetails] = []
+ ) {
+ self.message = message
+ self.typeName = typeName
+ self.fullTypeName = fullTypeName
+ self.evaluateName = evaluateName
+ self.stackTrace = stackTrace
+ self.innerExceptions = innerExceptions
+ }
+}
+
+public struct DebugStepInTarget: Identifiable, Equatable, Sendable {
+ public let id: Int
+ public let label: String
+ public let line: Int?
+ public let column: Int?
+ public let endLine: Int?
+ public let endColumn: Int?
+
+ public init(
+ id: Int,
+ label: String,
+ line: Int?,
+ column: Int?,
+ endLine: Int?,
+ endColumn: Int?
+ ) {
+ self.id = id
+ self.label = label
+ self.line = line
+ self.column = column
+ self.endLine = endLine
+ self.endColumn = endColumn
+ }
+}
+
+public struct DebugGotoTarget: Identifiable, Equatable, Sendable {
+ public let id: Int
+ public let label: String
+ public let line: Int
+ public let column: Int?
+ public let endLine: Int?
+ public let endColumn: Int?
+ public let instructionPointerReference: String?
+
+ public init(
+ id: Int,
+ label: String,
+ line: Int,
+ column: Int?,
+ endLine: Int?,
+ endColumn: Int?,
+ instructionPointerReference: String?
+ ) {
+ self.id = id
+ self.label = label
+ self.line = line
+ self.column = column
+ self.endLine = endLine
+ self.endColumn = endColumn
+ self.instructionPointerReference = instructionPointerReference
+ }
+}
+
+public struct DebugAdapterCapabilities: Equatable, Sendable {
+ public let negotiated: Bool
+ public let supportsConfigurationDone: Bool
+ public let supportsConditionalBreakpoints: Bool
+ public let supportsHitConditionalBreakpoints: Bool
+ public let supportsLogPoints: Bool
+ public let supportsFunctionBreakpoints: Bool
+ public let supportsDataBreakpoints: Bool
+ public let supportsExceptionOptions: Bool
+ public let supportsExceptionFilterOptions: Bool
+ public let supportsSetVariable: Bool
+ public let supportsCancelRequest: Bool
+ public let supportsSingleThreadExecutionRequests: Bool
+ public let supportsRestartRequest: Bool
+ public let supportsTerminateRequest: Bool
+ public let supportsStepBack: Bool
+ public let supportsExceptionInfoRequest: Bool
+ public let supportsStepInTargetsRequest: Bool
+ public let supportsGotoTargetsRequest: Bool
+ public let exceptionBreakpointFilters: [DebugExceptionBreakpointFilter]
+
+ public static let unknown = DebugAdapterCapabilities()
+
+ public init(
+ negotiated: Bool = false,
+ supportsConfigurationDone: Bool = false,
+ supportsConditionalBreakpoints: Bool = false,
+ supportsHitConditionalBreakpoints: Bool = false,
+ supportsLogPoints: Bool = false,
+ supportsFunctionBreakpoints: Bool = false,
+ supportsDataBreakpoints: Bool = false,
+ supportsExceptionOptions: Bool = false,
+ supportsExceptionFilterOptions: Bool = false,
+ supportsSetVariable: Bool = false,
+ supportsCancelRequest: Bool = false,
+ supportsSingleThreadExecutionRequests: Bool = false,
+ supportsRestartRequest: Bool = false,
+ supportsTerminateRequest: Bool = false,
+ supportsStepBack: Bool = false,
+ supportsExceptionInfoRequest: Bool = false,
+ supportsStepInTargetsRequest: Bool = false,
+ supportsGotoTargetsRequest: Bool = false,
+ exceptionBreakpointFilters: [DebugExceptionBreakpointFilter] = []
+ ) {
+ self.negotiated = negotiated
+ self.supportsConfigurationDone = supportsConfigurationDone
+ self.supportsConditionalBreakpoints = supportsConditionalBreakpoints
+ self.supportsHitConditionalBreakpoints = supportsHitConditionalBreakpoints
+ self.supportsLogPoints = supportsLogPoints
+ self.supportsFunctionBreakpoints = supportsFunctionBreakpoints
+ self.supportsDataBreakpoints = supportsDataBreakpoints
+ self.supportsExceptionOptions = supportsExceptionOptions
+ self.supportsExceptionFilterOptions = supportsExceptionFilterOptions
+ self.supportsSetVariable = supportsSetVariable
+ self.supportsCancelRequest = supportsCancelRequest
+ self.supportsSingleThreadExecutionRequests = supportsSingleThreadExecutionRequests
+ self.supportsRestartRequest = supportsRestartRequest
+ self.supportsTerminateRequest = supportsTerminateRequest
+ self.supportsStepBack = supportsStepBack
+ self.supportsExceptionInfoRequest = supportsExceptionInfoRequest
+ self.supportsStepInTargetsRequest = supportsStepInTargetsRequest
+ self.supportsGotoTargetsRequest = supportsGotoTargetsRequest
+ self.exceptionBreakpointFilters = exceptionBreakpointFilters
+ }
+}
+
public struct DebugThread: Identifiable, Equatable, Sendable {
public let id: Int
public let name: String
@@ -90,13 +603,22 @@ public struct DebugStackFrame: Identifiable, Equatable, Sendable {
public let sourceURL: URL?
public let line: Int
public let column: Int
+ public let isFiltered: Bool
- public init(id: Int, name: String, sourceURL: URL?, line: Int, column: Int) {
+ public init(
+ id: Int,
+ name: String,
+ sourceURL: URL?,
+ line: Int,
+ column: Int,
+ isFiltered: Bool = false
+ ) {
self.id = id
self.name = name
self.sourceURL = sourceURL
self.line = line
self.column = column
+ self.isFiltered = isFiltered
}
}
@@ -105,15 +627,30 @@ public struct DebugScope: Identifiable, Equatable, Sendable {
public let name: String
public let variablesReference: Int
public let expensive: Bool
+ public let namedVariables: Int
+ public let indexedVariables: Int
- public init(id: Int, name: String, variablesReference: Int, expensive: Bool) {
+ public init(
+ id: Int,
+ name: String,
+ variablesReference: Int,
+ expensive: Bool,
+ namedVariables: Int = 0,
+ indexedVariables: Int = 0
+ ) {
self.id = id
self.name = name
self.variablesReference = variablesReference
self.expensive = expensive
+ self.namedVariables = max(0, namedVariables)
+ self.indexedVariables = max(0, indexedVariables)
}
}
+public enum DebugVariableFilter: String, Codable, Equatable, Sendable {
+ case named, indexed
+}
+
public struct DebugVariable: Identifiable, Equatable, Sendable {
public let id: String
public let name: String
@@ -121,6 +658,9 @@ public struct DebugVariable: Identifiable, Equatable, Sendable {
public let type: String?
public let evaluateName: String?
public let variablesReference: Int
+ public let containerReference: Int?
+ public let namedVariables: Int
+ public let indexedVariables: Int
public var isExpandable: Bool { variablesReference > 0 }
public init(
@@ -129,7 +669,10 @@ public struct DebugVariable: Identifiable, Equatable, Sendable {
value: String,
type: String?,
evaluateName: String?,
- variablesReference: Int
+ variablesReference: Int,
+ containerReference: Int? = nil,
+ namedVariables: Int = 0,
+ indexedVariables: Int = 0
) {
self.id = id
self.name = name
@@ -137,11 +680,15 @@ public struct DebugVariable: Identifiable, Equatable, Sendable {
self.type = type
self.evaluateName = evaluateName
self.variablesReference = variablesReference
+ self.containerReference = containerReference
+ self.namedVariables = max(0, namedVariables)
+ self.indexedVariables = max(0, indexedVariables)
}
}
public enum DebugAdapterEvent: Equatable, Sendable {
case initialized
+ case capabilities(DebugAdapterCapabilities)
case output(category: String?, output: String)
case stopped(reason: String, threadID: Int?, description: String?)
case continued(threadID: Int?)
@@ -149,21 +696,141 @@ public enum DebugAdapterEvent: Equatable, Sendable {
case breakpoint(DebugBreakpoint)
}
-public enum DebugExecutionCommand: String, Equatable, Sendable {
+public enum DebugExecutionCommand: String, Codable, Equatable, Sendable {
case continueExecution = "continue"
- case pause, next, stepIn, stepOut
+ case pause, next, stepIn, stepOut, stepBack, goto, restart, terminate
}
@MainActor
public protocol DebugAdapterControllingSession: DebugAdapterSession {
+ var capabilities: DebugAdapterCapabilities { get }
var onStateChange: ((DebugAdapterState) -> Void)? { get set }
var onEvent: ((DebugAdapterEvent) -> Void)? { get set }
func launch(_ configuration: DebugLaunchConfiguration) throws
func setBreakpoints(_ breakpoints: [DebugSourceBreakpoint], in fileURL: URL)
+ func setExceptionBreakpoints(_ breakpoints: [DebugExceptionBreakpoint])
+ func setFunctionBreakpoints(_ breakpoints: [DebugFunctionBreakpoint])
+ func setDataBreakpoints(_ breakpoints: [DebugDataBreakpoint])
+ func requestDataBreakpointInfo(
+ name: String,
+ variablesReference: Int?,
+ frameID: Int?,
+ completion: @escaping (Result) -> Void
+ )
func execute(_ command: DebugExecutionCommand, threadID: Int?)
+ func execute(_ command: DebugExecutionCommand, threadID: Int?, targetID: Int?)
+ func execute(
+ _ command: DebugExecutionCommand,
+ threadID: Int?,
+ targetID: Int?,
+ singleThread: Bool
+ )
+ func requestStepInTargets(
+ frameID: Int,
+ completion: @escaping (Result<[DebugStepInTarget], Error>) -> Void
+ )
+ func requestGotoTargets(
+ fileURL: URL,
+ line: Int,
+ column: Int?,
+ completion: @escaping (Result<[DebugGotoTarget], Error>) -> Void
+ )
func requestThreads(_ completion: @escaping (Result<[DebugThread], Error>) -> Void)
+ func requestExceptionInfo(
+ threadID: Int,
+ completion: @escaping (Result) -> Void
+ )
func requestStackTrace(threadID: Int, completion: @escaping (Result<[DebugStackFrame], Error>) -> Void)
func requestScopes(frameID: Int, completion: @escaping (Result<[DebugScope], Error>) -> Void)
func requestVariables(reference: Int, completion: @escaping (Result<[DebugVariable], Error>) -> Void)
+ func requestVariables(
+ reference: Int,
+ filter: DebugVariableFilter?,
+ start: Int?,
+ count: Int?,
+ completion: @escaping (Result<[DebugVariable], Error>) -> Void
+ )
+ func setVariable(
+ variablesReference: Int,
+ name: String,
+ value: String,
+ completion: @escaping (Result) -> Void
+ )
func evaluate(_ expression: String, frameID: Int?, completion: @escaping (Result) -> Void)
+ func cancelPendingOperations()
+}
+
+public extension DebugAdapterControllingSession {
+ var capabilities: DebugAdapterCapabilities { .unknown }
+ func setExceptionBreakpoints(_: [DebugExceptionBreakpoint]) {}
+ func setFunctionBreakpoints(_: [DebugFunctionBreakpoint]) {}
+ func setDataBreakpoints(_: [DebugDataBreakpoint]) {}
+ func execute(_ command: DebugExecutionCommand, threadID: Int?, targetID _: Int?) {
+ execute(command, threadID: threadID)
+ }
+ func execute(
+ _ command: DebugExecutionCommand,
+ threadID: Int?,
+ targetID: Int?,
+ singleThread _: Bool
+ ) {
+ execute(command, threadID: threadID, targetID: targetID)
+ }
+ func requestStepInTargets(
+ frameID _: Int,
+ completion: @escaping (Result<[DebugStepInTarget], Error>) -> Void
+ ) {
+ completion(.failure(DebugAdapterCapabilityError.unsupported("smart step into")))
+ }
+ func requestGotoTargets(
+ fileURL _: URL,
+ line _: Int,
+ column _: Int?,
+ completion: @escaping (Result<[DebugGotoTarget], Error>) -> Void
+ ) {
+ completion(.failure(DebugAdapterCapabilityError.unsupported("run to cursor")))
+ }
+ func requestDataBreakpointInfo(
+ name _: String,
+ variablesReference _: Int?,
+ frameID _: Int?,
+ completion: @escaping (Result) -> Void
+ ) {
+ completion(.failure(DebugAdapterCapabilityError.unsupported("data breakpoints")))
+ }
+ func requestExceptionInfo(
+ threadID _: Int,
+ completion: @escaping (Result) -> Void
+ ) {
+ completion(.failure(DebugAdapterCapabilityError.unsupported("exception information")))
+ }
+ func requestVariables(
+ reference: Int,
+ filter _: DebugVariableFilter?,
+ start _: Int?,
+ count _: Int?,
+ completion: @escaping (Result<[DebugVariable], Error>) -> Void
+ ) {
+ requestVariables(reference: reference, completion: completion)
+ }
+ func setVariable(
+ variablesReference _: Int,
+ name _: String,
+ value _: String,
+ completion: @escaping (Result) -> Void
+ ) {
+ completion(.failure(DebugAdapterCapabilityError.unsupported("variable mutation")))
+ }
+ func cancelPendingOperations() {}
+}
+
+public enum DebugAdapterCapabilityError: LocalizedError, Sendable {
+ case unsupported(String)
+
+ public var errorDescription: String? {
+ switch self {
+ case let .unsupported(feature):
+ "The active debug adapter does not support \(feature)."
+ }
+ }
}
diff --git a/macos/Sources/LitheCoreContracts/Debug/DebugProtocolCore.swift b/macos/Sources/LitheCoreContracts/Debug/DebugProtocolCore.swift
new file mode 100644
index 000000000..18f5b4d30
--- /dev/null
+++ b/macos/Sources/LitheCoreContracts/Debug/DebugProtocolCore.swift
@@ -0,0 +1,357 @@
+import Foundation
+
+/// Lifecycle state reduced by the shared Rust Debug Core.
+public enum DebugCoreSessionState: String, Decodable, Equatable, Sendable {
+ case idle, initializing, ready, launching, running, paused, terminating, terminated, failed
+}
+
+/// One deterministic reduction returned by a shared Debug Core command.
+public struct DebugCoreUpdate: Decodable, Equatable, Sendable {
+ public let sessionID: String
+ public let state: DebugCoreSessionState
+ public let outboundFrames: [String]
+ public let events: [DebugCoreEvent]
+
+ private enum CodingKeys: String, CodingKey {
+ case sessionID = "sessionId"
+ case state
+ case outboundFrames
+ case events
+ }
+}
+
+/// A normalized event emitted by the shared Debug Core.
+public struct DebugCoreEvent: Decodable, Equatable, Sendable {
+ public let sequence: UInt64
+ public let type: String
+ public let state: DebugCoreSessionState?
+ public let category: String?
+ public let output: String?
+ public let reason: String?
+ public let threadID: Int?
+ public let description: String?
+ public let exitCode: Int?
+ public let breakpoint: DebugCoreBreakpoint?
+ public let capabilities: DebugCoreCapabilities?
+ public let requestID: String?
+ public let request: DebugRunInTerminalRequest?
+ public let operationID: String?
+ public let result: DebugCoreOperationResult?
+ public let command: String?
+ public let code: String?
+ public let message: String?
+
+ private enum CodingKeys: String, CodingKey {
+ case sequence
+ case type
+ case state
+ case category
+ case output
+ case reason
+ case threadID = "threadId"
+ case description
+ case exitCode
+ case breakpoint
+ case capabilities
+ case requestID = "requestId"
+ case request
+ case operationID = "operationId"
+ case result
+ case command
+ case code
+ case message
+ }
+}
+
+public struct DebugCoreCapabilities: Decodable, Equatable, Sendable {
+ public let supportsConfigurationDone: Bool
+ public let supportsConditionalBreakpoints: Bool
+ public let supportsHitConditionalBreakpoints: Bool
+ public let supportsLogPoints: Bool
+ public let supportsFunctionBreakpoints: Bool
+ public let supportsDataBreakpoints: Bool
+ public let supportsExceptionOptions: Bool
+ public let supportsExceptionFilterOptions: Bool
+ public let supportsSetVariable: Bool
+ public let supportsCancelRequest: Bool
+ public let supportsSingleThreadExecutionRequests: Bool
+ public let supportsRestartRequest: Bool
+ public let supportsTerminateRequest: Bool
+ public let supportsStepBack: Bool
+ public let supportsExceptionInfoRequest: Bool
+ public let supportsStepInTargetsRequest: Bool
+ public let supportsGotoTargetsRequest: Bool
+ public let exceptionBreakpointFilters: [DebugExceptionBreakpointFilter]
+}
+
+public struct DebugCoreOperationResult: Decodable, Equatable, Sendable {
+ public let kind: String
+ public let command: String?
+ public let threads: [DebugCoreThread]?
+ public let stackFrames: [DebugCoreStackFrame]?
+ public let scopes: [DebugCoreScope]?
+ public let variables: [DebugCoreVariable]?
+ public let variable: DebugCoreVariable?
+ public let exceptionInfo: DebugCoreExceptionInfo?
+ public let dataID: String?
+ public let description: String?
+ public let accessTypes: [String]?
+ public let canPersist: Bool?
+ public let targets: [DebugCoreTarget]?
+
+ private enum CodingKeys: String, CodingKey {
+ case kind, command, threads, stackFrames, scopes, variables, variable, exceptionInfo
+ case dataID = "dataId"
+ case description, accessTypes, canPersist, targets
+ }
+}
+
+public struct DebugCoreTarget: Decodable, Equatable, Sendable {
+ public let id: Int
+ public let label: String
+ public let line: Int?
+ public let column: Int?
+ public let endLine: Int?
+ public let endColumn: Int?
+ public let instructionPointerReference: String?
+}
+
+public struct DebugCoreBreakpoint: Decodable, Equatable, Sendable {
+ public let id: Int
+ public let verified: Bool
+ public let message: String?
+ public let functionName: String?
+ public let dataID: String?
+ public let sourcePath: String?
+ public let line: Int?
+ public let column: Int?
+
+ private enum CodingKeys: String, CodingKey {
+ case id, verified, message, functionName
+ case dataID = "dataId"
+ case sourcePath, line, column
+ }
+}
+
+public struct DebugCoreThread: Decodable, Equatable, Sendable {
+ public let id: Int
+ public let name: String
+}
+
+public struct DebugCoreStackFrame: Decodable, Equatable, Sendable {
+ public let id: Int
+ public let name: String
+ public let sourcePath: String?
+ public let line: Int
+ public let column: Int
+ public let isFiltered: Bool
+
+ public init(
+ id: Int,
+ name: String,
+ sourcePath: String?,
+ line: Int,
+ column: Int,
+ isFiltered: Bool = false
+ ) {
+ self.id = id
+ self.name = name
+ self.sourcePath = sourcePath
+ self.line = line
+ self.column = column
+ self.isFiltered = isFiltered
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case id, name, sourcePath, line, column, isFiltered
+ }
+
+ public init(from decoder: any Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ id = try container.decode(Int.self, forKey: .id)
+ name = try container.decode(String.self, forKey: .name)
+ sourcePath = try container.decodeIfPresent(String.self, forKey: .sourcePath)
+ line = try container.decode(Int.self, forKey: .line)
+ column = try container.decode(Int.self, forKey: .column)
+ isFiltered = try container.decodeIfPresent(Bool.self, forKey: .isFiltered) ?? false
+ }
+}
+
+public struct DebugCoreScope: Decodable, Equatable, Sendable {
+ public let name: String
+ public let variablesReference: Int
+ public let expensive: Bool
+ public let namedVariables: Int
+ public let indexedVariables: Int
+
+ private enum CodingKeys: String, CodingKey {
+ case name, variablesReference, expensive, namedVariables, indexedVariables
+ }
+
+ public init(from decoder: any Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ name = try container.decode(String.self, forKey: .name)
+ variablesReference = try container.decode(Int.self, forKey: .variablesReference)
+ expensive = try container.decode(Bool.self, forKey: .expensive)
+ namedVariables = max(0, try container.decodeIfPresent(Int.self, forKey: .namedVariables) ?? 0)
+ indexedVariables = max(0, try container.decodeIfPresent(Int.self, forKey: .indexedVariables) ?? 0)
+ }
+}
+
+public struct DebugCoreVariable: Decodable, Equatable, Sendable {
+ public let name: String
+ public let value: String
+ public let type: String?
+ public let evaluateName: String?
+ public let variablesReference: Int
+ public let namedVariables: Int
+ public let indexedVariables: Int
+
+ private enum CodingKeys: String, CodingKey {
+ case name, value, type, evaluateName, variablesReference, namedVariables, indexedVariables
+ }
+
+ public init(from decoder: any Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ name = try container.decode(String.self, forKey: .name)
+ value = try container.decode(String.self, forKey: .value)
+ type = try container.decodeIfPresent(String.self, forKey: .type)
+ evaluateName = try container.decodeIfPresent(String.self, forKey: .evaluateName)
+ variablesReference = try container.decode(Int.self, forKey: .variablesReference)
+ namedVariables = max(0, try container.decodeIfPresent(Int.self, forKey: .namedVariables) ?? 0)
+ indexedVariables = max(0, try container.decodeIfPresent(Int.self, forKey: .indexedVariables) ?? 0)
+ }
+}
+
+public struct DebugCoreExceptionInfo: Decodable, Equatable, Sendable {
+ public let exceptionID: String
+ public let description: String?
+ public let breakMode: String
+ public let details: DebugCoreExceptionDetails?
+
+ private enum CodingKeys: String, CodingKey {
+ case exceptionID = "exceptionId"
+ case description, breakMode, details
+ }
+}
+
+public struct DebugCoreExceptionDetails: Decodable, Equatable, Sendable {
+ public let message: String?
+ public let typeName: String?
+ public let fullTypeName: String?
+ public let evaluateName: String?
+ public let stackTrace: String?
+ public let innerExceptions: [DebugCoreExceptionDetails]
+}
+
+/// Focused policy boundary for portable debugger stepping defaults and validation.
+@MainActor
+public protocol DebugSteppingFilterResolving: Sendable {
+ func resolveDebugSteppingFilters(
+ adapterID: String,
+ filters: DebugSteppingFilters?
+ ) throws -> DebugSteppingFilters
+}
+
+/// Focused shared-Core boundary for deterministic Java test launch arguments.
+@MainActor
+public protocol JavaTestDebugLaunchResolving: Sendable {
+ func resolveJavaTestDebugLaunch(
+ target: JavaTestDebugLaunchTarget,
+ resultPort: UInt16
+ ) throws -> DebugLaunchConfiguration
+}
+
+/// Focused shared-Core boundary for moving source breakpoints with editor text.
+@MainActor
+public protocol DebugBreakpointRelocating: Sendable {
+ func relocateDebugBreakpoints(
+ source: String,
+ edit: DebugSourceEdit,
+ breakpoints: [DebugSourceBreakpoint]
+ ) throws -> [DebugSourceBreakpoint]
+}
+
+/// Transport-neutral Debug Core boundary. Native products own processes and
+/// sockets; this contract owns DAP framing, state, sequencing, and normalized data.
+@MainActor
+public protocol DebugProtocolCore: DebugSteppingFilterResolving, Sendable {
+ func createDebugSession(
+ sessionID: String,
+ adapterID: String,
+ rootPath: String,
+ supportsRunInTerminalRequest: Bool
+ ) throws -> DebugCoreUpdate
+ func launchDebugSession(
+ sessionID: String,
+ operationID: String,
+ configuration: DebugLaunchConfiguration
+ ) throws -> DebugCoreUpdate
+ func setDebugBreakpoints(
+ sessionID: String,
+ sourcePath: String,
+ breakpoints: [DebugSourceBreakpoint]
+ ) throws -> DebugCoreUpdate
+ func setDebugExceptionBreakpoints(
+ sessionID: String,
+ breakpoints: [DebugExceptionBreakpoint]
+ ) throws -> DebugCoreUpdate
+ func setDebugFunctionBreakpoints(
+ sessionID: String,
+ breakpoints: [DebugFunctionBreakpoint]
+ ) throws -> DebugCoreUpdate
+ func debugDataBreakpointInfo(
+ sessionID: String,
+ operationID: String,
+ name: String,
+ variablesReference: Int?,
+ frameID: Int?
+ ) throws -> DebugCoreUpdate
+ func setDebugDataBreakpoints(
+ sessionID: String,
+ breakpoints: [DebugDataBreakpoint]
+ ) throws -> DebugCoreUpdate
+ func setDebugVariable(
+ sessionID: String,
+ operationID: String,
+ variablesReference: Int,
+ name: String,
+ value: String
+ ) throws -> DebugCoreUpdate
+ func cancelDebugOperation(
+ sessionID: String,
+ operationID: String,
+ reason: String
+ ) throws -> DebugCoreUpdate
+ func executeDebugCommand(
+ sessionID: String,
+ operationID: String,
+ command: DebugExecutionCommand,
+ threadID: Int?,
+ targetID: Int?,
+ singleThread: Bool
+ ) throws -> DebugCoreUpdate
+ func inspectDebugSession(
+ sessionID: String,
+ operationID: String,
+ kind: String,
+ threadID: Int?,
+ frameID: Int?,
+ variablesReference: Int?,
+ variableFilter: DebugVariableFilter?,
+ start: Int?,
+ count: Int?,
+ expression: String?,
+ sourcePath: String?,
+ line: Int?,
+ column: Int?
+ ) throws -> DebugCoreUpdate
+ func receiveDebugData(sessionID: String, data: Data) throws -> DebugCoreUpdate
+ func completeDebugRunInTerminalRequest(
+ sessionID: String,
+ requestID: String,
+ result: Result
+ ) throws -> DebugCoreUpdate
+ func disconnectDebugSession(sessionID: String) throws -> DebugCoreUpdate
+ func destroyDebugSession(sessionID: String)
+}
diff --git a/macos/Sources/LitheCoreContracts/Execution/ExecutionContracts.swift b/macos/Sources/LitheCoreContracts/Execution/ExecutionContracts.swift
index 7dabf70a0..2946504cc 100644
--- a/macos/Sources/LitheCoreContracts/Execution/ExecutionContracts.swift
+++ b/macos/Sources/LitheCoreContracts/Execution/ExecutionContracts.swift
@@ -232,13 +232,27 @@ package struct LanguageTestItem: Identifiable, Equatable, Sendable {
package let label: String
package let kind: LanguageTestItemKind
package let fileURL: URL?
+ /// Stable provider identifier used to run or debug this exact test item.
+ package let testIdentifier: String?
+ /// Visual nesting below the provider section; source files start at zero.
+ package let depth: Int
- package init(id: String, providerID: String, label: String, kind: LanguageTestItemKind, fileURL: URL?) {
+ package init(
+ id: String,
+ providerID: String,
+ label: String,
+ kind: LanguageTestItemKind,
+ fileURL: URL?,
+ testIdentifier: String? = nil,
+ depth: Int = 0
+ ) {
self.id = id
self.providerID = providerID
self.label = label
self.kind = kind
self.fileURL = fileURL
+ self.testIdentifier = testIdentifier
+ self.depth = max(0, depth)
}
}
diff --git a/macos/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift b/macos/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift
index 0b11b76d0..34b20fa4f 100644
--- a/macos/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift
+++ b/macos/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift
@@ -47,8 +47,28 @@ package struct ProjectRunConfigurationInspection: Equatable, Sendable {
}
}
+package enum ProjectLoadState: Equatable, Sendable {
+ case idle
+ case loading(workspace: URL)
+ case bound(workspace: URL)
+ case ready(workspace: URL, snapshotID: UUID)
+ case failed(workspace: URL, message: String)
+
+ package func isReady(for workspace: URL, snapshotID: UUID? = nil) -> Bool {
+ guard case .ready(let boundWorkspace, let boundSnapshotID) = self,
+ boundWorkspace == workspace.standardizedFileURL else { return false }
+ return snapshotID.map { $0 == boundSnapshotID } ?? true
+ }
+
+ package func hasReadyInventory(for workspace: URL) -> Bool {
+ guard case .ready(let boundWorkspace, _) = self else { return false }
+ return boundWorkspace == workspace.standardizedFileURL
+ }
+}
+
package enum RunConfigurationGenerationState: Equatable, Sendable {
case idle
+ case projectNotReady
case succeeded(entryCount: Int)
case noEntries
case failed(String)
diff --git a/macos/Sources/LitheCoreContracts/Execution/RunModels.swift b/macos/Sources/LitheCoreContracts/Execution/RunModels.swift
index 0d7dd5ba6..63f38c90a 100644
--- a/macos/Sources/LitheCoreContracts/Execution/RunModels.swift
+++ b/macos/Sources/LitheCoreContracts/Execution/RunModels.swift
@@ -37,7 +37,7 @@ package struct RunPortConflict: Identifiable, Hashable, Sendable {
package var id: String { String(port) }
package var title: String {
- "Port (port) is used by " + configurationNames.joined(separator: ", ")
+ "Port \(port) is used by " + configurationNames.joined(separator: ", ")
}
}
diff --git a/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift b/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift
index 86f3c5aa4..065983e1e 100644
--- a/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift
+++ b/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift
@@ -34,15 +34,34 @@ package struct JDTLSLaunchResources: Equatable, Sendable {
package let launcherJarURL: URL
package let configurationDirectoryURL: URL
package let lombokAgentURL: URL
+ /// Ordered OSGi bundles contributed by Java tooling extensions. The Java
+ /// Debug Server remains first for compatibility with older Rust cores.
+ package let javaExtensionBundleURLs: [URL]
+ /// Standalone TestNG runner used only when a TestNG debug launch is requested.
+ package let javaTestRunnerURL: URL?
package init(
launcherJarURL: URL,
configurationDirectoryURL: URL,
- lombokAgentURL: URL
+ lombokAgentURL: URL,
+ javaDebugBundleURL: URL? = nil,
+ javaExtensionBundleURLs: [URL] = [],
+ javaTestRunnerURL: URL? = nil
) {
self.launcherJarURL = launcherJarURL.standardizedFileURL
self.configurationDirectoryURL = configurationDirectoryURL.standardizedFileURL
self.lombokAgentURL = lombokAgentURL.standardizedFileURL
+ var seen = Set()
+ self.javaExtensionBundleURLs = ([javaDebugBundleURL].compactMap { $0 } + javaExtensionBundleURLs)
+ .map(\.standardizedFileURL)
+ .filter { seen.insert($0.path).inserted }
+ self.javaTestRunnerURL = javaTestRunnerURL?.standardizedFileURL
+ }
+
+ package var javaDebugBundleURL: URL? {
+ javaExtensionBundleURLs.first {
+ $0.lastPathComponent.hasPrefix("com.microsoft.java.debug.plugin-")
+ }
}
}
diff --git a/macos/Sources/LitheCoreContracts/Language/LanguageToolingContracts.swift b/macos/Sources/LitheCoreContracts/Language/LanguageToolingContracts.swift
index e2c08fbce..beb15769f 100644
--- a/macos/Sources/LitheCoreContracts/Language/LanguageToolingContracts.swift
+++ b/macos/Sources/LitheCoreContracts/Language/LanguageToolingContracts.swift
@@ -170,7 +170,7 @@ package struct LanguageProviderCatalog: Sendable {
package static let compatibilityFallback = LanguageProviderCatalog(descriptors: [
LanguageProviderDescriptor(
id: "java", displayName: "Java", fileExtensions: ["java"],
- capabilities: [.run, .languageServer, .formatting, .testing],
+ capabilities: [.run, .languageServer, .debugAdapter, .formatting, .testing],
activationPolicy: .onDemand
),
LanguageProviderDescriptor(
@@ -526,6 +526,8 @@ package struct LanguageServerCodeAction: Identifiable, Equatable, Sendable {
@MainActor
package protocol LanguageServerSession: AnyObject {
var isRunning: Bool { get }
+ /// Packaged Java Test runner, if this JDT LS session was launched with one.
+ var javaTestRunnerURL: URL? { get }
var onDiagnostics: ((URL, [LanguageServerDiagnostic]) -> Void)? { get set }
var onLog: ((LanguageServerLogLevel, String, String?, String?) -> Void)? { get set }
var onStateChange: ((LanguageServerSessionState) -> Void)? { get set }
@@ -594,6 +596,11 @@ package protocol LanguageServerSession: AnyObject {
fileURL: URL,
completion: @escaping (Result) -> Void
) throws
+ func executeReturningValue(
+ _ command: LanguageServerCommand,
+ fileURL: URL,
+ completion: @escaping (Result) -> Void
+ ) throws
func resolveVirtualDocument(
uri: String,
completion: @escaping (Result) -> Void
@@ -634,6 +641,15 @@ package extension LanguageServerSession {
get { nil }
set {}
}
+ func executeReturningValue(
+ _ command: LanguageServerCommand,
+ fileURL: URL,
+ completion: @escaping (Result) -> Void
+ ) throws {
+ try execute(command, fileURL: fileURL) { result in
+ completion(result.map { .null })
+ }
+ }
var serverInfo: LanguageServerInfo? { nil }
var onServerInfoChange: ((LanguageServerInfo?) -> Void)? {
get { nil }
diff --git a/macos/Sources/LitheCoreContracts/Workspace/WorkspaceModels.swift b/macos/Sources/LitheCoreContracts/Workspace/WorkspaceModels.swift
index 542bf7d45..60a3403ba 100644
--- a/macos/Sources/LitheCoreContracts/Workspace/WorkspaceModels.swift
+++ b/macos/Sources/LitheCoreContracts/Workspace/WorkspaceModels.swift
@@ -36,9 +36,11 @@ package struct FileNode: Identifiable, Hashable, Sendable {
}
package struct WorkspaceSnapshot: Sendable {
+ package let id: UUID
package let root: FileNode
package let files: [URL]
- package init(root: FileNode, files: [URL]) {
+ package init(root: FileNode, files: [URL], id: UUID = UUID()) {
+ self.id = id
self.root = root
self.files = files
}
diff --git a/macos/Sources/LitheDebugModule/Application/DebugBreakpointLocationValidator.swift b/macos/Sources/LitheDebugModule/Application/DebugBreakpointLocationValidator.swift
new file mode 100644
index 000000000..6b7a42326
--- /dev/null
+++ b/macos/Sources/LitheDebugModule/Application/DebugBreakpointLocationValidator.swift
@@ -0,0 +1,98 @@
+import Foundation
+
+/// Performs a conservative, language-aware preflight before a source
+/// breakpoint is sent to a debug adapter. The adapter remains authoritative;
+/// this only prevents obviously non-executable Java gutter locations.
+public enum DebugBreakpointLocationValidator {
+ public static func isExecutableJavaLine(source: String, line: Int) -> Bool {
+ guard line > 0 else { return false }
+ let lines = source.components(separatedBy: .newlines)
+ guard line <= lines.count else { return false }
+
+ var inBlockComment = false
+ for (index, rawLine) in lines.enumerated() {
+ let code = codeWithoutJavaCommentsAndStrings(
+ rawLine,
+ inBlockComment: &inBlockComment
+ ).trimmingCharacters(in: .whitespacesAndNewlines)
+ guard index + 1 == line else { continue }
+ guard !code.isEmpty,
+ !code.hasPrefix("@"),
+ !isJavaTypeDeclaration(code),
+ !Self.nonExecutableOnlyLines.contains(code) else { return false }
+ return code.contains(where: { $0.isLetter || $0.isNumber || $0 == "_" })
+ }
+ return false
+ }
+
+ private static let nonExecutableOnlyLines: Set = [
+ "{", "}", "(", ")", "[", "]", ";", ",", ":"
+ ]
+
+ /// Type declarations do not represent a Java execution location. Modifiers
+ /// are parsed as tokens so declarations such as `public final class` and
+ /// `private static interface` are treated the same as their short forms.
+ private static func isJavaTypeDeclaration(_ code: String) -> Bool {
+ let tokens = code.split(whereSeparator: { $0 == " " || $0 == "\t" })
+ guard !tokens.isEmpty else { return false }
+ let modifiers: Set = [
+ "public", "private", "protected", "abstract", "final", "static", "sealed", "non-sealed", "strictfp"
+ ]
+ var index = 0
+ while index < tokens.count, modifiers.contains(tokens[index]) {
+ index += 1
+ }
+ guard index < tokens.count else { return false }
+ if tokens[index] == "@interface" { return true }
+ return ["class", "interface", "enum", "record"].contains(tokens[index])
+ }
+
+ private static func codeWithoutJavaCommentsAndStrings(
+ _ line: String,
+ inBlockComment: inout Bool
+ ) -> String {
+ var result = ""
+ var index = line.startIndex
+ var inString: Character?
+ while index < line.endIndex {
+ let next = line.index(after: index)
+ let character = line[index]
+ let following = next < line.endIndex ? line[next] : nil
+ if inBlockComment {
+ if character == "*", following == "/" {
+ inBlockComment = false
+ index = line.index(after: next)
+ } else {
+ index = next
+ }
+ continue
+ }
+ if let quote = inString {
+ if character == "\\" {
+ index = next < line.endIndex ? line.index(after: next) : next
+ } else if character == quote {
+ inString = nil
+ result.append(" ")
+ index = next
+ } else {
+ index = next
+ }
+ continue
+ }
+ if character == "/", following == "*" {
+ inBlockComment = true
+ index = line.index(after: next)
+ } else if character == "/", following == "/" {
+ break
+ } else if character == "\"" || character == "'" {
+ inString = character
+ result.append(" ")
+ index = next
+ } else {
+ result.append(character)
+ index = next
+ }
+ }
+ return result
+ }
+}
diff --git a/macos/Sources/LitheDebugModule/Application/DebugBreakpointPersistence.swift b/macos/Sources/LitheDebugModule/Application/DebugBreakpointPersistence.swift
new file mode 100644
index 000000000..9067aa91c
--- /dev/null
+++ b/macos/Sources/LitheDebugModule/Application/DebugBreakpointPersistence.swift
@@ -0,0 +1,52 @@
+import Foundation
+
+public struct PersistedDebugBreakpoint: Codable, Equatable, Sendable {
+ public let relativePath: String
+ public let line: Int
+ public let column: Int?
+ public let enabled: Bool
+ public let condition: String?
+ public let hitCondition: String?
+ public let logMessage: String?
+
+ public init(
+ relativePath: String,
+ line: Int,
+ column: Int? = nil,
+ enabled: Bool = true,
+ condition: String? = nil,
+ hitCondition: String? = nil,
+ logMessage: String? = nil
+ ) {
+ self.relativePath = relativePath
+ self.line = line
+ self.column = column
+ self.enabled = enabled
+ self.condition = condition
+ self.hitCondition = hitCondition
+ self.logMessage = logMessage
+ }
+}
+
+public struct DebugBreakpointSnapshot: Codable, Equatable, Sendable {
+ public static let currentVersion = 1
+
+ public let version: Int
+ public let areBreakpointsMuted: Bool
+ public let breakpoints: [PersistedDebugBreakpoint]
+
+ public init(
+ version: Int = Self.currentVersion,
+ areBreakpointsMuted: Bool = false,
+ breakpoints: [PersistedDebugBreakpoint]
+ ) {
+ self.version = version
+ self.areBreakpointsMuted = areBreakpointsMuted
+ self.breakpoints = breakpoints
+ }
+}
+
+public protocol DebugBreakpointPersisting: Sendable {
+ func loadBreakpoints(for workspaceURL: URL) throws -> DebugBreakpointSnapshot?
+ func saveBreakpoints(_ snapshot: DebugBreakpointSnapshot, for workspaceURL: URL) throws
+}
diff --git a/macos/Sources/LitheDebugModule/Application/DebugSteppingFilterPersistence.swift b/macos/Sources/LitheDebugModule/Application/DebugSteppingFilterPersistence.swift
new file mode 100644
index 000000000..7a64d3a9f
--- /dev/null
+++ b/macos/Sources/LitheDebugModule/Application/DebugSteppingFilterPersistence.swift
@@ -0,0 +1,7 @@
+import Foundation
+import LitheCoreContracts
+
+public protocol DebugSteppingFilterPersisting: Sendable {
+ func loadSteppingFilters(adapterID: String) throws -> DebugSteppingFilters?
+ func saveSteppingFilters(_ filters: DebugSteppingFilters, adapterID: String) throws
+}
diff --git a/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift b/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift
index b75ec9366..d76d7966c 100644
--- a/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift
+++ b/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift
@@ -4,43 +4,360 @@ import LitheCoreContracts
public struct GenericDebugBreakpoint: Identifiable, Equatable, Sendable {
public let fileURL: URL
public let line: Int
+ public let column: Int?
+ public let enabled: Bool
+ public let condition: String?
+ public let hitCondition: String?
+ public let logMessage: String?
public var verified: Bool
public var message: String?
- public var id: String { fileURL.standardizedFileURL.path + ":" + String(line) }
+ public var id: String {
+ fileURL.standardizedFileURL.path + ":" + String(line) + ":" + String(column ?? 0)
+ }
public var title: String { fileURL.lastPathComponent + ":" + String(line) }
+ public var isLogpoint: Bool { logMessage?.isEmpty == false }
+}
+
+public struct GenericDebugExceptionBreakpoint: Identifiable, Equatable, Sendable {
+ public let filter: String
+ public let label: String
+ public let description: String?
+ public let enabled: Bool
+ public let condition: String?
+ public let supportsCondition: Bool
+ public let conditionDescription: String?
+
+ public var id: String { filter }
+}
+
+public struct GenericDebugFunctionBreakpoint: Identifiable, Equatable, Sendable {
+ public let name: String
+ public let enabled: Bool
+ public let condition: String?
+ public let hitCondition: String?
+ public var verified: Bool
+ public var message: String?
+
+ public var id: String { name }
+}
+
+public struct GenericDebugDataBreakpoint: Identifiable, Equatable, Sendable {
+ public let dataID: String
+ public let label: String
+ public let enabled: Bool
+ public let accessType: String?
+ public let accessTypes: [String]
+ public let condition: String?
+ public let hitCondition: String?
+ public let canPersist: Bool
+ public var verified: Bool
+ public var message: String?
+
+ public var id: String { dataID + ":" + (accessType ?? "") }
+}
+
+public struct GenericDebugWatch: Identifiable, Equatable, Sendable {
+ public let expression: String
+ public var value: String?
+ public var type: String?
+ public var error: String?
+
+ public var id: String { expression }
+}
+
+public enum GenericDebugVariableRowContent: Equatable, Sendable {
+ case variable(DebugVariable)
+ case loadMore(parentVariableID: String?, nextCount: Int, remainingCount: Int?)
+}
+
+public struct GenericDebugVariableRow: Identifiable, Equatable, Sendable {
+ public let id: String
+ public let content: GenericDebugVariableRowContent
+ public let depth: Int
+
+ public var variable: DebugVariable? {
+ guard case .variable(let variable) = content else { return nil }
+ return variable
+ }
+}
+
+public struct GenericDebugStackFrameRow: Identifiable, Equatable, Sendable {
+ public let id: String
+ public let frame: DebugStackFrame?
+ public let hiddenFrameCount: Int
+
+ public var isHiddenGroup: Bool { frame == nil }
+}
+
+private struct GenericDebugVariablePageSegment: Equatable, Sendable {
+ let filter: DebugVariableFilter?
+ var nextStart: Int
+ let totalCount: Int?
+}
+
+private struct GenericDebugVariablePageState: Equatable, Sendable {
+ let reference: Int
+ var segments: [GenericDebugVariablePageSegment]
+ var loadedPageFingerprints: Set<[GenericDebugVariablePageItemFingerprint]>
+
+ var remainingCount: Int? {
+ var remaining = 0
+ for segment in segments {
+ guard let totalCount = segment.totalCount else { return nil }
+ remaining += max(0, totalCount - segment.nextStart)
+ }
+ return remaining
+ }
+}
+
+private struct GenericDebugVariablePageItemFingerprint: Hashable, Sendable {
+ let name: String
+ let value: String
+ let type: String?
+ let evaluateName: String?
+ let variablesReference: Int
+}
+
+private struct GenericDebugStartRequest: Equatable, Sendable {
+ let fileURL: URL
+ let rootURL: URL
+ let configuration: DebugLaunchConfiguration
+}
+
+private struct GenericDebugOutputNormalizer {
+ private enum State {
+ case normal
+ case escape
+ case controlSequence
+ case operatingSystemCommand
+ case operatingSystemCommandEscape
+ }
+
+ private var state = State.normal
+ private var sawCarriageReturn = false
+
+ mutating func normalize(_ rawOutput: String) -> String {
+ var normalized = String()
+ normalized.reserveCapacity(rawOutput.count)
+ for scalar in rawOutput.unicodeScalars {
+ switch state {
+ case .normal:
+ if scalar.value == 0x1B {
+ sawCarriageReturn = false
+ state = .escape
+ } else if scalar.value == 0x0D {
+ normalized.append("\n")
+ sawCarriageReturn = true
+ } else if scalar.value == 0x0A {
+ if !sawCarriageReturn { normalized.append("\n") }
+ sawCarriageReturn = false
+ } else if scalar.value == 0x09 || scalar.value >= 0x20 {
+ normalized.unicodeScalars.append(scalar)
+ sawCarriageReturn = false
+ }
+ case .escape:
+ sawCarriageReturn = false
+ switch scalar.value {
+ case 0x5B: // CSI: ESC [ ... final byte
+ state = .controlSequence
+ case 0x5D: // OSC: ESC ] ... BEL or ST
+ state = .operatingSystemCommand
+ default:
+ state = .normal
+ }
+ case .controlSequence:
+ sawCarriageReturn = false
+ if (0x40...0x7E).contains(scalar.value) {
+ state = .normal
+ }
+ case .operatingSystemCommand:
+ sawCarriageReturn = false
+ if scalar.value == 0x07 {
+ state = .normal
+ } else if scalar.value == 0x1B {
+ state = .operatingSystemCommandEscape
+ }
+ case .operatingSystemCommandEscape:
+ sawCarriageReturn = false
+ state = scalar.value == 0x5C ? .normal : .operatingSystemCommand
+ }
+ }
+ return normalized
+ }
+
+ mutating func reset() {
+ state = .normal
+ sawCarriageReturn = false
+ }
+}
+
+/// Cached presentation state for an inactive session. Inspection data is
+/// refreshed when the session becomes active so stale frame references are
+/// never reused across adapter sessions.
+private struct GenericDebugSessionSnapshot {
+ let providerID: String
+ let targetTitle: String?
+ var state: DebugAdapterState
+ var output: String
+ var errorMessage: String?
+ var stoppedReason: String?
+ var exceptionInfo: DebugExceptionInfo?
+ var capabilities: DebugAdapterCapabilities
+ var activeFileURL: URL?
+ var lastStartRequest: GenericDebugStartRequest?
+ var normalizer: GenericDebugOutputNormalizer
}
@MainActor
public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatureTarget {
@Published public private(set) var providerID: String?
+ @Published public private(set) var activeSessionID: DebugSessionID?
+ @Published public private(set) var sessionSummaries: [DebugSessionSummary] = []
@Published public private(set) var targetTitle: String?
@Published public private(set) var state: DebugAdapterState = .idle
@Published public private(set) var output = ""
@Published public private(set) var errorMessage: String?
@Published public private(set) var stoppedReason: String?
+ @Published public private(set) var exceptionInfo: DebugExceptionInfo?
@Published public private(set) var breakpoints: [GenericDebugBreakpoint] = []
+ @Published public private(set) var exceptionBreakpoints: [GenericDebugExceptionBreakpoint] = []
+ @Published public private(set) var functionBreakpoints: [GenericDebugFunctionBreakpoint] = []
+ @Published public private(set) var dataBreakpoints: [GenericDebugDataBreakpoint] = []
@Published public private(set) var threads: [DebugThread] = []
+ /// Thread IDs reported stopped by the adapter while the debuggee is paused.
+ /// A missing set means the adapter stopped all threads or did not provide a
+ /// thread ID, so the UI should show the session-level paused state instead.
+ @Published public private(set) var stoppedThreadIDs: Set = []
@Published public private(set) var stackFrames: [DebugStackFrame] = []
@Published public private(set) var scopes: [DebugScope] = []
+ @Published public private(set) var selectedScopeID: Int?
@Published public private(set) var variables: [DebugVariable] = []
+ @Published public private(set) var automaticVariables: [DebugVariable] = []
+ @Published public private(set) var variableChildren: [String: [DebugVariable]] = [:]
+ @Published public private(set) var expandedVariableIDs: Set = []
+ @Published public private(set) var loadingVariableIDs: Set = []
+ @Published private var variablePageStates: [String: GenericDebugVariablePageState] = [:]
+ @Published private var loadingVariablePageIDs: Set = []
+ @Published public private(set) var watches: [GenericDebugWatch] = []
@Published public private(set) var selectedThreadID: Int?
@Published public private(set) var selectedFrameID: Int?
+ @Published public private(set) var areBreakpointsMuted = false
+ @Published public private(set) var capabilities: DebugAdapterCapabilities = .unknown
+ @Published public private(set) var stoppedFrame: DebugStackFrame?
+ /// The frame currently selected in the call stack, which may differ from
+ /// the frame that initially caused the stop.
+ @Published public private(set) var selectedFrame: DebugStackFrame?
+ @Published public private(set) var javaSteppingFilters: DebugSteppingFilters?
+ @Published public private(set) var consoleHistory: [String] = []
+ @Published public private(set) var areFilteredStackFramesExpanded = false
+ /// Prevents overlapping execution requests while the adapter is still
+ /// acknowledging the previous step/continue operation.
+ @Published public private(set) var isExecutionRequestPending = false
+
+ /// Delivers the selected stopped frame to the host editor for source
+ /// navigation. The Debug module does not own editor presentation.
+ public var onStoppedLocation: ((URL, Int, Int) -> Void)?
+ /// Lets the host derive source-referenced expressions after the selected
+ /// frame has completed its inspection-state reset.
+ public var onAutomaticVariableInspectionRequest: ((DebugStackFrame) -> Void)?
+ /// Lets the host activate its native Terminal module without coupling
+ /// Debug to a platform process or presentation implementation.
+ public var onRunInTerminalRequest: DebugRunInTerminalRequestHandler? {
+ get { sessions.onRunInTerminalRequest }
+ set { sessions.onRunInTerminalRequest = newValue }
+ }
+
+ /// Routes an integrated-terminal reverse request with its owning session.
+ public var onSessionRunInTerminalRequest: ((
+ DebugSessionID,
+ DebugRunInTerminalRequest,
+ @escaping DebugRunInTerminalCompletion
+ ) -> Void)? {
+ get { sessions.onSessionRunInTerminalRequest }
+ set { sessions.onSessionRunInTerminalRequest = newValue }
+ }
+
+ /// Notifies the host when the visible debugger session changes.
+ public var onSessionSelectionChanged: ((DebugSessionID?) -> Void)?
+ /// Notifies the host after a debugger session has been stopped and its
+ /// adapter resources have been released.
+ public var onSessionStopped: ((DebugSessionID) -> Void)?
private let sessions: DebugAdapterSessionManager
- private var requestedLinesByFile: [URL: Set] = [:]
+ private let breakpointPersistence: (any DebugBreakpointPersisting)?
+ private let breakpointRelocator: (any DebugBreakpointRelocating)?
+ private let steppingFilterResolver: (any DebugSteppingFilterResolving)?
+ private let steppingFilterPersistence: (any DebugSteppingFilterPersisting)?
+ private var requestedBreakpointsByFile: [URL: [Int: DebugSourceBreakpoint]] = [:]
+ private var workspaceURL: URL?
+ private var activeFileURL: URL?
+ private var lastStartRequest: GenericDebugStartRequest?
+ private var sessionSnapshots: [DebugSessionID: GenericDebugSessionSnapshot] = [:]
+ private var consoleHistoryBySession: [DebugSessionID: [String]] = [:]
+ private var consoleHistoryCursorBySession: [DebugSessionID: Int] = [:]
+ private var consoleHistoryDraftBySession: [DebugSessionID: String] = [:]
+ private let maximumConsoleHistoryEntries = 100
private let maximumOutputCharacters = 400_000
+ private let variablePageSize = 100
+ private let maximumAutomaticVariables = 8
+ private var watchGeneration = 0
+ private var inspectionGeneration = 0
+ private var automaticExpressionOrder: [String] = []
+ private var automaticExpressionResults: [String: DebugVariable] = [:]
+ private var automaticExpressionFrameID: Int?
+ private static let rootVariablePageID = "__lithe_debug_root_variables__"
+ private var debuggeeOutputNormalizer = GenericDebugOutputNormalizer()
- public init(sessions: DebugAdapterSessionManager) {
+ public init(
+ sessions: DebugAdapterSessionManager,
+ breakpointPersistence: (any DebugBreakpointPersisting)? = nil,
+ breakpointRelocator: (any DebugBreakpointRelocating)? = nil,
+ steppingFilterResolver: (any DebugSteppingFilterResolving)? = nil,
+ steppingFilterPersistence: (any DebugSteppingFilterPersisting)? = nil
+ ) {
self.sessions = sessions
- sessions.onStateChange = { [weak self] providerID, state in
- guard self?.providerID == providerID else { return }
- self?.state = state
+ self.breakpointPersistence = breakpointPersistence
+ self.breakpointRelocator = breakpointRelocator
+ self.steppingFilterResolver = steppingFilterResolver
+ self.steppingFilterPersistence = steppingFilterPersistence
+ sessionSummaries = sessions.sessionSummaries
+ sessions.onSessionStateChange = { [weak self] sessionID, providerID, state in
+ guard let self else { return }
+ self.sessionSummaries = self.sessions.sessionSummaries
+ if self.activeSessionID == sessionID {
+ self.providerID = providerID
+ self.state = state
+ if state != .paused {
+ self.isExecutionRequestPending = false
+ }
+ if state == .running {
+ self.clearStoppedInspection()
+ }
+ self.saveActiveSessionSnapshot()
+ } else {
+ self.updateInactiveSessionState(
+ sessionID,
+ providerID: providerID,
+ state: state
+ )
+ }
}
- sessions.onEvent = { [weak self] providerID, event in
- guard self?.providerID == providerID else { return }
- self?.consume(event)
+ sessions.onSessionEvent = { [weak self] sessionID, providerID, event in
+ guard let self else { return }
+ self.sessionSummaries = self.sessions.sessionSummaries
+ if self.activeSessionID == sessionID {
+ self.consume(event)
+ self.saveActiveSessionSnapshot()
+ } else {
+ self.consumeInactiveSessionEvent(
+ sessionID,
+ providerID: providerID,
+ event: event
+ )
+ }
}
+ loadJavaSteppingFilters()
}
public var isSessionActive: Bool {
@@ -48,6 +365,72 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu
}
public var canControl: Bool { state == .running || state == .paused }
+ public var canRestart: Bool {
+ // Some Java adapters (notably older JDT LS builds) do not advertise
+ // DAP `restart`. We can still provide the IDEA-style rerun action by
+ // relaunching the exact saved request after stopping this session.
+ canControl && (capabilities.supportsRestartRequest || lastStartRequest != nil)
+ }
+ public var canTerminate: Bool {
+ canControl && capabilities.supportsTerminateRequest
+ }
+ public var canStepBack: Bool {
+ state == .paused && capabilities.supportsStepBack
+ }
+ public var canRetry: Bool {
+ lastStartRequest != nil && (state == .failed || state == .terminated)
+ }
+ public var visibleVariableRows: [GenericDebugVariableRow] {
+ var rows: [GenericDebugVariableRow] = []
+ appendVisibleVariables(presentedVariables, parentPath: "root", depth: 0, to: &rows)
+ appendVariableLoadMoreRow(
+ parentVariableID: nil,
+ parentPath: "root",
+ depth: 0,
+ to: &rows
+ )
+ return rows
+ }
+ /// Root-scope values plus source-referenced expressions evaluated for the
+ /// selected frame, matching the compact variable list used by Java IDEs.
+ public var presentedVariables: [DebugVariable] {
+ var knownNames = Set()
+ var result: [DebugVariable] = []
+ for variable in variables + automaticVariables {
+ guard knownNames.insert(variable.name).inserted else { continue }
+ result.append(variable)
+ }
+ return result
+ }
+ public var visibleStackFrameRows: [GenericDebugStackFrameRow] {
+ guard javaSteppingFilters?.hideFilteredStackFrames == true,
+ !areFilteredStackFramesExpanded else {
+ return stackFrames.map(stackFrameRow)
+ }
+ var rows: [GenericDebugStackFrameRow] = []
+ var hiddenCount = 0
+ var hiddenStartID: Int?
+ for frame in stackFrames {
+ if frame.isFiltered {
+ hiddenCount += 1
+ hiddenStartID = hiddenStartID ?? frame.id
+ continue
+ }
+ appendHiddenStackFrames(
+ count: hiddenCount,
+ startID: hiddenStartID,
+ to: &rows
+ )
+ hiddenCount = 0
+ hiddenStartID = nil
+ rows.append(stackFrameRow(frame))
+ }
+ appendHiddenStackFrames(count: hiddenCount, startID: hiddenStartID, to: &rows)
+ return rows
+ }
+ public var hiddenStackFrameCount: Int {
+ stackFrames.lazy.filter(\.isFiltered).count
+ }
public func start(
fileURL: URL,
@@ -55,30 +438,148 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu
configuration: DebugLaunchConfiguration
) -> Bool {
stop()
+ return startSession(
+ fileURL: fileURL,
+ rootURL: rootURL,
+ configuration: configuration
+ )
+ }
+
+ /// Starts another debug session while keeping existing sessions alive.
+ /// The new session becomes the active session shown by the Debug tool
+ /// window.
+ @discardableResult
+ public func startAdditional(
+ fileURL: URL,
+ rootURL: URL,
+ configuration: DebugLaunchConfiguration
+ ) -> Bool {
+ let previousSessionID = activeSessionID
+ let previousSnapshot = previousSessionID.flatMap { sessionSnapshots[$0] }
+ saveActiveSessionSnapshot()
+ let started = startSession(
+ fileURL: fileURL,
+ rootURL: rootURL,
+ configuration: configuration
+ )
+ guard !started,
+ let previousSessionID,
+ let previousSnapshot,
+ let previousSummary = sessions.sessionSummaries.first(where: { $0.id == previousSessionID })
+ else {
+ return started
+ }
+ _ = sessions.select(sessionID: previousSessionID)
+ activeSessionID = previousSessionID
+ providerID = previousSnapshot.providerID
+ onSessionSelectionChanged?(previousSessionID)
+ sessionSnapshots[previousSessionID] = previousSnapshot
+ restoreSessionSnapshot(
+ previousSessionID,
+ summary: previousSummary
+ )
+ sessionSummaries = sessions.sessionSummaries
+ return false
+ }
+
+ /// Makes a registered session active without starting or stopping it.
+ /// Paused sessions refresh their inspection context after the switch.
+ @discardableResult
+ public func selectSession(_ sessionID: DebugSessionID) -> Bool {
+ guard sessionID != activeSessionID,
+ let summary = sessions.sessionSummaries.first(where: { $0.id == sessionID })
+ else { return false }
+ saveActiveSessionSnapshot()
+ invalidateInspectionRequests()
+ guard sessions.select(sessionID: sessionID) else { return false }
+ activeSessionID = sessionID
+ providerID = summary.providerID
+ isExecutionRequestPending = false
+ onSessionSelectionChanged?(sessionID)
+ publishConsoleHistory(for: sessionID)
+ restoreSessionSnapshot(sessionID, summary: summary)
+ sessionSummaries = sessions.sessionSummaries
+ resetInspectionState()
+ if state == .paused {
+ let generation = inspectionGeneration
+ loadStoppedContext(
+ threadID: nil,
+ generation: generation,
+ shouldLoadExceptionInfo: false
+ )
+ }
+ return true
+ }
+
+ /// Stops one session. Inactive sessions do not disturb the currently
+ /// displayed debugger state.
+ public func stopSession(_ sessionID: DebugSessionID) {
+ if activeSessionID == sessionID {
+ stop()
+ return
+ }
+ sessions.stop(sessionID: sessionID)
+ sessionSnapshots[sessionID] = nil
+ sessionSummaries = sessions.sessionSummaries
+ onSessionStopped?(sessionID)
+ }
+
+ private func startSession(
+ fileURL: URL,
+ rootURL: URL,
+ configuration: DebugLaunchConfiguration
+ ) -> Bool {
+ let request = GenericDebugStartRequest(
+ fileURL: fileURL.standardizedFileURL,
+ rootURL: rootURL.standardizedFileURL,
+ configuration: configuration
+ )
+ lastStartRequest = request
+ activeFileURL = request.fileURL
providerID = sessionsProviderID(for: fileURL)
targetTitle = configuration.name
output = ""
+ debuggeeOutputNormalizer.reset()
errorMessage = nil
stoppedReason = nil
+ isExecutionRequestPending = false
+ exceptionInfo = nil
threads = []
+ stoppedThreadIDs = []
stackFrames = []
scopes = []
- variables = []
+ selectedScopeID = nil
+ resetVariableTree()
+ resetAutomaticVariables()
+ invalidateWatchResults()
+ capabilities = .unknown
selectedThreadID = nil
selectedFrameID = nil
+ stoppedFrame = nil
+ selectedFrame = nil
do {
- if let lines = requestedLinesByFile[fileURL.standardizedFileURL] {
- try sessions.setBreakpoints(
- lines.sorted().map { DebugSourceBreakpoint(line: $0) },
- in: fileURL
+ try synchronizeRequestedBreakpoints(for: providerID)
+ if !dataBreakpoints.isEmpty {
+ try sessions.setDataBreakpoints(coreDataBreakpoints, for: request.fileURL)
+ }
+ let effectiveConfiguration: DebugLaunchConfiguration
+ if providerID == "java", let javaSteppingFilters {
+ effectiveConfiguration = request.configuration.applying(
+ steppingFilters: javaSteppingFilters
)
+ } else {
+ effectiveConfiguration = request.configuration
}
- let session = try sessions.launch(
- for: fileURL,
- rootURL: rootURL,
- configuration: configuration
+ let launched = try sessions.launchNew(
+ for: request.fileURL,
+ rootURL: request.rootURL,
+ configuration: effectiveConfiguration
)
- state = session.state
+ activeSessionID = launched.id
+ state = launched.session.state
+ sessionSummaries = sessions.sessionSummaries
+ publishConsoleHistory(for: launched.id)
+ saveActiveSessionSnapshot()
return true
} catch {
state = .failed
@@ -88,125 +589,1198 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu
}
}
+ @discardableResult
+ public func retry() -> Bool {
+ guard let request = lastStartRequest else { return false }
+ return start(
+ fileURL: request.fileURL,
+ rootURL: request.rootURL,
+ configuration: request.configuration
+ )
+ }
+
public func stop() {
- if let providerID {
- sessions.stop(providerID: providerID)
+ invalidateInspectionRequests()
+ if let activeFileURL {
+ dataBreakpoints.removeAll { !$0.canPersist }
+ try? sessions.setDataBreakpoints(coreDataBreakpoints, for: activeFileURL)
+ }
+ if let activeSessionID {
+ sessions.stop(sessionID: activeSessionID)
+ sessionSnapshots[activeSessionID] = nil
+ onSessionStopped?(activeSessionID)
}
+ sessionSummaries = sessions.sessionSummaries
+ if let replacement = sessionSummaries.last,
+ sessions.select(sessionID: replacement.id) {
+ activeSessionID = replacement.id
+ providerID = replacement.providerID
+ onSessionSelectionChanged?(replacement.id)
+ publishConsoleHistory(for: replacement.id)
+ restoreSessionSnapshot(replacement.id, summary: replacement)
+ resetInspectionState()
+ if state == .paused {
+ let generation = inspectionGeneration
+ loadStoppedContext(
+ threadID: nil,
+ generation: generation,
+ shouldLoadExceptionInfo: false
+ )
+ }
+ return
+ }
+ activeSessionID = nil
+ onSessionSelectionChanged?(nil)
+ consoleHistory = []
state = .idle
+ isExecutionRequestPending = false
stoppedReason = nil
+ exceptionInfo = nil
selectedThreadID = nil
selectedFrameID = nil
+ stoppedFrame = nil
+ selectedFrame = nil
threads = []
+ stoppedThreadIDs = []
stackFrames = []
+ areFilteredStackFramesExpanded = false
scopes = []
- variables = []
+ selectedScopeID = nil
+ resetVariableTree()
+ resetAutomaticVariables()
+ invalidateWatchResults()
+ capabilities = .unknown
+ activeFileURL = nil
+ debuggeeOutputNormalizer.reset()
}
public func reset() {
- stop()
+ invalidateInspectionRequests()
+ sessions.stopAll()
+ sessionSnapshots.removeAll()
+ activeSessionID = nil
+ sessionSummaries = []
+ consoleHistory = []
+ consoleHistoryBySession.removeAll()
+ consoleHistoryCursorBySession.removeAll()
+ consoleHistoryDraftBySession.removeAll()
+ state = .idle
+ isExecutionRequestPending = false
+ stoppedReason = nil
+ exceptionInfo = nil
+ selectedThreadID = nil
+ selectedFrameID = nil
+ stoppedFrame = nil
+ selectedFrame = nil
+ threads = []
+ stoppedThreadIDs = []
+ stackFrames = []
+ areFilteredStackFramesExpanded = false
+ scopes = []
+ selectedScopeID = nil
+ resetVariableTree()
+ resetAutomaticVariables()
+ invalidateWatchResults()
+ capabilities = .unknown
+ activeFileURL = nil
+ debuggeeOutputNormalizer.reset()
providerID = nil
targetTitle = nil
output = ""
+ debuggeeOutputNormalizer.reset()
errorMessage = nil
breakpoints = []
- requestedLinesByFile = [:]
+ exceptionBreakpoints = []
+ functionBreakpoints = []
+ dataBreakpoints = []
+ watches = []
+ requestedBreakpointsByFile = [:]
+ areBreakpointsMuted = false
+ workspaceURL = nil
+ lastStartRequest = nil
+ }
+
+ public func openWorkspace(at workspaceURL: URL) {
+ let root = workspaceURL.standardizedFileURL
+ guard self.workspaceURL != root else { return }
+ self.workspaceURL = root
+ requestedBreakpointsByFile = [:]
+ breakpoints = []
+ areBreakpointsMuted = false
+ guard let breakpointPersistence else { return }
+ do {
+ guard let snapshot = try breakpointPersistence.loadBreakpoints(for: root),
+ snapshot.version == DebugBreakpointSnapshot.currentVersion else { return }
+ areBreakpointsMuted = snapshot.areBreakpointsMuted
+ for persisted in snapshot.breakpoints {
+ guard let fileURL = restoredFileURL(for: persisted.relativePath, root: root),
+ persisted.line > 0 else { continue }
+ var values = requestedBreakpointsByFile[fileURL] ?? [:]
+ values[persisted.line] = DebugSourceBreakpoint(
+ line: persisted.line,
+ column: persisted.column,
+ enabled: persisted.enabled,
+ condition: normalizedOptionalText(persisted.condition),
+ hitCondition: normalizedOptionalText(persisted.hitCondition),
+ logMessage: normalizedOptionalText(persisted.logMessage)
+ )
+ requestedBreakpointsByFile[fileURL] = values
+ }
+ reconcileBreakpoints()
+ } catch {
+ record(error)
+ }
}
public func toggleBreakpoint(fileURL: URL, line: Int) {
guard line > 0 else { return }
let normalizedURL = fileURL.standardizedFileURL
- var lines = requestedLinesByFile[normalizedURL] ?? []
- if lines.contains(line) {
- lines.remove(line)
+ var values = requestedBreakpointsByFile[normalizedURL] ?? [:]
+ if values[line] != nil {
+ values[line] = nil
} else {
- lines.insert(line)
+ values[line] = DebugSourceBreakpoint(line: line)
}
- requestedLinesByFile[normalizedURL] = lines
+ requestedBreakpointsByFile[normalizedURL] = values.isEmpty ? nil : values
reconcileBreakpoints()
- try? sessions.setBreakpoints(
- lines.sorted().map { DebugSourceBreakpoint(line: $0) },
- in: normalizedURL
+ persistBreakpoints()
+ synchronizeBreakpoints(for: normalizedURL)
+ }
+
+ public func updateBreakpoint(
+ fileURL: URL,
+ line: Int,
+ enabled: Bool,
+ condition: String?,
+ hitCondition: String?,
+ logMessage: String?
+ ) {
+ guard line > 0 else { return }
+ let normalizedURL = fileURL.standardizedFileURL
+ var values = requestedBreakpointsByFile[normalizedURL] ?? [:]
+ values[line] = DebugSourceBreakpoint(
+ line: line,
+ enabled: enabled,
+ condition: normalizedOptionalText(condition),
+ hitCondition: normalizedOptionalText(hitCondition),
+ logMessage: normalizedOptionalText(logMessage)
+ )
+ requestedBreakpointsByFile[normalizedURL] = values
+ reconcileBreakpoints()
+ persistBreakpoints()
+ synchronizeBreakpoints(for: normalizedURL)
+ }
+
+ public func setBreakpointEnabled(_ breakpoint: GenericDebugBreakpoint, enabled: Bool) {
+ updateBreakpoint(
+ fileURL: breakpoint.fileURL,
+ line: breakpoint.line,
+ enabled: enabled,
+ condition: breakpoint.condition,
+ hitCondition: breakpoint.hitCondition,
+ logMessage: breakpoint.logMessage
+ )
+ }
+
+ public func removeBreakpoint(_ breakpoint: GenericDebugBreakpoint) {
+ let fileURL = breakpoint.fileURL.standardizedFileURL
+ var values = requestedBreakpointsByFile[fileURL] ?? [:]
+ values[breakpoint.line] = nil
+ requestedBreakpointsByFile[fileURL] = values.isEmpty ? nil : values
+ reconcileBreakpoints()
+ persistBreakpoints()
+ synchronizeBreakpoints(for: fileURL)
+ }
+
+ public func removeAllBreakpoints() {
+ let fileURLs = requestedBreakpointsByFile.keys.sorted { $0.path < $1.path }
+ requestedBreakpointsByFile = [:]
+ reconcileBreakpoints()
+ persistBreakpoints()
+ for fileURL in fileURLs { synchronizeBreakpoints(for: fileURL) }
+ }
+
+ public func toggleBreakpointMute() {
+ areBreakpointsMuted.toggle()
+ persistBreakpoints()
+ for fileURL in requestedBreakpointsByFile.keys.sorted(by: { $0.path < $1.path }) {
+ synchronizeBreakpoints(for: fileURL)
+ }
+ }
+
+ public func applySourceEdit(
+ fileURL: URL,
+ source: String,
+ edit: DebugSourceEdit
+ ) {
+ let normalizedURL = fileURL.standardizedFileURL
+ guard let breakpointRelocator,
+ let values = requestedBreakpointsByFile[normalizedURL],
+ !values.isEmpty else { return }
+ let current = values.values.sorted {
+ ($0.line, $0.column ?? 0) < ($1.line, $1.column ?? 0)
+ }
+ guard sourceEditMayRelocateBreakpoints(source: source, edit: edit, breakpoints: current)
+ else { return }
+ do {
+ let relocated = try breakpointRelocator.relocateDebugBreakpoints(
+ source: source,
+ edit: edit,
+ breakpoints: current
+ )
+ guard relocated != current else { return }
+ requestedBreakpointsByFile[normalizedURL] = Dictionary(
+ uniqueKeysWithValues: relocated.map { ($0.line, $0) }
+ )
+ reconcileBreakpoints()
+ persistBreakpoints()
+ synchronizeBreakpoints(for: normalizedURL)
+ } catch {
+ record(error)
+ }
+ }
+
+ private func sourceEditMayRelocateBreakpoints(
+ source: String,
+ edit: DebugSourceEdit,
+ breakpoints: [DebugSourceBreakpoint]
+ ) -> Bool {
+ if breakpoints.contains(where: { $0.column != nil })
+ || edit.replacement.utf16.contains(10) {
+ return true
+ }
+ guard edit.startUTF16Offset >= 0,
+ edit.endUTF16Offset >= edit.startUTF16Offset else { return true }
+ guard edit.startUTF16Offset != edit.endUTF16Offset else { return false }
+ let sourceUTF16 = source.utf16
+ guard let start = sourceUTF16.index(
+ sourceUTF16.startIndex,
+ offsetBy: edit.startUTF16Offset,
+ limitedBy: sourceUTF16.endIndex
+ ), let end = sourceUTF16.index(
+ sourceUTF16.startIndex,
+ offsetBy: edit.endUTF16Offset,
+ limitedBy: sourceUTF16.endIndex
+ ) else { return true }
+ return sourceUTF16[start..) -> Void
+ ) {
+ guard state == .paused,
+ capabilities.supportsStepInTargetsRequest,
+ let selectedFrameID,
+ let session = activeSession else {
+ completion(.failure(DebugAdapterCapabilityError.unsupported("smart step into")))
+ return
+ }
+ session.requestStepInTargets(frameID: selectedFrameID) { [weak self] result in
+ if case .failure(let error) = result { self?.record(error) }
+ completion(result)
+ }
+ }
+
+ public func smartStepInto(_ target: DebugStepInTarget) {
+ guard let selectedThreadID, let session = activeSession else { return }
+ session.execute(.stepIn, threadID: selectedThreadID, targetID: target.id)
+ }
+
+ public func requestRunToCursor(
+ fileURL: URL,
+ line: Int,
+ column: Int?,
+ completion: @escaping (Result<[DebugGotoTarget], Error>) -> Void
+ ) {
+ guard state == .paused,
+ capabilities.supportsGotoTargetsRequest,
+ let session = activeSession else {
+ completion(.failure(DebugAdapterCapabilityError.unsupported("run to cursor")))
+ return
+ }
+ session.requestGotoTargets(
+ fileURL: fileURL,
+ line: line,
+ column: column
+ ) { [weak self] result in
+ if case .failure(let error) = result { self?.record(error) }
+ completion(result)
+ }
+ }
+
+ public func runToCursor(_ target: DebugGotoTarget) {
+ guard let selectedThreadID, let session = activeSession else { return }
+ session.execute(.goto, threadID: selectedThreadID, targetID: target.id)
+ }
+
public func inspectThreads() {
guard let session = activeSession else { return }
+ let generation = inspectionGeneration
session.requestThreads { [weak self] result in
+ guard let self, self.inspectionGeneration == generation else { return }
switch result {
case .success(let threads):
- self?.threads = threads
- if self?.selectedThreadID == nil { self?.selectedThreadID = threads.first?.id }
- case .failure(let error): self?.record(error)
+ self.threads = threads
+ if self.selectedThreadID == nil { self.selectedThreadID = threads.first?.id }
+ case .failure(let error): self.record(error)
}
}
}
public func selectThread(_ thread: DebugThread) {
+ let generation = beginInspectionTransition()
+ exceptionInfo = nil
selectedThreadID = thread.id
+ selectedFrameID = nil
+ selectedFrame = nil
+ stackFrames = []
+ areFilteredStackFramesExpanded = false
+ scopes = []
+ selectedScopeID = nil
+ resetVariableTree()
+ resetAutomaticVariables()
+ invalidateWatchResults()
guard let session = activeSession else { return }
session.requestStackTrace(threadID: thread.id) { [weak self] result in
+ guard let self,
+ self.inspectionGeneration == generation,
+ self.selectedThreadID == thread.id else { return }
switch result {
case .success(let frames):
- self?.stackFrames = frames
- self?.selectedFrameID = frames.first?.id
- if let frame = frames.first { self?.selectFrame(frame) }
- case .failure(let error): self?.record(error)
+ self.stackFrames = frames
+ self.areFilteredStackFramesExpanded = false
+ let preferredFrame = self.preferredStoppedFrame(in: frames)
+ self.selectedFrameID = preferredFrame?.id
+ if let frame = preferredFrame {
+ self.selectFrame(frame, generation: generation)
+ } else {
+ self.selectedFrame = nil
+ }
+ case .failure(let error): self.record(error)
}
}
}
public func selectFrame(_ frame: DebugStackFrame) {
+ let generation = beginInspectionTransition()
+ selectFrame(frame, generation: generation)
+ }
+
+ private func selectFrame(_ frame: DebugStackFrame, generation: Int) {
selectedFrameID = frame.id
+ selectedFrame = frame
+ scopes = []
+ selectedScopeID = nil
+ resetVariableTree()
+ resetAutomaticVariables()
+ invalidateWatchResults()
+ publishStoppedLocation(frame)
+ onAutomaticVariableInspectionRequest?(frame)
+ refreshWatches()
guard let session = activeSession else { return }
session.requestScopes(frameID: frame.id) { [weak self] result in
+ guard let self,
+ self.inspectionGeneration == generation,
+ self.selectedFrameID == frame.id else { return }
switch result {
case .success(let scopes):
- self?.scopes = scopes
+ self.scopes = scopes
if let scope = scopes.first(where: { !$0.expensive }) ?? scopes.first {
- self?.loadVariables(reference: scope.variablesReference)
+ self.selectScope(scope, frameID: frame.id, generation: generation)
} else {
- self?.variables = []
+ self.resetVariableTree()
}
- case .failure(let error): self?.record(error)
+ case .failure(let error): self.record(error)
}
}
}
- public func loadVariables(reference: Int) {
- guard let session = activeSession else { return }
- session.requestVariables(reference: reference) { [weak self] result in
- switch result {
- case .success(let variables): self?.variables = variables
- case .failure(let error): self?.record(error)
- }
- }
+ /// Selects the stack-frame scope whose variables are shown in the inspector.
+ public func selectScope(_ scope: DebugScope) {
+ guard let frameID = selectedFrameID else { return }
+ selectScope(scope, frameID: frameID, generation: inspectionGeneration)
}
- public func evaluate(_ expression: String) {
- let value = expression.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !value.isEmpty, let session = activeSession else { return }
- session.evaluate(value, frameID: selectedFrameID) { [weak self] result in
- switch result {
- case .success(let variable):
- self?.append("\(value) = \(variable.value)\n")
- case .failure(let error): self?.record(error)
- }
- }
+ private func selectScope(_ scope: DebugScope, frameID: Int, generation: Int) {
+ guard selectedFrameID == frameID, inspectionGeneration == generation else { return }
+ selectedScopeID = scope.id
+ loadVariables(
+ reference: scope.variablesReference,
+ namedVariables: scope.namedVariables,
+ indexedVariables: scope.indexedVariables,
+ frameID: frameID,
+ generation: generation
+ )
}
- public func clearOutput() { output = "" }
-
- private var activeSession: (any DebugAdapterControllingSession)? {
+ public func loadVariables(reference: Int) {
+ loadVariables(
+ reference: reference,
+ namedVariables: 0,
+ indexedVariables: 0,
+ frameID: selectedFrameID,
+ generation: inspectionGeneration
+ )
+ }
+
+ /// Evaluates identifiers referenced by the paused source line so fields
+ /// such as Java's `service` appear beside locals and as editor inlays.
+ public func requestAutomaticVariables(_ expressions: [String]) {
+ guard state == .paused,
+ let frameID = selectedFrameID,
+ let session = activeSession else { return }
+ var knownExpressions = Set()
+ var orderedExpressions: [String] = []
+ for rawExpression in expressions {
+ let expression = rawExpression.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !expression.isEmpty,
+ knownExpressions.insert(expression).inserted else { continue }
+ orderedExpressions.append(expression)
+ if orderedExpressions.count == maximumAutomaticVariables { break }
+ }
+ guard automaticExpressionFrameID != frameID
+ || automaticExpressionOrder != orderedExpressions else { return }
+
+ automaticExpressionFrameID = frameID
+ automaticExpressionOrder = orderedExpressions
+ automaticExpressionResults = [:]
+ automaticVariables = []
+ guard !orderedExpressions.isEmpty else { return }
+
+ let generation = inspectionGeneration
+ for expression in orderedExpressions {
+ evaluateAutomaticVariable(
+ expression,
+ evaluatedExpression: expression,
+ allowsJavaFieldFallback: true,
+ orderedExpressions: orderedExpressions,
+ frameID: frameID,
+ generation: generation,
+ session: session
+ )
+ }
+ }
+
+ private func evaluateAutomaticVariable(
+ _ expression: String,
+ evaluatedExpression: String,
+ allowsJavaFieldFallback: Bool,
+ orderedExpressions: [String],
+ frameID: Int,
+ generation: Int,
+ session: any DebugAdapterControllingSession
+ ) {
+ session.evaluate(evaluatedExpression, frameID: frameID) { [weak self] result in
+ guard let self,
+ self.state == .paused,
+ self.inspectionGeneration == generation,
+ self.selectedFrameID == frameID,
+ self.automaticExpressionFrameID == frameID,
+ self.automaticExpressionOrder == orderedExpressions else { return }
+ switch result {
+ case .success(let variable):
+ let displayName = expression.split(separator: ".").last.map(String.init)
+ ?? expression
+ self.automaticExpressionResults[expression] = DebugVariable(
+ id: variable.id,
+ name: displayName,
+ value: variable.value,
+ type: variable.type,
+ evaluateName: variable.evaluateName ?? evaluatedExpression,
+ variablesReference: variable.variablesReference,
+ containerReference: variable.containerReference,
+ namedVariables: variable.namedVariables,
+ indexedVariables: variable.indexedVariables
+ )
+ self.publishAutomaticVariables(orderedExpressions)
+ case .failure:
+ if allowsJavaFieldFallback,
+ self.providerID == "java",
+ !expression.contains(".") {
+ self.evaluateAutomaticVariable(
+ expression,
+ evaluatedExpression: "this.\(expression)",
+ allowsJavaFieldFallback: false,
+ orderedExpressions: orderedExpressions,
+ frameID: frameID,
+ generation: generation,
+ session: session
+ )
+ } else {
+ // Source-driven evaluation is speculative. Invalid
+ // candidates should not flood the user's Debug Console.
+ self.publishAutomaticVariables(orderedExpressions)
+ }
+ }
+ }
+ }
+
+ private func publishAutomaticVariables(_ orderedExpressions: [String]) {
+ automaticVariables = orderedExpressions.compactMap {
+ automaticExpressionResults[$0]
+ }
+ }
+
+ private func loadVariables(
+ reference: Int,
+ namedVariables: Int,
+ indexedVariables: Int,
+ frameID: Int?,
+ generation: Int
+ ) {
+ resetVariableTree()
+ variablePageStates[Self.rootVariablePageID] = makeVariablePageState(
+ reference: reference,
+ namedVariables: namedVariables,
+ indexedVariables: indexedVariables
+ )
+ requestVariablePage(
+ parentVariableID: nil,
+ frameID: frameID,
+ generation: generation,
+ expandsParent: false
+ )
+ }
+
+ public func toggleVariableExpansion(_ variable: DebugVariable) {
+ guard variable.isExpandable else { return }
+ if expandedVariableIDs.contains(variable.id) {
+ expandedVariableIDs.remove(variable.id)
+ return
+ }
+ if variableChildren[variable.id] != nil {
+ expandedVariableIDs.insert(variable.id)
+ return
+ }
+ guard !loadingVariableIDs.contains(variable.id), activeSession != nil else { return }
+ loadingVariableIDs.insert(variable.id)
+ variablePageStates[variable.id] = makeVariablePageState(
+ reference: variable.variablesReference,
+ namedVariables: variable.namedVariables,
+ indexedVariables: variable.indexedVariables
+ )
+ requestVariablePage(
+ parentVariableID: variable.id,
+ frameID: selectedFrameID,
+ generation: inspectionGeneration,
+ expandsParent: true
+ )
+ }
+
+ public func loadMoreVariables(parentVariableID: String?) {
+ requestVariablePage(
+ parentVariableID: parentVariableID,
+ frameID: selectedFrameID,
+ generation: inspectionGeneration,
+ expandsParent: parentVariableID != nil
+ )
+ }
+
+ public func isVariablePageLoading(parentVariableID: String?) -> Bool {
+ loadingVariablePageIDs.contains(variablePageID(parentVariableID))
+ }
+
+ public func children(of variable: DebugVariable) -> [DebugVariable] {
+ variableChildren[variable.id] ?? []
+ }
+
+ public func isVariableExpanded(_ variable: DebugVariable) -> Bool {
+ expandedVariableIDs.contains(variable.id)
+ }
+
+ public func isVariableLoading(_ variable: DebugVariable) -> Bool {
+ loadingVariableIDs.contains(variable.id)
+ }
+
+ public func setVariable(_ variable: DebugVariable, value: String) {
+ guard state == .paused,
+ capabilities.supportsSetVariable,
+ let containerReference = variable.containerReference,
+ let session = activeSession else { return }
+ let frameID = selectedFrameID
+ let generation = inspectionGeneration
+ session.setVariable(
+ variablesReference: containerReference,
+ name: variable.name,
+ value: value
+ ) { [weak self] result in
+ guard let self,
+ self.inspectionGeneration == generation,
+ self.selectedFrameID == frameID else { return }
+ switch result {
+ case .success(let replacement):
+ let updated = DebugVariable(
+ id: variable.id,
+ name: variable.name,
+ value: replacement.value,
+ type: replacement.type ?? variable.type,
+ evaluateName: variable.evaluateName,
+ variablesReference: replacement.variablesReference,
+ containerReference: containerReference,
+ namedVariables: replacement.namedVariables,
+ indexedVariables: replacement.indexedVariables
+ )
+ self.replaceVariable(updated)
+ self.refreshWatches()
+ case .failure(let error):
+ self.record(error)
+ }
+ }
+ }
+
+ public func addWatch(_ expression: String) {
+ let expression = expression.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !expression.isEmpty else { return }
+ if !watches.contains(where: { $0.expression == expression }) {
+ watches.append(GenericDebugWatch(
+ expression: expression,
+ value: nil,
+ type: nil,
+ error: nil
+ ))
+ }
+ refreshWatches()
+ }
+
+ public func updateWatch(_ watch: GenericDebugWatch, expression: String) {
+ let expression = expression.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !expression.isEmpty else { return }
+ watches.removeAll { $0.expression == watch.expression || $0.expression == expression }
+ watches.append(GenericDebugWatch(expression: expression, value: nil, type: nil, error: nil))
+ refreshWatches()
+ }
+
+ public func removeWatch(_ watch: GenericDebugWatch) {
+ watches.removeAll { $0.expression == watch.expression }
+ refreshWatches()
+ }
+
+ public func refreshWatches() {
+ watchGeneration += 1
+ let generation = watchGeneration
+ let expressions = watches.map(\.expression)
+ for expression in expressions {
+ evaluateWatch(expression, generation: generation)
+ }
+ }
+
+ public func evaluate(_ expression: String) {
+ let value = expression.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !value.isEmpty, let session = activeSession else { return }
+ recordConsoleExpression(value)
+ session.evaluate(value, frameID: selectedFrameID) { [weak self] result in
+ switch result {
+ case .success(let variable):
+ self?.append("\(value) = \(variable.value)\n")
+ case .failure(let error): self?.record(error)
+ }
+ }
+ }
+
+ /// Returns the previous expression for the active session's console.
+ public func previousConsoleExpression(current: String) -> String? {
+ guard let activeSessionID else { return nil }
+ let history = consoleHistoryBySession[activeSessionID] ?? []
+ guard !history.isEmpty else { return nil }
+ let cursor = consoleHistoryCursorBySession[activeSessionID] ?? history.count
+ if cursor == history.count, !current.isEmpty {
+ consoleHistoryDraftBySession[activeSessionID] = current
+ }
+ let next = max(0, cursor - 1)
+ consoleHistoryCursorBySession[activeSessionID] = next
+ return history[next]
+ }
+
+ /// Returns the next expression for the active session's console.
+ public func nextConsoleExpression() -> String? {
+ guard let activeSessionID else { return nil }
+ let history = consoleHistoryBySession[activeSessionID] ?? []
+ guard !history.isEmpty else { return nil }
+ let cursor = consoleHistoryCursorBySession[activeSessionID] ?? history.count
+ let next = min(history.count, cursor + 1)
+ consoleHistoryCursorBySession[activeSessionID] = next
+ if next < history.count { return history[next] }
+ return consoleHistoryDraftBySession[activeSessionID] ?? ""
+ }
+
+ private func recordConsoleExpression(_ expression: String) {
+ guard let activeSessionID else { return }
+ var history = consoleHistoryBySession[activeSessionID] ?? []
+ history.removeAll { $0 == expression }
+ history.append(expression)
+ if history.count > maximumConsoleHistoryEntries {
+ history.removeFirst(history.count - maximumConsoleHistoryEntries)
+ }
+ consoleHistoryBySession[activeSessionID] = history
+ consoleHistoryCursorBySession[activeSessionID] = history.count
+ consoleHistoryDraftBySession[activeSessionID] = nil
+ consoleHistory = history
+ }
+
+ private func publishConsoleHistory(for sessionID: DebugSessionID) {
+ consoleHistory = consoleHistoryBySession[sessionID] ?? []
+ consoleHistoryCursorBySession[sessionID] = consoleHistory.count
+ consoleHistoryDraftBySession[sessionID] = nil
+ }
+
+ public func evaluateForHover(
+ _ expression: String,
+ completion: @escaping (DebugVariable?) -> Void
+ ) {
+ let value = expression.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !value.isEmpty,
+ state == .paused,
+ let session = activeSession else {
+ completion(nil)
+ return
+ }
+ let frameID = selectedFrameID
+ session.evaluate(value, frameID: frameID) { [weak self] result in
+ guard let self,
+ self.state == .paused,
+ self.selectedFrameID == frameID else {
+ completion(nil)
+ return
+ }
+ completion(try? result.get())
+ }
+ }
+
+ public func clearOutput() {
+ output = ""
+ debuggeeOutputNormalizer.reset()
+ }
+
+ /// Mirrors output emitted by a debuggee running in the host terminal into
+ /// the Debug Console. PTY control sequences are meaningful to the terminal
+ /// emulator but would otherwise render as noise in the text console.
+ public func appendDebuggeeOutput(_ rawOutput: String) {
+ let normalized = debuggeeOutputNormalizer.normalize(rawOutput)
+ guard !normalized.isEmpty else { return }
+ append(normalized)
+ if let diagnostic = launchDiagnostic(in: normalized) {
+ errorMessage = diagnostic
+ }
+ }
+
+ private func resetInspectionState() {
+ stoppedReason = state == .paused ? stoppedReason : nil
+ exceptionInfo = nil
+ selectedThreadID = nil
+ selectedFrameID = nil
+ stoppedFrame = nil
+ selectedFrame = nil
+ threads = []
+ stoppedThreadIDs = []
+ stackFrames = []
+ areFilteredStackFramesExpanded = false
+ scopes = []
+ selectedScopeID = nil
+ resetVariableTree()
+ resetAutomaticVariables()
+ invalidateWatchResults()
+ }
+
+ private func clearStoppedInspection() {
+ invalidateInspectionRequests()
+ resetInspectionState()
+ }
+
+ private func saveActiveSessionSnapshot() {
+ guard let activeSessionID, let providerID else { return }
+ sessionSnapshots[activeSessionID] = GenericDebugSessionSnapshot(
+ providerID: providerID,
+ targetTitle: targetTitle,
+ state: state,
+ output: output,
+ errorMessage: errorMessage,
+ stoppedReason: stoppedReason,
+ exceptionInfo: exceptionInfo,
+ capabilities: capabilities,
+ activeFileURL: activeFileURL,
+ lastStartRequest: lastStartRequest,
+ normalizer: debuggeeOutputNormalizer
+ )
+ }
+
+ private func restoreSessionSnapshot(
+ _ sessionID: DebugSessionID,
+ summary: DebugSessionSummary
+ ) {
+ guard let snapshot = sessionSnapshots[sessionID] else {
+ providerID = summary.providerID
+ targetTitle = summary.targetTitle
+ state = summary.state
+ output = ""
+ errorMessage = nil
+ stoppedReason = nil
+ exceptionInfo = nil
+ capabilities = .unknown
+ activeFileURL = nil
+ lastStartRequest = nil
+ debuggeeOutputNormalizer.reset()
+ return
+ }
+ providerID = snapshot.providerID
+ targetTitle = snapshot.targetTitle
+ state = snapshot.state
+ output = snapshot.output
+ errorMessage = snapshot.errorMessage
+ stoppedReason = snapshot.stoppedReason
+ exceptionInfo = snapshot.exceptionInfo
+ capabilities = snapshot.capabilities
+ activeFileURL = snapshot.activeFileURL
+ lastStartRequest = snapshot.lastStartRequest
+ debuggeeOutputNormalizer = snapshot.normalizer
+ }
+
+ private func updateInactiveSessionState(
+ _ sessionID: DebugSessionID,
+ providerID: String,
+ state: DebugAdapterState
+ ) {
+ var snapshot = sessionSnapshots[sessionID] ?? GenericDebugSessionSnapshot(
+ providerID: providerID,
+ targetTitle: nil,
+ state: state,
+ output: "",
+ errorMessage: nil,
+ stoppedReason: nil,
+ exceptionInfo: nil,
+ capabilities: .unknown,
+ activeFileURL: nil,
+ lastStartRequest: nil,
+ normalizer: GenericDebugOutputNormalizer()
+ )
+ snapshot.state = state
+ sessionSnapshots[sessionID] = snapshot
+ }
+
+ private func consumeInactiveSessionEvent(
+ _ sessionID: DebugSessionID,
+ providerID: String,
+ event: DebugAdapterEvent
+ ) {
+ var snapshot = sessionSnapshots[sessionID] ?? GenericDebugSessionSnapshot(
+ providerID: providerID,
+ targetTitle: nil,
+ state: sessions.sessionSummaries.first(where: { $0.id == sessionID })?.state ?? .idle,
+ output: "",
+ errorMessage: nil,
+ stoppedReason: nil,
+ exceptionInfo: nil,
+ capabilities: .unknown,
+ activeFileURL: nil,
+ lastStartRequest: nil,
+ normalizer: GenericDebugOutputNormalizer()
+ )
+ switch event {
+ case .initialized:
+ break
+ case .capabilities(let capabilities):
+ snapshot.capabilities = capabilities
+ case .output(_, let text):
+ let normalized = snapshot.normalizer.normalize(text)
+ append(normalized, to: &snapshot.output)
+ case .stopped(let reason, _, let description):
+ snapshot.state = .paused
+ snapshot.stoppedReason = description ?? reason
+ snapshot.exceptionInfo = nil
+ case .continued:
+ snapshot.state = .running
+ snapshot.stoppedReason = nil
+ snapshot.exceptionInfo = nil
+ case .terminated(let exitCode):
+ snapshot.state = .terminated
+ snapshot.stoppedReason = nil
+ snapshot.exceptionInfo = nil
+ if let exitCode {
+ append("Debug session exited with code \(exitCode).\n", to: &snapshot.output)
+ }
+ case .breakpoint:
+ break
+ }
+ sessionSnapshots[sessionID] = snapshot
+ }
+
+ private var activeSession: (any DebugAdapterControllingSession)? {
+ if let activeSessionID {
+ return sessions.session(id: activeSessionID)
+ }
guard let providerID else { return nil }
return sessions.session(providerID: providerID)
}
+ private func evaluateWatch(_ expression: String, generation: Int) {
+ guard state == .paused, let session = activeSession else { return }
+ let frameID = selectedFrameID
+ session.evaluate(expression, frameID: frameID) { [weak self] result in
+ guard let self,
+ self.watchGeneration == generation,
+ self.selectedFrameID == frameID,
+ let index = self.watches.firstIndex(where: { $0.expression == expression })
+ else { return }
+ switch result {
+ case .success(let variable):
+ self.watches[index].value = variable.value
+ self.watches[index].type = variable.type
+ self.watches[index].error = nil
+ case .failure(let error):
+ self.watches[index].value = nil
+ self.watches[index].type = nil
+ self.watches[index].error = error.localizedDescription
+ }
+ }
+ }
+
+ private func invalidateWatchResults() {
+ watchGeneration += 1
+ watches = watches.map {
+ GenericDebugWatch(expression: $0.expression, value: nil, type: nil, error: nil)
+ }
+ }
+
private func sessionsProviderID(for fileURL: URL) -> String? {
sessions.provider(for: fileURL)?.id
}
@@ -215,28 +1789,65 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu
switch event {
case .initialized:
break
+ case .capabilities(let capabilities):
+ self.capabilities = capabilities
+ reconcileExceptionBreakpoints(with: capabilities.exceptionBreakpointFilters)
+ synchronizeExceptionBreakpoints()
case .output(_, let text):
append(text)
case .stopped(let reason, let threadID, let description):
+ isExecutionRequestPending = false
+ let generation = beginInspectionTransition()
+ if let threadID {
+ stoppedThreadIDs.insert(threadID)
+ } else {
+ stoppedThreadIDs = Set(threads.map(\.id))
+ }
stoppedReason = description ?? reason
+ exceptionInfo = nil
selectedThreadID = threadID
- inspectThreads()
- if let threadID,
- let thread = threads.first(where: { $0.id == threadID }) {
- selectThread(thread)
- } else if let threadID, let session = activeSession {
- session.requestStackTrace(threadID: threadID) { [weak self] result in
- if case .success(let frames) = result {
- self?.stackFrames = frames
- if let frame = frames.first { self?.selectFrame(frame) }
- }
- }
+ selectedFrameID = nil
+ selectedFrame = nil
+ threads = []
+ stackFrames = []
+ areFilteredStackFramesExpanded = false
+ scopes = []
+ resetVariableTree()
+ resetAutomaticVariables()
+ invalidateWatchResults()
+ if reason == "exception", let threadID {
+ loadExceptionInfo(threadID: threadID, generation: generation)
}
- case .continued:
- stoppedReason = nil
+ loadStoppedContext(
+ threadID: threadID,
+ generation: generation,
+ shouldLoadExceptionInfo: reason == "exception" && threadID == nil
+ )
+ case .continued(let threadID):
+ isExecutionRequestPending = false
+ if let threadID {
+ stoppedThreadIDs.remove(threadID)
+ } else {
+ stoppedThreadIDs = []
+ }
+ clearStoppedInspection()
case .terminated(let exitCode):
+ isExecutionRequestPending = false
+ clearStoppedInspection()
if let exitCode { append("Debug session exited with code \(exitCode).\n") }
case .breakpoint(let resolved):
+ if let dataID = resolved.dataID,
+ let index = dataBreakpoints.firstIndex(where: { $0.dataID == dataID }) {
+ dataBreakpoints[index].verified = resolved.verified
+ dataBreakpoints[index].message = resolved.message
+ return
+ }
+ if let functionName = resolved.functionName,
+ let index = functionBreakpoints.firstIndex(where: { $0.name == functionName }) {
+ functionBreakpoints[index].verified = resolved.verified
+ functionBreakpoints[index].message = resolved.message
+ return
+ }
guard let sourceURL = resolved.sourceURL, let line = resolved.line else { return }
if let index = breakpoints.firstIndex(where: {
$0.fileURL.standardizedFileURL == sourceURL.standardizedFileURL && $0.line == line
@@ -247,15 +1858,175 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu
}
}
+ private func loadStoppedContext(
+ threadID: Int?,
+ generation: Int,
+ shouldLoadExceptionInfo: Bool
+ ) {
+ guard let session = activeSession else { return }
+ session.requestThreads { [weak self] result in
+ guard let self, self.inspectionGeneration == generation else { return }
+ switch result {
+ case .success(let threads):
+ self.threads = threads
+ let selectedThreadID = threadID.flatMap { stoppedID in
+ threads.first(where: { $0.id == stoppedID })?.id
+ } ?? threads.first?.id ?? threadID
+ self.selectedThreadID = selectedThreadID
+ if let selectedThreadID {
+ if shouldLoadExceptionInfo {
+ self.loadExceptionInfo(
+ threadID: selectedThreadID,
+ generation: generation
+ )
+ }
+ self.loadStoppedStack(threadID: selectedThreadID, generation: generation)
+ }
+ case .failure(let error):
+ self.record(error)
+ if let threadID {
+ self.loadStoppedStack(threadID: threadID, generation: generation)
+ }
+ }
+ }
+ }
+
+ private func loadStoppedStack(threadID: Int, generation: Int) {
+ activeSession?.requestStackTrace(threadID: threadID) { [weak self] result in
+ guard let self,
+ self.inspectionGeneration == generation,
+ self.selectedThreadID == threadID else { return }
+ switch result {
+ case .success(let frames):
+ self.stackFrames = frames
+ self.areFilteredStackFramesExpanded = false
+ let preferredFrame = self.preferredStoppedFrame(in: frames)
+ self.selectedFrameID = preferredFrame?.id
+ if let frame = preferredFrame {
+ self.selectFrame(frame, generation: generation)
+ } else {
+ self.selectedFrame = nil
+ }
+ case .failure(let error):
+ self.record(error)
+ }
+ }
+ }
+
+ /// Prefer a source-backed, unfiltered frame after a stop so Java's
+ /// synthetic reflection and method-handle frames do not become the user's
+ /// initial inspection location. The full stack remains available and can
+ /// still be expanded from the filtered-frame group.
+ private func preferredStoppedFrame(in frames: [DebugStackFrame]) -> DebugStackFrame? {
+ frames.first(where: { !$0.isFiltered && $0.sourceURL != nil })
+ ?? frames.first(where: { !$0.isFiltered })
+ ?? frames.first
+ }
+
+ private func loadExceptionInfo(threadID: Int, generation: Int) {
+ guard capabilities.supportsExceptionInfoRequest,
+ let session = activeSession else { return }
+ session.requestExceptionInfo(threadID: threadID) { [weak self] result in
+ guard let self, self.inspectionGeneration == generation else { return }
+ switch result {
+ case .success(let info):
+ self.exceptionInfo = info
+ case .failure(let error):
+ self.record(error)
+ }
+ }
+ }
+
+ private func beginInspectionTransition() -> Int {
+ inspectionGeneration &+= 1
+ activeSession?.cancelPendingOperations()
+ return inspectionGeneration
+ }
+
+ private func loadJavaSteppingFilters() {
+ guard let steppingFilterResolver else { return }
+ let persisted: DebugSteppingFilters?
+ do {
+ persisted = try steppingFilterPersistence?.loadSteppingFilters(adapterID: "java")
+ } catch {
+ record(error)
+ loadDefaultJavaSteppingFilters(using: steppingFilterResolver)
+ return
+ }
+ do {
+ javaSteppingFilters = try steppingFilterResolver.resolveDebugSteppingFilters(
+ adapterID: "java",
+ filters: persisted
+ )
+ } catch {
+ record(error)
+ if persisted != nil {
+ loadDefaultJavaSteppingFilters(using: steppingFilterResolver)
+ }
+ }
+ }
+
+ private func loadDefaultJavaSteppingFilters(
+ using steppingFilterResolver: any DebugSteppingFilterResolving
+ ) {
+ do {
+ javaSteppingFilters = try steppingFilterResolver.resolveDebugSteppingFilters(
+ adapterID: "java",
+ filters: nil
+ )
+ } catch {
+ record(error)
+ }
+ }
+
+ private func stackFrameRow(_ frame: DebugStackFrame) -> GenericDebugStackFrameRow {
+ GenericDebugStackFrameRow(
+ id: "frame-\(frame.id)",
+ frame: frame,
+ hiddenFrameCount: 0
+ )
+ }
+
+ private func appendHiddenStackFrames(
+ count: Int,
+ startID: Int?,
+ to rows: inout [GenericDebugStackFrameRow]
+ ) {
+ guard count > 0, let startID else { return }
+ rows.append(GenericDebugStackFrameRow(
+ id: "filtered-\(startID)",
+ frame: nil,
+ hiddenFrameCount: count
+ ))
+ }
+
+ private func invalidateInspectionRequests() {
+ _ = beginInspectionTransition()
+ }
+
+ private func publishStoppedLocation(_ frame: DebugStackFrame) {
+ stoppedFrame = frame
+ guard let sourceURL = frame.sourceURL else { return }
+ onStoppedLocation?(sourceURL, frame.line, frame.column)
+ }
+
private func reconcileBreakpoints() {
- breakpoints = requestedLinesByFile
- .flatMap { fileURL, lines in
- lines.map {
- GenericDebugBreakpoint(
+ let previous = Dictionary(uniqueKeysWithValues: breakpoints.map { ($0.id, $0) })
+ breakpoints = requestedBreakpointsByFile
+ .flatMap { fileURL, values in
+ values.values.map { configuration in
+ let id = fileURL.standardizedFileURL.path + ":"
+ + String(configuration.line) + ":" + String(configuration.column ?? 0)
+ return GenericDebugBreakpoint(
fileURL: fileURL,
- line: $0,
- verified: false,
- message: nil
+ line: configuration.line,
+ column: configuration.column,
+ enabled: configuration.enabled,
+ condition: configuration.condition,
+ hitCondition: configuration.hitCondition,
+ logMessage: configuration.logMessage,
+ verified: previous[id]?.verified ?? false,
+ message: previous[id]?.message
)
}
}
@@ -265,6 +2036,424 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu
}
}
+ private func persistBreakpoints() {
+ guard let breakpointPersistence, let workspaceURL else { return }
+ let values = breakpoints.compactMap { breakpoint -> PersistedDebugBreakpoint? in
+ guard let relativePath = workspaceRelativePath(
+ for: breakpoint.fileURL,
+ root: workspaceURL
+ ) else { return nil }
+ return PersistedDebugBreakpoint(
+ relativePath: relativePath,
+ line: breakpoint.line,
+ column: breakpoint.column,
+ enabled: breakpoint.enabled,
+ condition: breakpoint.condition,
+ hitCondition: breakpoint.hitCondition,
+ logMessage: breakpoint.logMessage
+ )
+ }.sorted {
+ ($0.relativePath, $0.line, $0.column ?? 0)
+ < ($1.relativePath, $1.line, $1.column ?? 0)
+ }
+ do {
+ try breakpointPersistence.saveBreakpoints(
+ DebugBreakpointSnapshot(
+ areBreakpointsMuted: areBreakpointsMuted,
+ breakpoints: values
+ ),
+ for: workspaceURL
+ )
+ } catch {
+ record(error)
+ }
+ }
+
+ private func workspaceRelativePath(for fileURL: URL, root: URL) -> String? {
+ let rootPath = root.standardizedFileURL.path
+ let filePath = fileURL.standardizedFileURL.path
+ guard filePath.hasPrefix(rootPath + "/") else { return nil }
+ let value = String(filePath.dropFirst(rootPath.count + 1))
+ guard !value.isEmpty else { return nil }
+ return value.replacingOccurrences(of: "\\", with: "/")
+ }
+
+ private func restoredFileURL(for relativePath: String, root: URL) -> URL? {
+ guard !relativePath.isEmpty,
+ !relativePath.hasPrefix("/"),
+ !relativePath.contains("\\") else { return nil }
+ let components = relativePath.split(separator: "/", omittingEmptySubsequences: false)
+ guard components.allSatisfy({ !$0.isEmpty && $0 != "." && $0 != ".." }) else {
+ return nil
+ }
+ let value = components.reduce(root) { partial, component in
+ partial.appendingPathComponent(String(component), isDirectory: false)
+ }.standardizedFileURL
+ guard value.path.hasPrefix(root.path + "/") else { return nil }
+ return value
+ }
+
+ private func effectiveBreakpoints(for fileURL: URL) -> [DebugSourceBreakpoint] {
+ (requestedBreakpointsByFile[fileURL]?.values ?? [:].values)
+ .map { breakpoint in
+ DebugSourceBreakpoint(
+ line: breakpoint.line,
+ column: breakpoint.column,
+ enabled: breakpoint.enabled && !areBreakpointsMuted,
+ condition: breakpoint.condition,
+ hitCondition: breakpoint.hitCondition,
+ logMessage: breakpoint.logMessage
+ )
+ }
+ .sorted {
+ ($0.line, $0.column ?? 0) < ($1.line, $1.column ?? 0)
+ }
+ }
+
+ private func synchronizeRequestedBreakpoints(for providerID: String?) throws {
+ guard let providerID else { return }
+ for fileURL in requestedBreakpointsByFile.keys.sorted(by: { $0.path < $1.path })
+ where sessionsProviderID(for: fileURL) == providerID {
+ try sessions.setBreakpoints(effectiveBreakpoints(for: fileURL), in: fileURL)
+ }
+ }
+
+ private func synchronizeBreakpoints(for fileURL: URL) {
+ do {
+ try sessions.setBreakpoints(effectiveBreakpoints(for: fileURL), in: fileURL)
+ } catch {
+ record(error)
+ }
+ }
+
+ private func reconcileExceptionBreakpoints(
+ with filters: [DebugExceptionBreakpointFilter]
+ ) {
+ let previous = Dictionary(uniqueKeysWithValues: exceptionBreakpoints.map { ($0.filter, $0) })
+ exceptionBreakpoints = filters.map { filter in
+ let existing = previous[filter.filter]
+ return GenericDebugExceptionBreakpoint(
+ filter: filter.filter,
+ label: filter.label,
+ description: filter.description,
+ enabled: existing?.enabled ?? filter.isDefault,
+ condition: filter.supportsCondition ? existing?.condition : nil,
+ supportsCondition: filter.supportsCondition,
+ conditionDescription: filter.conditionDescription
+ )
+ }
+ }
+
+ private func synchronizeExceptionBreakpoints() {
+ guard let activeFileURL else { return }
+ do {
+ try sessions.setExceptionBreakpoints(
+ exceptionBreakpoints.map {
+ DebugExceptionBreakpoint(
+ filter: $0.filter,
+ enabled: $0.enabled,
+ condition: $0.condition
+ )
+ },
+ for: activeFileURL
+ )
+ } catch {
+ record(error)
+ }
+ }
+
+ private func synchronizeFunctionBreakpoints() {
+ guard let activeFileURL else { return }
+ do {
+ try sessions.setFunctionBreakpoints(
+ functionBreakpoints.map {
+ DebugFunctionBreakpoint(
+ name: $0.name,
+ enabled: $0.enabled,
+ condition: $0.condition,
+ hitCondition: $0.hitCondition
+ )
+ },
+ for: activeFileURL
+ )
+ } catch {
+ record(error)
+ }
+ }
+
+ private var coreDataBreakpoints: [DebugDataBreakpoint] {
+ dataBreakpoints.map {
+ DebugDataBreakpoint(
+ dataID: $0.dataID,
+ label: $0.label,
+ enabled: $0.enabled,
+ accessType: $0.accessType,
+ condition: $0.condition,
+ hitCondition: $0.hitCondition
+ )
+ }
+ }
+
+ private func synchronizeDataBreakpoints() {
+ guard let activeFileURL else { return }
+ do {
+ try sessions.setDataBreakpoints(coreDataBreakpoints, for: activeFileURL)
+ } catch {
+ record(error)
+ }
+ }
+
+ private func sortDataBreakpoints() {
+ dataBreakpoints.sort { ($0.label, $0.id) < ($1.label, $1.id) }
+ }
+
+ private func makeVariablePageState(
+ reference: Int,
+ namedVariables: Int,
+ indexedVariables: Int
+ ) -> GenericDebugVariablePageState {
+ var segments: [GenericDebugVariablePageSegment] = []
+ if namedVariables > 0 {
+ segments.append(GenericDebugVariablePageSegment(
+ filter: .named,
+ nextStart: 0,
+ totalCount: namedVariables
+ ))
+ }
+ if indexedVariables > 0 {
+ segments.append(GenericDebugVariablePageSegment(
+ filter: .indexed,
+ nextStart: 0,
+ totalCount: indexedVariables
+ ))
+ }
+ if segments.isEmpty {
+ segments.append(GenericDebugVariablePageSegment(
+ filter: nil,
+ nextStart: 0,
+ totalCount: nil
+ ))
+ }
+ return GenericDebugVariablePageState(
+ reference: reference,
+ segments: segments,
+ loadedPageFingerprints: []
+ )
+ }
+
+ private func requestVariablePage(
+ parentVariableID: String?,
+ frameID: Int?,
+ generation: Int,
+ expandsParent: Bool
+ ) {
+ let pageID = variablePageID(parentVariableID)
+ guard !loadingVariablePageIDs.contains(pageID),
+ let state = variablePageStates[pageID],
+ let segment = state.segments.first,
+ let session = activeSession else { return }
+ let remaining = segment.totalCount.map { max(0, $0 - segment.nextStart) }
+ let requestedCount = min(variablePageSize, remaining ?? variablePageSize)
+ guard requestedCount > 0 else { return }
+ loadingVariablePageIDs.insert(pageID)
+ session.requestVariables(
+ reference: state.reference,
+ filter: segment.filter,
+ start: segment.nextStart,
+ count: requestedCount
+ ) { [weak self] result in
+ guard let self,
+ self.inspectionGeneration == generation,
+ self.selectedFrameID == frameID else { return }
+ self.loadingVariablePageIDs.remove(pageID)
+ if let parentVariableID {
+ self.loadingVariableIDs.remove(parentVariableID)
+ }
+ switch result {
+ case .success(let values):
+ self.mergeVariablePage(
+ values,
+ parentVariableID: parentVariableID,
+ requestedFilter: segment.filter,
+ requestedStart: segment.nextStart,
+ requestedCount: requestedCount
+ )
+ if expandsParent, let parentVariableID {
+ self.expandedVariableIDs.insert(parentVariableID)
+ }
+ case .failure(let error):
+ self.record(error)
+ }
+ }
+ }
+
+ private func mergeVariablePage(
+ _ page: [DebugVariable],
+ parentVariableID: String?,
+ requestedFilter: DebugVariableFilter?,
+ requestedStart: Int,
+ requestedCount: Int
+ ) {
+ let pageID = variablePageID(parentVariableID)
+ guard var state = variablePageStates[pageID],
+ let currentSegment = state.segments.first,
+ currentSegment.filter == requestedFilter,
+ currentSegment.nextStart == requestedStart else { return }
+
+ let pageFingerprint = page.map {
+ GenericDebugVariablePageItemFingerprint(
+ name: $0.name,
+ value: $0.value,
+ type: $0.type,
+ evaluateName: $0.evaluateName,
+ variablesReference: $0.variablesReference
+ )
+ }
+ if !page.isEmpty, !state.loadedPageFingerprints.insert(pageFingerprint).inserted {
+ variablePageStates.removeValue(forKey: pageID)
+ return
+ }
+
+ let existing = parentVariableID.map { variableChildren[$0] ?? [] } ?? variables
+ var knownIDs = Set(existing.map(\.id))
+ let additions = page.filter { knownIDs.insert($0.id).inserted }
+ if let parentVariableID {
+ variableChildren[parentVariableID] = existing + additions
+ } else {
+ variables = existing + additions
+ }
+
+ if page.count > requestedCount || (!page.isEmpty && additions.isEmpty) {
+ state.segments = []
+ } else {
+ var segment = state.segments.removeFirst()
+ segment.nextStart = requestedStart + page.count
+ let reachedReportedTotal = segment.totalCount.map {
+ segment.nextStart >= $0
+ } ?? false
+ let shouldContinue = !page.isEmpty
+ && !reachedReportedTotal
+ && (segment.totalCount != nil || page.count == requestedCount)
+ if shouldContinue {
+ state.segments.insert(segment, at: 0)
+ }
+ }
+ if state.segments.isEmpty {
+ variablePageStates.removeValue(forKey: pageID)
+ } else {
+ variablePageStates[pageID] = state
+ }
+ }
+
+ private func variablePageID(_ parentVariableID: String?) -> String {
+ parentVariableID ?? Self.rootVariablePageID
+ }
+
+ private func resetVariableTree() {
+ variables = []
+ variableChildren = [:]
+ expandedVariableIDs = []
+ loadingVariableIDs = []
+ variablePageStates = [:]
+ loadingVariablePageIDs = []
+ }
+
+ private func resetAutomaticVariables() {
+ automaticVariables = []
+ automaticExpressionOrder = []
+ automaticExpressionResults = [:]
+ automaticExpressionFrameID = nil
+ }
+
+ private func replaceVariable(_ replacement: DebugVariable) {
+ if let index = variables.firstIndex(where: { $0.id == replacement.id }) {
+ variables[index] = replacement
+ return
+ }
+ for parentID in variableChildren.keys.sorted() {
+ guard var children = variableChildren[parentID],
+ let index = children.firstIndex(where: { $0.id == replacement.id }) else {
+ continue
+ }
+ children[index] = replacement
+ variableChildren[parentID] = children
+ return
+ }
+ }
+
+ private func appendVisibleVariables(
+ _ values: [DebugVariable],
+ parentPath: String,
+ depth: Int,
+ to rows: inout [GenericDebugVariableRow],
+ ancestorVariableIDs: Set = [],
+ ancestorVariableReferences: Set = []
+ ) {
+ for (index, variable) in values.enumerated() {
+ let path = "\(parentPath)/\(index):\(variable.id)"
+ rows.append(GenericDebugVariableRow(
+ id: path,
+ content: .variable(variable),
+ depth: depth
+ ))
+
+ // DAP variable containers can legally expose a parent object again
+ // (for example through `this`), so stop walking when an ancestor
+ // reference or identity repeats instead of recursing forever.
+ let repeatsAncestor = ancestorVariableIDs.contains(variable.id)
+ || (variable.variablesReference != 0
+ && ancestorVariableReferences.contains(variable.variablesReference))
+ if expandedVariableIDs.contains(variable.id), !repeatsAncestor {
+ var nextAncestorIDs = ancestorVariableIDs
+ nextAncestorIDs.insert(variable.id)
+ var nextAncestorReferences = ancestorVariableReferences
+ if variable.variablesReference != 0 {
+ nextAncestorReferences.insert(variable.variablesReference)
+ }
+ appendVisibleVariables(
+ variableChildren[variable.id] ?? [],
+ parentPath: path,
+ depth: depth + 1,
+ to: &rows,
+ ancestorVariableIDs: nextAncestorIDs,
+ ancestorVariableReferences: nextAncestorReferences
+ )
+ appendVariableLoadMoreRow(
+ parentVariableID: variable.id,
+ parentPath: path,
+ depth: depth + 1,
+ to: &rows
+ )
+ }
+ }
+ }
+
+ private func appendVariableLoadMoreRow(
+ parentVariableID: String?,
+ parentPath: String,
+ depth: Int,
+ to rows: inout [GenericDebugVariableRow]
+ ) {
+ let pageID = variablePageID(parentVariableID)
+ guard let pageState = variablePageStates[pageID],
+ !pageState.segments.isEmpty else { return }
+ rows.append(GenericDebugVariableRow(
+ id: "\(parentPath)/load-more",
+ content: .loadMore(
+ parentVariableID: parentVariableID,
+ nextCount: min(variablePageSize, pageState.remainingCount ?? variablePageSize),
+ remainingCount: pageState.remainingCount
+ ),
+ depth: depth
+ ))
+ }
+
+ private func normalizedOptionalText(_ value: String?) -> String? {
+ guard let value else { return nil }
+ let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ return normalized.isEmpty ? nil : normalized
+ }
+
private func record(_ error: Error) {
errorMessage = error.localizedDescription
append(error.localizedDescription + "\n")
@@ -275,5 +2464,27 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu
if output.count > maximumOutputCharacters {
output.removeFirst(output.count - maximumOutputCharacters)
}
+ saveActiveSessionSnapshot()
+ }
+
+ private func launchDiagnostic(in output: String) -> String? {
+ let pattern = #"(?i)\bport\s+(\d{1,5})\s+was already in use\b"#
+ guard let expression = try? NSRegularExpression(pattern: pattern),
+ let match = expression.firstMatch(
+ in: output,
+ range: NSRange(output.startIndex..., in: output)
+ ),
+ let portRange = Range(match.range(at: 1), in: output) else {
+ return nil
+ }
+ let port = output[portRange]
+ return "Port \(port) is already in use. Stop the process using it or change server.port in the Run configuration."
+ }
+
+ private func append(_ text: String, to output: inout String) {
+ output += text
+ if output.count > maximumOutputCharacters {
+ output.removeFirst(output.count - maximumOutputCharacters)
+ }
}
}
diff --git a/macos/Sources/LitheDebugModule/Module/DebugModule.swift b/macos/Sources/LitheDebugModule/Module/DebugModule.swift
index 8e1bb492d..c7aa01662 100644
--- a/macos/Sources/LitheDebugModule/Module/DebugModule.swift
+++ b/macos/Sources/LitheDebugModule/Module/DebugModule.swift
@@ -1,15 +1,11 @@
import Foundation
import LitheModuleAPI
-@MainActor
-public protocol JavaDebugFeatureTarget: AnyObject {}
-
@MainActor
public protocol GenericDebugFeatureTarget: AnyObject {}
@MainActor
public protocol DebugServiceGraph: AnyObject {
- var javaFeatureTarget: any JavaDebugFeatureTarget { get }
var genericFeatureTarget: any GenericDebugFeatureTarget { get }
var hasActiveDebugWork: Bool { get }
func activate(context: ModuleContext)
@@ -19,11 +15,9 @@ public protocol DebugServiceGraph: AnyObject {
@MainActor
public final class DebugModuleCapability: NSObject {
- public let javaFeature: any JavaDebugFeatureTarget
public let genericFeature: any GenericDebugFeatureTarget
fileprivate init(graph: any DebugServiceGraph) {
- javaFeature = graph.javaFeatureTarget
genericFeature = graph.genericFeatureTarget
}
}
diff --git a/macos/Sources/LitheDebugModule/Runtime/CoreDebugAdapterProtocolSession.swift b/macos/Sources/LitheDebugModule/Runtime/CoreDebugAdapterProtocolSession.swift
new file mode 100644
index 000000000..9cc2c59f8
--- /dev/null
+++ b/macos/Sources/LitheDebugModule/Runtime/CoreDebugAdapterProtocolSession.swift
@@ -0,0 +1,797 @@
+import Foundation
+import LitheCoreContracts
+
+/// DAP session projected from the shared Rust Debug Core. This type owns only
+/// callback correlation and UI model conversion; framing and state reduction
+/// stay behind `DebugProtocolCore`, while native I/O stays in the transport.
+@MainActor
+public final class CoreDebugAdapterProtocolSession: DebugAdapterControllingSession,
+ DebugAdapterRunInTerminalSession {
+ private typealias OperationHandler = (Result) -> Void
+
+ private struct PendingOperation {
+ let handler: OperationHandler
+ let deadline: any DebugOperationDeadline
+ }
+
+ private struct PendingRunInTerminalRequest {
+ let deadline: any DebugOperationDeadline
+ }
+
+ private let adapterID: String
+ private let transport: any DebugAdapterTransport
+ private let core: any DebugProtocolCore
+ private let sessionID: String
+ private let deadlineScheduler: any DebugOperationDeadlineScheduling
+ private let operationTimeoutMilliseconds: Int
+ private var operationHandlers: [String: PendingOperation] = [:]
+ private var pendingRunInTerminalRequests: [String: PendingRunInTerminalRequest] = [:]
+ private var ownsCoreSession = false
+ private var isStopping = false
+
+ public private(set) var state: DebugAdapterState = .idle {
+ didSet { if oldValue != state { onStateChange?(state) } }
+ }
+ public var onStateChange: ((DebugAdapterState) -> Void)?
+ public var onEvent: ((DebugAdapterEvent) -> Void)?
+ public var onRunInTerminalRequest: DebugRunInTerminalRequestHandler?
+ public var isRunning: Bool { transport.isRunning }
+ public private(set) var capabilities: DebugAdapterCapabilities = .unknown
+
+ public init(
+ adapterID: String,
+ transport: any DebugAdapterTransport,
+ core: any DebugProtocolCore,
+ sessionID: String = UUID().uuidString,
+ deadlineScheduler: any DebugOperationDeadlineScheduling,
+ operationTimeoutMilliseconds: Int = 10_000
+ ) {
+ self.adapterID = adapterID
+ self.transport = transport
+ self.core = core
+ self.sessionID = sessionID
+ self.deadlineScheduler = deadlineScheduler
+ self.operationTimeoutMilliseconds = max(1, operationTimeoutMilliseconds)
+ transport.onData = { [weak self] data in self?.receive(data) }
+ transport.onErrorOutput = { [weak self] data in
+ guard let text = String(data: data, encoding: .utf8), !text.isEmpty else { return }
+ self?.onEvent?(.output(category: "stderr", output: text))
+ }
+ transport.onTermination = { [weak self] code in self?.transportTerminated(exitCode: code) }
+ }
+
+ public func start(rootURL: URL) throws {
+ guard state == .idle || state == .terminated || state == .failed else { return }
+ isStopping = false
+ operationHandlers = [:]
+ discardPendingRunInTerminalRequests()
+ capabilities = .unknown
+ try transport.start(rootURL: rootURL.standardizedFileURL)
+ do {
+ let update = try core.createDebugSession(
+ sessionID: sessionID,
+ adapterID: adapterID,
+ rootPath: rootURL.standardizedFileURL.path,
+ supportsRunInTerminalRequest: onRunInTerminalRequest != nil
+ )
+ ownsCoreSession = true
+ try apply(update)
+ } catch {
+ transport.stop()
+ releaseCoreSession()
+ state = .failed
+ throw error
+ }
+ }
+
+ public func stop() {
+ guard state != .idle || transport.isRunning || ownsCoreSession else { return }
+ isStopping = true
+ failPendingRunInTerminalRequests(DebugAdapterProtocolError.stopped)
+ if ownsCoreSession, transport.isRunning,
+ let update = try? core.disconnectDebugSession(sessionID: sessionID) {
+ try? apply(update)
+ }
+ transport.stop()
+ releaseCoreSession()
+ failPendingOperations(DebugAdapterProtocolError.stopped)
+ state = .idle
+ capabilities = .unknown
+ isStopping = false
+ }
+
+ public func launch(_ configuration: DebugLaunchConfiguration) throws {
+ guard ownsCoreSession else { throw DebugAdapterProtocolError.notReady }
+ let operationID = UUID().uuidString
+ let update = try core.launchDebugSession(
+ sessionID: sessionID,
+ operationID: operationID,
+ configuration: configuration
+ )
+ try apply(update)
+ }
+
+ public func setBreakpoints(_ breakpoints: [DebugSourceBreakpoint], in fileURL: URL) {
+ guard ownsCoreSession else { return }
+ do {
+ try apply(core.setDebugBreakpoints(
+ sessionID: sessionID,
+ sourcePath: fileURL.standardizedFileURL.path,
+ breakpoints: breakpoints
+ ))
+ } catch {
+ onEvent?(.output(category: "stderr", output: error.localizedDescription + "\n"))
+ }
+ }
+
+ public func setExceptionBreakpoints(_ breakpoints: [DebugExceptionBreakpoint]) {
+ guard ownsCoreSession else { return }
+ do {
+ try apply(core.setDebugExceptionBreakpoints(
+ sessionID: sessionID,
+ breakpoints: breakpoints
+ ))
+ } catch {
+ onEvent?(.output(category: "stderr", output: error.localizedDescription + "\n"))
+ }
+ }
+
+ public func setFunctionBreakpoints(_ breakpoints: [DebugFunctionBreakpoint]) {
+ guard ownsCoreSession else { return }
+ do {
+ try apply(core.setDebugFunctionBreakpoints(
+ sessionID: sessionID,
+ breakpoints: breakpoints
+ ))
+ } catch {
+ onEvent?(.output(category: "stderr", output: error.localizedDescription + "\n"))
+ }
+ }
+
+ public func setDataBreakpoints(_ breakpoints: [DebugDataBreakpoint]) {
+ guard ownsCoreSession else { return }
+ do {
+ try apply(core.setDebugDataBreakpoints(
+ sessionID: sessionID,
+ breakpoints: breakpoints
+ ))
+ } catch {
+ onEvent?(.output(category: "stderr", output: error.localizedDescription + "\n"))
+ }
+ }
+
+ public func requestDataBreakpointInfo(
+ name: String,
+ variablesReference: Int?,
+ frameID: Int?,
+ completion: @escaping (Result) -> Void
+ ) {
+ guard ownsCoreSession else {
+ completion(.failure(DebugAdapterProtocolError.stopped))
+ return
+ }
+ guard capabilities.supportsDataBreakpoints else {
+ completion(.failure(DebugAdapterCapabilityError.unsupported("data breakpoints")))
+ return
+ }
+ let operationID = UUID().uuidString
+ registerOperation(operationID) { result in
+ completion(result.flatMap { value in
+ guard value.kind == "dataBreakpointInfo",
+ let description = value.description else {
+ return .failure(DebugAdapterProtocolError.invalidResponse("dataBreakpointInfo"))
+ }
+ return .success(DebugDataBreakpointInfo(
+ dataID: value.dataID,
+ description: description,
+ accessTypes: value.accessTypes ?? [],
+ canPersist: value.canPersist ?? false
+ ))
+ })
+ }
+ do {
+ try apply(core.debugDataBreakpointInfo(
+ sessionID: sessionID,
+ operationID: operationID,
+ name: name,
+ variablesReference: variablesReference,
+ frameID: frameID
+ ))
+ } catch {
+ completeOperation(operationID, result: .failure(error))
+ }
+ }
+
+ public func execute(_ command: DebugExecutionCommand, threadID: Int?) {
+ execute(command, threadID: threadID, targetID: nil, singleThread: false)
+ }
+
+ public func execute(_ command: DebugExecutionCommand, threadID: Int?, targetID: Int?) {
+ execute(command, threadID: threadID, targetID: targetID, singleThread: false)
+ }
+
+ public func execute(
+ _ command: DebugExecutionCommand,
+ threadID: Int?,
+ targetID: Int?,
+ singleThread: Bool
+ ) {
+ guard ownsCoreSession else { return }
+ let operationID = UUID().uuidString
+ do {
+ try apply(core.executeDebugCommand(
+ sessionID: sessionID,
+ operationID: operationID,
+ command: command,
+ threadID: threadID,
+ targetID: targetID,
+ singleThread: singleThread
+ ))
+ } catch {
+ onEvent?(.output(category: "stderr", output: error.localizedDescription + "\n"))
+ }
+ }
+
+ public func requestThreads(_ completion: @escaping (Result<[DebugThread], Error>) -> Void) {
+ inspect(kind: "threads") { result in
+ completion(result.flatMap { value in
+ guard value.kind == "threads", let threads = value.threads else {
+ return .failure(DebugAdapterProtocolError.invalidResponse("threads"))
+ }
+ return .success(threads.map { DebugThread(id: $0.id, name: $0.name) })
+ })
+ }
+ }
+
+ public func requestExceptionInfo(
+ threadID: Int,
+ completion: @escaping (Result) -> Void
+ ) {
+ guard capabilities.supportsExceptionInfoRequest else {
+ completion(.failure(DebugAdapterCapabilityError.unsupported("exception information")))
+ return
+ }
+ inspect(kind: "exceptionInfo", threadID: threadID) { result in
+ completion(result.flatMap { value in
+ guard value.kind == "exceptionInfo", let info = value.exceptionInfo else {
+ return .failure(DebugAdapterProtocolError.invalidResponse("exceptionInfo"))
+ }
+ return .success(DebugExceptionInfo(
+ exceptionID: info.exceptionID,
+ description: info.description,
+ breakMode: info.breakMode,
+ details: info.details.map(Self.makeExceptionDetails)
+ ))
+ })
+ }
+ }
+
+ public func requestStackTrace(
+ threadID: Int,
+ completion: @escaping (Result<[DebugStackFrame], Error>) -> Void
+ ) {
+ inspect(kind: "stackTrace", threadID: threadID) { result in
+ completion(result.flatMap { value in
+ guard value.kind == "stackTrace", let frames = value.stackFrames else {
+ return .failure(DebugAdapterProtocolError.invalidResponse("stackTrace"))
+ }
+ return .success(frames.map {
+ DebugStackFrame(
+ id: $0.id,
+ name: $0.name,
+ sourceURL: $0.sourcePath.map { URL(fileURLWithPath: $0) },
+ line: $0.line,
+ column: $0.column,
+ isFiltered: $0.isFiltered
+ )
+ })
+ })
+ }
+ }
+
+ public func requestScopes(
+ frameID: Int,
+ completion: @escaping (Result<[DebugScope], Error>) -> Void
+ ) {
+ inspect(kind: "scopes", frameID: frameID) { result in
+ completion(result.flatMap { value in
+ guard value.kind == "scopes", let scopes = value.scopes else {
+ return .failure(DebugAdapterProtocolError.invalidResponse("scopes"))
+ }
+ return .success(scopes.enumerated().map { offset, scope in
+ DebugScope(
+ id: scope.variablesReference * 1_000 + offset,
+ name: scope.name,
+ variablesReference: scope.variablesReference,
+ expensive: scope.expensive,
+ namedVariables: scope.namedVariables,
+ indexedVariables: scope.indexedVariables
+ )
+ })
+ })
+ }
+ }
+
+ public func requestVariables(
+ reference: Int,
+ completion: @escaping (Result<[DebugVariable], Error>) -> Void
+ ) {
+ requestVariables(
+ reference: reference,
+ filter: nil,
+ start: nil,
+ count: nil,
+ completion: completion
+ )
+ }
+
+ public func requestVariables(
+ reference: Int,
+ filter: DebugVariableFilter?,
+ start: Int?,
+ count: Int?,
+ completion: @escaping (Result<[DebugVariable], Error>) -> Void
+ ) {
+ inspect(
+ kind: "variables",
+ variablesReference: reference,
+ variableFilter: filter,
+ start: start,
+ count: count
+ ) { result in
+ completion(result.flatMap { value in
+ guard value.kind == "variables", let variables = value.variables else {
+ return .failure(DebugAdapterProtocolError.invalidResponse("variables"))
+ }
+ return .success(variables.enumerated().map { offset, variable in
+ Self.makeVariable(
+ variable,
+ fallbackID: [
+ String(reference),
+ filter?.rawValue ?? "all",
+ String((start ?? 0) + offset)
+ ].joined(separator: ":"),
+ containerReference: reference
+ )
+ })
+ })
+ }
+ }
+
+ public func setVariable(
+ variablesReference: Int,
+ name: String,
+ value: String,
+ completion: @escaping (Result) -> Void
+ ) {
+ guard ownsCoreSession else {
+ completion(.failure(DebugAdapterProtocolError.stopped))
+ return
+ }
+ guard capabilities.supportsSetVariable else {
+ completion(.failure(DebugAdapterCapabilityError.unsupported("variable mutation")))
+ return
+ }
+ let operationID = UUID().uuidString
+ registerOperation(operationID) { result in
+ completion(result.flatMap { result in
+ guard result.kind == "setVariable", let variable = result.variable else {
+ return .failure(DebugAdapterProtocolError.invalidResponse("setVariable"))
+ }
+ return .success(Self.makeVariable(
+ variable,
+ fallbackID: "\(variablesReference):\(name)",
+ containerReference: variablesReference
+ ))
+ })
+ }
+ do {
+ try apply(core.setDebugVariable(
+ sessionID: sessionID,
+ operationID: operationID,
+ variablesReference: variablesReference,
+ name: name,
+ value: value
+ ))
+ } catch {
+ completeOperation(operationID, result: .failure(error))
+ }
+ }
+
+ public func evaluate(
+ _ expression: String,
+ frameID: Int?,
+ completion: @escaping (Result) -> Void
+ ) {
+ inspect(kind: "evaluate", frameID: frameID, expression: expression) { result in
+ completion(result.flatMap { value in
+ guard value.kind == "evaluate", let variable = value.variable else {
+ return .failure(DebugAdapterProtocolError.invalidResponse("evaluate"))
+ }
+ return .success(Self.makeVariable(variable, fallbackID: expression))
+ })
+ }
+ }
+
+ public func requestStepInTargets(
+ frameID: Int,
+ completion: @escaping (Result<[DebugStepInTarget], Error>) -> Void
+ ) {
+ inspect(kind: "stepInTargets", frameID: frameID) { result in
+ completion(result.flatMap { value in
+ guard value.kind == "stepInTargets", let targets = value.targets else {
+ return .failure(DebugAdapterProtocolError.invalidResponse("stepInTargets"))
+ }
+ return .success(targets.map {
+ DebugStepInTarget(
+ id: $0.id,
+ label: $0.label,
+ line: $0.line,
+ column: $0.column,
+ endLine: $0.endLine,
+ endColumn: $0.endColumn
+ )
+ })
+ })
+ }
+ }
+
+ public func requestGotoTargets(
+ fileURL: URL,
+ line: Int,
+ column: Int?,
+ completion: @escaping (Result<[DebugGotoTarget], Error>) -> Void
+ ) {
+ inspect(
+ kind: "gotoTargets",
+ sourcePath: fileURL.standardizedFileURL.path,
+ line: line,
+ column: column
+ ) { result in
+ completion(result.flatMap { value in
+ guard value.kind == "gotoTargets", let targets = value.targets else {
+ return .failure(DebugAdapterProtocolError.invalidResponse("gotoTargets"))
+ }
+ return .success(targets.compactMap {
+ guard let line = $0.line else { return nil }
+ return DebugGotoTarget(
+ id: $0.id,
+ label: $0.label,
+ line: line,
+ column: $0.column,
+ endLine: $0.endLine,
+ endColumn: $0.endColumn,
+ instructionPointerReference: $0.instructionPointerReference
+ )
+ })
+ })
+ }
+ }
+
+ private func inspect(
+ kind: String,
+ threadID: Int? = nil,
+ frameID: Int? = nil,
+ variablesReference: Int? = nil,
+ variableFilter: DebugVariableFilter? = nil,
+ start: Int? = nil,
+ count: Int? = nil,
+ expression: String? = nil,
+ sourcePath: String? = nil,
+ line: Int? = nil,
+ column: Int? = nil,
+ completion: @escaping OperationHandler
+ ) {
+ guard ownsCoreSession else {
+ completion(.failure(DebugAdapterProtocolError.stopped))
+ return
+ }
+ let operationID = UUID().uuidString
+ registerOperation(operationID, handler: completion)
+ do {
+ try apply(core.inspectDebugSession(
+ sessionID: sessionID,
+ operationID: operationID,
+ kind: kind,
+ threadID: threadID,
+ frameID: frameID,
+ variablesReference: variablesReference,
+ variableFilter: variableFilter,
+ start: start,
+ count: count,
+ expression: expression,
+ sourcePath: sourcePath,
+ line: line,
+ column: column
+ ))
+ } catch {
+ completeOperation(operationID, result: .failure(error))
+ }
+ }
+
+ public func cancelPendingOperations() {
+ for operationID in operationHandlers.keys.sorted() {
+ cancelOperation(operationID, reason: "cancelled")
+ }
+ }
+
+ private func receive(_ data: Data) {
+ guard ownsCoreSession else { return }
+ do {
+ try apply(core.receiveDebugData(sessionID: sessionID, data: data))
+ } catch {
+ onEvent?(.output(category: "stderr", output: error.localizedDescription + "\n"))
+ failSession()
+ }
+ }
+
+ private func apply(_ update: DebugCoreUpdate) throws {
+ guard update.sessionID == sessionID else {
+ throw DebugAdapterProtocolError.invalidResponse("session update")
+ }
+ for frame in update.outboundFrames {
+ guard let data = Data(base64Encoded: frame) else {
+ throw DebugAdapterProtocolError.invalidResponse("outbound frame")
+ }
+ try transport.send(data)
+ }
+ for event in update.events.sorted(by: { $0.sequence < $1.sequence }) {
+ consume(event)
+ }
+ state = Self.adapterState(update.state)
+ }
+
+ private func consume(_ event: DebugCoreEvent) {
+ switch event.type {
+ case "stateChanged":
+ if let state = event.state { self.state = Self.adapterState(state) }
+ case "initialized":
+ onEvent?(.initialized)
+ case "capabilities":
+ guard let value = event.capabilities else { return }
+ capabilities = Self.makeCapabilities(value)
+ onEvent?(.capabilities(capabilities))
+ case "output":
+ onEvent?(.output(category: event.category, output: event.output ?? ""))
+ case "stopped":
+ onEvent?(.stopped(
+ reason: event.reason ?? "stopped",
+ threadID: event.threadID,
+ description: event.description
+ ))
+ case "continued":
+ onEvent?(.continued(threadID: event.threadID))
+ case "terminated":
+ onEvent?(.terminated(exitCode: event.exitCode))
+ case "breakpoint":
+ guard let breakpoint = event.breakpoint else { return }
+ onEvent?(.breakpoint(DebugBreakpoint(
+ id: breakpoint.id,
+ verified: breakpoint.verified,
+ message: breakpoint.message,
+ sourceURL: breakpoint.sourcePath.map { URL(fileURLWithPath: $0) },
+ line: breakpoint.line,
+ column: breakpoint.column,
+ functionName: breakpoint.functionName,
+ dataID: breakpoint.dataID
+ )))
+ case "runInTerminalRequested":
+ guard let requestID = event.requestID,
+ let request = event.request else { return }
+ beginRunInTerminalRequest(requestID: requestID, request: request)
+ case "operationCompleted":
+ guard let operationID = event.operationID,
+ let result = event.result else { return }
+ completeOperation(operationID, result: .success(result))
+ case "operationFailed":
+ guard let operationID = event.operationID else { return }
+ let command = event.command ?? "request"
+ let error: DebugAdapterProtocolError = switch event.code {
+ case "cancelled": .cancelled(command)
+ case "timedOut": .timedOut(command)
+ default: .requestFailed(
+ command: command,
+ message: event.message ?? "The Debug Adapter rejected the request."
+ )
+ }
+ if operationHandlers[operationID] != nil {
+ completeOperation(operationID, result: .failure(error))
+ } else {
+ onEvent?(.output(category: "stderr", output: error.localizedDescription + "\n"))
+ }
+ default:
+ break
+ }
+ }
+
+ private func transportTerminated(exitCode: Int) {
+ guard !isStopping else { return }
+ discardPendingRunInTerminalRequests()
+ releaseCoreSession()
+ failPendingOperations(DebugAdapterProtocolError.stopped)
+ state = exitCode == 0 ? .terminated : .failed
+ onEvent?(.terminated(exitCode: exitCode))
+ }
+
+ private func failSession() {
+ transport.stop()
+ discardPendingRunInTerminalRequests()
+ releaseCoreSession()
+ failPendingOperations(DebugAdapterProtocolError.stopped)
+ state = .failed
+ }
+
+ private func releaseCoreSession() {
+ guard ownsCoreSession else { return }
+ ownsCoreSession = false
+ core.destroyDebugSession(sessionID: sessionID)
+ }
+
+ private func beginRunInTerminalRequest(
+ requestID: String,
+ request: DebugRunInTerminalRequest
+ ) {
+ let deadline = deadlineScheduler.schedule(
+ afterMilliseconds: operationTimeoutMilliseconds
+ ) { [weak self] in
+ self?.completeRunInTerminalRequest(
+ requestID,
+ result: .failure(DebugAdapterProtocolError.timedOut("runInTerminal"))
+ )
+ }
+ pendingRunInTerminalRequests[requestID] = PendingRunInTerminalRequest(
+ deadline: deadline
+ )
+ guard let handler = onRunInTerminalRequest else {
+ completeRunInTerminalRequest(
+ requestID,
+ result: .failure(DebugAdapterCapabilityError.unsupported("run in terminal"))
+ )
+ return
+ }
+ handler(request) { [weak self] result in
+ self?.completeRunInTerminalRequest(requestID, result: result)
+ }
+ }
+
+ private func completeRunInTerminalRequest(
+ _ requestID: String,
+ result: Result
+ ) {
+ guard let pending = pendingRunInTerminalRequests.removeValue(forKey: requestID),
+ ownsCoreSession else { return }
+ pending.deadline.cancel()
+ do {
+ try apply(core.completeDebugRunInTerminalRequest(
+ sessionID: sessionID,
+ requestID: requestID,
+ result: result
+ ))
+ } catch {
+ onEvent?(.output(category: "stderr", output: error.localizedDescription + "\n"))
+ if !isStopping { failSession() }
+ }
+ }
+
+ private func failPendingRunInTerminalRequests(_ error: Error) {
+ for requestID in pendingRunInTerminalRequests.keys.sorted() {
+ completeRunInTerminalRequest(requestID, result: .failure(error))
+ }
+ }
+
+ private func discardPendingRunInTerminalRequests() {
+ let pending = pendingRunInTerminalRequests.values
+ pendingRunInTerminalRequests = [:]
+ pending.forEach { $0.deadline.cancel() }
+ }
+
+ private static func makeCapabilities(
+ _ value: DebugCoreCapabilities
+ ) -> DebugAdapterCapabilities {
+ DebugAdapterCapabilities(
+ negotiated: true,
+ supportsConfigurationDone: value.supportsConfigurationDone,
+ supportsConditionalBreakpoints: value.supportsConditionalBreakpoints,
+ supportsHitConditionalBreakpoints: value.supportsHitConditionalBreakpoints,
+ supportsLogPoints: value.supportsLogPoints,
+ supportsFunctionBreakpoints: value.supportsFunctionBreakpoints,
+ supportsDataBreakpoints: value.supportsDataBreakpoints,
+ supportsExceptionOptions: value.supportsExceptionOptions,
+ supportsExceptionFilterOptions: value.supportsExceptionFilterOptions,
+ supportsSetVariable: value.supportsSetVariable,
+ supportsCancelRequest: value.supportsCancelRequest,
+ supportsSingleThreadExecutionRequests: value.supportsSingleThreadExecutionRequests,
+ supportsRestartRequest: value.supportsRestartRequest,
+ supportsTerminateRequest: value.supportsTerminateRequest,
+ supportsStepBack: value.supportsStepBack,
+ supportsExceptionInfoRequest: value.supportsExceptionInfoRequest,
+ supportsStepInTargetsRequest: value.supportsStepInTargetsRequest,
+ supportsGotoTargetsRequest: value.supportsGotoTargetsRequest,
+ exceptionBreakpointFilters: value.exceptionBreakpointFilters
+ )
+ }
+
+ private func failPendingOperations(_ error: Error) {
+ let handlers = operationHandlers.values
+ operationHandlers = [:]
+ handlers.forEach {
+ $0.deadline.cancel()
+ $0.handler(.failure(error))
+ }
+ }
+
+ private func registerOperation(_ operationID: String, handler: @escaping OperationHandler) {
+ let deadline = deadlineScheduler.schedule(
+ afterMilliseconds: operationTimeoutMilliseconds
+ ) { [weak self] in
+ self?.cancelOperation(operationID, reason: "timedOut")
+ }
+ operationHandlers[operationID] = PendingOperation(handler: handler, deadline: deadline)
+ }
+
+ private func completeOperation(
+ _ operationID: String,
+ result: Result
+ ) {
+ guard let operation = operationHandlers.removeValue(forKey: operationID) else { return }
+ operation.deadline.cancel()
+ operation.handler(result)
+ }
+
+ private func cancelOperation(_ operationID: String, reason: String) {
+ guard operationHandlers[operationID] != nil, ownsCoreSession else { return }
+ do {
+ try apply(core.cancelDebugOperation(
+ sessionID: sessionID,
+ operationID: operationID,
+ reason: reason
+ ))
+ } catch {
+ completeOperation(operationID, result: .failure(error))
+ }
+ }
+
+ private static func adapterState(_ state: DebugCoreSessionState) -> DebugAdapterState {
+ switch state {
+ case .idle: .idle
+ case .initializing: .initializing
+ case .ready: .ready
+ case .launching: .launching
+ case .running: .running
+ case .paused: .paused
+ case .terminating, .terminated: .terminated
+ case .failed: .failed
+ }
+ }
+
+ private static func makeVariable(
+ _ variable: DebugCoreVariable,
+ fallbackID: String,
+ containerReference: Int? = nil
+ ) -> DebugVariable {
+ DebugVariable(
+ id: variable.evaluateName ?? fallbackID + ":" + variable.name,
+ name: variable.name,
+ value: variable.value,
+ type: variable.type,
+ evaluateName: variable.evaluateName,
+ variablesReference: variable.variablesReference,
+ containerReference: containerReference,
+ namedVariables: variable.namedVariables,
+ indexedVariables: variable.indexedVariables
+ )
+ }
+
+ private static func makeExceptionDetails(
+ _ details: DebugCoreExceptionDetails
+ ) -> DebugExceptionDetails {
+ DebugExceptionDetails(
+ message: details.message,
+ typeName: details.typeName,
+ fullTypeName: details.fullTypeName,
+ evaluateName: details.evaluateName,
+ stackTrace: details.stackTrace,
+ innerExceptions: details.innerExceptions.map(makeExceptionDetails)
+ )
+ }
+}
diff --git a/macos/Sources/LitheDebugModule/Runtime/DebugAdapterProtocolSession.swift b/macos/Sources/LitheDebugModule/Runtime/DebugAdapterProtocolSession.swift
index 5fc304450..d4e5172c7 100644
--- a/macos/Sources/LitheDebugModule/Runtime/DebugAdapterProtocolSession.swift
+++ b/macos/Sources/LitheDebugModule/Runtime/DebugAdapterProtocolSession.swift
@@ -6,6 +6,8 @@ public enum DebugAdapterProtocolError: LocalizedError {
case stopped
case invalidResponse(String)
case requestFailed(command: String, message: String)
+ case cancelled(String)
+ case timedOut(String)
public var errorDescription: String? {
switch self {
@@ -17,6 +19,10 @@ public enum DebugAdapterProtocolError: LocalizedError {
"The Debug Adapter returned an invalid \(command) response."
case .requestFailed(let command, let message):
"\(command) failed: \(message)"
+ case .cancelled(let command):
+ "\(command) was cancelled."
+ case .timedOut(let command):
+ "\(command) timed out."
}
}
}
@@ -35,9 +41,13 @@ public final class DebugAdapterProtocolSession: DebugAdapterControllingSession {
private var nextSequence = 1
private var responseHandlers: [Int: ResponseHandler] = [:]
private var breakpointsBySource: [URL: [DebugSourceBreakpoint]] = [:]
+ private var exceptionBreakpoints: [DebugExceptionBreakpoint] = []
+ private var functionBreakpoints: [DebugFunctionBreakpoint] = []
+ private var dataBreakpoints: [DebugDataBreakpoint] = []
private var didReceiveInitializedEvent = false
private var supportsConfigurationDone = false
private var pendingLaunch: DebugLaunchConfiguration?
+ private var activeRequestKind: DebugRequestKind?
private var childSessions: [DebugAdapterProtocolSession] = []
private weak var activeChildSession: DebugAdapterProtocolSession?
@@ -49,6 +59,7 @@ public final class DebugAdapterProtocolSession: DebugAdapterControllingSession {
}
public var onStateChange: ((DebugAdapterState) -> Void)?
public var onEvent: ((DebugAdapterEvent) -> Void)?
+ public private(set) var capabilities: DebugAdapterCapabilities = .unknown
public init(adapterID: String, transport: any DebugAdapterTransport) {
self.adapterID = adapterID
@@ -73,6 +84,7 @@ public final class DebugAdapterProtocolSession: DebugAdapterControllingSession {
do {
try transport.start(rootURL: rootURL.standardizedFileURL)
} catch {
+ reportFailure(error, context: "Debug Adapter failed to start")
state = .failed
throw error
}
@@ -95,13 +107,16 @@ public final class DebugAdapterProtocolSession: DebugAdapterControllingSession {
switch result {
case .success(let response):
let body = response["body"] as? [String: Any]
- self.supportsConfigurationDone = body?["supportsConfigurationDoneRequest"] as? Bool ?? false
+ self.capabilities = Self.parseCapabilities(body ?? [:])
+ self.supportsConfigurationDone = self.capabilities.supportsConfigurationDone
+ self.onEvent?(.capabilities(self.capabilities))
self.state = .ready
if let pendingLaunch = self.pendingLaunch {
self.pendingLaunch = nil
self.performLaunch(pendingLaunch)
}
- case .failure:
+ case .failure(let error):
+ self.reportFailure(error, context: "Debug Adapter initialization failed")
self.state = .failed
}
}
@@ -125,18 +140,57 @@ public final class DebugAdapterProtocolSession: DebugAdapterControllingSession {
if requestArguments["cwd"] == nil, let rootURL {
requestArguments["cwd"] = rootURL.path
}
+ activeRequestKind = configuration.request
state = .launching
sendRequest(command: configuration.request.rawValue, arguments: requestArguments) { [weak self] result in
guard let self else { return }
switch result {
case .success:
if self.state == .launching { self.state = .running }
- case .failure:
+ case .failure(let error):
+ self.reportFailure(error, context: "Debug launch failed")
self.state = .failed
}
}
}
+ private static func parseCapabilities(_ body: [String: Any]) -> DebugAdapterCapabilities {
+ let filters = (body["exceptionBreakpointFilters"] as? [[String: Any]] ?? [])
+ .compactMap { value -> DebugExceptionBreakpointFilter? in
+ guard let filter = value["filter"] as? String, !filter.isEmpty,
+ let label = value["label"] as? String, !label.isEmpty else { return nil }
+ return DebugExceptionBreakpointFilter(
+ filter: filter,
+ label: label,
+ description: value["description"] as? String,
+ isDefault: value["default"] as? Bool ?? false,
+ supportsCondition: value["supportsCondition"] as? Bool ?? false,
+ conditionDescription: value["conditionDescription"] as? String
+ )
+ }
+ return DebugAdapterCapabilities(
+ negotiated: true,
+ supportsConfigurationDone: body["supportsConfigurationDoneRequest"] as? Bool ?? false,
+ supportsConditionalBreakpoints: body["supportsConditionalBreakpoints"] as? Bool ?? false,
+ supportsHitConditionalBreakpoints: body["supportsHitConditionalBreakpoints"] as? Bool ?? false,
+ supportsLogPoints: body["supportsLogPoints"] as? Bool ?? false,
+ supportsFunctionBreakpoints: body["supportsFunctionBreakpoints"] as? Bool ?? false,
+ supportsDataBreakpoints: body["supportsDataBreakpoints"] as? Bool ?? false,
+ supportsExceptionOptions: body["supportsExceptionOptions"] as? Bool ?? false,
+ supportsExceptionFilterOptions: body["supportsExceptionFilterOptions"] as? Bool ?? false,
+ supportsSetVariable: body["supportsSetVariable"] as? Bool ?? false,
+ supportsCancelRequest: body["supportsCancelRequest"] as? Bool ?? false,
+ supportsSingleThreadExecutionRequests:
+ body["supportsSingleThreadExecutionRequests"] as? Bool ?? false,
+ supportsRestartRequest: body["supportsRestartRequest"] as? Bool ?? false,
+ supportsTerminateRequest: body["supportsTerminateRequest"] as? Bool ?? false,
+ supportsStepBack: body["supportsStepBack"] as? Bool ?? false,
+ supportsStepInTargetsRequest: body["supportsStepInTargetsRequest"] as? Bool ?? false,
+ supportsGotoTargetsRequest: body["supportsGotoTargetsRequest"] as? Bool ?? false,
+ exceptionBreakpointFilters: filters
+ )
+ }
+
public func setBreakpoints(_ breakpoints: [DebugSourceBreakpoint], in fileURL: URL) {
let normalizedURL = fileURL.standardizedFileURL
breakpointsBySource[normalizedURL] = breakpoints.sorted { $0.line < $1.line }
@@ -145,20 +199,117 @@ public final class DebugAdapterProtocolSession: DebugAdapterControllingSession {
sendBreakpoints(for: normalizedURL)
}
+ public func setExceptionBreakpoints(_ breakpoints: [DebugExceptionBreakpoint]) {
+ exceptionBreakpoints = breakpoints.sorted { $0.filter < $1.filter }
+ childSessions.forEach { $0.setExceptionBreakpoints(breakpoints) }
+ guard didReceiveInitializedEvent else { return }
+ sendExceptionBreakpoints()
+ }
+
+ public func setFunctionBreakpoints(_ breakpoints: [DebugFunctionBreakpoint]) {
+ functionBreakpoints = breakpoints.sorted { $0.name < $1.name }
+ childSessions.forEach { $0.setFunctionBreakpoints(breakpoints) }
+ guard didReceiveInitializedEvent, capabilities.supportsFunctionBreakpoints else { return }
+ sendFunctionBreakpoints()
+ }
+
+ public func setDataBreakpoints(_ breakpoints: [DebugDataBreakpoint]) {
+ dataBreakpoints = breakpoints.sorted {
+ ($0.dataID, $0.accessType ?? "") < ($1.dataID, $1.accessType ?? "")
+ }
+ childSessions.forEach { $0.setDataBreakpoints(breakpoints) }
+ guard didReceiveInitializedEvent, capabilities.supportsDataBreakpoints else { return }
+ sendDataBreakpoints()
+ }
+
+ public func requestDataBreakpointInfo(
+ name: String,
+ variablesReference: Int?,
+ frameID: Int?,
+ completion: @escaping (Result) -> Void
+ ) {
+ if let activeChildSession {
+ activeChildSession.requestDataBreakpointInfo(
+ name: name,
+ variablesReference: variablesReference,
+ frameID: frameID,
+ completion: completion
+ )
+ return
+ }
+ guard capabilities.supportsDataBreakpoints else {
+ completion(.failure(DebugAdapterCapabilityError.unsupported("data breakpoints")))
+ return
+ }
+ var arguments: [String: Any] = ["name": name]
+ if let variablesReference { arguments["variablesReference"] = variablesReference }
+ if let frameID { arguments["frameId"] = frameID }
+ sendRequest(command: "dataBreakpointInfo", arguments: arguments) { result in
+ completion(result.flatMap { response in
+ guard let body = response["body"] as? [String: Any],
+ let description = body["description"] as? String else {
+ return .failure(DebugAdapterProtocolError.invalidResponse("dataBreakpointInfo"))
+ }
+ return .success(DebugDataBreakpointInfo(
+ dataID: body["dataId"] as? String,
+ description: description,
+ accessTypes: body["accessTypes"] as? [String] ?? [],
+ canPersist: body["canPersist"] as? Bool ?? false
+ ))
+ })
+ }
+ }
+
public func execute(_ command: DebugExecutionCommand, threadID: Int?) {
+ execute(command, threadID: threadID, targetID: nil, singleThread: false)
+ }
+
+ public func execute(_ command: DebugExecutionCommand, threadID: Int?, targetID: Int?) {
+ execute(command, threadID: threadID, targetID: targetID, singleThread: false)
+ }
+
+ public func execute(
+ _ command: DebugExecutionCommand,
+ threadID: Int?,
+ targetID: Int?,
+ singleThread: Bool
+ ) {
if let activeChildSession {
- activeChildSession.execute(command, threadID: threadID)
+ activeChildSession.execute(
+ command,
+ threadID: threadID,
+ targetID: targetID,
+ singleThread: singleThread
+ )
return
}
guard transport.isRunning else { return }
+ if command == .stepBack, !capabilities.supportsStepBack { return }
+ if command == .goto, !capabilities.supportsGotoTargetsRequest { return }
+ if command == .restart, !capabilities.supportsRestartRequest { return }
+ if command == .terminate, !capabilities.supportsTerminateRequest { return }
+ if singleThread, !capabilities.supportsSingleThreadExecutionRequests { return }
+ if [.next, .stepIn, .stepOut, .stepBack, .goto].contains(command), state != .paused { return }
+ if command == .pause, state != .running { return }
+ if command == .continueExecution, state != .paused { return }
var arguments: [String: Any] = [:]
- if let threadID { arguments["threadId"] = threadID }
- if command == .continueExecution || command == .next || command == .stepIn || command == .stepOut {
- arguments["singleThread"] = false
+ if command != .restart, command != .terminate, let threadID {
+ arguments["threadId"] = threadID
+ }
+ if let targetID, command == .stepIn || command == .goto {
+ arguments["targetId"] = targetID
+ }
+ if command == .continueExecution || command == .next || command == .stepIn
+ || command == .stepOut || command == .stepBack || command == .goto
+ || command == .pause {
+ arguments["singleThread"] = singleThread
}
sendRequest(command: command.rawValue, arguments: arguments) { [weak self] result in
- if case .success = result, command != .pause {
- self?.state = .running
+ if case .success = result {
+ if command != .pause, command != .terminate,
+ !(singleThread && command == .continueExecution) {
+ self?.state = .running
+ }
}
}
}
@@ -217,23 +368,101 @@ public final class DebugAdapterProtocolSession: DebugAdapterControllingSession {
public func requestVariables(
reference: Int,
completion: @escaping (Result<[DebugVariable], Error>) -> Void
+ ) {
+ requestVariables(
+ reference: reference,
+ filter: nil,
+ start: nil,
+ count: nil,
+ completion: completion
+ )
+ }
+
+ public func requestVariables(
+ reference: Int,
+ filter: DebugVariableFilter?,
+ start: Int?,
+ count: Int?,
+ completion: @escaping (Result<[DebugVariable], Error>) -> Void
) {
if let activeChildSession {
- activeChildSession.requestVariables(reference: reference, completion: completion)
+ activeChildSession.requestVariables(
+ reference: reference,
+ filter: filter,
+ start: start,
+ count: count,
+ completion: completion
+ )
return
}
- sendRequest(command: "variables", arguments: ["variablesReference": reference]) { result in
+ var arguments: [String: Any] = ["variablesReference": reference]
+ if let filter { arguments["filter"] = filter.rawValue }
+ if let start { arguments["start"] = start }
+ if let count { arguments["count"] = count }
+ sendRequest(command: "variables", arguments: arguments) { result in
completion(result.flatMap { response in
guard let values = (response["body"] as? [String: Any])?["variables"] as? [[String: Any]] else {
return .failure(DebugAdapterProtocolError.invalidResponse("variables"))
}
return .success(values.enumerated().compactMap { index, value in
- Self.parseVariable(value, fallbackID: "\(reference):\(index)")
+ Self.parseVariable(
+ value,
+ fallbackID: [
+ String(reference),
+ filter?.rawValue ?? "all",
+ String((start ?? 0) + index)
+ ].joined(separator: ":"),
+ containerReference: reference
+ )
})
})
}
}
+ public func setVariable(
+ variablesReference: Int,
+ name: String,
+ value: String,
+ completion: @escaping (Result) -> Void
+ ) {
+ if let activeChildSession {
+ activeChildSession.setVariable(
+ variablesReference: variablesReference,
+ name: name,
+ value: value,
+ completion: completion
+ )
+ return
+ }
+ guard capabilities.supportsSetVariable else {
+ completion(.failure(DebugAdapterCapabilityError.unsupported("variable mutation")))
+ return
+ }
+ sendRequest(command: "setVariable", arguments: [
+ "variablesReference": variablesReference,
+ "name": name,
+ "value": value
+ ]) { result in
+ completion(result.flatMap { response in
+ guard let body = response["body"] as? [String: Any],
+ let resolvedValue = body["value"] as? String else {
+ return .failure(DebugAdapterProtocolError.invalidResponse("setVariable"))
+ }
+ return .success(DebugVariable(
+ id: "\(variablesReference):\(name)",
+ name: name,
+ value: resolvedValue,
+ type: body["type"] as? String,
+ evaluateName: nil,
+ variablesReference: body["variablesReference"] as? Int ?? 0,
+ containerReference: variablesReference,
+ namedVariables: body["namedVariables"] as? Int ?? 0,
+ indexedVariables: body["indexedVariables"] as? Int ?? 0
+ ))
+ })
+ }
+ }
+
public func evaluate(
_ expression: String,
frameID: Int?,
@@ -257,12 +486,67 @@ public final class DebugAdapterProtocolSession: DebugAdapterControllingSession {
value: value,
type: body["type"] as? String,
evaluateName: expression,
- variablesReference: body["variablesReference"] as? Int ?? 0
+ variablesReference: body["variablesReference"] as? Int ?? 0,
+ namedVariables: body["namedVariables"] as? Int ?? 0,
+ indexedVariables: body["indexedVariables"] as? Int ?? 0
))
})
}
}
+ public func requestStepInTargets(
+ frameID: Int,
+ completion: @escaping (Result<[DebugStepInTarget], Error>) -> Void
+ ) {
+ if let activeChildSession {
+ activeChildSession.requestStepInTargets(frameID: frameID, completion: completion)
+ return
+ }
+ guard capabilities.supportsStepInTargetsRequest else {
+ completion(.failure(DebugAdapterCapabilityError.unsupported("smart step into")))
+ return
+ }
+ sendRequest(command: "stepInTargets", arguments: ["frameId": frameID]) { result in
+ completion(result.flatMap { response in
+ guard let values = (response["body"] as? [String: Any])?["targets"] as? [[String: Any]] else {
+ return .failure(DebugAdapterProtocolError.invalidResponse("stepInTargets"))
+ }
+ return .success(values.compactMap(Self.parseStepInTarget))
+ })
+ }
+ }
+
+ public func requestGotoTargets(
+ fileURL: URL,
+ line: Int,
+ column: Int?,
+ completion: @escaping (Result<[DebugGotoTarget], Error>) -> Void
+ ) {
+ if let activeChildSession {
+ activeChildSession.requestGotoTargets(
+ fileURL: fileURL,
+ line: line,
+ column: column,
+ completion: completion
+ )
+ return
+ }
+ guard capabilities.supportsGotoTargetsRequest else {
+ completion(.failure(DebugAdapterCapabilityError.unsupported("run to cursor")))
+ return
+ }
+ var arguments: [String: Any] = ["source": ["path": fileURL.path], "line": line]
+ if let column { arguments["column"] = column }
+ sendRequest(command: "gotoTargets", arguments: arguments) { result in
+ completion(result.flatMap { response in
+ guard let values = (response["body"] as? [String: Any])?["targets"] as? [[String: Any]] else {
+ return .failure(DebugAdapterProtocolError.invalidResponse("gotoTargets"))
+ }
+ return .success(values.compactMap(Self.parseGotoTarget))
+ })
+ }
+ }
+
public func stop() {
let children = childSessions
childSessions = []
@@ -271,7 +555,7 @@ public final class DebugAdapterProtocolSession: DebugAdapterControllingSession {
if transport.isRunning {
sendRequest(command: "disconnect", arguments: [
"restart": false,
- "terminateDebuggee": true
+ "terminateDebuggee": activeRequestKind == .launch
]) { _ in }
}
transport.stop()
@@ -281,11 +565,17 @@ public final class DebugAdapterProtocolSession: DebugAdapterControllingSession {
}
private func sendBreakpoints(for fileURL: URL) {
- let breakpoints = breakpointsBySource[fileURL] ?? []
+ let breakpoints = (breakpointsBySource[fileURL] ?? []).filter(\.enabled)
let values: [[String: Any]] = breakpoints.map { breakpoint in
var value: [String: Any] = ["line": breakpoint.line]
if let column = breakpoint.column { value["column"] = column }
if let condition = breakpoint.condition, !condition.isEmpty { value["condition"] = condition }
+ if let hitCondition = breakpoint.hitCondition, !hitCondition.isEmpty {
+ value["hitCondition"] = hitCondition
+ }
+ if let logMessage = breakpoint.logMessage, !logMessage.isEmpty {
+ value["logMessage"] = logMessage
+ }
return value
}
sendRequest(command: "setBreakpoints", arguments: [
@@ -298,7 +588,92 @@ public final class DebugAdapterProtocolSession: DebugAdapterControllingSession {
else { return }
for (index, value) in returned.enumerated() {
let fallback = breakpoints.indices.contains(index) ? breakpoints[index].line : nil
- if let parsed = Self.parseBreakpoint(value, fallbackLine: fallback, sourceURL: fileURL, index: index) {
+ if let parsed = Self.parseBreakpoint(
+ value,
+ fallbackLine: fallback,
+ sourceURL: fileURL,
+ functionName: nil,
+ index: index
+ ) {
+ self.onEvent?(.breakpoint(parsed))
+ }
+ }
+ }
+ }
+
+ private func sendExceptionBreakpoints() {
+ let active = exceptionBreakpoints.filter(\.enabled)
+ var arguments: [String: Any] = ["filters": active.map(\.filter)]
+ if capabilities.supportsExceptionFilterOptions {
+ let options = active.compactMap { breakpoint -> [String: Any]? in
+ guard let condition = breakpoint.condition, !condition.isEmpty else { return nil }
+ return ["filterId": breakpoint.filter, "condition": condition]
+ }
+ if !options.isEmpty { arguments["filterOptions"] = options }
+ }
+ sendRequest(command: "setExceptionBreakpoints", arguments: arguments) { _ in }
+ }
+
+ private func sendFunctionBreakpoints() {
+ let active = functionBreakpoints.filter(\.enabled)
+ let values: [[String: Any]] = active.map { breakpoint in
+ var value: [String: Any] = ["name": breakpoint.name]
+ if let condition = breakpoint.condition, !condition.isEmpty {
+ value["condition"] = condition
+ }
+ if let hitCondition = breakpoint.hitCondition, !hitCondition.isEmpty {
+ value["hitCondition"] = hitCondition
+ }
+ return value
+ }
+ sendRequest(command: "setFunctionBreakpoints", arguments: ["breakpoints": values]) { [weak self] result in
+ guard let self, case .success(let response) = result,
+ let returned = (response["body"] as? [String: Any])?["breakpoints"] as? [[String: Any]]
+ else { return }
+ for (index, value) in returned.enumerated() {
+ let functionName = active.indices.contains(index) ? active[index].name : nil
+ if let parsed = Self.parseBreakpoint(
+ value,
+ fallbackLine: nil,
+ sourceURL: nil,
+ functionName: functionName,
+ index: index
+ ) {
+ self.onEvent?(.breakpoint(parsed))
+ }
+ }
+ }
+ }
+
+ private func sendDataBreakpoints() {
+ let active = dataBreakpoints.filter(\.enabled)
+ let values: [[String: Any]] = active.map { breakpoint in
+ var value: [String: Any] = ["dataId": breakpoint.dataID]
+ if let accessType = breakpoint.accessType, !accessType.isEmpty {
+ value["accessType"] = accessType
+ }
+ if let condition = breakpoint.condition, !condition.isEmpty {
+ value["condition"] = condition
+ }
+ if let hitCondition = breakpoint.hitCondition, !hitCondition.isEmpty {
+ value["hitCondition"] = hitCondition
+ }
+ return value
+ }
+ sendRequest(command: "setDataBreakpoints", arguments: ["breakpoints": values]) { [weak self] result in
+ guard let self, case .success(let response) = result,
+ let returned = (response["body"] as? [String: Any])?["breakpoints"] as? [[String: Any]]
+ else { return }
+ for (index, value) in returned.enumerated() {
+ let dataID = active.indices.contains(index) ? active[index].dataID : nil
+ if let parsed = Self.parseBreakpoint(
+ value,
+ fallbackLine: nil,
+ sourceURL: nil,
+ functionName: nil,
+ dataID: dataID,
+ index: index
+ ) {
self.onEvent?(.breakpoint(parsed))
}
}
@@ -416,6 +791,13 @@ public final class DebugAdapterProtocolSession: DebugAdapterControllingSession {
case "initialized":
didReceiveInitializedEvent = true
onEvent?(.initialized)
+ sendExceptionBreakpoints()
+ if capabilities.supportsFunctionBreakpoints {
+ sendFunctionBreakpoints()
+ }
+ if capabilities.supportsDataBreakpoints {
+ sendDataBreakpoints()
+ }
for source in breakpointsBySource.keys.sorted(by: { $0.path < $1.path }) {
sendBreakpoints(for: source)
}
@@ -440,7 +822,14 @@ public final class DebugAdapterProtocolSession: DebugAdapterControllingSession {
onEvent?(.terminated(exitCode: body["exitCode"] as? Int))
case "breakpoint":
if let value = body["breakpoint"] as? [String: Any],
- let breakpoint = Self.parseBreakpoint(value, fallbackLine: nil, sourceURL: nil, index: 0) {
+ let breakpoint = Self.parseBreakpoint(
+ value,
+ fallbackLine: nil,
+ sourceURL: nil,
+ functionName: nil,
+ dataID: nil,
+ index: 0
+ ) {
onEvent?(.breakpoint(breakpoint))
}
default: break
@@ -482,11 +871,14 @@ public final class DebugAdapterProtocolSession: DebugAdapterControllingSession {
arguments: childArguments
)
let child = DebugAdapterProtocolSession(adapterID: adapterID, transport: childTransport)
+ child.setExceptionBreakpoints(exceptionBreakpoints)
+ child.setFunctionBreakpoints(functionBreakpoints)
+ child.setDataBreakpoints(dataBreakpoints)
for (source, breakpoints) in breakpointsBySource {
child.setBreakpoints(breakpoints, in: source)
}
child.onStateChange = { [weak self, weak child] childState in
- guard let self else { return }
+ guard let self, let child else { return }
switch childState {
case .paused:
self.activeChildSession = child
@@ -495,9 +887,10 @@ public final class DebugAdapterProtocolSession: DebugAdapterControllingSession {
self.activeChildSession = child
self.state = .running
case .failed:
+ self.removeFinishedChild(child)
self.state = .failed
case .terminated:
- if self.activeChildSession === child { self.activeChildSession = nil }
+ self.removeFinishedChild(child)
self.state = .terminated
default:
break
@@ -543,6 +936,20 @@ public final class DebugAdapterProtocolSession: DebugAdapterControllingSession {
}
}
+ private func removeFinishedChild(_ child: DebugAdapterProtocolSession) {
+ childSessions.removeAll { $0 === child }
+ if activeChildSession === child {
+ activeChildSession = nil
+ }
+ }
+
+ private func reportFailure(_ error: Error, context: String) {
+ onEvent?(.output(
+ category: "stderr",
+ output: "\(context): \(error.localizedDescription)\n"
+ ))
+ }
+
private func failPendingRequests(_ error: Error) {
let handlers = responseHandlers.values
responseHandlers = [:]
@@ -555,7 +962,9 @@ public final class DebugAdapterProtocolSession: DebugAdapterControllingSession {
responseHandlers = [:]
didReceiveInitializedEvent = false
supportsConfigurationDone = false
+ capabilities = .unknown
pendingLaunch = nil
+ activeRequestKind = nil
activeChildSession = nil
childSessions = []
if !keepingState { state = .idle }
@@ -587,11 +996,17 @@ public final class DebugAdapterProtocolSession: DebugAdapterControllingSession {
id: value["presentationHint"] as? Int ?? reference * 1_000 + offset,
name: name,
variablesReference: reference,
- expensive: value["expensive"] as? Bool ?? false
+ expensive: value["expensive"] as? Bool ?? false,
+ namedVariables: value["namedVariables"] as? Int ?? 0,
+ indexedVariables: value["indexedVariables"] as? Int ?? 0
)
}
- private static func parseVariable(_ value: [String: Any], fallbackID: String) -> DebugVariable? {
+ private static func parseVariable(
+ _ value: [String: Any],
+ fallbackID: String,
+ containerReference: Int? = nil
+ ) -> DebugVariable? {
guard let name = value["name"] as? String,
let rendered = value["value"] as? String else { return nil }
return DebugVariable(
@@ -600,7 +1015,37 @@ public final class DebugAdapterProtocolSession: DebugAdapterControllingSession {
value: rendered,
type: value["type"] as? String,
evaluateName: value["evaluateName"] as? String,
- variablesReference: value["variablesReference"] as? Int ?? 0
+ variablesReference: value["variablesReference"] as? Int ?? 0,
+ containerReference: containerReference,
+ namedVariables: value["namedVariables"] as? Int ?? 0,
+ indexedVariables: value["indexedVariables"] as? Int ?? 0
+ )
+ }
+
+ private static func parseStepInTarget(_ value: [String: Any]) -> DebugStepInTarget? {
+ guard let id = value["id"] as? Int, let label = value["label"] as? String else { return nil }
+ return DebugStepInTarget(
+ id: id,
+ label: label,
+ line: value["line"] as? Int,
+ column: value["column"] as? Int,
+ endLine: value["endLine"] as? Int,
+ endColumn: value["endColumn"] as? Int
+ )
+ }
+
+ private static func parseGotoTarget(_ value: [String: Any]) -> DebugGotoTarget? {
+ guard let id = value["id"] as? Int,
+ let label = value["label"] as? String,
+ let line = value["line"] as? Int else { return nil }
+ return DebugGotoTarget(
+ id: id,
+ label: label,
+ line: line,
+ column: value["column"] as? Int,
+ endLine: value["endLine"] as? Int,
+ endColumn: value["endColumn"] as? Int,
+ instructionPointerReference: value["instructionPointerReference"] as? String
)
}
@@ -608,6 +1053,8 @@ public final class DebugAdapterProtocolSession: DebugAdapterControllingSession {
_ value: [String: Any],
fallbackLine: Int?,
sourceURL: URL?,
+ functionName: String?,
+ dataID: String? = nil,
index: Int
) -> DebugBreakpoint? {
let line = value["line"] as? Int ?? fallbackLine
@@ -618,7 +1065,9 @@ public final class DebugAdapterProtocolSession: DebugAdapterControllingSession {
message: value["message"] as? String,
sourceURL: source,
line: line,
- column: value["column"] as? Int
+ column: value["column"] as? Int,
+ functionName: functionName,
+ dataID: dataID
)
}
diff --git a/macos/Sources/LitheDebugModule/Runtime/DebugAdapterSessionManager.swift b/macos/Sources/LitheDebugModule/Runtime/DebugAdapterSessionManager.swift
index 09eb24217..20027d55a 100644
--- a/macos/Sources/LitheDebugModule/Runtime/DebugAdapterSessionManager.swift
+++ b/macos/Sources/LitheDebugModule/Runtime/DebugAdapterSessionManager.swift
@@ -1,6 +1,58 @@
import Foundation
import LitheCoreContracts
+/// Identifies one independently managed debug adapter session.
+public struct DebugSessionID: Hashable, Codable, Sendable, CustomStringConvertible {
+ public let rawValue: UUID
+
+ public init(rawValue: UUID = UUID()) {
+ self.rawValue = rawValue
+ }
+
+ public var description: String { rawValue.uuidString.lowercased() }
+}
+
+/// A stable, UI-safe projection of one debug session.
+public struct DebugSessionSummary: Identifiable, Equatable, Sendable {
+ public let id: DebugSessionID
+ public let providerID: String
+ public let providerDisplayName: String
+ public let rootURL: URL
+ public let state: DebugAdapterState
+ public let targetTitle: String?
+
+ public var isRunning: Bool {
+ ![.idle, .terminated, .failed].contains(state)
+ }
+
+ public init(
+ id: DebugSessionID,
+ providerID: String,
+ providerDisplayName: String,
+ rootURL: URL,
+ state: DebugAdapterState,
+ targetTitle: String? = nil
+ ) {
+ self.id = id
+ self.providerID = providerID
+ self.providerDisplayName = providerDisplayName
+ self.rootURL = rootURL
+ self.state = state
+ self.targetTitle = targetTitle
+ }
+}
+
+/// The adapter and its public identity returned when a new session is created.
+public struct DebugSessionHandle {
+ public let id: DebugSessionID
+ public let session: any DebugAdapterSession
+
+ public init(id: DebugSessionID, session: any DebugAdapterSession) {
+ self.id = id
+ self.session = session
+ }
+}
+
/// Owns every DAP session, breakpoint projection, and debug callback.
///
/// This is deliberately separate from `LanguageToolingSessionManager`: an LSP
@@ -11,18 +63,56 @@ public final class DebugAdapterSessionManager: ObservableObject {
@Published public private(set) var states: [String: DebugAdapterState] = [:]
@Published public private(set) var lastEvents: [String: DebugAdapterEvent] = [:]
@Published public private(set) var verifiedBreakpoints: [String: [DebugBreakpoint]] = [:]
+ @Published public private(set) var sessionSummaries: [DebugSessionSummary] = []
public var onStateChange: ((String, DebugAdapterState) -> Void)?
public var onEvent: ((String, DebugAdapterEvent) -> Void)?
+ public var onSessionStateChange: ((DebugSessionID, String, DebugAdapterState) -> Void)?
+ public var onSessionEvent: ((DebugSessionID, String, DebugAdapterEvent) -> Void)?
+ public var onRunInTerminalRequest: DebugRunInTerminalRequestHandler? {
+ didSet {
+ for managed in sessions.values {
+ configureRunInTerminalHandler(managed.session, sessionID: managed.id)
+ }
+ }
+ }
+ /// Session-aware reverse request routing used by hosts that own one
+ /// integrated terminal per debugger session.
+ public var onSessionRunInTerminalRequest: ((
+ DebugSessionID,
+ DebugRunInTerminalRequest,
+ @escaping DebugRunInTerminalCompletion
+ ) -> Void)? {
+ didSet {
+ for managed in sessions.values {
+ configureRunInTerminalHandler(managed.session, sessionID: managed.id)
+ }
+ }
+ }
private let providers: [DebugProviderDescriptor]
private let makeSession: @MainActor (
DebugProviderDescriptor,
URL
) -> (any DebugAdapterSession)?
- private var sessions: [String: any DebugAdapterSession] = [:]
+ private struct ManagedSession {
+ let id: DebugSessionID
+ let descriptor: DebugProviderDescriptor
+ let rootURL: URL
+ let session: any DebugAdapterSession
+ let activationToken: UUID
+ var targetTitle: String?
+ }
+
+ private var sessions: [DebugSessionID: ManagedSession] = [:]
+ private var sessionOrder: [DebugSessionID] = []
+ private var activeSessionIDsByProvider: [String: DebugSessionID] = [:]
private var roots: [String: URL] = [:]
+ private var activationTokens: [String: UUID] = [:]
private var requestedBreakpoints: [String: [URL: [DebugSourceBreakpoint]]] = [:]
+ private var requestedExceptionBreakpoints: [String: [DebugExceptionBreakpoint]] = [:]
+ private var requestedFunctionBreakpoints: [String: [DebugFunctionBreakpoint]] = [:]
+ private var requestedDataBreakpoints: [String: [DebugDataBreakpoint]] = [:]
public init(
providers: [DebugProviderDescriptor],
@@ -35,7 +125,11 @@ public final class DebugAdapterSessionManager: ObservableObject {
self.makeSession = makeSession
}
- public var activeAdapterIDs: Set { Set(sessions.keys) }
+ /// Provider IDs with a currently selected compatibility session.
+ public var activeAdapterIDs: Set { Set(activeSessionIDsByProvider.keys) }
+
+ /// IDs for every session still registered with the manager.
+ public var activeSessionIDs: Set { Set(sessions.keys) }
public func provider(for fileURL: URL) -> DebugProviderDescriptor? {
providers.first { $0.matches(fileURL) }
@@ -43,36 +137,81 @@ public final class DebugAdapterSessionManager: ObservableObject {
@discardableResult
public func activate(for fileURL: URL, rootURL: URL) throws -> any DebugAdapterSession {
- guard let descriptor = provider(for: fileURL) else {
- throw DebugProviderError.noProvider(
- fileExtension: fileURL.pathExtension.lowercased()
- )
+ let providerID = try descriptor(for: fileURL).id
+ if let sessionID = activeSessionIDsByProvider[providerID],
+ let managed = sessions[sessionID],
+ managed.session.isRunning,
+ managed.rootURL == rootURL.standardizedFileURL {
+ return managed.session
}
-
- let normalizedRoot = rootURL.standardizedFileURL
- if let active = sessions[descriptor.id] {
- if active.isRunning, roots[descriptor.id] == normalizedRoot {
- return active
- }
- active.stop()
- sessions[descriptor.id] = nil
- roots[descriptor.id] = nil
+ if activeSessionIDsByProvider[providerID] != nil {
+ stop(providerID: providerID)
}
+ return try activateNew(for: fileURL, rootURL: rootURL).session
+ }
+ /// Creates a new independent session without replacing another session for
+ /// the same provider. Existing provider-level APIs continue to use the
+ /// latest session as their compatibility target.
+ @discardableResult
+ public func activateNew(for fileURL: URL, rootURL: URL) throws -> DebugSessionHandle {
+ let descriptor = try descriptor(for: fileURL)
+ let normalizedRoot = rootURL.standardizedFileURL
guard let session = makeSession(descriptor, normalizedRoot) else {
throw DebugProviderError.adapterUnavailable(descriptor.displayName)
}
- configureCallbacks(session, providerID: descriptor.id)
- try session.start(rootURL: normalizedRoot)
- sessions[descriptor.id] = session
+ let sessionID = DebugSessionID()
+ let activationToken = UUID()
+ configureCallbacks(
+ session,
+ sessionID: sessionID,
+ providerID: descriptor.id,
+ activationToken: activationToken
+ )
+ sessions[sessionID] = ManagedSession(
+ id: sessionID,
+ descriptor: descriptor,
+ rootURL: normalizedRoot,
+ session: session,
+ activationToken: activationToken,
+ targetTitle: nil
+ )
+ sessionOrder.append(sessionID)
+ activeSessionIDsByProvider[descriptor.id] = sessionID
roots[descriptor.id] = normalizedRoot
states[descriptor.id] = session.state
+ activationTokens[descriptor.id] = activationToken
+ updateSessionSummary(sessionID)
+ do {
+ try session.start(rootURL: normalizedRoot)
+ } catch {
+ session.stop()
+ sessions.removeValue(forKey: sessionID)
+ sessionOrder.removeAll { $0 == sessionID }
+ if activeSessionIDsByProvider[descriptor.id] == sessionID {
+ promoteLatestSession(for: descriptor.id)
+ }
+ throw error
+ }
+ if activeSessionIDsByProvider[descriptor.id] == sessionID {
+ states[descriptor.id] = session.state
+ }
+ updateSessionSummary(sessionID)
if let controlling = session as? any DebugAdapterControllingSession {
for (source, breakpoints) in requestedBreakpoints[descriptor.id] ?? [:] {
controlling.setBreakpoints(breakpoints, in: source)
}
+ if let breakpoints = requestedExceptionBreakpoints[descriptor.id] {
+ controlling.setExceptionBreakpoints(breakpoints)
+ }
+ if let breakpoints = requestedFunctionBreakpoints[descriptor.id] {
+ controlling.setFunctionBreakpoints(breakpoints)
+ }
+ if let breakpoints = requestedDataBreakpoints[descriptor.id] {
+ controlling.setDataBreakpoints(breakpoints)
+ }
}
- return session
+ return DebugSessionHandle(id: sessionID, session: session)
}
@discardableResult
@@ -92,6 +231,31 @@ public final class DebugAdapterSessionManager: ObservableObject {
return controlling
}
+ /// Starts a new independent adapter session and returns its identity.
+ @discardableResult
+ public func launchNew(
+ for fileURL: URL,
+ rootURL: URL,
+ configuration: DebugLaunchConfiguration
+ ) throws -> (id: DebugSessionID, session: any DebugAdapterControllingSession) {
+ let handle = try activateNew(for: fileURL, rootURL: rootURL)
+ guard let controlling = handle.session as? any DebugAdapterControllingSession else {
+ stop(sessionID: handle.id)
+ throw DebugProviderError.capabilityUnavailable(
+ provider: provider(for: fileURL)?.displayName ?? fileURL.pathExtension,
+ capability: "DAP launch control"
+ )
+ }
+ do {
+ try controlling.launch(configuration)
+ } catch {
+ stop(sessionID: handle.id)
+ throw error
+ }
+ updateSessionTargetTitle(handle.id, title: configuration.name)
+ return (handle.id, controlling)
+ }
+
public func setBreakpoints(_ breakpoints: [DebugSourceBreakpoint], in fileURL: URL) throws {
guard let descriptor = provider(for: fileURL) else {
throw DebugProviderError.noProvider(
@@ -101,40 +265,144 @@ public final class DebugAdapterSessionManager: ObservableObject {
var values = requestedBreakpoints[descriptor.id] ?? [:]
values[fileURL.standardizedFileURL] = breakpoints
requestedBreakpoints[descriptor.id] = values
- session(providerID: descriptor.id)?.setBreakpoints(breakpoints, in: fileURL)
+ sessions.values
+ .filter { $0.descriptor.id == descriptor.id }
+ .compactMap { $0.session as? any DebugAdapterControllingSession }
+ .forEach { $0.setBreakpoints(breakpoints, in: fileURL) }
+ }
+
+ public func setExceptionBreakpoints(
+ _ breakpoints: [DebugExceptionBreakpoint],
+ for fileURL: URL
+ ) throws {
+ guard let descriptor = provider(for: fileURL) else {
+ throw DebugProviderError.noProvider(
+ fileExtension: fileURL.pathExtension.lowercased()
+ )
+ }
+ requestedExceptionBreakpoints[descriptor.id] = breakpoints
+ sessions.values
+ .filter { $0.descriptor.id == descriptor.id }
+ .compactMap { $0.session as? any DebugAdapterControllingSession }
+ .forEach { $0.setExceptionBreakpoints(breakpoints) }
+ }
+
+ public func setFunctionBreakpoints(
+ _ breakpoints: [DebugFunctionBreakpoint],
+ for fileURL: URL
+ ) throws {
+ guard let descriptor = provider(for: fileURL) else {
+ throw DebugProviderError.noProvider(
+ fileExtension: fileURL.pathExtension.lowercased()
+ )
+ }
+ requestedFunctionBreakpoints[descriptor.id] = breakpoints
+ sessions.values
+ .filter { $0.descriptor.id == descriptor.id }
+ .compactMap { $0.session as? any DebugAdapterControllingSession }
+ .forEach { $0.setFunctionBreakpoints(breakpoints) }
+ }
+
+ public func setDataBreakpoints(
+ _ breakpoints: [DebugDataBreakpoint],
+ for fileURL: URL
+ ) throws {
+ guard let descriptor = provider(for: fileURL) else {
+ throw DebugProviderError.noProvider(
+ fileExtension: fileURL.pathExtension.lowercased()
+ )
+ }
+ requestedDataBreakpoints[descriptor.id] = breakpoints
+ sessions.values
+ .filter { $0.descriptor.id == descriptor.id }
+ .compactMap { $0.session as? any DebugAdapterControllingSession }
+ .forEach { $0.setDataBreakpoints(breakpoints) }
}
public func session(providerID: String) -> (any DebugAdapterControllingSession)? {
- sessions[providerID] as? any DebugAdapterControllingSession
+ guard let sessionID = activeSessionIDsByProvider[providerID] else { return nil }
+ return sessions[sessionID]?.session as? any DebugAdapterControllingSession
+ }
+
+ public func session(id: DebugSessionID) -> (any DebugAdapterControllingSession)? {
+ sessions[id]?.session as? any DebugAdapterControllingSession
+ }
+
+ /// Selects which session is addressed by the compatibility provider-level
+ /// APIs and callbacks. Selection does not start or stop a session.
+ @discardableResult
+ public func select(sessionID: DebugSessionID) -> Bool {
+ guard let managed = sessions[sessionID] else { return false }
+ let providerID = managed.descriptor.id
+ activeSessionIDsByProvider[providerID] = sessionID
+ roots[providerID] = managed.rootURL
+ activationTokens[providerID] = managed.activationToken
+ states[providerID] = managed.session.state
+ return true
+ }
+
+ public func selectedSessionID(providerID: String) -> DebugSessionID? {
+ activeSessionIDsByProvider[providerID]
}
public func stop(providerID: String) {
- sessions.removeValue(forKey: providerID)?.stop()
- roots[providerID] = nil
- states[providerID] = .idle
+ guard let sessionID = activeSessionIDsByProvider[providerID] else {
+ states[providerID] = .idle
+ return
+ }
+ stop(sessionID: sessionID)
+ }
+
+ public func stop(sessionID: DebugSessionID) {
+ guard let managed = sessions.removeValue(forKey: sessionID) else { return }
+ managed.session.stop()
+ sessionOrder.removeAll { $0 == sessionID }
+ if activeSessionIDsByProvider[managed.descriptor.id] == sessionID {
+ promoteLatestSession(for: managed.descriptor.id)
+ }
+ sessionSummaries.removeAll { $0.id == sessionID }
+ onSessionStateChange?(sessionID, managed.descriptor.id, .idle)
}
public func stopAll() {
- for session in sessions.values { session.stop() }
+ for session in sessions.values { session.session.stop() }
sessions.removeAll()
+ sessionOrder.removeAll()
+ activeSessionIDsByProvider.removeAll()
roots.removeAll()
+ activationTokens.removeAll()
states.removeAll()
lastEvents.removeAll()
verifiedBreakpoints.removeAll()
+ sessionSummaries.removeAll()
requestedBreakpoints.removeAll()
+ requestedExceptionBreakpoints.removeAll()
+ requestedFunctionBreakpoints.removeAll()
+ requestedDataBreakpoints.removeAll()
}
private func configureCallbacks(
_ session: any DebugAdapterSession,
- providerID: String
+ sessionID: DebugSessionID,
+ providerID: String,
+ activationToken: UUID
) {
+ configureRunInTerminalHandler(session, sessionID: sessionID)
guard let controlling = session as? any DebugAdapterControllingSession else { return }
controlling.onStateChange = { [weak self] state in
- self?.states[providerID] = state
- self?.onStateChange?(providerID, state)
+ guard let self,
+ self.sessions[sessionID]?.activationToken == activationToken else { return }
+ self.updateSessionState(sessionID, state: state)
+ self.onSessionStateChange?(sessionID, providerID, state)
+ guard self.activeSessionIDsByProvider[providerID] == sessionID else { return }
+ self.states[providerID] = state
+ self.onStateChange?(providerID, state)
}
controlling.onEvent = { [weak self] event in
- guard let self else { return }
+ guard let self,
+ self.sessions[sessionID]?.activationToken == activationToken else { return }
+ self.onSessionEvent?(sessionID, providerID, event)
+ guard self.activeSessionIDsByProvider[providerID] == sessionID else { return }
lastEvents[providerID] = event
onEvent?(providerID, event)
if case .breakpoint(let breakpoint) = event {
@@ -151,4 +419,95 @@ public final class DebugAdapterSessionManager: ObservableObject {
}
}
}
+
+ private func descriptor(for fileURL: URL) throws -> DebugProviderDescriptor {
+ guard let descriptor = provider(for: fileURL) else {
+ throw DebugProviderError.noProvider(
+ fileExtension: fileURL.pathExtension.lowercased()
+ )
+ }
+ return descriptor
+ }
+
+ private func updateSessionState(_ sessionID: DebugSessionID, state: DebugAdapterState) {
+ guard let index = sessionSummaries.firstIndex(where: { $0.id == sessionID }),
+ let existing = sessions[sessionID] else { return }
+ sessionSummaries[index] = DebugSessionSummary(
+ id: sessionID,
+ providerID: existing.descriptor.id,
+ providerDisplayName: existing.descriptor.displayName,
+ rootURL: existing.rootURL,
+ state: state,
+ targetTitle: existing.targetTitle
+ )
+ }
+
+ private func promoteLatestSession(for providerID: String) {
+ guard let replacementID = sessionOrder.reversed().first(where: {
+ sessions[$0]?.descriptor.id == providerID
+ }), let replacement = sessions[replacementID] else {
+ activeSessionIDsByProvider[providerID] = nil
+ roots[providerID] = nil
+ activationTokens[providerID] = nil
+ states[providerID] = .idle
+ lastEvents[providerID] = nil
+ verifiedBreakpoints[providerID] = nil
+ return
+ }
+ activeSessionIDsByProvider[providerID] = replacementID
+ roots[providerID] = replacement.rootURL
+ activationTokens[providerID] = replacement.activationToken
+ states[providerID] = replacement.session.state
+ lastEvents[providerID] = nil
+ verifiedBreakpoints[providerID] = nil
+ }
+
+ private func updateSessionSummary(_ sessionID: DebugSessionID) {
+ guard let managed = sessions[sessionID] else { return }
+ let summary = DebugSessionSummary(
+ id: sessionID,
+ providerID: managed.descriptor.id,
+ providerDisplayName: managed.descriptor.displayName,
+ rootURL: managed.rootURL,
+ state: managed.session.state,
+ targetTitle: managed.targetTitle
+ )
+ if let index = sessionSummaries.firstIndex(where: { $0.id == sessionID }) {
+ sessionSummaries[index] = summary
+ } else {
+ sessionSummaries.append(summary)
+ }
+ sessionSummaries.sort { left, right in
+ guard let leftIndex = sessionOrder.firstIndex(of: left.id),
+ let rightIndex = sessionOrder.firstIndex(of: right.id) else {
+ return left.id.description < right.id.description
+ }
+ return leftIndex < rightIndex
+ }
+ }
+
+ private func updateSessionTargetTitle(_ sessionID: DebugSessionID, title: String?) {
+ guard var managed = sessions[sessionID] else { return }
+ managed.targetTitle = title
+ sessions[sessionID] = managed
+ updateSessionSummary(sessionID)
+ }
+
+ private func configureRunInTerminalHandler(
+ _ session: any DebugAdapterSession,
+ sessionID: DebugSessionID
+ ) {
+ guard let session = session as? any DebugAdapterRunInTerminalSession else { return }
+ session.onRunInTerminalRequest = { [weak self] request, completion in
+ guard let self else {
+ completion(.failure(DebugAdapterProtocolError.stopped))
+ return
+ }
+ if let onSessionRunInTerminalRequest {
+ onSessionRunInTerminalRequest(sessionID, request, completion)
+ } else {
+ onRunInTerminalRequest?(request, completion)
+ }
+ }
+ }
}
diff --git a/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift b/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift
index 481407875..93cbe0206 100644
--- a/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift
+++ b/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift
@@ -36,7 +36,7 @@ package final class MavenFeatureModel: ObservableObject {
package var isReloadRequired: Bool { service.isReloadRequired }
package var launchContext: MavenLaunchContext? { service.launchContext }
- package func loadProject(at workspaceURL: URL, files: [URL]) async {
+ package func loadProject(at workspaceURL: URL, files: [URL], snapshotID: UUID? = nil) async {
await service.loadProject(at: workspaceURL, files: files)
}
@@ -134,6 +134,8 @@ package final class RunFeatureModel: ObservableObject {
package var configurationStatus: ProjectRunConfigurationStatus { service.configurationStatus }
package var configurationDiagnostics: [RunConfigurationDiagnostic] { service.configurationDiagnostics }
package var generationState: RunConfigurationGenerationState { service.generationState }
+ package var projectLoadState: ProjectLoadState { service.projectLoadState }
+ package func reportGenerationProjectNotReady() { service.reportGenerationProjectNotReady() }
package var recoveryAction: RunConfigurationRecoveryAction { service.recoveryAction }
package var recoveryPath: String? { service.recoveryPath }
package var configurationSaveError: String? { service.configurationSaveError }
@@ -142,15 +144,27 @@ package final class RunFeatureModel: ObservableObject {
service.blockingToolchainDiagnostic(for: service.selectedConfiguration)
}
package var sourceSearchRoots: [URL] { service.sourceSearchRoots }
+ package func isProjectReady(for workspace: URL, snapshotID: UUID?) -> Bool { service.isProjectReady(for: workspace, snapshotID: snapshotID) }
+ package func hasReadyInventory(for workspace: URL) -> Bool { service.hasReadyInventory(for: workspace) }
package func options(for configuration: RunConfiguration) -> RunOptions {
service.options(for: configuration)
}
+ package func configuredServerPort(for configuration: RunConfiguration) -> Int? {
+ service.configuredServerPort(for: configuration)
+ }
+
package func source(for configuration: RunConfiguration) -> RunConfigurationSource {
service.source(for: configuration)
}
+ /// Applies the same selected configuration side effects used by Run,
+ /// including project-scoped Java runtime selection, before Debug starts.
+ package func select(_ configuration: RunConfiguration) {
+ service.select(configuration)
+ }
+
package func serviceURL(for configuration: RunConfiguration) -> URL? {
service.serviceURL(for: configuration)
}
@@ -210,9 +224,10 @@ package final class RunFeatureModel: ObservableObject {
package func loadProject(
at workspaceURL: URL,
files: [URL],
- mavenProject: MavenProject?
+ mavenProject: MavenProject?,
+ snapshotID: UUID? = nil
) async {
- await service.loadProject(at: workspaceURL, files: files, mavenProject: mavenProject)
+ await service.loadProject(at: workspaceURL, files: files, mavenProject: mavenProject, snapshotID: snapshotID)
}
package func generateRunConfigurations() async {
@@ -226,7 +241,6 @@ package final class RunFeatureModel: ObservableObject {
isGenerationConfirmationPresented = true
}
- package func select(_ configuration: RunConfiguration) { service.select(configuration) }
@discardableResult
package func registerLanguageRunExtension(
_ provider: any LanguageRunExtensionProviding,
@@ -257,7 +271,7 @@ package final class ProjectDevelopmentFeatureModel {
self.runFeature = runFeature
}
- package func loadProject(at workspaceURL: URL, files: [URL]) async {
+ package func loadProject(at workspaceURL: URL, files: [URL], snapshotID: UUID? = nil) async {
// Maven is one build-system Provider, not a workspace prerequisite.
// Avoid scanning every project as Maven; non-Maven ecosystems should
// reach the generic run pipeline without paying for Java discovery.
@@ -272,7 +286,8 @@ package final class ProjectDevelopmentFeatureModel {
await runFeature.loadProject(
at: workspaceURL,
files: files,
- mavenProject: mavenFeature.project
+ mavenProject: mavenFeature.project,
+ snapshotID: snapshotID
)
}
}
diff --git a/macos/Sources/LitheExecutionModule/Services/LanguageTestService.swift b/macos/Sources/LitheExecutionModule/Services/LanguageTestService.swift
index 724ff4e1c..6d90e9d8a 100644
--- a/macos/Sources/LitheExecutionModule/Services/LanguageTestService.swift
+++ b/macos/Sources/LitheExecutionModule/Services/LanguageTestService.swift
@@ -101,6 +101,17 @@ package final class LanguageTestService: ObservableObject {
itemsByProviderID = discovered
}
+ package func replaceDiscoveredItems(
+ _ items: [LanguageTestItem],
+ providerID: String
+ ) {
+ if items.isEmpty {
+ itemsByProviderID[providerID] = nil
+ } else {
+ itemsByProviderID[providerID] = items
+ }
+ }
+
@discardableResult
package func run(
providerID: String,
diff --git a/macos/Sources/LitheExecutionModule/Services/RunService.swift b/macos/Sources/LitheExecutionModule/Services/RunService.swift
index b603ec2ba..879100d69 100644
--- a/macos/Sources/LitheExecutionModule/Services/RunService.swift
+++ b/macos/Sources/LitheExecutionModule/Services/RunService.swift
@@ -14,6 +14,7 @@ package final class RunService: ObservableObject {
}
}
@Published package private(set) var isLoadingProject = false
+ @Published package private(set) var projectLoadState: ProjectLoadState = .idle
@Published package private(set) var isRunning = false
@Published package private(set) var runningTitle: String?
@Published package private(set) var output = ""
@@ -106,6 +107,30 @@ package final class RunService: ObservableObject {
package var lastRunFileURL: URL? { lastCurrentFileURL }
package var lastConfiguration: RunConfiguration? { lastRunConfiguration }
+ /// Whether the file inventory for `workspace` came from its snapshot, and is
+ /// therefore complete enough to generate a configuration from. Entry points
+ /// that activate the execution module on demand use this to decide whether
+ /// the project still has to be loaded.
+ package func isProjectReady(for workspace: URL, snapshotID: UUID?) -> Bool {
+ projectLoadState.isReady(for: workspace, snapshotID: snapshotID)
+ }
+
+ /// Whether a complete inventory for `workspace` is already loaded, even if a
+ /// newer snapshot has since been published. Entry points use this to tell a
+ /// superseded inventory apart from one that was never loaded.
+ package func hasReadyInventory(for workspace: URL) -> Bool {
+ projectLoadState.hasReadyInventory(for: workspace)
+ }
+
+ /// Surfaces the "project still loading" generation notice without scanning.
+ ///
+ /// AppModel uses this when readiness cannot be established for the current
+ /// snapshot: the service may still hold an older `.ready` inventory, and
+ /// calling `generateRunConfigurations` would scan that stale list.
+ package func reportGenerationProjectNotReady() {
+ generationState = .projectNotReady
+ }
+
package func configureMavenContextProvider(
_ provider: @escaping @MainActor () -> MavenLaunchContext?
) {
@@ -139,14 +164,22 @@ package final class RunService: ObservableObject {
return roots
}
+ /// Loads run state for a workspace.
+ ///
+ /// `snapshotID` identifies the workspace snapshot `files` came from. Passing
+ /// `nil` means no snapshot has been applied yet, which binds the service so
+ /// existing configuration can be read while generation stays blocked.
package func loadProject(
at projectURL: URL,
files: [URL],
- mavenProject: MavenProject?
+ mavenProject: MavenProject?,
+ snapshotID: UUID? = nil
) async {
let loadID = UUID()
projectLoadID = loadID
+ let workspace = projectURL.standardizedFileURL
isLoadingProject = true
+ projectLoadState = .loading(workspace: workspace)
defer {
if projectLoadID == loadID {
isLoadingProject = false
@@ -162,7 +195,14 @@ package final class RunService: ObservableObject {
if let currentProject = self.projectURL {
selectedConfigurationIDsByProject[currentProject.path] = selectedConfigurationID
}
- self.projectURL = projectURL.standardizedFileURL
+ self.projectURL = workspace
+ // Whether the existing configuration parses is `configurationStatus`, not
+ // this state. Keeping them apart is what lets a broken generated.json be
+ // regenerated: folding a parse failure in here would block generation,
+ // which is the only way to repair it.
+ projectLoadState = snapshotID
+ .map { .ready(workspace: workspace, snapshotID: $0) }
+ ?? .bound(workspace: workspace)
self.mavenProject = mavenProject
mavenProfiles = mavenProject?.profiles ?? []
self.projectFiles = files
@@ -206,7 +246,14 @@ package final class RunService: ObservableObject {
}
package func generateRunConfigurations() async {
- guard let projectURL else { return }
+ // Generation scans the file inventory this service holds, so a
+ // provisional inventory would write a configuration that omits entry
+ // points the workspace contains. Dropping the request silently is also
+ // indistinguishable from a broken button, so report the pending state.
+ guard let projectURL, case .ready = projectLoadState else {
+ generationState = .projectNotReady
+ return
+ }
let loadID = projectLoadID
isLoadingProject = true
defer {
@@ -294,6 +341,13 @@ package final class RunService: ObservableObject {
optionsByConfigurationID[configuration.id] ?? RunOptions()
}
+ /// Returns the service port explicitly configured for this run target, or
+ /// a Spring-style framework's conventional 8080 default when no override exists.
+ package func configuredServerPort(for configuration: RunConfiguration) -> Int? {
+ configuredPort(for: configuration)
+ ?? (configuration.kind.mavenFramework != nil ? 8080 : nil)
+ }
+
package func source(for configuration: RunConfiguration) -> RunConfigurationSource {
effectiveSourcesByConfigurationID[configuration.id] ?? .generated
}
@@ -598,6 +652,7 @@ package final class RunService: ObservableObject {
stopAllServices()
projectLoadID = UUID()
projectURL = nil
+ projectLoadState = .idle
selectedConfigurationIDsByProject = [:]
projectFiles = []
mavenProject = nil
diff --git a/macos/Sources/LitheExecutionModule/Services/StandardLanguageTestProvider.swift b/macos/Sources/LitheExecutionModule/Services/StandardLanguageTestProvider.swift
index 9eb6ae4d4..fc09df5fb 100644
--- a/macos/Sources/LitheExecutionModule/Services/StandardLanguageTestProvider.swift
+++ b/macos/Sources/LitheExecutionModule/Services/StandardLanguageTestProvider.swift
@@ -200,7 +200,7 @@ package struct StandardLanguageTestProvider: LanguageTestProvider {
case .file(let url):
return ["-Dtest=" + url.deletingPathExtension().lastPathComponent, "test"]
case .testCase(let identifier, _):
- return ["-Dtest=" + identifier, "test"]
+ return ["-Dtest=" + normalizedJavaTestIdentifier(identifier), "test"]
}
}
@@ -212,11 +212,18 @@ package struct StandardLanguageTestProvider: LanguageTestProvider {
case .file(let url):
arguments.append(contentsOf: ["--tests", try gradleSelector(for: url, root: root)])
case .testCase(let identifier, _):
- arguments.append(contentsOf: ["--tests", identifier])
+ arguments.append(contentsOf: [
+ "--tests",
+ normalizedJavaTestIdentifier(identifier).replacingOccurrences(of: "#", with: "."),
+ ])
}
return arguments
}
+ private func normalizedJavaTestIdentifier(_ identifier: String) -> String {
+ identifier.hasSuffix("()") ? String(identifier.dropLast(2)) : identifier
+ }
+
private func gradleSelector(for url: URL, root: URL) throws -> String {
_ = try checkedRelativePath(url, root: root)
return url.deletingPathExtension().lastPathComponent
diff --git a/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift b/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift
index 74c8ad4a4..439524914 100644
--- a/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift
+++ b/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift
@@ -45,6 +45,7 @@ package final class LanguageServerRuntimeSession: LanguageServerSession {
package var onFeaturesChange: ((LanguageServerFeatureSet) -> Void)?
package private(set) var serverInfo: LanguageServerInfo?
package var onServerInfoChange: ((LanguageServerInfo?) -> Void)?
+ package var javaTestRunnerURL: URL? { jdtlsLaunchResources?.javaTestRunnerURL }
package init(
providerID: String,
@@ -386,12 +387,28 @@ package final class LanguageServerRuntimeSession: LanguageServerSession {
_ command: LanguageServerCommand,
fileURL: URL,
completion: @escaping (Result) -> Void
+ ) throws {
+ try executeReturningValue(command, fileURL: fileURL) { result in
+ completion(result.map { _ in () })
+ }
+ }
+
+ package func executeReturningValue(
+ _ command: LanguageServerCommand,
+ fileURL: URL,
+ completion: @escaping (Result) -> Void
) throws {
// A workspace command belongs to the server rather than to a document, so
// it carries no document URI and is not gated on one being open.
_ = fileURL
try request(.executeCommand, fileURL: nil, command: command) { result in
- completion(result.map { _ in () })
+ completion(result.flatMap { event in
+ guard case .object(let object)? = event.result,
+ let value = object["value"] else {
+ return .failure(LanguageServerRuntimeSessionError.missingResult)
+ }
+ return .success(value)
+ })
}
}
diff --git a/macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift b/macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift
index e1479e776..52890cca8 100644
--- a/macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift
+++ b/macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift
@@ -8,6 +8,7 @@ package enum LanguageToolingSessionError: LocalizedError, Equatable, Sendable {
case providerNotInstalled(String)
case toolingUnavailable(String)
case capabilityUnavailable(provider: String, capability: String)
+ case invalidJavaDebugServerPort
package var errorDescription: String? {
switch self {
@@ -19,6 +20,8 @@ package enum LanguageToolingSessionError: LocalizedError, Equatable, Sendable {
return message
case .capabilityUnavailable(let provider, let capability):
return "The \(provider) provider does not support \(capability)."
+ case .invalidJavaDebugServerPort:
+ return "The Java Debug Server returned an invalid TCP port."
}
}
}
@@ -26,7 +29,9 @@ package enum LanguageToolingSessionError: LocalizedError, Equatable, Sendable {
/// UI-facing façade that routes language features across active LSP sessions
/// and lightweight local providers without exposing either implementation.
@MainActor
-package final class LanguageToolingSessionManager: ObservableObject {
+package final class LanguageToolingSessionManager: ObservableObject,
+ JavaTestDebugLaunchTargetResolving
+{
@Published package private(set) var diagnostics: [URL: [LanguageServerDiagnostic]] = [:]
@Published package private(set) var languageServerFeatures: [String: LanguageServerFeatureSet] = [:]
@Published package private(set) var languageServerLogs: [LanguageServerLogEntry] = []
@@ -51,6 +56,7 @@ package final class LanguageToolingSessionManager: ObservableObject {
private var diagnosticsByProviderID: [String: [URL: [LanguageServerDiagnostic]]] = [:]
private var languageFeatureProviders: [any LanguageFeatureProvider]
private var languageServerFeatureProviders: [String: LanguageServerFeatureProvider] = [:]
+ private var languageServerReadyWaiters: [UUID: LanguageServerReadyWaiter] = [:]
private let workspaceFingerprintProvider: (LanguageProviderDescriptor, URL) throws -> String?
private let workspaceStateResetter: ((LanguageProviderDescriptor, URL, String?) throws -> Void)?
private let workspaceStateCleaner: ((LanguageProviderDescriptor, URL, String?) throws -> Int)?
@@ -273,6 +279,454 @@ package final class LanguageToolingSessionManager: ObservableObject {
return languageServerOperationIDs[providerID] ?? operationID
}
+ /// Starts or reuses JDT LS, then asks its bundled Java Debug extension for
+ /// the loopback DAP port. The caller remains responsible for the socket.
+ package func startJavaDebugServer(rootURL: URL) async throws -> UInt16 {
+ let normalizedRoot = rootURL.standardizedFileURL
+ _ = try startLanguageServer(providerID: "java", rootURL: normalizedRoot)
+ try await waitUntilLanguageServerReady(providerID: "java", rootURL: normalizedRoot)
+ let value = try await executeJavaCommand(
+ "vscode.java.startDebugSession",
+ arguments: [],
+ rootURL: normalizedRoot
+ )
+ let portValue: Int?
+ switch value {
+ case .integer(let value): portValue = value
+ case .string(let value): portValue = Int(value)
+ default: portValue = nil
+ }
+ guard let portValue, (1...Int(UInt16.max)).contains(portValue) else {
+ throw LanguageToolingSessionError.invalidJavaDebugServerPort
+ }
+ return UInt16(portValue)
+ }
+
+ /// Resolves the current Java source through JDT LS project metadata instead
+ /// of deriving package or module names from its filesystem path.
+ package func resolveJavaDebugLaunchTarget(
+ fileURL: URL,
+ rootURL: URL
+ ) async throws -> JavaDebugLaunchTarget {
+ let normalizedRoot = rootURL.standardizedFileURL
+ let resolvedFile = fileURL.standardizedFileURL.resolvingSymlinksInPath()
+ _ = try startLanguageServer(providerID: "java", rootURL: normalizedRoot)
+ try await waitUntilLanguageServerReady(providerID: "java", rootURL: normalizedRoot)
+ let value = try await executeJavaCommand(
+ "vscode.java.resolveMainClass",
+ arguments: [],
+ rootURL: normalizedRoot
+ )
+ guard case .array(let values) = value else {
+ throw LanguageToolingSessionError.toolingUnavailable(
+ "The Java language service returned an invalid main-class list."
+ )
+ }
+ let targets = values.compactMap(Self.javaDebugLaunchTarget)
+ let exactMatches = targets.filter { target in
+ guard let filePath = target.filePath else { return false }
+ return URL(fileURLWithPath: filePath)
+ .standardizedFileURL
+ .resolvingSymlinksInPath() == resolvedFile
+ }
+ let selected: JavaDebugLaunchTarget
+ if exactMatches.count == 1 {
+ selected = exactMatches[0].target
+ } else if targets.count == 1, targets[0].filePath == nil {
+ // Older JDT LS builds may omit filePath when the workspace has a
+ // single launch target. If a path is present, do not silently use
+ // another class for the current editor file: that turns a
+ // Spring-dependent source into an invalid bare-java launch.
+ selected = targets[0].target
+ } else {
+ let message = exactMatches.isEmpty
+ ? "No Java main method was found in \(resolvedFile.lastPathComponent)."
+ : "More than one Java main method was found in \(resolvedFile.lastPathComponent)."
+ throw LanguageToolingSessionError.toolingUnavailable(message)
+ }
+ let classpathValue = try await executeJavaCommand(
+ "vscode.java.resolveClasspath",
+ arguments: [
+ .string(selected.mainClass),
+ .string(selected.projectName ?? ""),
+ .string("runtime"),
+ ],
+ rootURL: normalizedRoot
+ )
+ guard case .array(let pathGroups) = classpathValue,
+ pathGroups.count == 2 else {
+ throw LanguageToolingSessionError.toolingUnavailable(
+ "The Java language service returned an invalid runtime classpath."
+ )
+ }
+ let modulePaths = Self.stringValues(pathGroups[0])
+ let classPaths = Self.stringValues(pathGroups[1])
+ guard !modulePaths.isEmpty || !classPaths.isEmpty else {
+ throw LanguageToolingSessionError.toolingUnavailable(
+ "The Java language service could not resolve the runtime classpath."
+ )
+ }
+ return JavaDebugLaunchTarget(
+ mainClass: selected.mainClass,
+ projectName: selected.projectName,
+ modulePaths: modulePaths,
+ classPaths: classPaths
+ )
+ }
+
+ /// Resolves one Java source file or discovered test item through the Java
+ /// Test extension and returns the metadata required by shared Debug Core.
+ package func resolveJavaTestDebugLaunchTarget(
+ fileURL: URL,
+ testIdentifier: String? = nil,
+ rootURL: URL
+ ) async throws -> JavaTestDebugLaunchTarget {
+ let normalizedFile = fileURL.standardizedFileURL
+ let normalizedRoot = rootURL.standardizedFileURL
+ let discovered = try await resolvedJavaTestItems(
+ fileURL: normalizedFile,
+ rootURL: normalizedRoot
+ )
+ let selected: [ResolvedJavaTestItem]
+ if let testIdentifier, !testIdentifier.isEmpty {
+ selected = Self.flattenJavaTestItems(discovered).filter {
+ $0.matches(identifier: testIdentifier)
+ }
+ } else {
+ selected = discovered.filter { $0.level == 5 }
+ }
+ guard !selected.isEmpty else {
+ let target = testIdentifier ?? normalizedFile.lastPathComponent
+ throw LanguageToolingSessionError.toolingUnavailable(
+ "No Java test was found for \(target)."
+ )
+ }
+ guard Set(selected.map(\.projectName)).count == 1,
+ Set(selected.map(\.kind)).count == 1,
+ Set(selected.map(\.level)).count == 1,
+ let projectName = selected.first?.projectName,
+ let kind = selected.first?.kind,
+ let level = selected.first?.level else {
+ throw LanguageToolingSessionError.toolingUnavailable(
+ "Debug one Java test framework and project at a time."
+ )
+ }
+ let framework: JavaTestDebugFramework
+ switch kind {
+ case 0, 1: framework = .junit
+ case 2: framework = .testng
+ default:
+ throw LanguageToolingSessionError.toolingUnavailable(
+ "The selected Java test framework is not supported."
+ )
+ }
+ let launchTestNames: [String]
+ if framework == .junit, level == 6 {
+ launchTestNames = selected.compactMap(\.jdtHandler)
+ } else {
+ launchTestNames = selected.map(\.fullName)
+ }
+ guard launchTestNames.count == selected.count else {
+ throw LanguageToolingSessionError.toolingUnavailable(
+ "The Java language service returned incomplete test identifiers."
+ )
+ }
+ let launchRequest = ToolingJSONValue.object([
+ "projectName": .string(projectName),
+ "testLevel": .integer(level),
+ "testKind": .integer(kind),
+ "testNames": .array(launchTestNames.map(ToolingJSONValue.string)),
+ ])
+ let requestData = try JSONSerialization.data(
+ withJSONObject: launchRequest.foundationObject,
+ options: [.sortedKeys]
+ )
+ guard let requestJSON = String(data: requestData, encoding: .utf8) else {
+ throw LanguageToolingSessionError.toolingUnavailable(
+ "Could not encode the Java test launch request."
+ )
+ }
+ let launchValue = try await executeJavaTestCommand(
+ "vscode.java.test.junit.argument",
+ arguments: [.string(requestJSON)],
+ rootURL: normalizedRoot
+ )
+ let launch = try Self.javaTestLaunchArguments(launchValue)
+ let testNGTestNames = framework == .testng
+ ? selected.flatMap(Self.javaTestNGMethodNames)
+ : []
+ let testNGRunnerPath: String?
+ let mainClass: String
+ if framework == .testng {
+ guard let runnerURL = languageServers["java"]?.javaTestRunnerURL else {
+ throw LanguageToolingSessionError.toolingUnavailable(
+ "The packaged Java TestNG runner is unavailable. Reinstall Lithe."
+ )
+ }
+ guard !testNGTestNames.isEmpty else {
+ throw LanguageToolingSessionError.toolingUnavailable(
+ "No TestNG test method was found in \(normalizedFile.lastPathComponent)."
+ )
+ }
+ testNGRunnerPath = runnerURL.standardizedFileURL.path
+ mainClass = "com.microsoft.java.test.runner.Launcher"
+ } else {
+ guard let resolvedMainClass = launch.mainClass else {
+ throw LanguageToolingSessionError.toolingUnavailable(
+ "The Java language service returned no JUnit runner main class."
+ )
+ }
+ testNGRunnerPath = nil
+ mainClass = resolvedMainClass
+ }
+ return JavaTestDebugLaunchTarget(
+ fileURL: normalizedFile,
+ name: selected.count == 1 ? selected[0].label : normalizedFile.lastPathComponent,
+ framework: framework,
+ workingDirectory: launch.workingDirectory,
+ mainClass: mainClass,
+ projectName: launch.projectName,
+ classPaths: launch.classPaths,
+ modulePaths: launch.modulePaths,
+ vmArguments: launch.vmArguments,
+ programArguments: launch.programArguments,
+ testNGRunnerPath: testNGRunnerPath,
+ testNGTestNames: testNGTestNames
+ )
+ }
+
+ /// Discovers the Java test classes and methods in one source file for the
+ /// native Tests tree. This reuses the same identifiers accepted by Debug.
+ package func discoverJavaTestItems(
+ fileURL: URL,
+ rootURL: URL
+ ) async throws -> [LanguageTestItem] {
+ let normalizedFile = fileURL.standardizedFileURL
+ let discovered = try await resolvedJavaTestItems(
+ fileURL: normalizedFile,
+ rootURL: rootURL.standardizedFileURL
+ )
+ return Self.projectJavaTestItems(
+ discovered,
+ fileURL: normalizedFile,
+ depth: 1
+ )
+ }
+
+ private func executeJavaCommand(
+ _ commandID: String,
+ arguments: [ToolingJSONValue],
+ rootURL: URL
+ ) async throws -> ToolingJSONValue {
+ let command = LanguageServerCommand(
+ title: commandID,
+ command: commandID,
+ arguments: arguments
+ )
+ return try await withCheckedThrowingContinuation { continuation in
+ do {
+ try executeReturningValue(
+ command,
+ fileURL: rootURL.appendingPathComponent("Main.java"),
+ rootURL: rootURL
+ ) { result in
+ continuation.resume(with: result)
+ }
+ } catch {
+ continuation.resume(throwing: error)
+ }
+ }
+ }
+
+ private func executeJavaTestCommand(
+ _ commandID: String,
+ arguments: [ToolingJSONValue],
+ rootURL: URL
+ ) async throws -> ToolingJSONValue {
+ try await executeJavaCommand(commandID, arguments: arguments, rootURL: rootURL)
+ }
+
+ private func resolvedJavaTestItems(
+ fileURL: URL,
+ rootURL: URL
+ ) async throws -> [ResolvedJavaTestItem] {
+ _ = try startLanguageServer(providerID: "java", rootURL: rootURL)
+ try await waitUntilLanguageServerReady(providerID: "java", rootURL: rootURL)
+ let discoveredValue = try await executeJavaTestCommand(
+ "vscode.java.test.findTestTypesAndMethods",
+ arguments: [.string(fileURL.absoluteString)],
+ rootURL: rootURL
+ )
+ guard case .array(let values) = discoveredValue else {
+ throw LanguageToolingSessionError.toolingUnavailable(
+ "The Java language service returned invalid test metadata."
+ )
+ }
+ return values.compactMap(Self.javaTestItem)
+ }
+
+ private static func javaDebugLaunchTarget(
+ _ value: ToolingJSONValue
+ ) -> ResolvedJavaDebugLaunchTarget? {
+ guard case .object(let object) = value,
+ case .string(let mainClass)? = object["mainClass"],
+ mainClass.isEmpty == false else { return nil }
+ let projectName: String?
+ if case .string(let value)? = object["projectName"], value.isEmpty == false {
+ projectName = value
+ } else {
+ projectName = nil
+ }
+ let filePath: String?
+ if case .string(let value)? = object["filePath"], value.isEmpty == false {
+ filePath = value
+ } else {
+ filePath = nil
+ }
+ return ResolvedJavaDebugLaunchTarget(
+ target: JavaDebugLaunchTarget(mainClass: mainClass, projectName: projectName),
+ filePath: filePath
+ )
+ }
+
+ private static func stringValues(_ value: ToolingJSONValue) -> [String] {
+ guard case .array(let values) = value else { return [] }
+ return values.compactMap { value in
+ guard case .string(let value) = value, !value.isEmpty else { return nil }
+ return value
+ }
+ }
+
+ private static func javaTestItem(_ value: ToolingJSONValue) -> ResolvedJavaTestItem? {
+ guard case .object(let object) = value,
+ case .string(let id)? = object["id"],
+ case .string(let label)? = object["label"],
+ case .string(let fullName)? = object["fullName"],
+ case .string(let projectName)? = object["projectName"],
+ let kind = integerValue(object["testKind"]),
+ let level = integerValue(object["testLevel"]) else { return nil }
+ let jdtHandler: String?
+ if case .string(let value)? = object["jdtHandler"], !value.isEmpty {
+ jdtHandler = value
+ } else {
+ jdtHandler = nil
+ }
+ let children: [ResolvedJavaTestItem]
+ if case .array(let values)? = object["children"] {
+ children = values.compactMap(javaTestItem)
+ } else {
+ children = []
+ }
+ let sortText: String?
+ if case .string(let value)? = object["sortText"], !value.isEmpty {
+ sortText = value
+ } else {
+ sortText = nil
+ }
+ return ResolvedJavaTestItem(
+ id: id,
+ label: label,
+ fullName: fullName,
+ projectName: projectName,
+ kind: kind,
+ level: level,
+ jdtHandler: jdtHandler,
+ sortText: sortText,
+ children: children
+ )
+ }
+
+ private static func integerValue(_ value: ToolingJSONValue?) -> Int? {
+ switch value {
+ case .integer(let value): value
+ case .string(let value): Int(value)
+ default: nil
+ }
+ }
+
+ private static func flattenJavaTestItems(
+ _ items: [ResolvedJavaTestItem]
+ ) -> [ResolvedJavaTestItem] {
+ items.flatMap { [$0] + flattenJavaTestItems($0.children) }
+ }
+
+ private static func projectJavaTestItems(
+ _ items: [ResolvedJavaTestItem],
+ fileURL: URL,
+ depth: Int
+ ) -> [LanguageTestItem] {
+ sortedJavaTestItems(items).flatMap { item in
+ [LanguageTestItem(
+ id: item.id,
+ providerID: "java",
+ label: item.label,
+ kind: .testCase,
+ fileURL: fileURL,
+ testIdentifier: item.fullName,
+ depth: depth
+ )] + projectJavaTestItems(
+ item.children,
+ fileURL: fileURL,
+ depth: depth + 1
+ )
+ }
+ }
+
+ private static func sortedJavaTestItems(
+ _ items: [ResolvedJavaTestItem]
+ ) -> [ResolvedJavaTestItem] {
+ items.sorted {
+ ($0.sortText ?? $0.label, $0.label, $0.id)
+ < ($1.sortText ?? $1.label, $1.label, $1.id)
+ }
+ }
+
+ private static func javaTestNGMethodNames(_ item: ResolvedJavaTestItem) -> [String] {
+ if item.level == 6 { return [item.fullName] }
+ return item.children.flatMap(javaTestNGMethodNames)
+ }
+
+ private static func javaTestLaunchArguments(
+ _ value: ToolingJSONValue
+ ) throws -> ResolvedJavaTestLaunchArguments {
+ guard case .object(let response) = value else {
+ throw LanguageToolingSessionError.toolingUnavailable(
+ "The Java language service returned invalid test launch arguments."
+ )
+ }
+ if case .string(let message)? = response["errorMessage"], !message.isEmpty {
+ throw LanguageToolingSessionError.toolingUnavailable(message)
+ }
+ guard case .object(let body)? = response["body"],
+ case .string(let workingDirectory)? = body["workingDirectory"],
+ !workingDirectory.isEmpty else {
+ throw LanguageToolingSessionError.toolingUnavailable(
+ "The Java language service returned incomplete test launch arguments."
+ )
+ }
+ let mainClass: String?
+ if case .string(let value)? = body["mainClass"], !value.isEmpty {
+ mainClass = value
+ } else {
+ mainClass = nil
+ }
+ let projectName: String?
+ if case .string(let value)? = body["projectName"], !value.isEmpty {
+ projectName = value
+ } else {
+ projectName = nil
+ }
+ return ResolvedJavaTestLaunchArguments(
+ workingDirectory: workingDirectory,
+ mainClass: mainClass,
+ projectName: projectName,
+ classPaths: stringValues(body["classpath"] ?? .array([])),
+ modulePaths: stringValues(body["modulepath"] ?? .array([])),
+ vmArguments: stringValues(body["vmArguments"] ?? .array([])),
+ programArguments: stringValues(body["programArguments"] ?? .array([]))
+ )
+ }
+
package func notifyWorkspaceFilesChanged(
providerID: String,
changes: [LanguageServerWorkspaceFileChange]
@@ -367,6 +821,11 @@ package final class LanguageToolingSessionManager: ObservableObject {
languageServerInfos[providerID] = nil
languageServerFeatureProviders[providerID] = nil
languageServerStates[providerID] = .stopped
+ resumeLanguageServerReadyWaiters(
+ providerID: providerID,
+ state: .stopped,
+ rootURL: nil
+ )
onLanguageServerStateChange?(
providerID,
.stopped,
@@ -586,6 +1045,25 @@ package final class LanguageToolingSessionManager: ObservableObject {
throw unavailableLanguageServerError(for: fileURL)
}
+ package func executeReturningValue(
+ _ command: LanguageServerCommand,
+ fileURL: URL,
+ rootURL _: URL,
+ completion: @escaping (Result) -> Void
+ ) throws {
+ guard command.command.isEmpty == false else {
+ throw LanguageToolingSessionError.capabilityUnavailable(
+ provider: catalog.provider(for: fileURL)?.displayName ?? fileURL.pathExtension,
+ capability: "execute command"
+ )
+ }
+ if let session = readyLanguageServerSession(for: fileURL) {
+ try session.executeReturningValue(command, fileURL: fileURL, completion: completion)
+ return
+ }
+ throw unavailableLanguageServerError(for: fileURL)
+ }
+
package func resolveVirtualDocument(
providerID: String,
uri: URL,
@@ -1140,6 +1618,11 @@ package final class LanguageToolingSessionManager: ObservableObject {
) {
guard languageServerSessionIdentities[providerID] == sessionIdentity else { return }
languageServerStates[providerID] = state
+ resumeLanguageServerReadyWaiters(
+ providerID: providerID,
+ state: state,
+ rootURL: languageServerRoots[providerID]
+ )
switch state {
case .stopped, .failed:
clearLanguageServerSession(
@@ -1177,6 +1660,109 @@ package final class LanguageToolingSessionManager: ObservableObject {
)
}
+ private func waitUntilLanguageServerReady(
+ providerID: String,
+ rootURL: URL
+ ) async throws {
+ if languageServerStates[providerID] == .ready,
+ languageServerRoots[providerID] == rootURL {
+ return
+ }
+ let waiterID = UUID()
+ try await withTaskCancellationHandler {
+ try await withCheckedThrowingContinuation {
+ (continuation: CheckedContinuation) in
+ if Task.isCancelled {
+ continuation.resume(throwing: CancellationError())
+ return
+ }
+ languageServerReadyWaiters[waiterID] = LanguageServerReadyWaiter(
+ providerID: providerID,
+ rootURL: rootURL,
+ continuation: continuation
+ )
+ }
+ } onCancel: {
+ Task { @MainActor [weak self] in
+ self?.cancelLanguageServerReadyWaiter(waiterID)
+ }
+ }
+ }
+
+ private func cancelLanguageServerReadyWaiter(_ waiterID: UUID) {
+ languageServerReadyWaiters.removeValue(forKey: waiterID)?
+ .continuation.resume(throwing: CancellationError())
+ }
+
+ private func resumeLanguageServerReadyWaiters(
+ providerID: String,
+ state: LanguageServerSessionState,
+ rootURL: URL?
+ ) {
+ let matching = languageServerReadyWaiters.filter { _, waiter in
+ waiter.providerID == providerID
+ }
+ for (waiterID, waiter) in matching {
+ switch state {
+ case .ready where rootURL == waiter.rootURL:
+ languageServerReadyWaiters.removeValue(forKey: waiterID)?
+ .continuation.resume()
+ case .failed(let failure):
+ languageServerReadyWaiters.removeValue(forKey: waiterID)?
+ .continuation.resume(throwing: LanguageToolingSessionError.toolingUnavailable(
+ failure.message ?? "The \(providerID) language server failed."
+ ))
+ case .stopped:
+ languageServerReadyWaiters.removeValue(forKey: waiterID)?
+ .continuation.resume(throwing: LanguageToolingSessionError.toolingUnavailable(
+ "The \(providerID) language server stopped before becoming ready."
+ ))
+ default:
+ break
+ }
+ }
+ }
+
+ private struct LanguageServerReadyWaiter {
+ let providerID: String
+ let rootURL: URL
+ let continuation: CheckedContinuation
+ }
+
+ private struct ResolvedJavaDebugLaunchTarget {
+ let target: JavaDebugLaunchTarget
+ let filePath: String?
+ }
+
+ private struct ResolvedJavaTestItem {
+ let id: String
+ let label: String
+ let fullName: String
+ let projectName: String
+ let kind: Int
+ let level: Int
+ let jdtHandler: String?
+ let sortText: String?
+ let children: [ResolvedJavaTestItem]
+
+ func matches(identifier: String) -> Bool {
+ id == identifier
+ || label == identifier
+ || fullName == identifier
+ || jdtHandler == identifier
+ }
+ }
+
+ private struct ResolvedJavaTestLaunchArguments {
+ let workingDirectory: String
+ let mainClass: String?
+ let projectName: String?
+ let classPaths: [String]
+ let modulePaths: [String]
+ let vmArguments: [String]
+ let programArguments: [String]
+ }
+
private func replaceDiagnostics(
_ updatedDiagnostics: [LanguageServerDiagnostic],
for fileURL: URL,
diff --git a/macos/Sources/LitheTerminalModule/Application/TerminalFeatureModel.swift b/macos/Sources/LitheTerminalModule/Application/TerminalFeatureModel.swift
index d1ba8ef6e..b6e0f190f 100644
--- a/macos/Sources/LitheTerminalModule/Application/TerminalFeatureModel.swift
+++ b/macos/Sources/LitheTerminalModule/Application/TerminalFeatureModel.swift
@@ -40,6 +40,19 @@ public final class TerminalFeatureModel: ObservableObject {
return session
}
+ @discardableResult
+ public func createProcessSession(
+ _ launch: TerminalProcessLaunch,
+ onOutput: ((String) -> Void)? = nil
+ ) throws -> (session: TerminalSession, processID: Int32) {
+ let session = TerminalSession(transport: terminalFactory())
+ session.onOutput = onOutput
+ let processID = try session.startProcess(launch)
+ terminalSessions.append(session)
+ activeTerminalSessionID = session.id
+ return (session, processID)
+ }
+
@discardableResult
public func selectSession(_ session: TerminalSession) -> Bool {
guard terminalSessions.contains(where: { $0.id == session.id }) else { return false }
@@ -61,6 +74,18 @@ public final class TerminalFeatureModel: ObservableObject {
public func restartActiveSession() { activeTerminalSession?.restart() }
public func restartActiveSession(using shellPath: String) { activeTerminalSession?.restart(using: shellPath) }
+ /// Sends UTF-8 input to a specific terminal session when its PTY is live.
+ /// The session ID keeps callers from accidentally writing to whichever
+ /// terminal happens to be selected in the UI.
+ @discardableResult
+ public func sendInput(_ input: String, to sessionID: UUID) -> Bool {
+ guard let session = terminalSessions.first(where: { $0.id == sessionID }),
+ session.isRunning,
+ session.isReady else { return false }
+ session.sendInput(input)
+ return true
+ }
+
public func stopAllSessions() {
terminalSessions.forEach { $0.stop() }
terminalSessions.removeAll()
diff --git a/macos/Sources/LitheTerminalModule/Ports/TerminalTransport.swift b/macos/Sources/LitheTerminalModule/Ports/TerminalTransport.swift
index 662776f90..0170a2031 100644
--- a/macos/Sources/LitheTerminalModule/Ports/TerminalTransport.swift
+++ b/macos/Sources/LitheTerminalModule/Ports/TerminalTransport.swift
@@ -1,12 +1,50 @@
import Foundation
+public struct TerminalEnvironmentChange: Equatable, Sendable {
+ public let name: String
+ public let value: String?
+
+ public init(name: String, value: String?) {
+ self.name = name
+ self.value = value
+ }
+}
+
+/// Direct process launch for a PTY-backed terminal. Arguments stay separated
+/// so callers never need to build a shell command string.
+public struct TerminalProcessLaunch: Equatable, Sendable {
+ public let title: String?
+ public let executablePath: String
+ public let arguments: [String]
+ public let workingDirectory: String
+ public let environmentChanges: [TerminalEnvironmentChange]
+
+ public init(
+ title: String?,
+ executablePath: String,
+ arguments: [String],
+ workingDirectory: String,
+ environmentChanges: [TerminalEnvironmentChange] = []
+ ) {
+ self.title = title
+ self.executablePath = executablePath
+ self.arguments = arguments
+ self.workingDirectory = workingDirectory
+ self.environmentChanges = environmentChanges
+ }
+}
+
/// Platform terminal runtime injected by the native composition root.
@MainActor
public protocol TerminalTransport: AnyObject {
var isRunning: Bool { get }
+ var processID: Int32? { get }
var shellName: String { get }
var nativeView: AnyObject { get }
var onTermination: ((Int32?) -> Void)? { get set }
+ /// Raw bytes received from the child process before terminal emulation.
+ /// Hosts may mirror this output into another product surface.
+ var onOutput: ((Data) -> Void)? { get set }
var onTitle: ((String) -> Void)? { get set }
var onDirectoryUpdate: ((String?) -> Void)? { get set }
var onLink: ((String, [String: String]) -> Void)? { get set }
@@ -14,6 +52,7 @@ public protocol TerminalTransport: AnyObject {
func defaultShellPath() -> String
func defaultEnvironment() -> [String: String]
func start(workingDirectory: String, shellPath: String, environment: [String: String]) throws
+ func startProcess(_ launch: TerminalProcessLaunch, environment: [String: String]) throws -> Int32
func send(_ input: Data) throws
func interrupt() throws
func focus()
diff --git a/macos/Sources/LitheTerminalModule/Runtime/TerminalSession.swift b/macos/Sources/LitheTerminalModule/Runtime/TerminalSession.swift
index d0f486d46..1e68bf723 100644
--- a/macos/Sources/LitheTerminalModule/Runtime/TerminalSession.swift
+++ b/macos/Sources/LitheTerminalModule/Runtime/TerminalSession.swift
@@ -6,6 +6,7 @@ public final class TerminalSession: ObservableObject, Identifiable {
public let id = UUID()
@Published public private(set) var isRunning = false
@Published public private(set) var isReady = false
+ @Published public private(set) var isManagedProcess = false
@Published public private(set) var shellName = "Shell"
@Published public private(set) var processTitle: String?
@Published public private(set) var currentDirectory: URL?
@@ -13,6 +14,9 @@ public final class TerminalSession: ObservableObject, Identifiable {
@Published public private(set) var startedAt: Date?
@Published public private(set) var endedAt: Date?
public var onLink: ((String, [String: String]) -> Void)?
+ /// Receives decoded child-process output without taking ownership of the
+ /// terminal surface.
+ public var onOutput: ((String) -> Void)?
private let transport: any TerminalTransport
private var workspaceURL: URL?
@@ -24,6 +28,10 @@ public final class TerminalSession: ObservableObject, Identifiable {
guard let self else { return }
isRunning = false; isReady = false; lastExitCode = exitCode; endedAt = Date()
}
+ transport.onOutput = { [weak self] data in
+ guard let self, !data.isEmpty else { return }
+ self.onOutput?(String(decoding: data, as: UTF8.self))
+ }
transport.onTitle = { [weak self] title in
let value = title.trimmingCharacters(in: .whitespacesAndNewlines)
self?.processTitle = value.isEmpty ? nil : value
@@ -48,15 +56,13 @@ public final class TerminalSession: ObservableObject, Identifiable {
public func start(in workspaceURL: URL, shellPath: String? = nil) {
stop()
self.workspaceURL = workspaceURL
+ isManagedProcess = false
currentDirectory = workspaceURL.standardizedFileURL
processTitle = nil; lastExitCode = nil; startedAt = Date(); endedAt = nil
let shell = shellPath ?? selectedShellPath ?? transport.defaultShellPath()
selectedShellPath = shell
shellName = URL(fileURLWithPath: shell).lastPathComponent
- var environment = transport.defaultEnvironment()
- environment["TERM"] = "xterm-256color"
- environment["COLORTERM"] = "truecolor"
- environment["TERM_PROGRAM"] = "Lithe"
+ let environment = terminalEnvironment()
do {
try transport.start(workingDirectory: workspaceURL.path, shellPath: shell, environment: environment)
isRunning = transport.isRunning; isReady = isRunning
@@ -65,8 +71,49 @@ public final class TerminalSession: ObservableObject, Identifiable {
}
}
- public func restart() { if let workspaceURL { start(in: workspaceURL, shellPath: selectedShellPath) } }
- public func restart(using shellPath: String) { if let workspaceURL { start(in: workspaceURL, shellPath: shellPath) } }
+ @discardableResult
+ public func startProcess(_ launch: TerminalProcessLaunch) throws -> Int32 {
+ stop()
+ let workingDirectory = URL(
+ fileURLWithPath: launch.workingDirectory,
+ isDirectory: true
+ ).standardizedFileURL
+ workspaceURL = workingDirectory
+ selectedShellPath = nil
+ isManagedProcess = true
+ currentDirectory = workingDirectory
+ processTitle = launch.title?.trimmingCharacters(in: .whitespacesAndNewlines)
+ if processTitle?.isEmpty == true { processTitle = nil }
+ lastExitCode = nil
+ startedAt = Date()
+ endedAt = nil
+ shellName = URL(fileURLWithPath: launch.executablePath).lastPathComponent
+
+ do {
+ let processID = try transport.startProcess(
+ launch,
+ environment: terminalEnvironment(applying: launch.environmentChanges)
+ )
+ isRunning = transport.isRunning
+ isReady = isRunning
+ return processID
+ } catch {
+ isRunning = false
+ isReady = false
+ startedAt = nil
+ endedAt = Date()
+ throw error
+ }
+ }
+
+ public func restart() {
+ guard !isManagedProcess, let workspaceURL else { return }
+ start(in: workspaceURL, shellPath: selectedShellPath)
+ }
+ public func restart(using shellPath: String) {
+ guard !isManagedProcess, let workspaceURL else { return }
+ start(in: workspaceURL, shellPath: shellPath)
+ }
public func send(_ command: String) { sendInput(command + "\n") }
public func sendInput(_ input: String) {
guard isRunning, isReady, let data = input.data(using: .utf8) else { return }
@@ -85,6 +132,23 @@ public final class TerminalSession: ObservableObject, Identifiable {
if let url = URL(string: rawValue), url.isFileURL { currentDirectory = url.standardizedFileURL }
else if rawValue.hasPrefix("/") { currentDirectory = URL(fileURLWithPath: rawValue).standardizedFileURL }
}
+
+ private func terminalEnvironment(
+ applying changes: [TerminalEnvironmentChange] = []
+ ) -> [String: String] {
+ var environment = transport.defaultEnvironment()
+ environment["TERM"] = "xterm-256color"
+ environment["COLORTERM"] = "truecolor"
+ environment["TERM_PROGRAM"] = "Lithe"
+ for change in changes {
+ if let value = change.value {
+ environment[change.name] = value
+ } else {
+ environment[change.name] = nil
+ }
+ }
+ return environment
+ }
}
private extension String { var nonEmpty: String? { isEmpty ? nil : self } }
diff --git a/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift b/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift
index 95783ec0a..5f67edbe3 100644
--- a/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift
+++ b/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift
@@ -11,6 +11,8 @@ package enum WorkspaceRebuildResult: Sendable {
/// Owns the workspace snapshot and delegates scanning and text reads to Core.
@MainActor
package final class WorkspaceFeatureModel: ObservableObject {
+ package private(set) var workspaceGeneration = 0
+ package private(set) var appliedSnapshot: WorkspaceSnapshot?
@Published package private(set) var rootNode: FileNode?
@Published package private(set) var projectFiles: [URL] = []
@Published package private(set) var isLoadingWorkspace = false
@@ -161,6 +163,7 @@ package final class WorkspaceFeatureModel: ObservableObject {
}
package func reset() {
+ workspaceGeneration &+= 1
if let workspaceURL {
scheduleSearchIndexInvalidation(at: workspaceURL, rules: visibilityRules)
}
@@ -184,6 +187,7 @@ package final class WorkspaceFeatureModel: ObservableObject {
hasRestoredWorkspaceSession = false
rootNode = nil
projectFiles = []
+ appliedSnapshot = nil
isLoadingWorkspace = false
isRefreshingWorkspace = false
loadErrorMessage = nil
@@ -202,6 +206,7 @@ package final class WorkspaceFeatureModel: ObservableObject {
}
package func beginWorkspace(at url: URL, visibilityRules: FileVisibilityRules) {
+ workspaceGeneration &+= 1
workspaceURL = url.standardizedFileURL
self.visibilityRules = visibilityRules
hasRestoredWorkspaceSession = false
@@ -294,6 +299,7 @@ package final class WorkspaceFeatureModel: ObservableObject {
loadErrorMessage = nil
rootNode = snapshot.root
projectFiles = snapshot.files
+ appliedSnapshot = snapshot
scheduleSearchIndexWarm(at: workspaceURL, rules: rules)
// The tree is usable as soon as the shared snapshot is ready. Service
@@ -308,10 +314,14 @@ package final class WorkspaceFeatureModel: ObservableObject {
if let restoreSession, let session = workspaceSessionStore.load(for: workspaceURL) {
await restoreSession(session, snapshot.files)
}
+ guard isCurrent() else { return .stale }
hasRestoredWorkspaceSession = true
}
+ guard isCurrent() else { return .stale }
await updateWatchConfiguration()
+ guard isCurrent() else { return .stale }
await onSnapshotLoaded?(snapshot, isInitialLoad)
+ guard isCurrent() else { return .stale }
await requestGitRefreshNow()
if pendingFullRescan || pendingWatchRootsChanged {
scheduleRecovery()
@@ -324,10 +334,13 @@ package final class WorkspaceFeatureModel: ObservableObject {
refreshTask?.cancel()
pendingExternalPaths.removeAll()
externalRefreshGeneration += 1
+ let generation = workspaceGeneration
_ = await rebuild(
at: workspaceURL,
rules: visibilityRules,
- isCurrent: { [weak self] in self?.workspaceURL == workspaceURL }
+ isCurrent: { [weak self] in
+ self?.workspaceURL == workspaceURL && self?.workspaceGeneration == generation
+ }
)
}
@@ -594,8 +607,10 @@ package final class WorkspaceFeatureModel: ObservableObject {
private func updateWatchConfiguration(forceRebuild: Bool = false) async {
guard let workspaceURL else { return }
+ let generation = workspaceGeneration
let context = await gitWatchContextProvider.watchContext(for: workspaceURL)
- guard self.workspaceURL == workspaceURL else { return }
+ guard self.workspaceURL == workspaceURL,
+ self.workspaceGeneration == generation else { return }
let configuration = DirectoryWatchConfiguration(
workspaceRoot: workspaceURL,
gitContext: context
diff --git a/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift b/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift
index 18ab259c4..ecde3db73 100644
--- a/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift
+++ b/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift
@@ -7,6 +7,2326 @@ import Testing
@MainActor
struct DebugModuleTests {
+ @Test
+ func debuggeeOutputIsMirroredIntoConsoleWithoutTerminalControlSequences() {
+ let manager = DebugAdapterSessionManager(providers: []) { _, _ in nil }
+ let feature = GenericDebugFeatureModel(sessions: manager)
+
+ feature.appendDebuggeeOutput("\u{001B}[31mready\u{001B}[0m\r\n")
+
+ #expect(feature.output == "ready\n")
+ }
+
+ @Test
+ func debuggeeOutputNormalizationPreservesStateAcrossOutputChunks() {
+ let manager = DebugAdapterSessionManager(providers: []) { _, _ in nil }
+ let feature = GenericDebugFeatureModel(sessions: manager)
+
+ feature.appendDebuggeeOutput("\u{001B}")
+ feature.appendDebuggeeOutput("[31mready\u{001B}[0m\r")
+ feature.appendDebuggeeOutput("\nnext\n")
+
+ #expect(feature.output == "ready\nnext\n")
+ }
+
+ @Test
+ func springPortConflictOutputProducesAnActionableDiagnostic() {
+ let manager = DebugAdapterSessionManager(providers: []) { _, _ in nil }
+ let feature = GenericDebugFeatureModel(sessions: manager)
+
+ feature.appendDebuggeeOutput("Web server failed to start. Port 8080 was already in use.\n")
+
+ #expect(feature.errorMessage == "Port 8080 is already in use. Stop the process using it or change server.port in the Run configuration.")
+ #expect(feature.output.contains("Port 8080 was already in use."))
+ }
+
+ @Test
+ func staleSessionCallbacksCannotOverwriteAReplacementSession() throws {
+ let descriptor = DebugProviderDescriptor(
+ id: "java",
+ displayName: "Java",
+ fileExtensions: ["java"]
+ )
+ var createdSessions: [DeferredInspectionDebugSession] = []
+ let manager = DebugAdapterSessionManager(providers: [descriptor]) { _, _ in
+ let session = DeferredInspectionDebugSession()
+ createdSessions.append(session)
+ return session
+ }
+ let root = URL(fileURLWithPath: "/tmp/java-debug-reconnect", isDirectory: true)
+ let source = root.appendingPathComponent("src/Main.java")
+
+ _ = try manager.activate(for: source, rootURL: root)
+ let first = try #require(createdSessions.first)
+ manager.stop(providerID: "java")
+
+ _ = try manager.activate(for: source, rootURL: root)
+ let second = try #require(createdSessions.dropFirst().first)
+ second.emit(.output(category: "stdout", output: "current\n"))
+ first.emit(.output(category: "stderr", output: "stale\n"))
+ first.fail()
+
+ #expect(manager.lastEvents["java"] == .output(category: "stdout", output: "current\n"))
+ #expect(manager.states["java"] == .ready)
+ manager.stopAll()
+ }
+
+ @Test
+ func independentSessionsShareBreakpointsButKeepStateAndCallbacksSeparate() throws {
+ let descriptor = DebugProviderDescriptor(
+ id: "java",
+ displayName: "Java",
+ fileExtensions: ["java"]
+ )
+ var createdSessions: [DeferredInspectionDebugSession] = []
+ let manager = DebugAdapterSessionManager(providers: [descriptor]) { _, _ in
+ let session = DeferredInspectionDebugSession()
+ createdSessions.append(session)
+ return session
+ }
+ var receivedEvents: [(DebugSessionID, String, DebugAdapterEvent)] = []
+ manager.onSessionEvent = { sessionID, providerID, event in
+ receivedEvents.append((sessionID, providerID, event))
+ }
+
+ let root = URL(fileURLWithPath: "/tmp/java-debug-multiple", isDirectory: true)
+ let source = root.appendingPathComponent("src/Main.java")
+ let first = try manager.activateNew(for: source, rootURL: root)
+ let second = try manager.activateNew(for: source, rootURL: root)
+ let firstSession = try #require(createdSessions.first)
+ let secondSession = try #require(createdSessions.dropFirst().first)
+
+ #expect(first.id != second.id)
+ #expect(manager.sessionSummaries.map(\.id) == [first.id, second.id])
+ #expect(manager.activeAdapterIDs == ["java"])
+ #expect(manager.session(providerID: "java") === secondSession)
+
+ try manager.setBreakpoints([
+ DebugSourceBreakpoint(line: 12, enabled: true)
+ ], in: source)
+ #expect(firstSession.breakpointUpdates.last?.first?.line == 12)
+ #expect(secondSession.breakpointUpdates.last?.first?.line == 12)
+
+ firstSession.emit(.output(category: "stdout", output: "old\n"))
+ secondSession.emit(.output(category: "stdout", output: "new\n"))
+ #expect(receivedEvents.map { $0.0 } == [first.id, second.id])
+ #expect(manager.lastEvents["java"] == .output(category: "stdout", output: "new\n"))
+
+ #expect(manager.select(sessionID: first.id))
+ firstSession.emit(.output(category: "stdout", output: "selected-first\n"))
+ #expect(manager.lastEvents["java"] == .output(category: "stdout", output: "selected-first\n"))
+
+ manager.stop(sessionID: second.id)
+ #expect(manager.session(providerID: "java") === firstSession)
+ #expect(manager.activeSessionIDs == [first.id])
+ #expect(manager.states["java"] == .ready)
+ #expect(manager.sessionSummaries.map(\.id) == [first.id])
+
+ manager.stop(sessionID: first.id)
+ #expect(manager.activeSessionIDs.isEmpty)
+ #expect(manager.activeAdapterIDs.isEmpty)
+ #expect(manager.states["java"] == .idle)
+ }
+
+ @Test
+ func sessionAwareRunInTerminalRequestsKeepTheirOwningSessionIdentity() throws {
+ let descriptor = DebugProviderDescriptor(
+ id: "java",
+ displayName: "Java",
+ fileExtensions: ["java"]
+ )
+ var createdSessions: [DeferredInspectionDebugSession] = []
+ let manager = DebugAdapterSessionManager(providers: [descriptor]) { _, _ in
+ let session = DeferredInspectionDebugSession()
+ createdSessions.append(session)
+ return session
+ }
+ var requests: [(DebugSessionID, DebugRunInTerminalRequest)] = []
+ manager.onSessionRunInTerminalRequest = { sessionID, request, completion in
+ requests.append((sessionID, request))
+ completion(.success(DebugRunInTerminalResponse(processID: 42)))
+ }
+ let root = URL(fileURLWithPath: "/tmp/java-debug-terminal-routing", isDirectory: true)
+ let source = root.appendingPathComponent("src/Main.java")
+ let first = try manager.activateNew(for: source, rootURL: root)
+ let second = try manager.activateNew(for: source, rootURL: root)
+ let request = DebugRunInTerminalRequest(
+ kind: .integrated,
+ title: "Second",
+ cwd: root.path,
+ args: ["/usr/bin/java", "example.Second"],
+ environment: [],
+ argsCanBeInterpretedByShell: false
+ )
+
+ createdSessions[0].emitRunInTerminalRequest(request)
+ createdSessions[1].emitRunInTerminalRequest(request)
+
+ #expect(requests.map(\.0) == [first.id, second.id])
+ manager.stopAll()
+ }
+
+ @Test
+ func consoleHistoryIsBoundedDeduplicatedAndScopedToTheSelectedSession() throws {
+ let descriptor = DebugProviderDescriptor(
+ id: "java",
+ displayName: "Java",
+ fileExtensions: ["java"]
+ )
+ var createdSessions: [DeferredInspectionDebugSession] = []
+ let manager = DebugAdapterSessionManager(providers: [descriptor]) { _, _ in
+ let session = DeferredInspectionDebugSession()
+ createdSessions.append(session)
+ return session
+ }
+ let feature = GenericDebugFeatureModel(sessions: manager)
+ let root = URL(fileURLWithPath: "/tmp/java-debug-console-history", isDirectory: true)
+ let source = root.appendingPathComponent("src/Main.java")
+ let configuration = DebugLaunchConfiguration(
+ name: "Main",
+ request: .launch,
+ arguments: ["mainClass": .string("example.Main")]
+ )
+
+ #expect(feature.start(fileURL: source, rootURL: root, configuration: configuration))
+ for index in 0..<105 {
+ feature.evaluate("value\(index)")
+ }
+ feature.evaluate("value104")
+
+ #expect(feature.consoleHistory.count == 100)
+ #expect(feature.consoleHistory.first == "value5")
+ #expect(feature.consoleHistory.last == "value104")
+ #expect(feature.previousConsoleExpression(current: "") == "value104")
+ #expect(feature.previousConsoleExpression(current: "value104") == "value103")
+ #expect(feature.nextConsoleExpression() == "value104")
+ #expect(feature.nextConsoleExpression() == "")
+
+ #expect(feature.startAdditional(
+ fileURL: source,
+ rootURL: root,
+ configuration: configuration
+ ))
+ #expect(feature.consoleHistory.isEmpty)
+ feature.stop()
+ }
+
+ @Test
+ func featureSwitchesSessionsWithoutMixingTheirConsoleState() throws {
+ let descriptor = DebugProviderDescriptor(
+ id: "java",
+ displayName: "Java",
+ fileExtensions: ["java"]
+ )
+ var createdSessions: [DeferredInspectionDebugSession] = []
+ let manager = DebugAdapterSessionManager(providers: [descriptor]) { _, _ in
+ let session = DeferredInspectionDebugSession()
+ createdSessions.append(session)
+ return session
+ }
+ let feature = GenericDebugFeatureModel(sessions: manager)
+ let root = URL(fileURLWithPath: "/tmp/java-debug-feature-sessions", isDirectory: true)
+ let source = root.appendingPathComponent("src/Main.java")
+ let firstConfiguration = DebugLaunchConfiguration(
+ name: "First",
+ request: .launch,
+ arguments: ["mainClass": .string("example.First")]
+ )
+ let secondConfiguration = DebugLaunchConfiguration(
+ name: "Second",
+ request: .launch,
+ arguments: ["mainClass": .string("example.Second")]
+ )
+
+ #expect(feature.start(
+ fileURL: source,
+ rootURL: root,
+ configuration: firstConfiguration
+ ))
+ let first = try #require(createdSessions.first)
+ let firstID = try #require(feature.activeSessionID)
+ first.emit(.output(category: "stdout", output: "first\n"))
+
+ #expect(feature.startAdditional(
+ fileURL: source,
+ rootURL: root,
+ configuration: secondConfiguration
+ ))
+ let second = try #require(createdSessions.dropFirst().first)
+ let secondID = try #require(feature.activeSessionID)
+ #expect(firstID != secondID)
+ second.emit(.output(category: "stdout", output: "second\n"))
+ first.emit(.output(category: "stdout", output: "first-late\n"))
+ #expect(feature.output == "second\n")
+
+ #expect(feature.selectSession(firstID))
+ #expect(feature.output == "first\nfirst-late\n")
+ #expect(feature.targetTitle == "First")
+ #expect(feature.activeSessionID == firstID)
+
+ #expect(feature.selectSession(secondID))
+ #expect(feature.output == "second\n")
+ #expect(feature.targetTitle == "Second")
+ #expect(feature.activeSessionID == secondID)
+
+ feature.stopSession(firstID)
+ #expect(feature.sessionSummaries.map(\.id) == [secondID])
+ feature.stop()
+ #expect(feature.sessionSummaries.isEmpty)
+ }
+
+ @Test
+ func additionalSessionLaunchFailureRestoresTheOriginalActiveSession() throws {
+ let descriptor = DebugProviderDescriptor(
+ id: "java",
+ displayName: "Java",
+ fileExtensions: ["java"]
+ )
+ var createdSessions: [DeferredInspectionDebugSession] = []
+ let manager = DebugAdapterSessionManager(providers: [descriptor]) { _, _ in
+ let session = DeferredInspectionDebugSession()
+ if createdSessions.count == 1 {
+ session.failNextLaunch = true
+ }
+ createdSessions.append(session)
+ return session
+ }
+ let feature = GenericDebugFeatureModel(sessions: manager)
+ let root = URL(fileURLWithPath: "/tmp/java-debug-additional-failure", isDirectory: true)
+ let source = root.appendingPathComponent("src/Main.java")
+ let firstConfiguration = DebugLaunchConfiguration(
+ name: "First",
+ request: .launch,
+ arguments: ["mainClass": .string("example.First")]
+ )
+ let secondConfiguration = DebugLaunchConfiguration(
+ name: "Second",
+ request: .launch,
+ arguments: ["mainClass": .string("example.Second")]
+ )
+
+ #expect(feature.start(fileURL: source, rootURL: root, configuration: firstConfiguration))
+ let first = try #require(createdSessions.first)
+ let firstID = try #require(feature.activeSessionID)
+ first.emit(.output(category: "stdout", output: "first\n"))
+
+ #expect(!feature.startAdditional(
+ fileURL: source,
+ rootURL: root,
+ configuration: secondConfiguration
+ ))
+ #expect(createdSessions.count == 2)
+ #expect(feature.activeSessionID == firstID)
+ #expect(feature.targetTitle == "First")
+ #expect(feature.output == "first\n")
+ #expect(feature.state == .paused)
+ #expect(feature.errorMessage == nil)
+ #expect(manager.activeSessionIDs == [firstID])
+
+ feature.stop()
+ }
+
+ @Test
+ func stoppingActiveSessionPromotesTheMostRecentRemainingSession() throws {
+ let descriptor = DebugProviderDescriptor(
+ id: "java",
+ displayName: "Java",
+ fileExtensions: ["java"]
+ )
+ var createdSessions: [DeferredInspectionDebugSession] = []
+ let manager = DebugAdapterSessionManager(providers: [descriptor]) { _, _ in
+ let session = DeferredInspectionDebugSession()
+ createdSessions.append(session)
+ return session
+ }
+ let feature = GenericDebugFeatureModel(sessions: manager)
+ let root = URL(fileURLWithPath: "/tmp/java-debug-stop-promotion", isDirectory: true)
+ let source = root.appendingPathComponent("src/Main.java")
+ let firstConfiguration = DebugLaunchConfiguration(
+ name: "First",
+ request: .launch,
+ arguments: ["mainClass": .string("example.First")]
+ )
+ let secondConfiguration = DebugLaunchConfiguration(
+ name: "Second",
+ request: .launch,
+ arguments: ["mainClass": .string("example.Second")]
+ )
+
+ #expect(feature.start(fileURL: source, rootURL: root, configuration: firstConfiguration))
+ let firstID = try #require(feature.activeSessionID)
+ let first = try #require(createdSessions.first)
+ first.emit(.output(category: "stdout", output: "first\n"))
+ #expect(feature.startAdditional(
+ fileURL: source,
+ rootURL: root,
+ configuration: secondConfiguration
+ ))
+ let secondID = try #require(feature.activeSessionID)
+ #expect(secondID != firstID)
+ let second = try #require(createdSessions.dropFirst().first)
+ second.emit(.output(category: "stdout", output: "second\n"))
+
+ feature.stop()
+
+ #expect(feature.activeSessionID == firstID)
+ #expect(feature.targetTitle == "First")
+ #expect(feature.output == "first\n")
+ #expect(feature.state == .paused)
+ #expect(manager.activeSessionIDs == [firstID])
+ #expect(second.isRunning == false)
+
+ feature.stop()
+ }
+
+ @Test
+ func coreProtocolSessionProjectsRustUpdatesThroughInjectedTransport() throws {
+ let transport = RecordingTransport()
+ let core = RecordingDebugProtocolCore()
+ let deadlines = RecordingDebugDeadlineScheduler()
+ let session = CoreDebugAdapterProtocolSession(
+ adapterID: "java",
+ transport: transport,
+ core: core,
+ sessionID: "java-session",
+ deadlineScheduler: deadlines
+ )
+
+ try session.start(rootURL: URL(fileURLWithPath: "/tmp/java-core", isDirectory: true))
+
+ #expect(session.state == .initializing)
+ #expect(transport.sentData == [Data("initialize-frame".utf8)])
+ core.enqueueReceive(state: "ready", events: [[
+ "sequence": 2,
+ "type": "stateChanged",
+ "state": "ready"
+ ], [
+ "sequence": 3,
+ "type": "capabilities",
+ "capabilities": [
+ "supportsConfigurationDone": true,
+ "supportsConditionalBreakpoints": true,
+ "supportsHitConditionalBreakpoints": true,
+ "supportsLogPoints": true,
+ "supportsFunctionBreakpoints": true,
+ "supportsDataBreakpoints": true,
+ "supportsExceptionOptions": true,
+ "supportsExceptionFilterOptions": true,
+ "supportsSetVariable": true,
+ "supportsCancelRequest": true,
+ "supportsSingleThreadExecutionRequests": true,
+ "supportsRestartRequest": true,
+ "supportsTerminateRequest": true,
+ "supportsStepBack": true,
+ "supportsExceptionInfoRequest": true,
+ "supportsStepInTargetsRequest": true,
+ "supportsGotoTargetsRequest": true,
+ "exceptionBreakpointFilters": [[
+ "filter": "caught",
+ "label": "Caught Exceptions",
+ "default": false,
+ "supportsCondition": true
+ ]]
+ ]
+ ]])
+ transport.emitData(Data("initialize-response".utf8))
+ #expect(session.state == .ready)
+ #expect(session.capabilities.negotiated)
+ #expect(session.capabilities.supportsConditionalBreakpoints)
+ #expect(session.capabilities.supportsFunctionBreakpoints)
+ #expect(session.capabilities.supportsDataBreakpoints)
+ #expect(session.capabilities.supportsExceptionInfoRequest)
+ #expect(session.capabilities.exceptionBreakpointFilters.first?.filter == "caught")
+
+ var dataInfoResult: Result?
+ session.requestDataBreakpointInfo(
+ name: "count",
+ variablesReference: 42,
+ frameID: 7
+ ) { dataInfoResult = $0 }
+ let dataOperationID = try #require(core.lastDataBreakpointInfoOperationID)
+ core.enqueueReceive(state: "paused", events: [[
+ "sequence": 4,
+ "type": "operationCompleted",
+ "operationId": dataOperationID,
+ "result": [
+ "kind": "dataBreakpointInfo",
+ "dataId": "field:count",
+ "description": "Main.count",
+ "accessTypes": ["read", "write"],
+ "canPersist": true
+ ]
+ ]])
+ transport.emitData(Data("data-info-response".utf8))
+ #expect(try dataInfoResult?.get() == DebugDataBreakpointInfo(
+ dataID: "field:count",
+ description: "Main.count",
+ accessTypes: ["read", "write"],
+ canPersist: true
+ ))
+
+ var setVariableResult: Result?
+ session.setVariable(
+ variablesReference: 42,
+ name: "count",
+ value: "7"
+ ) { setVariableResult = $0 }
+ let setVariableOperationID = try #require(core.lastSetVariableOperationID)
+ core.enqueueReceive(state: "paused", events: [[
+ "sequence": 5,
+ "type": "operationCompleted",
+ "operationId": setVariableOperationID,
+ "result": [
+ "kind": "setVariable",
+ "variable": [
+ "name": "count",
+ "value": "7",
+ "type": "int",
+ "variablesReference": 0
+ ]
+ ]
+ ]])
+ transport.emitData(Data("set-variable-response".utf8))
+ #expect(try setVariableResult?.get().value == "7")
+ #expect(try setVariableResult?.get().containerReference == 42)
+
+ var threadsResult: Result<[DebugThread], Error>?
+ session.requestThreads { threadsResult = $0 }
+ let operationID = try #require(core.lastInspectionOperationID)
+ core.enqueueReceive(state: "paused", events: [[
+ "sequence": 5,
+ "type": "operationCompleted",
+ "operationId": operationID,
+ "result": [
+ "kind": "threads",
+ "threads": [["id": 7, "name": "main"]]
+ ]
+ ]])
+ transport.emitData(Data("threads-response".utf8))
+
+ #expect(try threadsResult?.get() == [DebugThread(id: 7, name: "main")])
+
+ var exceptionInfoResult: Result?
+ session.requestExceptionInfo(threadID: 7) { exceptionInfoResult = $0 }
+ let exceptionOperationID = try #require(core.lastInspectionOperationID)
+ core.enqueueReceive(state: "paused", events: [[
+ "sequence": 6,
+ "type": "operationCompleted",
+ "operationId": exceptionOperationID,
+ "result": [
+ "kind": "exceptionInfo",
+ "exceptionInfo": [
+ "exceptionId": "java.lang.IllegalStateException",
+ "description": "java.lang.IllegalStateException: session expired",
+ "breakMode": "always",
+ "details": [
+ "message": "session expired",
+ "typeName": "IllegalStateException",
+ "fullTypeName": "java.lang.IllegalStateException",
+ "evaluateName": "exception",
+ "stackTrace": "at example.Main.run(Main.java:12)",
+ "innerExceptions": [[
+ "message": "token expired",
+ "typeName": "TokenExpiredException",
+ "fullTypeName": "example.TokenExpiredException",
+ "innerExceptions": []
+ ]]
+ ]
+ ]
+ ]
+ ]])
+ transport.emitData(Data("exception-info-response".utf8))
+ #expect(try exceptionInfoResult?.get() == DebugExceptionInfo(
+ exceptionID: "java.lang.IllegalStateException",
+ description: "java.lang.IllegalStateException: session expired",
+ breakMode: "always",
+ details: DebugExceptionDetails(
+ message: "session expired",
+ typeName: "IllegalStateException",
+ fullTypeName: "java.lang.IllegalStateException",
+ evaluateName: "exception",
+ stackTrace: "at example.Main.run(Main.java:12)",
+ innerExceptions: [DebugExceptionDetails(
+ message: "token expired",
+ typeName: "TokenExpiredException",
+ fullTypeName: "example.TokenExpiredException",
+ evaluateName: nil,
+ stackTrace: nil
+ )]
+ )
+ ))
+
+ var stepTargetsResult: Result<[DebugStepInTarget], Error>?
+ session.requestStepInTargets(frameID: 7) { stepTargetsResult = $0 }
+ let stepTargetsOperationID = try #require(core.lastInspectionOperationID)
+ core.enqueueReceive(state: "paused", events: [[
+ "sequence": 6,
+ "type": "operationCompleted",
+ "operationId": stepTargetsOperationID,
+ "result": [
+ "kind": "stepInTargets",
+ "targets": [["id": 21, "label": "service.load()", "line": 12]]
+ ]
+ ]])
+ transport.emitData(Data("step-targets-response".utf8))
+ #expect(try stepTargetsResult?.get().first?.label == "service.load()")
+
+ var gotoTargetsResult: Result<[DebugGotoTarget], Error>?
+ session.requestGotoTargets(
+ fileURL: URL(fileURLWithPath: "/tmp/Main.java"),
+ line: 20,
+ column: 5
+ ) { gotoTargetsResult = $0 }
+ let gotoTargetsOperationID = try #require(core.lastInspectionOperationID)
+ core.enqueueReceive(state: "paused", events: [[
+ "sequence": 7,
+ "type": "operationCompleted",
+ "operationId": gotoTargetsOperationID,
+ "result": [
+ "kind": "gotoTargets",
+ "targets": [["id": 31, "label": "Main.java:20", "line": 20]]
+ ]
+ ]])
+ transport.emitData(Data("goto-targets-response".utf8))
+ #expect(try gotoTargetsResult?.get().first?.line == 20)
+ session.execute(
+ .continueExecution,
+ threadID: 7,
+ targetID: nil,
+ singleThread: true
+ )
+ #expect(core.lastExecutionSingleThread == true)
+ #expect(core.lastExecutionThreadID == 7)
+
+ var timedOutResult: Result<[DebugThread], Error>?
+ session.requestThreads { timedOutResult = $0 }
+ deadlines.fireLast()
+ #expect(core.cancelledOperationReasons.last == "timedOut")
+ #expect(throws: (any Error).self) { try timedOutResult?.get() }
+ session.stop()
+ #expect(session.state == .idle)
+ #expect(core.destroyedSessionIDs == ["java-session"])
+ #expect(transport.stopCalls == 1)
+ }
+
+ @Test
+ func coreProtocolSessionLaunchesRunInTerminalAndReturnsProcessID() throws {
+ let transport = RecordingTransport()
+ let core = RecordingDebugProtocolCore()
+ let session = CoreDebugAdapterProtocolSession(
+ adapterID: "java",
+ transport: transport,
+ core: core,
+ sessionID: "java-run-in-terminal",
+ deadlineScheduler: RecordingDebugDeadlineScheduler()
+ )
+ var receivedRequest: DebugRunInTerminalRequest?
+ session.onRunInTerminalRequest = { request, completion in
+ receivedRequest = request
+ completion(.success(DebugRunInTerminalResponse(processID: 4242)))
+ }
+
+ try session.start(rootURL: URL(fileURLWithPath: "/tmp/java-run-in-terminal"))
+ defer { session.stop() }
+ #expect(core.lastSupportsRunInTerminalRequest == true)
+ core.enqueueReceive(sessionID: "java-run-in-terminal", state: "launching", events: [[
+ "sequence": 2,
+ "type": "runInTerminalRequested",
+ "requestId": "runInTerminal-44",
+ "request": [
+ "kind": "integrated",
+ "title": "Debug Main",
+ "cwd": "/tmp/java-run-in-terminal",
+ "args": ["/opt/jdk/bin/java", "example.Main"],
+ "environment": [["name": "JAVA_HOME", "value": "/opt/jdk"]],
+ "argsCanBeInterpretedByShell": false
+ ]
+ ]])
+
+ transport.emitData(Data("run-in-terminal-request".utf8))
+
+ #expect(receivedRequest?.args == ["/opt/jdk/bin/java", "example.Main"])
+ #expect(core.runInTerminalCompletions == [RecordingRunInTerminalCompletion(
+ requestID: "runInTerminal-44",
+ response: DebugRunInTerminalResponse(processID: 4242),
+ errorDescription: nil
+ )])
+ #expect(transport.sentData.contains(Data("run-in-terminal-response".utf8)))
+ }
+
+ @Test
+ func stoppingCoreProtocolSessionFailsPendingTerminalRequestAndIgnoresLateCompletion() throws {
+ let transport = RecordingTransport()
+ let core = RecordingDebugProtocolCore()
+ let session = CoreDebugAdapterProtocolSession(
+ adapterID: "java",
+ transport: transport,
+ core: core,
+ sessionID: "java-run-in-terminal-stop",
+ deadlineScheduler: RecordingDebugDeadlineScheduler()
+ )
+ var pendingCompletion: DebugRunInTerminalCompletion?
+ session.onRunInTerminalRequest = { _, completion in
+ pendingCompletion = completion
+ }
+ try session.start(rootURL: URL(fileURLWithPath: "/tmp/java-run-in-terminal-stop"))
+ core.enqueueReceive(sessionID: "java-run-in-terminal-stop", state: "launching", events: [[
+ "sequence": 2,
+ "type": "runInTerminalRequested",
+ "requestId": "runInTerminal-45",
+ "request": [
+ "kind": "integrated",
+ "cwd": "/tmp/java-run-in-terminal-stop",
+ "args": ["/opt/jdk/bin/java"],
+ "environment": [],
+ "argsCanBeInterpretedByShell": false
+ ]
+ ]])
+ transport.emitData(Data("run-in-terminal-request".utf8))
+
+ session.stop()
+ #expect(core.runInTerminalCompletions.count == 1)
+ #expect(core.runInTerminalCompletions[0].requestID == "runInTerminal-45")
+ #expect(core.runInTerminalCompletions[0].errorDescription != nil)
+
+ pendingCompletion?(.success(DebugRunInTerminalResponse(processID: 4242)))
+ #expect(core.runInTerminalCompletions.count == 1)
+ }
+
+ @Test
+ func coreProtocolSessionForwardsVariablePagingAndChildCounts() throws {
+ let transport = RecordingTransport()
+ let core = RecordingDebugProtocolCore()
+ let session = CoreDebugAdapterProtocolSession(
+ adapterID: "java",
+ transport: transport,
+ core: core,
+ sessionID: "java-variable-paging",
+ deadlineScheduler: RecordingDebugDeadlineScheduler()
+ )
+ try session.start(rootURL: URL(fileURLWithPath: "/tmp/java-variable-paging"))
+ defer { session.stop() }
+
+ var result: Result<[DebugVariable], Error>?
+ session.requestVariables(
+ reference: 700,
+ filter: .indexed,
+ start: 100,
+ count: 2
+ ) { result = $0 }
+
+ #expect(core.inspectionRequests.last?.variablesReference == 700)
+ #expect(core.inspectionRequests.last?.variableFilter == .indexed)
+ #expect(core.inspectionRequests.last?.start == 100)
+ #expect(core.inspectionRequests.last?.count == 2)
+
+ let operationID = try #require(core.lastInspectionOperationID)
+ core.enqueueReceive(sessionID: "java-variable-paging", state: "paused", events: [[
+ "sequence": 2,
+ "type": "operationCompleted",
+ "operationId": operationID,
+ "result": [
+ "kind": "variables",
+ "variables": [[
+ "name": "[100]",
+ "value": "Customer@100",
+ "type": "example.Customer",
+ "evaluateName": "customers[100]",
+ "variablesReference": 701,
+ "namedVariables": 4,
+ "indexedVariables": 5
+ ]]
+ ]
+ ]])
+ transport.emitData(Data("variables-response".utf8))
+
+ let variable = try #require(try result?.get().first)
+ #expect(variable.id == "customers[100]")
+ #expect(variable.containerReference == 700)
+ #expect(variable.namedVariables == 4)
+ #expect(variable.indexedVariables == 5)
+ }
+
+ @Test
+ func stoppedEventLoadsThreadStackScopeAndVariablesInOrder() throws {
+ let transport = RecordingTransport()
+ let core = RecordingDebugProtocolCore()
+ let descriptor = DebugProviderDescriptor(
+ id: "java",
+ displayName: "Java",
+ fileExtensions: ["java"]
+ )
+ let manager = DebugAdapterSessionManager(providers: [descriptor]) { _, _ in
+ CoreDebugAdapterProtocolSession(
+ adapterID: "java",
+ transport: transport,
+ core: core,
+ sessionID: "java-stopped-context",
+ deadlineScheduler: RecordingDebugDeadlineScheduler()
+ )
+ }
+ let feature = GenericDebugFeatureModel(sessions: manager)
+ let root = URL(fileURLWithPath: "/tmp/java-stopped-context", isDirectory: true)
+ let source = root.appendingPathComponent("src/Main.java")
+ var stoppedLocation: (URL, Int, Int)?
+ feature.onStoppedLocation = { stoppedLocation = ($0, $1, $2) }
+ #expect(feature.start(
+ fileURL: source,
+ rootURL: root,
+ configuration: DebugLaunchConfiguration(
+ name: "Main",
+ request: .launch,
+ arguments: ["mainClass": .string("example.Main")]
+ )
+ ))
+ defer { feature.stop() }
+
+ core.enqueueReceive(sessionID: "java-stopped-context", state: "paused", events: [[
+ "sequence": 1,
+ "type": "capabilities",
+ "capabilities": [
+ "supportsConfigurationDone": false,
+ "supportsConditionalBreakpoints": false,
+ "supportsHitConditionalBreakpoints": false,
+ "supportsLogPoints": false,
+ "supportsFunctionBreakpoints": false,
+ "supportsDataBreakpoints": false,
+ "supportsExceptionOptions": false,
+ "supportsExceptionFilterOptions": false,
+ "supportsSetVariable": false,
+ "supportsCancelRequest": false,
+ "supportsSingleThreadExecutionRequests": false,
+ "supportsRestartRequest": false,
+ "supportsTerminateRequest": false,
+ "supportsStepBack": false,
+ "supportsExceptionInfoRequest": true,
+ "supportsStepInTargetsRequest": false,
+ "supportsGotoTargetsRequest": false,
+ "exceptionBreakpointFilters": []
+ ]
+ ], [
+ "sequence": 2,
+ "type": "stopped",
+ "reason": "exception",
+ "threadId": 13,
+ "description": "java.lang.IllegalStateException: session expired"
+ ]])
+ transport.emitData(Data("stopped-event".utf8))
+ #expect(core.inspectionRequests.map(\.kind) == ["exceptionInfo", "threads"])
+
+ let threadsOperationID = try #require(
+ core.inspectionRequests.first(where: { $0.kind == "threads" })?.operationID
+ )
+ core.enqueueReceive(sessionID: "java-stopped-context", state: "paused", events: [[
+ "sequence": 2,
+ "type": "operationCompleted",
+ "operationId": threadsOperationID,
+ "result": [
+ "kind": "threads",
+ "threads": [
+ ["id": 2, "name": "Reference Handler"],
+ ["id": 13, "name": "http-nio-exec-1"]
+ ]
+ ]
+ ]])
+ transport.emitData(Data("threads-response".utf8))
+ #expect(core.inspectionRequests.map(\.kind) == [
+ "exceptionInfo", "threads", "stackTrace"
+ ])
+ #expect(core.inspectionRequests.last?.threadID == 13)
+
+ let stackOperationID = try #require(
+ core.inspectionRequests.first(where: { $0.kind == "stackTrace" })?.operationID
+ )
+ core.enqueueReceive(sessionID: "java-stopped-context", state: "paused", events: [[
+ "sequence": 3,
+ "type": "operationCompleted",
+ "operationId": stackOperationID,
+ "result": [
+ "kind": "stackTrace",
+ "stackFrames": [[
+ "id": 70,
+ "name": "example.Main.run",
+ "sourcePath": source.path,
+ "line": 12,
+ "column": 5
+ ]]
+ ]
+ ]])
+ transport.emitData(Data("stack-response".utf8))
+ #expect(core.inspectionRequests.map(\.kind) == [
+ "exceptionInfo", "threads", "stackTrace", "scopes"
+ ])
+ #expect(core.inspectionRequests.last?.frameID == 70)
+
+ let scopesOperationID = try #require(
+ core.inspectionRequests.first(where: { $0.kind == "scopes" })?.operationID
+ )
+ core.enqueueReceive(sessionID: "java-stopped-context", state: "paused", events: [[
+ "sequence": 4,
+ "type": "operationCompleted",
+ "operationId": scopesOperationID,
+ "result": [
+ "kind": "scopes",
+ "scopes": [[
+ "name": "Locals",
+ "variablesReference": 200,
+ "expensive": false
+ ], [
+ "name": "Fields",
+ "variablesReference": 201,
+ "expensive": true
+ ]]
+ ]
+ ]])
+ transport.emitData(Data("scopes-response".utf8))
+ #expect(core.inspectionRequests.map(\.kind) == [
+ "exceptionInfo", "threads", "stackTrace", "scopes", "variables"
+ ])
+ #expect(core.inspectionRequests.last?.variablesReference == 200)
+ #expect(feature.selectedScopeID == feature.scopes.first?.id)
+
+ let fieldsScope = try #require(feature.scopes.last)
+ feature.selectScope(fieldsScope)
+ #expect(core.inspectionRequests.last?.kind == "variables")
+ #expect(core.inspectionRequests.last?.variablesReference == 201)
+
+ let variablesOperationID = try #require(
+ core.inspectionRequests.first(where: { $0.kind == "variables" })?.operationID
+ )
+ core.enqueueReceive(sessionID: "java-stopped-context", state: "paused", events: [[
+ "sequence": 5,
+ "type": "operationCompleted",
+ "operationId": variablesOperationID,
+ "result": [
+ "kind": "variables",
+ "variables": [[
+ "name": "count",
+ "value": "7",
+ "type": "int",
+ "variablesReference": 0
+ ]]
+ ]
+ ]])
+ transport.emitData(Data("variables-response".utf8))
+
+ #expect(feature.selectedThreadID == 13)
+ #expect(feature.stoppedThreadIDs == [13])
+ #expect(feature.threads.map(\.id) == [2, 13])
+ #expect(feature.selectedFrame?.id == 70)
+ #expect(feature.selectedFrame?.isFiltered == false)
+ #expect(feature.scopes.first?.variablesReference == 200)
+ #expect(feature.variables.first?.value == "7")
+ #expect(stoppedLocation?.0 == source.standardizedFileURL)
+ #expect(stoppedLocation?.1 == 12)
+ #expect(stoppedLocation?.2 == 5)
+
+ let exceptionOperationID = try #require(
+ core.inspectionRequests.first(where: { $0.kind == "exceptionInfo" })?.operationID
+ )
+ core.enqueueReceive(sessionID: "java-stopped-context", state: "paused", events: [[
+ "sequence": 6,
+ "type": "operationCompleted",
+ "operationId": exceptionOperationID,
+ "result": [
+ "kind": "exceptionInfo",
+ "exceptionInfo": [
+ "exceptionId": "java.lang.IllegalStateException",
+ "description": "java.lang.IllegalStateException: session expired",
+ "breakMode": "always"
+ ]
+ ]
+ ]])
+ transport.emitData(Data("late-exception-response".utf8))
+ #expect(feature.exceptionInfo?.exceptionID == "java.lang.IllegalStateException")
+
+ core.enqueueReceive(sessionID: "java-stopped-context", state: "running", events: [[
+ "sequence": 7,
+ "type": "continued",
+ "threadId": 13
+ ]])
+ transport.emitData(Data("continued-event".utf8))
+ #expect(feature.stoppedThreadIDs.isEmpty)
+ }
+
+ @Test
+ func javaSteppingFiltersLoadDefaultsAndPersistNormalizedOverrides() {
+ let defaults = DebugSteppingFilters(
+ classNameFilters: ["$JDK", "org.junit.*"],
+ skipSynthetics: true,
+ skipStaticInitializers: true,
+ skipConstructors: false,
+ hideFilteredStackFrames: true
+ )
+ let normalized = DebugSteppingFilters(
+ classNameFilters: ["$JDK", "org.mockito.*"],
+ skipSynthetics: true,
+ skipStaticInitializers: false,
+ skipConstructors: true,
+ hideFilteredStackFrames: true
+ )
+ let resolver = RecordingDebugSteppingFilterResolver(
+ defaults: defaults,
+ normalizedOverride: normalized
+ )
+ let persistence = RecordingDebugSteppingFilterPersistence()
+ let manager = DebugAdapterSessionManager(providers: []) { _, _ in nil }
+ let feature = GenericDebugFeatureModel(
+ sessions: manager,
+ steppingFilterResolver: resolver,
+ steppingFilterPersistence: persistence
+ )
+
+ #expect(feature.javaSteppingFilters == defaults)
+ #expect(resolver.requests == [RecordingDebugSteppingFilterResolution(
+ adapterID: "java",
+ filters: nil
+ )])
+
+ let override = DebugSteppingFilters(
+ classNameFilters: [" org.mockito.* ", "$JDK", "org.mockito.*", ""],
+ skipSynthetics: true,
+ skipStaticInitializers: false,
+ skipConstructors: true,
+ hideFilteredStackFrames: true
+ )
+ feature.updateJavaSteppingFilters(override)
+
+ #expect(resolver.requests.last == RecordingDebugSteppingFilterResolution(
+ adapterID: "java",
+ filters: override
+ ))
+ #expect(feature.javaSteppingFilters == normalized)
+ #expect(persistence.filtersByAdapterID["java"] == normalized)
+ }
+
+ @Test
+ func javaSteppingFiltersFallBackToDefaultsWhenPersistenceCannotBeRead() {
+ let defaults = DebugSteppingFilters(
+ classNameFilters: ["$JDK", "org.junit.*"],
+ skipSynthetics: true,
+ skipStaticInitializers: true,
+ skipConstructors: false,
+ hideFilteredStackFrames: true
+ )
+ let resolver = RecordingDebugSteppingFilterResolver(
+ defaults: defaults,
+ normalizedOverride: defaults
+ )
+ let manager = DebugAdapterSessionManager(providers: []) { _, _ in nil }
+ let feature = GenericDebugFeatureModel(
+ sessions: manager,
+ steppingFilterResolver: resolver,
+ steppingFilterPersistence: FailingDebugSteppingFilterPersistence()
+ )
+
+ #expect(feature.javaSteppingFilters == defaults)
+ #expect(feature.errorMessage != nil)
+ #expect(resolver.requests == [RecordingDebugSteppingFilterResolution(
+ adapterID: "java",
+ filters: nil
+ )])
+ }
+
+ @Test
+ func javaLaunchAppliesResolvedSteppingFilters() throws {
+ let transport = RecordingTransport()
+ let core = RecordingDebugProtocolCore()
+ let descriptor = DebugProviderDescriptor(
+ id: "java",
+ displayName: "Java",
+ fileExtensions: ["java"]
+ )
+ let manager = DebugAdapterSessionManager(providers: [descriptor]) { _, _ in
+ CoreDebugAdapterProtocolSession(
+ adapterID: "java",
+ transport: transport,
+ core: core,
+ sessionID: "java-stepping-launch",
+ deadlineScheduler: RecordingDebugDeadlineScheduler()
+ )
+ }
+ let feature = GenericDebugFeatureModel(
+ sessions: manager,
+ steppingFilterResolver: core
+ )
+ let root = URL(fileURLWithPath: "/tmp/java-stepping-launch", isDirectory: true)
+ let source = root.appendingPathComponent("src/Main.java")
+
+ #expect(feature.start(
+ fileURL: source,
+ rootURL: root,
+ configuration: DebugLaunchConfiguration(
+ name: "Main",
+ request: .launch,
+ arguments: ["mainClass": .string("example.Main")]
+ )
+ ))
+ defer { feature.stop() }
+
+ #expect(feature.javaSteppingFilters == core.defaultSteppingFilters)
+ #expect(core.lastLaunchConfiguration?.steppingFilters == core.defaultSteppingFilters)
+ }
+
+ @Test
+ func failedSessionCanRetryUsingTheLastLaunchRequest() throws {
+ let session = DeferredInspectionDebugSession()
+ let descriptor = DebugProviderDescriptor(
+ id: "java",
+ displayName: "Java",
+ fileExtensions: ["java"]
+ )
+ let manager = DebugAdapterSessionManager(providers: [descriptor]) { _, _ in session }
+ let feature = GenericDebugFeatureModel(sessions: manager)
+ let root = URL(fileURLWithPath: "/tmp/java-debug-retry", isDirectory: true)
+ let source = root.appendingPathComponent("src/Main.java")
+ let configuration = DebugLaunchConfiguration(
+ name: "Retry Main",
+ request: .launch,
+ arguments: ["mainClass": .string("example.Main")]
+ )
+
+ #expect(feature.start(fileURL: source, rootURL: root, configuration: configuration))
+ session.fail()
+ #expect(feature.state == .failed)
+ #expect(feature.canRetry)
+ #expect(feature.retry())
+ #expect(session.startCount == 2)
+ #expect(session.launchConfigurations == [configuration, configuration])
+
+ feature.stop()
+ }
+
+ @Test
+ func restartFallsBackToRelaunchWhenAdapterDoesNotAdvertiseRestart() throws {
+ let session = DeferredInspectionDebugSession()
+ let descriptor = DebugProviderDescriptor(
+ id: "java",
+ displayName: "Java",
+ fileExtensions: ["java"]
+ )
+ let manager = DebugAdapterSessionManager(providers: [descriptor]) { _, _ in session }
+ let feature = GenericDebugFeatureModel(sessions: manager)
+ let root = URL(fileURLWithPath: "/tmp/java-debug-restart-fallback", isDirectory: true)
+ let source = root.appendingPathComponent("src/Main.java")
+ let configuration = DebugLaunchConfiguration(
+ name: "Restart Main",
+ request: .launch,
+ arguments: ["mainClass": .string("example.Main")]
+ )
+
+ #expect(feature.start(fileURL: source, rootURL: root, configuration: configuration))
+ #expect(feature.state == .paused)
+ #expect(!feature.capabilities.supportsRestartRequest)
+ #expect(feature.canRestart)
+
+ feature.execute(.restart)
+
+ #expect(session.startCount == 2)
+ #expect(session.launchConfigurations == [configuration, configuration])
+ feature.stop()
+ }
+
+ @Test
+ func executionControlsIgnoreOverlappingRequestsUntilSessionLeavesPausedState() throws {
+ let session = DeferredInspectionDebugSession()
+ let descriptor = DebugProviderDescriptor(
+ id: "java",
+ displayName: "Java",
+ fileExtensions: ["java"]
+ )
+ let manager = DebugAdapterSessionManager(providers: [descriptor]) { _, _ in session }
+ let feature = GenericDebugFeatureModel(sessions: manager)
+ let root = URL(fileURLWithPath: "/tmp/java-debug-execution-lock", isDirectory: true)
+ let source = root.appendingPathComponent("src/Main.java")
+ let configuration = DebugLaunchConfiguration(
+ name: "Execution lock",
+ request: .launch,
+ arguments: ["mainClass": .string("example.Main")]
+ )
+
+ #expect(feature.start(fileURL: source, rootURL: root, configuration: configuration))
+ feature.execute(.continueExecution)
+ feature.execute(.continueExecution)
+ #expect(session.executionCommands == [.continueExecution])
+ #expect(feature.isExecutionRequestPending)
+
+ session.transition(to: .running)
+ #expect(!feature.isExecutionRequestPending)
+ session.transition(to: .paused)
+ feature.execute(.continueExecution)
+ #expect(session.executionCommands == [.continueExecution, .continueExecution])
+
+ feature.stop()
+ }
+
+ @Test
+ func filteredStackFramesCollapseByConsecutiveRunsAndRestoreOrder() {
+ let session = DeferredInspectionDebugSession()
+ let descriptor = DebugProviderDescriptor(
+ id: "java",
+ displayName: "Java",
+ fileExtensions: ["java"]
+ )
+ let manager = DebugAdapterSessionManager(providers: [descriptor]) { _, _ in session }
+ let defaults = DebugSteppingFilters(
+ classNameFilters: ["$JDK"],
+ skipSynthetics: true,
+ skipStaticInitializers: true,
+ skipConstructors: false,
+ hideFilteredStackFrames: true
+ )
+ let feature = GenericDebugFeatureModel(
+ sessions: manager,
+ steppingFilterResolver: RecordingDebugSteppingFilterResolver(
+ defaults: defaults,
+ normalizedOverride: defaults
+ )
+ )
+ let root = URL(fileURLWithPath: "/tmp/java-filtered-stack", isDirectory: true)
+ let source = root.appendingPathComponent("src/Main.java")
+ #expect(feature.start(
+ fileURL: source,
+ rootURL: root,
+ configuration: DebugLaunchConfiguration(
+ name: "Main",
+ request: .launch,
+ arguments: ["mainClass": .string("example.Main")]
+ )
+ ))
+ defer { feature.stop() }
+
+ feature.selectThread(DebugThread(id: 7, name: "main"))
+ session.completeStackTrace(at: 0, with: [
+ DebugStackFrame(
+ id: 1,
+ name: "example.LoginController.login",
+ sourceURL: source,
+ line: 20,
+ column: 5
+ ),
+ DebugStackFrame(
+ id: 2,
+ name: "java.lang.reflect.Method.invoke",
+ sourceURL: nil,
+ line: 1,
+ column: 1,
+ isFiltered: true
+ ),
+ DebugStackFrame(
+ id: 3,
+ name: "org.springframework.cglib.Proxy.invoke",
+ sourceURL: nil,
+ line: 1,
+ column: 1,
+ isFiltered: true
+ ),
+ DebugStackFrame(
+ id: 4,
+ name: "example.Dispatcher.dispatch",
+ sourceURL: source,
+ line: 42,
+ column: 3
+ ),
+ DebugStackFrame(
+ id: 5,
+ name: "jdk.proxy1.$Proxy0.invoke",
+ sourceURL: nil,
+ line: 1,
+ column: 1,
+ isFiltered: true
+ )
+ ])
+
+ #expect(feature.hiddenStackFrameCount == 3)
+ #expect(feature.visibleStackFrameRows.map(\.id) == [
+ "frame-1", "filtered-2", "frame-4", "filtered-5"
+ ])
+ #expect(feature.visibleStackFrameRows.map(\.hiddenFrameCount) == [0, 2, 0, 1])
+
+ feature.expandFilteredStackFrames()
+ #expect(feature.visibleStackFrameRows.compactMap(\.frame?.id) == [1, 2, 3, 4, 5])
+ #expect(feature.visibleStackFrameRows.allSatisfy { !$0.isHiddenGroup })
+
+ feature.collapseFilteredStackFrames()
+ #expect(feature.visibleStackFrameRows.map(\.id) == [
+ "frame-1", "filtered-2", "frame-4", "filtered-5"
+ ])
+ }
+
+ @Test
+ func stoppedStackPrefersTheFirstSourceBackedUnfilteredFrame() throws {
+ let session = DeferredInspectionDebugSession()
+ let descriptor = DebugProviderDescriptor(
+ id: "java",
+ displayName: "Java",
+ fileExtensions: ["java"]
+ )
+ let manager = DebugAdapterSessionManager(providers: [descriptor]) { _, _ in session }
+ let feature = GenericDebugFeatureModel(sessions: manager)
+ let root = URL(fileURLWithPath: "/tmp/java-preferred-frame", isDirectory: true)
+ let source = root.appendingPathComponent("src/UserService.java")
+ #expect(feature.start(
+ fileURL: source,
+ rootURL: root,
+ configuration: DebugLaunchConfiguration(
+ name: "Main",
+ request: .launch,
+ arguments: ["mainClass": .string("example.Main")]
+ )
+ ))
+ defer { feature.stop() }
+
+ // Java adapters can report a synthetic method-handle frame above the
+ // actual application frame. The initial inspection must land on the
+ // application source while retaining the synthetic frame in the list.
+ feature.selectThread(DebugThread(id: 7, name: "http-worker"))
+ let synthetic = DebugStackFrame(
+ id: 1,
+ name: "java.lang.invoke.MethodHandle.invokeVirtual",
+ sourceURL: nil,
+ line: 1,
+ column: 1,
+ isFiltered: true
+ )
+ let application = DebugStackFrame(
+ id: 2,
+ name: "example.UserService.listUsers",
+ sourceURL: source,
+ line: 18,
+ column: 5
+ )
+ session.completeStackTrace(at: 0, with: [synthetic, application])
+
+ #expect(feature.stackFrames.map(\.id) == [1, 2])
+ #expect(feature.selectedFrameID == 2)
+ #expect(feature.selectedFrame?.sourceURL == source)
+ #expect(session.scopeFrameIDs == [2])
+ }
+
+ @Test
+ func rapidInspectionSelectionDiscardsOutOfOrderResults() throws {
+ let session = DeferredInspectionDebugSession()
+ let descriptor = DebugProviderDescriptor(
+ id: "java",
+ displayName: "Java",
+ fileExtensions: ["java"]
+ )
+ let manager = DebugAdapterSessionManager(providers: [descriptor]) { _, _ in session }
+ let feature = GenericDebugFeatureModel(sessions: manager)
+ let root = URL(fileURLWithPath: "/tmp/java-inspection-selection", isDirectory: true)
+ let source = root.appendingPathComponent("src/Main.java")
+ #expect(feature.start(
+ fileURL: source,
+ rootURL: root,
+ configuration: DebugLaunchConfiguration(
+ name: "Main",
+ request: .launch,
+ arguments: ["mainClass": .string("example.Main")]
+ )
+ ))
+ defer { feature.stop() }
+
+ let firstThread = DebugThread(id: 1, name: "worker-1")
+ let secondThread = DebugThread(id: 2, name: "worker-2")
+ let staleFrame = DebugStackFrame(
+ id: 10,
+ name: "stale",
+ sourceURL: source,
+ line: 10,
+ column: 1
+ )
+ let firstFrame = DebugStackFrame(
+ id: 20,
+ name: "first",
+ sourceURL: source,
+ line: 20,
+ column: 1
+ )
+ let secondFrame = DebugStackFrame(
+ id: 21,
+ name: "second",
+ sourceURL: source,
+ line: 21,
+ column: 1
+ )
+
+ // The older thread response arrives after the newer selection has
+ // already loaded its frames. It must not replace the active stack.
+ feature.selectThread(firstThread)
+ feature.selectThread(secondThread)
+ #expect(session.stackTraceThreadIDs == [1, 2])
+ session.completeStackTrace(at: 1, with: [firstFrame, secondFrame])
+ session.completeStackTrace(at: 0, with: [staleFrame])
+
+ #expect(feature.selectedThreadID == 2)
+ #expect(feature.stackFrames.map(\.id) == [20, 21])
+ #expect(feature.selectedFrameID == 20)
+ #expect(session.scopeFrameIDs == [20])
+
+ // The first frame's scope response arrives after the second frame was
+ // selected. It must not start a stale variables request.
+ feature.selectFrame(secondFrame)
+ #expect(session.scopeFrameIDs == [20, 21])
+ session.completeScopes(
+ at: 1,
+ with: [DebugScope(id: 210, name: "Locals", variablesReference: 210, expensive: false)]
+ )
+ session.completeScopes(
+ at: 0,
+ with: [DebugScope(id: 200, name: "Locals", variablesReference: 200, expensive: false)]
+ )
+
+ #expect(feature.selectedFrameID == 21)
+ #expect(feature.scopes.map(\.variablesReference) == [210])
+ #expect(session.variableReferences == [210])
+
+ // A variables response from the previously selected frame must not
+ // overwrite the variables that belong to the current frame.
+ feature.selectFrame(firstFrame)
+ session.completeScopes(
+ at: 2,
+ with: [DebugScope(id: 200, name: "Locals", variablesReference: 200, expensive: false)]
+ )
+ let currentVariable = DebugVariable(
+ id: "user",
+ name: "user",
+ value: "CurrentUser@1",
+ type: "CurrentUser",
+ evaluateName: "user",
+ variablesReference: 300
+ )
+ session.completeVariables(at: 1, with: [currentVariable])
+ session.completeVariables(at: 0, with: [
+ DebugVariable(
+ id: "stale",
+ name: "stale",
+ value: "OldUser@1",
+ type: "OldUser",
+ evaluateName: "stale",
+ variablesReference: 0
+ )
+ ])
+ #expect(feature.variables == [currentVariable])
+
+ // Child-variable loading is also scoped to the selected frame.
+ feature.toggleVariableExpansion(currentVariable)
+ #expect(session.variableReferences == [210, 200, 300])
+ feature.selectFrame(secondFrame)
+ session.completeVariables(at: 2, with: [
+ DebugVariable(
+ id: "name",
+ name: "name",
+ value: "stale child",
+ type: "String",
+ evaluateName: "user.name",
+ variablesReference: 0
+ )
+ ])
+ #expect(feature.variables.isEmpty)
+ #expect(feature.variableChildren.isEmpty)
+ #expect(feature.loadingVariableIDs.isEmpty)
+
+ session.emit(.continued(threadID: secondThread.id))
+ #expect(feature.selectedThreadID == nil)
+ #expect(feature.selectedFrameID == nil)
+ #expect(feature.stackFrames.isEmpty)
+ #expect(feature.scopes.isEmpty)
+ }
+
+ @Test
+ func runningStateClearsStoppedInspectionBeforeContinuedEvent() throws {
+ let session = DeferredInspectionDebugSession()
+ let feature = makeDeferredFeature(
+ session: session,
+ rootPath: "/tmp/java-running-inspection-reset"
+ )
+ defer { feature.stop() }
+ let thread = DebugThread(id: 7, name: "http-worker")
+ let frame = DebugStackFrame(
+ id: 70,
+ name: "UserController.list()",
+ sourceURL: URL(fileURLWithPath: "/tmp/java-running-inspection-reset/src/UserController.java"),
+ line: 23,
+ column: 1
+ )
+ let service = DebugVariable(
+ id: "service",
+ name: "service",
+ value: "UserService@72",
+ type: "UserService",
+ evaluateName: "service",
+ variablesReference: 0
+ )
+
+ feature.selectThread(thread)
+ session.completeStackTrace(at: 0, with: [frame])
+ session.completeScopes(
+ at: 0,
+ with: [DebugScope(id: 700, name: "Locals", variablesReference: 700, expensive: false)]
+ )
+ session.completeVariables(at: 0, with: [service])
+ feature.requestAutomaticVariables(["service"])
+ session.completeEvaluation(at: 0, with: .success(service))
+
+ #expect(feature.selectedThreadID == thread.id)
+ #expect(feature.selectedFrameID == frame.id)
+ #expect(feature.selectedScopeID == 700)
+ #expect(feature.presentedVariables == [service])
+
+ // The adapter commonly confirms the request before sending its
+ // continued event. Running state must not expose the old pause.
+ session.transition(to: .running)
+
+ #expect(feature.state == .running)
+ #expect(feature.selectedThreadID == nil)
+ #expect(feature.selectedFrameID == nil)
+ #expect(feature.selectedScopeID == nil)
+ #expect(feature.stackFrames.isEmpty)
+ #expect(feature.scopes.isEmpty)
+ #expect(feature.variables.isEmpty)
+ #expect(feature.automaticVariables.isEmpty)
+ #expect(feature.presentedVariables.isEmpty)
+ }
+
+ @Test
+ func automaticVariableInspectionRunsAfterFrameResetAndFallsBackToJavaField() {
+ let session = DeferredInspectionDebugSession()
+ let feature = makeDeferredFeature(
+ session: session,
+ rootPath: "/tmp/java-automatic-variable-inspection"
+ )
+ defer { feature.stop() }
+ let frame = DebugStackFrame(
+ id: 31,
+ name: "UserController.list()",
+ sourceURL: URL(fileURLWithPath: "/tmp/java-automatic-variable-inspection/src/UserController.java"),
+ line: 23,
+ column: 1
+ )
+ var requestedFrameID: Int?
+ feature.onAutomaticVariableInspectionRequest = { selectedFrame in
+ requestedFrameID = selectedFrame.id
+ feature.requestAutomaticVariables(["service"])
+ }
+
+ #expect(feature.state == .paused)
+ #expect(feature.providerID == "java")
+ feature.selectFrame(frame)
+ #expect(requestedFrameID == frame.id)
+ #expect(session.evaluateExpressions == ["service"])
+ #expect(session.evaluateFrameIDs == [frame.id])
+
+ guard session.evaluateExpressions.count == 1 else { return }
+ session.completeEvaluation(at: 0, with: .failure(DeferredDebugSessionError.launchFailed))
+ #expect(session.evaluateExpressions == ["service", "this.service"])
+
+ let service = DebugVariable(
+ id: "service",
+ name: "this.service",
+ value: "UserService@72",
+ type: "UserService",
+ evaluateName: "this.service",
+ variablesReference: 72
+ )
+ guard session.evaluateExpressions.count == 2 else { return }
+ session.completeEvaluation(at: 1, with: .success(service))
+
+ #expect(feature.automaticVariables.map(\.name) == ["service"])
+ #expect(feature.automaticVariables.map(\.value) == ["UserService@72"])
+ }
+
+ @Test
+ func largeIndexedVariableCollectionsLoadInBoundedPages() throws {
+ let session = DeferredInspectionDebugSession()
+ let feature = makeDeferredFeature(
+ session: session,
+ rootPath: "/tmp/java-large-variable-pages"
+ )
+ defer { feature.stop() }
+ let frame = DebugStackFrame(id: 70, name: "main", sourceURL: nil, line: 12, column: 1)
+
+ feature.selectFrame(frame)
+ session.completeScopes(at: 0, with: [DebugScope(
+ id: 70,
+ name: "Locals",
+ variablesReference: 700,
+ expensive: false,
+ indexedVariables: 250
+ )])
+ #expect(session.variablePageRequests == [RecordingDebugVariablePageRequest(
+ reference: 700,
+ filter: .indexed,
+ start: 0,
+ count: 100
+ )])
+
+ session.completeVariables(at: 0, with: indexedVariables(0..<100))
+ #expect(feature.variables.count == 100)
+ #expect(feature.visibleVariableRows.last?.content == .loadMore(
+ parentVariableID: nil,
+ nextCount: 100,
+ remainingCount: 150
+ ))
+
+ feature.loadMoreVariables(parentVariableID: nil)
+ #expect(session.variablePageRequests.last == RecordingDebugVariablePageRequest(
+ reference: 700,
+ filter: .indexed,
+ start: 100,
+ count: 100
+ ))
+ session.completeVariables(at: 1, with: indexedVariables(100..<200))
+ #expect(feature.visibleVariableRows.last?.content == .loadMore(
+ parentVariableID: nil,
+ nextCount: 50,
+ remainingCount: 50
+ ))
+
+ feature.loadMoreVariables(parentVariableID: nil)
+ #expect(session.variablePageRequests.last == RecordingDebugVariablePageRequest(
+ reference: 700,
+ filter: .indexed,
+ start: 200,
+ count: 50
+ ))
+ session.completeVariables(at: 2, with: indexedVariables(200..<250))
+
+ #expect(feature.variables.count == 250)
+ #expect(feature.variables.first?.name == "[0]")
+ #expect(feature.variables.last?.name == "[249]")
+ #expect(feature.visibleVariableRows.count == 250)
+ }
+
+ @Test
+ func namedAndIndexedVariableSegmentsLoadInProtocolOrder() throws {
+ let session = DeferredInspectionDebugSession()
+ let feature = makeDeferredFeature(
+ session: session,
+ rootPath: "/tmp/java-named-indexed-pages"
+ )
+ defer { feature.stop() }
+ let frame = DebugStackFrame(id: 71, name: "main", sourceURL: nil, line: 12, column: 1)
+
+ feature.selectFrame(frame)
+ session.completeScopes(at: 0, with: [DebugScope(
+ id: 71,
+ name: "Locals",
+ variablesReference: 710,
+ expensive: false,
+ namedVariables: 2,
+ indexedVariables: 3
+ )])
+ #expect(session.variablePageRequests.last == RecordingDebugVariablePageRequest(
+ reference: 710,
+ filter: .named,
+ start: 0,
+ count: 2
+ ))
+ session.completeVariables(at: 0, with: [
+ DebugVariable(id: "size", name: "size", value: "3", type: "int", evaluateName: "items.size", variablesReference: 0),
+ DebugVariable(id: "empty", name: "empty", value: "false", type: "boolean", evaluateName: "items.empty", variablesReference: 0)
+ ])
+ #expect(feature.visibleVariableRows.last?.content == .loadMore(
+ parentVariableID: nil,
+ nextCount: 3,
+ remainingCount: 3
+ ))
+
+ feature.loadMoreVariables(parentVariableID: nil)
+ #expect(session.variablePageRequests.last == RecordingDebugVariablePageRequest(
+ reference: 710,
+ filter: .indexed,
+ start: 0,
+ count: 3
+ ))
+ session.completeVariables(at: 1, with: indexedVariables(0..<3))
+
+ #expect(feature.variables.map(\.name) == ["size", "empty", "[0]", "[1]", "[2]"])
+ #expect(feature.visibleVariableRows.count == 5)
+ }
+
+ @Test
+ func repeatedVariablePageStopsWhenAdapterIgnoresStart() throws {
+ let session = DeferredInspectionDebugSession()
+ let feature = makeDeferredFeature(
+ session: session,
+ rootPath: "/tmp/java-ignored-variable-paging"
+ )
+ defer { feature.stop() }
+ let frame = DebugStackFrame(id: 72, name: "main", sourceURL: nil, line: 12, column: 1)
+
+ feature.selectFrame(frame)
+ session.completeScopes(at: 0, with: [DebugScope(
+ id: 72,
+ name: "Locals",
+ variablesReference: 720,
+ expensive: false,
+ indexedVariables: 250
+ )])
+ session.completeVariables(
+ at: 0,
+ with: indexedVariables(0..<100, idPrefix: "page-zero")
+ )
+ feature.loadMoreVariables(parentVariableID: nil)
+ #expect(session.variablePageRequests.last?.start == 100)
+
+ session.completeVariables(
+ at: 1,
+ with: indexedVariables(0..<100, idPrefix: "page-one")
+ )
+
+ #expect(feature.variables.count == 100)
+ #expect(feature.visibleVariableRows.count == 100)
+ feature.loadMoreVariables(parentVariableID: nil)
+ #expect(session.variablePageRequests.count == 2)
+ }
+
+ @Test
+ func staleVariablePageDoesNotEnterNewStackFrame() throws {
+ let session = DeferredInspectionDebugSession()
+ let feature = makeDeferredFeature(
+ session: session,
+ rootPath: "/tmp/java-stale-variable-page"
+ )
+ defer { feature.stop() }
+ let firstFrame = DebugStackFrame(id: 80, name: "first", sourceURL: nil, line: 12, column: 1)
+ let secondFrame = DebugStackFrame(id: 81, name: "second", sourceURL: nil, line: 20, column: 1)
+
+ feature.selectFrame(firstFrame)
+ session.completeScopes(at: 0, with: [DebugScope(
+ id: 80,
+ name: "Locals",
+ variablesReference: 800,
+ expensive: false,
+ indexedVariables: 250
+ )])
+ session.completeVariables(at: 0, with: indexedVariables(0..<100))
+ feature.loadMoreVariables(parentVariableID: nil)
+
+ feature.selectFrame(secondFrame)
+ session.completeScopes(at: 1, with: [DebugScope(
+ id: 81,
+ name: "Locals",
+ variablesReference: 810,
+ expensive: false,
+ indexedVariables: 1
+ )])
+ let current = DebugVariable(
+ id: "current",
+ name: "current",
+ value: "true",
+ type: "boolean",
+ evaluateName: "current",
+ variablesReference: 0
+ )
+ session.completeVariables(at: 2, with: [current])
+ session.completeVariables(at: 1, with: indexedVariables(100..<200))
+
+ #expect(feature.selectedFrameID == 81)
+ #expect(feature.variables == [current])
+ #expect(feature.visibleVariableRows.map(\.variable) == [current])
+ }
+
+ @Test
+ func exceptionStopsLoadCurrentMetadataAndDiscardStaleResponses() throws {
+ let capabilities = DebugAdapterCapabilities(
+ negotiated: true,
+ supportsExceptionInfoRequest: true
+ )
+ let session = DeferredInspectionDebugSession(capabilities: capabilities)
+ let descriptor = DebugProviderDescriptor(
+ id: "java",
+ displayName: "Java",
+ fileExtensions: ["java"]
+ )
+ let manager = DebugAdapterSessionManager(providers: [descriptor]) { _, _ in session }
+ let feature = GenericDebugFeatureModel(sessions: manager)
+ let root = URL(fileURLWithPath: "/tmp/java-exception-info", isDirectory: true)
+ let source = root.appendingPathComponent("src/Main.java")
+ #expect(feature.start(
+ fileURL: source,
+ rootURL: root,
+ configuration: DebugLaunchConfiguration(
+ name: "Main",
+ request: .launch,
+ arguments: ["mainClass": .string("example.Main")]
+ )
+ ))
+ defer { feature.stop() }
+ session.emit(.capabilities(capabilities))
+
+ let staleInfo = DebugExceptionInfo(
+ exceptionID: "example.StaleException",
+ description: "stale",
+ breakMode: "always",
+ details: nil
+ )
+ let currentInfo = DebugExceptionInfo(
+ exceptionID: "example.LoginException",
+ description: "Login failed",
+ breakMode: "userUnhandled",
+ details: nil
+ )
+
+ session.emit(.stopped(reason: "exception", threadID: 1, description: "stale stop"))
+ #expect(session.exceptionInfoThreadIDs == [1])
+ session.emit(.stopped(reason: "breakpoint", threadID: 2, description: nil))
+ session.completeExceptionInfo(at: 0, with: staleInfo)
+ #expect(feature.exceptionInfo == nil)
+
+ session.emit(.stopped(reason: "exception", threadID: 3, description: "Login failed"))
+ #expect(session.exceptionInfoThreadIDs == [1, 3])
+ session.completeExceptionInfo(at: 1, with: currentInfo)
+ #expect(feature.exceptionInfo == currentInfo)
+
+ session.emit(.continued(threadID: 3))
+ #expect(feature.exceptionInfo == nil)
+ session.emit(.capabilities(.unknown))
+ session.emit(.stopped(reason: "exception", threadID: 4, description: nil))
+ #expect(session.exceptionInfoThreadIDs == [1, 3])
+
+ session.emit(.capabilities(capabilities))
+ session.emit(.stopped(reason: "exception", threadID: 5, description: nil))
+ session.completeExceptionInfo(at: 2, with: currentInfo)
+ #expect(feature.exceptionInfo == currentInfo)
+ session.emit(.terminated(exitCode: 1))
+ #expect(feature.exceptionInfo == nil)
+ }
+
+ @Test
+ func expandingSelfReferentialVariableDoesNotRecurseForever() throws {
+ let session = DeferredInspectionDebugSession()
+ let feature = makeDeferredFeature(
+ session: session,
+ rootPath: "/tmp/java-self-referential-variable"
+ )
+ defer { feature.stop() }
+
+ session.emit(.stopped(reason: "breakpoint", threadID: 1, description: nil))
+ feature.loadVariables(reference: 100)
+ session.completeVariables(at: 0, with: [DebugVariable(
+ id: "self",
+ name: "self",
+ value: "Node@1",
+ type: "Node",
+ evaluateName: "self",
+ variablesReference: 101,
+ containerReference: 100,
+ namedVariables: 1,
+ indexedVariables: 0
+ )])
+
+ let root = try #require(feature.variables.first)
+ feature.toggleVariableExpansion(root)
+ session.completeVariables(at: 1, with: [DebugVariable(
+ id: "self",
+ name: "self",
+ value: "Node@1",
+ type: "Node",
+ evaluateName: "self",
+ variablesReference: 101,
+ containerReference: 101,
+ namedVariables: 1,
+ indexedVariables: 0
+ )])
+
+ #expect(feature.visibleVariableRows.map(\.depth) == [0, 1])
+ #expect(feature.visibleVariableRows.compactMap { $0.variable?.name } == ["self", "self"])
+ }
+
+ @Test
+ func genericBreakpointsPreserveAdvancedOptionsAcrossMuteAndClear() throws {
+ let transport = RecordingTransport()
+ let core = RecordingDebugProtocolCore()
+ let descriptor = DebugProviderDescriptor(
+ id: "java",
+ displayName: "Java",
+ fileExtensions: ["java"]
+ )
+ let manager = DebugAdapterSessionManager(providers: [descriptor]) { _, _ in
+ CoreDebugAdapterProtocolSession(
+ adapterID: "java",
+ transport: transport,
+ core: core,
+ sessionID: "java-breakpoints",
+ deadlineScheduler: RecordingDebugDeadlineScheduler()
+ )
+ }
+ let feature = GenericDebugFeatureModel(sessions: manager)
+ let root = URL(fileURLWithPath: "/tmp/java-breakpoints", isDirectory: true)
+ let source = root.appendingPathComponent("Main.java")
+
+ feature.toggleBreakpoint(fileURL: source, line: 12)
+ feature.updateBreakpoint(
+ fileURL: source,
+ line: 12,
+ enabled: true,
+ condition: "value > 1",
+ hitCondition: "3",
+ logMessage: "value = {value}"
+ )
+ feature.toggleBreakpointMute()
+
+ #expect(feature.breakpoints.count == 1)
+ #expect(feature.breakpoints[0].condition == "value > 1")
+ #expect(feature.breakpoints[0].hitCondition == "3")
+ #expect(feature.breakpoints[0].logMessage == "value = {value}")
+ #expect(feature.areBreakpointsMuted)
+ #expect(feature.start(
+ fileURL: source,
+ rootURL: root,
+ configuration: DebugLaunchConfiguration(
+ name: "Main",
+ request: .launch,
+ arguments: ["mainClass": .string("example.Main")]
+ )
+ ))
+ #expect(core.breakpointUpdates.last == [DebugSourceBreakpoint(
+ line: 12,
+ enabled: false,
+ condition: "value > 1",
+ hitCondition: "3",
+ logMessage: "value = {value}"
+ )])
+
+ core.enqueueReceive(sessionID: "java-breakpoints", state: "ready", events: [[
+ "sequence": 2,
+ "type": "capabilities",
+ "capabilities": [
+ "supportsConfigurationDone": true,
+ "supportsConditionalBreakpoints": true,
+ "supportsHitConditionalBreakpoints": true,
+ "supportsLogPoints": true,
+ "supportsFunctionBreakpoints": true,
+ "supportsDataBreakpoints": true,
+ "supportsExceptionOptions": false,
+ "supportsExceptionFilterOptions": true,
+ "supportsSetVariable": false,
+ "supportsCancelRequest": false,
+ "supportsSingleThreadExecutionRequests": false,
+ "supportsRestartRequest": false,
+ "supportsTerminateRequest": false,
+ "supportsStepBack": false,
+ "supportsExceptionInfoRequest": false,
+ "supportsStepInTargetsRequest": false,
+ "supportsGotoTargetsRequest": false,
+ "exceptionBreakpointFilters": [[
+ "filter": "caught",
+ "label": "Caught Exceptions",
+ "description": "Pause when an exception is caught.",
+ "default": false,
+ "supportsCondition": true,
+ "conditionDescription": "Exception class pattern"
+ ], [
+ "filter": "uncaught",
+ "label": "Uncaught Exceptions",
+ "default": true,
+ "supportsCondition": false
+ ]]
+ ]
+ ]])
+ transport.emitData(Data("capabilities-response".utf8))
+ #expect(feature.exceptionBreakpoints.map(\.filter) == ["caught", "uncaught"])
+ #expect(feature.exceptionBreakpoints.last?.enabled == true)
+ feature.updateExceptionBreakpoint(
+ try #require(feature.exceptionBreakpoints.first),
+ enabled: true,
+ condition: "example.CustomException"
+ )
+ #expect(core.exceptionBreakpointUpdates.last == [
+ DebugExceptionBreakpoint(
+ filter: "caught",
+ enabled: true,
+ condition: "example.CustomException"
+ ),
+ DebugExceptionBreakpoint(filter: "uncaught", enabled: true)
+ ])
+ feature.addFunctionBreakpoint(
+ name: " example.Main.run ",
+ condition: "ready",
+ hitCondition: "2"
+ )
+ #expect(feature.functionBreakpoints.first?.name == "example.Main.run")
+ #expect(core.functionBreakpointUpdates.last == [
+ DebugFunctionBreakpoint(
+ name: "example.Main.run",
+ enabled: true,
+ condition: "ready",
+ hitCondition: "2"
+ )
+ ])
+ core.enqueueReceive(sessionID: "java-breakpoints", state: "ready", events: [[
+ "sequence": 3,
+ "type": "breakpoint",
+ "breakpoint": [
+ "id": 8,
+ "verified": true,
+ "functionName": "example.Main.run"
+ ]
+ ]])
+ transport.emitData(Data("function-breakpoint-response".utf8))
+ #expect(feature.functionBreakpoints.first?.verified == true)
+
+ core.enqueueReceive(sessionID: "java-breakpoints", state: "paused", events: [[
+ "sequence": 4,
+ "type": "stopped",
+ "reason": "breakpoint"
+ ]])
+ transport.emitData(Data("stopped-event".utf8))
+ feature.addWatch(" count ")
+ let staleWatchOperationID = try #require(core.lastInspectionOperationID)
+ feature.updateWatch(try #require(feature.watches.first), expression: "count + 1")
+ let watchOperationID = try #require(core.lastInspectionOperationID)
+ core.enqueueReceive(sessionID: "java-breakpoints", state: "paused", events: [[
+ "sequence": 5,
+ "type": "operationCompleted",
+ "operationId": staleWatchOperationID,
+ "result": [
+ "kind": "evaluate",
+ "variable": [
+ "name": "count",
+ "value": "7",
+ "type": "int",
+ "variablesReference": 0
+ ]
+ ]
+ ]])
+ transport.emitData(Data("stale-watch-response".utf8))
+ #expect(feature.watches.first?.expression == "count + 1")
+ #expect(feature.watches.first?.value == nil)
+ core.enqueueReceive(sessionID: "java-breakpoints", state: "paused", events: [[
+ "sequence": 6,
+ "type": "operationCompleted",
+ "operationId": watchOperationID,
+ "result": [
+ "kind": "evaluate",
+ "variable": [
+ "name": "count + 1",
+ "value": "8",
+ "type": "int",
+ "variablesReference": 0
+ ]
+ ]
+ ]])
+ transport.emitData(Data("watch-response".utf8))
+ #expect(feature.watches.first?.value == "8")
+
+ var hoverValue: DebugVariable?
+ feature.evaluateForHover("count") { hoverValue = $0 }
+ let hoverOperationID = try #require(core.lastInspectionOperationID)
+ core.enqueueReceive(sessionID: "java-breakpoints", state: "paused", events: [[
+ "sequence": 7,
+ "type": "operationCompleted",
+ "operationId": hoverOperationID,
+ "result": [
+ "kind": "evaluate",
+ "variable": [
+ "name": "count",
+ "value": "7",
+ "type": "int",
+ "variablesReference": 0
+ ]
+ ]
+ ]])
+ transport.emitData(Data("hover-response".utf8))
+ #expect(hoverValue?.value == "7")
+
+ var staleHoverValue: DebugVariable?
+ feature.evaluateForHover("count") { staleHoverValue = $0 }
+ let staleHoverOperationID = try #require(core.lastInspectionOperationID)
+ core.enqueueReceive(sessionID: "java-breakpoints", state: "running", events: [[
+ "sequence": 8,
+ "type": "stateChanged",
+ "state": "running"
+ ], [
+ "sequence": 9,
+ "type": "operationCompleted",
+ "operationId": staleHoverOperationID,
+ "result": [
+ "kind": "evaluate",
+ "variable": [
+ "name": "count",
+ "value": "8",
+ "type": "int",
+ "variablesReference": 0
+ ]
+ ]
+ ]])
+ transport.emitData(Data("stale-hover-response".utf8))
+ #expect(staleHoverValue == nil)
+ core.enqueueReceive(sessionID: "java-breakpoints", state: "paused", events: [[
+ "sequence": 10,
+ "type": "stopped",
+ "reason": "breakpoint"
+ ]])
+ transport.emitData(Data("next-stopped-event".utf8))
+
+ feature.requestDataBreakpoint(for: DebugVariable(
+ id: "count",
+ name: "count",
+ value: "1",
+ type: "int",
+ evaluateName: "this.count",
+ variablesReference: 0,
+ containerReference: 42
+ ))
+ let dataOperationID = try #require(core.lastDataBreakpointInfoOperationID)
+ core.enqueueReceive(sessionID: "java-breakpoints", state: "paused", events: [[
+ "sequence": 5,
+ "type": "operationCompleted",
+ "operationId": dataOperationID,
+ "result": [
+ "kind": "dataBreakpointInfo",
+ "dataId": "field:count",
+ "description": "Main.count",
+ "accessTypes": ["read", "write"],
+ "canPersist": true
+ ]
+ ]])
+ transport.emitData(Data("data-info-response".utf8))
+ #expect(feature.dataBreakpoints.first?.accessType == "write")
+ #expect(core.dataBreakpointUpdates.last == [DebugDataBreakpoint(
+ dataID: "field:count",
+ label: "Main.count",
+ accessType: "write"
+ )])
+
+ feature.loadVariables(reference: 100)
+ let rootVariablesOperationID = try #require(core.lastInspectionOperationID)
+ core.enqueueReceive(sessionID: "java-breakpoints", state: "paused", events: [[
+ "sequence": 7,
+ "type": "operationCompleted",
+ "operationId": rootVariablesOperationID,
+ "result": [
+ "kind": "variables",
+ "variables": [[
+ "name": "user",
+ "value": "User@1",
+ "type": "User",
+ "variablesReference": 101
+ ]]
+ ]
+ ]])
+ transport.emitData(Data("root-variables-response".utf8))
+ let user = try #require(feature.variables.first)
+ feature.toggleVariableExpansion(user)
+ let childVariablesOperationID = try #require(core.lastInspectionOperationID)
+ core.enqueueReceive(sessionID: "java-breakpoints", state: "paused", events: [[
+ "sequence": 8,
+ "type": "operationCompleted",
+ "operationId": childVariablesOperationID,
+ "result": [
+ "kind": "variables",
+ "variables": [[
+ "name": "name",
+ "value": "Ada",
+ "type": "String",
+ "variablesReference": 0
+ ]]
+ ]
+ ]])
+ transport.emitData(Data("child-variables-response".utf8))
+ #expect(feature.visibleVariableRows.compactMap { $0.variable?.name } == ["user", "name"])
+ #expect(feature.visibleVariableRows.map(\.depth) == [0, 1])
+ feature.toggleVariableExpansion(user)
+ #expect(feature.visibleVariableRows.compactMap { $0.variable?.name } == ["user"])
+ #expect(feature.variables.first?.name == "user")
+
+ feature.toggleBreakpointMute()
+ #expect(core.breakpointUpdates.last?.first?.enabled == true)
+ feature.removeAllBreakpoints()
+ #expect(feature.breakpoints.isEmpty)
+ #expect(core.breakpointUpdates.last?.isEmpty == true)
+
+ feature.stop()
+ #expect(feature.watches.first?.expression == "count + 1")
+ #expect(feature.watches.first?.value == nil)
+ }
+
+ @Test
+ func projectBreakpointsPersistWithRelativePathsAndRestoreDeterministically() throws {
+ let descriptor = DebugProviderDescriptor(
+ id: "java",
+ displayName: "Java",
+ fileExtensions: ["java"]
+ )
+ let persistence = RecordingBreakpointPersistence()
+ let root = URL(fileURLWithPath: "/tmp/persisted-java-breakpoints", isDirectory: true)
+ let main = root.appendingPathComponent("src/Main.java")
+ let service = root.appendingPathComponent("src/Service.java")
+ let manager = DebugAdapterSessionManager(providers: [descriptor]) { _, _ in nil }
+ let feature = GenericDebugFeatureModel(
+ sessions: manager,
+ breakpointPersistence: persistence
+ )
+
+ feature.openWorkspace(at: root)
+ feature.toggleBreakpoint(fileURL: service, line: 21)
+ feature.toggleBreakpoint(fileURL: main, line: 8)
+ feature.updateBreakpoint(
+ fileURL: main,
+ line: 8,
+ enabled: false,
+ condition: "ready",
+ hitCondition: "3",
+ logMessage: "ready = {ready}"
+ )
+ feature.toggleBreakpointMute()
+
+ let saved = try #require(persistence.snapshots[root.standardizedFileURL])
+ #expect(saved.areBreakpointsMuted)
+ #expect(saved.breakpoints.map(\.relativePath) == ["src/Main.java", "src/Service.java"])
+ #expect(saved.breakpoints.first == PersistedDebugBreakpoint(
+ relativePath: "src/Main.java",
+ line: 8,
+ enabled: false,
+ condition: "ready",
+ hitCondition: "3",
+ logMessage: "ready = {ready}"
+ ))
+
+ let restored = GenericDebugFeatureModel(
+ sessions: DebugAdapterSessionManager(providers: [descriptor]) { _, _ in nil },
+ breakpointPersistence: persistence
+ )
+ restored.openWorkspace(at: root)
+
+ #expect(restored.areBreakpointsMuted)
+ #expect(restored.breakpoints.map(\.fileURL) == [main, service])
+ #expect(restored.breakpoints.map(\.line) == [8, 21])
+ #expect(restored.breakpoints.first?.enabled == false)
+ #expect(restored.breakpoints.first?.verified == false)
+ }
+
+ @Test
+ func sourceEditRelocatesPersistsAndResynchronizesBreakpoints() throws {
+ let descriptor = DebugProviderDescriptor(
+ id: "java",
+ displayName: "Java",
+ fileExtensions: ["java"]
+ )
+ let session = DeferredInspectionDebugSession()
+ let manager = DebugAdapterSessionManager(providers: [descriptor]) { _, _ in session }
+ let persistence = RecordingBreakpointPersistence()
+ let relocator = RecordingBreakpointRelocator(result: [
+ DebugSourceBreakpoint(line: 3, condition: "ready")
+ ])
+ let root = URL(fileURLWithPath: "/tmp/relocated-java-breakpoints", isDirectory: true)
+ let sourceURL = root.appendingPathComponent("src/Main.java")
+ let feature = GenericDebugFeatureModel(
+ sessions: manager,
+ breakpointPersistence: persistence,
+ breakpointRelocator: relocator
+ )
+ feature.openWorkspace(at: root)
+ feature.toggleBreakpoint(fileURL: sourceURL, line: 2)
+ feature.updateBreakpoint(
+ fileURL: sourceURL,
+ line: 2,
+ enabled: true,
+ condition: "ready",
+ hitCondition: nil,
+ logMessage: nil
+ )
+ #expect(feature.start(
+ fileURL: sourceURL,
+ rootURL: root,
+ configuration: DebugLaunchConfiguration(
+ name: "Main",
+ request: .launch,
+ arguments: ["mainClass": .string("example.Main")]
+ )
+ ))
+ defer { feature.stop() }
+
+ let source = "class Main {\n void run() {}\n}\n"
+ feature.applySourceEdit(
+ fileURL: sourceURL,
+ source: source,
+ edit: DebugSourceEdit(
+ startUTF16Offset: 13,
+ endUTF16Offset: 13,
+ replacement: "\n"
+ )
+ )
+
+ #expect(relocator.requests == [RecordingBreakpointRelocationRequest(
+ source: source,
+ edit: DebugSourceEdit(
+ startUTF16Offset: 13,
+ endUTF16Offset: 13,
+ replacement: "\n"
+ ),
+ breakpoints: [DebugSourceBreakpoint(line: 2, condition: "ready")]
+ )])
+ #expect(feature.breakpoints.map(\.line) == [3])
+ #expect(session.breakpointUpdates.last == [
+ DebugSourceBreakpoint(line: 3, condition: "ready")
+ ])
+ #expect(persistence.snapshots[root.standardizedFileURL]?.breakpoints.map(\.line) == [3])
+ }
+
+ @Test
+ func inlineEditSkipsRelocationForLineOnlyBreakpoints() {
+ let descriptor = DebugProviderDescriptor(
+ id: "java",
+ displayName: "Java",
+ fileExtensions: ["java"]
+ )
+ let relocator = RecordingBreakpointRelocator(result: [])
+ let root = URL(fileURLWithPath: "/tmp/inline-java-breakpoint-edit", isDirectory: true)
+ let sourceURL = root.appendingPathComponent("src/Main.java")
+ let feature = GenericDebugFeatureModel(
+ sessions: DebugAdapterSessionManager(providers: [descriptor]) { _, _ in nil },
+ breakpointRelocator: relocator
+ )
+ feature.openWorkspace(at: root)
+ feature.toggleBreakpoint(fileURL: sourceURL, line: 2)
+ let source = "class Main {\n void run() {}\n}\n"
+ let editOffset = (source as NSString).range(of: "run").location + 3
+
+ feature.applySourceEdit(
+ fileURL: sourceURL,
+ source: source,
+ edit: DebugSourceEdit(
+ startUTF16Offset: editOffset,
+ endUTF16Offset: editOffset,
+ replacement: "Now"
+ )
+ )
+
+ #expect(relocator.requests.isEmpty)
+ #expect(feature.breakpoints.map(\.line) == [2])
+ }
+
+ @Test
+ func projectBreakpointRestoreRejectsPathsOutsideTheWorkspace() {
+ let root = URL(fileURLWithPath: "/tmp/safe-java-breakpoints", isDirectory: true)
+ let persistence = RecordingBreakpointPersistence()
+ persistence.snapshots[root.standardizedFileURL] = DebugBreakpointSnapshot(breakpoints: [
+ PersistedDebugBreakpoint(relativePath: "../Outside.java", line: 4),
+ PersistedDebugBreakpoint(relativePath: "/tmp/Absolute.java", line: 5),
+ PersistedDebugBreakpoint(relativePath: "src/Main.java", line: 6)
+ ])
+ let manager = DebugAdapterSessionManager(
+ providers: [DebugProviderDescriptor(
+ id: "java",
+ displayName: "Java",
+ fileExtensions: ["java"]
+ )]
+ ) { _, _ in nil }
+ let feature = GenericDebugFeatureModel(
+ sessions: manager,
+ breakpointPersistence: persistence
+ )
+
+ feature.openWorkspace(at: root)
+
+ #expect(feature.breakpoints.map(\.fileURL) == [root.appendingPathComponent("src/Main.java")])
+ #expect(feature.breakpoints.map(\.line) == [6])
+ }
+
@Test
func protocolSessionInitializesAndStopsThroughInjectedTransport() throws {
let transport = RecordingTransport()
@@ -21,14 +2341,160 @@ struct DebugModuleTests {
#expect(transport.isRunning)
let initialize = try #require(transport.request(named: "initialize"))
transport.emitJSON([
- "seq": 2,
+ "seq": 2,
+ "type": "response",
+ "request_seq": initialize["seq"] as! Int,
+ "success": true,
+ "command": "initialize",
+ "body": [
+ "supportsConfigurationDoneRequest": true,
+ "supportsFunctionBreakpoints": true,
+ "supportsDataBreakpoints": true,
+ "supportsSetVariable": true,
+ "supportsStepBack": true,
+ "supportsExceptionInfoRequest": false,
+ "supportsStepInTargetsRequest": true,
+ "supportsGotoTargetsRequest": true,
+ "supportsRestartRequest": true,
+ "supportsTerminateRequest": true
+ ]
+ ])
+ #expect(session.state == .ready)
+ session.setExceptionBreakpoints([
+ DebugExceptionBreakpoint(filter: "uncaught", enabled: true),
+ DebugExceptionBreakpoint(filter: "caught", enabled: false)
+ ])
+ session.setFunctionBreakpoints([
+ DebugFunctionBreakpoint(
+ name: "example.Main.run",
+ enabled: true,
+ condition: "ready",
+ hitCondition: "2"
+ ),
+ DebugFunctionBreakpoint(name: "example.Main.skip", enabled: false)
+ ])
+ session.setDataBreakpoints([
+ DebugDataBreakpoint(
+ dataID: "field:count",
+ label: "Main.count",
+ accessType: "write",
+ condition: "count > 1"
+ )
+ ])
+ transport.emitJSON([
+ "seq": 3,
+ "type": "event",
+ "event": "initialized"
+ ])
+ let exceptionRequest = try #require(transport.request(named: "setExceptionBreakpoints"))
+ let exceptionArguments = try #require(exceptionRequest["arguments"] as? [String: Any])
+ #expect(exceptionArguments["filters"] as? [String] == ["uncaught"])
+ let functionRequest = try #require(transport.request(named: "setFunctionBreakpoints"))
+ let functionArguments = try #require(functionRequest["arguments"] as? [String: Any])
+ let functionValues = try #require(functionArguments["breakpoints"] as? [[String: Any]])
+ #expect(functionValues.count == 1)
+ #expect(functionValues.first?["name"] as? String == "example.Main.run")
+ #expect(functionValues.first?["condition"] as? String == "ready")
+ #expect(functionValues.first?["hitCondition"] as? String == "2")
+ let dataRequest = try #require(transport.request(named: "setDataBreakpoints"))
+ let dataArguments = try #require(dataRequest["arguments"] as? [String: Any])
+ let dataValues = try #require(dataArguments["breakpoints"] as? [[String: Any]])
+ #expect(dataValues.first?["dataId"] as? String == "field:count")
+ #expect(dataValues.first?["accessType"] as? String == "write")
+ var dataInfo: Result?
+ session.requestDataBreakpointInfo(
+ name: "count",
+ variablesReference: 42,
+ frameID: 7
+ ) { dataInfo = $0 }
+ let dataInfoRequest = try #require(transport.request(named: "dataBreakpointInfo"))
+ transport.emitJSON([
+ "seq": 8,
+ "type": "response",
+ "request_seq": dataInfoRequest["seq"] as! Int,
+ "success": true,
+ "command": "dataBreakpointInfo",
+ "body": [
+ "dataId": "field:count",
+ "description": "Main.count",
+ "accessTypes": ["write"],
+ "canPersist": false
+ ]
+ ])
+ #expect(try dataInfo?.get().dataID == "field:count")
+ #expect(transport.request(named: "configurationDone") != nil)
+
+ transport.emitJSON([
+ "seq": 9,
+ "type": "event",
+ "event": "stopped",
+ "body": ["reason": "breakpoint", "threadId": 11]
+ ])
+ var setVariable: Result?
+ session.setVariable(
+ variablesReference: 42,
+ name: "count",
+ value: "7"
+ ) { setVariable = $0 }
+ let setVariableRequest = try #require(transport.request(named: "setVariable"))
+ let setVariableArguments = try #require(setVariableRequest["arguments"] as? [String: Any])
+ #expect(setVariableArguments["variablesReference"] as? Int == 42)
+ #expect(setVariableArguments["name"] as? String == "count")
+ #expect(setVariableArguments["value"] as? String == "7")
+ transport.emitJSON([
+ "seq": 10,
+ "type": "response",
+ "request_seq": setVariableRequest["seq"] as! Int,
+ "success": true,
+ "command": "setVariable",
+ "body": ["value": "7", "type": "int", "variablesReference": 0]
+ ])
+ #expect(try setVariable?.get().value == "7")
+ var stepTargets: Result<[DebugStepInTarget], Error>?
+ session.requestStepInTargets(frameID: 7) { stepTargets = $0 }
+ let stepTargetsRequest = try #require(transport.request(named: "stepInTargets"))
+ transport.emitJSON([
+ "seq": 10,
+ "type": "response",
+ "request_seq": stepTargetsRequest["seq"] as! Int,
+ "success": true,
+ "command": "stepInTargets",
+ "body": ["targets": [["id": 21, "label": "service.load()", "line": 12]]]
+ ])
+ #expect(try stepTargets?.get().first?.id == 21)
+ var gotoTargets: Result<[DebugGotoTarget], Error>?
+ session.requestGotoTargets(
+ fileURL: URL(fileURLWithPath: "/tmp/Main.java"),
+ line: 20,
+ column: 5
+ ) { gotoTargets = $0 }
+ let gotoTargetsRequest = try #require(transport.request(named: "gotoTargets"))
+ transport.emitJSON([
+ "seq": 11,
"type": "response",
- "request_seq": initialize["seq"] as! Int,
+ "request_seq": gotoTargetsRequest["seq"] as! Int,
"success": true,
- "command": "initialize",
- "body": ["supportsConfigurationDoneRequest": true]
+ "command": "gotoTargets",
+ "body": ["targets": [["id": 31, "label": "Main.java:20", "line": 20]]]
])
- #expect(session.state == .ready)
+ #expect(try gotoTargets?.get().first?.id == 31)
+ session.execute(.stepIn, threadID: 11, targetID: 21)
+ session.execute(.goto, threadID: 11, targetID: 31)
+ session.execute(.stepBack, threadID: 11)
+ session.execute(.restart, threadID: 11)
+ session.execute(.terminate, threadID: 11)
+ let stepBack = try #require(transport.request(named: "stepBack"))
+ let stepBackArguments = try #require(stepBack["arguments"] as? [String: Any])
+ #expect(stepBackArguments["threadId"] as? Int == 11)
+ #expect(stepBackArguments["singleThread"] as? Bool == false)
+ let smartStep = try #require(transport.request(named: "stepIn"))
+ #expect((smartStep["arguments"] as? [String: Any])?["targetId"] as? Int == 21)
+ let goto = try #require(transport.request(named: "goto"))
+ #expect((goto["arguments"] as? [String: Any])?["targetId"] as? Int == 31)
+ let restart = try #require(transport.request(named: "restart"))
+ #expect((restart["arguments"] as? [String: Any])?["threadId"] == nil)
+ let terminate = try #require(transport.request(named: "terminate"))
+ #expect((terminate["arguments"] as? [String: Any])?["threadId"] == nil)
session.stop()
@@ -37,6 +2503,21 @@ struct DebugModuleTests {
#expect(transport.stopCalls == 1)
}
+ @Test
+ func protocolSessionDisconnectTerminatesOnlyLaunchedDebuggees() throws {
+ let launchArguments = try disconnectArguments(for: .launch)
+ #expect(launchArguments["restart"] as? Bool == false)
+ #expect(launchArguments["terminateDebuggee"] as? Bool == true)
+
+ let attachArguments = try disconnectArguments(for: .attach)
+ #expect(attachArguments["restart"] as? Bool == false)
+ #expect(attachArguments["terminateDebuggee"] as? Bool == false)
+
+ let unstartedArguments = try disconnectArguments(for: nil)
+ #expect(unstartedArguments["restart"] as? Bool == false)
+ #expect(unstartedArguments["terminateDebuggee"] as? Bool == false)
+ }
+
@Test
func protocolSessionCreatesAndStopsChildTransport() throws {
let parent = RecordingTransport()
@@ -77,6 +2558,88 @@ struct DebugModuleTests {
#expect(child.stopCalls == 1)
}
+ @Test
+ func protocolSessionRemovesFinishedChildTransport() throws {
+ let parent = RecordingTransport()
+ let session = DebugAdapterProtocolSession(adapterID: "test-adapter", transport: parent)
+ let root = URL(fileURLWithPath: "/tmp/debug-child-cleanup", isDirectory: true)
+
+ try session.start(rootURL: root)
+ let initialize = try #require(parent.request(named: "initialize"))
+ parent.emitJSON([
+ "seq": 2,
+ "type": "response",
+ "request_seq": initialize["seq"] as! Int,
+ "success": true,
+ "command": "initialize",
+ "body": [:]
+ ])
+ parent.emitJSON([
+ "seq": 3,
+ "type": "request",
+ "command": "startDebugging",
+ "arguments": [
+ "configuration": [
+ "name": "Child",
+ "request": "launch",
+ "program": root.appendingPathComponent("main.js").path
+ ]
+ ]
+ ])
+
+ let child = try #require(parent.children.first)
+ child.terminate(0)
+ #expect(!child.isRunning)
+
+ session.stop()
+
+ // A terminated child is removed immediately, so parent shutdown does
+ // not retain or stop the same transport a second time.
+ #expect(child.stopCalls == 0)
+ }
+
+ @Test
+ func protocolSessionReportsLaunchFailureInDebugOutput() throws {
+ let transport = RecordingTransport()
+ let session = DebugAdapterProtocolSession(
+ adapterID: "test-adapter",
+ transport: transport
+ )
+ var events: [DebugAdapterEvent] = []
+ session.onEvent = { events.append($0) }
+
+ try session.start(rootURL: URL(fileURLWithPath: "/tmp/debug-launch-failure"))
+ let initialize = try #require(transport.request(named: "initialize"))
+ transport.emitJSON([
+ "seq": 2,
+ "type": "response",
+ "request_seq": initialize["seq"] as! Int,
+ "success": true,
+ "command": "initialize",
+ "body": [:]
+ ])
+ try session.launch(DebugLaunchConfiguration(
+ name: "Broken Main",
+ request: .launch,
+ arguments: ["program": .string("/tmp/missing-main")]
+ ))
+ let launch = try #require(transport.request(named: "launch"))
+ transport.emitJSON([
+ "seq": 3,
+ "type": "response",
+ "request_seq": launch["seq"] as! Int,
+ "success": false,
+ "command": "launch",
+ "message": "main class was not found"
+ ])
+
+ #expect(session.state == .failed)
+ #expect(events.contains(.output(
+ category: "stderr",
+ output: "Debug launch failed: launch failed: main class was not found\n"
+ )))
+ }
+
@Test
func disabledDebugDoesNotConstructGraph() async throws {
let recorder = Recorder()
@@ -119,7 +2682,7 @@ struct DebugModuleTests {
let first = try #require(
try await runtime.activateCapability(.debugWorkspace) as? DebugModuleCapability
)
- let firstJavaID = ObjectIdentifier(first.javaFeature)
+ let firstFeatureID = ObjectIdentifier(first.genericFeature)
weak var released = recorder.latestGraph
try await runtime.sleep(.debug)
@@ -130,7 +2693,7 @@ struct DebugModuleTests {
let second = try #require(
try await runtime.activateCapability(.debugWorkspace) as? DebugModuleCapability
)
- #expect(ObjectIdentifier(second.javaFeature) != firstJavaID)
+ #expect(ObjectIdentifier(second.genericFeature) != firstFeatureID)
#expect(recorder.factoryCalls == 2)
#expect(recorder.graphCalls == 2)
}
@@ -152,6 +2715,80 @@ struct DebugModuleTests {
EmptyModule(id: .execution, name: "Execution")
}
}
+
+ private func makeDeferredFeature(
+ session: DeferredInspectionDebugSession,
+ rootPath: String
+ ) -> GenericDebugFeatureModel {
+ let descriptor = DebugProviderDescriptor(
+ id: "java",
+ displayName: "Java",
+ fileExtensions: ["java"]
+ )
+ let manager = DebugAdapterSessionManager(providers: [descriptor]) { _, _ in session }
+ let feature = GenericDebugFeatureModel(sessions: manager)
+ let root = URL(fileURLWithPath: rootPath, isDirectory: true)
+ let source = root.appendingPathComponent("src/Main.java")
+ precondition(feature.start(
+ fileURL: source,
+ rootURL: root,
+ configuration: DebugLaunchConfiguration(
+ name: "Main",
+ request: .launch,
+ arguments: ["mainClass": .string("example.Main")]
+ )
+ ))
+ return feature
+ }
+
+ private func indexedVariables(
+ _ range: Range,
+ idPrefix: String = "item"
+ ) -> [DebugVariable] {
+ range.map { index in
+ DebugVariable(
+ id: "\(idPrefix)-\(index)",
+ name: "[\(index)]",
+ value: "Item@\(index)",
+ type: "example.Item",
+ evaluateName: nil,
+ variablesReference: 0
+ )
+ }
+ }
+
+ private func disconnectArguments(
+ for requestKind: DebugRequestKind?
+ ) throws -> [String: Any] {
+ let transport = RecordingTransport()
+ let session = DebugAdapterProtocolSession(
+ adapterID: "test-adapter",
+ transport: transport
+ )
+ try session.start(rootURL: URL(fileURLWithPath: "/tmp/debug-disconnect"))
+ defer {
+ if session.isRunning { session.stop() }
+ }
+ let initialize = try #require(transport.request(named: "initialize"))
+ transport.emitJSON([
+ "seq": 2,
+ "type": "response",
+ "request_seq": initialize["seq"] as! Int,
+ "success": true,
+ "command": "initialize",
+ "body": [:]
+ ])
+ if let requestKind {
+ try session.launch(DebugLaunchConfiguration(
+ name: "Disconnect Policy",
+ request: requestKind,
+ arguments: [:]
+ ))
+ }
+ session.stop()
+ let disconnect = try #require(transport.request(named: "disconnect"))
+ return try #require(disconnect["arguments"] as? [String: Any])
+ }
}
@MainActor
@@ -177,6 +2814,11 @@ private final class RecordingTransport: DebugAdapterTransport, DebugAdapterChild
isRunning = false
}
+ func terminate(_ exitCode: Int) {
+ isRunning = false
+ onTermination?(exitCode)
+ }
+
func makeChildTransport() -> (any DebugAdapterTransport)? {
let child = RecordingTransport()
children.append(child)
@@ -190,6 +2832,10 @@ private final class RecordingTransport: DebugAdapterTransport, DebugAdapterChild
onData?(frame)
}
+ func emitData(_ data: Data) {
+ onData?(data)
+ }
+
func request(named command: String) -> [String: Any]? {
messages.first {
$0["type"] as? String == "request" && $0["command"] as? String == command
@@ -213,6 +2859,593 @@ private final class RecordingTransport: DebugAdapterTransport, DebugAdapterChild
}
}
+private final class RecordingBreakpointPersistence: DebugBreakpointPersisting, @unchecked Sendable {
+ var snapshots: [URL: DebugBreakpointSnapshot] = [:]
+
+ func loadBreakpoints(for workspaceURL: URL) throws -> DebugBreakpointSnapshot? {
+ snapshots[workspaceURL.standardizedFileURL]
+ }
+
+ func saveBreakpoints(_ snapshot: DebugBreakpointSnapshot, for workspaceURL: URL) throws {
+ snapshots[workspaceURL.standardizedFileURL] = snapshot
+ }
+}
+
+private struct RecordingBreakpointRelocationRequest: Equatable {
+ let source: String
+ let edit: DebugSourceEdit
+ let breakpoints: [DebugSourceBreakpoint]
+}
+
+@MainActor
+private final class RecordingBreakpointRelocator: DebugBreakpointRelocating {
+ let result: [DebugSourceBreakpoint]
+ private(set) var requests: [RecordingBreakpointRelocationRequest] = []
+
+ init(result: [DebugSourceBreakpoint]) {
+ self.result = result
+ }
+
+ func relocateDebugBreakpoints(
+ source: String,
+ edit: DebugSourceEdit,
+ breakpoints: [DebugSourceBreakpoint]
+ ) throws -> [DebugSourceBreakpoint] {
+ requests.append(RecordingBreakpointRelocationRequest(
+ source: source,
+ edit: edit,
+ breakpoints: breakpoints
+ ))
+ return result
+ }
+}
+
+private struct RecordingDebugSteppingFilterResolution: Equatable {
+ let adapterID: String
+ let filters: DebugSteppingFilters?
+}
+
+@MainActor
+private final class RecordingDebugSteppingFilterResolver: DebugSteppingFilterResolving {
+ let defaults: DebugSteppingFilters
+ let normalizedOverride: DebugSteppingFilters
+ private(set) var requests: [RecordingDebugSteppingFilterResolution] = []
+
+ init(defaults: DebugSteppingFilters, normalizedOverride: DebugSteppingFilters) {
+ self.defaults = defaults
+ self.normalizedOverride = normalizedOverride
+ }
+
+ func resolveDebugSteppingFilters(
+ adapterID: String,
+ filters: DebugSteppingFilters?
+ ) throws -> DebugSteppingFilters {
+ requests.append(RecordingDebugSteppingFilterResolution(
+ adapterID: adapterID,
+ filters: filters
+ ))
+ return filters == nil ? defaults : normalizedOverride
+ }
+}
+
+private final class RecordingDebugSteppingFilterPersistence:
+ DebugSteppingFilterPersisting,
+ @unchecked Sendable
+{
+ var filtersByAdapterID: [String: DebugSteppingFilters] = [:]
+
+ func loadSteppingFilters(adapterID: String) throws -> DebugSteppingFilters? {
+ filtersByAdapterID[adapterID]
+ }
+
+ func saveSteppingFilters(_ filters: DebugSteppingFilters, adapterID: String) throws {
+ filtersByAdapterID[adapterID] = filters
+ }
+}
+
+private enum DebugSteppingFilterPersistenceTestError: Error {
+ case unreadable
+}
+
+private enum DeferredDebugSessionError: Error {
+ case launchFailed
+}
+
+private final class FailingDebugSteppingFilterPersistence:
+ DebugSteppingFilterPersisting,
+ @unchecked Sendable
+{
+ func loadSteppingFilters(adapterID _: String) throws -> DebugSteppingFilters? {
+ throw DebugSteppingFilterPersistenceTestError.unreadable
+ }
+
+ func saveSteppingFilters(_: DebugSteppingFilters, adapterID _: String) throws {}
+}
+
+private struct RecordingDebugInspectionRequest: Equatable {
+ let operationID: String
+ let kind: String
+ let threadID: Int?
+ let frameID: Int?
+ let variablesReference: Int?
+ let variableFilter: DebugVariableFilter?
+ let start: Int?
+ let count: Int?
+}
+
+private struct RecordingDebugVariablePageRequest: Equatable {
+ let reference: Int
+ let filter: DebugVariableFilter?
+ let start: Int?
+ let count: Int?
+}
+
+@MainActor
+private final class DeferredInspectionDebugSession: DebugAdapterControllingSession, DebugAdapterRunInTerminalSession {
+ let capabilities: DebugAdapterCapabilities
+ private(set) var isRunning = false
+ private(set) var state: DebugAdapterState = .idle
+ private(set) var startCount = 0
+ private(set) var launchConfigurations: [DebugLaunchConfiguration] = []
+ private(set) var breakpointUpdates: [[DebugSourceBreakpoint]] = []
+ private(set) var executionCommands: [DebugExecutionCommand] = []
+ var failNextLaunch = false
+ var onStateChange: ((DebugAdapterState) -> Void)?
+ var onEvent: ((DebugAdapterEvent) -> Void)?
+ var onRunInTerminalRequest: DebugRunInTerminalRequestHandler?
+
+ private var stackTraceRequests: [(
+ threadID: Int,
+ completion: (Result<[DebugStackFrame], Error>) -> Void
+ )] = []
+ private var scopeRequests: [(
+ frameID: Int,
+ completion: (Result<[DebugScope], Error>) -> Void
+ )] = []
+ private var variableRequests: [(
+ reference: Int,
+ filter: DebugVariableFilter?,
+ start: Int?,
+ count: Int?,
+ completion: (Result<[DebugVariable], Error>) -> Void
+ )] = []
+ private var exceptionInfoRequests: [(
+ threadID: Int,
+ completion: (Result) -> Void
+ )] = []
+ private var evaluateRequests: [(
+ expression: String,
+ frameID: Int?,
+ completion: (Result) -> Void
+ )] = []
+
+ var stackTraceThreadIDs: [Int] { stackTraceRequests.map(\.threadID) }
+ var scopeFrameIDs: [Int] { scopeRequests.map(\.frameID) }
+ var variableReferences: [Int] { variableRequests.map(\.reference) }
+ var variablePageRequests: [RecordingDebugVariablePageRequest] {
+ variableRequests.map {
+ RecordingDebugVariablePageRequest(
+ reference: $0.reference,
+ filter: $0.filter,
+ start: $0.start,
+ count: $0.count
+ )
+ }
+ }
+ var exceptionInfoThreadIDs: [Int] { exceptionInfoRequests.map(\.threadID) }
+ var evaluateExpressions: [String] { evaluateRequests.map(\.expression) }
+ var evaluateFrameIDs: [Int?] { evaluateRequests.map(\.frameID) }
+
+ init(capabilities: DebugAdapterCapabilities = .unknown) {
+ self.capabilities = capabilities
+ }
+
+ func start(rootURL _: URL) throws {
+ startCount += 1
+ isRunning = true
+ state = .ready
+ }
+
+ func stop() {
+ isRunning = false
+ state = .idle
+ }
+
+ func launch(_ configuration: DebugLaunchConfiguration) throws {
+ launchConfigurations.append(configuration)
+ if failNextLaunch {
+ failNextLaunch = false
+ throw DeferredDebugSessionError.launchFailed
+ }
+ state = .paused
+ onStateChange?(.paused)
+ }
+
+ func fail() {
+ isRunning = false
+ state = .failed
+ onStateChange?(.failed)
+ }
+
+ func transition(to state: DebugAdapterState) {
+ self.state = state
+ onStateChange?(state)
+ }
+
+ func setBreakpoints(_ breakpoints: [DebugSourceBreakpoint], in _: URL) {
+ breakpointUpdates.append(breakpoints)
+ }
+ func execute(_ command: DebugExecutionCommand, threadID _: Int?) {
+ executionCommands.append(command)
+ }
+ func requestThreads(_: @escaping (Result<[DebugThread], Error>) -> Void) {}
+
+ func requestExceptionInfo(
+ threadID: Int,
+ completion: @escaping (Result) -> Void
+ ) {
+ exceptionInfoRequests.append((threadID, completion))
+ }
+
+ func requestStackTrace(
+ threadID: Int,
+ completion: @escaping (Result<[DebugStackFrame], Error>) -> Void
+ ) {
+ stackTraceRequests.append((threadID, completion))
+ }
+
+ func requestScopes(
+ frameID: Int,
+ completion: @escaping (Result<[DebugScope], Error>) -> Void
+ ) {
+ scopeRequests.append((frameID, completion))
+ }
+
+ func requestVariables(
+ reference: Int,
+ completion: @escaping (Result<[DebugVariable], Error>) -> Void
+ ) {
+ requestVariables(
+ reference: reference,
+ filter: nil,
+ start: nil,
+ count: nil,
+ completion: completion
+ )
+ }
+
+ func requestVariables(
+ reference: Int,
+ filter: DebugVariableFilter?,
+ start: Int?,
+ count: Int?,
+ completion: @escaping (Result<[DebugVariable], Error>) -> Void
+ ) {
+ variableRequests.append((reference, filter, start, count, completion))
+ }
+
+ func evaluate(
+ _ expression: String,
+ frameID: Int?,
+ completion: @escaping (Result) -> Void
+ ) {
+ evaluateRequests.append((expression, frameID, completion))
+ }
+
+ // This double deliberately delivers responses after cancellation to model
+ // adapters and callback queues that cannot retract an already-sent result.
+ func cancelPendingOperations() {}
+
+ func emit(_ event: DebugAdapterEvent) {
+ onEvent?(event)
+ }
+
+ func emitRunInTerminalRequest(_ request: DebugRunInTerminalRequest) {
+ onRunInTerminalRequest?(request) { _ in }
+ }
+
+ func completeStackTrace(at index: Int, with frames: [DebugStackFrame]) {
+ stackTraceRequests[index].completion(.success(frames))
+ }
+
+ func completeScopes(at index: Int, with scopes: [DebugScope]) {
+ scopeRequests[index].completion(.success(scopes))
+ }
+
+ func completeVariables(at index: Int, with variables: [DebugVariable]) {
+ variableRequests[index].completion(.success(variables))
+ }
+
+ func completeExceptionInfo(at index: Int, with info: DebugExceptionInfo) {
+ exceptionInfoRequests[index].completion(.success(info))
+ }
+
+ func completeEvaluation(at index: Int, with result: Result) {
+ evaluateRequests[index].completion(result)
+ }
+}
+
+@MainActor
+private final class RecordingDebugProtocolCore: DebugProtocolCore {
+ private var receiveUpdates: [DebugCoreUpdate] = []
+ private(set) var lastInspectionOperationID: String?
+ private(set) var lastDataBreakpointInfoOperationID: String?
+ private(set) var lastSetVariableOperationID: String?
+ private(set) var destroyedSessionIDs: [String] = []
+ private(set) var breakpointUpdates: [[DebugSourceBreakpoint]] = []
+ private(set) var exceptionBreakpointUpdates: [[DebugExceptionBreakpoint]] = []
+ private(set) var functionBreakpointUpdates: [[DebugFunctionBreakpoint]] = []
+ private(set) var dataBreakpointUpdates: [[DebugDataBreakpoint]] = []
+ private(set) var cancelledOperationReasons: [String] = []
+ private(set) var lastExecutionSingleThread: Bool?
+ private(set) var lastExecutionThreadID: Int?
+ private(set) var inspectionRequests: [RecordingDebugInspectionRequest] = []
+ private(set) var lastLaunchConfiguration: DebugLaunchConfiguration?
+ private(set) var lastSupportsRunInTerminalRequest: Bool?
+ private(set) var runInTerminalCompletions: [RecordingRunInTerminalCompletion] = []
+ let defaultSteppingFilters = DebugSteppingFilters(
+ classNameFilters: ["$JDK", "org.junit.*"],
+ skipSynthetics: true,
+ skipStaticInitializers: true,
+ skipConstructors: false,
+ hideFilteredStackFrames: true
+ )
+
+ func resolveDebugSteppingFilters(
+ adapterID _: String,
+ filters: DebugSteppingFilters?
+ ) throws -> DebugSteppingFilters {
+ filters ?? defaultSteppingFilters
+ }
+
+ func createDebugSession(
+ sessionID: String,
+ adapterID _: String,
+ rootPath _: String,
+ supportsRunInTerminalRequest: Bool
+ ) throws -> DebugCoreUpdate {
+ lastSupportsRunInTerminalRequest = supportsRunInTerminalRequest
+ return update(
+ sessionID: sessionID,
+ state: "initializing",
+ frames: [Data("initialize-frame".utf8)]
+ )
+ }
+
+ func launchDebugSession(
+ sessionID: String,
+ operationID _: String,
+ configuration: DebugLaunchConfiguration
+ ) throws -> DebugCoreUpdate {
+ lastLaunchConfiguration = configuration
+ return update(sessionID: sessionID, state: "launching")
+ }
+
+ func setDebugBreakpoints(
+ sessionID: String,
+ sourcePath _: String,
+ breakpoints: [DebugSourceBreakpoint]
+ ) throws -> DebugCoreUpdate {
+ breakpointUpdates.append(breakpoints)
+ return update(sessionID: sessionID, state: "ready")
+ }
+
+ func setDebugExceptionBreakpoints(
+ sessionID: String,
+ breakpoints: [DebugExceptionBreakpoint]
+ ) throws -> DebugCoreUpdate {
+ exceptionBreakpointUpdates.append(breakpoints)
+ return update(sessionID: sessionID, state: "ready")
+ }
+
+ func setDebugFunctionBreakpoints(
+ sessionID: String,
+ breakpoints: [DebugFunctionBreakpoint]
+ ) throws -> DebugCoreUpdate {
+ functionBreakpointUpdates.append(breakpoints)
+ return update(sessionID: sessionID, state: "ready")
+ }
+
+ func debugDataBreakpointInfo(
+ sessionID: String,
+ operationID: String,
+ name _: String,
+ variablesReference _: Int?,
+ frameID _: Int?
+ ) throws -> DebugCoreUpdate {
+ lastDataBreakpointInfoOperationID = operationID
+ return update(sessionID: sessionID, state: "paused")
+ }
+
+ func setDebugDataBreakpoints(
+ sessionID: String,
+ breakpoints: [DebugDataBreakpoint]
+ ) throws -> DebugCoreUpdate {
+ dataBreakpointUpdates.append(breakpoints)
+ return update(sessionID: sessionID, state: "paused")
+ }
+
+ func setDebugVariable(
+ sessionID: String,
+ operationID: String,
+ variablesReference _: Int,
+ name _: String,
+ value _: String
+ ) throws -> DebugCoreUpdate {
+ lastSetVariableOperationID = operationID
+ return update(sessionID: sessionID, state: "paused")
+ }
+
+ func cancelDebugOperation(
+ sessionID: String,
+ operationID: String,
+ reason: String
+ ) throws -> DebugCoreUpdate {
+ cancelledOperationReasons.append(reason)
+ return update(sessionID: sessionID, state: "paused", events: [[
+ "sequence": 90,
+ "type": "operationFailed",
+ "operationId": operationID,
+ "command": "threads",
+ "code": reason,
+ "message": reason == "timedOut"
+ ? "Debug operation timed out."
+ : "Debug operation was cancelled."
+ ]])
+ }
+
+ func executeDebugCommand(
+ sessionID: String,
+ operationID _: String,
+ command _: DebugExecutionCommand,
+ threadID: Int?,
+ targetID _: Int?,
+ singleThread: Bool
+ ) throws -> DebugCoreUpdate {
+ lastExecutionThreadID = threadID
+ lastExecutionSingleThread = singleThread
+ return update(sessionID: sessionID, state: "running")
+ }
+
+ func inspectDebugSession(
+ sessionID: String,
+ operationID: String,
+ kind: String,
+ threadID: Int?,
+ frameID: Int?,
+ variablesReference: Int?,
+ variableFilter: DebugVariableFilter?,
+ start: Int?,
+ count: Int?,
+ expression _: String?,
+ sourcePath _: String?,
+ line _: Int?,
+ column _: Int?
+ ) throws -> DebugCoreUpdate {
+ lastInspectionOperationID = operationID
+ inspectionRequests.append(RecordingDebugInspectionRequest(
+ operationID: operationID,
+ kind: kind,
+ threadID: threadID,
+ frameID: frameID,
+ variablesReference: variablesReference,
+ variableFilter: variableFilter,
+ start: start,
+ count: count
+ ))
+ return update(sessionID: sessionID, state: "paused")
+ }
+
+ func receiveDebugData(sessionID _: String, data _: Data) throws -> DebugCoreUpdate {
+ receiveUpdates.removeFirst()
+ }
+
+ func completeDebugRunInTerminalRequest(
+ sessionID: String,
+ requestID: String,
+ result: Result
+ ) throws -> DebugCoreUpdate {
+ switch result {
+ case .success(let response):
+ runInTerminalCompletions.append(RecordingRunInTerminalCompletion(
+ requestID: requestID,
+ response: response,
+ errorDescription: nil
+ ))
+ case .failure(let error):
+ runInTerminalCompletions.append(RecordingRunInTerminalCompletion(
+ requestID: requestID,
+ response: nil,
+ errorDescription: error.localizedDescription
+ ))
+ }
+ return update(
+ sessionID: sessionID,
+ state: "launching",
+ frames: [Data("run-in-terminal-response".utf8)]
+ )
+ }
+
+ func disconnectDebugSession(sessionID: String) throws -> DebugCoreUpdate {
+ update(sessionID: sessionID, state: "terminating")
+ }
+
+ func destroyDebugSession(sessionID: String) {
+ destroyedSessionIDs.append(sessionID)
+ }
+
+ func enqueueReceive(
+ sessionID: String = "java-session",
+ state: String,
+ events: [[String: Any]]
+ ) {
+ receiveUpdates.append(update(
+ sessionID: sessionID,
+ state: state,
+ events: events
+ ))
+ }
+
+ private func update(
+ sessionID: String,
+ state: String,
+ frames: [Data] = [],
+ events: [[String: Any]] = []
+ ) -> DebugCoreUpdate {
+ let object: [String: Any] = [
+ "sessionId": sessionID,
+ "state": state,
+ "outboundFrames": frames.map { $0.base64EncodedString() },
+ "events": events
+ ]
+ let data = try! JSONSerialization.data(withJSONObject: object)
+ return try! JSONDecoder().decode(DebugCoreUpdate.self, from: data)
+ }
+}
+
+private struct RecordingRunInTerminalCompletion: Equatable {
+ let requestID: String
+ let response: DebugRunInTerminalResponse?
+ let errorDescription: String?
+}
+
+@MainActor
+private final class RecordingDebugDeadlineScheduler: DebugOperationDeadlineScheduling {
+ private var deadlines: [RecordingDebugDeadline] = []
+
+ func schedule(
+ afterMilliseconds _: Int,
+ action: @escaping @MainActor () -> Void
+ ) -> any DebugOperationDeadline {
+ let deadline = RecordingDebugDeadline(action: action)
+ deadlines.append(deadline)
+ return deadline
+ }
+
+ func fireLast() {
+ deadlines.last?.fire()
+ }
+}
+
+@MainActor
+private final class RecordingDebugDeadline: DebugOperationDeadline {
+ private var action: (@MainActor () -> Void)?
+
+ init(action: @escaping @MainActor () -> Void) {
+ self.action = action
+ }
+
+ func cancel() {
+ action = nil
+ }
+
+ func fire() {
+ let action = action
+ self.action = nil
+ action?()
+ }
+}
+
@MainActor private final class Recorder {
var factoryCalls = 0
var graphCalls = 0
@@ -220,7 +3453,6 @@ private final class RecordingTransport: DebugAdapterTransport, DebugAdapterChild
}
@MainActor private final class TestGraph: DebugServiceGraph {
- let javaFeatureTarget: any JavaDebugFeatureTarget = TestJavaDebugFeatureTarget()
let genericFeatureTarget: any GenericDebugFeatureTarget = TestGenericDebugFeatureTarget()
var hasActiveDebugWork = false
func activate(context: ModuleContext) {}
@@ -228,7 +3460,6 @@ private final class RecordingTransport: DebugAdapterTransport, DebugAdapterChild
func stop() async {}
}
-@MainActor private final class TestJavaDebugFeatureTarget: JavaDebugFeatureTarget {}
@MainActor private final class TestGenericDebugFeatureTarget: GenericDebugFeatureTarget {}
@MainActor private final class EmptyModule: LitheModule {
diff --git a/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift b/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift
index bc6af7cb7..17774e0d3 100644
--- a/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift
+++ b/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift
@@ -7,6 +7,59 @@ import Testing
@MainActor
struct ExecutionModuleTests {
+ @Test
+ func configuredServerPortUsesArgumentsEnvironmentResourcesAndFrameworkDefault() async throws {
+ let root = URL(fileURLWithPath: "/workspace/service-port", isDirectory: true)
+ let properties = root.appendingPathComponent("src/main/resources/application.properties")
+ let configuration = RunConfiguration(
+ id: "spring:api",
+ name: "API",
+ kind: .springBoot,
+ modulePath: ".",
+ mainClass: "example.Application"
+ )
+
+ let argumentService = makeRunService(
+ configuration: configuration,
+ options: RunOptions(
+ vmArguments: "-Dserver.port=18081",
+ programArguments: "--server.port=18082",
+ environment: ["SERVER_PORT": "18083"]
+ ),
+ fileAccess: TestRunFileAccess(contents: [properties: "server.port=18084"]),
+ serverPortParser: FixedServerPortParser(port: 18084)
+ )
+ await argumentService.loadProject(at: root, files: [properties], mavenProject: nil)
+ #expect(argumentService.configuredServerPort(for: configuration) == 18082)
+
+ let environmentService = makeRunService(
+ configuration: configuration,
+ options: RunOptions(environment: ["SERVER_PORT": "18083"]),
+ fileAccess: TestRunFileAccess(contents: [properties: "server.port=18084"]),
+ serverPortParser: FixedServerPortParser(port: 18084)
+ )
+ await environmentService.loadProject(at: root, files: [properties], mavenProject: nil)
+ #expect(environmentService.configuredServerPort(for: configuration) == 18083)
+
+ let resourceService = makeRunService(
+ configuration: configuration,
+ options: RunOptions(),
+ fileAccess: TestRunFileAccess(contents: [properties: "server.port=18084"]),
+ serverPortParser: FixedServerPortParser(port: 18084)
+ )
+ await resourceService.loadProject(at: root, files: [properties], mavenProject: nil)
+ #expect(resourceService.configuredServerPort(for: configuration) == 18084)
+
+ let defaultService = makeRunService(
+ configuration: configuration,
+ options: RunOptions(),
+ fileAccess: TestRunFileAccess(),
+ serverPortParser: FixedServerPortParser(port: nil)
+ )
+ await defaultService.loadProject(at: root, files: [], mavenProject: nil)
+ #expect(defaultService.configuredServerPort(for: configuration) == 8080)
+ }
+
@Test
func disabledExecutionDoesNotConstructGraph() async throws {
let recorder = Recorder()
@@ -47,6 +100,148 @@ struct ExecutionModuleTests {
#expect(recorder.graphCalls == 2)
}
+ /// Run and Debug can reach identification before the workspace snapshot has
+ /// bound a project. Reporting nothing at all made the confirmed dialog look
+ /// like a dead button, so the unloaded project must become visible state.
+ @Test
+ func identificationBeforeProjectLoadReportsUnloadedProjectWithoutGenerating() async throws {
+ let operations = RecordingRunConfigurationOperations()
+ let service = RunService(
+ runtime: TestRuntime(),
+ process: TestStreamingProcess(),
+ processFactory: { TestStreamingProcess() },
+ fileAccess: TestRunFileAccess(),
+ preferences: TestRunPreferences(),
+ serverPortParser: TestServerPortParser(),
+ runConfigurationOperations: operations,
+ executableResolver: TestExecutableResolver(),
+ languageProviderCatalog: .compatibilityFallback,
+ languageRunProviders: .standard(catalog: .compatibilityFallback)
+ )
+
+ #expect(service.projectLoadState == .idle)
+ await service.generateRunConfigurations()
+
+ #expect(service.generationState == .projectNotReady)
+ #expect(operations.generateCallCount == 0)
+ #expect(service.configurationStatus == .missing)
+ }
+
+ /// Once the project is bound, identification must behave exactly as before.
+ @Test
+ func identificationAfterProjectLoadGeneratesAndClearsTheUnloadedState() async throws {
+ let operations = RecordingRunConfigurationOperations()
+ let service = RunService(
+ runtime: TestRuntime(),
+ process: TestStreamingProcess(),
+ processFactory: { TestStreamingProcess() },
+ fileAccess: TestRunFileAccess(),
+ preferences: TestRunPreferences(),
+ serverPortParser: TestServerPortParser(),
+ runConfigurationOperations: operations,
+ executableResolver: TestExecutableResolver(),
+ languageProviderCatalog: .compatibilityFallback,
+ languageRunProviders: .standard(catalog: .compatibilityFallback)
+ )
+ let root = URL(fileURLWithPath: "/workspace", isDirectory: true)
+
+ await service.generateRunConfigurations()
+ #expect(service.generationState == .projectNotReady)
+
+ // Binding without a snapshot only unlocks reading existing configuration.
+ await service.loadProject(at: root, files: [], mavenProject: nil)
+ #expect(service.projectLoadState == .bound(workspace: root))
+ #expect(!service.isProjectReady(for: root, snapshotID: UUID()))
+ await service.generateRunConfigurations()
+ #expect(service.generationState == .projectNotReady)
+ #expect(operations.generateCallCount == 0)
+
+ let snapshotID = UUID()
+ await service.loadProject(at: root, files: [], mavenProject: nil, snapshotID: snapshotID)
+ #expect(service.projectLoadState == .ready(workspace: root, snapshotID: snapshotID))
+ #expect(service.isProjectReady(for: root, snapshotID: snapshotID))
+ // A superseded snapshot of the same workspace is not ready.
+ #expect(!service.isProjectReady(for: root, snapshotID: UUID()))
+ await service.generateRunConfigurations()
+
+ #expect(operations.generateCallCount == 1)
+ #expect(service.generationState == .succeeded(entryCount: 1))
+ #expect(service.configurationStatus == .ready)
+ }
+
+ /// Generation scans the inventory the service holds, so a workspace that was
+ /// bound before its snapshot arrived must not be scanned with the provisional
+ /// list. Doing so writes a configuration that omits real entry points.
+ @Test
+ func generationScansTheSnapshotInventoryAndNeverAProvisionalOne() async throws {
+ let operations = RecordingRunConfigurationOperations()
+ let service = RunService(
+ runtime: TestRuntime(),
+ process: TestStreamingProcess(),
+ processFactory: { TestStreamingProcess() },
+ fileAccess: TestRunFileAccess(),
+ preferences: TestRunPreferences(),
+ serverPortParser: TestServerPortParser(),
+ runConfigurationOperations: operations,
+ executableResolver: TestExecutableResolver(),
+ languageProviderCatalog: .compatibilityFallback,
+ languageRunProviders: .standard(catalog: .compatibilityFallback)
+ )
+ let root = URL(fileURLWithPath: "/workspace", isDirectory: true)
+ let source = root.appendingPathComponent("src/main/java/demo/App.java")
+
+ // The workspace snapshot has not arrived, so the inventory is empty.
+ await service.loadProject(at: root, files: [], mavenProject: nil)
+ await service.generateRunConfigurations()
+ #expect(service.generationState == .projectNotReady)
+ #expect(operations.generatedInventories.isEmpty, "a provisional inventory must not be scanned")
+
+ await service.loadProject(
+ at: root,
+ files: [source],
+ mavenProject: nil,
+ snapshotID: UUID()
+ )
+ await service.generateRunConfigurations()
+
+ #expect(service.generationState == .succeeded(entryCount: 1))
+ #expect(
+ operations.generatedInventories == [[source]],
+ "generation must scan exactly the inventory the snapshot reported"
+ )
+ }
+
+ /// A broken configuration must stay regenerable. Inventory readiness and
+ /// configuration validity are separate concerns, so an unreadable
+ /// `generated.json` must not make the project un-ready and lock the user out
+ /// of the only action that repairs it.
+ @Test
+ func unreadableConfigurationStillAllowsRegeneration() async throws {
+ let operations = FailingInspectionRunConfigurationOperations()
+ let service = RunService(
+ runtime: TestRuntime(),
+ process: TestStreamingProcess(),
+ processFactory: { TestStreamingProcess() },
+ fileAccess: TestRunFileAccess(),
+ preferences: TestRunPreferences(),
+ serverPortParser: TestServerPortParser(),
+ runConfigurationOperations: operations,
+ executableResolver: TestExecutableResolver(),
+ languageProviderCatalog: .compatibilityFallback,
+ languageRunProviders: .standard(catalog: .compatibilityFallback)
+ )
+ let root = URL(fileURLWithPath: "/workspace", isDirectory: true)
+ let snapshotID = UUID()
+
+ await service.loadProject(at: root, files: [], mavenProject: nil, snapshotID: snapshotID)
+
+ #expect(service.configurationStatus == .invalid("generated.json is invalid"))
+ #expect(service.isProjectReady(for: root, snapshotID: snapshotID))
+
+ await service.generateRunConfigurations()
+ #expect(service.generationState != .projectNotReady)
+ }
+
@Test
func currentGoFileRunsThroughExtensionOwnedSession() async throws {
let builtInProcess = TestStreamingProcess()
@@ -368,6 +563,30 @@ struct ExecutionModuleTests {
}
}
+@MainActor
+private func makeRunService(
+ configuration: RunConfiguration,
+ options: RunOptions,
+ fileAccess: TestRunFileAccess,
+ serverPortParser: FixedServerPortParser
+) -> RunService {
+ RunService(
+ runtime: TestRuntime(),
+ process: TestStreamingProcess(),
+ processFactory: { TestStreamingProcess() },
+ fileAccess: fileAccess,
+ preferences: TestRunPreferences(),
+ serverPortParser: serverPortParser,
+ runConfigurationOperations: SingleRunConfigurationOperations(
+ configuration: configuration,
+ options: options
+ ),
+ executableResolver: TestExecutableResolver(),
+ languageProviderCatalog: .compatibilityFallback,
+ languageRunProviders: .standard(catalog: .compatibilityFallback)
+ )
+}
+
@MainActor private final class Recorder {
var factoryCalls = 0
var graphCalls = 0
@@ -608,8 +827,16 @@ private final class MavenRecordingProcess: StreamingProcess, @unchecked Sendable
}
private struct TestRunFileAccess: RunFileAccess {
+ let contents: [URL: String]
+
+ init(contents: [URL: String] = [:]) {
+ self.contents = contents
+ }
+
func isDirectory(at url: URL) -> Bool { false }
- func readData(from url: URL) throws -> Data { Data() }
+ func readData(from url: URL) throws -> Data {
+ Data((contents[url.standardizedFileURL] ?? "").utf8)
+ }
}
@MainActor
@@ -624,6 +851,41 @@ private struct TestServerPortParser: RunServerPortParsing {
func serverPort(content: String, fileExtension: String) -> Int? { nil }
}
+private struct FixedServerPortParser: RunServerPortParsing {
+ let port: Int?
+ func serverPort(content _: String, fileExtension _: String) -> Int? { port }
+}
+
+private struct SingleRunConfigurationOperations: RunConfigurationOperations {
+ let configuration: RunConfiguration
+ let options: RunOptions
+
+ func inspect(at _: URL) -> ProjectRunConfigurationInspection {
+ ProjectRunConfigurationInspection(status: .ready, diagnostics: [])
+ }
+ func generate(at _: URL, files _: [URL], modulePaths _: [String]) throws -> RunConfigurationGenerationResult {
+ RunConfigurationGenerationResult(entryCount: 1)
+ }
+ func resolve(at _: URL, toolchainCandidates _: [ProjectToolchainCandidate]) throws -> RunConfigurationResolution {
+ RunConfigurationResolution(
+ configurations: [EffectiveRunConfiguration(configuration: configuration, options: options)],
+ diagnostics: [],
+ defaultConfigurationID: configuration.id
+ )
+ }
+ func launchPlan(
+ at _: URL,
+ configurationID _: String,
+ currentFile _: String?,
+ classPath _: String?,
+ debugPort _: Int?
+ ) throws -> SharedLaunchPlan {
+ throw RunConfigurationOperationFailure(message: "Not required by the port resolution test")
+ }
+ func createConfiguration(_ draft: RunConfigurationDraft, at _: URL) throws -> String { draft.name }
+ func migrateLegacySettings(at _: URL, configurationIDs _: [String]) throws {}
+}
+
@MainActor
private final class TestExecutableResolver: RunExecutableResolving {
func resolve(_ plan: SharedLaunchPlan, projectURL: URL, options: RunOptions) throws -> ResolvedRunExecutable {
@@ -650,6 +912,63 @@ private struct TestRunConfigurationOperations: RunConfigurationOperations {
func migrateLegacySettings(at projectURL: URL, configurationIDs: [String]) throws {}
}
+/// Records the file inventory each generation attempt was given, so a test can
+/// prove both that a pending workspace never reaches the store and that a ready
+/// one is scanned with the complete inventory.
+private final class RecordingRunConfigurationOperations: RunConfigurationOperations, @unchecked Sendable {
+ private(set) var generatedInventories: [[URL]] = []
+
+ var generateCallCount: Int { generatedInventories.count }
+
+ func inspect(at projectURL: URL) -> ProjectRunConfigurationInspection {
+ ProjectRunConfigurationInspection(
+ status: generatedInventories.isEmpty ? .missing : .ready,
+ diagnostics: []
+ )
+ }
+ func generate(at projectURL: URL, files: [URL], modulePaths: [String]) throws -> RunConfigurationGenerationResult {
+ generatedInventories.append(files)
+ return RunConfigurationGenerationResult(entryCount: 1)
+ }
+ func resolve(at projectURL: URL, toolchainCandidates: [ProjectToolchainCandidate]) throws -> RunConfigurationResolution {
+ RunConfigurationResolution(
+ configurations: [EffectiveRunConfiguration(
+ configuration: .currentFile,
+ options: RunOptions()
+ )],
+ diagnostics: [],
+ defaultConfigurationID: RunConfiguration.currentFileID
+ )
+ }
+ func launchPlan(at projectURL: URL, configurationID: String, currentFile: String?, classPath: String?, debugPort: Int?) throws -> SharedLaunchPlan {
+ throw RunConfigurationOperationFailure(message: "Unavailable in identification test")
+ }
+ func createConfiguration(_ draft: RunConfigurationDraft, at projectURL: URL) throws -> String { draft.name }
+ func migrateLegacySettings(at projectURL: URL, configurationIDs: [String]) throws {}
+}
+
+/// Reports an unreadable configuration so a test can observe the failed state.
+private struct FailingInspectionRunConfigurationOperations: RunConfigurationOperations {
+ func inspect(at projectURL: URL) -> ProjectRunConfigurationInspection {
+ ProjectRunConfigurationInspection(
+ status: .invalid("generated.json is invalid"),
+ diagnostics: [],
+ recoveryAction: .editConfiguration
+ )
+ }
+ func generate(at projectURL: URL, files: [URL], modulePaths: [String]) throws -> RunConfigurationGenerationResult {
+ RunConfigurationGenerationResult(entryCount: 0)
+ }
+ func resolve(at projectURL: URL, toolchainCandidates: [ProjectToolchainCandidate]) throws -> RunConfigurationResolution {
+ RunConfigurationResolution(configurations: [], diagnostics: [], defaultConfigurationID: nil)
+ }
+ func launchPlan(at projectURL: URL, configurationID: String, currentFile: String?, classPath: String?, debugPort: Int?) throws -> SharedLaunchPlan {
+ throw RunConfigurationOperationFailure(message: "Unavailable in inspection test")
+ }
+ func createConfiguration(_ draft: RunConfigurationDraft, at projectURL: URL) throws -> String { draft.name }
+ func migrateLegacySettings(at projectURL: URL, configurationIDs: [String]) throws {}
+}
+
private struct TestReadyRunConfigurationOperations: RunConfigurationOperations {
func inspect(at projectURL: URL) -> ProjectRunConfigurationInspection {
ProjectRunConfigurationInspection(status: .ready, diagnostics: [])
diff --git a/macos/Tests/LitheLanguageIntelligenceModuleTests/LanguageIntelligenceModuleTests.swift b/macos/Tests/LitheLanguageIntelligenceModuleTests/LanguageIntelligenceModuleTests.swift
index d68664268..b64520c94 100644
--- a/macos/Tests/LitheLanguageIntelligenceModuleTests/LanguageIntelligenceModuleTests.swift
+++ b/macos/Tests/LitheLanguageIntelligenceModuleTests/LanguageIntelligenceModuleTests.swift
@@ -222,6 +222,432 @@ struct LanguageIntelligenceModuleTests {
#expect(manager.languageServerOperationIDs["java"] == nil)
}
+ @Test
+ func javaDebugServerWaitsForJdtlsReadyAndReturnsItsPort() async throws {
+ let root = URL(fileURLWithPath: "/workspace/java-debug", isDirectory: true)
+ let descriptor = try #require(
+ LanguageProviderCatalog.compatibilityFallback.provider(
+ for: root.appendingPathComponent("Main.java")
+ )
+ )
+ let session = WorkspaceStateLanguageServerSession()
+ let manager = LanguageToolingSessionManager(
+ catalog: .compatibilityFallback,
+ runtimes: [WorkspaceStateLanguageProviderRuntime(
+ descriptor: descriptor,
+ session: session
+ )]
+ )
+ let task = Task { try await manager.startJavaDebugServer(rootURL: root) }
+ defer { task.cancel() }
+
+ try await session.waitUntilStarted()
+ #expect(session.executedCommands.isEmpty)
+ session.publish(.ready)
+ let command = try await session.waitForExecuteCommand()
+ #expect(command.command == "vscode.java.startDebugSession")
+ #expect(command.arguments.isEmpty)
+ session.completeExecuteReturningValue(.success(.integer(5005)))
+
+ #expect(try await task.value == 5005)
+ }
+
+ @Test
+ func javaDebugLaunchTargetUsesJdtlsProjectMetadataForTheCurrentFile() async throws {
+ let root = URL(fileURLWithPath: "/workspace/java-debug", isDirectory: true)
+ let source = root.appendingPathComponent("service/src/main/java/example/Main.java")
+ let descriptor = try #require(
+ LanguageProviderCatalog.compatibilityFallback.provider(for: source)
+ )
+ let session = WorkspaceStateLanguageServerSession()
+ let manager = LanguageToolingSessionManager(
+ catalog: .compatibilityFallback,
+ runtimes: [WorkspaceStateLanguageProviderRuntime(
+ descriptor: descriptor,
+ session: session
+ )]
+ )
+ let task = Task {
+ try await manager.resolveJavaDebugLaunchTarget(fileURL: source, rootURL: root)
+ }
+ defer { task.cancel() }
+
+ try await session.waitUntilStarted()
+ session.publish(.ready)
+ let command = try await session.waitForExecuteCommand()
+ #expect(command.command == "vscode.java.resolveMainClass")
+ #expect(command.arguments.isEmpty)
+ session.completeExecuteReturningValue(.success(.array([
+ .object([
+ "mainClass": .string("other/example.Main"),
+ "projectName": .string("other"),
+ "filePath": .string(root.appendingPathComponent("other/Main.java").path),
+ ]),
+ .object([
+ "mainClass": .string("service/example.Main"),
+ "projectName": .string("service"),
+ "filePath": .string(source.path),
+ ]),
+ ])))
+
+ let classpathCommand = try await session.waitForExecuteCommand(number: 2)
+ #expect(classpathCommand.command == "vscode.java.resolveClasspath")
+ #expect(classpathCommand.arguments == [
+ .string("service/example.Main"),
+ .string("service"),
+ .string("runtime"),
+ ])
+ session.completeExecuteReturningValue(.success(.array([
+ .array([.string("/workspace/modules")]),
+ .array([.string("/workspace/classes")]),
+ ])))
+
+ #expect(try await task.value == JavaDebugLaunchTarget(
+ mainClass: "service/example.Main",
+ projectName: "service",
+ modulePaths: ["/workspace/modules"],
+ classPaths: ["/workspace/classes"]
+ ))
+ }
+
+ @Test
+ func javaDebugLaunchTargetDoesNotBorrowAnotherFileWhenJdtlsReportsItsPath() async throws {
+ let root = URL(fileURLWithPath: "/workspace/java-debug", isDirectory: true)
+ let source = root.appendingPathComponent("service/src/main/java/example/UserService.java")
+ let otherMain = root.appendingPathComponent("service/src/main/java/example/Main.java")
+ let descriptor = try #require(
+ LanguageProviderCatalog.compatibilityFallback.provider(for: source)
+ )
+ let session = WorkspaceStateLanguageServerSession()
+ let manager = LanguageToolingSessionManager(
+ catalog: .compatibilityFallback,
+ runtimes: [WorkspaceStateLanguageProviderRuntime(
+ descriptor: descriptor,
+ session: session
+ )]
+ )
+ let task = Task {
+ try await manager.resolveJavaDebugLaunchTarget(fileURL: source, rootURL: root)
+ }
+ defer { task.cancel() }
+
+ try await session.waitUntilStarted()
+ session.publish(.ready)
+ _ = try await session.waitForExecuteCommand()
+ session.completeExecuteReturningValue(.success(.array([
+ .object([
+ "mainClass": .string("service/example.Main"),
+ "projectName": .string("service"),
+ "filePath": .string(otherMain.path),
+ ])
+ ])))
+
+ await #expect(throws: LanguageToolingSessionError.toolingUnavailable(
+ "No Java main method was found in UserService.java."
+ )) {
+ try await task.value
+ }
+ }
+
+ @Test
+ func javaTestDiscoveryProjectsSortedClassesAndMethodsForTheTestsTree() async throws {
+ let root = URL(fileURLWithPath: "/workspace/java-tests", isDirectory: true)
+ let source = root.appendingPathComponent(
+ "service/src/test/java/example/UserServiceTest.java"
+ )
+ let descriptor = try #require(
+ LanguageProviderCatalog.compatibilityFallback.provider(for: source)
+ )
+ let session = WorkspaceStateLanguageServerSession()
+ let manager = LanguageToolingSessionManager(
+ catalog: .compatibilityFallback,
+ runtimes: [WorkspaceStateLanguageProviderRuntime(
+ descriptor: descriptor,
+ session: session
+ )]
+ )
+ let task = Task {
+ try await manager.discoverJavaTestItems(fileURL: source, rootURL: root)
+ }
+ defer { task.cancel() }
+
+ try await session.waitUntilStarted()
+ session.publish(.ready)
+ let discovery = try await session.waitForExecuteCommand()
+ #expect(discovery.command == "vscode.java.test.findTestTypesAndMethods")
+ #expect(discovery.arguments == [.string(source.standardizedFileURL.absoluteString)])
+ session.completeExecuteReturningValue(.success(.array([
+ .object([
+ "id": .string("service@example.UserServiceTest"),
+ "label": .string("UserServiceTest"),
+ "fullName": .string("example.UserServiceTest"),
+ "projectName": .string("service"),
+ "testKind": .integer(0),
+ "testLevel": .integer(5),
+ "jdtHandler": .string("class-handler"),
+ "sortText": .string("002"),
+ "children": .array([
+ .object([
+ "id": .string("service@example.UserServiceTest#logsOut"),
+ "label": .string("logsOut()"),
+ "fullName": .string("example.UserServiceTest#logsOut"),
+ "projectName": .string("service"),
+ "testKind": .integer(0),
+ "testLevel": .integer(6),
+ "jdtHandler": .string("logout-handler"),
+ "sortText": .string("002"),
+ "children": .array([]),
+ ]),
+ .object([
+ "id": .string("service@example.UserServiceTest#logsIn"),
+ "label": .string("logsIn()"),
+ "fullName": .string("example.UserServiceTest#logsIn"),
+ "projectName": .string("service"),
+ "testKind": .integer(0),
+ "testLevel": .integer(6),
+ "jdtHandler": .string("login-handler"),
+ "sortText": .string("001"),
+ "children": .array([]),
+ ]),
+ ]),
+ ]),
+ .object([
+ "id": .string("service@example.AccountTest"),
+ "label": .string("AccountTest"),
+ "fullName": .string("example.AccountTest"),
+ "projectName": .string("service"),
+ "testKind": .integer(0),
+ "testLevel": .integer(5),
+ "jdtHandler": .string("account-handler"),
+ "sortText": .string("001"),
+ "children": .array([]),
+ ]),
+ ])))
+
+ let items = try await task.value
+ #expect(items.map(\.label) == [
+ "AccountTest", "UserServiceTest", "logsIn()", "logsOut()",
+ ])
+ #expect(items.map(\.depth) == [1, 1, 2, 2])
+ #expect(items.map(\.kind) == [.testCase, .testCase, .testCase, .testCase])
+ #expect(items.map(\.fileURL) == Array(repeating: source.standardizedFileURL, count: 4))
+ #expect(items.map(\.testIdentifier) == [
+ "example.AccountTest",
+ "example.UserServiceTest",
+ "example.UserServiceTest#logsIn",
+ "example.UserServiceTest#logsOut",
+ ])
+ }
+
+ @Test
+ func junitDebugTargetUsesJavaTestDiscoveryAndLaunchArguments() async throws {
+ let root = URL(fileURLWithPath: "/workspace/java-tests", isDirectory: true)
+ let source = root.appendingPathComponent(
+ "service/src/test/java/example/UserServiceTest.java"
+ )
+ let descriptor = try #require(
+ LanguageProviderCatalog.compatibilityFallback.provider(for: source)
+ )
+ let session = WorkspaceStateLanguageServerSession()
+ let manager = LanguageToolingSessionManager(
+ catalog: .compatibilityFallback,
+ runtimes: [WorkspaceStateLanguageProviderRuntime(
+ descriptor: descriptor,
+ session: session
+ )]
+ )
+ let task = Task {
+ try await manager.resolveJavaTestDebugLaunchTarget(
+ fileURL: source,
+ rootURL: root
+ )
+ }
+ defer { task.cancel() }
+
+ try await session.waitUntilStarted()
+ session.publish(.ready)
+ let discovery = try await session.waitForExecuteCommand()
+ #expect(discovery.command == "vscode.java.test.findTestTypesAndMethods")
+ #expect(discovery.arguments == [.string(source.standardizedFileURL.absoluteString)])
+ session.completeExecuteReturningValue(.success(.array([
+ .object([
+ "id": .string("service@example.UserServiceTest"),
+ "label": .string("UserServiceTest"),
+ "fullName": .string("example.UserServiceTest"),
+ "projectName": .string("service"),
+ "testKind": .integer(0),
+ "testLevel": .integer(5),
+ "jdtHandler": .string("=service/src Void)?
var onLog: ((LanguageServerLogLevel, String, String?, String?) -> Void)?
var onStateChange: ((LanguageServerSessionState) -> Void)?
@@ -514,6 +941,15 @@ private final class WorkspaceStateLanguageServerSession: LanguageServerSession {
private(set) var startedMavenContext: MavenLaunchContext?
private(set) var stopCallCount = 0
var startError: Error?
+ private(set) var executedCommands: [LanguageServerCommand] = []
+ private var startWaiters: [UUID: CheckedContinuation] = [:]
+ private var startTimeoutTasks: [UUID: Task] = [:]
+ private var executeWaiters: [UUID: (
+ number: Int,
+ continuation: CheckedContinuation
+ )] = [:]
+ private var executeTimeoutTasks: [UUID: Task] = [:]
+ private var executeValueCompletion: ((Result) -> Void)?
func start(rootURL: URL, workspaceFingerprint: String?) throws {
try start(
@@ -532,12 +968,94 @@ private final class WorkspaceStateLanguageServerSession: LanguageServerSession {
startedFingerprint = workspaceFingerprint
startedMavenContext = mavenContext
isRunning = true
+ let waiterIDs = Array(startWaiters.keys)
+ waiterIDs.forEach { finishStartWaiter($0, result: .success(())) }
}
func publish(_ state: LanguageServerSessionState) {
onStateChange?(state)
}
+ func waitUntilStarted(timeout: Duration = .seconds(2)) async throws {
+ if isRunning { return }
+ let waiterID = UUID()
+ try await withTaskCancellationHandler {
+ try await withCheckedThrowingContinuation { continuation in
+ guard !isRunning else {
+ continuation.resume()
+ return
+ }
+ startWaiters[waiterID] = continuation
+ let timeoutTask = Task { @MainActor [weak self] in
+ // test-stability: allow(swift-real-sleep) reason: this watchdog bounds a failed continuation wait while successful synchronization remains event-driven.
+ try? await Task.sleep(for: timeout)
+ guard !Task.isCancelled else { return }
+ self?.finishStartWaiter(
+ waiterID,
+ result: .failure(WorkspaceStateSessionError.timedOut)
+ )
+ }
+ if startWaiters[waiterID] == nil {
+ timeoutTask.cancel()
+ } else {
+ startTimeoutTasks[waiterID] = timeoutTask
+ }
+ if Task.isCancelled {
+ finishStartWaiter(waiterID, result: .failure(CancellationError()))
+ }
+ }
+ } onCancel: {
+ Task { @MainActor [weak self] in
+ self?.finishStartWaiter(waiterID, result: .failure(CancellationError()))
+ }
+ }
+ }
+
+ func waitForExecuteCommand(
+ number: Int = 1,
+ timeout: Duration = .seconds(2)
+ ) async throws -> LanguageServerCommand {
+ precondition(number > 0)
+ if executedCommands.count >= number { return executedCommands[number - 1] }
+ let waiterID = UUID()
+ return try await withTaskCancellationHandler {
+ try await withCheckedThrowingContinuation { continuation in
+ guard executedCommands.count < number else {
+ continuation.resume(returning: executedCommands[number - 1])
+ return
+ }
+ executeWaiters[waiterID] = (number, continuation)
+ let timeoutTask = Task { @MainActor [weak self] in
+ // test-stability: allow(swift-real-sleep) reason: this watchdog bounds a failed continuation wait while successful synchronization remains event-driven.
+ try? await Task.sleep(for: timeout)
+ guard !Task.isCancelled else { return }
+ self?.finishExecuteWaiter(
+ waiterID,
+ result: .failure(WorkspaceStateSessionError.timedOut)
+ )
+ }
+ if executeWaiters[waiterID] == nil {
+ timeoutTask.cancel()
+ } else {
+ executeTimeoutTasks[waiterID] = timeoutTask
+ }
+ if Task.isCancelled {
+ finishExecuteWaiter(waiterID, result: .failure(CancellationError()))
+ }
+ }
+ } onCancel: {
+ Task { @MainActor [weak self] in
+ self?.finishExecuteWaiter(waiterID, result: .failure(CancellationError()))
+ }
+ }
+ }
+
+ func completeExecuteReturningValue(_ result: Result) {
+ let completion = executeValueCompletion
+ executeValueCompletion = nil
+ completion?(result)
+ }
+
func synchronize(fileURL _: URL, text _: String, languageID _: String) throws {}
func closeDocument(_: URL) {}
@@ -615,6 +1133,25 @@ private final class WorkspaceStateLanguageServerSession: LanguageServerSession {
throw WorkspaceStateSessionError.unexpectedOperation
}
+ func executeReturningValue(
+ _ command: LanguageServerCommand,
+ fileURL _: URL,
+ completion: @escaping (Result) -> Void
+ ) throws {
+ executedCommands.append(command)
+ executeValueCompletion = completion
+ let readyWaiterIDs = executeWaiters.compactMap { waiterID, waiter in
+ waiter.number <= executedCommands.count ? waiterID : nil
+ }
+ readyWaiterIDs.forEach { waiterID in
+ guard let waiter = executeWaiters[waiterID] else { return }
+ finishExecuteWaiter(
+ waiterID,
+ result: .success(executedCommands[waiter.number - 1])
+ )
+ }
+ }
+
func resolveVirtualDocument(
uri _: String,
completion _: @escaping (Result) -> Void
@@ -625,7 +1162,45 @@ private final class WorkspaceStateLanguageServerSession: LanguageServerSession {
func stop() {
stopCallCount += 1
isRunning = false
+ Array(startWaiters.keys).forEach {
+ finishStartWaiter($0, result: .failure(CancellationError()))
+ }
+ Array(executeWaiters.keys).forEach {
+ finishExecuteWaiter($0, result: .failure(CancellationError()))
+ }
+ let completion = executeValueCompletion
+ executeValueCompletion = nil
+ completion?(.failure(CancellationError()))
+ }
+
+ private func finishStartWaiter(_ waiterID: UUID, result: Result) {
+ let continuation = startWaiters.removeValue(forKey: waiterID)
+ let timeoutTask = startTimeoutTasks.removeValue(forKey: waiterID)
+ timeoutTask?.cancel()
+ continuation?.resume(with: result)
+ }
+
+ private func finishExecuteWaiter(
+ _ waiterID: UUID,
+ result: Result
+ ) {
+ let waiter = executeWaiters.removeValue(forKey: waiterID)
+ let timeoutTask = executeTimeoutTasks.removeValue(forKey: waiterID)
+ timeoutTask?.cancel()
+ waiter?.continuation.resume(with: result)
+ }
+}
+
+private func javaTestLaunchRequest(
+ from command: LanguageServerCommand
+) throws -> [String: Any] {
+ guard command.arguments.count == 1,
+ case .string(let value) = command.arguments[0],
+ let data = value.data(using: .utf8),
+ let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
+ throw WorkspaceStateSessionError.unexpectedOperation
}
+ return object
}
private enum WorkspaceStateSessionError: LocalizedError {
diff --git a/macos/Tests/LitheTerminalModuleTests/TerminalModuleTests.swift b/macos/Tests/LitheTerminalModuleTests/TerminalModuleTests.swift
index de18aba0a..d0a44d921 100644
--- a/macos/Tests/LitheTerminalModuleTests/TerminalModuleTests.swift
+++ b/macos/Tests/LitheTerminalModuleTests/TerminalModuleTests.swift
@@ -21,6 +21,57 @@ struct TerminalModuleTests {
#expect(feature.terminalSessions.isEmpty)
}
+ @Test
+ func managedProcessLaunchPreservesArgumentsEnvironmentAndProcessID() throws {
+ let transport = TestTransport()
+ let feature = TerminalFeatureModel(terminalFactory: { transport })
+ let launch = TerminalProcessLaunch(
+ title: "Debug Main",
+ executablePath: "/opt/jdk/bin/java",
+ arguments: ["-cp", "/workspace/classes", "example.Main"],
+ workingDirectory: "/workspace",
+ environmentChanges: [
+ TerminalEnvironmentChange(name: "JAVA_HOME", value: "/opt/jdk"),
+ TerminalEnvironmentChange(name: "REMOVE_ME", value: nil)
+ ]
+ )
+
+ let created = try feature.createProcessSession(launch)
+
+ #expect(created.processID == 1234)
+ #expect(created.session.isManagedProcess)
+ #expect(created.session.displayTitle == "Debug Main")
+ #expect(transport.processLaunches == [launch])
+ #expect(transport.processEnvironments.first?["JAVA_HOME"] == "/opt/jdk")
+ #expect(transport.processEnvironments.first?["REMOVE_ME"] == nil)
+ #expect(transport.processEnvironments.first?["TERM_PROGRAM"] == "Lithe")
+ created.session.restart()
+ #expect(transport.processLaunches.count == 1)
+
+ feature.stopAllSessions()
+ #expect(transport.stopCount == 1)
+ }
+
+ @Test
+ func managedProcessForwardsInputToItsOwnPTY() throws {
+ let transport = TestTransport()
+ let feature = TerminalFeatureModel(terminalFactory: { transport })
+ let launch = TerminalProcessLaunch(
+ title: "Debug Main",
+ executablePath: "/opt/jdk/bin/java",
+ arguments: ["example.Main"],
+ workingDirectory: "/workspace"
+ )
+ let created = try feature.createProcessSession(launch)
+
+ #expect(feature.sendInput("username\n", to: created.session.id))
+ #expect(transport.sentInputs == ["username\n"])
+
+ created.session.stop()
+ #expect(!feature.sendInput("late\n", to: created.session.id))
+ feature.stopAllSessions()
+ }
+
@Test
func linkResolverKeepsExternalURLsAndResolvesLocations() {
let workspace = URL(fileURLWithPath: "/tmp/lithe-terminal-module-test")
@@ -36,22 +87,66 @@ struct TerminalModuleTests {
fileExists: { _ in false }
) == .external(URL(string: "https://example.com")!))
}
+
+ @Test
+ func processOutputIsForwardedBeforeAndAfterProcessStart() throws {
+ let transport = TestTransport()
+ transport.outputOnStart = "early\n"
+ let feature = TerminalFeatureModel(terminalFactory: { transport })
+ var output: [String] = []
+
+ let created = try feature.createProcessSession(
+ TerminalProcessLaunch(
+ title: "Debug Main",
+ executablePath: "/usr/bin/java",
+ arguments: ["Main"],
+ workingDirectory: "/tmp"
+ ),
+ onOutput: { output.append($0) }
+ )
+ transport.emitOutput("late\n")
+
+ #expect(created.session.isRunning)
+ #expect(output == ["early\n", "late\n"])
+ feature.stopAllSessions()
+ }
}
@MainActor
private final class TestTransport: TerminalTransport {
let nativeView: AnyObject = NSObject()
var isRunning = false
+ var processID: Int32? { isRunning ? 1234 : nil }
var shellName = "Shell"
var onTermination: ((Int32?) -> Void)?
+ var onOutput: ((Data) -> Void)?
var onTitle: ((String) -> Void)?
var onDirectoryUpdate: ((String?) -> Void)?
var onLink: ((String, [String: String]) -> Void)?
var stopCount = 0
+ var processLaunches: [TerminalProcessLaunch] = []
+ var processEnvironments: [[String: String]] = []
+ var sentInputs: [String] = []
+ var outputOnStart: String?
func defaultShellPath() -> String { "/bin/zsh" }
- func defaultEnvironment() -> [String: String] { [:] }
+ func defaultEnvironment() -> [String: String] { ["REMOVE_ME": "old"] }
func start(workingDirectory: String, shellPath: String, environment: [String: String]) throws { isRunning = true }
- func send(_ input: Data) throws {}
+ func startProcess(
+ _ launch: TerminalProcessLaunch,
+ environment: [String: String]
+ ) throws -> Int32 {
+ processLaunches.append(launch)
+ processEnvironments.append(environment)
+ isRunning = true
+ if let outputOnStart {
+ onOutput?(Data(outputOnStart.utf8))
+ }
+ return 1234
+ }
+ func emitOutput(_ value: String) { onOutput?(Data(value.utf8)) }
+ func send(_ input: Data) throws {
+ sentInputs.append(String(decoding: input, as: UTF8.self))
+ }
func interrupt() throws {}
func focus() {}
func clear() {}
diff --git a/macos/Tests/LitheTests/AppLocalizationTests.swift b/macos/Tests/LitheTests/AppLocalizationTests.swift
index 880a69164..f4fd18559 100644
--- a/macos/Tests/LitheTests/AppLocalizationTests.swift
+++ b/macos/Tests/LitheTests/AppLocalizationTests.swift
@@ -160,6 +160,44 @@ struct AppLocalizationTests {
#expect(translations["Java service failed to start: %@"] == "Java 服务启动失败:%@")
}
+ @Test
+ func simplifiedChineseResourcesCoverBreakpointManager() throws {
+ let translations = try simplifiedChineseTranslations()
+ let requiredKeys = [
+ "View Breakpoints",
+ "Manage all project breakpoints",
+ "View breakpoints (⌘⇧F8)",
+ "View breakpoints",
+ "Loading breakpoints…",
+ "Manage project breakpoints without starting a debug session",
+ "Line Breakpoints",
+ "Exception Breakpoints",
+ "Method Breakpoints",
+ "Field Breakpoints",
+ "Mute Line Breakpoints",
+ "Unmute Line Breakpoints",
+ "Click the editor gutter to add a breakpoint",
+ "Add a class or method name",
+ "Right-click a field while paused to add a breakpoint",
+ "Remove All",
+ "Disable breakpoint",
+ "Enable breakpoint",
+ "Edit…",
+ "Edit exception breakpoint",
+ "Add method breakpoint",
+ "Breakpoint actions",
+ "Line breakpoint actions",
+ "If: %@",
+ "Hit: %@",
+ "Verified",
+ "Pending verification"
+ ]
+
+ for key in requiredKeys {
+ #expect(translations[key] != nil, "Missing breakpoint manager translation: \(key)")
+ }
+ }
+
private func simplifiedChineseTranslations() throws -> [String: String] {
let repositoryRoot = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
diff --git a/macos/Tests/LitheTests/DebugBreakpointManagerPresentationTests.swift b/macos/Tests/LitheTests/DebugBreakpointManagerPresentationTests.swift
new file mode 100644
index 000000000..618a9f88f
--- /dev/null
+++ b/macos/Tests/LitheTests/DebugBreakpointManagerPresentationTests.swift
@@ -0,0 +1,62 @@
+import Foundation
+import Testing
+@testable import Lithe
+
+@Suite("Debug breakpoint manager presentation")
+@MainActor
+struct DebugBreakpointManagerPresentationTests {
+ @Test
+ func doesNotPresentWithoutAWorkspace() {
+ let model = makeModel()
+
+ model.showDebugBreakpointManager()
+
+ #expect(!model.debugBreakpointPresentation.isManagerPresented)
+ #expect(model.workspaceURL == nil)
+ }
+
+ @Test
+ func switchingAndClosingProjectsDismissesTheManager() throws {
+ let root = FileManager.default.temporaryDirectory
+ .appendingPathComponent("lithe-breakpoint-manager-\(UUID().uuidString)")
+ let firstProject = root.appendingPathComponent("first", isDirectory: true)
+ let secondProject = root.appendingPathComponent("second", isDirectory: true)
+ try FileManager.default.createDirectory(at: firstProject, withIntermediateDirectories: true)
+ try FileManager.default.createDirectory(at: secondProject, withIntermediateDirectories: true)
+ defer { try? FileManager.default.removeItem(at: root) }
+ let model = makeModel()
+ model.openProjectDirectly(firstProject)
+ model.debugBreakpointPresentation.isManagerPresented = true
+
+ model.openProjectDirectly(secondProject)
+
+ #expect(!model.debugBreakpointPresentation.isManagerPresented)
+ model.debugBreakpointPresentation.isManagerPresented = true
+
+ model.closeProject()
+
+ #expect(!model.debugBreakpointPresentation.isManagerPresented)
+ #expect(model.workspaceURL == nil)
+ }
+
+ private func makeModel() -> AppModel {
+ let store = DebugBreakpointManagerTestStore()
+ let settings = AppSettings(store: store)
+ let services = MacServiceContainer(
+ store: store,
+ settings: settings,
+ moduleLaunchMode: .safeMode
+ ).services
+ return AppModel(settings: settings, services: services)
+ }
+}
+
+private final class DebugBreakpointManagerTestStore: KeyValueStore, @unchecked Sendable {
+ private var values: [String: Any] = [:]
+
+ func data(forKey key: String) -> Data? { values[key] as? Data }
+ func object(forKey key: String) -> Any? { values[key] }
+ func string(forKey key: String) -> String? { values[key] as? String }
+ func stringArray(forKey key: String) -> [String]? { values[key] as? [String] }
+ func set(_ value: Any?, forKey key: String) { values[key] = value }
+}
diff --git a/macos/Tests/LitheTests/DebugBreakpointPersistenceTests.swift b/macos/Tests/LitheTests/DebugBreakpointPersistenceTests.swift
new file mode 100644
index 000000000..9979b2932
--- /dev/null
+++ b/macos/Tests/LitheTests/DebugBreakpointPersistenceTests.swift
@@ -0,0 +1,102 @@
+import Foundation
+import LitheCoreContracts
+import LitheDebugModule
+@testable import Lithe
+import Testing
+
+struct DebugBreakpointPersistenceTests {
+ @Test
+ func macStoreKeepsBreakpointSnapshotsSeparateByProject() throws {
+ let preferences = DebugBreakpointTestStore()
+ let store = MacDebugBreakpointStore(store: preferences)
+ let firstRoot = URL(fileURLWithPath: "/tmp/first-debug-project", isDirectory: true)
+ let secondRoot = URL(fileURLWithPath: "/tmp/second-debug-project", isDirectory: true)
+ let first = DebugBreakpointSnapshot(
+ areBreakpointsMuted: true,
+ breakpoints: [PersistedDebugBreakpoint(
+ relativePath: "src/Main.java",
+ line: 12,
+ enabled: false,
+ condition: "user != null",
+ hitCondition: "2",
+ logMessage: "user = {user}"
+ )]
+ )
+ let second = DebugBreakpointSnapshot(
+ breakpoints: [PersistedDebugBreakpoint(relativePath: "App.java", line: 4)]
+ )
+
+ try store.saveBreakpoints(first, for: firstRoot)
+ try store.saveBreakpoints(second, for: secondRoot)
+
+ #expect(try store.loadBreakpoints(for: firstRoot) == first)
+ #expect(try store.loadBreakpoints(for: secondRoot) == second)
+ let firstData = try #require(preferences.data(
+ forKey: "lithe.debug.breakpoints." + firstRoot.path
+ ))
+ #expect(!String(decoding: firstData, as: UTF8.self).contains(firstRoot.path))
+ #expect(try store.loadBreakpoints(
+ for: URL(fileURLWithPath: "/tmp/unknown-debug-project", isDirectory: true)
+ ) == nil)
+ }
+
+ @Test
+ func macStoreReportsCorruptBreakpointData() {
+ let preferences = DebugBreakpointTestStore()
+ let root = URL(fileURLWithPath: "/tmp/corrupt-debug-project", isDirectory: true)
+ preferences.set(Data("not-json".utf8), forKey: "lithe.debug.breakpoints." + root.path)
+ let store = MacDebugBreakpointStore(store: preferences)
+
+ #expect(throws: MacDebugBreakpointStoreError.self) {
+ try store.loadBreakpoints(for: root)
+ }
+ }
+
+ @Test
+ func macStorePersistsSteppingFiltersByAdapter() throws {
+ let preferences = DebugBreakpointTestStore()
+ let store = MacDebugSteppingFilterStore(store: preferences)
+ let java = DebugSteppingFilters(
+ classNameFilters: ["$JDK", "org.mockito.*"],
+ skipSynthetics: true,
+ skipStaticInitializers: true,
+ skipConstructors: false,
+ hideFilteredStackFrames: true
+ )
+ let go = DebugSteppingFilters(
+ classNameFilters: [],
+ skipSynthetics: false,
+ skipStaticInitializers: false,
+ skipConstructors: false,
+ hideFilteredStackFrames: false
+ )
+
+ try store.saveSteppingFilters(java, adapterID: "java")
+ try store.saveSteppingFilters(go, adapterID: "go")
+
+ #expect(try store.loadSteppingFilters(adapterID: "java") == java)
+ #expect(try store.loadSteppingFilters(adapterID: "go") == go)
+ #expect(try store.loadSteppingFilters(adapterID: "python") == nil)
+ }
+
+ @Test
+ func macStoreReportsCorruptSteppingFilterData() {
+ let preferences = DebugBreakpointTestStore()
+ preferences.set(Data("not-json".utf8), forKey: "lithe.debug.steppingFilters.java")
+ let store = MacDebugSteppingFilterStore(store: preferences)
+
+ #expect(throws: MacDebugSteppingFilterStoreError.self) {
+ try store.loadSteppingFilters(adapterID: "java")
+ }
+ }
+}
+
+private final class DebugBreakpointTestStore: KeyValueStore, @unchecked Sendable {
+ private var values: [String: Any] = [:]
+
+ func data(forKey key: String) -> Data? { values[key] as? Data }
+ func object(forKey key: String) -> Any? { values[key] }
+ func string(forKey key: String) -> String? { values[key] as? String }
+ func stringArray(forKey key: String) -> [String]? { values[key] as? [String] }
+ func set(_ value: Any?, forKey key: String) { values[key] = value }
+}
diff --git a/macos/Tests/LitheTests/DebugToolbarPresentationTests.swift b/macos/Tests/LitheTests/DebugToolbarPresentationTests.swift
new file mode 100644
index 000000000..96cc2c349
--- /dev/null
+++ b/macos/Tests/LitheTests/DebugToolbarPresentationTests.swift
@@ -0,0 +1,137 @@
+import Foundation
+import Testing
+import LitheCoreContracts
+@testable import Lithe
+
+@Suite("IDEA-aligned Debug toolbar presentation")
+struct DebugToolbarPresentationTests {
+ @Test
+ func primaryActionsKeepTheIDEAOrderAndGrouping() {
+ #expect(DebugToolbarPresentation.primaryActions == [
+ .restartOrStart,
+ .stop,
+ .resume,
+ .pause,
+ .stepOver,
+ .stepInto,
+ .stepOut,
+ .viewBreakpoints,
+ .muteBreakpoints
+ ])
+ #expect(DebugToolbarPresentation.separatorsAfter == [.stop, .stepOut])
+ }
+
+ @Test
+ func startActionUsesDebugBeforeLaunchAndRestartDuringASession() {
+ #expect(DebugToolbarPresentation.ideaAssetPath(
+ for: .restartOrStart,
+ isSessionActive: false
+ ) == "debugger/debug.svg")
+ #expect(DebugToolbarPresentation.ideaAssetPath(
+ for: .restartOrStart,
+ isSessionActive: true
+ ) == "debugger/restartDebug.svg")
+ }
+
+ @Test
+ func everyPrimaryActionShipsLightAndDarkIDEAAssets() {
+ let iconRoot = repositoryRoot
+ .appendingPathComponent("macos/Resources/IDEAIcons", isDirectory: true)
+
+ for action in DebugToolbarPresentation.primaryActions {
+ let resourcePath = DebugToolbarPresentation.ideaAssetPath(
+ for: action,
+ isSessionActive: true
+ )
+ #expect(FileManager.default.fileExists(
+ atPath: iconRoot.appendingPathComponent(resourcePath).path
+ ))
+ #expect(FileManager.default.fileExists(
+ atPath: iconRoot.appendingPathComponent(
+ LitheIcons.darkIdeaAssetPath(for: resourcePath)
+ ).path
+ ))
+ }
+ }
+
+ @Test
+ func darkAssetPathKeepsTheIDEADirectoryAndSuffixConvention() {
+ #expect(
+ LitheIcons.darkIdeaAssetPath(for: "debugger/stepOver.svg")
+ == "debugger/stepOver_dark.svg"
+ )
+ }
+
+ @Test
+ func breakpointStatesUseTheIDEAGutterGlyphs() {
+ #expect(LitheIcons.debuggerBreakpointAssetPath(
+ enabled: true,
+ verified: false,
+ muted: false
+ ) == "debugger/db_set_breakpoint.svg")
+ #expect(LitheIcons.debuggerBreakpointAssetPath(
+ enabled: true,
+ verified: true,
+ muted: false
+ ) == "debugger/db_verified_breakpoint.svg")
+ #expect(LitheIcons.debuggerBreakpointAssetPath(
+ enabled: false,
+ verified: true,
+ muted: false
+ ) == "debugger/db_disabled_breakpoint.svg")
+ #expect(LitheIcons.debuggerBreakpointAssetPath(
+ enabled: true,
+ verified: true,
+ muted: true
+ ) == "debugger/db_muted_breakpoint.svg")
+ }
+
+ @Test
+ func toolbarCommandIDsMapToTheExistingKeymapCommands() {
+ #expect(LitheCommandCatalog.command(id: "debug-resume") != nil)
+ #expect(LitheCommandCatalog.command(id: "debug-step-over") != nil)
+ #expect(LitheCommandCatalog.command(id: "debug-step-into") != nil)
+ #expect(LitheCommandCatalog.command(id: "debug-step-out") != nil)
+ #expect(LitheCommandCatalog.command(id: "view-breakpoints") != nil)
+ }
+
+ @Test
+ func statusTextIncludesTheActualStopReason() {
+ #expect(DebugToolbarPresentation.statusText(
+ for: .paused,
+ stoppedReason: "breakpoint"
+ ) == "Paused · Breakpoint")
+ #expect(DebugToolbarPresentation.statusText(
+ for: .paused,
+ stoppedReason: "exception"
+ ) == "Paused · Exception")
+ #expect(DebugToolbarPresentation.statusText(
+ for: .paused,
+ stoppedReason: "function breakpoint"
+ ) == "Paused · Method breakpoint")
+ #expect(DebugToolbarPresentation.statusText(
+ for: .paused,
+ stoppedReason: "data breakpoint"
+ ) == "Paused · Field breakpoint")
+ #expect(DebugToolbarPresentation.statusText(
+ for: .paused,
+ stoppedReason: " "
+ ) == "Paused")
+ }
+
+ @Test
+ func statusTextMapsLifecycleStatesToStableLabels() {
+ #expect(DebugToolbarPresentation.statusText(for: .running, stoppedReason: nil) == "Running")
+ #expect(DebugToolbarPresentation.statusText(for: .launching, stoppedReason: nil) == "Launching")
+ #expect(DebugToolbarPresentation.statusText(for: .terminated, stoppedReason: nil) == "Finished")
+ #expect(DebugToolbarPresentation.statusText(for: .failed, stoppedReason: nil) == "Failed")
+ }
+
+ private var repositoryRoot: URL {
+ URL(fileURLWithPath: #filePath)
+ .deletingLastPathComponent()
+ .deletingLastPathComponent()
+ .deletingLastPathComponent()
+ .deletingLastPathComponent()
+ }
+}
diff --git a/macos/Tests/LitheTests/EditorGutterLayoutTests.swift b/macos/Tests/LitheTests/EditorGutterLayoutTests.swift
index c5ff3af77..c11797702 100644
--- a/macos/Tests/LitheTests/EditorGutterLayoutTests.swift
+++ b/macos/Tests/LitheTests/EditorGutterLayoutTests.swift
@@ -4,6 +4,156 @@ import Testing
@Suite("Editor gutter layout")
struct EditorGutterLayoutTests {
+ @Test
+ func inlineDebugValuesMatchWholeIdentifiersInSourceOrder() {
+ let values = EditorInlineDebugValueProjection.values(
+ forLine: 0,
+ in: "int total = count + counter;" as NSString,
+ variables: [
+ EditorInlineDebugValue(name: "counter", value: "9"),
+ EditorInlineDebugValue(name: "count", value: "4"),
+ EditorInlineDebugValue(name: "total", value: "13")
+ ]
+ )
+
+ #expect(values.map(\.name) == ["total", "count", "counter"])
+ #expect(values.map(\.value) == ["13", "4", "9"])
+ #expect(EditorInlineDebugValueProjection.values(
+ forLine: 0,
+ in: "counter" as NSString,
+ variables: [EditorInlineDebugValue(name: "count", value: "4")]
+ ).isEmpty)
+ }
+
+ @Test
+ func inlineDebugValuesAreBoundedAndSingleLine() {
+ let longValue = String(repeating: "x", count: 100) + "\nnext"
+ let values = EditorInlineDebugValueProjection.values(
+ forLine: 0,
+ in: "a + b + c + d + e" as NSString,
+ variables: ["e", "d", "c", "b", "a"].map {
+ EditorInlineDebugValue(name: $0, value: $0 == "a" ? longValue : $0)
+ }
+ )
+
+ #expect(values.map(\.name) == ["a", "b", "c", "d"])
+ #expect(values[0].value.count == EditorInlineDebugValueProjection.maximumValueCharacters)
+ #expect(values[0].value.last == "…")
+ #expect(!values[0].value.contains("\n"))
+ }
+
+ @Test
+ func javaAutomaticDebugExpressionsKeepReceiversAndSkipMethods() {
+ let source = "return service.listUsers();" as NSString
+
+ let expressions = DebugAutomaticExpressionProjection.javaExpressions(
+ forLine: 0,
+ in: source
+ )
+
+ #expect(expressions == ["service"])
+ }
+
+ @Test
+ func javaAutomaticDebugExpressionsAreBoundedAndSourceOrdered() {
+ let source = "a + b + c + d + e + f + g + h + i + j" as NSString
+
+ let expressions = DebugAutomaticExpressionProjection.javaExpressions(
+ forLine: 0,
+ in: source
+ )
+
+ #expect(expressions == ["a", "b", "c", "d", "e", "f", "g", "h"])
+ }
+
+ @MainActor
+ @Test
+ func inlineDebugValueOverlayUsesOnlyRemainingEditorWidth() throws {
+ let textView = CodeTextView(frame: NSRect(x: 0, y: 0, width: 480, height: 120))
+ textView.font = .monospacedSystemFont(ofSize: 13, weight: .regular)
+ textView.string = "int count = 4;\nreturn count;"
+ let layoutManager = try #require(textView.layoutManager)
+ let textContainer = try #require(textView.textContainer)
+ layoutManager.delegate = textView
+ layoutManager.ensureLayout(for: textContainer)
+ let overlay = DebugInlineValueOverlayController(textView: textView)
+
+ overlay.update(
+ line: 1,
+ values: [EditorInlineDebugValue(name: "count", value: "4")]
+ )
+
+ let frame = try #require(overlay.renderedFrame)
+ #expect(overlay.renderedText == "count = 4")
+ #expect(frame.minX > textView.textContainerOrigin.x)
+ #expect(frame.maxX <= textView.bounds.maxX - 12)
+
+ textView.frame.size.width = 40
+ overlay.update(
+ line: 1,
+ values: [EditorInlineDebugValue(name: "count", value: "4")]
+ )
+ #expect(overlay.renderedText == nil)
+ }
+
+ @Test
+ func debugHoverResolvesOnlyJavaIdentifierTokens() throws {
+ let source = "userService.login(userName)" as NSString
+
+ let service = try #require(DebugHoverExpressionResolver.expression(at: 5, in: source))
+ let argument = try #require(DebugHoverExpressionResolver.expression(at: 22, in: source))
+
+ #expect(service.0 == "userService")
+ #expect(source.substring(with: service.1) == "userService")
+ #expect(argument.0 == "userName")
+ #expect(DebugHoverExpressionResolver.expression(at: 11, in: source) == nil)
+ }
+
+ @Test
+ func editorBreakpointLinesConvertToOneBasedProductLines() {
+ #expect(EditorDebugBreakpointLocation.productLine(forEditorLine: 0) == 1)
+ #expect(EditorDebugBreakpointLocation.productLine(forEditorLine: 7) == 8)
+ }
+
+ @MainActor
+ @Test
+ func breakpointContextMenuOffersEditingAndDispatchesTheEditorLine() throws {
+ let gutter = LineNumberGutterView(frame: NSRect(x: 0, y: 0, width: 80, height: 200))
+ var editedLine: Int?
+ gutter.updateDebugBreakpointLines(
+ [7: EditorDebugBreakpointState(enabled: true, verified: false)],
+ onToggle: { _ in },
+ onEdit: { editedLine = $0 }
+ )
+
+ let menu = try #require(gutter.debugBreakpointContextMenu(forLine: 6))
+ #expect(menu.items.map(\.title) == [
+ "Edit Breakpoint…",
+ "Disable Breakpoint",
+ "Remove Breakpoint"
+ ])
+
+ gutter.editDebugBreakpointFromMenu()
+ #expect(editedLine == 6)
+ }
+
+ @MainActor
+ @Test
+ func emptyExecutableLineContextMenuOffersSettingABreakpoint() throws {
+ let gutter = LineNumberGutterView(frame: NSRect(x: 0, y: 0, width: 80, height: 200))
+ var toggledLine: Int?
+ gutter.updateDebugBreakpointLines(
+ [:],
+ onToggle: { toggledLine = $0 },
+ canAdd: { $0 == 4 }
+ )
+
+ let menu = try #require(gutter.debugBreakpointContextMenu(forLine: 4))
+ #expect(menu.items.map(\.title) == ["Set Breakpoint"])
+ gutter.addDebugBreakpointFromMenu()
+ #expect(toggledLine == 4)
+ }
+
@MainActor
@Test
func foldingLinesChangesTheOverlayTargetGeometry() throws {
@@ -272,13 +422,25 @@ struct EditorGutterLayoutTests {
@Test
func columnsHaveDistinctHitTargets() {
let layout = EditorGutterLayout(lineNumberTextWidth: 24)
- #expect(layout.hitTarget(at: 6, hasGitChange: false) == .breakpoint)
+ #expect(layout.hitTarget(at: 6, hasGitChange: false) == .lineNumber)
#expect(layout.hitTarget(at: 22, hasGitChange: false) == .lineNumber)
+ #expect(layout.hitTarget(at: 34, hasGitChange: false) == .breakpoint)
#expect(layout.hitTarget(at: 48, hasGitChange: false) == .implementation)
#expect(layout.hitTarget(at: 68, hasGitChange: false) == .fold)
#expect(layout.hitTarget(at: 78, hasGitChange: true) == .gitChange)
}
+ @Test
+ func breakpointInteractionIncludesMarkerAndLineNumberColumns() {
+ let layout = EditorGutterLayout(lineNumberTextWidth: 24)
+
+ #expect(layout.breakpointInteractionRange.contains(6))
+ #expect(layout.breakpointInteractionRange.contains(22))
+ #expect(layout.breakpointInteractionRange.contains(34))
+ #expect(!layout.breakpointInteractionRange.contains(48))
+ #expect(EditorDebugBreakpointAppearance.markerSize == 14)
+ }
+
@Test
func gitColumnDoesNotConsumeClicksWithoutAChange() {
let layout = EditorGutterLayout(lineNumberTextWidth: 24)
@@ -319,7 +481,8 @@ struct EditorGutterLayoutTests {
func lineNumberColumnExpandsWithoutOverlappingFollowingColumns() {
let layout = EditorGutterLayout(lineNumberTextWidth: 34)
#expect(layout.lineNumberRange.upperBound - layout.lineNumberRange.lowerBound == 37)
- #expect(layout.lineNumberRange.upperBound == layout.implementationRange.lowerBound)
+ #expect(layout.lineNumberRange.upperBound == layout.breakpointRange.lowerBound)
+ #expect(layout.breakpointRange.upperBound == layout.implementationRange.lowerBound)
#expect(layout.implementationRange.upperBound == layout.foldRange.lowerBound)
#expect(layout.foldRange.upperBound == layout.gitChangeRange.lowerBound)
#expect(layout.gitChangeRange.upperBound == layout.width)
diff --git a/macos/Tests/LitheTests/EditorLayoutMetricsTests.swift b/macos/Tests/LitheTests/EditorLayoutMetricsTests.swift
index e05efdd25..ebcf7dfa8 100644
--- a/macos/Tests/LitheTests/EditorLayoutMetricsTests.swift
+++ b/macos/Tests/LitheTests/EditorLayoutMetricsTests.swift
@@ -17,8 +17,8 @@ struct EditorLayoutMetricsTests {
@Test
func standardGutterUsesDistinctBreakpointImplementationLineNumberAndFoldColumns() {
let layout = EditorGutterLayout(lineNumberTextWidth: 0)
- #expect(layout.breakpointRange.upperBound == layout.lineNumberRange.lowerBound)
- #expect(layout.lineNumberRange.upperBound == layout.implementationRange.lowerBound)
+ #expect(layout.lineNumberRange.upperBound == layout.breakpointRange.lowerBound)
+ #expect(layout.breakpointRange.upperBound == layout.implementationRange.lowerBound)
#expect(layout.implementationRange.upperBound == layout.foldRange.lowerBound)
#expect(layout.foldRange.upperBound == layout.gitChangeRange.lowerBound)
#expect(layout.gitChangeRange.upperBound == EditorLayoutMetrics.standardGutterWidth)
diff --git a/macos/Tests/LitheTests/GitStatusObservationTests.swift b/macos/Tests/LitheTests/GitStatusObservationTests.swift
index f08b9bd5f..f4886a4d9 100644
--- a/macos/Tests/LitheTests/GitStatusObservationTests.swift
+++ b/macos/Tests/LitheTests/GitStatusObservationTests.swift
@@ -563,7 +563,7 @@ private func makeObservationModel(recorder: GitObservationRecorder) -> Workspace
recorder.gitRefreshCount += 1
},
updateHistoryVisibilityRules: { _ in },
- onSnapshotLoaded: { _, _ in }
+ onSnapshotLoaded: { _, _, _ in }
)
return model
}
diff --git a/macos/Tests/LitheTests/JavaLanguageServerRuntimeTests.swift b/macos/Tests/LitheTests/JavaLanguageServerRuntimeTests.swift
index 7981937e4..68faa2abd 100644
--- a/macos/Tests/LitheTests/JavaLanguageServerRuntimeTests.swift
+++ b/macos/Tests/LitheTests/JavaLanguageServerRuntimeTests.swift
@@ -94,7 +94,10 @@ struct JavaLanguageServerRuntimeTests {
let root = fileManager.temporaryDirectory
.appendingPathComponent("lithe-jdtls-resolver-\(UUID().uuidString)", isDirectory: true)
defer { try? fileManager.removeItem(at: root) }
- for directory in ["bin", "plugins", "config_mac", "config_mac_arm", "lombok"] {
+ for directory in [
+ "bin", "plugins", "config_mac", "config_mac_arm", "lombok", "java-debug",
+ "java-test/extensions", "java-test/runner"
+ ] {
try fileManager.createDirectory(
at: root.appendingPathComponent(directory, isDirectory: true),
withIntermediateDirectories: true
@@ -108,7 +111,13 @@ struct JavaLanguageServerRuntimeTests {
executable,
firstLauncher,
root.appendingPathComponent("plugins/org.eclipse.equinox.launcher_2.0.0.jar"),
- root.appendingPathComponent("lombok/lombok.jar")
+ root.appendingPathComponent("lombok/lombok.jar"),
+ root.appendingPathComponent("java-debug/com.microsoft.java.debug.plugin-0.53.1.jar"),
+ root.appendingPathComponent("java-test/extensions/org.opentest4j_1.2.0.jar"),
+ root.appendingPathComponent("java-test/extensions/com.microsoft.java.test.plugin-0.42.0.jar"),
+ root.appendingPathComponent(
+ "java-test/runner/com.microsoft.java.test.runner-jar-with-dependencies.jar"
+ )
] {
try Data().write(to: file)
}
@@ -125,6 +134,19 @@ struct JavaLanguageServerRuntimeTests {
#expect(resources.configurationDirectoryURL.lastPathComponent == "config_mac")
#endif
#expect(resources.lombokAgentURL.lastPathComponent == "lombok.jar")
+ #expect(
+ resources.javaDebugBundleURL?.lastPathComponent
+ == "com.microsoft.java.debug.plugin-0.53.1.jar"
+ )
+ #expect(resources.javaExtensionBundleURLs.map(\.lastPathComponent) == [
+ "com.microsoft.java.debug.plugin-0.53.1.jar",
+ "com.microsoft.java.test.plugin-0.42.0.jar",
+ "org.opentest4j_1.2.0.jar"
+ ])
+ #expect(
+ resources.javaTestRunnerURL?.lastPathComponent
+ == "com.microsoft.java.test.runner-jar-with-dependencies.jar"
+ )
}
@Test
@@ -153,7 +175,9 @@ struct JavaLanguageServerRuntimeTests {
let root = fileManager.temporaryDirectory
.appendingPathComponent("lithe-jdtls-architecture-\(UUID().uuidString)", isDirectory: true)
defer { try? fileManager.removeItem(at: root) }
- for directory in ["bin", "plugins", "lombok"] {
+ for directory in [
+ "bin", "plugins", "lombok", "java-debug", "java-test/extensions", "java-test/runner"
+ ] {
try fileManager.createDirectory(
at: root.appendingPathComponent(directory, isDirectory: true),
withIntermediateDirectories: true
@@ -174,7 +198,12 @@ struct JavaLanguageServerRuntimeTests {
for file in [
root.appendingPathComponent("bin/jdtls"),
root.appendingPathComponent("plugins/org.eclipse.equinox.launcher_1.0.0.jar"),
- root.appendingPathComponent("lombok/lombok.jar")
+ root.appendingPathComponent("lombok/lombok.jar"),
+ root.appendingPathComponent("java-debug/com.microsoft.java.debug.plugin-0.53.1.jar"),
+ root.appendingPathComponent("java-test/extensions/com.microsoft.java.test.plugin-0.42.0.jar"),
+ root.appendingPathComponent(
+ "java-test/runner/com.microsoft.java.test.runner-jar-with-dependencies.jar"
+ )
] {
try Data().write(to: file)
}
@@ -347,7 +376,6 @@ private struct JavaLanguageServerTestRuntimeLocator: RuntimeLocator {
func systemMavenExecutable() -> URL? { nil }
func mavenExecutable(forHomePath path: String) -> URL? { nil }
func mavenRuntime(at executableURL: URL) -> MavenRuntimeCandidate? { nil }
- func systemJDBExecutable() -> URL? { nil }
func bundledJdkHome() -> URL? {
bundledHomePath.map { URL(fileURLWithPath: $0, isDirectory: true) }
}
diff --git a/macos/Tests/LitheTests/JavaTestDebugLaunchServiceTests.swift b/macos/Tests/LitheTests/JavaTestDebugLaunchServiceTests.swift
new file mode 100644
index 000000000..6cda94cb5
--- /dev/null
+++ b/macos/Tests/LitheTests/JavaTestDebugLaunchServiceTests.swift
@@ -0,0 +1,297 @@
+import Foundation
+import LitheCoreContracts
+import LitheExecutionModule
+import Testing
+@testable import Lithe
+
+@Suite("Java test debug launch workflow")
+@MainActor
+struct JavaTestDebugLaunchServiceTests {
+ @Test
+ func fileSelectionResolvesTargetStartsResultsAndBuildsSharedConfiguration() async throws {
+ let root = URL(fileURLWithPath: "/workspace/java-tests", isDirectory: true)
+ let source = root.appendingPathComponent(
+ "src/test/java/example/UserServiceTest.java"
+ )
+ let target = javaTestTarget(fileURL: source)
+ let targetResolver = TestJavaTestTargetResolver(target: target)
+ let resultServer = TestJavaTestResultServer(port: 43_128)
+ let expectedConfiguration = DebugLaunchConfiguration(
+ name: "UserServiceTest",
+ request: .launch,
+ arguments: ["mainClass": .string(target.mainClass)]
+ )
+ let core = TestJavaTestLaunchCore(result: .success(expectedConfiguration))
+ let service = JavaTestDebugLaunchService(
+ configurationResolver: DebugLaunchConfigurationResolver(
+ fileExists: { _ in true },
+ javaTestLaunchResolver: core
+ ),
+ resultServerFactory: { resultServer }
+ )
+
+ let prepared = try await service.prepare(
+ fileURL: source,
+ testIdentifier: "service@example.UserServiceTest#logsIn",
+ rootURL: root,
+ targetResolver: targetResolver
+ )
+
+ #expect(targetResolver.requests == [TestJavaTestTargetResolver.Request(
+ fileURL: source,
+ testIdentifier: "service@example.UserServiceTest#logsIn",
+ rootURL: root
+ )])
+ #expect(resultServer.startCount == 1)
+ #expect(resultServer.stopCount == 0)
+ #expect(core.requests == [TestJavaTestLaunchCore.Request(
+ target: target,
+ resultPort: 43_128
+ )])
+ #expect(prepared.target == target)
+ #expect(prepared.configuration == expectedConfiguration)
+
+ prepared.stop()
+ #expect(resultServer.stopCount == 1)
+ }
+
+ @Test
+ func sharedConfigurationFailureStopsTheResultServer() async {
+ let source = URL(fileURLWithPath: "/workspace/UserServiceTest.java")
+ let resultServer = TestJavaTestResultServer(port: 43_128)
+ let service = JavaTestDebugLaunchService(
+ configurationResolver: DebugLaunchConfigurationResolver(
+ fileExists: { _ in true },
+ javaTestLaunchResolver: TestJavaTestLaunchCore(
+ result: .failure(TestJavaTestDebugError.configurationFailed)
+ )
+ ),
+ resultServerFactory: { resultServer }
+ )
+
+ await #expect(throws: TestJavaTestDebugError.configurationFailed) {
+ _ = try await service.prepare(
+ fileURL: source,
+ testIdentifier: nil,
+ rootURL: source.deletingLastPathComponent(),
+ targetResolver: TestJavaTestTargetResolver(
+ target: javaTestTarget(fileURL: source)
+ )
+ )
+ }
+ #expect(resultServer.startCount == 1)
+ #expect(resultServer.stopCount == 1)
+ }
+
+ @Test
+ func workspaceSelectionIsRejectedBeforeStartingAnAsyncLaunch() throws {
+ let root = FileManager.default.temporaryDirectory
+ .appendingPathComponent("lithe-java-test-debug-\(UUID().uuidString)")
+ try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
+ defer { try? FileManager.default.removeItem(at: root) }
+ let store = JavaTestDebugStore()
+ let settings = AppSettings(store: store)
+ let model = AppModel(
+ settings: settings,
+ services: MacServiceContainer(
+ store: store,
+ settings: settings,
+ moduleLaunchMode: .safeMode
+ ).services
+ )
+ model.openProjectDirectly(root)
+ model.clearNotifications()
+
+ model.debugTest(providerID: "java", scope: .workspace)
+
+ #expect(model.notificationMessage == "Select a Java test file or test case to debug")
+ #expect(model.javaTestWorkflowState.debugLaunchTask == nil)
+ #expect(model.javaTestWorkflowState.debugLaunchOperationID == nil)
+ }
+
+ @Test
+ func stoppingDebuggingReleasesTheJavaTestResultServer() {
+ let store = JavaTestDebugStore()
+ let settings = AppSettings(store: store)
+ let model = AppModel(
+ settings: settings,
+ services: MacServiceContainer(
+ store: store,
+ settings: settings,
+ moduleLaunchMode: .safeMode
+ ).services
+ )
+ let resultServer = TestJavaTestResultServer(port: 43_128)
+ model.javaTestWorkflowState.resultServer = resultServer
+
+ model.stopDebugging()
+
+ #expect(resultServer.stopCount == 1)
+ #expect(model.javaTestWorkflowState.resultServer == nil)
+ }
+
+ @Test
+ func terminalDebugStateReleasesTheJavaTestResultServer() {
+ let store = JavaTestDebugStore()
+ let settings = AppSettings(store: store)
+ let model = AppModel(
+ settings: settings,
+ services: MacServiceContainer(
+ store: store,
+ settings: settings,
+ moduleLaunchMode: .safeMode
+ ).services
+ )
+ let resultServer = TestJavaTestResultServer(port: 43_128)
+ model.javaTestWorkflowState.resultServer = resultServer
+ model.isTerminalVisible = true
+
+ model.handleDebugSessionStateChange(.running)
+ #expect(resultServer.stopCount == 0)
+ #expect(model.javaTestWorkflowState.resultServer != nil)
+ #expect(model.isDebugVisible)
+ #expect(!model.isTerminalVisible)
+
+ model.handleDebugSessionStateChange(.terminated)
+ #expect(resultServer.stopCount == 1)
+ #expect(model.javaTestWorkflowState.resultServer == nil)
+ }
+
+ @Test
+ func pausedDebugStateShowsDebuggerAndActivatesApplication() {
+ let store = JavaTestDebugStore()
+ let settings = AppSettings(store: store)
+ let platformUI = DebugActivationPlatformUI()
+ let model = AppModel(
+ settings: settings,
+ services: MacServiceContainer(
+ store: store,
+ settings: settings,
+ moduleLaunchMode: .safeMode,
+ platformUI: platformUI
+ ).services
+ )
+
+ model.handleDebugSessionStateChange(.paused)
+
+ #expect(model.isDebugVisible)
+ #expect(platformUI.activationCount == 1)
+ }
+
+ private func javaTestTarget(fileURL: URL) -> JavaTestDebugLaunchTarget {
+ JavaTestDebugLaunchTarget(
+ fileURL: fileURL,
+ name: "UserServiceTest",
+ framework: .junit,
+ workingDirectory: fileURL.deletingLastPathComponent().path,
+ mainClass: "org.eclipse.jdt.internal.junit.runner.RemoteTestRunner",
+ projectName: "service",
+ classPaths: ["/workspace/classes"],
+ modulePaths: [],
+ vmArguments: [],
+ programArguments: ["-port", "-1"]
+ )
+ }
+}
+
+@MainActor
+private final class DebugActivationPlatformUI: PlatformUI {
+ private(set) var activationCount = 0
+
+ func activateApplication() {
+ activationCount += 1
+ }
+
+ func chooseDirectory(title: String, prompt: String) -> URL? { nil }
+ func chooseFile(title: String, prompt: String) -> URL? { nil }
+ func revealInFileBrowser(_ url: URL) {}
+ func open(_ url: URL) {}
+ func copyToClipboard(_ value: String) {}
+ func markdownImageFromClipboard() -> MarkdownImageSource? { nil }
+}
+
+@MainActor
+private final class TestJavaTestTargetResolver: JavaTestDebugLaunchTargetResolving {
+ struct Request: Equatable {
+ let fileURL: URL
+ let testIdentifier: String?
+ let rootURL: URL
+ }
+
+ private let target: JavaTestDebugLaunchTarget
+ private(set) var requests: [Request] = []
+
+ init(target: JavaTestDebugLaunchTarget) {
+ self.target = target
+ }
+
+ func resolveJavaTestDebugLaunchTarget(
+ fileURL: URL,
+ testIdentifier: String?,
+ rootURL: URL
+ ) async throws -> JavaTestDebugLaunchTarget {
+ requests.append(Request(
+ fileURL: fileURL.standardizedFileURL,
+ testIdentifier: testIdentifier,
+ rootURL: rootURL.standardizedFileURL
+ ))
+ return target
+ }
+}
+
+@MainActor
+private final class TestJavaTestResultServer: JavaTestResultServing {
+ private let port: UInt16
+ private(set) var startCount = 0
+ private(set) var stopCount = 0
+
+ init(port: UInt16) {
+ self.port = port
+ }
+
+ func start() async throws -> UInt16 {
+ startCount += 1
+ return port
+ }
+
+ func stop() {
+ stopCount += 1
+ }
+}
+
+@MainActor
+private final class TestJavaTestLaunchCore: JavaTestDebugLaunchResolving, @unchecked Sendable {
+ struct Request: Equatable {
+ let target: JavaTestDebugLaunchTarget
+ let resultPort: UInt16
+ }
+
+ private let result: Result
+ private(set) var requests: [Request] = []
+
+ init(result: Result) {
+ self.result = result
+ }
+
+ func resolveJavaTestDebugLaunch(
+ target: JavaTestDebugLaunchTarget,
+ resultPort: UInt16
+ ) throws -> DebugLaunchConfiguration {
+ requests.append(Request(target: target, resultPort: resultPort))
+ return try result.get()
+ }
+}
+
+private enum TestJavaTestDebugError: Error, Equatable {
+ case configurationFailed
+}
+
+private final class JavaTestDebugStore: KeyValueStore, @unchecked Sendable {
+ private var values: [String: Any] = [:]
+
+ func data(forKey key: String) -> Data? { values[key] as? Data }
+ func object(forKey key: String) -> Any? { values[key] }
+ func string(forKey key: String) -> String? { values[key] as? String }
+ func stringArray(forKey key: String) -> [String]? { values[key] as? [String] }
+ func set(_ value: Any?, forKey key: String) { values[key] = value }
+}
diff --git a/macos/Tests/LitheTests/KeyboardShortcutTests.swift b/macos/Tests/LitheTests/KeyboardShortcutTests.swift
index 26dc2506a..c2751bca8 100644
--- a/macos/Tests/LitheTests/KeyboardShortcutTests.swift
+++ b/macos/Tests/LitheTests/KeyboardShortcutTests.swift
@@ -8,7 +8,7 @@ struct KeyboardShortcutTests {
@Test
func catalogHasStableUniqueCommandsAndConflictFreeDefaults() {
let commands = LitheCommandCatalog.commands
- #expect(commands.count == 33)
+ #expect(commands.count == 39)
#expect(Set(commands.map(\.id)).count == commands.count)
let owners = commands.flatMap { command in
@@ -21,6 +21,24 @@ struct KeyboardShortcutTests {
}
}
+ @Test
+ func toggleBreakpointUsesTheIDEADefaultShortcut() throws {
+ let command = try #require(LitheCommandCatalog.command(id: "toggle-breakpoint"))
+
+ #expect(command.defaultBindings == [
+ .keyPress(key: "f8", modifiers: [.command])
+ ])
+ }
+
+ @Test
+ func viewBreakpointsUsesTheIDEADefaultShortcut() throws {
+ let command = try #require(LitheCommandCatalog.command(id: "view-breakpoints"))
+
+ #expect(command.defaultBindings == [
+ .keyPress(key: "f8", modifiers: [.shift, .command])
+ ])
+ }
+
@Test
@MainActor
func actionRegistryCoversEveryCatalogCommand() {
@@ -49,6 +67,8 @@ struct KeyboardShortcutTests {
#expect(actionIDs.contains("go-to-implementation"))
#expect(actionIDs.contains("rebuild-java-index"))
#expect(actionIDs.contains("spring-endpoints"))
+ #expect(actionIDs.contains("toggle-breakpoint"))
+ #expect(actionIDs.contains("view-breakpoints"))
}
@Test
diff --git a/macos/Tests/LitheTests/LanguageServerToolServiceTests.swift b/macos/Tests/LitheTests/LanguageServerToolServiceTests.swift
index a4489358a..135607597 100644
--- a/macos/Tests/LitheTests/LanguageServerToolServiceTests.swift
+++ b/macos/Tests/LitheTests/LanguageServerToolServiceTests.swift
@@ -315,7 +315,6 @@ private struct LanguageServerToolTestRuntimeLocator: RuntimeLocator {
func systemMavenExecutable() -> URL? { nil }
func mavenExecutable(forHomePath _: String) -> URL? { nil }
func mavenRuntime(at _: URL) -> MavenRuntimeCandidate? { nil }
- func systemJDBExecutable() -> URL? { nil }
}
private struct LanguageServerToolTestDiscovery: RuntimeToolDiscovery {
diff --git a/macos/Tests/LitheTests/LitheCoreLogicTests.swift b/macos/Tests/LitheTests/LitheCoreLogicTests.swift
index cde850bb9..e249dffcc 100644
--- a/macos/Tests/LitheTests/LitheCoreLogicTests.swift
+++ b/macos/Tests/LitheTests/LitheCoreLogicTests.swift
@@ -3492,7 +3492,7 @@ struct EditorDocumentTests {
reloadProjectServices: {},
refreshGit: {},
updateHistoryVisibilityRules: { _ in },
- onSnapshotLoaded: { _, _ in }
+ onSnapshotLoaded: { _, _, _ in }
)
let workspace = URL(fileURLWithPath: "/tmp/retry-workspace")
@@ -3546,7 +3546,7 @@ struct EditorDocumentTests {
reloadProjectServices: {},
refreshGit: { gitRefreshCount += 1 },
updateHistoryVisibilityRules: { _ in },
- onSnapshotLoaded: { _, _ in snapshotLoadCount += 1 }
+ onSnapshotLoaded: { _, _, _ in snapshotLoadCount += 1 }
)
let workspace = URL(fileURLWithPath: "/tmp/lithe-initial-refresh")
model.beginWorkspace(at: workspace, visibilityRules: .default)
@@ -3560,6 +3560,151 @@ struct EditorDocumentTests {
#expect(gitRefreshCount == 1)
}
+ /// After the snapshot is published, restoreSession and watch setup can still
+ /// suspend. A project switch in that window must not deliver the old scan
+ /// through onSnapshotLoaded under the new workspace identity.
+ @Test
+ @MainActor
+ func rebuildRejectsStaleWorkspaceBeforeSnapshotCallback() async {
+ let enteredRestore = TestGate()
+ let releaseRestore = TestGate()
+ defer { releaseRestore.open() }
+
+ let operations = SequencedWorkspaceOperations(snapshotAvailability: [true])
+ let sessionStore = WorkspaceSessionStore(store: MutableKeyValueStore())
+ let workspace = URL(fileURLWithPath: "/tmp/lithe-stale-snapshot-callback")
+ sessionStore.save(
+ WorkspaceSession(openPaths: [], activePath: nil, selectedSidebar: "project"),
+ for: workspace
+ )
+
+ var snapshotLoadCount = 0
+ var isCurrent = true
+ let model = WorkspaceFeatureModel(
+ operations: operations,
+ fileOperations: EmptyWorkspaceFileOperations(),
+ fileStorage: InMemoryFileStorage(),
+ gitWatchContextProvider: SequencedGitWatchContextProvider([nil]),
+ directoryWatcherFactory: TestDirectoryWatcherFactory(),
+ workspaceSessionStore: sessionStore
+ )
+ model.configure(
+ documentsProvider: { [] },
+ activeDocumentProvider: { nil },
+ selectedSidebarProvider: { "project" },
+ setSelectedSidebar: { _ in },
+ restoreSession: { _, _ in
+ enteredRestore.open()
+ _ = await releaseRestore.waitUntilOpen(timeout: .seconds(5))
+ },
+ openFile: { _ in },
+ notify: { _ in },
+ recordHistory: { _, _ in },
+ relocateHistory: { _, _ in },
+ relocateOpenDocuments: { _, _ in },
+ closeDocuments: { _ in },
+ processExternalChanges: { _ in false },
+ reloadProjectServices: {},
+ refreshGit: {},
+ updateHistoryVisibilityRules: { _ in },
+ onSnapshotLoaded: { _, _, _ in snapshotLoadCount += 1 }
+ )
+
+ model.beginWorkspace(at: workspace, visibilityRules: .default)
+ let rebuildTask = Task {
+ await model.rebuild(
+ at: workspace,
+ rules: .default,
+ isCurrent: { isCurrent }
+ )
+ }
+
+ #expect(await enteredRestore.waitUntilOpen(timeout: .seconds(5)))
+ #expect(model.appliedSnapshot != nil, "the snapshot should already be published")
+ isCurrent = false
+ releaseRestore.open()
+
+ let result = await rebuildTask.value
+ if case .stale = result {} else {
+ Issue.record("A rebuild that lost isCurrent before the callback should report stale")
+ }
+ #expect(snapshotLoadCount == 0, "the stale rebuild must not deliver onSnapshotLoaded")
+ }
+
+ /// Closing and reopening the same path leaves the workspace URL unchanged, so
+ /// only the opening's generation can tell a refresh that outlived the close
+ /// from one that belongs to the current session. Without it, the earlier
+ /// refresh would publish its scan into the new opening after `reset`.
+ @Test
+ @MainActor
+ func refreshFromAnEarlierOpeningOfTheSamePathDoesNotDeliverItsSnapshot() async {
+ let enteredRestore = TestGate()
+ let releaseRestore = TestGate()
+ defer { releaseRestore.open() }
+
+ let operations = SequencedWorkspaceOperations(snapshotAvailability: [true])
+ let sessionStore = WorkspaceSessionStore(store: MutableKeyValueStore())
+ let workspace = URL(fileURLWithPath: "/tmp/lithe-same-path-reopen-refresh")
+ sessionStore.save(
+ WorkspaceSession(openPaths: [], activePath: nil, selectedSidebar: "project"),
+ for: workspace
+ )
+
+ var snapshotLoadCount = 0
+ let model = WorkspaceFeatureModel(
+ operations: operations,
+ fileOperations: EmptyWorkspaceFileOperations(),
+ fileStorage: InMemoryFileStorage(),
+ gitWatchContextProvider: SequencedGitWatchContextProvider([nil]),
+ directoryWatcherFactory: TestDirectoryWatcherFactory(),
+ workspaceSessionStore: sessionStore
+ )
+ model.configure(
+ documentsProvider: { [] },
+ activeDocumentProvider: { nil },
+ selectedSidebarProvider: { "project" },
+ setSelectedSidebar: { _ in },
+ restoreSession: { _, _ in
+ enteredRestore.open()
+ _ = await releaseRestore.waitUntilOpen(timeout: .seconds(5))
+ },
+ openFile: { _ in },
+ notify: { _ in },
+ recordHistory: { _, _ in },
+ relocateHistory: { _, _ in },
+ relocateOpenDocuments: { _, _ in },
+ closeDocuments: { _ in },
+ processExternalChanges: { _ in false },
+ reloadProjectServices: {},
+ refreshGit: {},
+ updateHistoryVisibilityRules: { _ in },
+ onSnapshotLoaded: { _, _, _ in snapshotLoadCount += 1 }
+ )
+
+ model.beginWorkspace(at: workspace, visibilityRules: .default)
+ // The refresh builds its own current guard, so this exercises production
+ // identity rather than a guard supplied by the test.
+ let refreshTask = Task { await model.refreshCurrent() }
+
+ #expect(await enteredRestore.waitUntilOpen(timeout: .seconds(5)))
+ #expect(model.appliedSnapshot != nil, "the snapshot should already be published")
+
+ // Close and reopen the same path while the refresh is suspended.
+ model.reset()
+ model.beginWorkspace(at: workspace, visibilityRules: .default)
+ releaseRestore.open()
+ await refreshTask.value
+
+ #expect(
+ snapshotLoadCount == 0,
+ "a refresh from the previous opening must not deliver its snapshot to the new one"
+ )
+ #expect(
+ model.appliedSnapshot == nil,
+ "the new opening has not scanned yet, so no snapshot should be applied"
+ )
+ }
+
@Test
@MainActor
func capturedProjectDeletionSurvivesConfirmationDialogDismissal() async throws {
@@ -3705,7 +3850,7 @@ struct EditorDocumentTests {
reloadProjectServices: {},
refreshGit: { refreshCount += 1 },
updateHistoryVisibilityRules: { _ in },
- onSnapshotLoaded: { _, _ in }
+ onSnapshotLoaded: { _, _, _ in }
)
let workspace = URL(fileURLWithPath: "/tmp/frozen-workspace")
@@ -4328,7 +4473,7 @@ private func makeWorkspaceObservationUnitModel(
reloadProjectServices: reloadProjectServices,
refreshGit: refreshGit,
updateHistoryVisibilityRules: { _ in },
- onSnapshotLoaded: { _, _ in }
+ onSnapshotLoaded: { _, _, _ in }
)
return model
}
@@ -4364,8 +4509,10 @@ private actor SequencedGitWatchContextProvider: GitWatchContextProviding {
private final class TestTerminalTransport: TerminalTransport {
let nativeView: AnyObject = NSView(frame: .zero)
var isRunning = false
+ var processID: Int32? { isRunning ? 1234 : nil }
var shellName = "Shell"
var onTermination: ((Int32?) -> Void)?
+ var onOutput: ((Data) -> Void)?
var onTitle: ((String) -> Void)?
var onDirectoryUpdate: ((String?) -> Void)?
var onLink: ((String, [String: String]) -> Void)?
@@ -4386,6 +4533,16 @@ private final class TestTerminalTransport: TerminalTransport {
isRunning = true
}
+ func startProcess(
+ _ launch: TerminalProcessLaunch,
+ environment: [String: String]
+ ) throws -> Int32 {
+ startRequests.append(launch.executablePath)
+ shellName = URL(fileURLWithPath: launch.executablePath).lastPathComponent
+ isRunning = true
+ return 1234
+ }
+
func send(_ input: Data) throws {}
func interrupt() throws {}
diff --git a/macos/Tests/LitheTests/MacDebugPortAvailabilityCheckerTests.swift b/macos/Tests/LitheTests/MacDebugPortAvailabilityCheckerTests.swift
new file mode 100644
index 000000000..efde9398b
--- /dev/null
+++ b/macos/Tests/LitheTests/MacDebugPortAvailabilityCheckerTests.swift
@@ -0,0 +1,47 @@
+import Darwin
+import Testing
+@testable import Lithe
+
+@Suite("macOS Debug port availability")
+@MainActor
+struct MacDebugPortAvailabilityCheckerTests {
+ @Test
+ func reportsAListeningPortAsUnavailableAndAReleasedPortAsAvailable() throws {
+ var descriptor = socket(AF_INET, SOCK_STREAM, 0)
+ #expect(descriptor >= 0)
+ guard descriptor >= 0 else { return }
+ defer {
+ if descriptor >= 0 { _ = close(descriptor) }
+ }
+
+ var address = sockaddr_in()
+ address.sin_len = UInt8(MemoryLayout.stride)
+ address.sin_family = sa_family_t(AF_INET)
+ address.sin_port = 0
+ address.sin_addr = in_addr(s_addr: INADDR_ANY)
+ let bindResult = withUnsafePointer(to: &address) { pointer in
+ pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) {
+ bind(descriptor, $0, socklen_t(MemoryLayout.stride))
+ }
+ }
+ #expect(bindResult == 0)
+ #expect(listen(descriptor, 1) == 0)
+
+ var boundAddress = sockaddr_in()
+ var boundLength = socklen_t(MemoryLayout.stride)
+ let nameResult = withUnsafeMutablePointer(to: &boundAddress) { pointer in
+ pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) {
+ getsockname(descriptor, $0, &boundLength)
+ }
+ }
+ #expect(nameResult == 0)
+ let port = Int(UInt16(bigEndian: boundAddress.sin_port))
+ #expect(port > 0)
+
+ let checker = MacDebugPortAvailabilityChecker()
+ #expect(!checker.isPortAvailable(port))
+ #expect(close(descriptor) == 0)
+ descriptor = -1
+ #expect(checker.isPortAvailable(port))
+ }
+}
diff --git a/macos/Tests/LitheTests/MacJavaTestResultServerTests.swift b/macos/Tests/LitheTests/MacJavaTestResultServerTests.swift
new file mode 100644
index 000000000..ce8acbf4f
--- /dev/null
+++ b/macos/Tests/LitheTests/MacJavaTestResultServerTests.swift
@@ -0,0 +1,69 @@
+import Foundation
+import Network
+import Testing
+@testable import Lithe
+
+@Suite("Java test result server")
+@MainActor
+struct MacJavaTestResultServerTests {
+ @Test
+ func startReturnsTheListenerPortAndStopCancelsIt() async throws {
+ let listener = TestJavaTestResultListener(readyPort: 43_128)
+ let server = MacJavaTestResultServer(listenerFactory: { listener })
+
+ #expect(try await server.start() == 43_128)
+ #expect(listener.startCount == 1)
+
+ server.stop()
+
+ #expect(listener.cancelCount == 1)
+ }
+
+ @Test
+ func cancellingStartupStopsThePendingListener() async {
+ let started = TestGate()
+ let listener = TestJavaTestResultListener(onStart: started.open)
+ let server = MacJavaTestResultServer(listenerFactory: { listener })
+ let startTask = Task { try await server.start() }
+ defer {
+ startTask.cancel()
+ server.stop()
+ }
+
+ #expect(await started.waitUntilOpen())
+ startTask.cancel()
+
+ await #expect(throws: CancellationError.self) {
+ try await startTask.value
+ }
+ #expect(listener.cancelCount == 1)
+ }
+}
+
+private final class TestJavaTestResultListener: MacJavaTestResultListening {
+ var onStateChange: ((MacJavaTestResultListenerState) -> Void)?
+ var onConnection: ((NWConnection) -> Void)?
+ private(set) var startCount = 0
+ private(set) var cancelCount = 0
+
+ private let readyPort: UInt16?
+ private let onStart: (() -> Void)?
+
+ init(readyPort: UInt16? = nil, onStart: (() -> Void)? = nil) {
+ self.readyPort = readyPort
+ self.onStart = onStart
+ }
+
+ func start(queue _: DispatchQueue) {
+ startCount += 1
+ onStart?()
+ if let readyPort {
+ onStateChange?(.ready(port: readyPort))
+ }
+ }
+
+ func cancel() {
+ cancelCount += 1
+ onStateChange?(.cancelled)
+ }
+}
diff --git a/macos/Tests/LitheTests/MacJdtWorkspaceStateTests.swift b/macos/Tests/LitheTests/MacJdtWorkspaceStateTests.swift
index fac3884b2..404df7b51 100644
--- a/macos/Tests/LitheTests/MacJdtWorkspaceStateTests.swift
+++ b/macos/Tests/LitheTests/MacJdtWorkspaceStateTests.swift
@@ -5,16 +5,16 @@ import Testing
@Suite("macOS JDT LS workspace state")
struct MacJdtWorkspaceStateTests {
@Test
- func fingerprintIncludesVersionWithoutRootBuildFiles() throws {
+ func fingerprintIncludesLanguageServerVersionAndInstallationPath() throws {
let fixture = try makeFixture()
defer { fixture.remove() }
let state = MacJdtWorkspaceState(
cacheDirectoryURL: fixture.cacheURL,
- workspaceFingerprintResolver: { buildFiles, modules, version in
+ workspaceFingerprintResolver: { buildFiles, modules, identity in
#expect(buildFiles.isEmpty)
#expect(modules.isEmpty)
- #expect(version == "7.6.5")
+ #expect(identity == "7.6.5|installation=\(fixture.executableURL.path)")
return "core-fingerprint"
},
workspaceKeyResolver: { _, _ in String(repeating: "a", count: 64) }
@@ -90,7 +90,7 @@ struct MacJdtWorkspaceStateTests {
)
#expect(Set(capture.last?.modules ?? []) == Set(["alpha", "zeta"]))
- #expect(capture.last?.version == "7.6.5")
+ #expect(capture.last?.version == "7.6.5|installation=\(fixture.executableURL.path)")
}
@Test
diff --git a/macos/Tests/LitheTests/MacProcessRunnerTests.swift b/macos/Tests/LitheTests/MacProcessRunnerTests.swift
index 6bd050e98..d544103ef 100644
--- a/macos/Tests/LitheTests/MacProcessRunnerTests.swift
+++ b/macos/Tests/LitheTests/MacProcessRunnerTests.swift
@@ -209,6 +209,23 @@ struct MacProcessRunnerTests {
#expect(!processIsRunning(descendantPID))
}
+ @Test
+ func stoppedChildProcessReaperCollectsAnExitedChild() async throws {
+ let processID = try spawnExitedTestChild()
+ defer { reapTestChildIfNecessary(processID) }
+ let reaped = TestGate()
+
+ MacStoppedChildProcessReaper().reapWhenExited(processID) {
+ reaped.open()
+ }
+
+ #expect(await reaped.waitUntilOpen())
+ var waitStatus: Int32 = 0
+ errno = 0
+ #expect(Darwin.waitpid(processID, &waitStatus, WNOHANG) == -1)
+ #expect(errno == ECHILD)
+ }
+
private func terminationResistantRequest(operationID: String) -> ProcessRequest {
ProcessRequest(
operationID: operationID,
@@ -261,6 +278,46 @@ struct MacProcessRunnerTests {
_ = Darwin.kill(pid, SIGKILL)
}
}
+
+ private func reapTestChildIfNecessary(_ pid: pid_t) {
+ var waitStatus: Int32 = 0
+ var waitResult = Darwin.waitpid(pid, &waitStatus, WNOHANG)
+ guard waitResult == 0 else { return }
+ _ = Darwin.kill(pid, SIGKILL)
+ repeat {
+ waitResult = Darwin.waitpid(pid, &waitStatus, 0)
+ } while waitResult == -1 && errno == EINTR
+ }
+
+ private func spawnExitedTestChild() throws -> pid_t {
+ let executablePath = "/usr/bin/true"
+ var processID: pid_t = 0
+ var arguments = [strdup(executablePath), nil]
+ var environment = ProcessInfo.processInfo.environment
+ .sorted { $0.key < $1.key }
+ .map { strdup("\($0.key)=\($0.value)") }
+ environment.append(nil)
+ defer {
+ arguments.forEach { free($0) }
+ environment.forEach { free($0) }
+ }
+ let result = arguments.withUnsafeMutableBufferPointer { argumentBuffer in
+ environment.withUnsafeMutableBufferPointer { environmentBuffer in
+ Darwin.posix_spawn(
+ &processID,
+ executablePath,
+ nil,
+ nil,
+ argumentBuffer.baseAddress,
+ environmentBuffer.baseAddress
+ )
+ }
+ }
+ guard result == 0 else {
+ throw POSIXError(POSIXErrorCode(rawValue: result) ?? .EIO)
+ }
+ return processID
+ }
}
private struct DescendantFixture {
diff --git a/macos/Tests/LitheTests/MavenRuntimeTests.swift b/macos/Tests/LitheTests/MavenRuntimeTests.swift
index 1398decf4..c3245fde9 100644
--- a/macos/Tests/LitheTests/MavenRuntimeTests.swift
+++ b/macos/Tests/LitheTests/MavenRuntimeTests.swift
@@ -354,7 +354,6 @@ private struct ProjectRelativeRuntimeLocator: RuntimeLocator {
func systemMavenExecutable() -> URL? { nil }
func mavenExecutable(forHomePath path: String) -> URL? { nil }
func mavenRuntime(at executableURL: URL) -> MavenRuntimeCandidate? { nil }
- func systemJDBExecutable() -> URL? { nil }
func javaLanguageServerExecutable() -> URL? { nil }
}
@@ -404,7 +403,6 @@ private final class BlockingRuntimeLocator: RuntimeLocator, @unchecked Sendable
func systemMavenExecutable() -> URL? { nil }
func mavenExecutable(forHomePath path: String) -> URL? { nil }
func mavenRuntime(at executableURL: URL) -> MavenRuntimeCandidate? { nil }
- func systemJDBExecutable() -> URL? { nil }
func javaLanguageServerExecutable() -> URL? { nil }
}
diff --git a/macos/Tests/LitheTests/ObservableChangeWaiter.swift b/macos/Tests/LitheTests/ObservableChangeWaiter.swift
new file mode 100644
index 000000000..62eda5c91
--- /dev/null
+++ b/macos/Tests/LitheTests/ObservableChangeWaiter.swift
@@ -0,0 +1,60 @@
+import Combine
+import Foundation
+
+/// Awaits an observable publication with a local deadline. Feature models
+/// publish from tasks they own, so a test cannot observe completion
+/// synchronously, and a poll loop would depend on machine speed.
+///
+/// Returns `false` when the deadline elapses first so the caller can turn the
+/// timeout into an assertion instead of hanging until the CI job is killed. The
+/// deadline is only paid by a failing test, so it stays well inside the timing
+/// harness budget.
+@MainActor
+func awaitChange(
+ on model: Model,
+ timeout: DispatchTimeInterval = .seconds(5),
+ until isSatisfied: @escaping @MainActor @Sendable () -> Bool
+) async -> Bool where Model.ObjectWillChangePublisher == ObservableObjectPublisher {
+ if isSatisfied() { return true }
+ return await withCheckedContinuation { continuation in
+ let resumption = SingleResumption(continuation)
+ // objectWillChange fires before each assignment, so the predicate runs
+ // on the following main-actor turn, once the publication has completed.
+ resumption.observe(model.objectWillChange.sink { _ in
+ Task { @MainActor in
+ guard isSatisfied() else { return }
+ resumption.finish(with: true)
+ }
+ })
+ DispatchQueue.main.asyncAfter(deadline: .now() + timeout) {
+ resumption.finish(with: false)
+ }
+ }
+}
+
+/// The observation and the deadline race for a continuation that may only be
+/// resumed once. Both arms run on the main thread.
+private final class SingleResumption: @unchecked Sendable {
+ private var continuation: CheckedContinuation?
+ private var observation: AnyCancellable?
+
+ init(_ continuation: CheckedContinuation) {
+ self.continuation = continuation
+ }
+
+ func observe(_ observation: AnyCancellable) {
+ guard continuation != nil else {
+ observation.cancel()
+ return
+ }
+ self.observation = observation
+ }
+
+ func finish(with value: Bool) {
+ guard let pending = continuation else { return }
+ continuation = nil
+ observation?.cancel()
+ observation = nil
+ pending.resume(returning: value)
+ }
+}
diff --git a/macos/Tests/LitheTests/RealJavaDebugIntegrationTests.swift b/macos/Tests/LitheTests/RealJavaDebugIntegrationTests.swift
new file mode 100644
index 000000000..8f8dee0b7
--- /dev/null
+++ b/macos/Tests/LitheTests/RealJavaDebugIntegrationTests.swift
@@ -0,0 +1,864 @@
+import Foundation
+import LitheCoreContracts
+import LitheDebugModule
+import LitheLanguageIntelligenceModule
+import LitheTerminalModule
+import Testing
+@testable import Lithe
+
+@Suite("Real Java Debug integration", .serialized)
+@MainActor
+struct RealJavaDebugIntegrationTests {
+ @Test
+ func springRequestHitsBreakpointInspectsStepsAndResumes() async throws {
+ let environment = ProcessInfo.processInfo.environment
+ guard environment["LITHE_RUN_JAVA_DEBUG_INTEGRATION"] == "1" else { return }
+
+ let repositoryRoot = Self.repositoryRoot
+ let jdtlsRoot = URL(
+ fileURLWithPath: environment["LITHE_JDTLS_ROOT"]
+ ?? repositoryRoot.appendingPathComponent(".artifacts/jdtls").path,
+ isDirectory: true
+ )
+ let javaURL = URL(
+ fileURLWithPath: environment["LITHE_JAVA_PATH"]
+ ?? repositoryRoot.appendingPathComponent(".artifacts/jdk-arm64/bin/java").path
+ )
+ let jdtlsURL = jdtlsRoot.appendingPathComponent("bin/jdtls")
+ let fileManager = FileManager.default
+ #expect(fileManager.isExecutableFile(atPath: javaURL.path))
+ #expect(fileManager.isExecutableFile(atPath: jdtlsURL.path))
+ guard fileManager.isExecutableFile(atPath: javaURL.path),
+ fileManager.isExecutableFile(atPath: jdtlsURL.path) else { return }
+
+ let rootURL = fileManager.temporaryDirectory.appendingPathComponent(
+ "lithe-real-java-debug-\(UUID().uuidString)",
+ isDirectory: true
+ )
+ let cacheURL = fileManager.temporaryDirectory.appendingPathComponent(
+ "\(rootURL.lastPathComponent)-jdtls-cache",
+ isDirectory: true
+ )
+ let fixtureURL = repositoryRoot.appendingPathComponent(
+ "shared/fixtures/projects/lithe-spring-boot-git-graph",
+ isDirectory: true
+ )
+ try fileManager.copyItem(at: fixtureURL, to: rootURL)
+ let mainURL = rootURL.appendingPathComponent(
+ "src/main/java/com/example/demo/DemoApplication.java"
+ )
+ let serviceURL = rootURL.appendingPathComponent(
+ "src/main/java/com/example/demo/user/UserService.java"
+ )
+ let serviceSource = try String(contentsOf: serviceURL, encoding: .utf8)
+ let serviceBreakpointLine = try #require(Self.line(
+ containing: "return repository.findAll();",
+ in: serviceSource
+ ))
+ let serviceConstructorLine = try #require(Self.line(
+ containing: "this.repository = repository;",
+ in: serviceSource
+ ))
+ let controllerSource = try String(contentsOf: rootURL.appendingPathComponent(
+ "src/main/java/com/example/demo/user/UserController.java"
+ ), encoding: .utf8)
+ let controllerURL = rootURL.appendingPathComponent(
+ "src/main/java/com/example/demo/user/UserController.java"
+ )
+ let controllerBreakpointLine = try #require(Self.line(
+ containing: "return service.listUsers();",
+ in: controllerSource
+ ))
+
+ let core = RustCoreBridge()
+ #expect(core.isAvailable)
+ guard core.isAvailable else { return }
+ let resources: JDTLSLaunchResources
+ switch MacJDTLSLaunchResourceResolver(
+ bundledJdtlsRootURL: jdtlsRoot
+ ).resolve(for: jdtlsURL) {
+ case .direct(let value):
+ resources = value
+ case .wrapperFallback:
+ Issue.record("The real Java Debug test requires direct JDT LS resources.")
+ return
+ case .unavailable(let message):
+ Issue.record("JDT LS resources are unavailable: \(message)")
+ return
+ }
+
+ let descriptor = try #require(LanguageProviderCatalog.standard.provider(for: mainURL))
+ let launch = try #require(descriptor.languageServerLaunch)
+ let languageSession = LanguageServerRuntimeSession(
+ providerID: descriptor.id,
+ executableURL: jdtlsURL,
+ arguments: launch.arguments,
+ environment: environment,
+ initializationOptions: launch.initializationOptions,
+ runtimeExecutableURL: javaURL,
+ jdtlsLaunchResources: resources,
+ cacheDirectoryURL: cacheURL,
+ initializeTimeout: 120,
+ requestTimeout: 120,
+ shutdownTimeout: 5,
+ core: core
+ )
+ let languageRuntime = RealJavaDebugLanguageRuntime(
+ descriptor: descriptor,
+ session: languageSession
+ )
+ let languageManager = LanguageToolingSessionManager(
+ catalog: LanguageProviderCatalog(descriptors: [descriptor]),
+ runtimes: [languageRuntime],
+ builtinCore: core
+ )
+ let protocolTrace = RealJavaDebugProtocolTrace()
+ let debugManager = DebugAdapterSessionManager(
+ providers: [DebugProviderDescriptor(
+ id: "java",
+ displayName: "Java",
+ fileExtensions: ["java"]
+ )]
+ ) { _, _ in
+ CoreDebugAdapterProtocolSession(
+ adapterID: "java",
+ transport: RealJavaDebugRecordingTransport(
+ wrapping: MacJavaDebugAdapterTransport(
+ portResolver: { rootURL in
+ try await languageManager.startJavaDebugServer(rootURL: rootURL)
+ }
+ ),
+ trace: protocolTrace
+ ),
+ core: core,
+ deadlineScheduler: MacDebugOperationDeadlineScheduler()
+ )
+ }
+ let feature = GenericDebugFeatureModel(sessions: debugManager)
+ let debugTerminals = RealJavaDebugTerminalOwner(workspaceURL: rootURL)
+ feature.onRunInTerminalRequest = debugTerminals.handle
+ let portAllocator = MacJavaTestResultServer()
+ let springPort = try await portAllocator.start()
+ portAllocator.stop()
+ var requestTask: Task<(Data, URLResponse), Error>?
+ defer {
+ requestTask?.cancel()
+ feature.stop()
+ debugTerminals.stop()
+ languageManager.stopAll()
+ try? fileManager.removeItem(at: rootURL)
+ try? fileManager.removeItem(at: cacheURL)
+ }
+
+ let mainSource = try String(contentsOf: mainURL, encoding: .utf8)
+ try languageManager.synchronizeLanguageServer(
+ for: mainURL,
+ text: mainSource,
+ rootURL: rootURL
+ )
+ let target: JavaDebugLaunchTarget
+ do {
+ target = try await languageManager.resolveJavaDebugLaunchTarget(
+ fileURL: mainURL,
+ rootURL: rootURL
+ )
+ } catch {
+ throw RealJavaDebugIntegrationError.languageToolingFailed(
+ message: String(describing: error),
+ logs: Self.languageServerLogSummary(languageManager.languageServerLogs)
+ )
+ }
+ // Start at the controller call so the real integration test exercises
+ // both Java step-into and step-out, not only a step-over at a leaf line.
+ feature.toggleBreakpoint(fileURL: controllerURL, line: controllerBreakpointLine)
+ feature.toggleBreakpoint(fileURL: serviceURL, line: serviceBreakpointLine)
+ feature.toggleBreakpoint(fileURL: serviceURL, line: serviceConstructorLine)
+ // Verify the Java adapter receives and honors the condition field,
+ // rather than only exercising an unconditional source breakpoint.
+ feature.updateBreakpoint(
+ fileURL: controllerURL,
+ line: controllerBreakpointLine,
+ enabled: true,
+ condition: "true",
+ hitCondition: "1",
+ logMessage: nil
+ )
+ // A logpoint must emit a Debug Console message without stopping the
+ // application. Keep it on the constructor so it is exercised during
+ // Spring Boot startup before the HTTP request breakpoint.
+ feature.updateBreakpoint(
+ fileURL: serviceURL,
+ line: serviceConstructorLine,
+ enabled: true,
+ condition: nil,
+ hitCondition: nil,
+ logMessage: "entered UserService constructor"
+ )
+ var arguments: [String: ToolingJSONValue] = [
+ "mainClass": .string(target.mainClass),
+ "cwd": .string(rootURL.path),
+ "console": .string("integratedTerminal"),
+ "args": .string("--server.port=\(springPort)")
+ ]
+ if let projectName = target.projectName {
+ arguments["projectName"] = .string(projectName)
+ }
+ if !target.modulePaths.isEmpty {
+ arguments["modulePaths"] = .array(target.modulePaths.map(ToolingJSONValue.string))
+ }
+ if !target.classPaths.isEmpty {
+ arguments["classPaths"] = .array(target.classPaths.map(ToolingJSONValue.string))
+ }
+ #expect(feature.start(
+ fileURL: mainURL,
+ rootURL: rootURL,
+ configuration: DebugLaunchConfiguration(
+ name: "Spring Debug Integration",
+ request: .launch,
+ arguments: arguments
+ )
+ ))
+
+ #expect(await Self.waitUntil(timeout: .seconds(120)) {
+ feature.state == .running
+ }, "Java Debug Server did not reach the running state. Output:\n\(feature.output)")
+ #expect(await Self.waitUntil(timeout: .seconds(120)) {
+ feature.breakpoints.count == 3 && feature.breakpoints.allSatisfy(\.verified)
+ }, "The Java breakpoints were not verified. Output:\n\(feature.output)")
+ #expect(
+ protocolTrace.entries.contains { $0.contains("\"condition\":\"true\"") },
+ "The Java condition breakpoint was not sent to the adapter."
+ )
+ #expect(
+ protocolTrace.entries.contains { $0.contains("\"hitCondition\":\"1\"") },
+ "The Java hit-count breakpoint was not sent to the adapter."
+ )
+ #expect(
+ protocolTrace.entries.contains {
+ $0.contains("\"logMessage\":\"entered UserService constructor\"")
+ },
+ "The Java logpoint was not sent to the adapter."
+ )
+ guard await Self.waitForSpringServer(port: springPort, timeout: .seconds(120)) else {
+ throw RealJavaDebugIntegrationError.springServerDidNotStart(
+ "expectedPort=\(springPort)\n" + Self.debugSnapshot(feature, protocolTrace: protocolTrace)
+ )
+ }
+ #expect(
+ feature.output.contains("entered UserService constructor"),
+ "The Java logpoint did not produce a Debug Console message."
+ )
+
+ var request = URLRequest(
+ url: URL(string: "http://127.0.0.1:\(springPort)/api/users")!
+ )
+ request.timeoutInterval = 60
+ requestTask = Task { try await URLSession.shared.data(for: request) }
+ guard await Self.waitUntil(timeout: .seconds(60), condition: {
+ feature.state == .paused
+ }) else {
+ throw RealJavaDebugIntegrationError.debuggerDidNotPause(
+ Self.debugSnapshot(feature, protocolTrace: protocolTrace)
+ )
+ }
+ guard await Self.waitUntil(timeout: .seconds(30), condition: {
+ feature.selectedFrame?.sourceURL?.standardizedFileURL
+ == controllerURL.standardizedFileURL
+ && feature.selectedFrame?.line == controllerBreakpointLine
+ }) else {
+ throw RealJavaDebugIntegrationError.stoppedFrameUnavailable(
+ Self.debugSnapshot(feature, protocolTrace: protocolTrace)
+ )
+ }
+ #expect(await Self.waitUntil(timeout: .seconds(30)) {
+ !feature.variables.isEmpty
+ }, "No variables were loaded for the stopped Java frame.")
+ // Exercise the same frame-scoped evaluation path used by the Debug
+ // console and inline inspection, not only the variables request.
+ let outputBeforeEvaluation = feature.output
+ feature.evaluate("service")
+ #expect(await Self.waitUntil(timeout: .seconds(30)) {
+ feature.output.count > outputBeforeEvaluation.count
+ && feature.output.contains("service =")
+ }, "The stopped Java frame did not evaluate the service expression.")
+
+ feature.execute(.stepIn)
+ #expect(await Self.waitUntil(timeout: .seconds(30)) {
+ feature.state == .paused
+ && feature.selectedFrame?.sourceURL?.standardizedFileURL
+ == serviceURL.standardizedFileURL
+ && feature.selectedFrame?.line == serviceBreakpointLine
+ }, "Step into did not enter UserService.listUsers().\n\(Self.debugSnapshot(feature, protocolTrace: protocolTrace))")
+
+ feature.execute(.stepOut)
+ #expect(await Self.waitUntil(timeout: .seconds(30)) {
+ feature.state == .paused
+ && feature.selectedFrame?.sourceURL?.standardizedFileURL
+ == controllerURL.standardizedFileURL
+ && feature.selectedFrame?.line == controllerBreakpointLine
+ }, "Step out did not return to UserController.list().\n\(Self.debugSnapshot(feature, protocolTrace: protocolTrace))")
+
+ let frameBeforeStepOver = try #require(feature.selectedFrame)
+ feature.execute(.next)
+ #expect(await Self.waitUntil(timeout: .seconds(30)) {
+ feature.state == .paused && feature.selectedFrame?.id != frameBeforeStepOver.id
+ }, "Step over did not reach the next Java source position.\n\(Self.debugSnapshot(feature, protocolTrace: protocolTrace))")
+ feature.execute(.continueExecution)
+ guard await Self.waitUntil(timeout: .seconds(10), condition: {
+ feature.state == .running || feature.state == .terminated
+ }) else {
+ throw RealJavaDebugIntegrationError.debuggerDidNotResume(
+ "Continue did not leave the paused state.\n" + Self.debugSnapshot(feature, protocolTrace: protocolTrace)
+ )
+ }
+
+ let response: (Data, URLResponse)
+ do {
+ response = try await Self.value(of: try #require(requestTask), timeout: .seconds(60))
+ } catch {
+ throw RealJavaDebugIntegrationError.debuggerDidNotResume(
+ "(error)\n" + Self.debugSnapshot(feature, protocolTrace: protocolTrace)
+ )
+ }
+ let httpResponse = try #require(response.1 as? HTTPURLResponse)
+ #expect(httpResponse.statusCode == 200)
+ let body = String(decoding: response.0, as: UTF8.self)
+ #expect(body.contains("Ada Lovelace"))
+ #expect(body.contains("Grace Hopper"))
+ }
+
+ @Test
+ func junitMethodHitsBreakpointAndResumes() async throws {
+ try await Self.runJavaTestDebug(.junit)
+ }
+
+ @Test
+ func testngMethodHitsBreakpointAndResumes() async throws {
+ try await Self.runJavaTestDebug(.testng)
+ }
+
+ private static func runJavaTestDebug(_ scenario: RealJavaTestDebugScenario) async throws {
+ let environment = ProcessInfo.processInfo.environment
+ guard environment["LITHE_RUN_JAVA_TEST_DEBUG_INTEGRATION"] == "1" else { return }
+
+ let repositoryRoot = Self.repositoryRoot
+ let jdtlsRoot = URL(
+ fileURLWithPath: environment["LITHE_JDTLS_ROOT"]
+ ?? repositoryRoot.appendingPathComponent(".artifacts/jdtls").path,
+ isDirectory: true
+ )
+ let javaURL = URL(
+ fileURLWithPath: environment["LITHE_JAVA_PATH"]
+ ?? repositoryRoot.appendingPathComponent(".artifacts/jdk-arm64/bin/java").path
+ )
+ let jdtlsURL = jdtlsRoot.appendingPathComponent("bin/jdtls")
+ let fileManager = FileManager.default
+ #expect(fileManager.isExecutableFile(atPath: javaURL.path))
+ #expect(fileManager.isExecutableFile(atPath: jdtlsURL.path))
+ guard fileManager.isExecutableFile(atPath: javaURL.path),
+ fileManager.isExecutableFile(atPath: jdtlsURL.path) else { return }
+
+ let rootURL = fileManager.temporaryDirectory.appendingPathComponent(
+ "lithe-real-java-test-debug-\(scenario.rawValue)-\(UUID().uuidString)",
+ isDirectory: true
+ )
+ let cacheURL = fileManager.temporaryDirectory.appendingPathComponent(
+ "\(rootURL.lastPathComponent)-jdtls-cache",
+ isDirectory: true
+ )
+ let fixtureURL = repositoryRoot.appendingPathComponent(
+ "shared/fixtures/projects/lithe-spring-boot-git-graph",
+ isDirectory: true
+ )
+ try fileManager.copyItem(at: fixtureURL, to: rootURL)
+ defer {
+ try? fileManager.removeItem(at: rootURL)
+ try? fileManager.removeItem(at: cacheURL)
+ }
+ try scenario.prepareProject(at: rootURL, fileManager: fileManager)
+ let testURL = rootURL.appendingPathComponent(
+ "src/test/java/com/example/demo/user/UserServiceTest.java"
+ )
+ let testSource = try String(contentsOf: testURL, encoding: .utf8)
+ let breakpointLine = try #require(line(
+ containing: scenario.breakpointNeedle,
+ in: testSource
+ ))
+
+ let core = RustCoreBridge()
+ #expect(core.isAvailable)
+ guard core.isAvailable else { return }
+ let resources: JDTLSLaunchResources
+ switch MacJDTLSLaunchResourceResolver(
+ bundledJdtlsRootURL: jdtlsRoot
+ ).resolve(for: jdtlsURL) {
+ case .direct(let value):
+ resources = value
+ case .wrapperFallback:
+ Issue.record("The real Java test Debug test requires direct JDT LS resources.")
+ return
+ case .unavailable(let message):
+ Issue.record("JDT LS resources are unavailable: \(message)")
+ return
+ }
+
+ let descriptor = try #require(LanguageProviderCatalog.standard.provider(for: testURL))
+ let launch = try #require(descriptor.languageServerLaunch)
+ let languageSession = LanguageServerRuntimeSession(
+ providerID: descriptor.id,
+ executableURL: jdtlsURL,
+ arguments: launch.arguments,
+ environment: environment,
+ initializationOptions: launch.initializationOptions,
+ runtimeExecutableURL: javaURL,
+ jdtlsLaunchResources: resources,
+ cacheDirectoryURL: cacheURL,
+ initializeTimeout: 120,
+ requestTimeout: 120,
+ shutdownTimeout: 5,
+ core: core
+ )
+ let languageRuntime = RealJavaDebugLanguageRuntime(
+ descriptor: descriptor,
+ session: languageSession
+ )
+ let languageManager = LanguageToolingSessionManager(
+ catalog: LanguageProviderCatalog(descriptors: [descriptor]),
+ runtimes: [languageRuntime],
+ builtinCore: core
+ )
+ let protocolTrace = RealJavaDebugProtocolTrace()
+ let debugManager = DebugAdapterSessionManager(
+ providers: [DebugProviderDescriptor(
+ id: "java",
+ displayName: "Java",
+ fileExtensions: ["java"]
+ )]
+ ) { _, _ in
+ CoreDebugAdapterProtocolSession(
+ adapterID: "java",
+ transport: RealJavaDebugRecordingTransport(
+ wrapping: MacJavaDebugAdapterTransport(
+ portResolver: { rootURL in
+ try await languageManager.startJavaDebugServer(rootURL: rootURL)
+ }
+ ),
+ trace: protocolTrace
+ ),
+ core: core,
+ deadlineScheduler: MacDebugOperationDeadlineScheduler()
+ )
+ }
+ let feature = GenericDebugFeatureModel(sessions: debugManager)
+ let debugTerminals = RealJavaDebugTerminalOwner(workspaceURL: rootURL)
+ feature.onRunInTerminalRequest = debugTerminals.handle
+ let launchService = JavaTestDebugLaunchService(
+ configurationResolver: DebugLaunchConfigurationResolver(
+ fileExists: { fileManager.fileExists(atPath: $0.path) },
+ javaTestLaunchResolver: core
+ ),
+ resultServerFactory: { MacJavaTestResultServer() }
+ )
+ defer {
+ feature.stop()
+ debugTerminals.stop()
+ languageManager.stopAll()
+ }
+
+ let prepared: PreparedJavaTestDebugLaunch
+ do {
+ prepared = try await launchService.prepare(
+ fileURL: testURL,
+ testIdentifier: scenario.testIdentifier,
+ rootURL: rootURL,
+ targetResolver: languageManager
+ )
+ } catch {
+ throw RealJavaDebugIntegrationError.languageToolingFailed(
+ message: String(describing: error),
+ logs: languageServerLogSummary(languageManager.languageServerLogs)
+ )
+ }
+ defer { prepared.stop() }
+
+ #expect(prepared.target.framework == scenario.framework)
+ feature.toggleBreakpoint(fileURL: testURL, line: breakpointLine)
+ #expect(feature.start(
+ fileURL: testURL,
+ rootURL: rootURL,
+ configuration: prepared.configuration
+ ))
+
+ guard await waitUntil(timeout: .seconds(120), condition: {
+ feature.state == .paused
+ }) else {
+ throw RealJavaDebugIntegrationError.debuggerDidNotPause(
+ debugSnapshot(feature, protocolTrace: protocolTrace)
+ )
+ }
+ #expect(
+ feature.breakpoints.first?.verified == true,
+ "The Java test breakpoint was not verified.\n\(debugSnapshot(feature, protocolTrace: protocolTrace))"
+ )
+ guard await waitUntil(timeout: .seconds(30), condition: {
+ feature.selectedFrame?.sourceURL?.standardizedFileURL
+ == testURL.standardizedFileURL
+ && feature.selectedFrame?.line == breakpointLine
+ }) else {
+ throw RealJavaDebugIntegrationError.stoppedFrameUnavailable(
+ debugSnapshot(feature, protocolTrace: protocolTrace)
+ )
+ }
+ #expect(await waitUntil(timeout: .seconds(30)) {
+ feature.variables.contains { $0.name == scenario.expectedVariable }
+ }, "The stopped Java test frame did not expose \(scenario.expectedVariable).")
+
+ feature.execute(.continueExecution)
+ #expect(await waitUntil(timeout: .seconds(60)) {
+ feature.state == .terminated
+ }, "Java test Debug did not terminate.\n\(debugSnapshot(feature, protocolTrace: protocolTrace))")
+ }
+
+ private static var repositoryRoot: URL {
+ URL(fileURLWithPath: #filePath)
+ .deletingLastPathComponent()
+ .deletingLastPathComponent()
+ .deletingLastPathComponent()
+ .deletingLastPathComponent()
+ }
+
+ private static func line(containing needle: String, in source: String) -> Int? {
+ source.split(separator: "\n", omittingEmptySubsequences: false)
+ .firstIndex { $0.contains(needle) }
+ .map { $0 + 1 }
+ }
+
+ private static func languageServerLogSummary(
+ _ entries: [LanguageServerLogEntry]
+ ) -> String {
+ entries.reversed().map { entry in
+ [entry.level.rawValue, entry.message, entry.detail]
+ .compactMap { $0 }
+ .joined(separator: " | ")
+ }.joined(separator: "\n")
+ }
+
+ private static func debugSnapshot(
+ _ feature: GenericDebugFeatureModel,
+ protocolTrace: RealJavaDebugProtocolTrace
+ ) -> String {
+ let breakpointSummary = feature.breakpoints.map {
+ "\($0.title) enabled=\($0.enabled) verified=\($0.verified) message=\($0.message ?? "nil")"
+ }.joined(separator: "\n")
+ let threadSummary = feature.threads.map { "\($0.id):\($0.name)" }.joined(separator: ", ")
+ let recentTrace = protocolTrace.entries.suffix(40).joined(separator: "\n")
+ return """
+ state=\(feature.state)
+ stoppedReason=\(feature.stoppedReason ?? "nil")
+ selectedThreadID=\(feature.selectedThreadID.map(String.init) ?? "nil")
+ selectedFrame=\(feature.selectedFrame.map { "\($0.name) @ \($0.line)" } ?? "nil")
+ threads=\(threadSummary)
+ breakpoints:
+ \(breakpointSummary)
+ output:
+ \(feature.output)
+ Recent DAP trace:
+ \(recentTrace)
+ """
+ }
+
+ private static func waitForSpringServer(
+ port: UInt16,
+ timeout: Duration
+ ) async -> Bool {
+ let clock = ContinuousClock()
+ let deadline = clock.now.advanced(by: timeout)
+ let url = URL(string: "http://127.0.0.1:\(port)/")!
+ while clock.now < deadline {
+ var request = URLRequest(url: url)
+ request.timeoutInterval = 2
+ do {
+ _ = try await URLSession.shared.data(for: request)
+ return true
+ } catch {
+ // Spring Boot may still be starting while the debuggee is
+ // already attached and accepting debugger requests.
+ }
+ // test-stability: allow(swift-real-sleep) reason: The external Spring process exposes readiness only through its loopback listener.
+ try? await Task.sleep(for: .milliseconds(100))
+ }
+ return false
+ }
+
+ private static func waitUntil(
+ timeout: Duration,
+ condition: @escaping @MainActor () -> Bool
+ ) async -> Bool {
+ let clock = ContinuousClock()
+ let deadline = clock.now.advanced(by: timeout)
+ while clock.now < deadline {
+ if condition() { return true }
+ // test-stability: allow(swift-real-sleep) reason: External JDT LS and JVM state arrives only through production process callbacks.
+ try? await Task.sleep(for: .milliseconds(25))
+ }
+ return condition()
+ }
+
+ private static func value(
+ of task: Task,
+ timeout: Duration
+ ) async throws -> T {
+ try await withThrowingTaskGroup(of: T.self) { group in
+ group.addTask { try await task.value }
+ group.addTask {
+ // test-stability: allow(swift-real-sleep) reason: The real HTTP request needs a local deadline independent of the test runner.
+ try await Task.sleep(for: timeout)
+ throw RealJavaDebugIntegrationError.timedOut
+ }
+ defer { group.cancelAll() }
+ return try await group.next()!
+ }
+ }
+}
+
+private enum RealJavaTestDebugScenario: String {
+ case junit
+ case testng
+
+ var framework: JavaTestDebugFramework {
+ switch self {
+ case .junit: .junit
+ case .testng: .testng
+ }
+ }
+
+ var testIdentifier: String {
+ switch self {
+ case .junit: "com.example.demo.user.UserServiceTest#addsNumbers()"
+ case .testng: "com.example.demo.user.UserServiceTest#multipliesNumbers()"
+ }
+ }
+
+ var breakpointNeedle: String {
+ switch self {
+ case .junit: "int total = left + right;"
+ case .testng: "int product = left * right;"
+ }
+ }
+
+ var expectedVariable: String {
+ switch self {
+ case .junit: "left"
+ case .testng: "left"
+ }
+ }
+
+ func prepareProject(at rootURL: URL, fileManager: FileManager) throws {
+ let testDirectory = rootURL.appendingPathComponent(
+ "src/test/java/com/example/demo/user",
+ isDirectory: true
+ )
+ try fileManager.createDirectory(
+ at: testDirectory,
+ withIntermediateDirectories: true
+ )
+ let sourceURL = testDirectory.appendingPathComponent("UserServiceTest.java")
+ try source.write(to: sourceURL, atomically: true, encoding: .utf8)
+ guard self == .testng else { return }
+
+ let pomURL = rootURL.appendingPathComponent("pom.xml")
+ var pom = try String(contentsOf: pomURL, encoding: .utf8)
+ guard let insertion = pom.range(of: "") else {
+ throw RealJavaDebugIntegrationError.invalidFixture("Missing Maven dependencies section")
+ }
+ pom.insert(contentsOf: testNGDependency, at: insertion.lowerBound)
+ try pom.write(to: pomURL, atomically: true, encoding: .utf8)
+ }
+
+ private var source: String {
+ switch self {
+ case .junit:
+ """
+ package com.example.demo.user;
+
+ import static org.junit.jupiter.api.Assertions.assertEquals;
+
+ import org.junit.jupiter.api.Test;
+
+ class UserServiceTest {
+ @Test
+ void addsNumbers() {
+ int left = 20;
+ int right = 22;
+ int total = left + right;
+ assertEquals(42, total);
+ }
+ }
+ """
+ case .testng:
+ """
+ package com.example.demo.user;
+
+ import org.testng.Assert;
+ import org.testng.annotations.Test;
+
+ public class UserServiceTest {
+ @Test
+ public void multipliesNumbers() {
+ int left = 6;
+ int right = 7;
+ int product = left * right;
+ Assert.assertEquals(product, 42);
+ }
+ }
+ """
+ }
+ }
+
+ private var testNGDependency: String {
+ """
+
+ org.testng
+ testng
+ 7.10.2
+ test
+
+ """
+ }
+}
+
+@MainActor
+private final class RealJavaDebugTerminalOwner {
+ private let workspaceURL: URL
+ private let feature = TerminalFeatureModel(
+ terminalFactory: { MacTerminalTransport() }
+ )
+
+ init(workspaceURL: URL) {
+ self.workspaceURL = workspaceURL.standardizedFileURL
+ }
+
+ func handle(
+ _ request: DebugRunInTerminalRequest,
+ completion: @escaping DebugRunInTerminalCompletion
+ ) {
+ do {
+ guard request.kind == .integrated else {
+ throw RealJavaDebugTerminalError.externalTerminalUnsupported
+ }
+ guard !request.argsCanBeInterpretedByShell else {
+ throw RealJavaDebugTerminalError.shellInterpretationUnsupported
+ }
+ guard let executablePath = request.args.first, !executablePath.isEmpty else {
+ throw RealJavaDebugTerminalError.missingExecutable
+ }
+ let workingDirectory = request.cwd.isEmpty ? workspaceURL.path : request.cwd
+ guard workingDirectory.hasPrefix("/") else {
+ throw RealJavaDebugTerminalError.invalidWorkingDirectory
+ }
+ let launch = TerminalProcessLaunch(
+ title: request.title,
+ executablePath: executablePath,
+ arguments: Array(request.args.dropFirst()),
+ workingDirectory: workingDirectory,
+ environmentChanges: request.environment.map {
+ TerminalEnvironmentChange(name: $0.name, value: $0.value)
+ }
+ )
+ let created = try feature.createProcessSession(launch)
+ completion(.success(DebugRunInTerminalResponse(processID: Int(created.processID))))
+ } catch {
+ completion(.failure(error))
+ }
+ }
+
+ func stop() {
+ feature.stopAllSessions()
+ }
+}
+
+private enum RealJavaDebugTerminalError: Error {
+ case externalTerminalUnsupported
+ case shellInterpretationUnsupported
+ case missingExecutable
+ case invalidWorkingDirectory
+}
+
+@MainActor
+private final class RealJavaDebugProtocolTrace {
+ private(set) var entries: [String] = []
+
+ func record(direction: String, data: Data) {
+ entries.append("\(direction) \(Self.payload(in: data))")
+ }
+
+ private static func payload(in data: Data) -> String {
+ guard let text = String(data: data, encoding: .utf8),
+ let separator = text.range(of: "\r\n\r\n") else {
+ return String(decoding: data, as: UTF8.self)
+ }
+ return String(text[separator.upperBound...])
+ }
+}
+
+@MainActor
+private final class RealJavaDebugRecordingTransport: DebugAdapterTransport {
+ private let wrapped: any DebugAdapterTransport
+ private let trace: RealJavaDebugProtocolTrace
+
+ var isRunning: Bool { wrapped.isRunning }
+ var onData: ((Data) -> Void)?
+ var onErrorOutput: ((Data) -> Void)?
+ var onTermination: ((Int) -> Void)?
+
+ init(
+ wrapping wrapped: any DebugAdapterTransport,
+ trace: RealJavaDebugProtocolTrace
+ ) {
+ self.wrapped = wrapped
+ self.trace = trace
+ wrapped.onData = { [weak self] data in
+ self?.trace.record(direction: "<-", data: data)
+ self?.onData?(data)
+ }
+ wrapped.onErrorOutput = { [weak self] data in self?.onErrorOutput?(data) }
+ wrapped.onTermination = { [weak self] code in self?.onTermination?(code) }
+ }
+
+ func start(rootURL: URL) throws {
+ try wrapped.start(rootURL: rootURL)
+ }
+
+ func send(_ data: Data) throws {
+ trace.record(direction: "->", data: data)
+ try wrapped.send(data)
+ }
+
+ func stop() {
+ wrapped.stop()
+ }
+}
+
+@MainActor
+private final class RealJavaDebugLanguageRuntime: LanguageProviderRuntime {
+ let descriptor: LanguageProviderDescriptor
+ let supportsLanguageServerSession = true
+ private let session: any LanguageServerSession
+
+ init(descriptor: LanguageProviderDescriptor, session: any LanguageServerSession) {
+ self.descriptor = descriptor
+ self.session = session
+ }
+
+ func makeLanguageServerSession() -> (any LanguageServerSession)? { session }
+}
+
+private enum RealJavaDebugIntegrationError: Error {
+ case timedOut
+ case languageToolingFailed(message: String, logs: String)
+ case springServerDidNotStart(String)
+ case debuggerDidNotPause(String)
+ case debuggerDidNotResume(String)
+ case stoppedFrameUnavailable(String)
+ case invalidFixture(String)
+}
diff --git a/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift b/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift
index 1e09f1661..9ebb3d9e5 100644
--- a/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift
+++ b/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift
@@ -9,6 +9,57 @@ import Testing
@Suite("Run configuration integration")
@MainActor
struct RunConfigurationIntegrationTests {
+ @Test
+ func portConflictTitleIncludesTheActualPortAndConfigurations() {
+ let conflict = RunPortConflict(
+ port: 18080,
+ configurationNames: ["api", "worker"]
+ )
+
+ #expect(conflict.title == "Port 18080 is used by api, worker")
+ }
+
+ @Test
+ func javaBreakpointLocationPreflightRejectsNonExecutableLines() {
+ let source = """
+ package demo;
+ // comment
+
+ public class Main {
+ /* block comment */
+ void run() {
+ System.out.println("ok");
+ }
+ }
+ """
+
+ #expect(!DebugBreakpointLocationValidator.isExecutableJavaLine(source: source, line: 2))
+ #expect(!DebugBreakpointLocationValidator.isExecutableJavaLine(source: source, line: 3))
+ #expect(!DebugBreakpointLocationValidator.isExecutableJavaLine(source: source, line: 5))
+ #expect(DebugBreakpointLocationValidator.isExecutableJavaLine(source: source, line: 7))
+ #expect(!DebugBreakpointLocationValidator.isExecutableJavaLine(source: source, line: 8))
+ }
+
+ @Test
+ func javaBreakpointLocationPreflightRejectsTypeDeclarationsWithAnyModifierOrder() {
+ let source = """
+ public final class Main {
+ private static interface Nested {
+ void run();
+ }
+ protected abstract record Value(String text) { }
+ static enum Kind { ONE }
+ void execute() { }
+ }
+ """
+
+ #expect(!DebugBreakpointLocationValidator.isExecutableJavaLine(source: source, line: 1))
+ #expect(!DebugBreakpointLocationValidator.isExecutableJavaLine(source: source, line: 2))
+ #expect(!DebugBreakpointLocationValidator.isExecutableJavaLine(source: source, line: 5))
+ #expect(!DebugBreakpointLocationValidator.isExecutableJavaLine(source: source, line: 6))
+ #expect(DebugBreakpointLocationValidator.isExecutableJavaLine(source: source, line: 7))
+ }
+
@Test
func providerCapabilitiesKeepProcessEditorsLanguageNeutral() {
let process = RunConfigurationKind.process(provider: "python.script").capabilities
@@ -100,7 +151,7 @@ struct RunConfigurationIntegrationTests {
#expect(go?.activationPolicy == .onDemand)
#expect(go?.capabilities.contains(.languageServer) == true)
#expect(go?.capabilities.contains(.debugAdapter) == true)
- #expect(catalog.provider(for: URL(fileURLWithPath: "/tmp/Main.java"))?.capabilities.contains(.debugAdapter) == false)
+ #expect(catalog.provider(for: URL(fileURLWithPath: "/tmp/Main.java"))?.capabilities.contains(.debugAdapter) == true)
if !RustCoreBridge().isAvailable {
#expect(catalog.provider(for: URL(fileURLWithPath: "/tmp/Package.swift")) == nil)
#expect(catalog.provider(for: URL(fileURLWithPath: "/tmp/Dockerfile")) == nil)
@@ -127,7 +178,7 @@ struct RunConfigurationIntegrationTests {
#expect(registry.pack(id: "go")?.debugAdapterLaunch?.executableNames == ["dlv"])
#expect(registry.pack(id: "python")?.debugAdapterLaunch?.adapterID == "python")
#expect(registry.pack(id: "rust")?.debugAdapterLaunch?.fallbacks.first?.executableName == "xcrun")
- #expect(registry.pack(id: "java")?.debugAdapterLaunch?.adapterID == "java")
+ #expect(registry.pack(id: "java")?.debugAdapterLaunch == nil)
if !RustCoreBridge().isAvailable {
#expect(providerIDSet == ["java", "go", "python", "node", "rust"])
#expect(registry.catalog.provider(for: URL(fileURLWithPath: "/tmp/Dockerfile")) == nil)
@@ -243,7 +294,7 @@ struct RunConfigurationIntegrationTests {
}
@Test
- func aFutureJavaDAPRuntimeCanOverrideTheLegacyDebugBoundary() throws {
+ func javaDAPRuntimeActivatesThroughTheSharedDebugBoundary() throws {
let javaDescriptor = LanguageProviderDescriptor(
id: "java",
displayName: "Java",
@@ -286,14 +337,312 @@ struct RunConfigurationIntegrationTests {
workspaceURL: root,
configurations: [],
selectedConfiguration: nil,
+ javaTarget: JavaDebugLaunchTarget(
+ mainClass: "service/com.acme.Main",
+ projectName: "service",
+ modulePaths: ["/tmp/java-project/modules"],
+ classPaths: ["/tmp/java-project/classes"]
+ ),
options: { _ in RunOptions() }
)
#expect(configuration.request == .launch)
- #expect(configuration.arguments["mainClass"] == .string("com.acme.Main"))
+ #expect(configuration.arguments["mainClass"] == .string("service/com.acme.Main"))
+ #expect(configuration.arguments["projectName"] == .string("service"))
+ #expect(configuration.arguments["modulePaths"] == .array([
+ .string("/tmp/java-project/modules"),
+ ]))
+ #expect(configuration.arguments["classPaths"] == .array([
+ .string("/tmp/java-project/classes"),
+ ]))
#expect(configuration.arguments["cwd"] == .string(root.path))
}
+ @Test
+ func javaDebugReusesTheSelectedRunConfigurationOptions() throws {
+ let provider = try #require(LanguageProviderCatalog.standard.provider(
+ for: URL(fileURLWithPath: "/tmp/java-project/src/main/java/com/acme/Main.java")
+ ))
+ let root = URL(fileURLWithPath: "/tmp/java-project", isDirectory: true)
+ let selected = RunConfiguration(
+ id: "java-main:service",
+ name: "Service",
+ kind: .javaMain,
+ execution: .application,
+ modulePath: "service",
+ mainClass: "com.acme.ConfiguredMain"
+ )
+ let options = RunOptions(
+ workingDirectoryPath: "service",
+ vmArguments: "-Xmx1g -Dprofile=dev",
+ programArguments: "--port 8080",
+ environment: ["APP_ENV": "dev"]
+ )
+ let resolver = DebugLaunchConfigurationResolver(fileExists: { _ in true })
+ let configuration = try resolver.resolve(
+ provider: provider,
+ documentURL: root.appendingPathComponent("src/main/java/com/acme/Main.java"),
+ workspaceURL: root,
+ configurations: [selected],
+ selectedConfiguration: selected,
+ javaTarget: JavaDebugLaunchTarget(
+ mainClass: "com.acme.ResolvedByJdtls",
+ projectName: nil,
+ modulePaths: [],
+ classPaths: []
+ ),
+ options: { _ in options }
+ )
+
+ #expect(configuration.name == "Service")
+ #expect(configuration.arguments["mainClass"] == .string("com.acme.ConfiguredMain"))
+ #expect(configuration.arguments["cwd"] == .string(root.appendingPathComponent("service").path))
+ #expect(configuration.arguments["vmArgs"] == .string("-Xmx1g -Dprofile=dev"))
+ #expect(configuration.arguments["args"] == .string("--port 8080"))
+ #expect(configuration.arguments["env"] == .object(["APP_ENV": .string("dev")]))
+ }
+
+ @Test
+ func selectedSpringBootConfigurationResolvesItsMainSourceWithoutAnOpenJavaEditor() throws {
+ let root = URL(fileURLWithPath: "/workspace/demo", isDirectory: true)
+ let readme = root.appendingPathComponent("README.md")
+ let application = root.appendingPathComponent(
+ "src/main/java/com/example/demo/DemoApplication.java"
+ )
+ let controller = root.appendingPathComponent(
+ "src/main/java/com/example/demo/user/UserController.java"
+ )
+ let configuration = RunConfiguration(
+ id: "spring-boot:demo",
+ name: "DemoApplication",
+ kind: .springBoot,
+ execution: .service,
+ modulePath: nil,
+ mainClass: "com.example.demo.DemoApplication"
+ )
+
+ let source = DebugLaunchSourceResolver().resolve(
+ configuration: configuration,
+ activeDocumentURL: readme,
+ projectFiles: [controller, application, readme],
+ workspaceURL: root
+ )
+
+ #expect(source == application)
+ }
+
+ @Test
+ func selectedJavaConfigurationUsesItsModuleToDisambiguateDuplicateMainClasses() throws {
+ let root = URL(fileURLWithPath: "/workspace/multi-module", isDirectory: true)
+ let first = root.appendingPathComponent("first/src/main/java/com/acme/Main.java")
+ let second = root.appendingPathComponent("second/src/main/java/com/acme/Main.java")
+ let configuration = RunConfiguration(
+ id: "java-main:second",
+ name: "Second Main",
+ kind: .javaMain,
+ execution: .application,
+ modulePath: "second",
+ mainClass: "com.acme.Main"
+ )
+
+ let source = DebugLaunchSourceResolver().resolve(
+ configuration: configuration,
+ activeDocumentURL: first,
+ projectFiles: [first, second],
+ workspaceURL: root
+ )
+
+ #expect(source == second)
+ }
+
+ @Test
+ func currentFileDebugStillUsesTheActiveEditorDocument() throws {
+ let root = URL(fileURLWithPath: "/workspace/current-file", isDirectory: true)
+ let current = root.appendingPathComponent("src/main/java/com/acme/Main.java")
+ let other = root.appendingPathComponent("src/main/java/com/acme/Other.java")
+
+ let source = DebugLaunchSourceResolver().resolve(
+ configuration: .currentFile,
+ activeDocumentURL: current,
+ projectFiles: [other],
+ workspaceURL: root
+ )
+
+ #expect(source == current)
+ }
+
+ @Test
+ func debugFallsBackFromNonLaunchableCurrentJavaFileToSpringBootConfiguration() {
+ let current = RunConfiguration(
+ id: "current-file",
+ name: "Current File",
+ kind: .currentFile,
+ execution: .application,
+ modulePath: nil,
+ mainClass: nil
+ )
+ let springBoot = RunConfiguration(
+ id: "spring-boot:demo",
+ name: "DemoApplication",
+ kind: .springBoot,
+ execution: .service,
+ modulePath: nil,
+ mainClass: "com.example.demo.DemoApplication"
+ )
+
+ let selected = DebugLaunchSourceResolver().configurationForDebug(
+ selected: current,
+ activeDocumentText: "@Repository class UserRepository { }",
+ configurations: [current, springBoot]
+ )
+
+ #expect(selected.id == springBoot.id)
+ }
+
+ @Test
+ func debugFallsBackWhenCurrentEditorTextIsUnavailable() {
+ let current = RunConfiguration(
+ id: "current-file",
+ name: "Current File",
+ kind: .currentFile,
+ execution: .application,
+ modulePath: nil,
+ mainClass: nil
+ )
+ let springBoot = RunConfiguration(
+ id: "spring-boot:demo",
+ name: "DemoApplication",
+ kind: .springBoot,
+ execution: .service,
+ modulePath: nil,
+ mainClass: "com.example.demo.DemoApplication"
+ )
+
+ let selected = DebugLaunchSourceResolver().configurationForDebug(
+ selected: current,
+ activeDocumentText: nil,
+ configurations: [current, springBoot]
+ )
+
+ #expect(selected.id == springBoot.id)
+ }
+
+ @Test
+ func debugKeepsCurrentJavaFileWhenItHasAMainMethod() {
+ let current = RunConfiguration(
+ id: "current-file",
+ name: "Current File",
+ kind: .currentFile,
+ execution: .application,
+ modulePath: nil,
+ mainClass: nil
+ )
+ let springBoot = RunConfiguration(
+ id: "spring-boot:demo",
+ name: "DemoApplication",
+ kind: .springBoot,
+ execution: .service,
+ modulePath: nil,
+ mainClass: "com.example.demo.DemoApplication"
+ )
+
+ let selected = DebugLaunchSourceResolver().configurationForDebug(
+ selected: current,
+ activeDocumentText: "public static void main(String[] args) { }",
+ configurations: [current, springBoot]
+ )
+
+ #expect(selected.id == current.id)
+ }
+
+ @Test
+ func javaTestDebugLaunchUsesTheSharedRustConfiguration() throws {
+ let core = RustCoreBridge()
+ guard core.isAvailable else { return }
+ let target = JavaTestDebugLaunchTarget(
+ fileURL: URL(fileURLWithPath: "/workspace/UserServiceTest.java"),
+ name: "UserServiceTest",
+ framework: .testng,
+ workingDirectory: "/workspace",
+ mainClass: "com.microsoft.java.test.runner.Launcher",
+ projectName: "service",
+ classPaths: ["/workspace/classes"],
+ modulePaths: [],
+ vmArguments: [],
+ programArguments: [],
+ testNGRunnerPath: "/lithe/java-test-runner.jar",
+ testNGTestNames: ["example.UserServiceTest#logsIn"]
+ )
+ let resolver = DebugLaunchConfigurationResolver(
+ fileExists: { _ in true },
+ javaTestLaunchResolver: core
+ )
+
+ let configuration = try resolver.resolveJavaTest(target: target, resultPort: 43_128)
+
+ #expect(configuration.name == "UserServiceTest")
+ #expect(configuration.request == .launch)
+ #expect(
+ configuration.arguments["mainClass"]
+ == .string("com.microsoft.java.test.runner.Launcher")
+ )
+ #expect(configuration.arguments["classPaths"] == .array([
+ .string("/workspace/classes"),
+ .string("/lithe/java-test-runner.jar"),
+ ]))
+ #expect(
+ configuration.arguments["args"]
+ == .string("43128 testng example.UserServiceTest#logsIn")
+ )
+ }
+
+ @Test
+ func sourceBreakpointRelocationUsesTheSharedRustCore() throws {
+ let core = RustCoreBridge()
+ guard core.isAvailable else { return }
+ let source = "class Main {\n void run() {}\n}\n"
+
+ let breakpoints = try core.relocateDebugBreakpoints(
+ source: source,
+ edit: DebugSourceEdit(
+ startUTF16Offset: 13,
+ endUTF16Offset: 13,
+ replacement: "\n"
+ ),
+ breakpoints: [DebugSourceBreakpoint(
+ line: 2,
+ condition: "ready",
+ hitCondition: "3"
+ )]
+ )
+
+ #expect(breakpoints == [DebugSourceBreakpoint(
+ line: 3,
+ condition: "ready",
+ hitCondition: "3"
+ )])
+ }
+
+ @Test
+ func javaAttachUsesTheSharedDAPSessionWithValidatedEndpointArguments() throws {
+ let resolver = DebugLaunchConfigurationResolver(fileExists: { _ in true })
+
+ let configuration = try resolver.resolveJavaAttach(host: " localhost ", port: 5005)
+
+ #expect(configuration.name == "localhost:5005")
+ #expect(configuration.request == .attach)
+ #expect(configuration.arguments == [
+ "hostName": .string("localhost"),
+ "port": .integer(5005)
+ ])
+ #expect(throws: DebugLaunchConfigurationResolutionError.invalidJavaAttachHost) {
+ try resolver.resolveJavaAttach(host: " ", port: 5005)
+ }
+ #expect(throws: DebugLaunchConfigurationResolutionError.invalidJavaAttachPort) {
+ try resolver.resolveJavaAttach(host: "localhost", port: 65_536)
+ }
+ }
+
@Test
func macToolDiscoveryReportsProjectHomebrewAndXcodeSources() {
let root = URL(fileURLWithPath: "/tmp/mac-tool-project", isDirectory: true)
@@ -327,7 +676,7 @@ struct RunConfigurationIntegrationTests {
)
#expect(lldbCandidates.first?.source == .xcode)
#expect(discovery.guidance(for: "java-debug-adapter", projectURL: root, environment: [:])
- .recovery.contains("LITHE_JAVA_DEBUG_PATH"))
+ .recovery.contains("bundled Java language and Debug Adapter resources"))
}
@Test
@@ -366,14 +715,59 @@ struct RunConfigurationIntegrationTests {
}
@Test
- func legacyJavaDoesNotAcceptGenericDAPBreakpointsWithoutAnAdapter() throws {
+ func javaDAPBreakpointsCanBeStoredBeforeTheAdapterStarts() throws {
let source = URL(fileURLWithPath: "/tmp/Main.java")
- #expect(throws: DebugProviderError.noProvider(fileExtension: "java")) {
- try DebugAdapterSessionManager(providers: LanguageProviderCatalog.standard.debugProviders) { _, _ in nil }.setBreakpoints(
- [DebugSourceBreakpoint(line: 1)],
- in: source
+ let manager = DebugAdapterSessionManager(
+ providers: LanguageProviderCatalog.standard.debugProviders
+ ) { _, _ in nil }
+
+ try manager.setBreakpoints([DebugSourceBreakpoint(line: 1)], in: source)
+
+ #expect(manager.provider(for: source)?.id == "java")
+ #expect(manager.activeAdapterIDs.isEmpty)
+ }
+
+ @Test
+ func restoredJavaBreakpointInAnotherSourceIsSentWhenDebugStarts() throws {
+ let root = URL(fileURLWithPath: "/tmp/restored-java-debug", isDirectory: true)
+ let launchSource = root.appendingPathComponent("src/main/java/demo/DemoApplication.java")
+ let breakpointSource = root.appendingPathComponent("src/main/java/demo/UserController.java")
+ let persistence = RestoredBreakpointPersistence(snapshot: DebugBreakpointSnapshot(
+ breakpoints: [PersistedDebugBreakpoint(
+ relativePath: "src/main/java/demo/UserController.java",
+ line: 23
+ )]
+ ))
+ var adapter: TestDebugAdapterSession?
+ let manager = DebugAdapterSessionManager(
+ providers: LanguageProviderCatalog.standard.debugProviders,
+ makeSession: { _, _ in
+ let value = TestDebugAdapterSession()
+ adapter = value
+ return value
+ }
+ )
+ let feature = GenericDebugFeatureModel(
+ sessions: manager,
+ breakpointPersistence: persistence
+ )
+ feature.openWorkspace(at: root)
+
+ let started = feature.start(
+ fileURL: launchSource,
+ rootURL: root,
+ configuration: DebugLaunchConfiguration(
+ name: "DemoApplication",
+ request: .launch,
+ arguments: [:]
)
- }
+ )
+
+ #expect(started)
+ let update = try #require(adapter?.breakpointUpdates.first(where: {
+ $0.0 == breakpointSource.standardizedFileURL
+ }))
+ #expect(update.1 == [DebugSourceBreakpoint(line: 23)])
}
@Test
@@ -419,6 +813,34 @@ struct RunConfigurationIntegrationTests {
#expect(javaPlan.launchPlan.toolchainID == "project-maven")
#expect(javaPlan.launchPlan.arguments == ["-Dtest=UserServiceTest", "test"])
+ let gradleMethodPlan = try javaProvider.testPlan(
+ scope: .testCase(
+ identifier: "example.UserServiceTest#logsIn()",
+ fileURL: files[0]
+ ),
+ context: LanguageTestContext(
+ workspaceURL: root,
+ projectFiles: [root.appendingPathComponent("build.gradle.kts"), files[0]]
+ )
+ )
+ #expect(gradleMethodPlan.launchPlan.arguments == [
+ "test", "--tests", "example.UserServiceTest.logsIn",
+ ])
+
+ let mavenMethodPlan = try javaProvider.testPlan(
+ scope: .testCase(
+ identifier: "example.UserServiceTest#logsIn()",
+ fileURL: files[0]
+ ),
+ context: LanguageTestContext(
+ workspaceURL: root,
+ projectFiles: [root.appendingPathComponent("pom.xml"), files[0]]
+ )
+ )
+ #expect(mavenMethodPlan.launchPlan.arguments == [
+ "-Dtest=example.UserServiceTest#logsIn", "test",
+ ])
+
let gradlePlan = try javaProvider.testPlan(
scope: .workspace,
context: LanguageTestContext(
@@ -747,6 +1169,62 @@ struct RunConfigurationIntegrationTests {
#expect(received == [response])
}
+ @Test
+ func javaTransportQueuesDAPBytesUntilJdtlsPortAndSocketAreReady() async throws {
+ let portGate = JavaDebugPortGate()
+ let socket = TestDlvSocketConnection()
+ let endpointRecorder = DebugEndpointRecorder()
+ let transport = MacJavaDebugAdapterTransport(
+ portResolver: { rootURL in try await portGate.resolve(rootURL: rootURL) },
+ socketFactory: { host, port in
+ endpointRecorder.record(host: host, port: port)
+ return socket
+ }
+ )
+ let root = URL(fileURLWithPath: "/tmp/java-dap", isDirectory: true)
+ let initializeFrame = Data("Content-Length: 2\r\n\r\n{}".utf8)
+
+ try transport.start(rootURL: root)
+ try transport.send(initializeFrame)
+ #expect(socket.sent.isEmpty)
+ #expect(await portGate.waitUntilRequested() == root.standardizedFileURL)
+
+ portGate.succeed(port: 5005)
+ let endpoint = await endpointRecorder.waitUntilRecorded()
+ #expect(endpoint.host == "127.0.0.1")
+ #expect(endpoint.port == 5005)
+ #expect(socket.startCount == 1)
+ #expect(socket.sent.isEmpty)
+
+ socket.onReady?()
+ #expect(socket.sent == [initializeFrame])
+ transport.stop()
+ #expect(socket.stopCount == 1)
+ }
+
+ @Test
+ func stoppingJavaTransportCancelsPortDiscoveryWithoutOpeningSocket() async throws {
+ let portGate = JavaDebugPortGate()
+ let socket = TestDlvSocketConnection()
+ let endpointRecorder = DebugEndpointRecorder()
+ let transport = MacJavaDebugAdapterTransport(
+ portResolver: { rootURL in try await portGate.resolve(rootURL: rootURL) },
+ socketFactory: { host, port in
+ endpointRecorder.record(host: host, port: port)
+ return socket
+ }
+ )
+
+ try transport.start(rootURL: URL(fileURLWithPath: "/tmp/java-dap-cancel"))
+ _ = await portGate.waitUntilRequested()
+ transport.stop()
+
+ await portGate.waitUntilCancelled()
+ #expect(!endpointRecorder.didRecord)
+ #expect(socket.startCount == 0)
+ #expect(!transport.isRunning)
+ }
+
@Test
func nodeTransportDiscoversTheOfficialBundleAndQueuesUntilTCPIsReady() async throws {
let root = URL(fileURLWithPath: "/tmp/node-dap", isDirectory: true)
@@ -1203,7 +1681,20 @@ struct RunConfigurationIntegrationTests {
let resources = JDTLSLaunchResources(
launcherJarURL: URL(fileURLWithPath: "/jdtls/plugins/equinox.jar"),
configurationDirectoryURL: URL(fileURLWithPath: "/jdtls/config_mac"),
- lombokAgentURL: URL(fileURLWithPath: "/jdtls/lombok/lombok.jar")
+ lombokAgentURL: URL(fileURLWithPath: "/jdtls/lombok/lombok.jar"),
+ javaDebugBundleURL: URL(
+ fileURLWithPath: "/jdtls/java-debug/com.microsoft.java.debug.plugin-0.53.1.jar"
+ ),
+ javaExtensionBundleURLs: [
+ URL(
+ fileURLWithPath:
+ "/jdtls/java-test/extensions/com.microsoft.java.test.plugin-0.42.0.jar"
+ )
+ ],
+ javaTestRunnerURL: URL(
+ fileURLWithPath:
+ "/jdtls/java-test/runner/com.microsoft.java.test.runner-jar-with-dependencies.jar"
+ )
)
let runtime = StdioLanguageProviderRuntime(
descriptor: descriptor,
@@ -1769,6 +2260,10 @@ struct RunConfigurationIntegrationTests {
text: "struct App {}\n",
rootURL: root
)
+ #expect(await Self.waitForMainActorCondition {
+ core.startCalls.count == 1
+ && core.syncCalls.last?.fileURL == source.standardizedFileURL
+ })
let startCall = try #require(core.startCalls.first)
#expect(startCall.providerID == "swift")
#expect(startCall.executableURL.path == "/usr/bin/sourcekit-lsp")
@@ -2011,7 +2506,9 @@ struct RunConfigurationIntegrationTests {
#expect(executeCall.operation == .executeCommand)
#expect(executeCall.fileURL == nil)
#expect(executeCall.command?.command == "source.fix")
- core.enqueueRequestSuccess(operation: .executeCommand, result: ["ok": true])
+ core.enqueueRequestSuccess(operation: .executeCommand, result: [
+ "value": ["ok": true],
+ ])
#expect(await Self.waitForMainActorCondition { executeResult != nil })
#expect(executeResult != nil)
try executeResult?.get()
@@ -2511,7 +3008,8 @@ struct RunConfigurationIntegrationTests {
await fixture.service.loadProject(
at: fixture.root,
files: [],
- mavenProject: fixture.mavenProject
+ mavenProject: fixture.mavenProject,
+ snapshotID: UUID()
)
#expect(fixture.service.configurationStatus == .missing)
@@ -2544,7 +3042,8 @@ struct RunConfigurationIntegrationTests {
await fixture.service.loadProject(
at: fixture.root,
files: [],
- mavenProject: fixture.mavenProject
+ mavenProject: fixture.mavenProject,
+ snapshotID: UUID()
)
fixture.service.run(configuration: configuration, currentFileURL: nil)
@@ -2997,7 +3496,6 @@ struct RunConfigurationIntegrationTests {
let report = try #require(runtime.javaEnvironmentReport)
#expect(report.status == .ready)
#expect(report.javaHomePath == "/toolchains/jdk")
- #expect(report.jdbExecutablePath == "/toolchains/jdk/bin/jdb")
#expect(!report.status.blocksJavaRun)
}
@@ -3017,69 +3515,6 @@ struct RunConfigurationIntegrationTests {
#expect(report.recovery.contains("JAVA_HOME"))
}
- @Test
- func mavenDebugUsesSharedDebugLaunchPlan() throws {
- let root = URL(fileURLWithPath: "/tmp/lithe-debug-service", isDirectory: true)
- let configuration = JavaRunConfiguration(
- id: "spring:com.example.App",
- name: "App",
- kind: .springBoot,
- modulePath: "backend",
- mainClass: "com.example.App"
- )
- let arguments = [
- "-B", "-ntp", "-pl", "backend",
- "-Dspring-boot.run.jvmArguments=-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=127.0.0.1:5555",
- "spring-boot:run"
- ]
- let operations = RecordingRunConfigurationOperations(
- status: .ready,
- effective: [],
- plans: [configuration.id: SharedLaunchPlan(
- executable: .toolchain("project-maven"),
- arguments: arguments,
- workingDirectory: "backend"
- )]
- )
- let processFactory = RecordingProcessFactory()
- let runtime = ProjectRuntimeService(
- runtimeLocator: RunTestRuntimeLocator(),
- store: RunTestKeyValueStore()
- )
- runtime.openProject(at: root)
- let project = MavenProject(
- rootURL: root,
- pomURL: root.appendingPathComponent("pom.xml"),
- groupID: nil,
- artifactID: "fixture",
- version: nil,
- packaging: "jar",
- modules: [],
- profiles: [],
- hasWrapper: false
- )
- let service = JavaDebugService(
- runtimeService: runtime,
- processFactory: { processFactory.make() },
- fileStorage: RunTestFileStorage(),
- javaMavenOperations: RunTestJavaMavenOperations(),
- runConfigurationOperations: operations
- )
-
- service.startMaven(
- configuration: configuration,
- project: project,
- projectURL: root,
- options: JavaRunOptions()
- )
-
- let request = try #require(processFactory.processes.first?.requests.first)
- #expect(request.arguments == arguments)
- #expect(request.workingDirectory == root.appendingPathComponent("backend").path)
- #expect(operations.debugPorts.count == 1)
- #expect(operations.debugPorts[0] != nil)
- }
-
@Test
func generationPublishesNoEntryResultAndKeepsCurrentFileAvailable() async {
let current = JavaRunConfiguration.currentFile
@@ -3092,7 +3527,8 @@ struct RunConfigurationIntegrationTests {
await fixture.service.loadProject(
at: fixture.root,
files: [],
- mavenProject: fixture.mavenProject
+ mavenProject: fixture.mavenProject,
+ snapshotID: UUID()
)
await fixture.service.generateRunConfigurations()
@@ -3218,7 +3654,11 @@ struct RunConfigurationIntegrationTests {
let document = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any])
let configurations = try #require(document["configurations"] as? [[String: Any]])
#expect(configurations.count == 2)
- #expect(configurations.first?["jvmArguments"] as? [String] == ["-Dlabel=hello world", "-Xmx2g"])
+ let mavenExtension = try #require(
+ configurations.first?["extensions"] as? [String: Any]
+ )
+ let mavenOptions = try #require(mavenExtension["maven"] as? [String: Any])
+ #expect(mavenOptions["jvmArguments"] as? [String] == ["-Dlabel=hello world", "-Xmx2g"])
}
@Test
@@ -3680,10 +4120,11 @@ struct RunConfigurationIntegrationTests {
runConfigurationOperations: operations
)
- await service.loadProject(at: root, files: [], mavenProject: nil)
+ let snapshotID = UUID()
+ await service.loadProject(at: root, files: [], mavenProject: nil, snapshotID: snapshotID)
let generation = Task { await service.generateRunConfigurations() }
#expect(await operations.waitUntilBlocked())
- await service.loadProject(at: root, files: [], mavenProject: nil)
+ await service.loadProject(at: root, files: [], mavenProject: nil, snapshotID: snapshotID)
operations.releaseGeneration()
await generation.value
@@ -4778,7 +5219,6 @@ private struct RunTestRuntimeLocator: RuntimeLocator {
version: "3.9.9"
)
}
- func systemJDBExecutable() -> URL? { URL(fileURLWithPath: "/toolchains/jdk/bin/jdb") }
}
private struct NestedMavenWrapperRuntimeLocator: RuntimeLocator {
@@ -4796,7 +5236,6 @@ private struct NestedMavenWrapperRuntimeLocator: RuntimeLocator {
func systemMavenExecutable() -> URL? { nil }
func mavenExecutable(forHomePath path: String) -> URL? { nil }
func mavenRuntime(at executableURL: URL) -> MavenRuntimeCandidate? { nil }
- func systemJDBExecutable() -> URL? { nil }
}
private struct MissingJavaRuntimeLocator: RuntimeLocator {
@@ -4810,7 +5249,6 @@ private struct MissingJavaRuntimeLocator: RuntimeLocator {
func systemMavenExecutable() -> URL? { nil }
func mavenExecutable(forHomePath path: String) -> URL? { nil }
func mavenRuntime(at executableURL: URL) -> MavenRuntimeCandidate? { nil }
- func systemJDBExecutable() -> URL? { nil }
}
private struct XcrunOnlyRuntimeLocator: RuntimeLocator {
@@ -4822,7 +5260,6 @@ private struct XcrunOnlyRuntimeLocator: RuntimeLocator {
func systemMavenExecutable() -> URL? { nil }
func mavenExecutable(forHomePath path: String) -> URL? { nil }
func mavenRuntime(at executableURL: URL) -> MavenRuntimeCandidate? { nil }
- func systemJDBExecutable() -> URL? { nil }
}
@MainActor
@@ -4862,6 +5299,20 @@ private final class TestDebugAdapterSession: DebugAdapterControllingSession {
) {}
}
+private final class RestoredBreakpointPersistence: DebugBreakpointPersisting, @unchecked Sendable {
+ private let snapshot: DebugBreakpointSnapshot
+
+ init(snapshot: DebugBreakpointSnapshot) {
+ self.snapshot = snapshot
+ }
+
+ func loadBreakpoints(for workspaceURL: URL) throws -> DebugBreakpointSnapshot? {
+ snapshot
+ }
+
+ func saveBreakpoints(_ snapshot: DebugBreakpointSnapshot, for workspaceURL: URL) throws {}
+}
+
@MainActor
private final class TestDebugLanguageProviderRuntime: LanguageProviderRuntime {
let descriptor: LanguageProviderDescriptor
@@ -4923,6 +5374,87 @@ private final class TestDlvSocketConnection: DlvSocketConnection {
func stop() { stopCount += 1 }
}
+@MainActor
+private final class JavaDebugPortGate {
+ private var requestedRoot: URL?
+ private var requestWaiters: [CheckedContinuation] = []
+ private var portContinuation: CheckedContinuation?
+ private var cancelled = false
+ private var cancellationWaiters: [CheckedContinuation] = []
+
+ func resolve(rootURL: URL) async throws -> UInt16 {
+ requestedRoot = rootURL.standardizedFileURL
+ let waiters = requestWaiters
+ requestWaiters = []
+ waiters.forEach { $0.resume(returning: rootURL.standardizedFileURL) }
+ return try await withTaskCancellationHandler {
+ try await withCheckedThrowingContinuation { continuation in
+ portContinuation = continuation
+ }
+ } onCancel: {
+ Task { @MainActor [weak self] in self?.cancel() }
+ }
+ }
+
+ func waitUntilRequested() async -> URL {
+ if let requestedRoot { return requestedRoot }
+ return await withCheckedContinuation { continuation in
+ requestWaiters.append(continuation)
+ }
+ }
+
+ func succeed(port: UInt16) {
+ let continuation = portContinuation
+ portContinuation = nil
+ continuation?.resume(returning: port)
+ }
+
+ func waitUntilCancelled() async {
+ if cancelled { return }
+ await withCheckedContinuation { continuation in
+ cancellationWaiters.append(continuation)
+ }
+ }
+
+ private func cancel() {
+ guard !cancelled else { return }
+ cancelled = true
+ let portContinuation = portContinuation
+ self.portContinuation = nil
+ portContinuation?.resume(throwing: CancellationError())
+ let waiters = cancellationWaiters
+ cancellationWaiters = []
+ waiters.forEach { $0.resume() }
+ }
+}
+
+@MainActor
+private final class DebugEndpointRecorder {
+ struct Endpoint {
+ let host: String
+ let port: UInt16
+ }
+
+ private var endpoint: Endpoint?
+ private var waiters: [CheckedContinuation] = []
+ var didRecord: Bool { endpoint != nil }
+
+ func record(host: String, port: UInt16) {
+ let endpoint = Endpoint(host: host, port: port)
+ self.endpoint = endpoint
+ let waiters = waiters
+ self.waiters = []
+ waiters.forEach { $0.resume(returning: endpoint) }
+ }
+
+ func waitUntilRecorded() async -> Endpoint {
+ if let endpoint { return endpoint }
+ return await withCheckedContinuation { continuation in
+ waiters.append(continuation)
+ }
+ }
+}
+
private struct RunTestFileStorage: FileStorage {
func homeDirectory() -> URL { URL(fileURLWithPath: "/tmp") }
func cacheDirectory() -> URL { URL(fileURLWithPath: "/tmp") }
diff --git a/macos/Tests/LitheTests/RunEntryPointTests.swift b/macos/Tests/LitheTests/RunEntryPointTests.swift
new file mode 100644
index 000000000..c21f24042
--- /dev/null
+++ b/macos/Tests/LitheTests/RunEntryPointTests.swift
@@ -0,0 +1,1068 @@
+import Foundation
+import Testing
+@testable import Lithe
+
+// Each test here opens a real workspace and builds a full service container, and
+// several of them coordinate on gates. Run in parallel they contend hard enough
+// to stretch their own durations by two orders of magnitude and to push other
+// suites past their deadlines, so this suite takes one test at a time.
+@Suite("Run entry points", .serialized)
+@MainActor
+struct RunEntryPointTests {
+ /// Run activates the execution module on demand, so it can reach a run
+ /// feature before the workspace snapshot has been applied. Binding the
+ /// workspace there is not enough: generation scans the file inventory the
+ /// run service holds, so identifying a provisional inventory would write a
+ /// `generated.json` that omits entry points the workspace contains.
+ ///
+ /// The snapshot is held until after Run is pressed, then released with a
+ /// real Java entry point, so the test observes both halves of the contract.
+ @Test
+ func runBeforeTheSnapshotDefersGenerationUntilTheInventoryIsComplete() async throws {
+ let workspace = try JavaWorkspaceFixture()
+ defer { workspace.remove() }
+ let operations = SequencedWorkspaceOperations.unavailableThenReady(workspace.snapshot)
+ let model = makeAppModel(workspaceOperations: operations)
+
+ model.openProjectDirectly(workspace.root)
+ model.runSelectedConfiguration()
+
+ // The entry point binds the workspace so existing configuration is
+ // readable, but the pending snapshot must keep generation out.
+ let bound = await awaitLoadDrivenChange(on: model) {
+ model.runFeatureIfActive?.projectLoadState == .bound(workspace: workspace.root.standardizedFileURL)
+ }
+ #expect(bound, "the Run entry point never bound the workspace")
+ #expect(model.runFeatureIfActive?.isProjectReady(for: workspace.root, snapshotID: model.workspaceSnapshotID) == false)
+
+ let runFeature = try #require(model.runFeatureIfActive)
+ await runFeature.generateRunConfigurations()
+ #expect(runFeature.generationState == .projectNotReady)
+ #expect(!workspace.hasGeneratedConfiguration, "a partial inventory must not be written")
+
+ await model.workspaceFeature.refreshCurrent()
+
+ let ready = await awaitLoadDrivenChange(on: model) {
+ model.runFeatureIfActive?.isProjectReady(
+ for: workspace.root,
+ snapshotID: model.workspaceSnapshotID
+ ) == true
+ }
+ #expect(ready, "the applied snapshot never made the run project ready")
+ // Which paths generation then scans is asserted against the run
+ // configuration store in ExecutionModuleTests, because the Swift test
+ // binary does not link the Rust Core that performs the scan.
+ }
+
+ /// Launching from a provisional inventory resolves toolchains without the
+ /// Maven project, so Run has to defer rather than proceed on a bound-only
+ /// workspace. The deferred action is remembered and resumed by the load the
+ /// snapshot drives, which is what lets the user press Run once.
+ @Test
+ func runDefersAndResumesWhenTheSnapshotArrivesLater() async throws {
+ let workspace = try JavaWorkspaceFixture()
+ defer { workspace.remove() }
+ let operations = SequencedWorkspaceOperations.unavailableThenReady(workspace.snapshot)
+ let model = makeAppModel(workspaceOperations: operations)
+
+ model.openProjectDirectly(workspace.root)
+ model.runSelectedConfiguration()
+
+ let deferred = await awaitLoadDrivenChange(on: model) { model.pendingRunAction?.kind == .run }
+ #expect(deferred, "Run must be deferred while the inventory is provisional")
+
+ await model.workspaceFeature.refreshCurrent()
+
+ let ready = await awaitLoadDrivenChange(on: model) {
+ model.runFeatureIfActive?.isProjectReady(
+ for: workspace.root,
+ snapshotID: model.workspaceSnapshotID
+ ) == true
+ }
+ #expect(ready, "the applied snapshot never made the run project ready")
+ let resumed = await awaitLoadDrivenChange(on: model) { model.pendingRunAction == nil }
+ #expect(resumed, "the deferred Run was never resumed")
+ }
+
+ /// A workspace that already has a configuration reports `configurationStatus
+ /// == .ready` as soon as it is bound, which used to be enough to launch. With
+ /// a provisional inventory the Maven project is absent, so toolchains resolve
+ /// without it. Readiness has to be checked before the configuration status.
+ @Test
+ func runWithExistingConfigurationLaunchesOnlyAfterTheSnapshotArrives() async throws {
+ let workspace = try JavaWorkspaceFixture()
+ defer { workspace.remove() }
+ let workspaceOperations = SequencedWorkspaceOperations.unavailableThenReady(workspace.snapshot)
+ let runConfigurations = ReadyRunConfigurationOperations()
+ let model = makeAppModel(
+ workspaceOperations: workspaceOperations,
+ runConfigurationOperations: runConfigurations
+ )
+
+ model.openProjectDirectly(workspace.root)
+ model.runSelectedConfiguration()
+
+ let deferred = await awaitLoadDrivenChange(on: model) { model.pendingRunAction?.kind == .run }
+ #expect(deferred, "Run must be deferred while the file inventory is provisional")
+ let runFeature = try #require(model.runFeatureIfActive)
+ #expect(
+ runFeature.configurationStatus == .ready,
+ "the seeded configuration should already report ready"
+ )
+ #expect(
+ runConfigurations.launchPlanCallCount == 0,
+ "Run must not build a launch plan from a provisional inventory"
+ )
+
+ await model.workspaceFeature.refreshCurrent()
+
+ // Production clears the deferred action before it re-issues Run, so
+ // waiting for the action to clear would pass even if the relaunch were
+ // dropped. The launch plan request is what proves Run actually ran.
+ let relaunched = await runConfigurations.launchPlanRequested(1)
+ #expect(relaunched, "the deferred Run was never actually re-issued")
+ #expect(model.pendingRunAction == nil)
+ #expect(
+ runFeature.isProjectReady(
+ for: workspace.root,
+ snapshotID: model.workspaceSnapshotID
+ )
+ )
+ }
+
+ /// When Run's own provisional load is still in flight, the snapshot can land
+ /// and be fully consumed first — including the deferred-run resume, which
+ /// finds nothing pending. The entry point must then re-check the *current*
+ /// snapshot rather than the one it captured before that load, or it defers
+ /// an action nothing will ever resume.
+ @Test
+ func runResumesWhenTheSnapshotLandsDuringTheEntryPointsOwnLoad() async throws {
+ let workspace = try JavaWorkspaceFixture()
+ defer { workspace.remove() }
+ let workspaceOperations = SequencedWorkspaceOperations.unavailableThenReady(workspace.snapshot)
+ let runConfigurations = InspectionGatedRunConfigurationOperations()
+ defer { runConfigurations.releaseAll() }
+ let model = makeAppModel(
+ workspaceOperations: workspaceOperations,
+ runConfigurationOperations: runConfigurations
+ )
+
+ model.openProjectDirectly(workspace.root)
+ model.runSelectedConfiguration()
+
+ // The Run entry point starts its own load before any scan succeeded, so
+ // it captures "no snapshot applied".
+ #expect(
+ await runConfigurations.inspectionEntered(1),
+ "the Run entry point never started its own load"
+ )
+
+ // The snapshot lands and is fully consumed while that load is suspended.
+ // The refresh cannot be awaited here: the load it drives suspends on the
+ // inspection this test releases further down.
+ let refreshTask = Task { await model.workspaceFeature.refreshCurrent() }
+ #expect(
+ await runConfigurations.inspectionEntered(2),
+ "the snapshot-driven load never started"
+ )
+ runConfigurations.release(2)
+ let ready = await awaitLoadDrivenChange(on: model) {
+ model.runFeatureIfActive?.isProjectReady(
+ for: workspace.root,
+ snapshotID: model.workspaceSnapshotID
+ ) == true
+ }
+ #expect(ready, "the snapshot-driven load never made the run project ready")
+ // Wait for the snapshot-driven load to finish completely, so its resume
+ // point has already run and found nothing deferred.
+ let snapshotLoadFinished = await awaitLoadDrivenChange(on: model) {
+ model.runFeatureIfActive?.isLoadingProject == false
+ && runConfigurations.resolveCallCount == 1
+ }
+ #expect(snapshotLoadFinished, "the snapshot-driven load never finished")
+
+ runConfigurations.release(1)
+
+ let relaunched = await runConfigurations.launchPlanRequested(1)
+ #expect(relaunched, "Run was neither launched nor resumed after the snapshot landed")
+ #expect(model.pendingRunAction == nil)
+ _ = await refreshTask.value
+ }
+
+ /// Debugging reaches the same run feature through its own entry point.
+ @Test
+ func debugBeforeTheSnapshotDefersGenerationUntilTheInventoryIsComplete() async throws {
+ let workspace = try JavaWorkspaceFixture()
+ defer { workspace.remove() }
+ let operations = SequencedWorkspaceOperations.unavailableThenReady(workspace.snapshot)
+ let model = makeAppModel(workspaceOperations: operations)
+
+ model.openProjectDirectly(workspace.root)
+ model.startDebugging()
+
+ let bound = await awaitLoadDrivenChange(on: model) {
+ model.runFeatureIfActive?.projectLoadState == .bound(workspace: workspace.root.standardizedFileURL)
+ }
+ #expect(bound, "the Debug entry point never bound the workspace")
+ #expect(model.runFeatureIfActive?.isProjectReady(for: workspace.root, snapshotID: model.workspaceSnapshotID) == false)
+
+ await model.workspaceFeature.refreshCurrent()
+
+ let ready = await awaitLoadDrivenChange(on: model) {
+ model.runFeatureIfActive?.isProjectReady(
+ for: workspace.root,
+ snapshotID: model.workspaceSnapshotID
+ ) == true
+ }
+ #expect(ready, "the applied snapshot never made the run project ready")
+ }
+
+ /// Opening a project normally must reach the same ready state without any
+ /// entry point, so the tool-window path keeps working.
+ @Test
+ func openingAProjectMakesTheRunProjectReadyOnItsOwn() async throws {
+ let workspace = try JavaWorkspaceFixture()
+ defer { workspace.remove() }
+ // The scan succeeds right away here, which is the ordinary case this test
+ // protects: no entry point is involved.
+ let operations = SequencedWorkspaceOperations(snapshots: [workspace.snapshot])
+ let model = makeAppModel(workspaceOperations: operations)
+
+ model.openProjectDirectly(workspace.root)
+
+ let ready = await awaitLoadDrivenChange(on: model) {
+ model.runFeatureIfActive?.isProjectReady(
+ for: workspace.root,
+ snapshotID: model.workspaceSnapshotID
+ ) == true
+ }
+ #expect(ready, "opening a project should make the run project ready")
+ }
+
+ /// The Run panel's play buttons call `startRunConfiguration`, not
+ /// `runSelectedConfiguration`. That path must defer under a provisional
+ /// inventory and remember the concrete configuration for resume.
+ @Test
+ func startRunConfigurationDefersAndResumesAfterTheSnapshotArrives() async throws {
+ let workspace = try JavaWorkspaceFixture()
+ defer { workspace.remove() }
+ let workspaceOperations = SequencedWorkspaceOperations.unavailableThenReady(workspace.snapshot)
+ let runConfigurations = ReadyRunConfigurationOperations()
+ let model = makeAppModel(
+ workspaceOperations: workspaceOperations,
+ runConfigurationOperations: runConfigurations
+ )
+ let configuration = ReadyRunConfigurationOperations.entryPoint
+
+ model.openProjectDirectly(workspace.root)
+ model.startRunConfiguration(configuration)
+
+ let deferred = await awaitLoadDrivenChange(on: model) {
+ model.pendingRunAction?.kind == .startConfiguration(configuration)
+ }
+ #expect(deferred, "direct start must be deferred while the inventory is provisional")
+ #expect(
+ runConfigurations.launchPlanCallCount == 0,
+ "direct start must not build a launch plan from a provisional inventory"
+ )
+
+ await model.workspaceFeature.refreshCurrent()
+
+ let relaunched = await runConfigurations.launchPlanRequested(1)
+ #expect(relaunched, "the deferred direct start was never actually re-issued")
+ #expect(model.pendingRunAction == nil)
+ }
+
+ /// The Run panel's "Run All Services" button calls `runAllServiceConfigurations`,
+ /// which must defer under a provisional inventory and remember that batch
+ /// intent — not collapse into a generic `.run`.
+ @Test
+ func runAllServicesDefersAndResumesAfterTheSnapshotArrives() async throws {
+ let workspace = try JavaWorkspaceFixture()
+ defer { workspace.remove() }
+ let workspaceOperations = SequencedWorkspaceOperations.unavailableThenReady(workspace.snapshot)
+ let runConfigurations = ReadyRunConfigurationOperations()
+ let model = makeAppModel(
+ workspaceOperations: workspaceOperations,
+ runConfigurationOperations: runConfigurations
+ )
+
+ model.openProjectDirectly(workspace.root)
+ model.runAllServiceConfigurations()
+
+ let deferred = await awaitLoadDrivenChange(on: model) {
+ model.pendingRunAction?.kind == .runAllServices
+ }
+ #expect(deferred, "run-all-services must be deferred while the inventory is provisional")
+ #expect(
+ runConfigurations.launchPlanCallCount == 0,
+ "run-all-services must not build a launch plan from a provisional inventory"
+ )
+
+ await model.workspaceFeature.refreshCurrent()
+
+ let relaunched = await runConfigurations.launchPlanRequested(1)
+ #expect(relaunched, "the deferred run-all-services was never actually re-issued")
+ #expect(model.pendingRunAction == nil)
+ }
+
+ /// Restart must use the same readiness funnel as direct start. A published
+ /// but not-yet-consumed refresh still leaves the run service on the old
+ /// inventory; restarting then would rebuild a launch plan from that stale
+ /// scan.
+ @Test
+ func restartDefersWhenANewerSnapshotIsPublishedButNotYetConsumed() async throws {
+ let workspace = try JavaWorkspaceFixture()
+ defer { workspace.remove() }
+
+ let first = workspace.snapshot
+ let secondSource = workspace.root.appendingPathComponent("src/main/java/demo/Other.java")
+ try """
+ package demo;
+ public class Other {
+ public static void main(String[] args) {}
+ }
+ """.write(to: secondSource, atomically: true, encoding: .utf8)
+ let second = WorkspaceSnapshot(
+ root: first.root,
+ files: [workspace.sourceURL, secondSource],
+ id: UUID()
+ )
+
+ let workspaceOperations = SequencedWorkspaceOperations(snapshots: [nil, first, second])
+ let watchContext = GatedGitWatchContextProvider()
+ defer { watchContext.releaseAll() }
+ let runConfigurations = ReadyRunConfigurationOperations()
+ let model = makeAppModel(
+ workspaceOperations: workspaceOperations,
+ runConfigurationOperations: runConfigurations,
+ gitWatchContextProvider: watchContext
+ )
+
+ // Establish lastConfiguration through the same deferred-run path the
+ // existing entry tests already cover, then refresh to a newer snapshot
+ // without letting the run service consume it.
+ model.openProjectDirectly(workspace.root)
+ model.runSelectedConfiguration()
+ let deferredRun = await awaitLoadDrivenChange(on: model) { model.pendingRunAction?.kind == .run }
+ #expect(deferredRun, "the initial run must defer until the first snapshot arrives")
+
+ // Each refresh is held at its watch-configuration fetch, so it runs as a
+ // task the test releases; a refresh must also finish before the next one
+ // starts, or the workspace feature would drop it as already refreshing.
+ let firstRefresh = Task { await model.workspaceFeature.refreshCurrent() }
+ #expect(await watchContext.entered(1))
+ watchContext.release(1)
+ let launched = await runConfigurations.launchPlanRequested(1)
+ #expect(launched, "the initial run never requested a launch plan")
+ #expect(model.runFeatureIfActive?.lastConfiguration != nil)
+ #expect(model.pendingRunAction == nil)
+ _ = await firstRefresh.value
+
+ let refreshTask = Task { await model.workspaceFeature.refreshCurrent() }
+ #expect(await watchContext.entered(2))
+ let advanced = await awaitLoadDrivenChange(on: model) {
+ model.workspaceSnapshotID == second.id
+ }
+ #expect(advanced, "the refreshed snapshot was never published")
+ #expect(
+ model.runFeatureIfActive?.isProjectReady(for: workspace.root, snapshotID: first.id) == true,
+ "the run service should still hold the first snapshot while B's callback is held"
+ )
+
+ model.restartSelectedRun()
+ let deferredRestart = await awaitLoadDrivenChange(on: model) {
+ model.pendingRunAction?.kind == .restart
+ }
+ #expect(deferredRestart, "Restart must defer while the newer snapshot is unpublished to the run service")
+ #expect(
+ runConfigurations.launchPlanCallCount == 1,
+ "Restart must not rebuild a launch plan from the superseded inventory"
+ )
+
+ watchContext.release(2)
+ let relaunched = await runConfigurations.launchPlanRequested(2)
+ #expect(relaunched, "the deferred Restart was never actually re-issued")
+ #expect(model.pendingRunAction == nil)
+ _ = await refreshTask.value
+ }
+
+ /// An entry task that started for workspace A must not re-record its action
+ /// against B after a project switch, and must not wipe B's own pending.
+ @Test
+ func directStartFromTheOldWorkspaceIsNotDeferredIntoTheNewWorkspace() async throws {
+ let workspaceA = try JavaWorkspaceFixture()
+ let workspaceB = try JavaWorkspaceFixture()
+ defer {
+ workspaceA.remove()
+ workspaceB.remove()
+ }
+
+ // Neither project ever gets an inventory, so both direct starts take the
+ // pre-snapshot path and the only coordination point is the inspection.
+ let workspaceOperations = SequencedWorkspaceOperations.neverScans()
+ let runConfigurations = InspectionGatedRunConfigurationOperations()
+ defer { runConfigurations.releaseAll() }
+ let model = makeAppModel(
+ workspaceOperations: workspaceOperations,
+ runConfigurationOperations: runConfigurations
+ )
+ let configurationA = ReadyRunConfigurationOperations.entryPoint
+ let configurationB = ReadyRunConfigurationOperations.serviceEntryPoint
+
+ model.openProjectDirectly(workspaceA.root)
+ model.startRunConfiguration(configurationA)
+ #expect(await runConfigurations.inspectionEntered(1))
+
+ model.openProjectDirectly(workspaceB.root)
+ #expect(model.pendingRunAction == nil, "opening B must clear A's pending")
+
+ // B has no inventory either, so its direct start defers for B itself.
+ model.startRunConfiguration(configurationB)
+ #expect(await runConfigurations.inspectionEntered(2))
+ runConfigurations.release(2)
+ let deferredForB = await awaitLoadDrivenChange(on: model) {
+ model.pendingRunAction?.kind == .startConfiguration(configurationB)
+ && model.pendingRunAction?.identity.url == workspaceB.root.standardizedFileURL
+ }
+ #expect(deferredForB, "B should record its own deferred direct start")
+
+ // A's in-flight ensure finishes after the switch. It must be treated as
+ // stale: no re-defer against B, and B's pending must survive.
+ runConfigurations.release(1)
+ let corruptedByStaleA = await awaitChange(on: model, timeout: .seconds(1)) {
+ model.pendingRunAction?.kind == .startConfiguration(configurationA)
+ }
+ #expect(
+ !corruptedByStaleA,
+ "a stale entry task for A must not re-defer its configuration onto B"
+ )
+ #expect(
+ model.pendingRunAction?.kind == .startConfiguration(configurationB)
+ && model.pendingRunAction?.identity.url == workspaceB.root.standardizedFileURL,
+ "B's pending action must survive the stale A task finishing"
+ )
+ }
+
+ /// Reopening the same path starts a new session while the URL stays the same,
+ /// so only the opening's generation separates the two. An entry task from the
+ /// previous opening must be discarded — otherwise it finds the new opening's
+ /// snapshot already applied, reads that as "ready", and launches its own
+ /// configuration into a session the user has replaced.
+ @Test
+ func directStartFromAnEarlierOpeningOfTheSameWorkspaceIsDiscarded() async throws {
+ let workspace = try JavaWorkspaceFixture()
+ defer { workspace.remove() }
+
+ // The first opening never gets a snapshot, so the direct start takes the
+ // pre-snapshot path and suspends in its own load; the second opening
+ // scans normally.
+ let workspaceOperations = SequencedWorkspaceOperations.unavailableThenReady(workspace.snapshot)
+ let runConfigurations = InspectionGatedRunConfigurationOperations()
+ defer { runConfigurations.releaseAll() }
+ // This test does not coordinate on the watch configuration, and the real
+ // provider would run Git twice for the two openings.
+ let watchContext = GatedGitWatchContextProvider()
+ watchContext.releaseAll()
+ let model = makeAppModel(
+ workspaceOperations: workspaceOperations,
+ runConfigurationOperations: runConfigurations,
+ gitWatchContextProvider: watchContext
+ )
+ let earlierConfiguration = ReadyRunConfigurationOperations.entryPoint
+
+ model.openProjectDirectly(workspace.root)
+ model.startRunConfiguration(earlierConfiguration)
+ #expect(
+ await runConfigurations.inspectionEntered(1),
+ "the direct start never began its own load"
+ )
+
+ // Reopen the same path and let this opening reach a fully loaded state.
+ model.openProjectDirectly(workspace.root)
+ #expect(model.pendingRunAction == nil, "reopening must clear the previous pending")
+ #expect(
+ await runConfigurations.inspectionEntered(2),
+ "the reopened project never loaded its own snapshot"
+ )
+ runConfigurations.release(2)
+ let readyForCurrentOpening = await awaitLoadDrivenChange(on: model) {
+ model.runFeatureIfActive?.isProjectReady(
+ for: workspace.root,
+ snapshotID: model.workspaceSnapshotID
+ ) == true
+ }
+ #expect(readyForCurrentOpening, "the reopened project never became ready")
+ let currentSnapshotID = model.workspaceSnapshotID
+
+ // The earlier opening's task finishes last. Its captured URL still
+ // matches, so only the generation can reject it.
+ runConfigurations.release(1)
+ #expect(
+ await runConfigurations.launchPlanNotRequested(within: .seconds(1)),
+ "a task from the previous opening must not launch into the current one"
+ )
+ #expect(
+ model.pendingRunAction == nil,
+ "a discarded task must not record a pending action either"
+ )
+ #expect(
+ model.runFeatureIfActive?.isProjectReady(
+ for: workspace.root,
+ snapshotID: currentSnapshotID
+ ) == true,
+ "the current opening's inventory must survive the discarded task"
+ )
+ }
+
+ /// When snapshot B is already published but its callback has not consumed it
+ /// into the run service, generation must stop rather than scan the still-ready
+ /// A inventory.
+ @Test
+ func generateRefusesStaleReadyInventoryWhenSnapshotAdvancesDuringLoad() async throws {
+ let workspace = try JavaWorkspaceFixture()
+ defer { workspace.remove() }
+
+ let first = workspace.snapshot
+ let secondSource = workspace.root.appendingPathComponent("src/main/java/demo/Other.java")
+ try """
+ package demo;
+ public class Other {
+ public static void main(String[] args) {}
+ }
+ """.write(to: secondSource, atomically: true, encoding: .utf8)
+ let second = WorkspaceSnapshot(
+ root: first.root,
+ files: [workspace.sourceURL, secondSource],
+ id: UUID()
+ )
+
+ let workspaceOperations = SequencedWorkspaceOperations(snapshots: [first, second])
+ let watchContext = GatedGitWatchContextProvider()
+ defer { watchContext.releaseAll() }
+ let runConfigurations = InventoryRecordingGatedRunConfigurationOperations()
+ defer { runConfigurations.releaseAll() }
+ let model = makeAppModel(
+ workspaceOperations: workspaceOperations,
+ runConfigurationOperations: runConfigurations,
+ gitWatchContextProvider: watchContext
+ )
+
+ model.openProjectDirectly(workspace.root)
+ #expect(await watchContext.entered(1))
+ watchContext.release(1)
+ #expect(await runConfigurations.inspectionEntered(1))
+ runConfigurations.release(1)
+
+ let readyForFirst = await awaitLoadDrivenChange(on: model) {
+ model.runFeatureIfActive?.isProjectReady(
+ for: workspace.root,
+ snapshotID: first.id
+ ) == true
+ }
+ #expect(readyForFirst, "the first snapshot never made the run project ready")
+
+ let runFeature = try #require(model.runFeatureIfActive)
+ let refreshTask = Task { await model.workspaceFeature.refreshCurrent() }
+ #expect(await watchContext.entered(2))
+ let advanced = await awaitLoadDrivenChange(on: model) {
+ model.workspaceSnapshotID == second.id
+ }
+ #expect(advanced, "the refreshed snapshot was never published")
+ #expect(
+ runFeature.isProjectReady(for: workspace.root, snapshotID: first.id),
+ "the run service should still hold A while B's callback is held"
+ )
+
+ await model.generateRunConfigurations()
+ #expect(runFeature.generationState == .projectNotReady)
+ #expect(
+ runConfigurations.generatedInventories.isEmpty,
+ "generation must not scan the superseded inventory"
+ )
+
+ watchContext.release(2)
+ #expect(await runConfigurations.inspectionEntered(2))
+ runConfigurations.release(2)
+ _ = await refreshTask.value
+ }
+
+ private func makeAppModel(
+ workspaceOperations: any WorkspaceOperations,
+ runConfigurationOperations: (any RunConfigurationOperations)? = nil,
+ gitWatchContextProvider: (any GitWatchContextProviding)? = nil
+ ) -> AppModel {
+ let store = RunEntryPointTestStore()
+ let settings = AppSettings(store: store)
+ let services = MacServiceContainer(
+ store: store,
+ settings: settings,
+ workspaceOperations: workspaceOperations,
+ runConfigurationOperations: runConfigurationOperations,
+ gitWatchContextProvider: gitWatchContextProvider,
+ // These tests assert load ordering, never toolchain selection. The
+ // real resolver inspects installed JDKs through processes, which
+ // makes every test here depend on the machine and serialize behind
+ // that discovery.
+ runExecutableResolver: StubRunExecutableResolver()
+ ).services
+ return AppModel(settings: settings, services: services)
+ }
+}
+
+/// Awaits a state this suite reaches only after a full snapshot-driven load —
+/// watch configuration, project load, toolchain resolution, and the deferred
+/// resume that follows. That pipeline is far longer than a single publication,
+/// so these waits carry the same deadline as the file's cross-load gates
+/// instead of the shared default a failing test would otherwise hit first.
+@MainActor
+private func awaitLoadDrivenChange(
+ on model: AppModel,
+ until isSatisfied: @escaping @MainActor @Sendable () -> Bool
+) async -> Bool {
+ await awaitChange(on: model, timeout: .seconds(30), until: isSatisfied)
+}
+
+/// A real workspace on disk holding one Java entry point, so generation runs
+/// through the shared Core instead of a stubbed result.
+@MainActor
+private struct JavaWorkspaceFixture {
+ let root: URL
+ let sourceURL: URL
+
+ init() throws {
+ root = FileManager.default.temporaryDirectory
+ .appendingPathComponent("lithe-run-entry-\(UUID().uuidString)")
+ sourceURL = root.appendingPathComponent("src/main/java/demo/App.java")
+ try FileManager.default.createDirectory(
+ at: sourceURL.deletingLastPathComponent(),
+ withIntermediateDirectories: true
+ )
+ try """
+ package demo;
+ public class App {
+ public static void main(String[] args) {}
+ }
+ """.write(to: sourceURL, atomically: true, encoding: .utf8)
+ }
+
+ var snapshot: WorkspaceSnapshot {
+ WorkspaceSnapshot(
+ root: FileNode(url: root, isDirectory: true, children: []),
+ files: [sourceURL]
+ )
+ }
+
+ var hasGeneratedConfiguration: Bool {
+ FileManager.default.fileExists(
+ atPath: root.appendingPathComponent(".lithe/run/generated.json").path
+ )
+ }
+
+ func remove() {
+ try? FileManager.default.removeItem(at: root)
+ }
+}
+
+/// Answers toolchain questions without inspecting the machine.
+///
+/// Launching is out of scope for this suite, so resolution never has to succeed;
+/// the protocol's default candidate and refresh behavior is what keeps real JDK
+/// discovery out of these tests.
+private final class StubRunExecutableResolver: RunExecutableResolving {
+ func resolve(
+ _ plan: SharedLaunchPlan,
+ projectURL: URL,
+ options: RunOptions
+ ) throws -> ResolvedRunExecutable {
+ throw RunConfigurationOperationFailure(message: "Launching is out of scope for this test")
+ }
+}
+
+/// Hands out prepared scans in call order, and reports the folder as unreadable
+/// once they run out.
+///
+/// Reporting "no snapshot" is how these tests reach the pre-snapshot entry path.
+/// Suspending the scan instead would model a scan in flight, but the workspace
+/// feature scans from a detached task, so a held scan occupies a thread of the
+/// cooperative pool for the whole test and starves every other suite running in
+/// parallel. The tests coordinate on the run service's inspection and on the
+/// watch-configuration fetch instead, both of which suspend without a thread.
+private final class SequencedWorkspaceOperations: WorkspaceOperations, @unchecked Sendable {
+ private let lock = NSLock()
+ private let snapshots: [WorkspaceSnapshot?]
+ private var scanCount = 0
+
+ init(snapshots: [WorkspaceSnapshot?]) {
+ self.snapshots = snapshots
+ }
+
+ /// No scan ever succeeds, so every opening stays before its inventory.
+ static func neverScans() -> SequencedWorkspaceOperations {
+ SequencedWorkspaceOperations(snapshots: [])
+ }
+
+ /// The first scan finds nothing and later scans succeed, which is what a test
+ /// uses to publish an inventory on demand through `refreshCurrent()`.
+ static func unavailableThenReady(_ snapshot: WorkspaceSnapshot) -> SequencedWorkspaceOperations {
+ SequencedWorkspaceOperations(snapshots: [nil, snapshot, snapshot, snapshot])
+ }
+
+ func snapshot(at rootURL: URL, visibilityRules: FileVisibilityRules) -> WorkspaceSnapshot? {
+ lock.lock()
+ let ordinal = scanCount
+ scanCount += 1
+ lock.unlock()
+ guard ordinal < snapshots.count else { return nil }
+ return snapshots[ordinal]
+ }
+
+ func readFile(at rootURL: URL, relativePath: String) -> String? {
+ try? String(contentsOf: rootURL.appendingPathComponent(relativePath), encoding: .utf8)
+ }
+
+ func writeFile(_ text: String, at rootURL: URL, relativePath: String) -> Bool {
+ (try? text.write(
+ to: rootURL.appendingPathComponent(relativePath),
+ atomically: true,
+ encoding: .utf8
+ )) != nil
+ }
+}
+
+/// Reports a workspace that already carries a configuration, which the Swift test
+/// binary cannot obtain from the real store because it does not link the Rust
+/// Core. Records launch-plan requests so a test can prove no launch was built.
+private final class ReadyRunConfigurationOperations: RunConfigurationOperations, @unchecked Sendable {
+ private let lock = NSLock()
+ private var launchPlanCalls = 0
+ private let launchPlanRequests: [TestGate]
+
+ init(capacity: Int = 8) {
+ launchPlanRequests = (0.. Bool {
+ await launchPlanRequests[ordinal - 1].waitUntilOpen(timeout: .seconds(30))
+ }
+
+ func inspect(at projectURL: URL) -> ProjectRunConfigurationInspection {
+ ProjectRunConfigurationInspection(status: .ready, diagnostics: [])
+ }
+
+ func generate(at projectURL: URL, files: [URL], modulePaths: [String]) throws -> RunConfigurationGenerationResult {
+ RunConfigurationGenerationResult(entryCount: 1)
+ }
+
+ /// A configuration that does not depend on the active editor file, so a
+ /// resumed Run reaches the launch plan instead of stopping at "no open file".
+ static let entryPoint = RunConfiguration(
+ id: "java-main:demo.App",
+ name: "App",
+ kind: .javaMain,
+ execution: .application,
+ modulePath: nil,
+ mainClass: "demo.App"
+ )
+
+ /// A service configuration so `runAllServiceConfigurations` has something to
+ /// launch after a deferred resume.
+ static let serviceEntryPoint = RunConfiguration(
+ id: "spring-boot:demo.App",
+ name: "App (Spring Boot)",
+ kind: .mavenFramework(.springBoot),
+ execution: .service,
+ modulePath: nil,
+ mainClass: "demo.App"
+ )
+
+ func resolve(at projectURL: URL, toolchainCandidates: [ProjectToolchainCandidate]) throws -> RunConfigurationResolution {
+ RunConfigurationResolution(
+ configurations: [
+ EffectiveRunConfiguration(
+ configuration: Self.entryPoint,
+ options: RunOptions()
+ ),
+ EffectiveRunConfiguration(
+ configuration: Self.serviceEntryPoint,
+ options: RunOptions()
+ ),
+ ],
+ diagnostics: [],
+ defaultConfigurationID: Self.entryPoint.id
+ )
+ }
+
+ func launchPlan(
+ at projectURL: URL,
+ configurationID: String,
+ currentFile: String?,
+ classPath: String?,
+ debugPort: Int?
+ ) throws -> SharedLaunchPlan {
+ lock.lock()
+ launchPlanCalls += 1
+ let ordinal = launchPlanCalls
+ lock.unlock()
+ launchPlanRequests[ordinal - 1].open()
+ throw RunConfigurationOperationFailure(message: "Launching is out of scope for this test")
+ }
+
+ func createConfiguration(_ draft: RunConfigurationDraft, at projectURL: URL) throws -> String { draft.name }
+ func migrateLegacySettings(at projectURL: URL, configurationIDs: [String]) throws {}
+}
+
+/// Reports a workspace that already carries a configuration, and lets a test
+/// release each `inspect` individually so it can decide what happens while a
+/// specific project load is suspended.
+private final class InspectionGatedRunConfigurationOperations: RunConfigurationOperations, @unchecked Sendable {
+ private let entered: [TestGate]
+ private let releases: [TestGate]
+ private let launchPlanRequests: [TestGate]
+ private let lock = NSLock()
+ private var inspectCalls = 0
+ private var launchPlanCalls = 0
+ private var resolveCalls = 0
+
+ init(capacity: Int = 8) {
+ entered = (0.. Bool {
+ await launchPlanRequests[ordinal - 1].waitUntilOpen(timeout: .seconds(30))
+ }
+
+ /// Asserting that no launch happens needs a short deadline: the whole wait is
+ /// paid on the passing path, so it must not carry a load-sized one.
+ func launchPlanNotRequested(within duration: Duration) async -> Bool {
+ await !launchPlanRequests[0].waitUntilOpen(timeout: duration)
+ }
+
+ var resolveCallCount: Int {
+ lock.lock()
+ defer { lock.unlock() }
+ return resolveCalls
+ }
+
+ /// Waits for the `ordinal`-th inspection (1-based) to reach its gate.
+ func inspectionEntered(_ ordinal: Int) async -> Bool {
+ await entered[ordinal - 1].waitUntilOpen(timeout: .seconds(5))
+ }
+
+ func release(_ ordinal: Int) {
+ releases[ordinal - 1].open()
+ }
+
+ func releaseAll() {
+ releases.forEach { $0.open() }
+ }
+
+ func inspect(at projectURL: URL) -> ProjectRunConfigurationInspection {
+ lock.lock()
+ inspectCalls += 1
+ let ordinal = inspectCalls
+ lock.unlock()
+ // Runs on the run service's utility queue, never the cooperative
+ // executor. The race test holds the first gate across a full
+ // snapshot-driven load, so the deadline must cover that window.
+ entered[ordinal - 1].open()
+ _ = releases[ordinal - 1].waitSynchronously(timeout: 30)
+ return ProjectRunConfigurationInspection(status: .ready, diagnostics: [])
+ }
+
+ func generate(at projectURL: URL, files: [URL], modulePaths: [String]) throws -> RunConfigurationGenerationResult {
+ RunConfigurationGenerationResult(entryCount: 1)
+ }
+
+ func resolve(at projectURL: URL, toolchainCandidates: [ProjectToolchainCandidate]) throws -> RunConfigurationResolution {
+ lock.lock()
+ resolveCalls += 1
+ lock.unlock()
+ return RunConfigurationResolution(
+ configurations: [EffectiveRunConfiguration(
+ configuration: ReadyRunConfigurationOperations.entryPoint,
+ options: RunOptions()
+ )],
+ diagnostics: [],
+ defaultConfigurationID: ReadyRunConfigurationOperations.entryPoint.id
+ )
+ }
+
+ func launchPlan(
+ at projectURL: URL,
+ configurationID: String,
+ currentFile: String?,
+ classPath: String?,
+ debugPort: Int?
+ ) throws -> SharedLaunchPlan {
+ lock.lock()
+ launchPlanCalls += 1
+ let ordinal = launchPlanCalls
+ lock.unlock()
+ launchPlanRequests[ordinal - 1].open()
+ throw RunConfigurationOperationFailure(message: "Launching is out of scope for this test")
+ }
+
+ func createConfiguration(_ draft: RunConfigurationDraft, at projectURL: URL) throws -> String { draft.name }
+ func migrateLegacySettings(at projectURL: URL, configurationIDs: [String]) throws {}
+}
+
+/// Holds `updateWatchConfiguration`'s git-context fetch so a test can publish a
+/// newer snapshot without letting `onSnapshotLoaded` consume it yet.
+private final class GatedGitWatchContextProvider: GitWatchContextProviding, @unchecked Sendable {
+ private let entered: [TestGate]
+ private let releases: [TestGate]
+ private let queue = DispatchQueue(label: "lithe.tests.gated-git-watch-context")
+ private var calls = 0
+
+ init(capacity: Int = 8) {
+ entered = (0.. Bool {
+ await entered[ordinal - 1].waitUntilOpen(timeout: .seconds(5))
+ }
+
+ func release(_ ordinal: Int) {
+ releases[ordinal - 1].open()
+ }
+
+ func releaseAll() {
+ releases.forEach { $0.open() }
+ }
+
+ func watchContext(for workspace: URL) async -> GitWatchContext? {
+ let ordinal: Int = await withCheckedContinuation { continuation in
+ queue.async {
+ self.calls += 1
+ continuation.resume(returning: self.calls)
+ }
+ }
+ entered[ordinal - 1].open()
+ _ = await releases[ordinal - 1].waitUntilOpen(timeout: .seconds(30))
+ return nil
+ }
+}
+
+/// Like `InspectionGatedRunConfigurationOperations`, but also records every
+/// generate inventory so a superseded-snapshot test can prove generation never
+/// scanned the stale file list.
+private final class InventoryRecordingGatedRunConfigurationOperations: RunConfigurationOperations, @unchecked Sendable {
+ private let entered: [TestGate]
+ private let releases: [TestGate]
+ private let lock = NSLock()
+ private var inspectCalls = 0
+ private var launchPlanCalls = 0
+ private var inventories: [[URL]] = []
+
+ init(capacity: Int = 8) {
+ entered = (0.. Bool {
+ await entered[ordinal - 1].waitUntilOpen(timeout: .seconds(5))
+ }
+
+ func release(_ ordinal: Int) {
+ releases[ordinal - 1].open()
+ }
+
+ func releaseAll() {
+ releases.forEach { $0.open() }
+ }
+
+ func inspect(at projectURL: URL) -> ProjectRunConfigurationInspection {
+ lock.lock()
+ inspectCalls += 1
+ let ordinal = inspectCalls
+ lock.unlock()
+ entered[ordinal - 1].open()
+ _ = releases[ordinal - 1].waitSynchronously(timeout: 30)
+ return ProjectRunConfigurationInspection(status: .ready, diagnostics: [])
+ }
+
+ func generate(at projectURL: URL, files: [URL], modulePaths: [String]) throws -> RunConfigurationGenerationResult {
+ lock.lock()
+ inventories.append(files)
+ lock.unlock()
+ return RunConfigurationGenerationResult(entryCount: files.count)
+ }
+
+ func resolve(at projectURL: URL, toolchainCandidates: [ProjectToolchainCandidate]) throws -> RunConfigurationResolution {
+ RunConfigurationResolution(
+ configurations: [EffectiveRunConfiguration(
+ configuration: ReadyRunConfigurationOperations.entryPoint,
+ options: RunOptions()
+ )],
+ diagnostics: [],
+ defaultConfigurationID: ReadyRunConfigurationOperations.entryPoint.id
+ )
+ }
+
+ func launchPlan(
+ at projectURL: URL,
+ configurationID: String,
+ currentFile: String?,
+ classPath: String?,
+ debugPort: Int?
+ ) throws -> SharedLaunchPlan {
+ lock.lock()
+ launchPlanCalls += 1
+ lock.unlock()
+ throw RunConfigurationOperationFailure(message: "Launching is out of scope for this test")
+ }
+
+ func createConfiguration(_ draft: RunConfigurationDraft, at projectURL: URL) throws -> String { draft.name }
+ func migrateLegacySettings(at projectURL: URL, configurationIDs: [String]) throws {}
+}
+
+private final class RunEntryPointTestStore: KeyValueStore, @unchecked Sendable {
+ private var values: [String: Any] = [:]
+
+ func data(forKey key: String) -> Data? { values[key] as? Data }
+ func object(forKey key: String) -> Any? { values[key] }
+ func string(forKey key: String) -> String? { values[key] as? String }
+ func stringArray(forKey key: String) -> [String]? { values[key] as? [String] }
+ func set(_ value: Any?, forKey key: String) { values[key] = value }
+}
diff --git a/macos/Tests/LitheTests/SpringFeatureModelTests.swift b/macos/Tests/LitheTests/SpringFeatureModelTests.swift
index 48d557610..ca8f5a828 100644
--- a/macos/Tests/LitheTests/SpringFeatureModelTests.swift
+++ b/macos/Tests/LitheTests/SpringFeatureModelTests.swift
@@ -99,17 +99,111 @@ struct SpringFeatureModelTests {
let locations = feature.navigationLocations(for: injectionURL, line: 7)
#expect(locations.map(\.url) == [firstURL, secondURL])
}
+
+ /// Opening a workspace must not wait for Spring indexing, which scales with
+ /// the number of Java sources.
+ @Test
+ func scheduleLoadDefersIndexingAndPublishesTheResult() async throws {
+ let root = URL(fileURLWithPath: "/workspace")
+ let beanURL = root.appendingPathComponent("Service.java")
+ let operations = SpringTestOperations(result: componentIndex(at: beanURL))
+ let feature = SpringFeatureModel(operations: operations)
+ defer { feature.reset() }
+
+ feature.scheduleLoad(workspaceURL: root, files: [beanURL])
+
+ // The schedule owns a MainActor task, which cannot run before this test
+ // suspends. Reaching these assertions proves the caller was not blocked.
+ #expect(feature.beans.isEmpty)
+ #expect(operations.requestedFiles.isEmpty)
+
+ let published = await awaitChange(on: feature) {
+ !feature.isIndexing && !feature.beans.isEmpty
+ }
+ #expect(published, "the scheduled index never published a result")
+ #expect(feature.beans.map(\.id) == ["Service.java"])
+ #expect(operations.requestedFiles == [[beanURL]])
+ }
+
+ /// A newer schedule supersedes the pending one so a burst of reloads cannot
+ /// publish a stale index.
+ @Test
+ func scheduleLoadReplacesAPendingSchedule() async throws {
+ let root = URL(fileURLWithPath: "/workspace")
+ let staleURL = root.appendingPathComponent("Stale.java")
+ let freshURL = root.appendingPathComponent("Fresh.java")
+ let operations = SpringTestOperations { files in
+ files.first.map(componentIndex(at:)) ?? .empty
+ }
+ let feature = SpringFeatureModel(operations: operations)
+ defer { feature.reset() }
+
+ feature.scheduleLoad(workspaceURL: root, files: [staleURL])
+ feature.scheduleLoad(workspaceURL: root, files: [freshURL])
+
+ let published = await awaitChange(on: feature) {
+ !feature.isIndexing && !feature.beans.isEmpty
+ }
+ #expect(published, "the replacement schedule never published a result")
+ // A schedule superseded before it started must not run a second full
+ // workspace index, which the generation token would only discard.
+ #expect(operations.requestedFiles == [[freshURL]])
+ #expect(feature.beans.map(\.id) == ["Fresh.java"])
+ }
+}
+
+private func componentIndex(at url: URL) -> SpringIndexResult {
+ SpringIndexResult(
+ properties: [],
+ values: [],
+ propertyReferences: [],
+ diagnostics: [],
+ beans: [SpringBean(
+ id: url.lastPathComponent,
+ name: "service",
+ typeName: "Service",
+ url: url,
+ line: 3,
+ column: 7,
+ kind: "component"
+ )],
+ injections: [],
+ endpoints: []
+ )
}
-private struct SpringTestOperations: JavaMavenOperations {
- let result: SpringIndexResult
+private final class SpringTestOperations: JavaMavenOperations, @unchecked Sendable {
+ private let makeResult: @Sendable ([URL]) -> SpringIndexResult
+ private let lock = NSLock()
+ private var requested: [[URL]] = []
+
+ /// Every set of files handed to the index, in call order. An empty value
+ /// proves the double was never reached.
+ var requestedFiles: [[URL]] {
+ lock.lock()
+ defer { lock.unlock() }
+ return requested
+ }
+
+ init(result: SpringIndexResult) {
+ makeResult = { _ in result }
+ }
+
+ init(resultForFiles: @escaping @Sendable ([URL]) -> SpringIndexResult) {
+ makeResult = resultForFiles
+ }
func springIndex(
at rootURL: URL,
files: [URL],
textOverrides: [URL: String],
refreshDependencyMetadata: Bool
- ) -> SpringIndexResult? { result }
+ ) -> SpringIndexResult? {
+ lock.lock()
+ requested.append(files)
+ lock.unlock()
+ return makeResult(files)
+ }
func scanMavenProject(at rootURL: URL, files: [URL]) -> MavenProject? { nil }
func mavenDiagnostics(output: String, projectRoot: URL) -> [MavenBuildIssue] { [] }
func codeVision(at rootURL: URL, targetPath: String, paths: [String]) -> [JavaCodeVisionValue] { [] }
diff --git a/macos/Tests/LitheTests/TerminalPlacementFeatureModelTests.swift b/macos/Tests/LitheTests/TerminalPlacementFeatureModelTests.swift
index ce05a1d27..691009789 100644
--- a/macos/Tests/LitheTests/TerminalPlacementFeatureModelTests.swift
+++ b/macos/Tests/LitheTests/TerminalPlacementFeatureModelTests.swift
@@ -163,8 +163,10 @@ struct TerminalPlacementFeatureModelTests {
private final class PlacementTestTerminalTransport: TerminalTransport {
let nativeView: AnyObject = NSObject()
var isRunning = false
+ var processID: Int32? { isRunning ? 1234 : nil }
var shellName = "Shell"
var onTermination: ((Int32?) -> Void)?
+ var onOutput: ((Data) -> Void)?
var onTitle: ((String) -> Void)?
var onDirectoryUpdate: ((String?) -> Void)?
var onLink: ((String, [String: String]) -> Void)?
@@ -183,6 +185,15 @@ private final class PlacementTestTerminalTransport: TerminalTransport {
isRunning = true
}
+ func startProcess(
+ _ launch: TerminalProcessLaunch,
+ environment: [String: String]
+ ) throws -> Int32 {
+ startCount += 1
+ isRunning = true
+ return 1234
+ }
+
func send(_ input: Data) throws {}
func interrupt() throws {}
func focus() {}
diff --git a/rust/lithe-core/resources/lsp/language-providers.json b/rust/lithe-core/resources/lsp/language-providers.json
index 113241ef6..6bf7f0012 100644
--- a/rust/lithe-core/resources/lsp/language-providers.json
+++ b/rust/lithe-core/resources/lsp/language-providers.json
@@ -5,7 +5,7 @@
"id": "java",
"displayName": "Java",
"fileExtensions": ["java"],
- "capabilities": ["run", "languageServer", "formatting", "testing"],
+ "capabilities": ["run", "languageServer", "debugAdapter", "formatting", "testing"],
"activationPolicy": "onDemand",
"languageId": "java",
"languageServerLaunch": {
diff --git a/rust/lithe-core/src/debug/breakpoint_relocation.rs b/rust/lithe-core/src/debug/breakpoint_relocation.rs
new file mode 100644
index 000000000..618bced4b
--- /dev/null
+++ b/rust/lithe-core/src/debug/breakpoint_relocation.rs
@@ -0,0 +1,353 @@
+//! Deterministic source-breakpoint relocation across UTF-16 editor edits.
+
+use std::collections::BTreeMap;
+
+use serde::{Deserialize, Serialize};
+
+use crate::protocol::{CoreError, ErrorCode};
+
+use super::SourceBreakpoint;
+
+#[derive(Debug, Clone, Deserialize)]
+#[serde(rename_all = "camelCase")]
+/// One native-editor replacement expressed in document-relative UTF-16 offsets.
+pub struct DebugSourceEdit {
+ /// Inclusive start offset in the source text before the edit.
+ pub start_utf16_offset: usize,
+ /// Exclusive end offset in the source text before the edit.
+ pub end_utf16_offset: usize,
+ /// Text inserted in place of the edited range.
+ pub replacement: String,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+#[serde(rename_all = "camelCase")]
+/// Relocates requested breakpoints after one exact editor mutation.
+pub struct RelocateBreakpointsRequest {
+ /// Complete source text before the mutation.
+ pub source: String,
+ pub edit: DebugSourceEdit,
+ #[serde(default)]
+ pub breakpoints: Vec,
+}
+
+#[derive(Debug, Clone, Eq, PartialEq, Serialize)]
+#[serde(rename_all = "camelCase")]
+/// Stable breakpoint set after applying one source edit.
+pub struct RelocateBreakpointsResult {
+ pub breakpoints: Vec,
+}
+
+/// Moves breakpoint anchors with inserted or removed source text.
+pub fn relocate_breakpoints(
+ request: RelocateBreakpointsRequest,
+) -> Result {
+ let source_utf16_length = request.source.encode_utf16().count();
+ if request.edit.start_utf16_offset > request.edit.end_utf16_offset
+ || request.edit.end_utf16_offset > source_utf16_length
+ {
+ return Err(invalid_request(
+ "Debug source edit offsets must form a valid UTF-16 range.",
+ ));
+ }
+
+ let start_byte = byte_index_at_utf16_offset(&request.source, request.edit.start_utf16_offset)?;
+ let end_byte = byte_index_at_utf16_offset(&request.source, request.edit.end_utf16_offset)?;
+ let mut updated_source = String::with_capacity(
+ request.source.len() - (end_byte - start_byte) + request.edit.replacement.len(),
+ );
+ updated_source.push_str(&request.source[..start_byte]);
+ updated_source.push_str(&request.edit.replacement);
+ updated_source.push_str(&request.source[end_byte..]);
+
+ let replacement_utf16_length = request.edit.replacement.encode_utf16().count();
+ let mut relocated = BTreeMap::new();
+ for breakpoint in request.breakpoints {
+ let anchor = breakpoint_anchor_utf16_offset(&request.source, &breakpoint)?;
+ let anchor_was_replaced = request.edit.start_utf16_offset < request.edit.end_utf16_offset
+ && anchor >= request.edit.start_utf16_offset
+ && anchor < request.edit.end_utf16_offset;
+ let relocated_anchor = relocate_anchor(
+ anchor,
+ request.edit.start_utf16_offset,
+ request.edit.end_utf16_offset,
+ replacement_utf16_length,
+ );
+ let (line, column) = position_at_utf16_offset(&updated_source, relocated_anchor)?;
+ let relocated_breakpoint = SourceBreakpoint {
+ line,
+ column: breakpoint.column.map(|_| column),
+ enabled: breakpoint.enabled,
+ condition: breakpoint.condition,
+ hit_condition: breakpoint.hit_condition,
+ log_message: breakpoint.log_message,
+ };
+ let identity = (
+ relocated_breakpoint.line,
+ relocated_breakpoint.column.unwrap_or(0),
+ );
+ match relocated.get(&identity) {
+ Some((existing_anchor_was_replaced, _))
+ if !existing_anchor_was_replaced || anchor_was_replaced => {}
+ _ => {
+ // Preserve conditions and log settings from code that survived the edit
+ // when a removed anchor collapses onto the same resulting position.
+ relocated.insert(identity, (anchor_was_replaced, relocated_breakpoint));
+ }
+ }
+ }
+ Ok(RelocateBreakpointsResult {
+ breakpoints: relocated
+ .into_values()
+ .map(|(_, breakpoint)| breakpoint)
+ .collect(),
+ })
+}
+
+fn breakpoint_anchor_utf16_offset(
+ source: &str,
+ breakpoint: &SourceBreakpoint,
+) -> Result {
+ if breakpoint.line < 1 || breakpoint.column.is_some_and(|column| column < 1) {
+ return Err(invalid_request(
+ "Debug breakpoint line and column values must be one-based.",
+ ));
+ }
+ let line_index = usize::try_from(breakpoint.line - 1)
+ .map_err(|_| invalid_request("Debug breakpoint line is out of range."))?;
+ let line_starts = line_start_utf16_offsets(source);
+ let Some(&line_start) = line_starts.get(line_index) else {
+ return Err(invalid_request("Debug breakpoint line is out of range."));
+ };
+ let line_end = line_starts
+ .get(line_index + 1)
+ .copied()
+ .map(|offset| offset.saturating_sub(1))
+ .unwrap_or_else(|| source.encode_utf16().count());
+ let column = match breakpoint.column {
+ Some(column) => usize::try_from(column - 1)
+ .map_err(|_| invalid_request("Debug breakpoint column is out of range."))?,
+ None => first_non_whitespace_utf16_column(source, line_start, line_end)?,
+ };
+ let anchor = line_start.saturating_add(column);
+ if anchor > line_end {
+ return Err(invalid_request("Debug breakpoint column is out of range."));
+ }
+ Ok(anchor)
+}
+
+fn first_non_whitespace_utf16_column(
+ source: &str,
+ line_start: usize,
+ line_end: usize,
+) -> Result {
+ let start_byte = byte_index_at_utf16_offset(source, line_start)?;
+ let end_byte = byte_index_at_utf16_offset(source, line_end)?;
+ let mut column = 0;
+ for character in source[start_byte..end_byte].chars() {
+ if !character.is_whitespace() {
+ return Ok(column);
+ }
+ column += character.len_utf16();
+ }
+ Ok(0)
+}
+
+fn relocate_anchor(
+ anchor: usize,
+ edit_start: usize,
+ edit_end: usize,
+ replacement_length: usize,
+) -> usize {
+ if anchor < edit_start {
+ return anchor;
+ }
+ if anchor > edit_end || (anchor == edit_end && edit_start != edit_end) {
+ return anchor - (edit_end - edit_start) + replacement_length;
+ }
+ edit_start + replacement_length
+}
+
+fn line_start_utf16_offsets(source: &str) -> Vec {
+ let mut values = vec![0];
+ let mut offset = 0;
+ for character in source.chars() {
+ offset += character.len_utf16();
+ if character == '\n' {
+ values.push(offset);
+ }
+ }
+ values
+}
+
+fn position_at_utf16_offset(source: &str, offset: usize) -> Result<(i64, i64), CoreError> {
+ let source_length = source.encode_utf16().count();
+ if offset > source_length {
+ return Err(invalid_request(
+ "Relocated debug breakpoint offset is outside the edited source.",
+ ));
+ }
+ let line_starts = line_start_utf16_offsets(source);
+ let line_index = line_starts.partition_point(|line_start| *line_start <= offset) - 1;
+ let column = offset - line_starts[line_index];
+ Ok(((line_index + 1) as i64, (column + 1) as i64))
+}
+
+fn byte_index_at_utf16_offset(source: &str, target: usize) -> Result {
+ let mut utf16_offset = 0;
+ for (byte_offset, character) in source.char_indices() {
+ if utf16_offset == target {
+ return Ok(byte_offset);
+ }
+ utf16_offset += character.len_utf16();
+ if utf16_offset > target {
+ return Err(invalid_request(
+ "Debug source edit offsets cannot split a UTF-16 surrogate pair.",
+ ));
+ }
+ }
+ if utf16_offset == target {
+ return Ok(source.len());
+ }
+ Err(invalid_request(
+ "Debug source edit offset is outside the source text.",
+ ))
+}
+
+fn invalid_request(message: &str) -> CoreError {
+ CoreError::new(ErrorCode::InvalidRequest, message)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn breakpoint(line: i64) -> SourceBreakpoint {
+ SourceBreakpoint {
+ line,
+ column: None,
+ enabled: true,
+ condition: Some("ready".to_string()),
+ hit_condition: None,
+ log_message: None,
+ }
+ }
+
+ #[test]
+ fn insertion_before_statement_moves_line_breakpoint_with_its_code() {
+ let source = "class Main {\n void run() {}\n}\n";
+ let line_start = source.find(" void").unwrap();
+ let result = relocate_breakpoints(RelocateBreakpointsRequest {
+ source: source.to_string(),
+ edit: DebugSourceEdit {
+ start_utf16_offset: line_start,
+ end_utf16_offset: line_start,
+ replacement: "\n".to_string(),
+ },
+ breakpoints: vec![breakpoint(2)],
+ })
+ .unwrap();
+
+ assert_eq!(result.breakpoints, vec![breakpoint(3)]);
+ }
+
+ #[test]
+ fn editing_after_statement_anchor_keeps_breakpoint_on_its_line() {
+ let source = "class Main {\n void run() {}\n}\n";
+ let edit_offset = source.find("run").unwrap() + "run".len();
+ let result = relocate_breakpoints(RelocateBreakpointsRequest {
+ source: source.to_string(),
+ edit: DebugSourceEdit {
+ start_utf16_offset: edit_offset,
+ end_utf16_offset: edit_offset,
+ replacement: "Now".to_string(),
+ },
+ breakpoints: vec![breakpoint(2)],
+ })
+ .unwrap();
+
+ assert_eq!(result.breakpoints, vec![breakpoint(2)]);
+ }
+
+ #[test]
+ fn deleting_preceding_lines_moves_breakpoint_up() {
+ let source = "class Main {\n int value;\n void run() {}\n}\n";
+ let deletion_start = source.find(" int").unwrap();
+ let deletion_end = source.find(" void").unwrap();
+ let result = relocate_breakpoints(RelocateBreakpointsRequest {
+ source: source.to_string(),
+ edit: DebugSourceEdit {
+ start_utf16_offset: deletion_start,
+ end_utf16_offset: deletion_end,
+ replacement: String::new(),
+ },
+ breakpoints: vec![breakpoint(3)],
+ })
+ .unwrap();
+
+ assert_eq!(result.breakpoints, vec![breakpoint(2)]);
+ }
+
+ #[test]
+ fn utf16_offsets_preserve_columns_after_non_bmp_text() {
+ let source = "class Main {\n String icon = \"🚀\"; run();\n}\n";
+ let anchor_byte = source.find("run").unwrap();
+ let anchor_utf16 = source[..anchor_byte].encode_utf16().count();
+ let mut expected = breakpoint(2);
+ expected.column = Some(29);
+ let result = relocate_breakpoints(RelocateBreakpointsRequest {
+ source: source.to_string(),
+ edit: DebugSourceEdit {
+ start_utf16_offset: anchor_utf16,
+ end_utf16_offset: anchor_utf16,
+ replacement: "next".to_string(),
+ },
+ breakpoints: vec![SourceBreakpoint {
+ line: 2,
+ column: Some(25),
+ ..breakpoint(2)
+ }],
+ })
+ .unwrap();
+
+ assert_eq!(result.breakpoints, vec![expected]);
+ }
+
+ #[test]
+ fn breakpoints_that_collapse_to_one_location_are_deduplicated() {
+ let source = "first();\nsecond();\n";
+ let mut deleted_breakpoint = breakpoint(1);
+ deleted_breakpoint.condition = Some("deleted".to_string());
+ let mut surviving_breakpoint = breakpoint(2);
+ surviving_breakpoint.condition = Some("surviving".to_string());
+ let result = relocate_breakpoints(RelocateBreakpointsRequest {
+ source: source.to_string(),
+ edit: DebugSourceEdit {
+ start_utf16_offset: 0,
+ end_utf16_offset: "first();\n".len(),
+ replacement: String::new(),
+ },
+ breakpoints: vec![deleted_breakpoint, surviving_breakpoint.clone()],
+ })
+ .unwrap();
+
+ surviving_breakpoint.line = 1;
+ assert_eq!(result.breakpoints, vec![surviving_breakpoint]);
+ }
+
+ #[test]
+ fn edit_cannot_split_a_utf16_surrogate_pair() {
+ let error = relocate_breakpoints(RelocateBreakpointsRequest {
+ source: "🚀".to_string(),
+ edit: DebugSourceEdit {
+ start_utf16_offset: 1,
+ end_utf16_offset: 1,
+ replacement: String::new(),
+ },
+ breakpoints: vec![],
+ })
+ .unwrap_err();
+
+ assert!(matches!(error.code, ErrorCode::InvalidRequest));
+ }
+}
diff --git a/rust/lithe-core/src/debug/engine.rs b/rust/lithe-core/src/debug/engine.rs
new file mode 100644
index 000000000..5c77341ac
--- /dev/null
+++ b/rust/lithe-core/src/debug/engine.rs
@@ -0,0 +1,3866 @@
+//! Stateful DAP reducer whose byte transport and process lifecycle remain platform owned.
+
+use super::protocol::{frame_message, parse_messages};
+use super::types::*;
+use crate::protocol::{CoreError, ErrorCode};
+use base64::engine::general_purpose::STANDARD as BASE64;
+use base64::Engine as _;
+use serde_json::{json, Map, Value};
+use std::collections::{BTreeMap, HashMap};
+use std::sync::{Mutex, OnceLock};
+
+static SESSIONS: OnceLock>> = OnceLock::new();
+
+#[derive(Debug)]
+struct DebugSession {
+ id: String,
+ adapter_id: String,
+ root_path: String,
+ state: DebugSessionState,
+ next_request_sequence: i64,
+ next_event_sequence: u64,
+ read_buffer: Vec,
+ pending_requests: BTreeMap,
+ breakpoints: BTreeMap>,
+ exception_breakpoints: Vec,
+ did_configure_exception_breakpoints: bool,
+ function_breakpoints: Vec,
+ data_breakpoints: Vec,
+ supports_run_in_terminal_request: bool,
+ pending_run_in_terminal_requests: BTreeMap,
+ did_receive_initialized: bool,
+ supports_configuration_done: bool,
+ capabilities: DebugCapabilities,
+ stepping_filters: DebugSteppingFilters,
+ debug_request_kind: Option,
+ pending_launch: Option<(String, DebugLaunchConfiguration)>,
+ outbound_frames: Vec>,
+ events: Vec,
+}
+
+#[derive(Debug)]
+enum PendingRequest {
+ Initialize,
+ Launch {
+ operation_id: String,
+ },
+ SetBreakpoints {
+ source_path: String,
+ requested: Vec,
+ },
+ SetExceptionBreakpoints,
+ SetFunctionBreakpoints {
+ requested: Vec,
+ },
+ DataBreakpointInfo {
+ operation_id: String,
+ },
+ SetDataBreakpoints {
+ requested: Vec,
+ },
+ SetVariable {
+ operation_id: String,
+ name: String,
+ },
+ Cancel,
+ ConfigurationDone,
+ Execute {
+ operation_id: String,
+ command: DebugExecutionCommand,
+ single_thread: bool,
+ },
+ Inspect {
+ operation_id: String,
+ kind: DebugInspectKind,
+ },
+ Disconnect,
+}
+
+/// Creates a session and returns the framed DAP `initialize` request to send.
+pub(crate) fn create_session(
+ request: CreateSessionRequest,
+) -> Result {
+ validate_identifier(&request.session_id, "sessionId")?;
+ validate_identifier(&request.adapter_id, "adapterId")?;
+ validate_path(&request.root_path, "rootPath")?;
+ let mut sessions = sessions_lock()?;
+ if sessions.contains_key(&request.session_id) {
+ return Err(invalid_request(
+ "A debug session with this sessionId already exists.",
+ ));
+ }
+ let mut session = DebugSession {
+ id: request.session_id.clone(),
+ adapter_id: request.adapter_id,
+ root_path: request.root_path,
+ state: DebugSessionState::Idle,
+ next_request_sequence: 1,
+ next_event_sequence: 1,
+ read_buffer: Vec::new(),
+ pending_requests: BTreeMap::new(),
+ breakpoints: BTreeMap::new(),
+ exception_breakpoints: Vec::new(),
+ did_configure_exception_breakpoints: false,
+ function_breakpoints: Vec::new(),
+ data_breakpoints: Vec::new(),
+ supports_run_in_terminal_request: request.supports_run_in_terminal_request,
+ pending_run_in_terminal_requests: BTreeMap::new(),
+ did_receive_initialized: false,
+ supports_configuration_done: false,
+ capabilities: DebugCapabilities::default(),
+ stepping_filters: DebugSteppingFilters::unfiltered(),
+ debug_request_kind: None,
+ pending_launch: None,
+ outbound_frames: Vec::new(),
+ events: Vec::new(),
+ };
+ session.transition(DebugSessionState::Initializing);
+ session.send_request(
+ "initialize",
+ json!({
+ "clientID": "lithe",
+ "clientName": "Lithe",
+ "adapterID": session.adapter_id,
+ "linesStartAt1": true,
+ "columnsStartAt1": true,
+ "pathFormat": "path",
+ "supportsVariableType": true,
+ "supportsVariablePaging": true,
+ "supportsRunInTerminalRequest": session.supports_run_in_terminal_request,
+ "supportsMemoryReferences": false,
+ "supportsProgressReporting": false,
+ "supportsInvalidatedEvent": true
+ }),
+ PendingRequest::Initialize,
+ )?;
+ let update = session.take_update();
+ sessions.insert(request.session_id, session);
+ Ok(update)
+}
+
+/// Queues launch or attach now, or stores it until initialize completes.
+pub(crate) fn launch(request: LaunchRequest) -> Result {
+ validate_identifier(&request.operation_id, "operationId")?;
+ with_session(&request.session_id, |session| {
+ match session.state {
+ DebugSessionState::Initializing => {
+ session.pending_launch = Some((request.operation_id, request.configuration));
+ }
+ DebugSessionState::Ready => {
+ session.perform_launch(request.operation_id, request.configuration)?;
+ }
+ _ => {
+ return Err(invalid_request(
+ "The debug session is not ready to launch or attach.",
+ ))
+ }
+ }
+ Ok(session.take_update())
+ })
+}
+
+/// Returns deterministic adapter defaults or a normalized native-client override.
+pub(crate) fn stepping_filters(
+ request: DebugSteppingFiltersRequest,
+) -> Result {
+ validate_identifier(&request.adapter_id, "adapterId")?;
+ normalize_stepping_filters(
+ request
+ .filters
+ .unwrap_or_else(|| DebugSteppingFilters::defaults_for_adapter(&request.adapter_id)),
+ )
+}
+
+/// Stores a deterministic breakpoint set and sends it after DAP initialization.
+pub(crate) fn set_breakpoints(
+ mut request: SetBreakpointsRequest,
+) -> Result {
+ validate_path(&request.source_path, "sourcePath")?;
+ for breakpoint in &request.breakpoints {
+ if breakpoint.line < 1 || breakpoint.column.is_some_and(|column| column < 1) {
+ return Err(invalid_request(
+ "Debug breakpoint line and column values must be one-based.",
+ ));
+ }
+ }
+ request.breakpoints.sort_by_key(|breakpoint| {
+ (
+ breakpoint.line,
+ breakpoint.column.unwrap_or(0),
+ breakpoint.enabled,
+ breakpoint.condition.clone().unwrap_or_default(),
+ breakpoint.hit_condition.clone().unwrap_or_default(),
+ breakpoint.log_message.clone().unwrap_or_default(),
+ )
+ });
+ request.breakpoints.dedup();
+ with_session(&request.session_id, |session| {
+ session
+ .breakpoints
+ .insert(request.source_path.clone(), request.breakpoints);
+ if session.did_receive_initialized {
+ session.send_breakpoints(&request.source_path)?;
+ }
+ Ok(session.take_update())
+ })
+}
+
+/// Stores deterministic exception filters and sends them after DAP initialization.
+pub(crate) fn set_exception_breakpoints(
+ mut request: SetExceptionBreakpointsRequest,
+) -> Result {
+ for breakpoint in &mut request.breakpoints {
+ breakpoint.filter = breakpoint.filter.trim().to_string();
+ if breakpoint.filter.is_empty() {
+ return Err(invalid_request(
+ "Debug exception breakpoint filters cannot be empty.",
+ ));
+ }
+ breakpoint.condition = breakpoint
+ .condition
+ .take()
+ .map(|condition| condition.trim().to_string())
+ .filter(|condition| !condition.is_empty());
+ }
+ request.breakpoints.sort_by(|left, right| {
+ (&left.filter, left.enabled, &left.condition).cmp(&(
+ &right.filter,
+ right.enabled,
+ &right.condition,
+ ))
+ });
+ request
+ .breakpoints
+ .dedup_by(|left, right| left.filter == right.filter);
+ with_session(&request.session_id, |session| {
+ session.exception_breakpoints = request.breakpoints;
+ session.did_configure_exception_breakpoints = true;
+ if session.did_receive_initialized {
+ session.send_exception_breakpoints()?;
+ }
+ Ok(session.take_update())
+ })
+}
+
+/// Stores deterministic function breakpoints and sends them when supported.
+pub(crate) fn set_function_breakpoints(
+ mut request: SetFunctionBreakpointsRequest,
+) -> Result {
+ for breakpoint in &mut request.breakpoints {
+ breakpoint.name = breakpoint.name.trim().to_string();
+ if breakpoint.name.is_empty() {
+ return Err(invalid_request(
+ "Debug function breakpoint names cannot be empty.",
+ ));
+ }
+ breakpoint.condition = normalize_optional_text(breakpoint.condition.take());
+ breakpoint.hit_condition = normalize_optional_text(breakpoint.hit_condition.take());
+ }
+ request.breakpoints.sort_by(|left, right| {
+ (
+ &left.name,
+ left.enabled,
+ &left.condition,
+ &left.hit_condition,
+ )
+ .cmp(&(
+ &right.name,
+ right.enabled,
+ &right.condition,
+ &right.hit_condition,
+ ))
+ });
+ request
+ .breakpoints
+ .dedup_by(|left, right| left.name == right.name);
+ with_session(&request.session_id, |session| {
+ session.function_breakpoints = request.breakpoints;
+ if session.did_receive_initialized && session.capabilities.supports_function_breakpoints {
+ session.send_function_breakpoints()?;
+ }
+ Ok(session.take_update())
+ })
+}
+
+/// Resolves one adapter-owned data breakpoint identity for the selected variable.
+pub(crate) fn data_breakpoint_info(
+ mut request: DataBreakpointInfoRequest,
+) -> Result {
+ validate_identifier(&request.operation_id, "operationId")?;
+ request.name = request.name.trim().to_string();
+ if request.name.is_empty() {
+ return Err(invalid_request(
+ "Debug data breakpoint info requires a variable name.",
+ ));
+ }
+ if request
+ .variables_reference
+ .is_some_and(|reference| reference < 1)
+ {
+ return Err(invalid_request(
+ "Debug variablesReference must be positive.",
+ ));
+ }
+ if request.frame_id.is_some_and(|frame_id| frame_id < 0) {
+ return Err(invalid_request("Debug frameId cannot be negative."));
+ }
+ if request.variables_reference.is_none() && request.frame_id.is_none() {
+ return Err(invalid_request(
+ "Debug data breakpoint info requires a variable reference or frame.",
+ ));
+ }
+ with_session(&request.session_id, |session| {
+ if !session.capabilities.supports_data_breakpoints {
+ return Err(invalid_request(
+ "The debug adapter does not support data breakpoints.",
+ ));
+ }
+ let mut arguments = Map::new();
+ arguments.insert("name".to_string(), json!(request.name));
+ insert_option(
+ &mut arguments,
+ "variablesReference",
+ request.variables_reference,
+ );
+ insert_option(&mut arguments, "frameId", request.frame_id);
+ session.send_request(
+ "dataBreakpointInfo",
+ Value::Object(arguments),
+ PendingRequest::DataBreakpointInfo {
+ operation_id: request.operation_id,
+ },
+ )?;
+ Ok(session.take_update())
+ })
+}
+
+/// Stores deterministic adapter-resolved data breakpoints and sends them when supported.
+pub(crate) fn set_data_breakpoints(
+ mut request: SetDataBreakpointsRequest,
+) -> Result {
+ for breakpoint in &mut request.breakpoints {
+ breakpoint.data_id = breakpoint.data_id.trim().to_string();
+ if breakpoint.data_id.is_empty() {
+ return Err(invalid_request(
+ "Debug data breakpoint identifiers cannot be empty.",
+ ));
+ }
+ breakpoint.label = normalize_optional_text(breakpoint.label.take());
+ breakpoint.access_type = normalize_optional_text(breakpoint.access_type.take());
+ breakpoint.condition = normalize_optional_text(breakpoint.condition.take());
+ breakpoint.hit_condition = normalize_optional_text(breakpoint.hit_condition.take());
+ }
+ request.breakpoints.sort_by(|left, right| {
+ (
+ &left.data_id,
+ &left.access_type,
+ left.enabled,
+ &left.condition,
+ &left.hit_condition,
+ )
+ .cmp(&(
+ &right.data_id,
+ &right.access_type,
+ right.enabled,
+ &right.condition,
+ &right.hit_condition,
+ ))
+ });
+ request.breakpoints.dedup_by(|left, right| {
+ left.data_id == right.data_id && left.access_type == right.access_type
+ });
+ with_session(&request.session_id, |session| {
+ session.data_breakpoints = request.breakpoints;
+ if session.did_receive_initialized && session.capabilities.supports_data_breakpoints {
+ session.send_data_breakpoints()?;
+ }
+ Ok(session.take_update())
+ })
+}
+
+/// Queues one capability-gated variable mutation while execution is paused.
+pub(crate) fn set_variable(
+ mut request: SetVariableRequest,
+) -> Result {
+ validate_identifier(&request.operation_id, "operationId")?;
+ if request.variables_reference < 1 {
+ return Err(invalid_request(
+ "Debug variablesReference must be positive.",
+ ));
+ }
+ request.name = request.name.trim().to_string();
+ if request.name.is_empty() {
+ return Err(invalid_request(
+ "Debug setVariable requires a variable name.",
+ ));
+ }
+ with_session(&request.session_id, |session| {
+ if session.state != DebugSessionState::Paused {
+ return Err(invalid_request(
+ "Variable mutation requires a paused debug session.",
+ ));
+ }
+ if !session.capabilities.supports_set_variable {
+ return Err(invalid_request(
+ "The debug adapter does not support variable mutation.",
+ ));
+ }
+ session.send_request(
+ "setVariable",
+ json!({
+ "variablesReference": request.variables_reference,
+ "name": request.name,
+ "value": request.value
+ }),
+ PendingRequest::SetVariable {
+ operation_id: request.operation_id,
+ name: request.name,
+ },
+ )?;
+ Ok(session.take_update())
+ })
+}
+
+/// Ends one caller-owned operation and ignores any later adapter response.
+pub(crate) fn cancel_operation(
+ request: CancelOperationRequest,
+) -> Result {
+ validate_identifier(&request.operation_id, "operationId")?;
+ with_session(&request.session_id, |session| {
+ let pending_sequence = session
+ .pending_requests
+ .iter()
+ .find_map(|(sequence, pending)| {
+ (pending.operation_id() == Some(request.operation_id.as_str())).then_some(*sequence)
+ });
+ let Some(pending_sequence) = pending_sequence else {
+ return Ok(session.take_update());
+ };
+ let pending = session
+ .pending_requests
+ .remove(&pending_sequence)
+ .expect("located pending debug operation should still exist");
+ let command = pending.command().to_string();
+ let message = match request.reason {
+ DebugCancellationReason::Cancelled => "Debug operation was cancelled.",
+ DebugCancellationReason::TimedOut => "Debug operation timed out.",
+ };
+ session.emit(DebugEventBody::OperationFailed {
+ operation_id: request.operation_id,
+ command,
+ code: match request.reason {
+ DebugCancellationReason::Cancelled => DebugOperationFailureCode::Cancelled,
+ DebugCancellationReason::TimedOut => DebugOperationFailureCode::TimedOut,
+ },
+ message: message.to_string(),
+ });
+ if matches!(pending, PendingRequest::Launch { .. }) {
+ session.transition(DebugSessionState::Failed);
+ }
+ if session.capabilities.supports_cancel_request {
+ session.send_request(
+ "cancel",
+ json!({"requestId": pending_sequence}),
+ PendingRequest::Cancel,
+ )?;
+ }
+ Ok(session.take_update())
+ })
+}
+
+/// Queues one continue, pause, or stepping request.
+pub(crate) fn execute(request: ExecuteRequest) -> Result {
+ validate_identifier(&request.operation_id, "operationId")?;
+ with_session(&request.session_id, |session| {
+ if !matches!(
+ session.state,
+ DebugSessionState::Running | DebugSessionState::Paused
+ ) {
+ return Err(invalid_request(
+ "Execution control requires a running or paused debug session.",
+ ));
+ }
+ if matches!(
+ request.command,
+ DebugExecutionCommand::Next
+ | DebugExecutionCommand::StepIn
+ | DebugExecutionCommand::StepOut
+ | DebugExecutionCommand::StepBack
+ | DebugExecutionCommand::Goto
+ ) && session.state != DebugSessionState::Paused
+ {
+ return Err(invalid_request("Stepping requires a paused debug session."));
+ }
+ if request.command == DebugExecutionCommand::Pause
+ && session.state != DebugSessionState::Running
+ {
+ return Err(invalid_request("Pause requires a running debug session."));
+ }
+ if request.command == DebugExecutionCommand::Continue
+ && session.state != DebugSessionState::Paused
+ {
+ return Err(invalid_request("Continue requires a paused debug session."));
+ }
+ if request.single_thread
+ && !session
+ .capabilities
+ .supports_single_thread_execution_requests
+ {
+ return Err(invalid_request(
+ "The debug adapter does not support single-thread execution control.",
+ ));
+ }
+ if request.command == DebugExecutionCommand::StepBack
+ && !session.capabilities.supports_step_back
+ {
+ return Err(invalid_request(
+ "The debug adapter does not support stepping backwards.",
+ ));
+ }
+ if request.command == DebugExecutionCommand::Goto
+ && !session.capabilities.supports_goto_targets_request
+ {
+ return Err(invalid_request(
+ "The debug adapter does not support run to cursor.",
+ ));
+ }
+ if request.command == DebugExecutionCommand::Restart
+ && !session.capabilities.supports_restart_request
+ {
+ return Err(invalid_request(
+ "The debug adapter does not support restart requests.",
+ ));
+ }
+ if request.command == DebugExecutionCommand::Terminate
+ && !session.capabilities.supports_terminate_request
+ {
+ return Err(invalid_request(
+ "The debug adapter does not support terminate requests.",
+ ));
+ }
+ if matches!(
+ request.command,
+ DebugExecutionCommand::Next
+ | DebugExecutionCommand::StepIn
+ | DebugExecutionCommand::StepOut
+ | DebugExecutionCommand::StepBack
+ | DebugExecutionCommand::Goto
+ ) && request.thread_id.is_none()
+ {
+ return Err(invalid_request("Stepping requires a selected thread."));
+ }
+ let mut arguments = Map::new();
+ if !matches!(
+ request.command,
+ DebugExecutionCommand::Restart | DebugExecutionCommand::Terminate
+ ) {
+ if let Some(thread_id) = request.thread_id {
+ arguments.insert("threadId".to_string(), json!(thread_id));
+ }
+ }
+ if request.command == DebugExecutionCommand::Goto {
+ insert_option(
+ &mut arguments,
+ "targetId",
+ Some(required_positive(request.target_id, "targetId")?),
+ );
+ } else if request.command == DebugExecutionCommand::StepIn {
+ if let Some(target_id) = request.target_id {
+ if target_id < 1 {
+ return Err(invalid_request("Debug targetId must be positive."));
+ }
+ arguments.insert("targetId".to_string(), json!(target_id));
+ }
+ } else if request.target_id.is_some() {
+ return Err(invalid_request(
+ "Debug targetId is only valid for stepIn or goto.",
+ ));
+ }
+ if matches!(
+ request.command,
+ DebugExecutionCommand::Continue
+ | DebugExecutionCommand::Next
+ | DebugExecutionCommand::StepIn
+ | DebugExecutionCommand::StepOut
+ | DebugExecutionCommand::StepBack
+ | DebugExecutionCommand::Goto
+ | DebugExecutionCommand::Pause
+ ) {
+ arguments.insert(
+ "singleThread".to_string(),
+ Value::Bool(request.single_thread),
+ );
+ }
+ session.send_request(
+ request.command.command(),
+ Value::Object(arguments),
+ PendingRequest::Execute {
+ operation_id: request.operation_id,
+ command: request.command,
+ single_thread: request.single_thread,
+ },
+ )?;
+ Ok(session.take_update())
+ })
+}
+
+/// Queues one typed thread, stack, scope, variable, or evaluation request.
+pub(crate) fn inspect(request: InspectRequest) -> Result {
+ validate_identifier(&request.operation_id, "operationId")?;
+ let arguments = inspect_arguments(&request)?;
+ with_session(&request.session_id, |session| {
+ if !matches!(
+ session.state,
+ DebugSessionState::Running | DebugSessionState::Paused
+ ) {
+ return Err(invalid_request(
+ "Debugger inspection requires a running or paused session.",
+ ));
+ }
+ if request.kind == DebugInspectKind::StepInTargets
+ && !session.capabilities.supports_step_in_targets_request
+ {
+ return Err(invalid_request(
+ "The debug adapter does not support smart step into.",
+ ));
+ }
+ if request.kind == DebugInspectKind::GotoTargets
+ && !session.capabilities.supports_goto_targets_request
+ {
+ return Err(invalid_request(
+ "The debug adapter does not support run to cursor.",
+ ));
+ }
+ if request.kind == DebugInspectKind::ExceptionInfo {
+ if session.state != DebugSessionState::Paused {
+ return Err(invalid_request(
+ "Exception information requires a paused debug session.",
+ ));
+ }
+ if !session.capabilities.supports_exception_info_request {
+ return Err(invalid_request(
+ "The debug adapter does not support exception information.",
+ ));
+ }
+ }
+ session.send_request(
+ request.kind.command(),
+ Value::Object(arguments),
+ PendingRequest::Inspect {
+ operation_id: request.operation_id,
+ kind: request.kind,
+ },
+ )?;
+ Ok(session.take_update())
+ })
+}
+
+/// Reduces one transport byte chunk and returns ordered writes and events.
+pub(crate) fn receive(request: ReceiveRequest) -> Result {
+ let bytes = BASE64.decode(request.data_base64).map_err(|error| {
+ invalid_request("Debug transport dataBase64 was invalid.").with_details(error.to_string())
+ })?;
+ with_session(&request.session_id, |session| {
+ let messages = match parse_messages(&mut session.read_buffer, &bytes) {
+ Ok(messages) => messages,
+ Err(error) => {
+ session.transition(DebugSessionState::Failed);
+ return Err(error);
+ }
+ };
+ for message in messages {
+ session.handle_message(message)?;
+ }
+ Ok(session.take_update())
+ })
+}
+
+/// Completes one adapter-originated terminal launch and emits its DAP response.
+pub(crate) fn run_in_terminal_response(
+ request: DebugRunInTerminalResponseRequest,
+) -> Result {
+ validate_identifier(&request.request_id, "requestId")?;
+ with_session(&request.session_id, |session| {
+ let Some(&request_sequence) = session
+ .pending_run_in_terminal_requests
+ .get(&request.request_id)
+ else {
+ // Native terminal creation is asynchronous. A response that races
+ // session shutdown, timeout, or a previous completion must not be
+ // applied to a later adapter request.
+ return Ok(session.take_update());
+ };
+
+ if request.success {
+ validate_dap_process_id(request.process_id, "processId")?;
+ validate_dap_process_id(request.shell_process_id, "shellProcessId")?;
+ }
+ session
+ .pending_run_in_terminal_requests
+ .remove(&request.request_id);
+
+ if request.success {
+ let mut body = Map::new();
+ insert_option(&mut body, "processId", request.process_id);
+ insert_option(&mut body, "shellProcessId", request.shell_process_id);
+ session.send_response_with_body(
+ request_sequence,
+ "runInTerminal",
+ true,
+ None,
+ Some(Value::Object(body)),
+ )?;
+ } else {
+ let message = normalize_optional_text(request.message)
+ .unwrap_or_else(|| "Lithe could not start the debuggee terminal.".to_string());
+ session.send_response_with_body(
+ request_sequence,
+ "runInTerminal",
+ false,
+ Some(&message),
+ None,
+ )?;
+ }
+ Ok(session.take_update())
+ })
+}
+
+/// Begins a graceful DAP disconnect while the host keeps transport ownership.
+pub(crate) fn disconnect(request: SessionRequest) -> Result {
+ with_session(&request.session_id, |session| {
+ if matches!(
+ session.state,
+ DebugSessionState::Terminating | DebugSessionState::Terminated
+ ) {
+ return Ok(session.take_update());
+ }
+ session.fail_pending_run_in_terminal_requests(
+ "The debug session stopped before the terminal could start.",
+ )?;
+ // An attach session does not own the remote JVM, so disconnect must
+ // never terminate it. A launch session owns the local debuggee.
+ let terminate_debuggee = session.debug_request_kind == Some(DebugRequestKind::Launch);
+ session.send_request(
+ "disconnect",
+ json!({"restart": false, "terminateDebuggee": terminate_debuggee}),
+ PendingRequest::Disconnect,
+ )?;
+ session.transition(DebugSessionState::Terminating);
+ Ok(session.take_update())
+ })
+}
+
+/// Removes a session after the platform has closed its socket or process.
+pub(crate) fn destroy_session(request: SessionRequest) -> Result<(), CoreError> {
+ let mut sessions = sessions_lock()?;
+ if sessions.remove(&request.session_id).is_none() {
+ return Err(session_not_found(&request.session_id));
+ }
+ Ok(())
+}
+
+impl DebugSession {
+ fn perform_launch(
+ &mut self,
+ operation_id: String,
+ configuration: DebugLaunchConfiguration,
+ ) -> Result<(), CoreError> {
+ let request_kind = configuration.request;
+ let mut arguments = configuration.arguments;
+ let filters = normalize_stepping_filters(
+ configuration
+ .stepping_filters
+ .unwrap_or_else(|| DebugSteppingFilters::defaults_for_adapter(&self.adapter_id)),
+ )?;
+ if self.adapter_id == "java" && !arguments.contains_key("stepFilters") {
+ arguments.insert("stepFilters".to_string(), java_step_filters(&filters));
+ }
+ self.stepping_filters = filters;
+ arguments
+ .entry("name".to_string())
+ .or_insert(Value::String(configuration.name));
+ arguments
+ .entry("cwd".to_string())
+ .or_insert(Value::String(self.root_path.clone()));
+ self.debug_request_kind = Some(request_kind);
+ self.transition(DebugSessionState::Launching);
+ self.send_request(
+ request_kind.command(),
+ Value::Object(arguments),
+ PendingRequest::Launch { operation_id },
+ )
+ }
+
+ fn send_breakpoints(&mut self, source_path: &str) -> Result<(), CoreError> {
+ let requested = self
+ .breakpoints
+ .get(source_path)
+ .cloned()
+ .unwrap_or_default();
+ let active: Vec = requested
+ .iter()
+ .filter(|breakpoint| breakpoint.enabled)
+ .cloned()
+ .collect();
+ let breakpoints: Vec = active
+ .iter()
+ .map(|breakpoint| {
+ let mut value = Map::new();
+ value.insert("line".to_string(), json!(breakpoint.line));
+ insert_option(&mut value, "column", breakpoint.column);
+ insert_nonempty(&mut value, "condition", breakpoint.condition.as_deref());
+ insert_nonempty(
+ &mut value,
+ "hitCondition",
+ breakpoint.hit_condition.as_deref(),
+ );
+ insert_nonempty(&mut value, "logMessage", breakpoint.log_message.as_deref());
+ Value::Object(value)
+ })
+ .collect();
+ let source_name = source_path
+ .rsplit(['/', '\\'])
+ .next()
+ .unwrap_or(source_path);
+ self.send_request(
+ "setBreakpoints",
+ json!({
+ "source": {"name": source_name, "path": source_path},
+ "breakpoints": breakpoints,
+ "sourceModified": false
+ }),
+ PendingRequest::SetBreakpoints {
+ source_path: source_path.to_string(),
+ requested: active,
+ },
+ )
+ }
+
+ fn send_exception_breakpoints(&mut self) -> Result<(), CoreError> {
+ let active: Vec<&ExceptionBreakpoint> = self
+ .exception_breakpoints
+ .iter()
+ .filter(|breakpoint| breakpoint.enabled)
+ .collect();
+ let filters: Vec<&str> = active
+ .iter()
+ .map(|breakpoint| breakpoint.filter.as_str())
+ .collect();
+ let filter_options: Vec = if self.capabilities.supports_exception_filter_options {
+ active
+ .iter()
+ .filter_map(|breakpoint| {
+ breakpoint.condition.as_ref().map(|condition| {
+ json!({
+ "filterId": breakpoint.filter,
+ "condition": condition
+ })
+ })
+ })
+ .collect()
+ } else {
+ Vec::new()
+ };
+ let mut arguments = Map::new();
+ arguments.insert("filters".to_string(), json!(filters));
+ if !filter_options.is_empty() {
+ arguments.insert("filterOptions".to_string(), Value::Array(filter_options));
+ }
+ self.send_request(
+ "setExceptionBreakpoints",
+ Value::Object(arguments),
+ PendingRequest::SetExceptionBreakpoints,
+ )
+ }
+
+ fn send_function_breakpoints(&mut self) -> Result<(), CoreError> {
+ let requested: Vec = self
+ .function_breakpoints
+ .iter()
+ .filter(|breakpoint| breakpoint.enabled)
+ .cloned()
+ .collect();
+ let breakpoints: Vec = requested
+ .iter()
+ .map(|breakpoint| {
+ let mut value = Map::new();
+ value.insert("name".to_string(), json!(breakpoint.name));
+ insert_nonempty(&mut value, "condition", breakpoint.condition.as_deref());
+ insert_nonempty(
+ &mut value,
+ "hitCondition",
+ breakpoint.hit_condition.as_deref(),
+ );
+ Value::Object(value)
+ })
+ .collect();
+ self.send_request(
+ "setFunctionBreakpoints",
+ json!({"breakpoints": breakpoints}),
+ PendingRequest::SetFunctionBreakpoints { requested },
+ )
+ }
+
+ fn send_data_breakpoints(&mut self) -> Result<(), CoreError> {
+ let requested: Vec = self
+ .data_breakpoints
+ .iter()
+ .filter(|breakpoint| breakpoint.enabled)
+ .cloned()
+ .collect();
+ let breakpoints: Vec = requested
+ .iter()
+ .map(|breakpoint| {
+ let mut value = Map::new();
+ value.insert("dataId".to_string(), json!(breakpoint.data_id));
+ insert_nonempty(&mut value, "accessType", breakpoint.access_type.as_deref());
+ insert_nonempty(&mut value, "condition", breakpoint.condition.as_deref());
+ insert_nonempty(
+ &mut value,
+ "hitCondition",
+ breakpoint.hit_condition.as_deref(),
+ );
+ Value::Object(value)
+ })
+ .collect();
+ self.send_request(
+ "setDataBreakpoints",
+ json!({"breakpoints": breakpoints}),
+ PendingRequest::SetDataBreakpoints { requested },
+ )
+ }
+
+ fn send_request(
+ &mut self,
+ command: &str,
+ arguments: Value,
+ pending: PendingRequest,
+ ) -> Result<(), CoreError> {
+ let sequence = self.next_request_sequence;
+ self.next_request_sequence += 1;
+ let message = json!({
+ "seq": sequence,
+ "type": "request",
+ "command": command,
+ "arguments": arguments
+ });
+ self.outbound_frames.push(frame_message(&message)?);
+ self.pending_requests.insert(sequence, pending);
+ Ok(())
+ }
+
+ fn send_response(
+ &mut self,
+ request_sequence: i64,
+ command: &str,
+ success: bool,
+ message: Option<&str>,
+ ) -> Result<(), CoreError> {
+ self.send_response_with_body(request_sequence, command, success, message, None)
+ }
+
+ fn send_response_with_body(
+ &mut self,
+ request_sequence: i64,
+ command: &str,
+ success: bool,
+ message: Option<&str>,
+ body: Option,
+ ) -> Result<(), CoreError> {
+ let sequence = self.next_request_sequence;
+ self.next_request_sequence += 1;
+ let mut response = json!({
+ "seq": sequence,
+ "type": "response",
+ "request_seq": request_sequence,
+ "success": success,
+ "command": command
+ });
+ if let Some(message) = message {
+ response["message"] = Value::String(message.to_string());
+ }
+ if let Some(body) = body {
+ response["body"] = body;
+ }
+ self.outbound_frames.push(frame_message(&response)?);
+ Ok(())
+ }
+
+ fn handle_message(&mut self, message: Value) -> Result<(), CoreError> {
+ match message.get("type").and_then(Value::as_str) {
+ Some("response") => self.handle_response(&message),
+ Some("event") => self.handle_event(&message),
+ Some("request") => self.handle_server_request(&message),
+ _ => Err(CoreError::new(
+ ErrorCode::ParseFailed,
+ "DAP message did not contain a supported type.",
+ )),
+ }
+ }
+
+ fn handle_response(&mut self, message: &Value) -> Result<(), CoreError> {
+ let request_sequence = required_i64(message, "request_seq")?;
+ let Some(pending) = self.pending_requests.remove(&request_sequence) else {
+ return Ok(());
+ };
+ let success = message
+ .get("success")
+ .and_then(Value::as_bool)
+ .unwrap_or(false);
+ if !success {
+ let command = pending.command().to_string();
+ let detail = message
+ .get("message")
+ .and_then(Value::as_str)
+ .unwrap_or("The debug adapter rejected the request.")
+ .to_string();
+ if let Some(operation_id) = pending.operation_id() {
+ self.emit(DebugEventBody::OperationFailed {
+ operation_id: operation_id.to_string(),
+ command,
+ code: DebugOperationFailureCode::AdapterRejected,
+ message: detail,
+ });
+ }
+ if matches!(
+ pending,
+ PendingRequest::Initialize | PendingRequest::Launch { .. }
+ ) {
+ self.transition(DebugSessionState::Failed);
+ }
+ return Ok(());
+ }
+ let body = message.get("body").cloned().unwrap_or_else(|| json!({}));
+ match pending {
+ PendingRequest::Initialize => {
+ self.capabilities = parse_capabilities(&body);
+ if !self.did_configure_exception_breakpoints {
+ self.exception_breakpoints = self
+ .capabilities
+ .exception_breakpoint_filters
+ .iter()
+ .map(|filter| ExceptionBreakpoint {
+ filter: filter.filter.clone(),
+ enabled: filter.default,
+ condition: None,
+ })
+ .collect();
+ }
+ self.supports_configuration_done = self.capabilities.supports_configuration_done;
+ self.emit(DebugEventBody::Capabilities {
+ capabilities: self.capabilities.clone(),
+ });
+ self.transition(DebugSessionState::Ready);
+ if let Some((operation_id, configuration)) = self.pending_launch.take() {
+ self.perform_launch(operation_id, configuration)?;
+ }
+ }
+ PendingRequest::Launch { operation_id } => {
+ self.transition(DebugSessionState::Running);
+ self.emit(DebugEventBody::OperationCompleted {
+ operation_id,
+ result: DebugOperationResult::Acknowledged {
+ command: "launch".to_string(),
+ },
+ });
+ }
+ PendingRequest::SetBreakpoints {
+ source_path,
+ requested,
+ } => self.emit_breakpoint_results(&body, &source_path, &requested),
+ PendingRequest::SetExceptionBreakpoints => {}
+ PendingRequest::SetFunctionBreakpoints { requested } => {
+ self.emit_function_breakpoint_results(&body, &requested)
+ }
+ PendingRequest::DataBreakpointInfo { operation_id } => {
+ self.emit(DebugEventBody::OperationCompleted {
+ operation_id,
+ result: DebugOperationResult::DataBreakpointInfo {
+ data_id: string_field(&body, "dataId"),
+ description: body
+ .get("description")
+ .and_then(Value::as_str)
+ .unwrap_or_default()
+ .to_string(),
+ access_types: body
+ .get("accessTypes")
+ .and_then(Value::as_array)
+ .into_iter()
+ .flatten()
+ .filter_map(Value::as_str)
+ .map(str::to_string)
+ .collect(),
+ can_persist: bool_field(&body, "canPersist"),
+ },
+ });
+ }
+ PendingRequest::SetDataBreakpoints { requested } => {
+ self.emit_data_breakpoint_results(&body, &requested)
+ }
+ PendingRequest::SetVariable { operation_id, name } => {
+ self.emit(DebugEventBody::OperationCompleted {
+ operation_id,
+ result: DebugOperationResult::SetVariable {
+ variable: DebugVariable {
+ name,
+ value: required_str(&body, "value")?.to_string(),
+ r#type: string_field(&body, "type"),
+ evaluate_name: None,
+ variables_reference: body
+ .get("variablesReference")
+ .and_then(Value::as_i64)
+ .unwrap_or(0),
+ named_variables: nonnegative_count_field(&body, "namedVariables"),
+ indexed_variables: nonnegative_count_field(&body, "indexedVariables"),
+ },
+ },
+ });
+ }
+ PendingRequest::Cancel => {}
+ PendingRequest::ConfigurationDone => {}
+ PendingRequest::Execute {
+ operation_id,
+ command,
+ single_thread,
+ } => {
+ if command != DebugExecutionCommand::Pause
+ && command != DebugExecutionCommand::Terminate
+ && !(single_thread && command == DebugExecutionCommand::Continue)
+ {
+ self.transition(DebugSessionState::Running);
+ }
+ self.emit(DebugEventBody::OperationCompleted {
+ operation_id,
+ result: DebugOperationResult::Acknowledged {
+ command: command.command().to_string(),
+ },
+ });
+ }
+ PendingRequest::Inspect { operation_id, kind } => {
+ let result =
+ normalize_inspection(kind, &body, &self.stepping_filters, &self.root_path)?;
+ self.emit(DebugEventBody::OperationCompleted {
+ operation_id,
+ result,
+ });
+ }
+ PendingRequest::Disconnect => {}
+ }
+ Ok(())
+ }
+
+ fn handle_event(&mut self, message: &Value) -> Result<(), CoreError> {
+ let event = required_str(message, "event")?;
+ let body = message.get("body").cloned().unwrap_or_else(|| json!({}));
+ match event {
+ "initialized" => {
+ self.did_receive_initialized = true;
+ self.emit(DebugEventBody::Initialized);
+ self.send_exception_breakpoints()?;
+ if self.capabilities.supports_function_breakpoints {
+ self.send_function_breakpoints()?;
+ }
+ if self.capabilities.supports_data_breakpoints {
+ self.send_data_breakpoints()?;
+ }
+ let sources: Vec = self.breakpoints.keys().cloned().collect();
+ for source in sources {
+ self.send_breakpoints(&source)?;
+ }
+ if self.supports_configuration_done {
+ self.send_request(
+ "configurationDone",
+ json!({}),
+ PendingRequest::ConfigurationDone,
+ )?;
+ }
+ }
+ "output" => self.emit(DebugEventBody::Output {
+ category: body
+ .get("category")
+ .and_then(Value::as_str)
+ .map(str::to_string),
+ output: body
+ .get("output")
+ .and_then(Value::as_str)
+ .unwrap_or_default()
+ .to_string(),
+ }),
+ "stopped" => {
+ self.transition(DebugSessionState::Paused);
+ self.emit(DebugEventBody::Stopped {
+ reason: body
+ .get("reason")
+ .and_then(Value::as_str)
+ .unwrap_or("pause")
+ .to_string(),
+ thread_id: body.get("threadId").and_then(Value::as_i64),
+ description: body
+ .get("description")
+ .and_then(Value::as_str)
+ .map(str::to_string),
+ });
+ }
+ "continued" => {
+ if body
+ .get("allThreadsContinued")
+ .and_then(Value::as_bool)
+ .unwrap_or(true)
+ {
+ self.transition(DebugSessionState::Running);
+ }
+ self.emit(DebugEventBody::Continued {
+ thread_id: body.get("threadId").and_then(Value::as_i64),
+ });
+ }
+ "terminated" => {
+ self.transition(DebugSessionState::Terminated);
+ self.emit(DebugEventBody::Terminated { exit_code: None });
+ }
+ "exited" => {
+ self.transition(DebugSessionState::Terminated);
+ self.emit(DebugEventBody::Terminated {
+ exit_code: body.get("exitCode").and_then(Value::as_i64),
+ });
+ }
+ "breakpoint" => {
+ if let Some(value) = body.get("breakpoint") {
+ self.emit(DebugEventBody::Breakpoint {
+ breakpoint: parse_breakpoint(value, None, None, 0),
+ });
+ }
+ }
+ _ => {}
+ }
+ Ok(())
+ }
+
+ fn handle_server_request(&mut self, message: &Value) -> Result<(), CoreError> {
+ let request_sequence = required_i64(message, "seq")?;
+ let command = required_str(message, "command")?;
+ if command == "runInTerminal" && self.supports_run_in_terminal_request {
+ let arguments = message.get("arguments").unwrap_or(&Value::Null);
+ match parse_run_in_terminal_request(arguments) {
+ Ok(request) => {
+ let request_id = format!("runInTerminal-{request_sequence}");
+ if self
+ .pending_run_in_terminal_requests
+ .contains_key(&request_id)
+ {
+ // One DAP sequence can have only one response. Keep the
+ // original native launch pending instead of replacing
+ // it or starting a second process for a malformed retry.
+ return Ok(());
+ }
+ self.pending_run_in_terminal_requests
+ .insert(request_id.clone(), request_sequence);
+ self.emit(DebugEventBody::RunInTerminalRequested {
+ request_id,
+ request,
+ });
+ return Ok(());
+ }
+ Err(error) => {
+ return self.send_response(
+ request_sequence,
+ command,
+ false,
+ Some(&error.message),
+ );
+ }
+ }
+ }
+ self.send_response(
+ request_sequence,
+ command,
+ false,
+ Some("This debug adapter request is not supported by Lithe."),
+ )
+ }
+
+ fn fail_pending_run_in_terminal_requests(&mut self, message: &str) -> Result<(), CoreError> {
+ let pending = std::mem::take(&mut self.pending_run_in_terminal_requests);
+ for (_, request_sequence) in pending {
+ self.send_response(request_sequence, "runInTerminal", false, Some(message))?;
+ }
+ Ok(())
+ }
+
+ fn emit_breakpoint_results(
+ &mut self,
+ body: &Value,
+ source_path: &str,
+ requested: &[SourceBreakpoint],
+ ) {
+ let values = body
+ .get("breakpoints")
+ .and_then(Value::as_array)
+ .cloned()
+ .unwrap_or_default();
+ for (index, value) in values.iter().enumerate() {
+ let fallback = requested.get(index).map(|breakpoint| breakpoint.line);
+ self.emit(DebugEventBody::Breakpoint {
+ breakpoint: parse_breakpoint(value, None, Some(source_path), fallback.unwrap_or(0)),
+ });
+ }
+ }
+
+ fn emit_function_breakpoint_results(&mut self, body: &Value, requested: &[FunctionBreakpoint]) {
+ let values = body
+ .get("breakpoints")
+ .and_then(Value::as_array)
+ .cloned()
+ .unwrap_or_default();
+ for (index, value) in values.iter().enumerate() {
+ let function_name = requested
+ .get(index)
+ .map(|breakpoint| breakpoint.name.as_str());
+ self.emit(DebugEventBody::Breakpoint {
+ breakpoint: parse_breakpoint(value, function_name, None, 0),
+ });
+ }
+ }
+
+ fn emit_data_breakpoint_results(&mut self, body: &Value, requested: &[DataBreakpoint]) {
+ let values = body
+ .get("breakpoints")
+ .and_then(Value::as_array)
+ .cloned()
+ .unwrap_or_default();
+ for (index, value) in values.iter().enumerate() {
+ let mut breakpoint = parse_breakpoint(value, None, None, 0);
+ breakpoint.data_id = requested.get(index).map(|item| item.data_id.clone());
+ self.emit(DebugEventBody::Breakpoint { breakpoint });
+ }
+ }
+
+ fn transition(&mut self, state: DebugSessionState) {
+ if self.state == state {
+ return;
+ }
+ self.state = state;
+ self.emit(DebugEventBody::StateChanged { state });
+ }
+
+ fn emit(&mut self, body: DebugEventBody) {
+ let sequence = self.next_event_sequence;
+ self.next_event_sequence += 1;
+ self.events.push(DebugEvent { sequence, body });
+ }
+
+ fn take_update(&mut self) -> DebugSessionUpdate {
+ DebugSessionUpdate {
+ session_id: self.id.clone(),
+ state: self.state,
+ outbound_frames: std::mem::take(&mut self.outbound_frames)
+ .into_iter()
+ .map(|frame| BASE64.encode(frame))
+ .collect(),
+ events: std::mem::take(&mut self.events),
+ }
+ }
+}
+
+impl PendingRequest {
+ fn command(&self) -> &'static str {
+ match self {
+ Self::Initialize => "initialize",
+ Self::Launch { .. } => "launch",
+ Self::SetBreakpoints { .. } => "setBreakpoints",
+ Self::SetExceptionBreakpoints => "setExceptionBreakpoints",
+ Self::SetFunctionBreakpoints { .. } => "setFunctionBreakpoints",
+ Self::DataBreakpointInfo { .. } => "dataBreakpointInfo",
+ Self::SetDataBreakpoints { .. } => "setDataBreakpoints",
+ Self::SetVariable { .. } => "setVariable",
+ Self::Cancel => "cancel",
+ Self::ConfigurationDone => "configurationDone",
+ Self::Execute { command, .. } => command.command(),
+ Self::Inspect { kind, .. } => kind.command(),
+ Self::Disconnect => "disconnect",
+ }
+ }
+
+ fn operation_id(&self) -> Option<&str> {
+ match self {
+ Self::Launch { operation_id }
+ | Self::Execute { operation_id, .. }
+ | Self::Inspect { operation_id, .. }
+ | Self::DataBreakpointInfo { operation_id }
+ | Self::SetVariable { operation_id, .. } => Some(operation_id),
+ _ => None,
+ }
+ }
+}
+
+fn inspect_arguments(request: &InspectRequest) -> Result