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, CoreError> { + if request.kind != DebugInspectKind::Variables + && (request.variable_filter.is_some() || request.start.is_some() || request.count.is_some()) + { + return Err(invalid_request( + "Debug variable paging is only valid for variables inspection.", + )); + } + let mut arguments = Map::new(); + match request.kind { + DebugInspectKind::Threads => {} + DebugInspectKind::StackTrace => { + arguments.insert( + "threadId".to_string(), + json!(required_positive(request.thread_id, "threadId")?), + ); + } + DebugInspectKind::Scopes => { + arguments.insert( + "frameId".to_string(), + json!(required_nonnegative(request.frame_id, "frameId")?), + ); + } + DebugInspectKind::Variables => { + arguments.insert( + "variablesReference".to_string(), + json!(required_positive( + request.variables_reference, + "variablesReference" + )?), + ); + if let Some(filter) = request.variable_filter { + arguments.insert("filter".to_string(), json!(filter.argument())); + } + if let Some(start) = request.start { + arguments.insert( + "start".to_string(), + json!(required_nonnegative(Some(start), "start")?), + ); + } + if let Some(count) = request.count { + arguments.insert( + "count".to_string(), + json!(required_positive(Some(count), "count")?), + ); + } + } + DebugInspectKind::Evaluate => { + let expression = request.expression.as_deref().unwrap_or_default().trim(); + if expression.is_empty() { + return Err(invalid_request("Debug evaluate requires an expression.")); + } + arguments.insert("expression".to_string(), json!(expression)); + arguments.insert("context".to_string(), json!("watch")); + if let Some(frame_id) = request.frame_id { + if frame_id < 0 { + return Err(invalid_request("Debug frameId cannot be negative.")); + } + arguments.insert("frameId".to_string(), json!(frame_id)); + } + } + DebugInspectKind::ExceptionInfo => { + arguments.insert( + "threadId".to_string(), + json!(required_positive(request.thread_id, "threadId")?), + ); + } + DebugInspectKind::StepInTargets => { + arguments.insert( + "frameId".to_string(), + json!(required_nonnegative(request.frame_id, "frameId")?), + ); + } + DebugInspectKind::GotoTargets => { + let source_path = request.source_path.as_deref().unwrap_or_default().trim(); + if source_path.is_empty() { + return Err(invalid_request("Debug gotoTargets requires a source path.")); + } + arguments.insert("source".to_string(), json!({"path": source_path})); + arguments.insert( + "line".to_string(), + json!(required_positive(request.line, "line")?), + ); + if let Some(column) = request.column { + if column < 1 { + return Err(invalid_request("Debug column must be positive.")); + } + arguments.insert("column".to_string(), json!(column)); + } + } + } + Ok(arguments) +} + +fn normalize_inspection( + kind: DebugInspectKind, + body: &Value, + stepping_filters: &DebugSteppingFilters, + root_path: &str, +) -> Result { + match kind { + DebugInspectKind::Threads => Ok(DebugOperationResult::Threads { + threads: required_array(body, "threads")? + .iter() + .filter_map(parse_thread) + .collect(), + }), + DebugInspectKind::StackTrace => Ok(DebugOperationResult::StackTrace { + stack_frames: required_array(body, "stackFrames")? + .iter() + .filter_map(|value| parse_stack_frame(value, stepping_filters, root_path)) + .collect(), + }), + DebugInspectKind::Scopes => Ok(DebugOperationResult::Scopes { + scopes: required_array(body, "scopes")? + .iter() + .filter_map(parse_scope) + .collect(), + }), + DebugInspectKind::Variables => Ok(DebugOperationResult::Variables { + variables: required_array(body, "variables")? + .iter() + .filter_map(parse_variable) + .collect(), + }), + DebugInspectKind::Evaluate => Ok(DebugOperationResult::Evaluate { + variable: DebugVariable { + name: body + .get("evaluateName") + .and_then(Value::as_str) + .unwrap_or("Expression") + .to_string(), + value: required_str(body, "result")?.to_string(), + r#type: string_field(body, "type"), + evaluate_name: string_field(body, "evaluateName"), + 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"), + }, + }), + DebugInspectKind::ExceptionInfo => Ok(DebugOperationResult::ExceptionInfo { + exception_info: DebugExceptionInfo { + exception_id: required_str(body, "exceptionId")?.to_string(), + description: string_field(body, "description"), + break_mode: required_str(body, "breakMode")?.to_string(), + details: body.get("details").and_then(parse_exception_details), + }, + }), + DebugInspectKind::StepInTargets => Ok(DebugOperationResult::StepInTargets { + targets: required_array(body, "targets")? + .iter() + .filter_map(parse_step_in_target) + .collect(), + }), + DebugInspectKind::GotoTargets => Ok(DebugOperationResult::GotoTargets { + targets: required_array(body, "targets")? + .iter() + .filter_map(parse_goto_target) + .collect(), + }), + } +} + +fn parse_exception_details(value: &Value) -> Option { + value.as_object()?; + Some(DebugExceptionDetails { + message: string_field(value, "message"), + type_name: string_field(value, "typeName"), + full_type_name: string_field(value, "fullTypeName"), + evaluate_name: string_field(value, "evaluateName"), + stack_trace: string_field(value, "stackTrace"), + inner_exceptions: value + .get("innerException") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(parse_exception_details) + .collect(), + }) +} + +fn parse_step_in_target(value: &Value) -> Option { + Some(DebugStepInTarget { + id: value.get("id")?.as_i64()?, + label: value.get("label")?.as_str()?.to_string(), + line: value.get("line").and_then(Value::as_i64), + column: value.get("column").and_then(Value::as_i64), + end_line: value.get("endLine").and_then(Value::as_i64), + end_column: value.get("endColumn").and_then(Value::as_i64), + }) +} + +fn parse_goto_target(value: &Value) -> Option { + Some(DebugGotoTarget { + id: value.get("id")?.as_i64()?, + label: value.get("label")?.as_str()?.to_string(), + line: value.get("line")?.as_i64()?, + column: value.get("column").and_then(Value::as_i64), + end_line: value.get("endLine").and_then(Value::as_i64), + end_column: value.get("endColumn").and_then(Value::as_i64), + instruction_pointer_reference: string_field(value, "instructionPointerReference"), + }) +} + +fn parse_thread(value: &Value) -> Option { + Some(DebugThread { + id: value.get("id")?.as_i64()?, + name: value.get("name")?.as_str()?.to_string(), + }) +} + +fn parse_stack_frame( + value: &Value, + stepping_filters: &DebugSteppingFilters, + root_path: &str, +) -> Option { + let name = value.get("name")?.as_str()?.to_string(); + let source_path = value + .get("source") + .and_then(|source| source.get("path")) + .and_then(Value::as_str) + .map(str::to_string); + let presentation_hint = value.get("presentationHint").and_then(Value::as_str); + Some(DebugStackFrame { + id: value.get("id")?.as_i64()?, + is_filtered: stack_frame_matches_filters( + &name, + source_path.as_deref(), + presentation_hint, + stepping_filters, + root_path, + ), + name, + source_path, + line: value.get("line").and_then(Value::as_i64).unwrap_or(1), + column: value.get("column").and_then(Value::as_i64).unwrap_or(1), + }) +} + +fn normalize_stepping_filters( + mut filters: DebugSteppingFilters, +) -> Result { + const MAXIMUM_FILTER_COUNT: usize = 256; + const MAXIMUM_FILTER_LENGTH: usize = 256; + + let mut normalized = Vec::with_capacity(filters.class_name_filters.len()); + for filter in filters.class_name_filters { + let filter = filter.trim(); + if filter.is_empty() { + continue; + } + if filter.chars().count() > MAXIMUM_FILTER_LENGTH || filter.chars().any(char::is_control) { + return Err(invalid_request( + "Debug stepping filters must be short single-line class patterns.", + )); + } + normalized.push(filter.to_string()); + } + normalized.sort(); + normalized.dedup(); + if normalized.len() > MAXIMUM_FILTER_COUNT { + return Err(invalid_request( + "Debug stepping filters cannot contain more than 256 class patterns.", + )); + } + filters.class_name_filters = normalized; + Ok(filters) +} + +fn java_step_filters(filters: &DebugSteppingFilters) -> Value { + json!({ + "skipClasses": filters.class_name_filters, + "skipSynthetics": filters.skip_synthetics, + "skipStaticInitializers": filters.skip_static_initializers, + "skipConstructors": filters.skip_constructors + }) +} + +fn stack_frame_matches_filters( + name: &str, + source_path: Option<&str>, + presentation_hint: Option<&str>, + filters: &DebugSteppingFilters, + root_path: &str, +) -> bool { + filters + .class_name_filters + .iter() + .any(|filter| match filter.as_str() { + "$JDK" => is_jdk_frame(name, source_path, presentation_hint), + "$Libraries" => is_library_frame(source_path, presentation_hint, root_path), + pattern => class_pattern_matches_frame(pattern, name, source_path), + }) +} + +fn class_pattern_matches_frame(pattern: &str, name: &str, source_path: Option<&str>) -> bool { + if wildcard_match(pattern, name) || name == pattern || name.starts_with(&format!("{pattern}.")) + { + return true; + } + + let simple_type = name.split('.').next().unwrap_or(name); + if source_path.is_none() + && !pattern.contains('*') + && pattern + .rsplit('.') + .next() + .is_some_and(|value| value == simple_type) + { + return true; + } + + source_path.is_some_and(|source_path| source_path_matches_pattern(source_path, pattern)) +} + +fn source_path_matches_pattern(source_path: &str, pattern: &str) -> bool { + let source_path = source_path.replace('\\', "/"); + let slash_pattern = format!("*{}*", pattern.replace('.', "/")); + let dotted_pattern = format!("*{pattern}*"); + wildcard_match(&slash_pattern, &source_path) || wildcard_match(&dotted_pattern, &source_path) +} + +fn is_jdk_frame(name: &str, source_path: Option<&str>, presentation_hint: Option<&str>) -> bool { + if ["com.sun.", "java.", "javax.", "jdk.", "org.omg.", "sun."] + .iter() + .any(|prefix| name.starts_with(prefix)) + { + return true; + } + if presentation_hint == Some("subtle") && source_path.is_none() { + return true; + } + let Some(source_path) = source_path else { + return false; + }; + let source_path = source_path.replace('\\', "/"); + if source_path.contains("/java.base/") + || source_path.contains("/java.desktop/") + || source_path.contains("/java.logging/") + || source_path.contains("/java.management/") + || source_path.contains("/java.naming/") + || source_path.contains("/java.net.http/") + || source_path.contains("/java.sql/") + || source_path.contains("/java.xml/") + || source_path.contains("/jdk.") + { + return true; + } + [ + "java/applet", + "java/awt", + "java/beans", + "java/io", + "java/lang", + "java/math", + "java/net", + "java/nio", + "java/rmi", + "java/security", + "java/sql", + "java/text", + "java/time", + "java/util", + "javax", + "jdk", + "sun", + "com/sun", + "org/omg", + ] + .iter() + .any(|prefix| source_has_path_prefix(&source_path, prefix)) +} + +fn source_has_path_prefix(source_path: &str, prefix: &str) -> bool { + source_path.contains(&format!("/{prefix}/")) || source_path.contains(&format!("/{prefix}.")) +} + +fn is_library_frame( + source_path: Option<&str>, + presentation_hint: Option<&str>, + root_path: &str, +) -> bool { + if presentation_hint == Some("subtle") || source_path.is_none() { + return true; + } + let source_path = source_path.unwrap_or_default(); + !source_path_is_within_root(source_path, root_path) && path_is_absolute_or_uri(source_path) +} + +fn source_path_is_within_root(source_path: &str, root_path: &str) -> bool { + let source_path = normalized_comparison_path(source_path); + let root_path = normalized_comparison_path(root_path); + source_path == root_path + || source_path + .strip_prefix(&root_path) + .is_some_and(|suffix| suffix.starts_with('/')) +} + +fn normalized_comparison_path(path: &str) -> String { + path.strip_prefix("file://") + .unwrap_or(path) + .replace('\\', "/") + .trim_end_matches('/') + .to_ascii_lowercase() +} + +fn path_is_absolute_or_uri(path: &str) -> bool { + let bytes = path.as_bytes(); + path.starts_with('/') || path.contains("://") || (bytes.len() >= 2 && bytes[1] == b':') +} + +fn wildcard_match(pattern: &str, value: &str) -> bool { + let pattern = pattern.as_bytes(); + let value = value.as_bytes(); + let (mut pattern_index, mut value_index) = (0, 0); + let (mut star_index, mut star_value_index) = (None, 0); + + while value_index < value.len() { + if pattern_index < pattern.len() && pattern[pattern_index] == value[value_index] { + pattern_index += 1; + value_index += 1; + } else if pattern_index < pattern.len() && pattern[pattern_index] == b'*' { + star_index = Some(pattern_index); + pattern_index += 1; + star_value_index = value_index; + } else if let Some(star) = star_index { + pattern_index = star + 1; + star_value_index += 1; + value_index = star_value_index; + } else { + return false; + } + } + while pattern_index < pattern.len() && pattern[pattern_index] == b'*' { + pattern_index += 1; + } + pattern_index == pattern.len() +} + +fn parse_scope(value: &Value) -> Option { + Some(DebugScope { + name: value.get("name")?.as_str()?.to_string(), + variables_reference: value.get("variablesReference")?.as_i64()?, + expensive: value + .get("expensive") + .and_then(Value::as_bool) + .unwrap_or(false), + named_variables: nonnegative_count_field(value, "namedVariables"), + indexed_variables: nonnegative_count_field(value, "indexedVariables"), + }) +} + +fn parse_variable(value: &Value) -> Option { + Some(DebugVariable { + name: value.get("name")?.as_str()?.to_string(), + value: value.get("value")?.as_str()?.to_string(), + r#type: string_field(value, "type"), + evaluate_name: string_field(value, "evaluateName"), + variables_reference: value + .get("variablesReference") + .and_then(Value::as_i64) + .unwrap_or(0), + named_variables: nonnegative_count_field(value, "namedVariables"), + indexed_variables: nonnegative_count_field(value, "indexedVariables"), + }) +} + +fn nonnegative_count_field(value: &Value, key: &str) -> i64 { + value.get(key).and_then(Value::as_i64).unwrap_or(0).max(0) +} + +fn parse_breakpoint( + value: &Value, + function_name: Option<&str>, + source_path: Option<&str>, + fallback_line: i64, +) -> DebugBreakpoint { + DebugBreakpoint { + id: value.get("id").and_then(Value::as_i64).unwrap_or(0), + verified: value + .get("verified") + .and_then(Value::as_bool) + .unwrap_or(false), + message: string_field(value, "message"), + function_name: function_name.map(str::to_string), + data_id: None, + source_path: value + .get("source") + .and_then(|source| source.get("path")) + .and_then(Value::as_str) + .or(source_path) + .map(str::to_string), + line: value + .get("line") + .and_then(Value::as_i64) + .or((fallback_line > 0).then_some(fallback_line)), + column: value.get("column").and_then(Value::as_i64), + } +} + +fn parse_capabilities(value: &Value) -> DebugCapabilities { + DebugCapabilities { + supports_configuration_done: bool_field(value, "supportsConfigurationDoneRequest"), + supports_conditional_breakpoints: bool_field(value, "supportsConditionalBreakpoints"), + supports_hit_conditional_breakpoints: bool_field( + value, + "supportsHitConditionalBreakpoints", + ), + supports_log_points: bool_field(value, "supportsLogPoints"), + supports_function_breakpoints: bool_field(value, "supportsFunctionBreakpoints"), + supports_data_breakpoints: bool_field(value, "supportsDataBreakpoints"), + supports_exception_options: bool_field(value, "supportsExceptionOptions"), + supports_exception_filter_options: bool_field(value, "supportsExceptionFilterOptions"), + supports_set_variable: bool_field(value, "supportsSetVariable"), + supports_cancel_request: bool_field(value, "supportsCancelRequest"), + supports_single_thread_execution_requests: bool_field( + value, + "supportsSingleThreadExecutionRequests", + ), + supports_restart_request: bool_field(value, "supportsRestartRequest"), + supports_terminate_request: bool_field(value, "supportsTerminateRequest"), + supports_step_back: bool_field(value, "supportsStepBack"), + supports_exception_info_request: bool_field(value, "supportsExceptionInfoRequest"), + supports_step_in_targets_request: bool_field(value, "supportsStepInTargetsRequest"), + supports_goto_targets_request: bool_field(value, "supportsGotoTargetsRequest"), + exception_breakpoint_filters: value + .get("exceptionBreakpointFilters") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(parse_exception_breakpoint_filter) + .collect(), + } +} + +fn parse_exception_breakpoint_filter(value: &Value) -> Option { + let filter = value.get("filter")?.as_str()?.trim(); + let label = value.get("label")?.as_str()?.trim(); + if filter.is_empty() || label.is_empty() { + return None; + } + Some(DebugExceptionBreakpointFilter { + filter: filter.to_string(), + label: label.to_string(), + description: string_field(value, "description"), + default: bool_field(value, "default"), + supports_condition: bool_field(value, "supportsCondition"), + condition_description: string_field(value, "conditionDescription"), + }) +} + +fn with_session( + session_id: &str, + operation: impl FnOnce(&mut DebugSession) -> Result, +) -> Result { + let mut sessions = sessions_lock()?; + let session = sessions + .get_mut(session_id) + .ok_or_else(|| session_not_found(session_id))?; + operation(session) +} + +fn sessions_lock( +) -> Result>, CoreError> { + SESSIONS + .get_or_init(|| Mutex::new(HashMap::new())) + .lock() + .map_err(|_| CoreError::new(ErrorCode::Unknown, "Debug session state is unavailable.")) +} + +fn parse_run_in_terminal_request(value: &Value) -> Result { + const MAX_ARGUMENT_COUNT: usize = 4_096; + const MAX_ENVIRONMENT_COUNT: usize = 4_096; + + let object = value + .as_object() + .ok_or_else(|| invalid_request("Debug runInTerminal arguments must be a JSON object."))?; + let kind = match object.get("kind") { + None => DebugRunInTerminalKind::Integrated, + Some(Value::String(value)) if value == "integrated" => DebugRunInTerminalKind::Integrated, + Some(Value::String(value)) if value == "external" => DebugRunInTerminalKind::External, + _ => { + return Err(invalid_request( + "Debug runInTerminal kind must be integrated or external.", + )) + } + }; + let title = match object.get("title") { + None | Some(Value::Null) => None, + Some(Value::String(value)) => normalize_optional_text(Some(value.clone())), + _ => { + return Err(invalid_request( + "Debug runInTerminal title must be a string.", + )) + } + }; + let cwd = object + .get("cwd") + .and_then(Value::as_str) + .ok_or_else(|| invalid_request("Debug runInTerminal cwd must be a string."))? + .to_string(); + if cwd.contains('\0') { + return Err(invalid_request( + "Debug runInTerminal cwd contains an invalid null byte.", + )); + } + let argument_values = object + .get("args") + .and_then(Value::as_array) + .ok_or_else(|| invalid_request("Debug runInTerminal args must be an array."))?; + if argument_values.is_empty() || argument_values.len() > MAX_ARGUMENT_COUNT { + return Err(invalid_request( + "Debug runInTerminal args must contain between 1 and 4096 items.", + )); + } + let mut args = Vec::with_capacity(argument_values.len()); + for value in argument_values { + let argument = value.as_str().ok_or_else(|| { + invalid_request("Debug runInTerminal args must contain only strings.") + })?; + if argument.contains('\0') { + return Err(invalid_request( + "Debug runInTerminal args contain an invalid null byte.", + )); + } + args.push(argument.to_string()); + } + if args[0].trim().is_empty() { + return Err(invalid_request( + "Debug runInTerminal args must begin with an executable.", + )); + } + + let mut environment = Vec::new(); + if let Some(value) = object.get("env") { + let values = value + .as_object() + .ok_or_else(|| invalid_request("Debug runInTerminal env must be a JSON object."))?; + if values.len() > MAX_ENVIRONMENT_COUNT { + return Err(invalid_request( + "Debug runInTerminal env cannot exceed 4096 entries.", + )); + } + let mut names: Vec<&String> = values.keys().collect(); + names.sort(); + for name in names { + if name.is_empty() || name.contains(['=', '\0']) { + return Err(invalid_request( + "Debug runInTerminal env contains an invalid variable name.", + )); + } + let value = match &values[name] { + Value::Null => None, + Value::String(value) if !value.contains('\0') => Some(value.clone()), + _ => { + return Err(invalid_request( + "Debug runInTerminal env values must be strings or null.", + )) + } + }; + environment.push(DebugRunInTerminalEnvironmentVariable { + name: name.clone(), + value, + }); + } + } + let args_can_be_interpreted_by_shell = match object.get("argsCanBeInterpretedByShell") { + None => false, + Some(Value::Bool(value)) => *value, + _ => { + return Err(invalid_request( + "Debug runInTerminal argsCanBeInterpretedByShell must be a boolean.", + )) + } + }; + + Ok(DebugRunInTerminalRequest { + kind, + title, + cwd, + args, + environment, + args_can_be_interpreted_by_shell, + }) +} + +fn validate_dap_process_id(value: Option, field: &str) -> Result<(), CoreError> { + if value.is_some_and(|value| !(1..=i32::MAX as i64).contains(&value)) { + return Err(invalid_request(&format!( + "Debug runInTerminal {field} must be between 1 and {}.", + i32::MAX + ))); + } + Ok(()) +} + +fn validate_identifier(value: &str, field: &str) -> Result<(), CoreError> { + if value.trim().is_empty() || value.contains('\0') || value.len() > 512 { + return Err(invalid_request(&format!("Debug {field} was invalid."))); + } + Ok(()) +} + +fn validate_path(value: &str, field: &str) -> Result<(), CoreError> { + if value.trim().is_empty() || value.contains('\0') { + return Err(invalid_request(&format!("Debug {field} was invalid."))); + } + Ok(()) +} + +fn required_positive(value: Option, field: &str) -> Result { + value + .filter(|value| *value > 0) + .ok_or_else(|| invalid_request(&format!("Debug {field} must be positive."))) +} + +fn required_nonnegative(value: Option, field: &str) -> Result { + value + .filter(|value| *value >= 0) + .ok_or_else(|| invalid_request(&format!("Debug {field} cannot be negative."))) +} + +fn required_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, CoreError> { + value.get(field).and_then(Value::as_str).ok_or_else(|| { + CoreError::new( + ErrorCode::ParseFailed, + format!("DAP message did not contain a valid {field}."), + ) + }) +} + +fn required_i64(value: &Value, field: &str) -> Result { + value.get(field).and_then(Value::as_i64).ok_or_else(|| { + CoreError::new( + ErrorCode::ParseFailed, + format!("DAP message did not contain a valid {field}."), + ) + }) +} + +fn required_array<'a>(value: &'a Value, field: &str) -> Result<&'a Vec, CoreError> { + value.get(field).and_then(Value::as_array).ok_or_else(|| { + CoreError::new( + ErrorCode::ParseFailed, + format!("DAP response did not contain a valid {field} array."), + ) + }) +} + +fn string_field(value: &Value, field: &str) -> Option { + value.get(field).and_then(Value::as_str).map(str::to_string) +} + +fn bool_field(value: &Value, field: &str) -> bool { + value.get(field).and_then(Value::as_bool).unwrap_or(false) +} + +fn insert_option(map: &mut Map, key: &str, value: Option) { + if let Some(value) = value { + map.insert(key.to_string(), json!(value)); + } +} + +fn insert_nonempty(map: &mut Map, key: &str, value: Option<&str>) { + if let Some(value) = value.filter(|value| !value.is_empty()) { + map.insert(key.to_string(), Value::String(value.to_string())); + } +} + +fn normalize_optional_text(value: Option) -> Option { + value + .map(|text| text.trim().to_string()) + .filter(|text| !text.is_empty()) +} + +fn invalid_request(message: &str) -> CoreError { + CoreError::new(ErrorCode::InvalidRequest, message) +} + +fn session_not_found(session_id: &str) -> CoreError { + CoreError::new(ErrorCode::InvalidRequest, "Debug session was not found.") + .with_details(session_id.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn request_message(sequence: i64, command: &str, arguments: Value) -> Value { + json!({ + "seq": sequence, + "type": "request", + "command": command, + "arguments": arguments + }) + } + + fn response_message(request_sequence: i64, command: &str, body: Value) -> Value { + json!({ + "seq": 100 + request_sequence, + "type": "response", + "request_seq": request_sequence, + "success": true, + "command": command, + "body": body + }) + } + + fn receive_messages(session_id: &str, messages: Vec) -> DebugSessionUpdate { + let bytes = messages + .into_iter() + .flat_map(|message| frame_message(&message).unwrap()) + .collect::>(); + receive(ReceiveRequest { + session_id: session_id.to_string(), + data_base64: BASE64.encode(bytes), + }) + .unwrap() + } + + fn decode_frame(frame: &str) -> Value { + let bytes = BASE64.decode(frame).unwrap(); + let body_start = bytes + .windows(4) + .position(|value| value == b"\r\n\r\n") + .unwrap() + + 4; + serde_json::from_slice(&bytes[body_start..]).unwrap() + } + + #[test] + fn debug_update_serializes_variant_fields_with_contract_casing() { + let update = DebugSessionUpdate { + session_id: "debug-contract-casing".to_string(), + state: DebugSessionState::Paused, + outbound_frames: Vec::new(), + events: vec![ + DebugEvent { + sequence: 1, + body: DebugEventBody::Stopped { + reason: "breakpoint".to_string(), + thread_id: Some(13), + description: None, + }, + }, + DebugEvent { + sequence: 2, + body: DebugEventBody::OperationCompleted { + operation_id: "stack-1".to_string(), + result: DebugOperationResult::StackTrace { + stack_frames: vec![DebugStackFrame { + id: 7, + name: "example.Main.run".to_string(), + source_path: Some("/workspace/Main.java".to_string()), + line: 12, + column: 1, + is_filtered: false, + }], + }, + }, + }, + ], + }; + + let value = serde_json::to_value(update).unwrap(); + assert_eq!(value["events"][0]["threadId"], 13); + assert!(value["events"][0].get("thread_id").is_none()); + assert_eq!(value["events"][1]["operationId"], "stack-1"); + assert!(value["events"][1].get("operation_id").is_none()); + assert_eq!(value["events"][1]["result"]["kind"], "stackTrace"); + assert_eq!(value["events"][1]["result"]["stackFrames"][0]["id"], 7); + assert_eq!( + value["events"][1]["result"]["stackFrames"][0]["isFiltered"], + false + ); + assert!(value["events"][1]["result"].get("stack_frames").is_none()); + } + + #[test] + fn disconnect_policy_matches_shared_fixture() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/fixtures/debug/disconnect-policy-v1.json" + ))) + .unwrap(); + + for case in fixture["cases"].as_array().unwrap() { + let request_name = case["request"].as_str().unwrap_or("unstarted"); + let session_id = format!("debug-disconnect-{request_name}"); + create_session(CreateSessionRequest { + session_id: session_id.clone(), + adapter_id: "java".to_string(), + root_path: "/workspace".to_string(), + supports_run_in_terminal_request: false, + }) + .unwrap(); + + if case["request"].is_string() { + let request_kind = serde_json::from_value(case["request"].clone()).unwrap(); + launch(LaunchRequest { + session_id: session_id.clone(), + operation_id: format!("{request_name}-main"), + configuration: DebugLaunchConfiguration { + name: "Main".to_string(), + request: request_kind, + arguments: Map::new(), + stepping_filters: None, + }, + }) + .unwrap(); + let initialized = receive_messages( + &session_id, + vec![response_message(1, "initialize", json!({}))], + ); + assert_eq!( + decode_frame(&initialized.outbound_frames[0])["command"], + request_name + ); + } + + let disconnected = disconnect(SessionRequest { + session_id: session_id.clone(), + }) + .unwrap(); + let request = decode_frame(&disconnected.outbound_frames[0]); + assert_eq!(request["command"], "disconnect"); + assert_eq!(request["arguments"], case["expectedArguments"]); + destroy_session(SessionRequest { session_id }).unwrap(); + } + } + + #[test] + fn variable_paging_arguments_reject_invalid_combinations() { + let base = InspectRequest { + session_id: "debug-variable-validation".to_string(), + operation_id: "variables".to_string(), + kind: DebugInspectKind::Variables, + thread_id: None, + frame_id: None, + variables_reference: Some(700), + variable_filter: Some(DebugVariableFilter::Indexed), + start: Some(0), + count: Some(100), + expression: None, + source_path: None, + line: None, + column: None, + }; + + let mut negative_start = base.clone(); + negative_start.start = Some(-1); + let error = inspect_arguments(&negative_start).unwrap_err(); + assert!(matches!(error.code, ErrorCode::InvalidRequest)); + + let mut zero_count = base.clone(); + zero_count.count = Some(0); + let error = inspect_arguments(&zero_count).unwrap_err(); + assert!(matches!(error.code, ErrorCode::InvalidRequest)); + + let mut non_variable_request = base; + non_variable_request.kind = DebugInspectKind::Threads; + non_variable_request.variables_reference = None; + non_variable_request.start = None; + non_variable_request.count = None; + let error = inspect_arguments(&non_variable_request).unwrap_err(); + assert!(matches!(error.code, ErrorCode::InvalidRequest)); + } + + #[test] + fn variable_child_counts_are_normalized_for_scopes_and_evaluation() { + let scopes = normalize_inspection( + DebugInspectKind::Scopes, + &json!({ + "scopes": [{ + "name": "Locals", + "variablesReference": 700, + "expensive": false, + "namedVariables": -2, + "indexedVariables": 250 + }] + }), + &DebugSteppingFilters::default(), + "/workspace", + ) + .unwrap(); + let scopes = serde_json::to_value(scopes).unwrap(); + assert_eq!(scopes["scopes"][0]["namedVariables"], 0); + assert_eq!(scopes["scopes"][0]["indexedVariables"], 250); + + let evaluation = normalize_inspection( + DebugInspectKind::Evaluate, + &json!({ + "result": "Customer[250]", + "variablesReference": 701, + "namedVariables": 4, + "indexedVariables": -1 + }), + &DebugSteppingFilters::default(), + "/workspace", + ) + .unwrap(); + let evaluation = serde_json::to_value(evaluation).unwrap(); + assert_eq!(evaluation["variable"]["namedVariables"], 4); + assert_eq!(evaluation["variable"]["indexedVariables"], 0); + } + + #[test] + fn java_stepping_filters_are_normalized_projected_and_mark_stack_frames() { + let defaults = stepping_filters(DebugSteppingFiltersRequest { + adapter_id: "java".to_string(), + filters: None, + }) + .unwrap(); + assert!(defaults.class_name_filters.contains(&"$JDK".to_string())); + assert!(defaults + .class_name_filters + .contains(&"$Libraries".to_string())); + assert!(defaults.skip_synthetics); + assert!(!defaults.skip_constructors); + + let filters = stepping_filters(DebugSteppingFiltersRequest { + adapter_id: "java".to_string(), + filters: Some(DebugSteppingFilters { + class_name_filters: vec![ + " org.mockito.* ".to_string(), + "$JDK".to_string(), + "org.mockito.*".to_string(), + String::new(), + ], + skip_synthetics: true, + skip_static_initializers: false, + skip_constructors: true, + hide_filtered_stack_frames: true, + }), + }) + .unwrap(); + assert_eq!(filters.class_name_filters, ["$JDK", "org.mockito.*"]); + + let multiline_error = stepping_filters(DebugSteppingFiltersRequest { + adapter_id: "java".to_string(), + filters: Some(DebugSteppingFilters { + class_name_filters: vec!["example.Valid\nexample.Invalid".to_string()], + skip_synthetics: false, + skip_static_initializers: false, + skip_constructors: false, + hide_filtered_stack_frames: false, + }), + }) + .unwrap_err(); + assert!(matches!(multiline_error.code, ErrorCode::InvalidRequest)); + + let excessive_filter_error = stepping_filters(DebugSteppingFiltersRequest { + adapter_id: "java".to_string(), + filters: Some(DebugSteppingFilters { + class_name_filters: (0..257) + .map(|index| format!("example.Type{index}")) + .collect(), + skip_synthetics: false, + skip_static_initializers: false, + skip_constructors: false, + hide_filtered_stack_frames: false, + }), + }) + .unwrap_err(); + assert!(matches!( + excessive_filter_error.code, + ErrorCode::InvalidRequest + )); + + let session_id = "debug-java-stepping-filters"; + create_session(CreateSessionRequest { + session_id: session_id.to_string(), + adapter_id: "java".to_string(), + root_path: "/workspace".to_string(), + supports_run_in_terminal_request: false, + }) + .unwrap(); + launch(LaunchRequest { + session_id: session_id.to_string(), + operation_id: "launch".to_string(), + configuration: DebugLaunchConfiguration { + name: "Main".to_string(), + request: DebugRequestKind::Launch, + arguments: Map::new(), + stepping_filters: Some(filters), + }, + }) + .unwrap(); + let initialized = receive_messages( + session_id, + vec![response_message(1, "initialize", json!({}))], + ); + let launch_request = decode_frame(&initialized.outbound_frames[0]); + assert_eq!(launch_request["command"], "launch"); + assert_eq!( + launch_request["arguments"]["stepFilters"]["skipClasses"], + json!(["$JDK", "org.mockito.*"]) + ); + assert_eq!( + launch_request["arguments"]["stepFilters"]["skipConstructors"], + true + ); + receive_messages( + session_id, + vec![ + response_message(2, "launch", json!({})), + json!({ + "seq": 103, + "type": "event", + "event": "stopped", + "body": {"reason": "breakpoint", "threadId": 11} + }), + ], + ); + inspect(InspectRequest { + session_id: session_id.to_string(), + operation_id: "stack".to_string(), + kind: DebugInspectKind::StackTrace, + thread_id: Some(11), + frame_id: None, + variables_reference: None, + variable_filter: None, + start: None, + count: None, + expression: None, + source_path: None, + line: None, + column: None, + }) + .unwrap(); + let stack = receive_messages( + session_id, + vec![response_message( + 3, + "stackTrace", + json!({"stackFrames": [ + { + "id": 7, + "name": "Method.invoke(Object,Object[])", + "source": { + "path": "jdt://contents/java.base/java/lang/reflect/Method.class" + }, + "line": 1, + "column": 1 + }, + { + "id": 8, + "name": "MockMethodInterceptor.intercept(Object,Method,Object[],Invoker)", + "source": { + "path": "jdt://contents/mockito-core/org/mockito/internal/creation/bytebuddy/MockMethodInterceptor.class" + }, + "line": 1, + "column": 1 + }, + { + "id": 9, + "name": "example.LoginService.authenticate", + "source": {"path": "/workspace/LoginService.java"}, + "line": 24, + "column": 5 + } + ]}), + )], + ); + assert!(stack.events.iter().any(|event| matches!( + &event.body, + DebugEventBody::OperationCompleted { + operation_id, + result: DebugOperationResult::StackTrace { stack_frames } + } if operation_id == "stack" + && stack_frames.len() == 3 + && stack_frames[0].is_filtered + && stack_frames[1].is_filtered + && !stack_frames[2].is_filtered + ))); + let library_filters = DebugSteppingFilters { + class_name_filters: vec!["$Libraries".to_string()], + skip_synthetics: false, + skip_static_initializers: false, + skip_constructors: false, + hide_filtered_stack_frames: true, + }; + assert!(stack_frame_matches_filters( + "Dependency.call()", + Some("/dependencies/example/Dependency.java"), + None, + &library_filters, + "/workspace" + )); + assert!(!stack_frame_matches_filters( + "LoginService.authenticate()", + Some("/workspace/LoginService.java"), + None, + &library_filters, + "/workspace" + )); + destroy_session(SessionRequest { + session_id: session_id.to_string(), + }) + .unwrap(); + } + + #[test] + fn initialize_launch_breakpoints_and_inspection_are_reduced_in_order() { + let session_id = "debug-engine-flow"; + let created = create_session(CreateSessionRequest { + session_id: session_id.to_string(), + adapter_id: "java".to_string(), + root_path: "/workspace".to_string(), + supports_run_in_terminal_request: false, + }) + .unwrap(); + assert_eq!(created.state, DebugSessionState::Initializing); + assert_eq!( + decode_frame(&created.outbound_frames[0])["command"], + "initialize" + ); + + let queued = launch(LaunchRequest { + session_id: session_id.to_string(), + operation_id: "launch-1".to_string(), + configuration: DebugLaunchConfiguration { + name: "Main".to_string(), + request: DebugRequestKind::Launch, + arguments: Map::from_iter([("mainClass".to_string(), json!("example.Main"))]), + stepping_filters: None, + }, + }) + .unwrap(); + assert!(queued.outbound_frames.is_empty()); + set_breakpoints(SetBreakpointsRequest { + session_id: session_id.to_string(), + source_path: "/workspace/src/Main.java".to_string(), + breakpoints: vec![ + SourceBreakpoint { + line: 12, + column: None, + enabled: true, + condition: Some("value > 1".to_string()), + hit_condition: Some("3".to_string()), + log_message: Some("value = {value}".to_string()), + }, + SourceBreakpoint { + line: 14, + column: None, + enabled: false, + condition: None, + hit_condition: None, + log_message: None, + }, + ], + }) + .unwrap(); + set_exception_breakpoints(SetExceptionBreakpointsRequest { + session_id: session_id.to_string(), + breakpoints: vec![ + ExceptionBreakpoint { + filter: "uncaught".to_string(), + enabled: false, + condition: None, + }, + ExceptionBreakpoint { + filter: "caught".to_string(), + enabled: true, + condition: Some(" example.CustomException ".to_string()), + }, + ], + }) + .unwrap(); + set_function_breakpoints(SetFunctionBreakpointsRequest { + session_id: session_id.to_string(), + breakpoints: vec![ + FunctionBreakpoint { + name: " example.Main.run ".to_string(), + enabled: true, + condition: Some("ready".to_string()), + hit_condition: Some("2".to_string()), + }, + FunctionBreakpoint { + name: "example.Main.skip".to_string(), + enabled: false, + condition: None, + hit_condition: None, + }, + ], + }) + .unwrap(); + + let initialized = receive_messages( + session_id, + vec![response_message( + 1, + "initialize", + json!({ + "supportsConfigurationDoneRequest": true, + "supportsConditionalBreakpoints": true, + "supportsHitConditionalBreakpoints": true, + "supportsLogPoints": true, + "supportsFunctionBreakpoints": true, + "supportsDataBreakpoints": true, + "supportsSetVariable": true, + "supportsRestartRequest": true, + "supportsExceptionFilterOptions": true, + "exceptionBreakpointFilters": [{ + "filter": "caught", + "label": "Caught Exceptions", + "default": false, + "supportsCondition": true + }] + }), + )], + ); + assert_eq!(initialized.state, DebugSessionState::Launching); + assert!(initialized.events.iter().any(|event| matches!( + &event.body, + DebugEventBody::Capabilities { capabilities } + if capabilities.supports_conditional_breakpoints + && capabilities.supports_hit_conditional_breakpoints + && capabilities.supports_log_points + && capabilities.supports_function_breakpoints + && capabilities.supports_data_breakpoints + && capabilities.supports_set_variable + && capabilities.supports_restart_request + && capabilities.exception_breakpoint_filters.len() == 1 + ))); + assert_eq!( + decode_frame(&initialized.outbound_frames[0])["command"], + "launch" + ); + + let configured = receive_messages( + session_id, + vec![json!({"seq": 102, "type": "event", "event": "initialized"})], + ); + assert_eq!( + decode_frame(&configured.outbound_frames[0])["command"], + "setExceptionBreakpoints" + ); + assert_eq!( + decode_frame(&configured.outbound_frames[1])["command"], + "setFunctionBreakpoints" + ); + assert_eq!( + decode_frame(&configured.outbound_frames[2])["command"], + "setDataBreakpoints" + ); + assert_eq!( + decode_frame(&configured.outbound_frames[3])["command"], + "setBreakpoints" + ); + assert_eq!( + decode_frame(&configured.outbound_frames[4])["command"], + "configurationDone" + ); + assert_eq!( + decode_frame(&configured.outbound_frames[0])["arguments"]["filters"], + json!(["caught"]) + ); + assert_eq!( + decode_frame(&configured.outbound_frames[0])["arguments"]["filterOptions"][0] + ["condition"], + "example.CustomException" + ); + assert_eq!( + decode_frame(&configured.outbound_frames[1])["arguments"]["breakpoints"][0]["name"], + "example.Main.run" + ); + assert_eq!( + decode_frame(&configured.outbound_frames[1])["arguments"]["breakpoints"][0] + ["condition"], + "ready" + ); + assert_eq!( + decode_frame(&configured.outbound_frames[1])["arguments"]["breakpoints"][0] + ["hitCondition"], + "2" + ); + assert_eq!( + decode_frame(&configured.outbound_frames[1])["arguments"]["breakpoints"] + .as_array() + .unwrap() + .len(), + 1 + ); + assert_eq!( + decode_frame(&configured.outbound_frames[3])["arguments"]["breakpoints"][0] + ["condition"], + "value > 1" + ); + assert_eq!( + decode_frame(&configured.outbound_frames[3])["arguments"]["breakpoints"][0] + ["hitCondition"], + "3" + ); + assert_eq!( + decode_frame(&configured.outbound_frames[3])["arguments"]["breakpoints"][0] + ["logMessage"], + "value = {value}" + ); + assert_eq!( + decode_frame(&configured.outbound_frames[3])["arguments"]["breakpoints"] + .as_array() + .unwrap() + .len(), + 1 + ); + + let running = receive_messages( + session_id, + vec![ + response_message(2, "launch", json!({})), + response_message(3, "setExceptionBreakpoints", json!({})), + response_message( + 4, + "setFunctionBreakpoints", + json!({"breakpoints": [{"id": 8, "verified": true}]}), + ), + response_message(5, "setDataBreakpoints", json!({"breakpoints": []})), + response_message( + 6, + "setBreakpoints", + json!({"breakpoints": [{"id": 7, "verified": true, "line": 12}]}), + ), + response_message(7, "configurationDone", json!({})), + ], + ); + assert_eq!(running.state, DebugSessionState::Running); + assert!(running + .events + .iter() + .any(|event| matches!(event.body, DebugEventBody::Breakpoint { .. }))); + assert!(running.events.iter().any(|event| matches!( + &event.body, + DebugEventBody::Breakpoint { breakpoint } + if breakpoint.function_name.as_deref() == Some("example.Main.run") + && breakpoint.verified + ))); + + let inspection = inspect(InspectRequest { + session_id: session_id.to_string(), + operation_id: "threads-1".to_string(), + kind: DebugInspectKind::Threads, + thread_id: None, + frame_id: None, + variables_reference: None, + variable_filter: None, + start: None, + count: None, + expression: None, + source_path: None, + line: None, + column: None, + }) + .unwrap(); + assert_eq!( + decode_frame(&inspection.outbound_frames[0])["command"], + "threads" + ); + let completed = receive_messages( + session_id, + vec![response_message( + 8, + "threads", + json!({"threads": [{"id": 1, "name": "main"}]}), + )], + ); + assert!(completed.events.iter().any(|event| matches!( + &event.body, + DebugEventBody::OperationCompleted { operation_id, result: DebugOperationResult::Threads { threads } } + if operation_id == "threads-1" && threads.len() == 1 + ))); + + destroy_session(SessionRequest { + session_id: session_id.to_string(), + }) + .unwrap(); + } + + #[test] + fn data_breakpoint_identity_and_verification_are_correlated() { + let session_id = "debug-data-breakpoint"; + create_session(CreateSessionRequest { + session_id: session_id.to_string(), + adapter_id: "java".to_string(), + root_path: "/workspace".to_string(), + supports_run_in_terminal_request: false, + }) + .unwrap(); + set_data_breakpoints(SetDataBreakpointsRequest { + session_id: session_id.to_string(), + breakpoints: vec![ + DataBreakpoint { + data_id: " field:count ".to_string(), + label: Some("count".to_string()), + enabled: true, + access_type: Some(" write ".to_string()), + condition: Some(" count > 1 ".to_string()), + hit_condition: Some(" 2 ".to_string()), + }, + DataBreakpoint { + data_id: "field:ignored".to_string(), + label: None, + enabled: false, + access_type: None, + condition: None, + hit_condition: None, + }, + ], + }) + .unwrap(); + receive_messages( + session_id, + vec![response_message( + 1, + "initialize", + json!({"supportsDataBreakpoints": true}), + )], + ); + let configured = receive_messages( + session_id, + vec![json!({"seq": 102, "type": "event", "event": "initialized"})], + ); + assert_eq!( + decode_frame(&configured.outbound_frames[1])["command"], + "setDataBreakpoints" + ); + let arguments = &decode_frame(&configured.outbound_frames[1])["arguments"]["breakpoints"]; + assert_eq!(arguments.as_array().unwrap().len(), 1); + assert_eq!(arguments[0]["dataId"], "field:count"); + assert_eq!(arguments[0]["accessType"], "write"); + assert_eq!(arguments[0]["condition"], "count > 1"); + assert_eq!(arguments[0]["hitCondition"], "2"); + + let verified = receive_messages( + session_id, + vec![ + response_message(2, "setExceptionBreakpoints", json!({})), + response_message( + 3, + "setDataBreakpoints", + json!({"breakpoints": [{"id": 9, "verified": true}]}), + ), + ], + ); + assert!(verified.events.iter().any(|event| matches!( + &event.body, + DebugEventBody::Breakpoint { breakpoint } + if breakpoint.data_id.as_deref() == Some("field:count") + && breakpoint.verified + ))); + + let info = data_breakpoint_info(DataBreakpointInfoRequest { + session_id: session_id.to_string(), + operation_id: "field-info".to_string(), + name: " count ".to_string(), + variables_reference: Some(42), + frame_id: Some(7), + }) + .unwrap(); + let request = decode_frame(&info.outbound_frames[0]); + assert_eq!(request["command"], "dataBreakpointInfo"); + assert_eq!(request["arguments"]["name"], "count"); + assert_eq!(request["arguments"]["variablesReference"], 42); + assert_eq!(request["arguments"]["frameId"], 7); + let completed = receive_messages( + session_id, + vec![response_message( + 4, + "dataBreakpointInfo", + json!({ + "dataId": "field:count", + "description": "Main.count", + "accessTypes": ["read", "write"], + "canPersist": true + }), + )], + ); + assert!(completed.events.iter().any(|event| matches!( + &event.body, + DebugEventBody::OperationCompleted { + operation_id, + result: DebugOperationResult::DataBreakpointInfo { + data_id, + description, + access_types, + can_persist + } + } if operation_id == "field-info" + && data_id.as_deref() == Some("field:count") + && description == "Main.count" + && access_types == &["read", "write"] + && *can_persist + ))); + destroy_session(SessionRequest { + session_id: session_id.to_string(), + }) + .unwrap(); + } + + #[test] + fn variable_mutation_is_capability_gated_and_correlated() { + let session_id = "debug-set-variable"; + create_session(CreateSessionRequest { + session_id: session_id.to_string(), + adapter_id: "java".to_string(), + root_path: "/workspace".to_string(), + supports_run_in_terminal_request: false, + }) + .unwrap(); + launch(LaunchRequest { + session_id: session_id.to_string(), + operation_id: "launch".to_string(), + configuration: DebugLaunchConfiguration { + name: "Main".to_string(), + request: DebugRequestKind::Launch, + arguments: Map::new(), + stepping_filters: None, + }, + }) + .unwrap(); + receive_messages( + session_id, + vec![response_message( + 1, + "initialize", + json!({"supportsSetVariable": true}), + )], + ); + receive_messages(session_id, vec![response_message(2, "launch", json!({}))]); + receive_messages( + session_id, + vec![json!({ + "seq": 103, + "type": "event", + "event": "stopped", + "body": {"reason": "breakpoint", "threadId": 11} + })], + ); + + let update = set_variable(SetVariableRequest { + session_id: session_id.to_string(), + operation_id: "set-count".to_string(), + variables_reference: 42, + name: " count ".to_string(), + value: "7".to_string(), + }) + .unwrap(); + let request = decode_frame(&update.outbound_frames[0]); + assert_eq!(request["command"], "setVariable"); + assert_eq!(request["arguments"]["variablesReference"], 42); + assert_eq!(request["arguments"]["name"], "count"); + assert_eq!(request["arguments"]["value"], "7"); + + let completed = receive_messages( + session_id, + vec![response_message( + 3, + "setVariable", + json!({ + "value": "7", + "type": "int", + "variablesReference": 0, + "namedVariables": -1, + "indexedVariables": 2 + }), + )], + ); + assert!(completed.events.iter().any(|event| matches!( + &event.body, + DebugEventBody::OperationCompleted { + operation_id, + result: DebugOperationResult::SetVariable { variable } + } if operation_id == "set-count" + && variable.name == "count" + && variable.value == "7" + && variable.r#type.as_deref() == Some("int") + && variable.named_variables == 0 + && variable.indexed_variables == 2 + ))); + destroy_session(SessionRequest { + session_id: session_id.to_string(), + }) + .unwrap(); + } + + #[test] + fn cancelled_operation_forwards_dap_cancel_and_ignores_late_response() { + let session_id = "debug-cancel-operation"; + create_session(CreateSessionRequest { + session_id: session_id.to_string(), + adapter_id: "java".to_string(), + root_path: "/workspace".to_string(), + supports_run_in_terminal_request: false, + }) + .unwrap(); + launch(LaunchRequest { + session_id: session_id.to_string(), + operation_id: "launch".to_string(), + configuration: DebugLaunchConfiguration { + name: "Main".to_string(), + request: DebugRequestKind::Launch, + arguments: Map::new(), + stepping_filters: None, + }, + }) + .unwrap(); + receive_messages( + session_id, + vec![response_message( + 1, + "initialize", + json!({ + "supportsCancelRequest": true, + "supportsSingleThreadExecutionRequests": true + }), + )], + ); + receive_messages(session_id, vec![response_message(2, "launch", json!({}))]); + receive_messages( + session_id, + vec![json!({ + "seq": 103, + "type": "event", + "event": "stopped", + "body": {"reason": "breakpoint", "threadId": 11} + })], + ); + let pending = inspect(InspectRequest { + session_id: session_id.to_string(), + operation_id: "threads-timeout".to_string(), + kind: DebugInspectKind::Threads, + thread_id: None, + frame_id: None, + variables_reference: None, + variable_filter: None, + start: None, + count: None, + expression: None, + source_path: None, + line: None, + column: None, + }) + .unwrap(); + assert_eq!(decode_frame(&pending.outbound_frames[0])["seq"], 3); + + let cancelled = cancel_operation(CancelOperationRequest { + session_id: session_id.to_string(), + operation_id: "threads-timeout".to_string(), + reason: DebugCancellationReason::TimedOut, + }) + .unwrap(); + assert!(cancelled.events.iter().any(|event| matches!( + &event.body, + DebugEventBody::OperationFailed { operation_id, command, code, message } + if operation_id == "threads-timeout" + && command == "threads" + && *code == DebugOperationFailureCode::TimedOut + && message == "Debug operation timed out." + ))); + let cancel = decode_frame(&cancelled.outbound_frames[0]); + assert_eq!(cancel["command"], "cancel"); + assert_eq!(cancel["arguments"]["requestId"], 3); + + let late = receive_messages( + session_id, + vec![ + response_message(3, "threads", json!({"threads": []})), + response_message(4, "cancel", json!({})), + ], + ); + assert!(!late + .events + .iter() + .any(|event| matches!(event.body, DebugEventBody::OperationCompleted { .. }))); + let resumed = execute(ExecuteRequest { + session_id: session_id.to_string(), + operation_id: "resume-main-thread".to_string(), + command: DebugExecutionCommand::Continue, + thread_id: Some(11), + target_id: None, + single_thread: true, + }) + .unwrap(); + let resumed_request = decode_frame(&resumed.outbound_frames[0]); + assert_eq!(resumed_request["command"], "continue"); + assert_eq!(resumed_request["arguments"]["threadId"], 11); + assert_eq!(resumed_request["arguments"]["singleThread"], true); + let resumed = receive_messages( + session_id, + vec![ + response_message(5, "continue", json!({})), + json!({ + "seq": 108, + "type": "event", + "event": "continued", + "body": {"threadId": 11, "allThreadsContinued": false} + }), + ], + ); + assert_eq!(resumed.state, DebugSessionState::Paused); + destroy_session(SessionRequest { + session_id: session_id.to_string(), + }) + .unwrap(); + } + + #[test] + fn advanced_execution_controls_are_capability_gated_and_correlated() { + let session_id = "debug-advanced-control"; + create_session(CreateSessionRequest { + session_id: session_id.to_string(), + adapter_id: "java".to_string(), + root_path: "/workspace".to_string(), + supports_run_in_terminal_request: false, + }) + .unwrap(); + launch(LaunchRequest { + session_id: session_id.to_string(), + operation_id: "launch".to_string(), + configuration: DebugLaunchConfiguration { + name: "Main".to_string(), + request: DebugRequestKind::Launch, + arguments: Map::new(), + stepping_filters: None, + }, + }) + .unwrap(); + receive_messages( + session_id, + vec![response_message( + 1, + "initialize", + json!({ + "supportsStepBack": true, + "supportsRestartRequest": true, + "supportsTerminateRequest": true + }), + )], + ); + receive_messages(session_id, vec![response_message(2, "launch", json!({}))]); + receive_messages( + session_id, + vec![json!({ + "seq": 103, + "type": "event", + "event": "stopped", + "body": {"reason": "breakpoint", "threadId": 11} + })], + ); + + let step_back = execute(ExecuteRequest { + session_id: session_id.to_string(), + operation_id: "step-back".to_string(), + command: DebugExecutionCommand::StepBack, + thread_id: Some(11), + target_id: None, + single_thread: false, + }) + .unwrap(); + let request = decode_frame(&step_back.outbound_frames[0]); + assert_eq!(request["command"], "stepBack"); + assert_eq!(request["arguments"]["threadId"], 11); + assert_eq!(request["arguments"]["singleThread"], false); + let stepped = + receive_messages(session_id, vec![response_message(3, "stepBack", json!({}))]); + assert!(stepped.events.iter().any(|event| matches!( + &event.body, + DebugEventBody::OperationCompleted { operation_id, result: DebugOperationResult::Acknowledged { command } } + if operation_id == "step-back" && command == "stepBack" + ))); + + let restart = execute(ExecuteRequest { + session_id: session_id.to_string(), + operation_id: "restart".to_string(), + command: DebugExecutionCommand::Restart, + thread_id: Some(11), + target_id: None, + single_thread: false, + }) + .unwrap(); + let request = decode_frame(&restart.outbound_frames[0]); + assert_eq!(request["command"], "restart"); + assert!(request["arguments"].get("threadId").is_none()); + receive_messages(session_id, vec![response_message(4, "restart", json!({}))]); + + let terminate = execute(ExecuteRequest { + session_id: session_id.to_string(), + operation_id: "terminate".to_string(), + command: DebugExecutionCommand::Terminate, + thread_id: Some(11), + target_id: None, + single_thread: false, + }) + .unwrap(); + let request = decode_frame(&terminate.outbound_frames[0]); + assert_eq!(request["command"], "terminate"); + assert!(request["arguments"].get("threadId").is_none()); + destroy_session(SessionRequest { + session_id: session_id.to_string(), + }) + .unwrap(); + } + + #[test] + fn smart_step_and_goto_targets_are_normalized_before_targeted_execution() { + let session_id = "debug-targeted-control"; + create_session(CreateSessionRequest { + session_id: session_id.to_string(), + adapter_id: "java".to_string(), + root_path: "/workspace".to_string(), + supports_run_in_terminal_request: false, + }) + .unwrap(); + launch(LaunchRequest { + session_id: session_id.to_string(), + operation_id: "launch".to_string(), + configuration: DebugLaunchConfiguration { + name: "Main".to_string(), + request: DebugRequestKind::Launch, + arguments: Map::new(), + stepping_filters: None, + }, + }) + .unwrap(); + receive_messages( + session_id, + vec![response_message( + 1, + "initialize", + json!({ + "supportsStepInTargetsRequest": true, + "supportsGotoTargetsRequest": true + }), + )], + ); + receive_messages(session_id, vec![response_message(2, "launch", json!({}))]); + receive_messages( + session_id, + vec![json!({ + "seq": 103, + "type": "event", + "event": "stopped", + "body": {"reason": "breakpoint", "threadId": 11} + })], + ); + + let step_targets = inspect(InspectRequest { + session_id: session_id.to_string(), + operation_id: "step-targets".to_string(), + kind: DebugInspectKind::StepInTargets, + thread_id: None, + frame_id: Some(7), + variables_reference: None, + variable_filter: None, + start: None, + count: None, + expression: None, + source_path: None, + line: None, + column: None, + }) + .unwrap(); + let request = decode_frame(&step_targets.outbound_frames[0]); + assert_eq!(request["command"], "stepInTargets"); + assert_eq!(request["arguments"]["frameId"], 7); + let step_targets = receive_messages( + session_id, + vec![response_message( + 3, + "stepInTargets", + json!({"targets": [{ + "id": 21, + "label": "service.load()", + "line": 12, + "column": 9, + "endLine": 12, + "endColumn": 23 + }]}), + )], + ); + assert!(step_targets.events.iter().any(|event| matches!( + &event.body, + DebugEventBody::OperationCompleted { + operation_id, + result: DebugOperationResult::StepInTargets { targets } + } if operation_id == "step-targets" + && targets.first().map(|target| target.id) == Some(21) + ))); + let targeted_step = execute(ExecuteRequest { + session_id: session_id.to_string(), + operation_id: "targeted-step".to_string(), + command: DebugExecutionCommand::StepIn, + thread_id: Some(11), + target_id: Some(21), + single_thread: false, + }) + .unwrap(); + assert_eq!( + decode_frame(&targeted_step.outbound_frames[0])["arguments"]["targetId"], + 21 + ); + receive_messages(session_id, vec![response_message(4, "stepIn", json!({}))]); + receive_messages( + session_id, + vec![json!({ + "seq": 105, + "type": "event", + "event": "stopped", + "body": {"reason": "step", "threadId": 11} + })], + ); + + let goto_targets = inspect(InspectRequest { + session_id: session_id.to_string(), + operation_id: "goto-targets".to_string(), + kind: DebugInspectKind::GotoTargets, + thread_id: None, + frame_id: None, + variables_reference: None, + variable_filter: None, + start: None, + count: None, + expression: None, + source_path: Some("/workspace/src/Main.java".to_string()), + line: Some(20), + column: Some(5), + }) + .unwrap(); + let request = decode_frame(&goto_targets.outbound_frames[0]); + assert_eq!(request["command"], "gotoTargets"); + assert_eq!( + request["arguments"]["source"]["path"], + "/workspace/src/Main.java" + ); + assert_eq!(request["arguments"]["line"], 20); + let goto_targets = receive_messages( + session_id, + vec![response_message( + 5, + "gotoTargets", + json!({"targets": [{"id": 31, "label": "Main.java:20", "line": 20}]}), + )], + ); + assert!(goto_targets.events.iter().any(|event| matches!( + &event.body, + DebugEventBody::OperationCompleted { + operation_id, + result: DebugOperationResult::GotoTargets { targets } + } if operation_id == "goto-targets" + && targets.first().map(|target| target.id) == Some(31) + ))); + let goto = execute(ExecuteRequest { + session_id: session_id.to_string(), + operation_id: "goto".to_string(), + command: DebugExecutionCommand::Goto, + thread_id: Some(11), + target_id: Some(31), + single_thread: false, + }) + .unwrap(); + let request = decode_frame(&goto.outbound_frames[0]); + assert_eq!(request["command"], "goto"); + assert_eq!(request["arguments"]["targetId"], 31); + destroy_session(SessionRequest { + session_id: session_id.to_string(), + }) + .unwrap(); + } + + #[test] + fn exception_information_is_capability_gated_and_normalized() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/fixtures/debug/exception-info-v1.json" + ))) + .unwrap(); + let session_id = "debug-exception-info"; + create_session(CreateSessionRequest { + session_id: session_id.to_string(), + adapter_id: "java".to_string(), + root_path: "/workspace".to_string(), + supports_run_in_terminal_request: false, + }) + .unwrap(); + launch(LaunchRequest { + session_id: session_id.to_string(), + operation_id: "launch".to_string(), + configuration: DebugLaunchConfiguration { + name: "Main".to_string(), + request: DebugRequestKind::Launch, + arguments: Map::new(), + stepping_filters: None, + }, + }) + .unwrap(); + let initialized = receive_messages( + session_id, + vec![response_message( + 1, + "initialize", + json!({"supportsExceptionInfoRequest": true}), + )], + ); + assert!(initialized.events.iter().any(|event| matches!( + &event.body, + DebugEventBody::Capabilities { capabilities } + if capabilities.supports_exception_info_request + ))); + receive_messages(session_id, vec![response_message(2, "launch", json!({}))]); + receive_messages( + session_id, + vec![json!({ + "seq": 103, + "type": "event", + "event": "stopped", + "body": {"reason": "exception", "threadId": fixture["request"]["threadId"]} + })], + ); + + let inspection = inspect(InspectRequest { + session_id: session_id.to_string(), + operation_id: fixture["request"]["operationId"] + .as_str() + .unwrap() + .to_string(), + kind: DebugInspectKind::ExceptionInfo, + thread_id: fixture["request"]["threadId"].as_i64(), + frame_id: None, + variables_reference: None, + variable_filter: None, + start: None, + count: None, + expression: None, + source_path: None, + line: None, + column: None, + }) + .unwrap(); + let request = decode_frame(&inspection.outbound_frames[0]); + assert_eq!(request["command"], "exceptionInfo"); + assert_eq!( + request["arguments"]["threadId"], + fixture["request"]["threadId"] + ); + + let completed = receive_messages( + session_id, + vec![response_message( + 3, + "exceptionInfo", + fixture["adapterResponse"].clone(), + )], + ); + let result = completed.events.iter().find_map(|event| match &event.body { + DebugEventBody::OperationCompleted { + operation_id, + result, + } if operation_id == fixture["request"]["operationId"].as_str().unwrap() => { + Some(result) + } + _ => None, + }); + assert_eq!( + serde_json::to_value(result.unwrap()).unwrap(), + fixture["expected"] + ); + destroy_session(SessionRequest { + session_id: session_id.to_string(), + }) + .unwrap(); + } + + #[test] + fn variable_paging_is_forwarded_and_normalized_from_shared_fixture() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/fixtures/debug/variable-paging-v1.json" + ))) + .unwrap(); + let session_id = "debug-variable-paging"; + create_session(CreateSessionRequest { + session_id: session_id.to_string(), + adapter_id: "java".to_string(), + root_path: "/workspace".to_string(), + supports_run_in_terminal_request: false, + }) + .unwrap(); + launch(LaunchRequest { + session_id: session_id.to_string(), + operation_id: "launch".to_string(), + configuration: DebugLaunchConfiguration { + name: "Main".to_string(), + request: DebugRequestKind::Launch, + arguments: Map::new(), + stepping_filters: None, + }, + }) + .unwrap(); + receive_messages( + session_id, + vec![response_message(1, "initialize", json!({}))], + ); + receive_messages(session_id, vec![response_message(2, "launch", json!({}))]); + receive_messages( + session_id, + vec![json!({ + "seq": 103, + "type": "event", + "event": "stopped", + "body": {"reason": "breakpoint", "threadId": 11} + })], + ); + + let inspection = inspect(InspectRequest { + session_id: session_id.to_string(), + operation_id: fixture["request"]["operationId"] + .as_str() + .unwrap() + .to_string(), + kind: DebugInspectKind::Variables, + thread_id: None, + frame_id: None, + variables_reference: fixture["request"]["variablesReference"].as_i64(), + variable_filter: serde_json::from_value(fixture["request"]["variableFilter"].clone()) + .unwrap(), + start: fixture["request"]["start"].as_i64(), + count: fixture["request"]["count"].as_i64(), + expression: None, + source_path: None, + line: None, + column: None, + }) + .unwrap(); + let request = decode_frame(&inspection.outbound_frames[0]); + assert_eq!(request["command"], "variables"); + assert_eq!(request["arguments"]["variablesReference"], 700); + assert_eq!(request["arguments"]["filter"], "indexed"); + assert_eq!(request["arguments"]["start"], 100); + assert_eq!(request["arguments"]["count"], 2); + + let completed = receive_messages( + session_id, + vec![response_message( + 3, + "variables", + fixture["adapterResponse"].clone(), + )], + ); + let result = completed.events.iter().find_map(|event| match &event.body { + DebugEventBody::OperationCompleted { + operation_id, + result, + } if operation_id == fixture["request"]["operationId"].as_str().unwrap() => { + Some(result) + } + _ => None, + }); + assert_eq!( + serde_json::to_value(result.unwrap()).unwrap(), + fixture["expected"] + ); + destroy_session(SessionRequest { + session_id: session_id.to_string(), + }) + .unwrap(); + } + + #[test] + fn exception_information_is_rejected_when_the_adapter_does_not_support_it() { + let session_id = "debug-exception-info-unsupported"; + create_session(CreateSessionRequest { + session_id: session_id.to_string(), + adapter_id: "java".to_string(), + root_path: "/workspace".to_string(), + supports_run_in_terminal_request: false, + }) + .unwrap(); + launch(LaunchRequest { + session_id: session_id.to_string(), + operation_id: "launch".to_string(), + configuration: DebugLaunchConfiguration { + name: "Main".to_string(), + request: DebugRequestKind::Launch, + arguments: Map::new(), + stepping_filters: None, + }, + }) + .unwrap(); + receive_messages( + session_id, + vec![response_message(1, "initialize", json!({}))], + ); + receive_messages(session_id, vec![response_message(2, "launch", json!({}))]); + receive_messages( + session_id, + vec![json!({ + "seq": 103, + "type": "event", + "event": "stopped", + "body": {"reason": "exception", "threadId": 13} + })], + ); + + let error = inspect(InspectRequest { + session_id: session_id.to_string(), + operation_id: "exception-main".to_string(), + kind: DebugInspectKind::ExceptionInfo, + thread_id: Some(13), + frame_id: None, + variables_reference: None, + variable_filter: None, + start: None, + count: None, + expression: None, + source_path: None, + line: None, + column: None, + }) + .unwrap_err(); + assert!(matches!(error.code, ErrorCode::InvalidRequest)); + destroy_session(SessionRequest { + session_id: session_id.to_string(), + }) + .unwrap(); + } + + #[test] + fn run_in_terminal_request_is_normalized_completed_and_stale_safe() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/fixtures/debug/run-in-terminal-v1.json" + ))) + .unwrap(); + let session_id = "debug-run-in-terminal"; + let created = create_session(CreateSessionRequest { + session_id: session_id.to_string(), + adapter_id: "java".to_string(), + root_path: "/workspace".to_string(), + supports_run_in_terminal_request: true, + }) + .unwrap(); + assert_eq!( + decode_frame(&created.outbound_frames[0])["arguments"]["supportsRunInTerminalRequest"], + true + ); + + let requested = receive_messages(session_id, vec![fixture["adapterRequest"].clone()]); + assert!(requested.outbound_frames.is_empty()); + let request_id = requested + .events + .iter() + .find_map(|event| match &event.body { + DebugEventBody::RunInTerminalRequested { + request_id, + request, + } => { + assert_eq!( + serde_json::to_value(request).unwrap(), + fixture["expectedRequest"] + ); + Some(request_id.clone()) + } + _ => None, + }) + .unwrap(); + + let duplicate = receive_messages(session_id, vec![fixture["adapterRequest"].clone()]); + assert!(duplicate.outbound_frames.is_empty()); + assert!(duplicate.events.is_empty()); + + let invalid = run_in_terminal_response(DebugRunInTerminalResponseRequest { + session_id: session_id.to_string(), + request_id: request_id.clone(), + success: true, + process_id: Some(0), + shell_process_id: None, + message: None, + }) + .unwrap_err(); + assert!(matches!(invalid.code, ErrorCode::InvalidRequest)); + + let success = run_in_terminal_response(DebugRunInTerminalResponseRequest { + session_id: session_id.to_string(), + request_id: request_id.clone(), + success: true, + process_id: fixture["successResponse"]["processId"].as_i64(), + shell_process_id: fixture["successResponse"]["shellProcessId"].as_i64(), + message: None, + }) + .unwrap(); + let response = decode_frame(&success.outbound_frames[0]); + assert_eq!(response["request_seq"], fixture["adapterRequest"]["seq"]); + assert_eq!(response["success"], true); + assert_eq!(response["body"], fixture["expectedSuccessBody"]); + + let stale = run_in_terminal_response(DebugRunInTerminalResponseRequest { + session_id: session_id.to_string(), + request_id, + success: false, + process_id: None, + shell_process_id: None, + message: Some(fixture["failureMessage"].as_str().unwrap().to_string()), + }) + .unwrap(); + assert!(stale.outbound_frames.is_empty()); + destroy_session(SessionRequest { + session_id: session_id.to_string(), + }) + .unwrap(); + } + + #[test] + fn malformed_run_in_terminal_request_gets_an_explicit_failure_response() { + let session_id = "debug-run-in-terminal-invalid"; + create_session(CreateSessionRequest { + session_id: session_id.to_string(), + adapter_id: "java".to_string(), + root_path: "/workspace".to_string(), + supports_run_in_terminal_request: true, + }) + .unwrap(); + + let update = receive_messages( + session_id, + vec![request_message( + 45, + "runInTerminal", + json!({"cwd": "/workspace", "args": []}), + )], + ); + + let response = decode_frame(&update.outbound_frames[0]); + assert_eq!(response["request_seq"], 45); + assert_eq!(response["success"], false); + assert!(response["message"] + .as_str() + .unwrap() + .contains("between 1 and 4096")); + destroy_session(SessionRequest { + session_id: session_id.to_string(), + }) + .unwrap(); + } + + #[test] + fn unknown_server_request_gets_an_explicit_failure_response() { + let session_id = "debug-server-request"; + create_session(CreateSessionRequest { + session_id: session_id.to_string(), + adapter_id: "java".to_string(), + root_path: "/workspace".to_string(), + supports_run_in_terminal_request: false, + }) + .unwrap(); + + let update = receive_messages( + session_id, + vec![request_message(44, "runInTerminal", json!({}))], + ); + + let response = decode_frame(&update.outbound_frames[0]); + assert_eq!(response["request_seq"], 44); + assert_eq!(response["success"], false); + destroy_session(SessionRequest { + session_id: session_id.to_string(), + }) + .unwrap(); + } +} diff --git a/rust/lithe-core/src/debug/java_test.rs b/rust/lithe-core/src/debug/java_test.rs new file mode 100644 index 000000000..9723ab56a --- /dev/null +++ b/rust/lithe-core/src/debug/java_test.rs @@ -0,0 +1,291 @@ +//! Deterministic Java test launch configuration shared by native products. + +use std::collections::HashSet; + +use serde::Deserialize; +use serde_json::{Map, Value}; + +use crate::protocol::{CoreError, ErrorCode}; + +use super::{DebugLaunchConfiguration, DebugRequestKind}; + +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Java test framework whose runner arguments must be projected into DAP. +pub enum JavaTestFramework { + Junit, + Testng, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Platform-observed JDT LS launch metadata plus one loopback result port. +pub struct JavaTestLaunchRequest { + pub name: String, + pub framework: JavaTestFramework, + pub working_directory: String, + pub main_class: String, + #[serde(default)] + pub project_name: Option, + #[serde(default)] + pub class_paths: Vec, + #[serde(default)] + pub module_paths: Vec, + #[serde(default)] + pub vm_arguments: Vec, + #[serde(default)] + pub program_arguments: Vec, + pub result_port: u16, + #[serde(default)] + pub testng_runner_path: Option, + #[serde(default)] + pub testng_test_names: Vec, +} + +/// Creates the provider arguments consumed by Java Debug Server. +pub fn java_test_launch( + request: JavaTestLaunchRequest, +) -> Result { + let name = required(request.name, "Java test launch name is required.")?; + let working_directory = required( + request.working_directory, + "Java test working directory is required.", + )?; + let main_class = required(request.main_class, "Java test main class is required.")?; + if request.result_port == 0 { + return Err(invalid_request( + "Java test result port must be between 1 and 65535.", + )); + } + + let mut class_paths = non_empty_unique(request.class_paths); + let module_paths = non_empty_unique(request.module_paths); + let vm_arguments = non_empty(request.vm_arguments); + let mut arguments = Map::new(); + arguments.insert("mainClass".to_string(), Value::String(main_class)); + arguments.insert("cwd".to_string(), Value::String(working_directory)); + arguments.insert( + "console".to_string(), + Value::String("integratedTerminal".to_string()), + ); + if let Some(project_name) = optional_non_empty(request.project_name) { + arguments.insert("projectName".to_string(), Value::String(project_name)); + } + + let program_arguments = match request.framework { + JavaTestFramework::Junit => junit_arguments(request.program_arguments, request.result_port), + JavaTestFramework::Testng => { + let runner_path = required( + request.testng_runner_path.unwrap_or_default(), + "The Java TestNG runner is unavailable.", + )?; + if !class_paths.iter().any(|path| path == &runner_path) { + class_paths.push(runner_path); + } + let test_names = non_empty_unique(request.testng_test_names); + if test_names.is_empty() { + return Err(invalid_request( + "At least one TestNG test method is required.", + )); + } + std::iter::once(request.result_port.to_string()) + .chain(std::iter::once("testng".to_string())) + .chain(test_names) + .collect() + } + }; + + insert_string_array(&mut arguments, "classPaths", class_paths); + insert_string_array(&mut arguments, "modulePaths", module_paths); + insert_java_debug_arguments(&mut arguments, "args", program_arguments); + insert_java_debug_arguments(&mut arguments, "vmArgs", vm_arguments); + + Ok(DebugLaunchConfiguration { + name, + request: DebugRequestKind::Launch, + arguments, + stepping_filters: None, + }) +} + +fn junit_arguments(arguments: Vec, result_port: u16) -> Vec { + let mut arguments = arguments; + let port = result_port.to_string(); + if let Some(index) = arguments.iter().rposition(|value| value == "-port") { + if index + 1 < arguments.len() { + arguments[index + 1] = port; + return arguments; + } + } + arguments.push("-port".to_string()); + arguments.push(port); + arguments +} + +fn required(value: String, message: &str) -> Result { + let value = value.trim().to_string(); + if value.is_empty() { + Err(invalid_request(message)) + } else { + Ok(value) + } +} + +fn optional_non_empty(value: Option) -> Option { + value.and_then(|value| { + let value = value.trim().to_string(); + (!value.is_empty()).then_some(value) + }) +} + +fn non_empty(values: Vec) -> Vec { + values + .into_iter() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .collect() +} + +fn non_empty_unique(values: Vec) -> Vec { + let mut seen = HashSet::new(); + non_empty(values) + .into_iter() + .filter(|value| seen.insert(value.clone())) + .collect() +} + +fn insert_string_array(arguments: &mut Map, key: &str, values: Vec) { + if values.is_empty() { + return; + } + arguments.insert( + key.to_string(), + Value::Array(values.into_iter().map(Value::String).collect()), + ); +} + +fn insert_java_debug_arguments(arguments: &mut Map, key: &str, values: Vec) { + let value = java_debug_argument_string(values); + if !value.is_empty() { + arguments.insert(key.to_string(), Value::String(value)); + } +} + +fn java_debug_argument_string(values: Vec) -> String { + // Java Test exposes arrays to VS Code, but Java Debug Server's DAP model + // accepts one command-line string. Mirror the upstream extension's + // serialization so the adapter can reconstruct spaces, quotes, and paths. + non_empty(values) + .into_iter() + .map(|value| { + if value + .chars() + .any(|character| character == '"' || character.is_whitespace()) + { + let escaped = value.chars().fold(String::new(), |mut result, character| { + if matches!(character, '"' | '\\') { + result.push('\\'); + } + result.push(character); + result + }); + format!("\"{escaped}\"") + } else { + value + } + }) + .collect::>() + .join(" ") +} + +fn invalid_request(message: &str) -> CoreError { + CoreError::new(ErrorCode::InvalidRequest, message) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn junit_launch_replaces_the_server_placeholder_port() { + let configuration = java_test_launch(JavaTestLaunchRequest { + name: "UserServiceTest".to_string(), + framework: JavaTestFramework::Junit, + working_directory: "/workspace".to_string(), + main_class: "org.eclipse.jdt.internal.junit.runner.RemoteTestRunner".to_string(), + project_name: Some("service".to_string()), + class_paths: vec!["/workspace/classes".to_string()], + module_paths: Vec::new(), + vm_arguments: vec!["--enable-preview".to_string()], + program_arguments: vec![ + "-version".to_string(), + "3".to_string(), + "-port".to_string(), + "-1".to_string(), + ], + result_port: 43127, + testng_runner_path: None, + testng_test_names: Vec::new(), + }) + .expect("JUnit launch should resolve"); + + assert_eq!( + configuration.arguments["args"], + json!("-version 3 -port 43127") + ); + assert_eq!( + configuration.arguments["classPaths"], + json!(["/workspace/classes"]) + ); + assert_eq!(configuration.arguments["vmArgs"], json!("--enable-preview")); + } + + #[test] + fn testng_launch_appends_the_packaged_runner_once() { + let configuration = java_test_launch(JavaTestLaunchRequest { + name: "UserServiceTest".to_string(), + framework: JavaTestFramework::Testng, + working_directory: "/workspace".to_string(), + main_class: "com.microsoft.java.test.runner.Launcher".to_string(), + project_name: Some("service".to_string()), + class_paths: vec![ + "/workspace/classes".to_string(), + "/lithe/java-test-runner.jar".to_string(), + ], + module_paths: Vec::new(), + vm_arguments: Vec::new(), + program_arguments: Vec::new(), + result_port: 43128, + testng_runner_path: Some("/lithe/java-test-runner.jar".to_string()), + testng_test_names: vec![ + "example.UserServiceTest#logsIn".to_string(), + "example.UserServiceTest#logsIn".to_string(), + ], + }) + .expect("TestNG launch should resolve"); + + assert_eq!( + configuration.arguments["classPaths"], + json!(["/workspace/classes", "/lithe/java-test-runner.jar"]) + ); + assert_eq!( + configuration.arguments["args"], + json!("43128 testng example.UserServiceTest#logsIn") + ); + } + + #[test] + fn java_debug_arguments_match_the_adapter_command_line_contract() { + assert_eq!( + java_debug_argument_string(vec![ + "-Dlabel=hello world".to_string(), + "say\"hello".to_string(), + r"C:\plain".to_string(), + r"C:\Program Files\Java".to_string(), + ]), + r#""-Dlabel=hello world" "say\"hello" C:\plain "C:\\Program Files\\Java""# + ); + } +} diff --git a/rust/lithe-core/src/debug/mod.rs b/rust/lithe-core/src/debug/mod.rs new file mode 100644 index 000000000..ccbc648b7 --- /dev/null +++ b/rust/lithe-core/src/debug/mod.rs @@ -0,0 +1,12 @@ +//! Transport-neutral Debug Adapter Protocol state and normalized debugger models. + +mod breakpoint_relocation; +mod engine; +mod java_test; +mod protocol; +mod types; + +pub(crate) use breakpoint_relocation::*; +pub(crate) use engine::*; +pub(crate) use java_test::*; +pub(crate) use types::*; diff --git a/rust/lithe-core/src/debug/protocol.rs b/rust/lithe-core/src/debug/protocol.rs new file mode 100644 index 000000000..64dd12ad7 --- /dev/null +++ b/rust/lithe-core/src/debug/protocol.rs @@ -0,0 +1,105 @@ +//! Bounded DAP framing and JSON message helpers independent of native transport. + +use crate::protocol::{CoreError, ErrorCode}; +use serde_json::Value; + +const MAX_HEADER_BYTES: usize = 64 * 1024; +const MAX_MESSAGE_BYTES: usize = 64 * 1024 * 1024; + +pub(crate) fn frame_message(message: &Value) -> Result, CoreError> { + let body = serde_json::to_vec(message).map_err(|error| { + CoreError::new(ErrorCode::ParseFailed, "Could not encode DAP message.") + .with_details(error.to_string()) + })?; + let mut frame = format!("Content-Length: {}\r\n\r\n", body.len()).into_bytes(); + frame.extend(body); + Ok(frame) +} + +pub(crate) fn parse_messages(buffer: &mut Vec, chunk: &[u8]) -> Result, CoreError> { + buffer.extend_from_slice(chunk); + let mut messages = Vec::new(); + loop { + let Some(header_end) = buffer.windows(4).position(|window| window == b"\r\n\r\n") else { + if buffer.len() > MAX_HEADER_BYTES { + return Err(protocol_error("DAP header exceeded the maximum size.")); + } + break; + }; + if header_end > MAX_HEADER_BYTES { + return Err(protocol_error("DAP header exceeded the maximum size.")); + } + let header = String::from_utf8_lossy(&buffer[..header_end]); + let content_length = content_length(&header)?; + if content_length > MAX_MESSAGE_BYTES { + return Err(protocol_error("DAP message exceeded the maximum size.")); + } + let body_start = header_end + 4; + let body_end = body_start + .checked_add(content_length) + .ok_or_else(|| protocol_error("DAP Content-Length overflowed."))?; + if buffer.len() < body_end { + break; + } + let body = &buffer[body_start..body_end]; + let message = serde_json::from_slice(body).map_err(|error| { + protocol_error("DAP message body was not valid JSON.").with_details(error.to_string()) + })?; + messages.push(message); + buffer.drain(..body_end); + } + Ok(messages) +} + +fn content_length(header: &str) -> Result { + let value = header.lines().find_map(|line| { + let (name, value) = line.split_once(':')?; + name.trim() + .eq_ignore_ascii_case("content-length") + .then_some(value.trim()) + }); + value + .ok_or_else(|| protocol_error("DAP frame did not contain Content-Length."))? + .parse::() + .map_err(|error| { + protocol_error("DAP Content-Length was not a valid non-negative integer.") + .with_details(error.to_string()) + }) +} + +fn protocol_error(message: &str) -> CoreError { + CoreError::new(ErrorCode::ParseFailed, message) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn partial_and_consecutive_frames_are_parsed_in_order() { + let first = frame_message(&json!({"type": "event", "event": "initialized"})).unwrap(); + let second = frame_message(&json!({"type": "event", "event": "terminated"})).unwrap(); + let split = first.len() / 2; + let mut buffer = Vec::new(); + assert!(parse_messages(&mut buffer, &first[..split]) + .unwrap() + .is_empty()); + let mut remainder = first[split..].to_vec(); + remainder.extend(second); + + let messages = parse_messages(&mut buffer, &remainder).unwrap(); + + assert_eq!(messages.len(), 2); + assert_eq!(messages[0]["event"], "initialized"); + assert_eq!(messages[1]["event"], "terminated"); + assert!(buffer.is_empty()); + } + + #[test] + fn malformed_content_length_is_rejected() { + let mut buffer = Vec::new(); + let result = parse_messages(&mut buffer, b"Content-Length: nope\r\n\r\n{}"); + assert!(result.is_err()); + } +} diff --git a/rust/lithe-core/src/debug/types.rs b/rust/lithe-core/src/debug/types.rs new file mode 100644 index 000000000..875203862 --- /dev/null +++ b/rust/lithe-core/src/debug/types.rs @@ -0,0 +1,756 @@ +//! Stable requests, updates, events, and inspection results for shared debugging. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +/// Lifecycle state reduced from DAP requests, responses, and events. +pub enum DebugSessionState { + Idle, + Initializing, + Ready, + Launching, + Running, + Paused, + Terminating, + Terminated, + Failed, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Creates a protocol session without opening a socket or starting a process. +pub struct CreateSessionRequest { + pub session_id: String, + pub adapter_id: String, + pub root_path: String, + /// Whether the native host can launch adapter-requested processes in a terminal. + #[serde(default)] + pub supports_run_in_terminal_request: bool, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Identifies one existing debug session. +pub struct SessionRequest { + pub session_id: String, +} + +#[derive(Debug, Clone, Copy, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +/// DAP request used to begin a debuggee session. +pub enum DebugRequestKind { + Launch, + Attach, +} + +impl DebugRequestKind { + pub(crate) fn command(self) -> &'static str { + match self { + Self::Launch => "launch", + Self::Attach => "attach", + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +/// Provider-specific launch arguments wrapped in a language-neutral contract. +pub struct DebugLaunchConfiguration { + pub name: String, + pub request: DebugRequestKind, + #[serde(default)] + pub arguments: serde_json::Map, + /// Optional portable stepping policy projected into adapter-specific launch arguments. + #[serde(default)] + pub stepping_filters: Option, +} + +#[derive(Debug, Clone, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +/// Portable class and method filters used for stepping and stack-frame presentation. +pub struct DebugSteppingFilters { + /// Adapter-neutral class-name patterns; Java also accepts `$JDK` and `$Libraries`. + #[serde(default)] + pub class_name_filters: Vec, + /// Whether compiler-generated methods should be skipped. + #[serde(default)] + pub skip_synthetics: bool, + /// Whether class static initializers should be skipped. + #[serde(default)] + pub skip_static_initializers: bool, + /// Whether constructors should be skipped. + #[serde(default)] + pub skip_constructors: bool, + /// Whether native clients should collapse stack frames matched by the class filters. + #[serde(default)] + pub hide_filtered_stack_frames: bool, +} + +impl DebugSteppingFilters { + pub(crate) fn defaults_for_adapter(adapter_id: &str) -> Self { + if adapter_id == "java" { + Self::default() + } else { + Self::unfiltered() + } + } + + pub(crate) fn unfiltered() -> Self { + Self { + class_name_filters: Vec::new(), + skip_synthetics: false, + skip_static_initializers: false, + skip_constructors: false, + hide_filtered_stack_frames: false, + } + } +} + +impl Default for DebugSteppingFilters { + fn default() -> Self { + Self { + class_name_filters: default_java_class_name_filters(), + skip_synthetics: true, + skip_static_initializers: true, + // IDEA leaves constructor skipping off by default because application + // initialization is often meaningful user code. + skip_constructors: false, + hide_filtered_stack_frames: true, + } + } +} + +fn default_java_class_name_filters() -> Vec { + [ + "$JDK", + "$Libraries", + "com.ibm.ws.*", + "com.springsource.loaded.*", + "com.sun.proxy.*", + "javassist.*", + "jdk.proxy*.*", + "junit.*", + "net.bytebuddy.*", + "net.sf.cglib.*", + "org.apache.webbeans.*", + "org.junit.*", + "org.mockito.*", + "org.springframework.aop.framework.*", + "org.springframework.cglib.*", + "org.springsource.loaded.*", + ] + .into_iter() + .map(str::to_string) + .collect() +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Returns an adapter's defaults or normalizes a native client's stepping policy. +pub struct DebugSteppingFiltersRequest { + /// Stable adapter identifier, such as `java`. + pub adapter_id: String, + /// Optional client override; omission requests the adapter defaults. + #[serde(default)] + pub filters: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Queues launch or attach, waiting for initialization when necessary. +pub struct LaunchRequest { + pub session_id: String, + pub operation_id: String, + pub configuration: DebugLaunchConfiguration, +} + +#[derive(Debug, Clone, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +/// One requested source breakpoint using one-based DAP coordinates. +pub struct SourceBreakpoint { + pub line: i64, + #[serde(default)] + pub column: Option, + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default)] + pub condition: Option, + #[serde(default)] + pub hit_condition: Option, + #[serde(default)] + pub log_message: Option, +} + +fn default_true() -> bool { + true +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Replaces the complete breakpoint set for one absolute source path. +pub struct SetBreakpointsRequest { + pub session_id: String, + pub source_path: String, + #[serde(default)] + pub breakpoints: Vec, +} + +#[derive(Debug, Clone, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +/// One adapter-defined exception filter and its optional exception condition. +pub struct ExceptionBreakpoint { + pub filter: String, + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default)] + pub condition: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Replaces the complete exception breakpoint selection for one session. +pub struct SetExceptionBreakpointsRequest { + pub session_id: String, + #[serde(default)] + pub breakpoints: Vec, +} + +#[derive(Debug, Clone, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +/// One named function or method breakpoint understood by the active adapter. +pub struct FunctionBreakpoint { + pub name: String, + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default)] + pub condition: Option, + #[serde(default)] + pub hit_condition: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Replaces the complete function breakpoint set for one session. +pub struct SetFunctionBreakpointsRequest { + pub session_id: String, + #[serde(default)] + pub breakpoints: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Resolves an adapter-owned data identifier for one visible variable or expression. +pub struct DataBreakpointInfoRequest { + pub session_id: String, + pub operation_id: String, + pub name: String, + #[serde(default)] + pub variables_reference: Option, + #[serde(default)] + pub frame_id: Option, +} + +#[derive(Debug, Clone, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +/// One adapter-resolved field or data breakpoint. +pub struct DataBreakpoint { + pub data_id: String, + #[serde(default)] + pub label: Option, + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default)] + pub access_type: Option, + #[serde(default)] + pub condition: Option, + #[serde(default)] + pub hit_condition: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Replaces the complete adapter-resolved data breakpoint set for one session. +pub struct SetDataBreakpointsRequest { + pub session_id: String, + #[serde(default)] + pub breakpoints: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Replaces one visible variable value in its adapter-owned parent container. +pub struct SetVariableRequest { + pub session_id: String, + pub operation_id: String, + pub variables_reference: i64, + pub name: String, + pub value: String, +} + +#[derive(Debug, Clone, Copy, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +/// Why a native host ended one pending debug operation. +pub enum DebugCancellationReason { + Cancelled, + TimedOut, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Ends one pending operation and optionally forwards DAP cancellation. +pub struct CancelOperationRequest { + pub session_id: String, + pub operation_id: String, + pub reason: DebugCancellationReason, +} + +#[derive(Debug, Clone, Copy, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +/// Supported execution controls shared by every DAP provider. +pub enum DebugExecutionCommand { + Continue, + Pause, + Next, + StepIn, + StepOut, + StepBack, + Goto, + Restart, + Terminate, +} + +impl DebugExecutionCommand { + pub(crate) fn command(self) -> &'static str { + match self { + Self::Continue => "continue", + Self::Pause => "pause", + Self::Next => "next", + Self::StepIn => "stepIn", + Self::StepOut => "stepOut", + Self::StepBack => "stepBack", + Self::Goto => "goto", + Self::Restart => "restart", + Self::Terminate => "terminate", + } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Queues a control request and correlates its eventual result to an operation. +pub struct ExecuteRequest { + pub session_id: String, + pub operation_id: String, + pub command: DebugExecutionCommand, + #[serde(default)] + pub thread_id: Option, + #[serde(default)] + pub target_id: Option, + #[serde(default)] + pub single_thread: bool, +} + +#[derive(Debug, Clone, Copy, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +/// Normalized debugger data requests supported by the shared UI contract. +pub enum DebugInspectKind { + Threads, + StackTrace, + Scopes, + Variables, + Evaluate, + ExceptionInfo, + StepInTargets, + GotoTargets, +} + +impl DebugInspectKind { + pub(crate) fn command(self) -> &'static str { + match self { + Self::Threads => "threads", + Self::StackTrace => "stackTrace", + Self::Scopes => "scopes", + Self::Variables => "variables", + Self::Evaluate => "evaluate", + Self::ExceptionInfo => "exceptionInfo", + Self::StepInTargets => "stepInTargets", + Self::GotoTargets => "gotoTargets", + } + } +} + +#[derive(Debug, Clone, Copy, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +/// Selects the named or indexed child collection of a debugger variable. +pub enum DebugVariableFilter { + Named, + Indexed, +} + +impl DebugVariableFilter { + pub(crate) fn argument(self) -> &'static str { + match self { + Self::Named => "named", + Self::Indexed => "indexed", + } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Parameters for one thread, frame, variable, or expression inspection. +pub struct InspectRequest { + pub session_id: String, + pub operation_id: String, + pub kind: DebugInspectKind, + #[serde(default)] + pub thread_id: Option, + #[serde(default)] + pub frame_id: Option, + #[serde(default)] + pub variables_reference: Option, + #[serde(default)] + pub variable_filter: Option, + /// Zero-based child offset for a paged variables request. + #[serde(default)] + pub start: Option, + /// Maximum child count for a paged variables request. + #[serde(default)] + pub count: Option, + #[serde(default)] + pub expression: Option, + #[serde(default)] + pub source_path: Option, + #[serde(default)] + pub line: Option, + #[serde(default)] + pub column: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Base64-encoded bytes received from a platform-owned DAP transport. +pub struct ReceiveRequest { + pub session_id: String, + pub data_base64: String, +} + +#[derive(Debug, Clone, Copy, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +/// Terminal surface requested by a Debug Adapter Protocol reverse request. +pub enum DebugRunInTerminalKind { + Integrated, + External, +} + +#[derive(Debug, Clone, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +/// One deterministic environment mutation requested for a debuggee process. +pub struct DebugRunInTerminalEnvironmentVariable { + pub name: String, + /// A missing value removes the inherited variable from the child environment. + pub value: Option, +} + +#[derive(Debug, Clone, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +/// Normalized platform request for launching a debuggee inside a terminal. +pub struct DebugRunInTerminalRequest { + pub kind: DebugRunInTerminalKind, + pub title: Option, + pub cwd: String, + /// The executable is the first item; remaining items are passed without a shell. + pub args: Vec, + pub environment: Vec, + /// True only when the adapter intentionally supplied shell-language arguments. + pub args_can_be_interpreted_by_shell: bool, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Completes one pending `runInTerminal` reverse request from the native host. +pub struct DebugRunInTerminalResponseRequest { + pub session_id: String, + pub request_id: String, + pub success: bool, + #[serde(default)] + pub process_id: Option, + #[serde(default)] + pub shell_process_id: Option, + #[serde(default)] + pub message: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Effects produced by one deterministic session reduction. +pub struct DebugSessionUpdate { + pub session_id: String, + pub state: DebugSessionState, + /// Complete framed byte sequences, base64 encoded in send order. + pub outbound_frames: Vec, + pub events: Vec, +} + +#[derive(Debug, Clone, Default, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +/// Adapter abilities negotiated by the DAP initialize response. +pub struct DebugCapabilities { + pub supports_configuration_done: bool, + pub supports_conditional_breakpoints: bool, + pub supports_hit_conditional_breakpoints: bool, + pub supports_log_points: bool, + pub supports_function_breakpoints: bool, + pub supports_data_breakpoints: bool, + pub supports_exception_options: bool, + pub supports_exception_filter_options: bool, + pub supports_set_variable: bool, + pub supports_cancel_request: bool, + pub supports_single_thread_execution_requests: bool, + pub supports_restart_request: bool, + pub supports_terminate_request: bool, + pub supports_step_back: bool, + pub supports_exception_info_request: bool, + pub supports_step_in_targets_request: bool, + pub supports_goto_targets_request: bool, + pub exception_breakpoint_filters: Vec, +} + +#[derive(Debug, Clone, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +/// One adapter-defined exception category presented by native clients. +pub struct DebugExceptionBreakpointFilter { + pub filter: String, + pub label: String, + pub description: Option, + pub default: bool, + pub supports_condition: bool, + pub condition_description: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Ordered event projected to platform feature models. +pub struct DebugEvent { + pub sequence: u64, + #[serde(flatten)] + pub body: DebugEventBody, +} + +#[derive(Debug, Clone, Serialize)] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +/// Provider-neutral lifecycle, output, breakpoint, and request result events. +pub enum DebugEventBody { + StateChanged { + state: DebugSessionState, + }, + Initialized, + Capabilities { + capabilities: DebugCapabilities, + }, + Output { + category: Option, + output: String, + }, + Stopped { + reason: String, + thread_id: Option, + description: Option, + }, + Continued { + thread_id: Option, + }, + Terminated { + exit_code: Option, + }, + Breakpoint { + breakpoint: DebugBreakpoint, + }, + RunInTerminalRequested { + request_id: String, + request: DebugRunInTerminalRequest, + }, + OperationCompleted { + operation_id: String, + result: DebugOperationResult, + }, + OperationFailed { + operation_id: String, + command: String, + code: DebugOperationFailureCode, + message: String, + }, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +/// Stable reason for a terminal debug operation failure. +pub enum DebugOperationFailureCode { + AdapterRejected, + Cancelled, + TimedOut, +} + +#[derive(Debug, Clone, Serialize)] +#[serde( + tag = "kind", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +/// Typed terminal data for one caller-owned debug operation. +pub enum DebugOperationResult { + Acknowledged { + command: String, + }, + Threads { + threads: Vec, + }, + StackTrace { + stack_frames: Vec, + }, + Scopes { + scopes: Vec, + }, + Variables { + variables: Vec, + }, + Evaluate { + variable: DebugVariable, + }, + ExceptionInfo { + exception_info: DebugExceptionInfo, + }, + SetVariable { + variable: DebugVariable, + }, + DataBreakpointInfo { + data_id: Option, + description: String, + access_types: Vec, + can_persist: bool, + }, + StepInTargets { + targets: Vec, + }, + GotoTargets { + targets: Vec, + }, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// One adapter-selected call expression eligible for targeted step-in. +pub struct DebugStepInTarget { + pub id: i64, + pub label: String, + pub line: Option, + pub column: Option, + pub end_line: Option, + pub end_column: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// One executable location returned for a run-to-cursor request. +pub struct DebugGotoTarget { + pub id: i64, + pub label: String, + pub line: i64, + pub column: Option, + pub end_line: Option, + pub end_column: Option, + pub instruction_pointer_reference: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Adapter-verified breakpoint and its optional resolved source location. +pub struct DebugBreakpoint { + pub id: i64, + pub verified: bool, + pub message: Option, + /// Requested function name for a function-breakpoint verification result. + pub function_name: Option, + /// Adapter-owned identity for a data-breakpoint verification result. + pub data_id: Option, + pub source_path: Option, + pub line: Option, + pub column: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// One debuggee thread. +pub struct DebugThread { + pub id: i64, + pub name: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// One stack frame using one-based DAP source coordinates. +pub struct DebugStackFrame { + pub id: i64, + pub name: String, + pub source_path: Option, + pub line: i64, + pub column: i64, + /// True when the active portable stepping policy matches this frame. + pub is_filtered: bool, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// One variable scope associated with a selected stack frame. +pub struct DebugScope { + pub name: String, + pub variables_reference: i64, + pub expensive: bool, + /// Adapter-reported count of named children, or zero when unavailable. + pub named_variables: i64, + /// Adapter-reported count of indexed children, or zero when unavailable. + pub indexed_variables: i64, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// A debugger variable or evaluated expression result. +pub struct DebugVariable { + pub name: String, + pub value: String, + pub r#type: Option, + pub evaluate_name: Option, + pub variables_reference: i64, + /// Adapter-reported count of named children, or zero when unavailable. + pub named_variables: i64, + /// Adapter-reported count of indexed children, or zero when unavailable. + pub indexed_variables: i64, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Exception metadata returned for the thread that caused an exception pause. +pub struct DebugExceptionInfo { + pub exception_id: String, + pub description: Option, + pub break_mode: String, + pub details: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Optional adapter-provided exception detail tree. +pub struct DebugExceptionDetails { + pub message: Option, + pub type_name: Option, + pub full_type_name: Option, + pub evaluate_name: Option, + pub stack_trace: Option, + pub inner_exceptions: Vec, +} diff --git a/rust/lithe-core/src/lib.rs b/rust/lithe-core/src/lib.rs index a70c56691..981fbf6d4 100644 --- a/rust/lithe-core/src/lib.rs +++ b/rust/lithe-core/src/lib.rs @@ -3,6 +3,7 @@ use std::path::Path; mod community; +mod debug; mod execution; mod git; mod github; diff --git a/rust/lithe-core/src/lsp/interface/client.rs b/rust/lithe-core/src/lsp/interface/client.rs index da6f60a1e..ef8d5634e 100644 --- a/rust/lithe-core/src/lsp/interface/client.rs +++ b/rust/lithe-core/src/lsp/interface/client.rs @@ -851,7 +851,7 @@ fn lsp_feature_result_for_method(method: Option<&str>, result: Option<&Value>) - "action": action }) }), - Some("workspace/executeCommand") => Some(json!({ "ok": true })), + Some("workspace/executeCommand") => Some(json!({ "value": result })), Some("textDocument/definition") | Some("textDocument/declaration") | Some("textDocument/typeDefinition") diff --git a/rust/lithe-core/src/lsp/interface/engine.rs b/rust/lithe-core/src/lsp/interface/engine.rs index 5cc834bfb..07909e056 100644 --- a/rust/lithe-core/src/lsp/interface/engine.rs +++ b/rust/lithe-core/src/lsp/interface/engine.rs @@ -115,6 +115,15 @@ pub struct JdtlsLaunchResources { pub configuration_directory: String, /// Lombok agent shipped with the selected JDT LS installation. pub lombok_agent_path: String, + /// Legacy Java Debug Server bundle retained for older platform clients. + #[serde(default)] + pub java_debug_bundle_path: Option, + /// Ordered Java extension bundles loaded through JDT LS initialization. + /// + /// When the legacy Debug field is also present, Core loads it first and + /// removes duplicate paths while preserving the remaining caller order. + #[serde(default)] + pub java_extension_bundle_paths: Vec, } #[derive(Debug, Clone, Serialize)] @@ -756,6 +765,23 @@ impl LspEngine { .as_deref() .map(PathBuf::from) .or_else(|| java_executable_from_environment(&request.environment)); + let java_extension_bundle_paths = request + .jdtls_launch_resources + .as_ref() + .map(|resources| { + let mut paths = Vec::new(); + if let Some(path) = &resources.java_debug_bundle_path { + paths.push(PathBuf::from(path)); + } + for path in &resources.java_extension_bundle_paths { + let path = PathBuf::from(path); + if !paths.contains(&path) { + paths.push(path); + } + } + paths + }) + .unwrap_or_default(); let adaptation = adapt_start(&JdtStartContext { provider_id: request.provider_id.clone(), workspace_root: workspace_root.clone(), @@ -766,6 +792,10 @@ impl LspEngine { launcher_jar_path: PathBuf::from(&resources.launcher_jar_path), configuration_directory: PathBuf::from(&resources.configuration_directory), lombok_agent_path: PathBuf::from(&resources.lombok_agent_path), + java_debug_bundle_path: resources + .java_debug_bundle_path + .as_deref() + .map(PathBuf::from), } }), arguments: request.arguments.clone(), @@ -812,6 +842,7 @@ impl LspEngine { initialization_options: adapt_initialization_options( &request.provider_id, request.initialization_options, + &java_extension_bundle_paths, ), })?; let request_id = (initialize.state.next_request_id - 1).to_string(); @@ -2693,6 +2724,14 @@ fn validate_start_request(request: &StartServerRequest) -> Result<(), CoreError> || !is_valid_process_path(Some(&resources.launcher_jar_path)) || !is_valid_process_path(Some(&resources.configuration_directory)) || !is_valid_process_path(Some(&resources.lombok_agent_path)) + || resources + .java_debug_bundle_path + .as_deref() + .is_some_and(|path| !is_valid_process_path(Some(path))) + || resources + .java_extension_bundle_paths + .iter() + .any(|path| !is_valid_process_path(Some(path))) { return Err(invalid_field("jdtlsLaunchResources/runtimeExecutablePath")); } @@ -4674,6 +4713,16 @@ mod tests { launcher_jar_path: "/opt/lithe/jdtls/plugins/equinox.jar".to_string(), configuration_directory: "/opt/lithe/jdtls/config_mac".to_string(), lombok_agent_path: "/opt/lithe/jdtls/lombok/lombok.jar".to_string(), + java_debug_bundle_path: Some( + "/opt/lithe/jdtls/java-debug/com.microsoft.java.debug.plugin-0.53.1.jar" + .to_string(), + ), + java_extension_bundle_paths: vec![ + "/opt/lithe/jdtls/java-debug/com.microsoft.java.debug.plugin-0.53.1.jar" + .to_string(), + "/opt/lithe/jdtls/java-test/extensions/com.microsoft.java.test.plugin-0.42.0.jar" + .to_string(), + ], }); request.cache_directory = Some(cache.to_string_lossy().into_owned()); engine @@ -5237,8 +5286,20 @@ mod tests { let mut harness = Harness::start(|request| { request.provider_id = "java".to_string(); request.cache_directory = Some(cache.to_string_lossy().into_owned()); + request.runtime_executable_path = Some("/opt/lithe/jdk/bin/java".to_string()); + request.jdtls_launch_resources = Some(JdtlsLaunchResources { + launcher_jar_path: "/opt/lithe/jdtls/plugins/equinox.jar".to_string(), + configuration_directory: "/opt/lithe/jdtls/config_mac".to_string(), + lombok_agent_path: "/opt/lithe/jdtls/lombok/lombok.jar".to_string(), + java_debug_bundle_path: Some("/plugins/java-debug.jar".to_string()), + java_extension_bundle_paths: vec![ + "/plugins/java-debug.jar".to_string(), + "/plugins/java-test.jar".to_string(), + ], + }); request.initialization_options = Some(json!({ - "extendedClientCapabilities": { "customCapability": true } + "extendedClientCapabilities": { "customCapability": true }, + "bundles": ["/plugins/catalog.jar"] })); }); let initialize = harness @@ -5257,6 +5318,14 @@ mod tests { ["customCapability"], true ); + assert_eq!( + initialize["params"]["initializationOptions"]["bundles"], + json!([ + "/plugins/catalog.jar", + "/plugins/java-debug.jar", + "/plugins/java-test.jar" + ]) + ); harness .server @@ -5617,10 +5686,16 @@ public class Main { let source_path = source_directory.join("Main.java"); std::fs::write(&source_path, source).expect("smoke source should be written"); - let root_uri = url::Url::from_directory_path(&workspace) + let canonical_workspace = workspace + .canonicalize() + .expect("smoke workspace should canonicalize"); + let canonical_source_path = source_path + .canonicalize() + .expect("smoke source should canonicalize"); + let root_uri = url::Url::from_directory_path(&canonical_workspace) .expect("workspace should convert to a file URI") .to_string(); - let source_uri = url::Url::from_file_path(&source_path) + let source_uri = url::Url::from_file_path(&canonical_source_path) .expect("source should convert to a file URI") .to_string(); let engine = LspEngine::new(); @@ -6385,6 +6460,8 @@ public class Main { launcher_jar_path: "/jdtls/plugins/equinox.jar".to_string(), configuration_directory: "/jdtls/config_mac".to_string(), lombok_agent_path: "/jdtls/lombok/lombok.jar".to_string(), + java_debug_bundle_path: None, + java_extension_bundle_paths: Vec::new(), }); assert!(validate_start_request(&request).is_err()); @@ -6416,6 +6493,13 @@ public class Main { resources.configuration_directory, "/opt/lithe/jdtls/config_mac" ); + assert_eq!( + resources.java_extension_bundle_paths, + vec![ + "/opt/lithe/jdtls/java-debug/com.microsoft.java.debug.plugin-0.53.1.jar", + "/opt/lithe/jdtls/java-test/extensions/com.microsoft.java.test.plugin-0.42.0.jar", + ] + ); assert_eq!(request.initialize_timeout_milliseconds, 30_000); assert_eq!(request.service_ready_idle_timeout_milliseconds, 45_000); assert_eq!(request.service_ready_absolute_timeout_milliseconds, 600_000); diff --git a/rust/lithe-core/src/lsp/languages/jdt.rs b/rust/lithe-core/src/lsp/languages/jdt.rs index cf526a1b0..4885f660b 100644 --- a/rust/lithe-core/src/lsp/languages/jdt.rs +++ b/rust/lithe-core/src/lsp/languages/jdt.rs @@ -107,6 +107,8 @@ pub(crate) struct JdtDirectLaunchResources { pub launcher_jar_path: PathBuf, pub configuration_directory: PathBuf, pub lombok_agent_path: PathBuf, + #[serde(default)] + pub java_debug_bundle_path: Option, } #[derive(Debug, Clone, Deserialize, Eq, PartialEq)] @@ -217,13 +219,16 @@ pub(crate) fn adapt_start(context: &JdtStartContext) -> JdtStartAdaptation { } } -/// Adds the JDT LS client extensions required for class-file navigation. +/// Adds the JDT LS client extensions required for Java tooling. /// /// Catalog-provided options are preserved, while the provider-owned capability /// is authoritative because virtual class files cannot be opened without it. +/// Extension bundles are appended in caller order without duplicating catalog +/// entries, which keeps Debug and Test plugin activation deterministic. pub(crate) fn adapt_initialization_options( provider_id: &str, initialization_options: Option, + java_extension_bundle_paths: &[PathBuf], ) -> Option { if !is_java_provider(provider_id) { return initialization_options; @@ -244,6 +249,24 @@ pub(crate) fn adapt_initialization_options( .expect("the extended capabilities were normalized to an object") .insert("classFileContentsSupport".to_string(), Value::Bool(true)); + if !java_extension_bundle_paths.is_empty() { + let bundles = options + .entry("bundles") + .or_insert_with(|| Value::Array(Vec::new())); + if !bundles.is_array() { + *bundles = Value::Array(Vec::new()); + } + let bundles = bundles + .as_array_mut() + .expect("the Java extension bundles were normalized to an array"); + for bundle_path in java_extension_bundle_paths { + let bundle = Value::String(bundle_path.to_string_lossy().into_owned()); + if !bundles.contains(&bundle) { + bundles.push(bundle); + } + } + } + Some(Value::Object(options)) } @@ -1030,6 +1053,9 @@ mod tests { launcher_jar_path: PathBuf::from("/jdtls/plugins/equinox.jar"), configuration_directory: PathBuf::from("/jdtls/config_mac"), lombok_agent_path: PathBuf::from("/jdtls/lombok/lombok.jar"), + java_debug_bundle_path: Some(PathBuf::from( + "/jdtls/java-debug/com.microsoft.java.debug.plugin-0.53.1.jar", + )), }); context.arguments = vec![ "--stdio".to_string(), @@ -1145,7 +1171,7 @@ mod tests { assert!(initialized_notification("rust", None).is_none()); assert!(virtual_source_resolve_params("rust", "jdt://contents/A.class").is_none()); assert_eq!( - adapt_initialization_options("rust", Some(json!({ "custom": true }))), + adapt_initialization_options("rust", Some(json!({ "custom": true })), &[]), Some(json!({ "custom": true })) ); let location = ProviderLocation { @@ -1162,11 +1188,18 @@ mod tests { "JAVA", Some(json!({ "workspace": { "custom": true }, + "bundles": ["/plugins/custom.jar"], "extendedClientCapabilities": { "customCapability": true, "classFileContentsSupport": false } })), + &[ + PathBuf::from("/jdtls/java-debug/com.microsoft.java.debug.plugin-0.53.1.jar"), + PathBuf::from( + "/jdtls/java-test/extensions/com.microsoft.java.test.plugin-0.42.0.jar", + ), + ], ) .unwrap(); @@ -1179,6 +1212,14 @@ mod tests { options["extendedClientCapabilities"]["classFileContentsSupport"], true ); + assert_eq!( + options["bundles"], + json!([ + "/plugins/custom.jar", + "/jdtls/java-debug/com.microsoft.java.debug.plugin-0.53.1.jar", + "/jdtls/java-test/extensions/com.microsoft.java.test.plugin-0.42.0.jar" + ]) + ); } #[test] diff --git a/rust/lithe-core/src/lsp/tests.rs b/rust/lithe-core/src/lsp/tests.rs index 29deb2b12..c1c6da10b 100644 --- a/rust/lithe-core/src/lsp/tests.rs +++ b/rust/lithe-core/src/lsp/tests.rs @@ -1607,12 +1607,12 @@ fn client_core_shapes_feature_responses_for_swift_models() { message: r#"{ "jsonrpc": "2.0", "id": "7", - "result": null + "result": 5005 }"# .to_string(), }) .unwrap(); - assert_eq!(executed.events[0].result.as_ref().unwrap()["ok"], true); + assert_eq!(executed.events[0].result.as_ref().unwrap()["value"], 5005); } #[test] diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index 7a44ee165..fc5b6d447 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -85,6 +85,42 @@ pub enum CoreCommand { MavenDiagnostics, /// Renders and sanitizes shared Markdown (`markdown.render`). MarkdownRender, + /// Creates one transport-neutral Debug Adapter Protocol session (`debug.createSession`). + DebugCreateSession, + /// Queues a launch or attach request for a debug session (`debug.launch`). + DebugLaunch, + /// Creates a Java test launch configuration from JDT LS metadata (`debug.javaTestLaunch`). + DebugJavaTestLaunch, + /// Returns or normalizes portable stepping filters (`debug.steppingFilters`). + DebugSteppingFilters, + /// Moves source breakpoints across one UTF-16 editor mutation (`debug.relocateBreakpoints`). + DebugRelocateBreakpoints, + /// Replaces breakpoints for one source file (`debug.setBreakpoints`). + DebugSetBreakpoints, + /// Replaces exception filters for one debug session (`debug.setExceptionBreakpoints`). + DebugSetExceptionBreakpoints, + /// Replaces named function breakpoints for one debug session (`debug.setFunctionBreakpoints`). + DebugSetFunctionBreakpoints, + /// Resolves one adapter-owned data breakpoint identity (`debug.dataBreakpointInfo`). + DebugDataBreakpointInfo, + /// Replaces data breakpoints for one debug session (`debug.setDataBreakpoints`). + DebugSetDataBreakpoints, + /// Replaces one visible variable value (`debug.setVariable`). + DebugSetVariable, + /// Cancels or times out one pending debug operation (`debug.cancelOperation`). + DebugCancelOperation, + /// Queues continue, pause, or stepping control (`debug.execute`). + DebugExecute, + /// Queues one normalized debugger inspection request (`debug.inspect`). + DebugInspect, + /// Reduces bytes received from a platform-owned DAP transport (`debug.receive`). + DebugReceive, + /// Completes one adapter-requested terminal launch (`debug.runInTerminalResponse`). + DebugRunInTerminalResponse, + /// Begins the DAP disconnect handshake (`debug.disconnect`). + DebugDisconnect, + /// Removes all state for a debug session (`debug.destroySession`). + DebugDestroySession, /// Applies validated UTF-16 LSP text edits (`lsp.applyTextEdits`). LspApplyTextEdits, /// Reduces an LSP snippet to insertion text (`lsp.plainSnippet`). @@ -232,6 +268,24 @@ impl CoreCommand { "maven.launchPlan" => Some(Self::MavenLaunchPlan), "maven.diagnostics" => Some(Self::MavenDiagnostics), "markdown.render" => Some(Self::MarkdownRender), + "debug.createSession" => Some(Self::DebugCreateSession), + "debug.launch" => Some(Self::DebugLaunch), + "debug.javaTestLaunch" => Some(Self::DebugJavaTestLaunch), + "debug.steppingFilters" => Some(Self::DebugSteppingFilters), + "debug.relocateBreakpoints" => Some(Self::DebugRelocateBreakpoints), + "debug.setBreakpoints" => Some(Self::DebugSetBreakpoints), + "debug.setExceptionBreakpoints" => Some(Self::DebugSetExceptionBreakpoints), + "debug.setFunctionBreakpoints" => Some(Self::DebugSetFunctionBreakpoints), + "debug.dataBreakpointInfo" => Some(Self::DebugDataBreakpointInfo), + "debug.setDataBreakpoints" => Some(Self::DebugSetDataBreakpoints), + "debug.setVariable" => Some(Self::DebugSetVariable), + "debug.cancelOperation" => Some(Self::DebugCancelOperation), + "debug.execute" => Some(Self::DebugExecute), + "debug.inspect" => Some(Self::DebugInspect), + "debug.receive" => Some(Self::DebugReceive), + "debug.runInTerminalResponse" => Some(Self::DebugRunInTerminalResponse), + "debug.disconnect" => Some(Self::DebugDisconnect), + "debug.destroySession" => Some(Self::DebugDestroySession), "lsp.applyTextEdits" => Some(Self::LspApplyTextEdits), "lsp.plainSnippet" => Some(Self::LspPlainSnippet), "lsp.builtinCompletions" => Some(Self::LspBuiltinCompletions), @@ -334,4 +388,30 @@ mod tests { fn parses_document_lifecycle_command() { assert!(CoreCommand::parse("document.lifecycle").is_some()); } + + #[test] + fn parses_debug_runtime_commands() { + for command in [ + "debug.createSession", + "debug.launch", + "debug.javaTestLaunch", + "debug.steppingFilters", + "debug.relocateBreakpoints", + "debug.setBreakpoints", + "debug.setExceptionBreakpoints", + "debug.setFunctionBreakpoints", + "debug.dataBreakpointInfo", + "debug.setDataBreakpoints", + "debug.setVariable", + "debug.cancelOperation", + "debug.execute", + "debug.inspect", + "debug.receive", + "debug.runInTerminalResponse", + "debug.disconnect", + "debug.destroySession", + ] { + assert!(CoreCommand::parse(command).is_some(), "missing {command}"); + } + } } diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index 05095cfb6..b87bd388b 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -500,6 +500,328 @@ fn execute(request: &str) -> CoreResponse { Err(error) => CoreResponse::failure(id, error), } } + CoreCommand::DebugCreateSession => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid debug create-session request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::debug::create_session) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Debug session update should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::DebugLaunch => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid debug launch request") + .with_details(error.to_string()) + }) + .and_then(crate::debug::launch) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Debug launch update should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::DebugJavaTestLaunch => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid Java test debug launch request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::debug::java_test_launch) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data) + .expect("Java test debug launch configuration should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::DebugSteppingFilters => { + match serde_json::from_value::( + parsed.payload, + ) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid debug stepping-filters request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::debug::stepping_filters) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Debug stepping filters should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::DebugRelocateBreakpoints => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid debug relocate-breakpoints request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::debug::relocate_breakpoints) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Debug breakpoint relocation should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::DebugSetBreakpoints => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid debug set-breakpoints request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::debug::set_breakpoints) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Debug breakpoint update should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::DebugSetExceptionBreakpoints => { + match serde_json::from_value::( + parsed.payload, + ) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid debug set-exception-breakpoints request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::debug::set_exception_breakpoints) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data) + .expect("Debug exception breakpoint update should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::DebugSetFunctionBreakpoints => { + match serde_json::from_value::( + parsed.payload, + ) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid debug set-function-breakpoints request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::debug::set_function_breakpoints) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data) + .expect("Debug function breakpoint update should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::DebugDataBreakpointInfo => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid debug data-breakpoint-info request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::debug::data_breakpoint_info) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data) + .expect("Debug data breakpoint info update should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::DebugSetDataBreakpoints => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid debug set-data-breakpoints request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::debug::set_data_breakpoints) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Debug data breakpoint update should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::DebugSetVariable => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid debug set-variable request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::debug::set_variable) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Debug variable update should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::DebugCancelOperation => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid debug cancel-operation request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::debug::cancel_operation) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Debug cancellation update should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::DebugExecute => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid debug execute request") + .with_details(error.to_string()) + }) + .and_then(crate::debug::execute) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Debug execution update should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::DebugInspect => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid debug inspect request") + .with_details(error.to_string()) + }) + .and_then(crate::debug::inspect) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Debug inspection update should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::DebugReceive => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid debug receive request") + .with_details(error.to_string()) + }) + .and_then(crate::debug::receive) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Debug receive update should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::DebugRunInTerminalResponse => { + match serde_json::from_value::( + parsed.payload, + ) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid debug run-in-terminal response", + ) + .with_details(error.to_string()) + }) + .and_then(crate::debug::run_in_terminal_response) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data) + .expect("Debug run-in-terminal response update should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::DebugDisconnect => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid debug disconnect request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::debug::disconnect) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Debug disconnect update should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::DebugDestroySession => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid debug destroy-session request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::debug::destroy_session) + { + Ok(()) => CoreResponse::success(id, json!({"destroyed": true})), + Err(error) => CoreResponse::failure(id, error), + } + } CoreCommand::LspApplyTextEdits => { match serde_json::from_value::(parsed.payload) .map_err(|error| { diff --git a/rust/lithe-core/src/tests/protocol.rs b/rust/lithe-core/src/tests/protocol.rs index fcd09f393..b9f3a0a8e 100644 --- a/rust/lithe-core/src/tests/protocol.rs +++ b/rust/lithe-core/src/tests/protocol.rs @@ -1,4 +1,6 @@ use crate::execute_json; +use base64::engine::general_purpose::STANDARD as BASE64; +use base64::Engine as _; use serde_json::Value; #[test] @@ -12,3 +14,227 @@ fn ping_exposes_protocol_version() { assert_eq!(response["data"]["protocolVersion"], 1); assert_eq!(response["data"]["coreVersion"], "0.1.0"); } + +#[test] +fn debug_create_and_destroy_commands_cross_the_json_boundary() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/fixtures/debug/dap-session-v1.json" + ))) + .expect("debug fixture should be valid JSON"); + let session = &fixture["session"]; + let create_request = serde_json::json!({ + "id": "debug-create", + "command": "debug.createSession", + "payload": session + }); + + let created: Value = serde_json::from_str(&execute_json(&create_request.to_string())) + .expect("debug create response should be JSON"); + + assert_eq!(created["ok"], true); + assert_eq!(created["data"]["state"], fixture["expected"]["createState"]); + let frame = created["data"]["outboundFrames"][0] + .as_str() + .expect("initialize frame should be base64"); + let bytes = BASE64 + .decode(frame) + .expect("initialize frame should decode"); + let body_start = bytes + .windows(4) + .position(|window| window == b"\r\n\r\n") + .expect("initialize frame should have a header") + + 4; + let message: Value = + serde_json::from_slice(&bytes[body_start..]).expect("initialize body should be JSON"); + assert_eq!(message["command"], fixture["expected"]["createCommand"]); + + let destroy_request = serde_json::json!({ + "id": "debug-destroy", + "command": "debug.destroySession", + "payload": {"sessionId": session["sessionId"]} + }); + let destroyed: Value = serde_json::from_str(&execute_json(&destroy_request.to_string())) + .expect("debug destroy response should be JSON"); + assert_eq!(destroyed["ok"], true); + assert_eq!(destroyed["data"]["destroyed"], true); +} + +#[test] +fn debug_run_in_terminal_response_crosses_the_json_boundary() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/fixtures/debug/run-in-terminal-v1.json" + ))) + .expect("debug run-in-terminal fixture should be valid JSON"); + let session_id = "debug-terminal-protocol-boundary"; + let create_request = serde_json::json!({ + "id": "debug-terminal-create", + "command": "debug.createSession", + "payload": { + "sessionId": session_id, + "adapterId": "java", + "rootPath": "/workspace", + "supportsRunInTerminalRequest": true + } + }); + let created: Value = serde_json::from_str(&execute_json(&create_request.to_string())) + .expect("debug create response should be JSON"); + assert_eq!(created["ok"], true); + + let adapter_message = + serde_json::to_vec(&fixture["adapterRequest"]).expect("adapter request should encode"); + let mut framed = format!("Content-Length: {}\r\n\r\n", adapter_message.len()).into_bytes(); + framed.extend(adapter_message); + let receive_request = serde_json::json!({ + "id": "debug-terminal-receive", + "command": "debug.receive", + "payload": { + "sessionId": session_id, + "dataBase64": BASE64.encode(framed) + } + }); + let received: Value = serde_json::from_str(&execute_json(&receive_request.to_string())) + .expect("debug receive response should be JSON"); + assert_eq!(received["ok"], true); + let terminal_event = received["data"]["events"] + .as_array() + .expect("debug update should contain events") + .iter() + .find(|event| event["type"] == "runInTerminalRequested") + .expect("debug update should request an integrated terminal"); + assert_eq!(terminal_event["request"], fixture["expectedRequest"]); + let request_id = terminal_event["requestId"] + .as_str() + .expect("terminal request should have a correlation ID"); + + let response_request = serde_json::json!({ + "id": "debug-terminal-response", + "command": "debug.runInTerminalResponse", + "payload": { + "sessionId": session_id, + "requestId": request_id, + "success": true, + "processId": fixture["successResponse"]["processId"], + "shellProcessId": fixture["successResponse"]["shellProcessId"] + } + }); + let completed: Value = serde_json::from_str(&execute_json(&response_request.to_string())) + .expect("debug run-in-terminal response should be JSON"); + assert_eq!(completed["ok"], true); + let response_frame = completed["data"]["outboundFrames"][0] + .as_str() + .expect("terminal response frame should be base64"); + let response_bytes = BASE64 + .decode(response_frame) + .expect("terminal response frame should decode"); + let body_start = response_bytes + .windows(4) + .position(|window| window == b"\r\n\r\n") + .expect("terminal response frame should have a header") + + 4; + let response_message: Value = serde_json::from_slice(&response_bytes[body_start..]) + .expect("terminal response body should be JSON"); + assert_eq!( + response_message["request_seq"], + fixture["adapterRequest"]["seq"] + ); + assert_eq!(response_message["success"], true); + assert_eq!(response_message["body"], fixture["expectedSuccessBody"]); + + let destroy_request = serde_json::json!({ + "id": "debug-terminal-destroy", + "command": "debug.destroySession", + "payload": {"sessionId": session_id} + }); + let destroyed: Value = serde_json::from_str(&execute_json(&destroy_request.to_string())) + .expect("debug destroy response should be JSON"); + assert_eq!(destroyed["ok"], true); +} + +#[test] +fn debug_stepping_filters_match_the_shared_contract_fixture() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/fixtures/debug/stepping-filters-v1.json" + ))) + .expect("debug stepping-filter fixture should be valid JSON"); + + for case in fixture["cases"] + .as_array() + .expect("debug stepping-filter fixture should contain cases") + { + let request = serde_json::json!({ + "id": case["name"], + "command": "debug.steppingFilters", + "payload": case["payload"] + }); + let response: Value = serde_json::from_str(&execute_json(&request.to_string())) + .expect("debug stepping-filter response should be JSON"); + + assert_eq!(response["ok"], true, "fixture case {}", case["name"]); + assert_eq!( + response["data"], case["expected"], + "fixture case {}", + case["name"] + ); + } +} + +#[test] +fn debug_breakpoint_relocation_matches_the_shared_contract_fixture() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/fixtures/debug/breakpoint-relocation-v1.json" + ))) + .expect("debug breakpoint-relocation fixture should be valid JSON"); + + for case in fixture["cases"] + .as_array() + .expect("debug breakpoint-relocation fixture should contain cases") + { + let request = serde_json::json!({ + "id": case["name"], + "command": "debug.relocateBreakpoints", + "payload": case["payload"] + }); + let response: Value = serde_json::from_str(&execute_json(&request.to_string())) + .expect("debug breakpoint-relocation response should be JSON"); + + assert_eq!(response["ok"], true, "fixture case {}", case["name"]); + assert_eq!( + response["data"], case["expected"], + "fixture case {}", + case["name"] + ); + } +} + +#[test] +fn java_test_debug_launch_matches_the_shared_contract_fixture() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/fixtures/debug/java-test-launch-v1.json" + ))) + .expect("Java test debug fixture should be valid JSON"); + + for case in fixture["cases"] + .as_array() + .expect("Java test debug fixture should contain cases") + { + let request = serde_json::json!({ + "id": case["name"], + "command": "debug.javaTestLaunch", + "payload": case["payload"] + }); + let response: Value = serde_json::from_str(&execute_json(&request.to_string())) + .expect("Java test debug response should be JSON"); + + assert_eq!(response["ok"], true, "fixture case {}", case["name"]); + assert_eq!( + response["data"], case["expected"], + "fixture case {}", + case["name"] + ); + } +} diff --git a/scripts/prepare-jdtls.ps1 b/scripts/prepare-jdtls.ps1 index a1d62aafe..d0e460f73 100644 --- a/scripts/prepare-jdtls.ps1 +++ b/scripts/prepare-jdtls.ps1 @@ -27,8 +27,13 @@ $archiveHash = $manifest.archiveSHA256.ToLowerInvariant() $licenseHash = $manifest.licenseSHA256.ToLowerInvariant() $lombokHash = $manifest.lombokSHA256.ToLowerInvariant() $lombokLicenseHash = $manifest.lombokLicenseSHA256.ToLowerInvariant() +$javaDebugArchiveHash = $manifest.javaDebugArchiveSHA256.ToLowerInvariant() +$javaDebugPluginHash = $manifest.javaDebugPluginSHA256.ToLowerInvariant() +$javaDebugLicenseHash = $manifest.javaDebugLicenseSHA256.ToLowerInvariant() $safeVersion = ([string]$manifest.version) -replace '[^A-Za-z0-9._-]', '_' $safeLombokVersion = ([string]$manifest.lombokVersion) -replace '[^A-Za-z0-9._-]', '_' +$safeJavaDebugExtensionVersion = ([string]$manifest.javaDebugExtensionVersion) -replace '[^A-Za-z0-9._-]', '_' +$safeJavaDebugServerVersion = ([string]$manifest.javaDebugServerVersion) -replace '[^A-Za-z0-9._-]', '_' $archive = if ($archiveUsesOverride) { $env:LITHE_JDTLS_ARCHIVE } else { @@ -37,6 +42,11 @@ $archive = if ($archiveUsesOverride) { $license = Join-Path $cache "EPL-2.0-$licenseHash.txt" $lombok = Join-Path $cache "lombok-$safeLombokVersion-$lombokHash.jar" $lombokLicense = Join-Path $cache "lombok-MIT-$safeLombokVersion-$lombokLicenseHash.txt" +# Expand-Archive validates the file extension even though VSIX files are ZIP +# archives, so keep the verified payload under a compatible cache name. +$javaDebugArchive = Join-Path $cache "vscode-java-debug-$safeJavaDebugExtensionVersion-$javaDebugArchiveHash.zip" +$javaDebugLicense = Join-Path $cache "java-debug-EPL-1.0-$safeJavaDebugServerVersion-$javaDebugLicenseHash.txt" +$javaDebugPluginName = "com.microsoft.java.debug.plugin-$safeJavaDebugServerVersion.jar" function Get-FileSHA256 { param([Parameter(Mandatory)][string]$Path) @@ -94,6 +104,8 @@ function Assert-JdtlsOutput { if (-not (Test-Path -LiteralPath (Join-Path $output "bin/jdtls.bat") -PathType Leaf)) { throw "JDTLS batch launcher is missing: $output" } if (-not (Test-Path -LiteralPath (Join-Path $output "lombok/lombok.jar") -PathType Leaf)) { throw "JDTLS Lombok agent is missing: $output" } if (-not (Test-Path -LiteralPath (Join-Path $output "lombok/LICENSE-MIT.txt") -PathType Leaf)) { throw "JDTLS Lombok license is missing: $output" } + if (-not (Test-Path -LiteralPath (Join-Path $output "java-debug/$javaDebugPluginName") -PathType Leaf)) { throw "Java Debug Server plugin is missing: $output" } + if (-not (Test-Path -LiteralPath (Join-Path $output "java-debug/LICENSE-EPL-1.0.txt") -PathType Leaf)) { throw "Java Debug Server license is missing: $output" } # Wrapper scripts remain for external/legacy launch plans. Packaged JDTLS # uses the direct-launch resources validated above. $launcher = Get-Content -Raw -LiteralPath (Join-Path $output "bin/jdtls.ps1") @@ -117,6 +129,8 @@ if ($archiveUsesOverride) { Get-VerifiedDownload -Uri $manifest.licenseURL -ExpectedSHA256 $licenseHash -Destination $license -Description "EPL-2.0 license" Get-VerifiedDownload -Uri $manifest.lombokURL -ExpectedSHA256 $lombokHash -Destination $lombok -Description "Lombok agent" Get-VerifiedDownload -Uri $manifest.lombokLicenseURL -ExpectedSHA256 $lombokLicenseHash -Destination $lombokLicense -Description "Lombok MIT license" +Get-VerifiedDownload -Uri $manifest.javaDebugArchiveURL -ExpectedSHA256 $javaDebugArchiveHash -Destination $javaDebugArchive -Description "Java Debug extension" +Get-VerifiedDownload -Uri $manifest.javaDebugLicenseURL -ExpectedSHA256 $javaDebugLicenseHash -Destination $javaDebugLicense -Description "Java Debug EPL-1.0 license" if (Test-Path -LiteralPath $output) { Remove-Item -Recurse -Force -LiteralPath $output } New-Item -ItemType Directory -Force -Path $output | Out-Null @@ -127,6 +141,23 @@ $lombokOutput = Join-Path $output "lombok" New-Item -ItemType Directory -Force -Path $lombokOutput | Out-Null Copy-Item -LiteralPath $lombok -Destination (Join-Path $lombokOutput "lombok.jar") -Force Copy-Item -LiteralPath $lombokLicense -Destination (Join-Path $lombokOutput "LICENSE-MIT.txt") -Force +$javaDebugOutput = Join-Path $output "java-debug" +New-Item -ItemType Directory -Force -Path $javaDebugOutput | Out-Null +$javaDebugExtraction = Join-Path $cache "java-debug-extract-$PID" +try { + if (Test-Path -LiteralPath $javaDebugExtraction) { Remove-Item -Recurse -Force -LiteralPath $javaDebugExtraction } + Expand-Archive -LiteralPath $javaDebugArchive -DestinationPath $javaDebugExtraction -Force + $javaDebugPlugin = Join-Path $javaDebugExtraction "extension/server/$javaDebugPluginName" + if (-not (Test-Path -LiteralPath $javaDebugPlugin -PathType Leaf)) { throw "Java Debug Server plugin was not found in the verified extension archive" } + $actualJavaDebugPluginHash = Get-FileSHA256 -Path $javaDebugPlugin + if ($actualJavaDebugPluginHash -ne $javaDebugPluginHash) { + throw "Java Debug Server plugin checksum mismatch: expected $javaDebugPluginHash, got $actualJavaDebugPluginHash" + } + Copy-Item -LiteralPath $javaDebugPlugin -Destination (Join-Path $javaDebugOutput $javaDebugPluginName) -Force +} finally { + if (Test-Path -LiteralPath $javaDebugExtraction) { Remove-Item -Recurse -Force -LiteralPath $javaDebugExtraction } +} +Copy-Item -LiteralPath $javaDebugLicense -Destination (Join-Path $javaDebugOutput "LICENSE-EPL-1.0.txt") -Force $windowsLauncher = @' $ErrorActionPreference = "Stop" diff --git a/scripts/prepare-jdtls.sh b/scripts/prepare-jdtls.sh index 91023fc7d..cea744d0f 100755 --- a/scripts/prepare-jdtls.sh +++ b/scripts/prepare-jdtls.sh @@ -19,12 +19,33 @@ lombok_url="$(manifest_value lombokURL)" lombok_sha256="$(manifest_value lombokSHA256)" lombok_license_url="$(manifest_value lombokLicenseURL)" lombok_license_sha256="$(manifest_value lombokLicenseSHA256)" +java_debug_archive_url="$(manifest_value javaDebugArchiveURL)" +java_debug_archive_sha256="$(manifest_value javaDebugArchiveSHA256)" +java_debug_plugin_sha256="$(manifest_value javaDebugPluginSHA256)" +java_debug_license_url="$(manifest_value javaDebugLicenseURL)" +java_debug_license_sha256="$(manifest_value javaDebugLicenseSHA256)" +java_test_archive_url="$(manifest_value javaTestArchiveURL)" +java_test_archive_sha256="$(manifest_value javaTestArchiveSHA256)" +java_test_plugin_sha256="$(manifest_value javaTestPluginSHA256)" +java_test_runner_sha256="$(manifest_value javaTestRunnerSHA256)" +java_test_license_url="$(manifest_value javaTestLicenseURL)" +java_test_license_sha256="$(manifest_value javaTestLicenseSHA256)" jdtls_version="$(manifest_value version)" lombok_version="$(manifest_value lombokVersion)" +java_debug_extension_version="$(manifest_value javaDebugExtensionVersion)" +java_debug_server_version="$(manifest_value javaDebugServerVersion)" +java_test_extension_version="$(manifest_value javaTestExtensionVersion)" archive_path="${LITHE_JDTLS_ARCHIVE:-$CACHE_DIR/jdtls-$jdtls_version-$archive_sha256.tar.gz}" license_path="$CACHE_DIR/EPL-2.0-$license_sha256.txt" lombok_path="$CACHE_DIR/lombok-$lombok_version-$lombok_sha256.jar" lombok_license_path="$CACHE_DIR/lombok-MIT-$lombok_version-$lombok_license_sha256.txt" +java_debug_archive_path="$CACHE_DIR/vscode-java-debug-$java_debug_extension_version-$java_debug_archive_sha256.vsix" +java_debug_license_path="$CACHE_DIR/java-debug-EPL-1.0-$java_debug_server_version-$java_debug_license_sha256.txt" +java_debug_plugin_name="com.microsoft.java.debug.plugin-$java_debug_server_version.jar" +java_test_archive_path="$CACHE_DIR/vscode-java-test-$java_test_extension_version-$java_test_archive_sha256.vsix" +java_test_license_path="$CACHE_DIR/java-test-MIT-$java_test_extension_version-$java_test_license_sha256.txt" +java_test_plugin_name="com.microsoft.java.test.plugin-$java_test_extension_version.jar" +java_test_runner_name="com.microsoft.java.test.runner-jar-with-dependencies.jar" file_sha256() { shasum -a 256 "$1" | awk '{print tolower($1)}' @@ -96,6 +117,13 @@ validate_output() { [[ -f "$OUTPUT_DIR/bin/jdtls.ps1" ]] || { print -u2 -- "JDTLS Windows launcher is missing: $OUTPUT_DIR"; exit 1; } [[ -f "$OUTPUT_DIR/lombok/lombok.jar" ]] || { print -u2 -- "JDTLS Lombok agent is missing: $OUTPUT_DIR"; exit 1; } [[ -f "$OUTPUT_DIR/lombok/LICENSE-MIT.txt" ]] || { print -u2 -- "JDTLS Lombok license is missing: $OUTPUT_DIR"; exit 1; } + [[ -f "$OUTPUT_DIR/java-debug/$java_debug_plugin_name" ]] || { print -u2 -- "Java Debug Server plugin is missing: $OUTPUT_DIR"; exit 1; } + [[ -f "$OUTPUT_DIR/java-debug/LICENSE-EPL-1.0.txt" ]] || { print -u2 -- "Java Debug Server license is missing: $OUTPUT_DIR"; exit 1; } + [[ -f "$OUTPUT_DIR/java-test/extensions/$java_test_plugin_name" ]] || { print -u2 -- "Java Test extension plugin is missing: $OUTPUT_DIR"; exit 1; } + local java_test_extension_bundles=("$OUTPUT_DIR"/java-test/extensions/*.jar(N)) + (( ${#java_test_extension_bundles[@]} == 18 )) || { print -u2 -- "Java Test extension bundle set is incomplete: $OUTPUT_DIR/java-test/extensions"; exit 1; } + [[ -f "$OUTPUT_DIR/java-test/runner/$java_test_runner_name" ]] || { print -u2 -- "Java Test runner is missing: $OUTPUT_DIR"; exit 1; } + [[ -f "$OUTPUT_DIR/java-test/LICENSE-MIT.txt" ]] || { print -u2 -- "Java Test license is missing: $OUTPUT_DIR"; exit 1; } # Wrapper scripts remain available for external/legacy launch plans. The # packaged product launches bundled Java directly with the resources above. grep -Fq -- '-javaagent:' "$OUTPUT_DIR/bin/jdtls" || { print -u2 -- "JDTLS launcher does not load the Lombok agent: $OUTPUT_DIR"; exit 1; } @@ -122,6 +150,10 @@ fi download_verified_file "$license_url" "$license_sha256" "$license_path" "EPL-2.0 license" download_verified_file "$lombok_url" "$lombok_sha256" "$lombok_path" "Lombok agent" download_verified_file "$lombok_license_url" "$lombok_license_sha256" "$lombok_license_path" "Lombok MIT license" +download_verified_file "$java_debug_archive_url" "$java_debug_archive_sha256" "$java_debug_archive_path" "Java Debug extension" +download_verified_file "$java_debug_license_url" "$java_debug_license_sha256" "$java_debug_license_path" "Java Debug EPL-1.0 license" +download_verified_file "$java_test_archive_url" "$java_test_archive_sha256" "$java_test_archive_path" "Java Test extension" +download_verified_file "$java_test_license_url" "$java_test_license_sha256" "$java_test_license_path" "Java Test MIT license" rm -rf "$OUTPUT_DIR" mkdir -p "$OUTPUT_DIR" @@ -131,6 +163,47 @@ cp "$MANIFEST" "$OUTPUT_DIR/manifest.json" mkdir -p "$OUTPUT_DIR/lombok" cp "$lombok_path" "$OUTPUT_DIR/lombok/lombok.jar" cp "$lombok_license_path" "$OUTPUT_DIR/lombok/LICENSE-MIT.txt" +mkdir -p "$OUTPUT_DIR/java-debug" +unzip -p \ + "$java_debug_archive_path" \ + "extension/server/$java_debug_plugin_name" \ + > "$OUTPUT_DIR/java-debug/$java_debug_plugin_name" +actual_java_debug_plugin_sha256="$(file_sha256 "$OUTPUT_DIR/java-debug/$java_debug_plugin_name")" +if [[ "$actual_java_debug_plugin_sha256" != "$java_debug_plugin_sha256" ]]; then + print -u2 -- "Java Debug Server plugin checksum mismatch: expected $java_debug_plugin_sha256, got $actual_java_debug_plugin_sha256" + exit 1 +fi +cp "$java_debug_license_path" "$OUTPUT_DIR/java-debug/LICENSE-EPL-1.0.txt" +java_test_extraction="$(mktemp -d "$CACHE_DIR/java-test-extract.XXXXXX")" +unzip -q -j \ + "$java_test_archive_path" \ + "extension/server/*.jar" \ + -d "$java_test_extraction" +mkdir -p "$OUTPUT_DIR/java-test/extensions" "$OUTPUT_DIR/java-test/runner" +for java_test_jar in "$java_test_extraction"/*.jar(N); do + case "${java_test_jar:t}" in + jacocoagent.jar) + ;; + "$java_test_runner_name") + cp "$java_test_jar" "$OUTPUT_DIR/java-test/runner/$java_test_runner_name" + ;; + *) + cp "$java_test_jar" "$OUTPUT_DIR/java-test/extensions/${java_test_jar:t}" + ;; + esac +done +rm -rf -- "$java_test_extraction" +actual_java_test_plugin_sha256="$(file_sha256 "$OUTPUT_DIR/java-test/extensions/$java_test_plugin_name")" +if [[ "$actual_java_test_plugin_sha256" != "$java_test_plugin_sha256" ]]; then + print -u2 -- "Java Test plugin checksum mismatch: expected $java_test_plugin_sha256, got $actual_java_test_plugin_sha256" + exit 1 +fi +actual_java_test_runner_sha256="$(file_sha256 "$OUTPUT_DIR/java-test/runner/$java_test_runner_name")" +if [[ "$actual_java_test_runner_sha256" != "$java_test_runner_sha256" ]]; then + print -u2 -- "Java Test runner checksum mismatch: expected $java_test_runner_sha256, got $actual_java_test_runner_sha256" + exit 1 +fi +cp "$java_test_license_path" "$OUTPUT_DIR/java-test/LICENSE-MIT.txt" cat > "$OUTPUT_DIR/bin/jdtls" <<'EOF' #!/bin/zsh diff --git a/scripts/test-macos.sh b/scripts/test-macos.sh index 4c04e1cfa..10b00413a 100755 --- a/scripts/test-macos.sh +++ b/scripts/test-macos.sh @@ -11,6 +11,22 @@ SWIFT_ARGS=( -Xcc -include -Xcc "$ROOT_DIR/scripts/MacOS13SDKCompatibility.h" ) + +# Real process-backed integration tests need the same Rust Core static library +# that is force-loaded into the application and the bridge verification binary. +# Keep it opt-in so the normal unit-test build remains lightweight and does not +# change its existing linkage behavior. +if [[ "${LITHE_RUN_JAVA_DEBUG_INTEGRATION:-0}" == "1" \ + || "${LITHE_RUN_JAVA_TEST_DEBUG_INTEGRATION:-0}" == "1" ]]; then + case "$(uname -m)" in + arm64) RUST_TARGET="aarch64-apple-darwin" ;; + x86_64) RUST_TARGET="x86_64-apple-darwin" ;; + *) print -u2 -- "Unsupported host architecture for Rust Core integration tests: $(uname -m)"; exit 1 ;; + esac + RUST_LIBRARY="$(scripts/build-rust-core.sh --debug --target "$RUST_TARGET")" + SWIFT_ARGS+=(-Xlinker -force_load -Xlinker "$RUST_LIBRARY") +fi + if ! /usr/bin/xcrun ld -help 2>&1 | /usr/bin/grep -q -- '-no_warn_duplicate_libraries'; then SWIFT_ARGS+=(-Xswiftc "-ld-path=$ROOT_DIR/scripts/ld-macos13-compat.sh") fi diff --git a/scripts/verify-macos-package.sh b/scripts/verify-macos-package.sh index 4c4ba614a..6b4db9713 100755 --- a/scripts/verify-macos-package.sh +++ b/scripts/verify-macos-package.sh @@ -4,6 +4,16 @@ set -euo pipefail ROOT_DIR="${0:A:h:h}" cd "$ROOT_DIR" +java_debug_server_version="$( + /usr/bin/plutil -extract javaDebugServerVersion raw -o - third_party/jdtls/manifest.json +)" +java_debug_plugin_name="com.microsoft.java.debug.plugin-$java_debug_server_version.jar" +java_test_extension_version="$( + /usr/bin/plutil -extract javaTestExtensionVersion raw -o - third_party/jdtls/manifest.json +)" +java_test_plugin_name="com.microsoft.java.test.plugin-$java_test_extension_version.jar" +java_test_runner_name="com.microsoft.java.test.runner-jar-with-dependencies.jar" + temporary_directory=$(mktemp -d "${TMPDIR:-/tmp}/lithe-package-verification.XXXXXX") trap 'rm -rf -- "$temporary_directory"' EXIT jdtls_root="$temporary_directory/jdtls" @@ -22,7 +32,10 @@ mkdir -p \ "$jdtls_root/config_mac" \ "$jdtls_root/config_win" \ "$jdtls_root/bin" \ - "$jdtls_root/lombok" + "$jdtls_root/lombok" \ + "$jdtls_root/java-debug" \ + "$jdtls_root/java-test/extensions" \ + "$jdtls_root/java-test/runner" cat > "$jdtls_root/bin/jdtls" <<'LAUNCHER' #!/bin/zsh java_agent_argument="-javaagent:../lombok/lombok.jar" @@ -36,6 +49,33 @@ LAUNCHER : > "$jdtls_root/lombok/lombok.jar" : > "$jdtls_root/lombok/LICENSE-MIT.txt" : > "$jdtls_root/plugins/org.eclipse.equinox.launcher_1.0.0.jar" +: > "$jdtls_root/java-debug/$java_debug_plugin_name" +: > "$jdtls_root/java-debug/LICENSE-EPL-1.0.txt" +java_test_extension_bundles=( + "junit-jupiter-api_5.9.3.jar" + "junit-jupiter-engine_5.9.3.jar" + "junit-jupiter-migrationsupport_5.9.3.jar" + "junit-jupiter-params_5.9.3.jar" + "junit-platform-commons_1.9.3.jar" + "junit-platform-engine_1.9.3.jar" + "junit-platform-launcher_1.9.3.jar" + "junit-platform-runner_1.9.3.jar" + "junit-platform-suite-api_1.9.3.jar" + "junit-platform-suite-commons_1.9.3.jar" + "junit-platform-suite-engine_1.9.3.jar" + "junit-vintage-engine_5.9.3.jar" + "org.apiguardian.api_1.1.2.jar" + "org.eclipse.jdt.junit4.runtime_1.3.0.v20220609-1843.jar" + "org.eclipse.jdt.junit5.runtime_1.1.100.v20220907-0450.jar" + "org.opentest4j_1.2.0.jar" + "org.jacoco.core_0.8.12.202403310830.jar" + "$java_test_plugin_name" +) +for bundle in "${java_test_extension_bundles[@]}"; do + : > "$jdtls_root/java-test/extensions/$bundle" +done +: > "$jdtls_root/java-test/runner/$java_test_runner_name" +: > "$jdtls_root/java-test/LICENSE-MIT.txt" for missing_configuration in config_mac_arm config_mac; do broken_jdtls_root="$temporary_directory/jdtls-missing-$missing_configuration" @@ -104,6 +144,11 @@ required_resources=( "$app_path/Contents/Resources/LanguageServers/jdtls/config_mac_arm" "$app_path/Contents/Resources/LanguageServers/jdtls/config_mac" "$app_path/Contents/Resources/LanguageServers/jdtls/lombok/lombok.jar" + "$app_path/Contents/Resources/LanguageServers/jdtls/java-debug/$java_debug_plugin_name" + "$app_path/Contents/Resources/LanguageServers/jdtls/java-debug/LICENSE-EPL-1.0.txt" + "$app_path/Contents/Resources/LanguageServers/jdtls/java-test/extensions/$java_test_plugin_name" + "$app_path/Contents/Resources/LanguageServers/jdtls/java-test/runner/$java_test_runner_name" + "$app_path/Contents/Resources/LanguageServers/jdtls/java-test/LICENSE-MIT.txt" "$app_path/Contents/Resources/LanguageServers/jdk-arm64/bin/java" "$app_path/Contents/Resources/LanguageServers/jdk-arm64/lib" "$app_path/Contents/Resources/LanguageServers/jdk-x86_64/bin/java" diff --git a/shared/contracts/application-boundary.md b/shared/contracts/application-boundary.md index 1c14bb083..06986513a 100644 --- a/shared/contracts/application-boundary.md +++ b/shared/contracts/application-boundary.md @@ -29,8 +29,8 @@ verification scripts are the executable source of boundary checks. | GitHub | remote parsing, trusted request plans, normalized branch comparisons and pull requests/reviews/comments, deterministic ordering, and stable errors | OAuth configuration, HTTPS, browser opening, and operating-system credential storage | | Runtime | Java/Maven requirements, normalized candidates, and effective toolchain references | JDK/Maven probing and executable paths | | Language tooling | provider catalog, local fallback results, complete LSP process/session runtime, capabilities, diagnostics, UTF-16 edits, and normalized feature results | executable/environment discovery and UI provider routing | -| Java/Maven/Spring | deterministic Maven-root selection, project structure, modules and profiles; compiler diagnostic parsing; Java source structure, symbols, code vision, run-configuration detection, Spring configuration/bean/endpoint indexing, and JDTLS adapter policy | JDK/Maven discovery, local dependency-repository selection, Java/Maven child processes, sockets, and JDB transport | -| Run/Debug | versioned configuration documents, three-layer resolution, diagnostics, and platform-neutral launch plans | project file persistence, child processes, sockets, and JDB transport | +| Java/Maven/Spring | deterministic Maven-root selection, project structure, modules and profiles; compiler diagnostic parsing; Java source structure, symbols, code vision, run-configuration detection, Spring configuration/bean/endpoint indexing, and JDTLS/Java Debug adapter policy | JDK/Maven discovery, local dependency-repository selection, Java/Maven child processes, and sockets | +| Run/Debug | versioned configuration documents, three-layer resolution, diagnostics, platform-neutral launch plans, DAP framing/state, reverse terminal requests, breakpoint relocation, stepping filters, threads, stacks, variables, and events | project and preference persistence, native edit reporting, adapter discovery, PTY/ConPTY debuggee launch, child processes, sockets, native termination, and UI | | Terminal | input bytes, output bytes, lifecycle | PTY/ConPTY, shell and environment | | Workbench background | versioned source (`none`, bundled slot `01`–`10`, or `custom`) and opacity | UI, image rendering, bundled-resource packaging, local-image access permission and persistence | | Local History | revision metadata, text content, restore result | persistence location and file operations | @@ -143,12 +143,32 @@ preparing, ready, failure, and timeout notifications; a navigation command while preparing ends after the notice and is never replayed later. macOS and Windows adapters discover the selected JDT LS installation's Equinox -launcher JAR, platform configuration directory, Lombok agent, and bundled Java -executable. They submit those paths as structured launch resources; Rust Core -owns the JVM flags and directly starts `java`/`java.exe` with array arguments. -Packaged JDT LS therefore has no runtime dependency on shell wrappers, -PowerShell, or the user's `PATH`. Legacy wrappers are an external-plan -compatibility fallback and are not the packaged execution path. +launcher JAR, platform configuration directory, Lombok agent, Java Debug +Server, and bundled Java executable. Java Test-capable adapters additionally +submit ordered extension bundles; Rust Core owns their ordering and +de-duplication, the JVM flags, and direct `java`/`java.exe` startup with array +arguments. The macOS TestNG runner remains a packaged native resource used only +when a TestNG session starts. Packaged JDT LS therefore has no runtime dependency +on shell wrappers, PowerShell, or the user's `PATH`. Legacy wrappers are an +external-plan compatibility fallback and are not the packaged execution path. + +Java test discovery remains a language-service workflow rather than a UI or +Debug Core parser. When the Tests tool window is opened or refreshed, the +language facade asks the Java Test extension for each candidate source file's +class and method tree, then projects stable fully qualified identifiers into +the native list. Closing the tool window, changing workspace, or reloading the +Java runtime cancels the owning discovery operation; late results cannot replace +the current workspace's tree. Discovery does not create a Debug session, result +socket, adapter connection, or target JVM. + +Starting one JUnit or TestNG file, class, or method creates a short-lived native +loopback result listener on demand. JDT LS owns project/test metadata, Rust Core +owns deterministic DAP launch argument projection, and the Debug module owns the +adapter session. Repeated launch, stop, project close, runtime reload, and launch +failure all cancel the active operation and release the listener. The selected +Run configuration remains the source of project-scoped Java runtime selection; +JDT LS remains authoritative for the test runner classpath, working directory, +and test-specific VM and program arguments. Platforms observe JDT LS version and non-recursive build-file metadata, while Rust Core alone validates and reduces those observations to the opaque workspace @@ -179,6 +199,45 @@ code, and diagnostic detail across the Rust, Swift, and TypeScript boundaries. Domain and adapter layers return stable reasons rather than user-facing prose; each product's presentation layer owns localized notification text. +Debugger stepping policy is portable. Rust Core owns adapter defaults, +normalization, validation, adapter launch projection, and the `isFiltered` +classification on normalized stack frames. Platform products own preference +persistence and decide whether matching consecutive frames are collapsed or +expanded in their native call-stack UI. No Debug session, adapter process, or +background task is created merely because stepping preferences exist. + +Exception pause metadata is portable when the adapter advertises the standard +exception-information request. Rust Core normalizes the exception type, +description, break mode, stack trace, evaluation name, and nested details; +native products decide how that data is presented beside the current frame's +ordinary scopes and variables. An adapter that supplies no object reference +does not make the exception itself expandable through this contract. + +Debugger variable paging is portable. Rust Core owns the standard DAP +`filter`, zero-based `start`, and positive `count` request projection and +normalizes adapter-reported `namedVariables` and `indexedVariables` counts to +non-negative values. Native products own tree expansion and page-size policy; +the macOS reference product loads at most 100 children per request, appends +named children before indexed children, exposes an in-tree load-more action, +and discards stale pages after the selected frame changes. A native client must +also stop offering more pages when an adapter returns more children than were +requested or repeats an already loaded page. + +Debugger terminal launch ownership is split at the native boundary. Rust Core +advertises terminal support, validates and normalizes DAP `runInTerminal` +reverse requests, correlates the platform response, and rejects stale or +duplicate completions. The platform Terminal module owns PTY/ConPTY creation, +direct executable-and-argument startup, environment application, process IDs, +terminal presentation, and native termination. A Debug session is still lazy: +neither a terminal nor a debuggee process exists until an adapter requests one. + +Debugger disconnect ownership is portable. A session started with `launch` +owns its local debuggee and sends `terminateDebuggee: true` when stopping. A +session started with `attach` does not own the remote JVM and sends +`terminateDebuggee: false`; closing the native transport must therefore detach +without killing the remote process. A session stopped before launch or attach +also uses the non-terminating policy. + For JDT LS, the standard initialize handshake and project-import readiness use separate Core-owned deadlines. Project import fails only after 45 seconds without changed progress or the 10-minute absolute safety cap; platform clients diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 37b32f5e5..7cb37cfdf 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -82,6 +82,24 @@ stable error code and a user-facing message: | `maven.scan` | Parse a Maven project descriptor and recursively return modules/profiles | | `maven.launchPlan` | Produce a deterministic Maven invocation from a versioned project context | | `maven.diagnostics` | Parse stable Maven compiler diagnostics from build output | +| `debug.createSession` | Create a transport-neutral DAP session and return its initialize frame | +| `debug.launch` | Queue a launch or attach request, including during initialization | +| `debug.javaTestLaunch` | Normalize JUnit or TestNG launch metadata into Java DAP arguments | +| `debug.steppingFilters` | Return adapter defaults or normalize portable stepping filters | +| `debug.relocateBreakpoints` | Move source breakpoints across one exact UTF-16 editor replacement | +| `debug.setBreakpoints` | Replace and deterministically order one source's DAP breakpoints | +| `debug.setExceptionBreakpoints` | Replace and deterministically order one session's exception filters | +| `debug.setFunctionBreakpoints` | Replace and deterministically order one session's named function breakpoints | +| `debug.dataBreakpointInfo` | Resolve an adapter-owned data breakpoint identity for a paused variable or field | +| `debug.setDataBreakpoints` | Replace and deterministically order one session's resolved data breakpoints | +| `debug.setVariable` | Replace one visible variable value in its adapter-owned parent container | +| `debug.cancelOperation` | Cancel or time out one pending operation and ignore its late response | +| `debug.execute` | Submit continue, pause, next, step-in, or step-out control | +| `debug.inspect` | Request normalized threads, frames, scopes, variables, or evaluation | +| `debug.receive` | Reduce base64-encoded bytes received from a platform-owned DAP transport | +| `debug.runInTerminalResponse` | Complete one adapter-requested native terminal launch | +| `debug.disconnect` | Begin the DAP disconnect handshake without closing the native transport | +| `debug.destroySession` | Remove a session after the platform closes its native transport | | `lsp.applyTextEdits` | Apply LSP UTF-16 text edits with range validation | | `lsp.plainSnippet` | Convert LSP snippet insert text into plain editor text | | `lsp.builtinCompletions` | Return lightweight current-file identifier completions | @@ -364,6 +382,173 @@ details `invalidRange`. Successful responses return `{ "text": string }`. after removing LSP tab stops and replacing simple placeholder defaults such as `${1:name}` with `name`. +The `debug.*` commands are the shared Debug Adapter Protocol boundary. Rust +owns DAP framing, request sequences, response correlation, initialization and +execution state, deterministic breakpoint sets, and normalized thread, stack, +scope, variable, evaluation, output, stop, continue, and termination events. +Platforms own adapter discovery, JDT LS activation, sockets or process pipes, +native process termination, persistence, and UI rendering. + +`debug.createSession` accepts `{ sessionId, adapterId, rootPath, +supportsRunInTerminalRequest }`. It does not +open a socket or launch a process. It returns a session update in +`initializing` state with an ordered `outboundFrames` array. Each frame is a +complete Content-Length-framed byte sequence encoded as base64. Every Debug +command returns the same update shape: `{ sessionId, state, outboundFrames, +events }`. The platform writes frames in array order and feeds received chunks +back through `debug.receive` as `{ sessionId, dataBase64 }`; partial and +consecutive messages are buffered and reduced in Rust. + +When `supportsRunInTerminalRequest` is true, the initialize frame advertises +the native host's terminal capability. An adapter `runInTerminal` reverse +request becomes a deterministic `runInTerminalRequested` event containing a +Core-generated `requestId`, terminal kind, title, working directory, ordered +argument vector, sorted environment changes, and shell-interpretation flag. +The platform launches the process through its PTY/ConPTY adapter and calls +`debug.runInTerminalResponse` with `{ sessionId, requestId, success, processId?, +shellProcessId?, message? }`. Core validates process identifiers, emits the DAP +response, ignores duplicate or expired completions, and fails pending terminal +requests when the session disconnects. The shared compatibility cases are in +`shared/fixtures/debug/run-in-terminal-v1.json`. + +`debug.launch` accepts an `operationId` and a language-neutral configuration +containing `name`, request kind (`launch` or `attach`), provider arguments, and +optional portable `steppingFilters`. +`debug.javaTestLaunch` accepts JDT LS-owned working directory, main class, +project, classpath, module path, VM arguments, program arguments, Java test +framework, and a platform-owned loopback result port. JUnit placeholder ports +are replaced deterministically. TestNG appends the packaged runner once and +uses its selected method names. Core serializes JDT's VM and program argument +arrays into the string fields required by Java Debug Server's DAP launch model. +JDT LS remains responsible for resolving file, +class, and method selections to this metadata; Core does not parse Java source +or infer a test framework in this command. The command creates no process, +socket, timer, or persistent session; compatibility cases live in +`shared/fixtures/debug/java-test-launch-v1.json`. +Launch submitted during initialization is retained until the initialize +response. For Java, Core projects those filters into the adapter's `stepFilters` +launch object unless the provider arguments already contain an explicit value. +`debug.steppingFilters` accepts `{ adapterId, filters? }`; omission of `filters` +returns deterministic adapter defaults, while a supplied value is trimmed, +sorted, de-duplicated, and validated before persistence or launch. Omitted +fields inside a supplied value are empty or false, so future adapters never +inherit Java policy accidentally. Java class +patterns support `$JDK`, `$Libraries`, and adapter-compatible wildcards. Other +adapters default to an unfiltered policy until their integration defines one. +Java defaults include both `$JDK` and `$Libraries`, matching the IDE convention +of collapsing platform and dependency frames while retaining project frames. +The portable cases are in +`shared/fixtures/debug/stepping-filters-v1.json`. + +Normalized stack frames include `isFiltered`. Core derives it from the active +class filters using the DAP frame name, source path, presentation hint, and +session root. This classification is presentation metadata only: Core returns +the complete ordered stack, while native UIs may collapse consecutive matching +frames and must allow users to expand them. `debug.setBreakpoints` accepts +one-based line and optional column, +enabled state, condition, hit condition, and log message values. Rust sorts and +de-duplicates the complete source set, retains disabled entries without sending +them to the adapter, waits for the DAP `initialized` event, then sends all +sources in deterministic path order followed by `configurationDone` when the +adapter supports it. This allows native products to mute or restore breakpoints +without maintaining a second protocol representation. + +`debug.setExceptionBreakpoints` accepts adapter-defined filter identifiers, +enabled state, and an optional condition. Rust trims, sorts, and de-duplicates +the complete selection, retains disabled filters without sending them, and uses +DAP `filterOptions` only when the adapter negotiated that capability. Before a +native client has configured a selection, Rust adopts the adapter's declared +defaults so the first `initialized` flow sends exception filters before source +breakpoints and `configurationDone`. + +`debug.setFunctionBreakpoints` accepts a method or function name, enabled +state, condition, and hit condition. Rust retains the complete sorted set, +omits disabled entries, and sends DAP `setFunctionBreakpoints` before source +breakpoints only when the adapter negotiated function-breakpoint support. + +Data breakpoints use DAP's required two-step flow. The native client first calls +`debug.dataBreakpointInfo` with the selected variable name plus its parent +`variablesReference` and current frame. Rust Core correlates the response by +`operationId` and returns the adapter-owned `dataId`, display description, +allowed access modes, and `canPersist`. The client then calls +`debug.setDataBreakpoints`; Core keeps the complete deterministic set, omits +disabled entries, and sends access type, condition, and hit count only when the +adapter negotiated data-breakpoint support. Native clients must discard IDs +whose `canPersist` is false when the debug session ends. + +`debug.setVariable` accepts the selected variable's parent `variablesReference`, +name, and replacement text. Core permits mutation only while paused and after +the adapter advertises `supportsSetVariable`, then returns the adapter's +normalized replacement value and optional type through the caller's +`operationId`. + +`debug.execute` covers continue, pause, step over, step in, step out, step back, +restart, terminate, and capability-gated single-thread execution. Rust Core rejects stepping unless the session is paused +and a thread is selected, and gates step back, restart, and terminate against +the adapter capabilities negotiated during initialization. Restart and +terminate are session-level requests and never receive a stale `threadId`. +Single-thread pause, continue, and stepping preserve the paused session when +the adapter reports that other threads remain stopped. + +`debug.cancelOperation` removes the matching pending request before emitting a +terminal failure, so a late adapter response cannot mutate current UI state. If +the adapter advertises `supportsCancelRequest`, Core also sends DAP `cancel` +with the original request sequence. Native hosts own monotonic deadlines and +invoke this command with `cancelled` or `timedOut`; the macOS reference product +uses a bounded 10-second deadline for interactive inspections and mutations. + +Smart step into and run to cursor keep DAP's target lookup explicit. Clients +use `debug.inspect` with `stepInTargets` and a frame, or `gotoTargets` with a +source path and one-based cursor coordinates. Core normalizes the returned +targets and correlates them to the caller's operation. The selected target is +then passed as `targetId` to `debug.execute` using `stepIn` or `goto`; both +flows are rejected unless the adapter advertised the matching capability. + +The successful DAP initialize response emits a normalized `capabilities` event. +It includes conditional, hit-count, log, function, data, and exception +breakpoint support; variable mutation; restart and terminate requests; step +back; exception information; request cancellation; single-thread execution; +step-in targets; goto targets; and ordered exception filters. Native UIs +must treat capability state as unknown until this event arrives and hide or +disable unsupported actions after negotiation. + +`debug.execute` correlates continue, pause, next, step-in, and step-out to the +caller's `operationId`. `debug.inspect` supports `threads`, `stackTrace`, +`scopes`, `variables`, `evaluate`, and capability-gated `exceptionInfo`; +required thread, frame, variable reference, and expression fields are validated +before a request is emitted. A `variables` inspection may additionally carry +`variableFilter` (`named` or `indexed`), zero-based `start`, and positive +`count`; Core maps them to DAP `filter`, `start`, and `count` and rejects those +fields for every other inspection kind. Normalized scopes, variables, +evaluations, and variable-mutation results include non-negative +`namedVariables` and `indexedVariables` counts, using zero when the adapter +omits or reports an invalid negative value. The compatibility cases are in +`shared/fixtures/debug/variable-paging-v1.json`. + +Exception information is available only while +paused and normalizes the exception type, description, break mode, optional +stack trace, evaluation name, and nested exception details. The Java adapter +currently supplies the type, description, and break mode but no expandable +exception object reference, so native clients continue to inspect ordinary +frame scopes for local state. +Terminal operation events are exactly one of `operationCompleted` with a typed +result or `operationFailed` with the adapter command and safe message. Other +ordered events are `stateChanged`, `initialized`, `output`, `stopped`, +`continued`, `terminated`, and `breakpoint`. Source coordinates are one-based. +The compatibility flow is captured in +`shared/fixtures/debug/dap-session-v1.json`; exception normalization cases are +captured in `shared/fixtures/debug/exception-info-v1.json`. + +`debug.disconnect` emits the protocol handshake and enters `terminating`. +Core derives DAP `terminateDebuggee` from the session's request kind: `launch` +uses `true`, while `attach` and a session stopped before either request use +`false`. This prevents a remote detach from killing a JVM the IDE does not own. +The compatibility cases are in +`shared/fixtures/debug/disconnect-policy-v1.json`. The platform keeps the +socket or process alive long enough to flush the frame, then closes it and +calls `debug.destroySession`. A session allocates no process, socket, timer, or +background task, and no session exists until Debug is used. + `lsp.builtinCompletions`, `lsp.builtinHover`, and `lsp.builtinNavigation` are the no-process lightweight language path. They accept current-file text, an absolute `filePath`, and a zero-based LSP position. Completion returns @@ -404,8 +589,11 @@ provider such as JDT LS that has a later readiness signal, progress and `serviceReadyAbsoluteTimeoutMilliseconds` is the final safety cap. The defaults are 45 seconds idle and 10 minutes absolute; duplicate progress does not refresh the idle deadline. `jdtlsLaunchResources`, when present, -contains `launcherJarPath`, `configurationDirectory`, and `lombokAgentPath`; it -is valid only for the Java provider and requires `runtimeExecutablePath`. Rust +contains `launcherJarPath`, `configurationDirectory`, `lombokAgentPath`, the +legacy optional `javaDebugBundlePath`, and ordered +`javaExtensionBundlePaths`. It is valid only for the Java provider and requires +`runtimeExecutablePath`. Rust loads the legacy Debug bundle first when present, +then appends the extension bundle paths with stable de-duplication. Rust then uses `runtimeExecutablePath` as the process executable and constructs the complete deterministic JDT LS JVM argument list. When the structured object is absent, the selected `executablePath` and legacy wrapper arguments remain the @@ -425,10 +613,11 @@ diagnostic snapshot in `underlyingMessage`. Platform adapters own filesystem discovery and validate that packaged JDT LS contains the Equinox launcher, platform configuration directory, Lombok agent, -and bundled Java. They do not construct JVM commands. Packaged macOS and Windows -plans always use structured direct launch, so runtime startup has no shell, -PowerShell, or user-`PATH` dependency. Wrapper launch remains optional only for -external or older plans. +Java Debug Server, and bundled Java. Java Test-capable hosts additionally +validate their extension bundles and runner. They do not construct JVM commands. +Packaged macOS and Windows plans always use structured direct launch, so runtime +startup has no shell, PowerShell, or user-`PATH` dependency. Wrapper launch +remains optional only for external or older plans. For JDT LS, platform adapters observe root Maven/Gradle descriptor timestamps and sizes, names of direct Maven module directories, and the selected JDT LS diff --git a/shared/fixtures/debug/breakpoint-relocation-v1.json b/shared/fixtures/debug/breakpoint-relocation-v1.json new file mode 100644 index 000000000..f9e6fdc91 --- /dev/null +++ b/shared/fixtures/debug/breakpoint-relocation-v1.json @@ -0,0 +1,38 @@ +{ + "version": 1, + "cases": [ + { + "name": "inserted line moves breakpoint with statement", + "payload": { + "source": "class Main {\n void run() {}\n}\n", + "edit": { + "startUtf16Offset": 13, + "endUtf16Offset": 13, + "replacement": "\n" + }, + "breakpoints": [ + { + "line": 2, + "column": null, + "enabled": true, + "condition": "ready", + "hitCondition": "3", + "logMessage": null + } + ] + }, + "expected": { + "breakpoints": [ + { + "line": 3, + "column": null, + "enabled": true, + "condition": "ready", + "hitCondition": "3", + "logMessage": null + } + ] + } + } + ] +} diff --git a/shared/fixtures/debug/dap-session-v1.json b/shared/fixtures/debug/dap-session-v1.json new file mode 100644 index 000000000..89278e05a --- /dev/null +++ b/shared/fixtures/debug/dap-session-v1.json @@ -0,0 +1,140 @@ +{ + "version": 1, + "session": { + "sessionId": "contract-debug-session", + "adapterId": "java", + "rootPath": "/workspace" + }, + "launch": { + "operationId": "launch-main", + "configuration": { + "name": "Main", + "request": "launch", + "arguments": { + "mainClass": "example.Main" + } + } + }, + "breakpoints": { + "sourcePath": "/workspace/src/main/java/example/Main.java", + "values": [ + { + "line": 12, + "enabled": true, + "condition": "value > 1", + "hitCondition": "3", + "logMessage": "value = {value}" + }, + { + "line": 14, + "enabled": false + } + ] + }, + "exceptionBreakpoints": { + "values": [ + { + "filter": "caught", + "enabled": true, + "condition": "example.CustomException" + }, + { + "filter": "uncaught", + "enabled": false + } + ] + }, + "functionBreakpoints": { + "values": [ + { + "name": "example.Main.run", + "enabled": true, + "condition": "ready", + "hitCondition": "2" + }, + { + "name": "example.Main.skip", + "enabled": false + } + ] + }, + "dataBreakpointInfo": { + "operationId": "field-count", + "name": "count", + "variablesReference": 42, + "frameId": 7 + }, + "dataBreakpoints": { + "values": [ + { + "dataId": "field:count", + "label": "Main.count", + "enabled": true, + "accessType": "write", + "condition": "count > 1", + "hitCondition": "2" + } + ] + }, + "setVariable": { + "operationId": "set-count", + "variablesReference": 42, + "name": "count", + "value": "7" + }, + "adapterMessages": { + "initializeResponse": { + "seq": 101, + "type": "response", + "request_seq": 1, + "success": true, + "command": "initialize", + "body": { + "supportsConfigurationDoneRequest": true, + "supportsConditionalBreakpoints": true, + "supportsHitConditionalBreakpoints": true, + "supportsLogPoints": true, + "supportsFunctionBreakpoints": true, + "supportsDataBreakpoints": true, + "supportsSetVariable": true, + "supportsCancelRequest": true, + "supportsSingleThreadExecutionRequests": true, + "supportsRestartRequest": true, + "supportsExceptionInfoRequest": true, + "supportsExceptionFilterOptions": true, + "exceptionBreakpointFilters": [ + { + "filter": "caught", + "label": "Caught Exceptions", + "default": false, + "supportsCondition": true + }, + { + "filter": "uncaught", + "label": "Uncaught Exceptions", + "default": true, + "supportsCondition": false + } + ] + } + }, + "initializedEvent": { + "seq": 102, + "type": "event", + "event": "initialized" + } + }, + "expected": { + "createState": "initializing", + "createCommand": "initialize", + "postInitializeState": "launching", + "postInitializeCommand": "launch", + "configurationCommands": [ + "setExceptionBreakpoints", + "setFunctionBreakpoints", + "setDataBreakpoints", + "setBreakpoints", + "configurationDone" + ] + } +} diff --git a/shared/fixtures/debug/disconnect-policy-v1.json b/shared/fixtures/debug/disconnect-policy-v1.json new file mode 100644 index 000000000..386652db7 --- /dev/null +++ b/shared/fixtures/debug/disconnect-policy-v1.json @@ -0,0 +1,29 @@ +{ + "version": 1, + "cases": [ + { + "name": "local launch owns the debuggee", + "request": "launch", + "expectedArguments": { + "restart": false, + "terminateDebuggee": true + } + }, + { + "name": "remote attach does not own the debuggee", + "request": "attach", + "expectedArguments": { + "restart": false, + "terminateDebuggee": false + } + }, + { + "name": "unstarted session has no debuggee ownership", + "request": null, + "expectedArguments": { + "restart": false, + "terminateDebuggee": false + } + } + ] +} diff --git a/shared/fixtures/debug/exception-info-v1.json b/shared/fixtures/debug/exception-info-v1.json new file mode 100644 index 000000000..c7810523c --- /dev/null +++ b/shared/fixtures/debug/exception-info-v1.json @@ -0,0 +1,51 @@ +{ + "version": 1, + "request": { + "operationId": "exception-main", + "threadId": 13 + }, + "adapterResponse": { + "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": "java.lang.IllegalStateException: session expired\n\tat example.LoginService.login(LoginService.java:42)", + "innerException": [ + { + "message": "token expired", + "typeName": "TokenExpiredException", + "fullTypeName": "example.TokenExpiredException" + } + ] + } + }, + "expected": { + "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": "java.lang.IllegalStateException: session expired\n\tat example.LoginService.login(LoginService.java:42)", + "innerExceptions": [ + { + "message": "token expired", + "typeName": "TokenExpiredException", + "fullTypeName": "example.TokenExpiredException", + "evaluateName": null, + "stackTrace": null, + "innerExceptions": [] + } + ] + } + } + } +} diff --git a/shared/fixtures/debug/java-test-launch-v1.json b/shared/fixtures/debug/java-test-launch-v1.json new file mode 100644 index 000000000..9f680a190 --- /dev/null +++ b/shared/fixtures/debug/java-test-launch-v1.json @@ -0,0 +1,70 @@ +{ + "version": 1, + "cases": [ + { + "name": "junit replaces the result port", + "payload": { + "name": "UserServiceTest", + "framework": "junit", + "workingDirectory": "/workspace/service", + "mainClass": "org.eclipse.jdt.internal.junit.runner.RemoteTestRunner", + "projectName": "service", + "classPaths": ["/workspace/service/classes", "/workspace/service/classes", ""], + "modulePaths": [], + "vmArguments": ["--enable-preview"], + "programArguments": ["-version", "3", "-port", "-1"], + "resultPort": 43127, + "testngRunnerPath": null, + "testngTestNames": [] + }, + "expected": { + "name": "UserServiceTest", + "request": "launch", + "arguments": { + "mainClass": "org.eclipse.jdt.internal.junit.runner.RemoteTestRunner", + "cwd": "/workspace/service", + "console": "integratedTerminal", + "projectName": "service", + "classPaths": ["/workspace/service/classes"], + "args": "-version 3 -port 43127", + "vmArgs": "--enable-preview" + }, + "steppingFilters": null + } + }, + { + "name": "testng appends the packaged runner", + "payload": { + "name": "UserServiceTest", + "framework": "testng", + "workingDirectory": "/workspace/service", + "mainClass": "com.microsoft.java.test.runner.Launcher", + "projectName": "service", + "classPaths": ["/workspace/service/classes"], + "modulePaths": ["/workspace/service/modules"], + "vmArguments": [], + "programArguments": [], + "resultPort": 43128, + "testngRunnerPath": "/lithe/java-test-runner.jar", + "testngTestNames": [ + "example.UserServiceTest#logsIn", + "example.UserServiceTest#logsIn" + ] + }, + "expected": { + "name": "UserServiceTest", + "request": "launch", + "arguments": { + "mainClass": "com.microsoft.java.test.runner.Launcher", + "cwd": "/workspace/service", + "console": "integratedTerminal", + "projectName": "service", + "classPaths": ["/workspace/service/classes", "/lithe/java-test-runner.jar"], + "modulePaths": ["/workspace/service/modules"], + "args": "43128 testng example.UserServiceTest#logsIn" + }, + "steppingFilters": null + } + } + ] +} diff --git a/shared/fixtures/debug/run-in-terminal-v1.json b/shared/fixtures/debug/run-in-terminal-v1.json new file mode 100644 index 000000000..06d95d23d --- /dev/null +++ b/shared/fixtures/debug/run-in-terminal-v1.json @@ -0,0 +1,54 @@ +{ + "version": 1, + "adapterRequest": { + "seq": 44, + "type": "request", + "command": "runInTerminal", + "arguments": { + "kind": "integrated", + "title": "Debug Main", + "cwd": "/workspace/service", + "args": [ + "/opt/jdk/bin/java", + "-cp", + "/workspace/service/classes", + "example.Main" + ], + "env": { + "JAVA_HOME": "/opt/jdk", + "LITHE_REMOVE_ME": null + }, + "argsCanBeInterpretedByShell": false + } + }, + "expectedRequest": { + "kind": "integrated", + "title": "Debug Main", + "cwd": "/workspace/service", + "args": [ + "/opt/jdk/bin/java", + "-cp", + "/workspace/service/classes", + "example.Main" + ], + "environment": [ + { + "name": "JAVA_HOME", + "value": "/opt/jdk" + }, + { + "name": "LITHE_REMOVE_ME", + "value": null + } + ], + "argsCanBeInterpretedByShell": false + }, + "successResponse": { + "processId": 4242, + "shellProcessId": null + }, + "expectedSuccessBody": { + "processId": 4242 + }, + "failureMessage": "The integrated terminal is unavailable." +} diff --git a/shared/fixtures/debug/stepping-filters-v1.json b/shared/fixtures/debug/stepping-filters-v1.json new file mode 100644 index 000000000..1fa70005a --- /dev/null +++ b/shared/fixtures/debug/stepping-filters-v1.json @@ -0,0 +1,90 @@ +{ + "version": 1, + "cases": [ + { + "name": "java-defaults", + "payload": { + "adapterId": "java" + }, + "expected": { + "classNameFilters": [ + "$JDK", + "$Libraries", + "com.ibm.ws.*", + "com.springsource.loaded.*", + "com.sun.proxy.*", + "javassist.*", + "jdk.proxy*.*", + "junit.*", + "net.bytebuddy.*", + "net.sf.cglib.*", + "org.apache.webbeans.*", + "org.junit.*", + "org.mockito.*", + "org.springframework.aop.framework.*", + "org.springframework.cglib.*", + "org.springsource.loaded.*" + ], + "skipSynthetics": true, + "skipStaticInitializers": true, + "skipConstructors": false, + "hideFilteredStackFrames": true + } + }, + { + "name": "normalized-java-override", + "payload": { + "adapterId": "java", + "filters": { + "classNameFilters": [ + " org.mockito.* ", + "$JDK", + "org.mockito.*", + "" + ], + "skipSynthetics": true, + "skipStaticInitializers": false, + "skipConstructors": true, + "hideFilteredStackFrames": true + } + }, + "expected": { + "classNameFilters": [ + "$JDK", + "org.mockito.*" + ], + "skipSynthetics": true, + "skipStaticInitializers": false, + "skipConstructors": true, + "hideFilteredStackFrames": true + } + }, + { + "name": "unknown-adapter-is-unfiltered", + "payload": { + "adapterId": "go" + }, + "expected": { + "classNameFilters": [], + "skipSynthetics": false, + "skipStaticInitializers": false, + "skipConstructors": false, + "hideFilteredStackFrames": false + } + }, + { + "name": "unknown-adapter-empty-override-stays-unfiltered", + "payload": { + "adapterId": "python", + "filters": {} + }, + "expected": { + "classNameFilters": [], + "skipSynthetics": false, + "skipStaticInitializers": false, + "skipConstructors": false, + "hideFilteredStackFrames": false + } + } + ] +} diff --git a/shared/fixtures/debug/variable-paging-v1.json b/shared/fixtures/debug/variable-paging-v1.json new file mode 100644 index 000000000..40c643c51 --- /dev/null +++ b/shared/fixtures/debug/variable-paging-v1.json @@ -0,0 +1,55 @@ +{ + "version": 1, + "request": { + "operationId": "variables-customers-100", + "variablesReference": 700, + "variableFilter": "indexed", + "start": 100, + "count": 2 + }, + "adapterResponse": { + "variables": [ + { + "name": "[100]", + "value": "Customer@100", + "type": "example.Customer", + "evaluateName": "customers[100]", + "variablesReference": 701, + "namedVariables": 4, + "indexedVariables": 0 + }, + { + "name": "[101]", + "value": "Customer@101", + "type": "example.Customer", + "evaluateName": "customers[101]", + "variablesReference": 702, + "namedVariables": -3, + "indexedVariables": 5 + } + ] + }, + "expected": { + "kind": "variables", + "variables": [ + { + "name": "[100]", + "value": "Customer@100", + "type": "example.Customer", + "evaluateName": "customers[100]", + "variablesReference": 701, + "namedVariables": 4, + "indexedVariables": 0 + }, + { + "name": "[101]", + "value": "Customer@101", + "type": "example.Customer", + "evaluateName": "customers[101]", + "variablesReference": 702, + "namedVariables": 0, + "indexedVariables": 5 + } + ] + } +} diff --git a/shared/fixtures/lsp/jdt-direct-launch-v1.json b/shared/fixtures/lsp/jdt-direct-launch-v1.json index 921ea6420..90959256d 100644 --- a/shared/fixtures/lsp/jdt-direct-launch-v1.json +++ b/shared/fixtures/lsp/jdt-direct-launch-v1.json @@ -16,7 +16,12 @@ "jdtlsLaunchResources": { "launcherJarPath": "/opt/lithe/jdtls/plugins/org.eclipse.equinox.launcher_1.7.0.jar", "configurationDirectory": "/opt/lithe/jdtls/config_mac", - "lombokAgentPath": "/opt/lithe/jdtls/lombok/lombok.jar" + "lombokAgentPath": "/opt/lithe/jdtls/lombok/lombok.jar", + "javaDebugBundlePath": "/opt/lithe/jdtls/java-debug/com.microsoft.java.debug.plugin-0.53.1.jar", + "javaExtensionBundlePaths": [ + "/opt/lithe/jdtls/java-debug/com.microsoft.java.debug.plugin-0.53.1.jar", + "/opt/lithe/jdtls/java-test/extensions/com.microsoft.java.test.plugin-0.42.0.jar" + ] }, "cacheDirectory": "/var/cache/lithe/language-servers", "workspaceFingerprint": "build=|modules=|jdtls=1.55.0", diff --git a/third_party/jdtls/manifest.json b/third_party/jdtls/manifest.json index ddb001889..819eb5921 100644 --- a/third_party/jdtls/manifest.json +++ b/third_party/jdtls/manifest.json @@ -9,5 +9,19 @@ "lombokSHA256": "01f7b1a015e33e2b62d5f5f37053306357ab1415fd181fcba7794f5d198c1126", "lombokLicenseURL": "https://raw.githubusercontent.com/projectlombok/lombok/v1.18.46/LICENSE", "lombokLicenseSHA256": "76479448741d7a7a3a97b6afd9ab9699d95621faafc65a1b8e9149342ea00feb", + "javaDebugExtensionVersion": "0.58.1", + "javaDebugServerVersion": "0.53.1", + "javaDebugArchiveURL": "https://open-vsx.org/api/vscjava/vscode-java-debug/0.58.1/file/vscjava.vscode-java-debug-0.58.1.vsix", + "javaDebugArchiveSHA256": "d1edf57a28321afcb1d88ab8c525f1ec4edef837ccd9ce8fe2c7179f2c6a74f7", + "javaDebugPluginSHA256": "daaaa5f63f527dc1e9bfa7bae1aca006b69fb29fa0525d7e284d3420ec7b9c44", + "javaDebugLicenseURL": "https://raw.githubusercontent.com/microsoft/java-debug/0.53.1/LICENSE.txt", + "javaDebugLicenseSHA256": "f494326c16bc95ebb14874ea5fa2c16a963eb36d1f2ab6fe99490073709771c1", + "javaTestExtensionVersion": "0.42.0", + "javaTestArchiveURL": "https://open-vsx.org/api/vscjava/vscode-java-test/0.42.0/file/vscjava.vscode-java-test-0.42.0.vsix", + "javaTestArchiveSHA256": "6293167533595b812d0490c5e2b649f920bf61eb331f17de6046364fe3894bea", + "javaTestPluginSHA256": "3f8a5af986b0440223845f34e70860ee50a096375692dd5ebe69ebc8a75ab99f", + "javaTestRunnerSHA256": "f7f3298c28ae0a01f69e45744648a2a81867228e48027374669e4ef7bfc15bc7", + "javaTestLicenseURL": "https://raw.githubusercontent.com/microsoft/vscode-java-test/0.42.0/LICENSE.txt", + "javaTestLicenseSHA256": "8314299543336aa4fe5c1d1d6cf278d538b387aad74cba37e50f5c4d34add9f3", "minimumJavaVersion": 17 } diff --git a/windows/tauri/src-tauri/Cargo.lock b/windows/tauri/src-tauri/Cargo.lock index 625757a5f..554812551 100644 --- a/windows/tauri/src-tauri/Cargo.lock +++ b/windows/tauri/src-tauri/Cargo.lock @@ -2620,6 +2620,7 @@ dependencies = [ "regex", "serde", "serde_json", + "sha2", "tauri", "tauri-build", "tauri-plugin-clipboard-manager", diff --git a/windows/tauri/src-tauri/Cargo.toml b/windows/tauri/src-tauri/Cargo.toml index c0b46ba43..9ebac1fb5 100644 --- a/windows/tauri/src-tauri/Cargo.toml +++ b/windows/tauri/src-tauri/Cargo.toml @@ -17,6 +17,7 @@ keyring = { version = "3.6.3", features = ["windows-native"] } serde = { version = "1", features = ["derive"] } serde_json = "1" regex = "1" +sha2 = "0.10" tauri = { version = "2", features = ["common-controls-v6", "protocol-asset"] } tauri-plugin-clipboard-manager = "2" tauri-plugin-deep-link = "2" diff --git a/windows/tauri/src-tauri/src/main.rs b/windows/tauri/src-tauri/src/main.rs index a75d921c4..41a43c60d 100644 --- a/windows/tauri/src-tauri/src/main.rs +++ b/windows/tauri/src-tauri/src/main.rs @@ -5,6 +5,7 @@ mod file_events; mod host; mod logging; mod lsp; +mod maven; mod memory; mod platform; mod run; @@ -118,6 +119,8 @@ fn main() { host::create_app_window, lsp::lsp_resolve_java_launch, lsp::lsp_rebuild_java_index, + maven::maven_load_configuration, + maven::maven_write_configuration, run::run_list_java_sources, run::run_write_generated, run::run_write_documents, diff --git a/windows/tauri/src-tauri/src/maven.rs b/windows/tauri/src-tauri/src/maven.rs new file mode 100644 index 000000000..4187845fd --- /dev/null +++ b/windows/tauri/src-tauri/src/maven.rs @@ -0,0 +1,250 @@ +//! Windows persistence for Maven project and machine-local configuration. +//! +//! Portable selections stay below the workspace `.lithe` directory. Maven, +//! JDK, and settings paths are stored only in the application data directory. + +use crate::run::atomic_write; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::fs; +use std::path::{Path, PathBuf}; +use tauri::{AppHandle, Manager}; + +const MAVEN_CONFIGURATION_VERSION: u32 = 1; + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MavenPortableConfiguration { + pub version: u32, + #[serde(default)] + pub selected_profiles: Vec, + #[serde(default)] + pub custom_profiles: Vec, + #[serde(default)] + pub skip_tests: bool, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MavenLocalConfiguration { + pub version: u32, + #[serde(default)] + pub settings_path: Option, + #[serde(default)] + pub maven_executable_path: Option, + #[serde(default)] + pub java_home_path: Option, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MavenStoredConfiguration { + pub portable: Option, + pub local: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WriteMavenConfigurationArgs { + pub root: PathBuf, + pub reactor_path: String, + pub configuration: MavenStoredConfiguration, +} + +#[tauri::command] +pub fn maven_load_configuration( + app: AppHandle, + root: PathBuf, + reactor_path: String, +) -> Result { + let root = existing_directory(&root)?; + let portable = read_optional::(&portable_path(&root))?; + let local = read_optional::(&local_path(&app, &root, &reactor_path)?)?; + validate_versions(portable.as_ref(), local.as_ref())?; + Ok(MavenStoredConfiguration { portable, local }) +} + +#[tauri::command] +pub fn maven_write_configuration( + app: AppHandle, + args: WriteMavenConfigurationArgs, +) -> Result<(), String> { + let root = existing_directory(&args.root)?; + validate_versions( + args.configuration.portable.as_ref(), + args.configuration.local.as_ref(), + )?; + write_optional(&portable_path(&root), args.configuration.portable.as_ref())?; + write_optional( + &local_path(&app, &root, &args.reactor_path)?, + args.configuration.local.as_ref(), + ) +} + +fn validate_versions( + portable: Option<&MavenPortableConfiguration>, + local: Option<&MavenLocalConfiguration>, +) -> Result<(), String> { + if portable.is_some_and(|value| value.version != MAVEN_CONFIGURATION_VERSION) + || local.is_some_and(|value| value.version != MAVEN_CONFIGURATION_VERSION) + { + return Err( + "The Maven configuration was created by an unsupported version of Lithe.".into(), + ); + } + Ok(()) +} + +fn portable_path(root: &Path) -> PathBuf { + root.join(".lithe").join("maven").join("config.json") +} + +fn local_path(app: &AppHandle, root: &Path, reactor_path: &str) -> Result { + let app_data = app + .path() + .app_data_dir() + .map_err(|error| error.to_string())?; + let mut digest = Sha256::new(); + let identity = storage_identity(&root.to_string_lossy(), reactor_path); + digest.update(identity.as_bytes()); + Ok(app_data + .join("maven") + .join(format!("{:x}.json", digest.finalize()))) +} + +fn storage_identity(workspace_path: &str, reactor_path: &str) -> String { + format!( + "{}\0{}", + workspace_path.to_lowercase(), + reactor_path.replace('\\', "/") + ) +} + +fn existing_directory(path: &Path) -> Result { + let root = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); + if !root.is_dir() { + return Err("The project directory is unavailable.".into()); + } + Ok(root) +} + +fn read_optional(path: &Path) -> Result, String> { + if !path.is_file() { + return Ok(None); + } + let contents = fs::read(path).map_err(|_| { + format!( + "Unable to read Maven configuration {}.", + path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or("file") + ) + })?; + serde_json::from_slice(&contents).map(Some).map_err(|_| { + format!( + "The Maven configuration in {} is invalid.", + path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or("file") + ) + }) +} + +fn write_optional(path: &Path, value: Option<&T>) -> Result<(), String> { + let Some(value) = value else { + if path.is_file() { + fs::remove_file(path).map_err(|error| error.to_string())?; + } + return Ok(()); + }; + let parent = path + .parent() + .ok_or_else(|| "Maven configuration path has no parent directory.".to_string())?; + fs::create_dir_all(parent).map_err(|error| error.to_string())?; + let mut contents = serde_json::to_string_pretty(value).map_err(|error| error.to_string())?; + contents.push('\n'); + atomic_write(path, contents.as_bytes()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; + + fn temp_directory() -> PathBuf { + static NEXT_DIRECTORY_ID: AtomicU64 = AtomicU64::new(1); + let id = NEXT_DIRECTORY_ID.fetch_add(1, Ordering::Relaxed); + let path = + std::env::temp_dir().join(format!("lithe-maven-config-{}-{id}", std::process::id())); + fs::create_dir_all(&path).expect("temp directory"); + path + } + + #[test] + fn portable_configuration_round_trips_without_local_paths() { + let root = temp_directory(); + let path = portable_path(&root); + let portable = MavenPortableConfiguration { + version: 1, + selected_profiles: vec!["dev".into(), "qa".into()], + custom_profiles: vec!["qa".into()], + skip_tests: true, + }; + write_optional(&path, Some(&portable)).expect("write portable configuration"); + let loaded = read_optional::(&path) + .expect("read portable configuration") + .expect("portable configuration"); + + assert_eq!(loaded.selected_profiles, ["dev", "qa"]); + assert!(loaded.skip_tests); + let text = fs::read_to_string(&path).expect("portable text"); + assert!(!text.contains("settingsPath")); + assert!(!text.contains("mavenExecutablePath")); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn rejects_unsupported_configuration_versions() { + let portable = MavenPortableConfiguration { + version: 2, + selected_profiles: Vec::new(), + custom_profiles: Vec::new(), + skip_tests: false, + }; + assert!(validate_versions(Some(&portable), None) + .unwrap_err() + .contains("unsupported version")); + } + + #[test] + fn windows_storage_identity_matches_shared_contract() { + let fixture: serde_json::Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../shared/fixtures/maven/platform-contract-v1.json" + ))) + .expect("Maven platform contract fixture"); + let cases = fixture["storageIdentityCases"] + .as_array() + .expect("storage identity cases"); + let windows_cases: Vec<_> = cases + .iter() + .filter(|item| item["platform"] == "windows") + .collect(); + + assert!( + !windows_cases.is_empty(), + "Windows fixture case is required" + ); + for item in windows_cases { + assert_eq!( + storage_identity( + item["workspacePath"].as_str().expect("workspace path"), + item["reactorPath"].as_str().expect("reactor path"), + ), + item["expectedIdentity"] + .as_str() + .expect("expected identity") + ); + } + } +} diff --git a/windows/tauri/src-tauri/src/run.rs b/windows/tauri/src-tauri/src/run.rs index 323e0b205..98b053290 100644 --- a/windows/tauri/src-tauri/src/run.rs +++ b/windows/tauri/src-tauri/src/run.rs @@ -440,7 +440,7 @@ fn validate_write_target(root: &Path, target: &Path) -> Result<(), String> { Ok(()) } -fn atomic_write(path: &Path, contents: &[u8]) -> Result<(), String> { +pub(crate) fn atomic_write(path: &Path, contents: &[u8]) -> Result<(), String> { if path.exists() { if let Ok(existing) = fs::read(path) { if existing == contents { diff --git a/windows/tauri/src/features/editor/components/monaco-editor.tsx b/windows/tauri/src/features/editor/components/monaco-editor.tsx index fa2d91763..d8d4001f0 100644 --- a/windows/tauri/src/features/editor/components/monaco-editor.tsx +++ b/windows/tauri/src/features/editor/components/monaco-editor.tsx @@ -32,6 +32,7 @@ import { InlineEditPopover } from "@/features/editor/inline-edit/inline-edit-pop import { useInlineEdit } from "@/features/editor/inline-edit/use-inline-edit"; import { useInlineEditToolbarStore } from "@/features/editor/stores/inline-edit-toolbar.store"; import { useFileSystemStore } from "@/features/file-system/stores/file-system.store"; +import { useActiveWorkspaceId } from "@/features/workspace/stores/create-workspace-scoped-store"; import { useGitBlame } from "@/features/git/hooks/use-git-blame"; import { keymapRegistry } from "@/features/keymaps/utils/registry"; import { useSettingsStore } from "@/features/settings/stores/settings.store"; @@ -272,6 +273,7 @@ export function MonacoEditor({ javaMarkerRefreshRevision(state.lspStatus), ); const inlineGitBlameEnabled = useSettingsStore((state) => state.settings.enableInlineGitBlame); + const workspaceId = useActiveWorkspaceId(); const rootFolderPath = useFileSystemStore((state) => state.rootFolderPath); const workspaceFolders = useFileSystemStore((state) => state.workspaceFolders); const vimModeEnabled = useSettingsStore((state) => state.settings.vimMode); @@ -795,7 +797,7 @@ export function MonacoEditor({ editor, model, documentTarget, - workspaceRoot: rootFolderPath, + workspaceScope: rootFolderPath ? { workspaceId, root: rootFolderPath } : undefined, enabled: enableExpensiveServices, }); let definitionClickIntent = 0; @@ -1127,6 +1129,7 @@ export function MonacoEditor({ renderIndentGuides, renderWhitespace, rootFolderPath, + workspaceId, scrollable, scheduleInlineGitBlameRender, selectEntireModel, @@ -1371,7 +1374,7 @@ export function MonacoEditor({ const markers = await loadJavaNavigationMarkers({ client: lspClient, target: documentTarget, - workspaceRoot: rootFolderPath, + workspaceScope: { workspaceId, root: rootFolderPath }, content: model.getValue(), }); if (isDisposed()) { @@ -1450,6 +1453,7 @@ export function MonacoEditor({ javaMarkerRevision, monacoLanguageId, rootFolderPath, + workspaceId, ]); useEffect(() => { diff --git a/windows/tauri/src/features/editor/engines/monaco/definition-link.ts b/windows/tauri/src/features/editor/engines/monaco/definition-link.ts index e17ee4f0f..527da1f6c 100644 --- a/windows/tauri/src/features/editor/engines/monaco/definition-link.ts +++ b/windows/tauri/src/features/editor/engines/monaco/definition-link.ts @@ -1,6 +1,7 @@ import { editor as monacoEditor, Range as MonacoRange } from "monaco-editor"; import type * as Monaco from "monaco-editor"; import type { DefinitionNavigationHint } from "@/features/editor/lsp/definition-navigation-hint"; +import type { WorkspaceLaunchScope } from "@/features/workspace/types/workspace-launch-scope"; import { isEditorLspTargetSupported, type LspDocumentTarget, @@ -25,7 +26,7 @@ interface MonacoDefinitionLinkOptions { editor: Monaco.editor.IStandaloneCodeEditor; model: Monaco.editor.ITextModel; documentTarget: LspDocumentTarget; - workspaceRoot?: string; + workspaceScope?: WorkspaceLaunchScope; enabled?: boolean; } @@ -54,7 +55,7 @@ export function registerMonacoDefinitionLinkGesture({ editor, model, documentTarget, - workspaceRoot, + workspaceScope, enabled = true, }: MonacoDefinitionLinkOptions): MonacoDefinitionLinkGesture { const decorations = editor.createDecorationsCollection(); @@ -114,7 +115,7 @@ export function registerMonacoDefinitionLinkGesture({ } const lspClient = LspClient.getInstance(); if ( - workspaceRoot && + workspaceScope && !isDocumentFeatureAvailable( lspClient.getDocumentAvailability(documentTarget, "definition"), ) @@ -122,7 +123,7 @@ export function registerMonacoDefinitionLinkGesture({ try { await lspClient.ensureDocumentReady( documentTarget, - workspaceRoot, + workspaceScope, model.getValue(), "definition", ); @@ -143,7 +144,7 @@ export function registerMonacoDefinitionLinkGesture({ model.isDisposed() || model.getLanguageId() !== "java" || documentTarget.documentUri || - !workspaceRoot + !workspaceScope ) { return { locations }; } @@ -152,7 +153,7 @@ export function registerMonacoDefinitionLinkGesture({ const lombokDefinition = await resolveLombokAccessorDefinition({ source: model.getValue(), sourceFilePath: documentTarget.filePath, - workspaceRoot, + workspaceRoot: workspaceScope.root, line, character: request.character, }); diff --git a/windows/tauri/src/features/editor/hooks/use-lsp-integration.ts b/windows/tauri/src/features/editor/hooks/use-lsp-integration.ts index 609471fa2..f1cebfd1e 100644 --- a/windows/tauri/src/features/editor/hooks/use-lsp-integration.ts +++ b/windows/tauri/src/features/editor/hooks/use-lsp-integration.ts @@ -12,6 +12,8 @@ import { useBufferStore } from "@/features/editor/stores/buffer.store"; import { getSourceEditorBufferByPath } from "@/features/editor/utils/buffer-index"; import { logger } from "@/features/editor/utils/logger"; import { useFileSystemStore } from "@/features/file-system/stores/file-system.store"; +import { useActiveWorkspaceId } from "@/features/workspace/stores/create-workspace-scoped-store"; +import { workspaceScopeMatchesRoot } from "@/features/workspace/types/workspace-launch-scope"; import { getDirName } from "@/utils/path-helpers"; interface UseLspIntegrationOptions { @@ -67,6 +69,7 @@ export const useLspIntegration = ({ contentRevision = 0, }: UseLspIntegrationOptions) => { const lspClient = useMemo(() => LspClient.getInstance(), []); + const workspaceId = useActiveWorkspaceId(); const rootFolderPath = useFileSystemStore((state) => state.rootFolderPath); const installedExtensions = useExtensionStore.use.installedExtensions(); const activeFilePath = enabled ? filePath : undefined; @@ -85,6 +88,17 @@ export const useLspIntegration = ({ logger.warn("LspIntegration", `Could not determine workspace path for ${filePath}`); return; } + const scope = { workspaceId, root: workspacePath }; + if ( + rootFolderPath && + !workspaceScopeMatchesRoot( + scope, + useFileSystemStore.getStore(workspaceId).getState().rootFolderPath, + ) + ) { + logger.warn("LspIntegration", `Ignoring stale workspace scope for ${filePath}`); + return; + } const existingOwner = documentOwnersRef.current.get(filePath); const owner: LspDocumentOwner = @@ -141,7 +155,7 @@ export const useLspIntegration = ({ const initializeLsp = async () => { try { logger.debug("LspIntegration", `Starting LSP for ${filePath} in ${workspacePath}`); - const attachment = await lspClient.startForFile(filePath, workspacePath); + const attachment = await lspClient.startForFile(filePath, scope); if (attachment.kind !== "attached") { owner.state = { phase: "stopped" }; if (documentOwnersRef.current.get(filePath) === owner) { @@ -183,7 +197,7 @@ export const useLspIntegration = ({ cancelInitialization(); cleanupDocument(); }; - }, [enabled, filePath, isLspSupported, lspClient, rootFolderPath]); + }, [enabled, filePath, isLspSupported, lspClient, rootFolderPath, workspaceId]); useEffect(() => { if (!enabled || !filePath || !isLspSupported) return; diff --git a/windows/tauri/src/features/editor/lsp/java-navigation-marker-loader.test.ts b/windows/tauri/src/features/editor/lsp/java-navigation-marker-loader.test.ts index 3515bf564..b99161d78 100644 --- a/windows/tauri/src/features/editor/lsp/java-navigation-marker-loader.test.ts +++ b/windows/tauri/src/features/editor/lsp/java-navigation-marker-loader.test.ts @@ -29,14 +29,14 @@ test("attaches the Java document before requesting gutter markers", async () => const markers = await loadJavaNavigationMarkers({ client: { ensureDocumentReady, getJavaNavigationMarkers }, target, - workspaceRoot: "C:/work", + workspaceScope: { workspaceId: "workspace-a", root: "C:/work" }, content: "interface Service {}", }); expect(calls).toEqual(["ensureDocumentReady", "getJavaNavigationMarkers"]); expect(ensureDocumentReady).toHaveBeenCalledWith( target, - "C:/work", + { workspaceId: "workspace-a", root: "C:/work" }, "interface Service {}", "codeLens", ); diff --git a/windows/tauri/src/features/editor/lsp/java-navigation-marker-loader.ts b/windows/tauri/src/features/editor/lsp/java-navigation-marker-loader.ts index c296d16e7..9114dde29 100644 --- a/windows/tauri/src/features/editor/lsp/java-navigation-marker-loader.ts +++ b/windows/tauri/src/features/editor/lsp/java-navigation-marker-loader.ts @@ -1,11 +1,12 @@ import type { JavaImplementationMarker } from "./java-navigation-models"; import type { LspDocumentAvailability } from "./lsp-client"; import type { LspDocumentTarget } from "./lsp-document-target"; +import type { WorkspaceLaunchScope } from "@/features/workspace/types/workspace-launch-scope"; export interface JavaNavigationMarkerClient { ensureDocumentReady( target: LspDocumentTarget, - workspaceRoot: string, + scope: WorkspaceLaunchScope, content: string, feature?: string, ): Promise; @@ -15,7 +16,7 @@ export interface JavaNavigationMarkerClient { interface LoadJavaNavigationMarkersOptions { client: JavaNavigationMarkerClient; target: LspDocumentTarget; - workspaceRoot: string; + workspaceScope: WorkspaceLaunchScope; content: string; } @@ -27,9 +28,9 @@ interface LoadJavaNavigationMarkersOptions { export async function loadJavaNavigationMarkers({ client, target, - workspaceRoot, + workspaceScope, content, }: LoadJavaNavigationMarkersOptions): Promise { - await client.ensureDocumentReady(target, workspaceRoot, content, "codeLens"); + await client.ensureDocumentReady(target, workspaceScope, content, "codeLens"); return client.getJavaNavigationMarkers(target); } diff --git a/windows/tauri/src/features/editor/lsp/java-workspace-language-server.test.ts b/windows/tauri/src/features/editor/lsp/java-workspace-language-server.test.ts index a2634a881..49d4b4702 100644 --- a/windows/tauri/src/features/editor/lsp/java-workspace-language-server.test.ts +++ b/windows/tauri/src/features/editor/lsp/java-workspace-language-server.test.ts @@ -1,6 +1,11 @@ -import { expect, mock, test } from "bun:test"; +import { afterEach, expect, mock, test } from "bun:test"; +import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; import { JavaWorkspaceLanguageServerOwner } from "./java-workspace-language-server"; +const workspaceA = { workspaceId: "workspace-a", root: "C:/work" }; + +afterEach(() => workspaceRuntimeRegistry.resetForTests()); + function operationRecorder() { const outcomes: string[] = []; const operationIds: string[] = []; @@ -43,14 +48,14 @@ test("shares one Java workspace prewarm and reports readiness once", async () => () => undefined, ); - const first = owner.prewarm("C:/work", "C:/work/src/Main.java"); - const second = owner.prewarm("C:\\work", "C:/work/src/Other.java"); + const first = owner.prewarm(workspaceA, "C:/work/src/Main.java"); + const second = owner.prewarm({ ...workspaceA, root: "C:\\work" }, "C:/work/src/Other.java"); expect(start).toHaveBeenCalledTimes(1); releaseStart?.({ kind: "ready" }); expect(await first).toEqual({ kind: "ready" }); expect(await second).toEqual({ kind: "ready" }); - expect(await owner.prewarm("C:/work", "C:/work/src/Third.java")).toEqual({ kind: "ready" }); + expect(await owner.prewarm(workspaceA, "C:/work/src/Third.java")).toEqual({ kind: "ready" }); expect(operations.outcomes).toEqual(["succeeded"]); expect(operations.names).toEqual(["workspacePrewarm"]); expect(notifyReady).toHaveBeenCalledTimes(1); @@ -78,8 +83,8 @@ test("closing a workspace cancels an in-flight prewarm and stops its server", as () => undefined, ); - const prewarm = owner.prewarm("C:/work", "C:/work/src/Main.java"); - const close = owner.stop("C:\\work"); + const prewarm = owner.prewarm(workspaceA, "C:/work/src/Main.java"); + const close = owner.stop({ ...workspaceA, root: "C:\\work" }); releaseStart?.({ kind: "ready" }); expect(await prewarm).toEqual({ @@ -113,7 +118,7 @@ test("records a timeout without converting it to a generic failure", async () => notifyFailure, ); - expect(await owner.prewarm("C:/work", "C:/work/src/Main.java")).toEqual({ + expect(await owner.prewarm(workspaceA, "C:/work/src/Main.java")).toEqual({ kind: "timedOut", error: timeout, }); @@ -152,15 +157,15 @@ test("waits for a stopping owner before starting a replacement workspace session () => undefined, ); - const first = owner.prewarm("C:/work", "C:/work/src/First.java"); - const stopping = owner.stop("C:/work"); + const first = owner.prewarm(workspaceA, "C:/work/src/First.java"); + const stopping = owner.stop(workspaceA); startResolvers[0]?.({ kind: "ready" }); expect(await first).toEqual({ kind: "cancelled", reason: "workspace-closed-before-ready", }); - const replacement = owner.prewarm("C:/work", "C:/work/src/Second.java"); + const replacement = owner.prewarm(workspaceA, "C:/work/src/Second.java"); await Promise.resolve(); expect(stop).toHaveBeenCalledTimes(1); releaseStop?.(); @@ -194,11 +199,11 @@ test("creates a new operation after a failed start is retried", async () => { () => undefined, ); - expect(await owner.prewarm("C:/work", "C:/work/src/Main.java")).toEqual({ + expect(await owner.prewarm(workspaceA, "C:/work/src/Main.java")).toEqual({ kind: "failed", error: failure, }); - expect(await owner.prewarm("C:/work", "C:/work/src/Main.java")).toEqual({ kind: "ready" }); + expect(await owner.prewarm(workspaceA, "C:/work/src/Main.java")).toEqual({ kind: "ready" }); expect(operations.outcomes).toEqual(["failed", "succeeded"]); expect(operations.operationIds).toHaveLength(2); @@ -220,7 +225,7 @@ test("reports a configured workspace without a usable runtime as unavailable", a notifyFailure, ); - expect(await owner.prewarm("C:/work", "C:/work/src/Main.java")).toEqual({ + expect(await owner.prewarm(workspaceA, "C:/work/src/Main.java")).toEqual({ kind: "unavailable", reason: "notConfigured", }); @@ -232,3 +237,31 @@ test("reports a configured workspace without a usable runtime as unavailable", a expect.any(Function), ); }); + +test("keeps workspace A scope when retrying while workspace B is active", async () => { + const retryCallbacks: Array<() => void> = []; + let startAttempt = 0; + const start = mock(async (_scope: typeof workspaceA) => { + startAttempt += 1; + if (startAttempt === 1) throw new Error("first start failed"); + return { kind: "ready" } as const; + }); + const owner = new JavaWorkspaceLanguageServerOwner( + { start, stop: async () => undefined }, + operationRecorder().factory, + () => undefined, + () => undefined, + () => undefined, + (_workspacePath, _languageId, _failure, retry) => retryCallbacks.push(retry), + ); + + workspaceRuntimeRegistry.activateWorkspace({ id: "workspace-b", name: "B" }, "ready"); + await owner.prewarm(workspaceA, "C:/work/src/Main.java"); + expect(retryCallbacks).toHaveLength(1); + retryCallbacks[0]!(); + expect(await owner.prewarm(workspaceA, "C:/work/src/Main.java")).toEqual({ kind: "ready" }); + + expect(workspaceRuntimeRegistry.getActiveWorkspaceId()).toBe("workspace-b"); + expect(start).toHaveBeenCalledTimes(2); + expect(start.mock.calls.map((call) => call[0])).toEqual([workspaceA, workspaceA]); +}); diff --git a/windows/tauri/src/features/editor/lsp/java-workspace-language-server.ts b/windows/tauri/src/features/editor/lsp/java-workspace-language-server.ts index 91c2df416..47ec4e150 100644 --- a/windows/tauri/src/features/editor/lsp/java-workspace-language-server.ts +++ b/windows/tauri/src/features/editor/lsp/java-workspace-language-server.ts @@ -1,4 +1,5 @@ import { LspOperationLog } from "@/platform/lsp-session-lifecycle"; +import type { WorkspaceLaunchScope } from "@/features/workspace/types/workspace-launch-scope"; import { JAVA_LANGUAGE_ID } from "./built-in-language-support"; import { clearLanguageServerReadyFeedback, @@ -14,7 +15,7 @@ import { interface WorkspaceLanguageServerClient { start( - workspacePath: string, + scope: WorkspaceLaunchScope, representativeFilePath?: string, ): Promise; stop(workspacePath: string): Promise; @@ -50,13 +51,13 @@ type WorkspaceOwnerState = interface WorkspaceOwner { operationId: string; operation: OperationLog; - workspacePath: string; + scope: WorkspaceLaunchScope; representativeJavaFile: string; state: WorkspaceOwnerState; } -function workspaceKey(workspacePath: string): string { - return workspacePath.replace(/\\/g, "/").toLowerCase(); +function workspaceKey(scope: WorkspaceLaunchScope): string { + return `${scope.workspaceId}\0${scope.root.replace(/\\/g, "/").toLowerCase()}`; } function isTimeout(error: unknown): boolean { @@ -92,21 +93,22 @@ export class JavaWorkspaceLanguageServerOwner { ) {} prewarm( - workspacePath: string, + scope: WorkspaceLaunchScope, representativeJavaFile: string, ): Promise { - const key = workspaceKey(workspacePath); + const workspacePath = scope.root; + const key = workspaceKey(scope); const existing = this.owners.get(key); if (existing) { if (existing.state.phase === "starting" || existing.state.phase === "ready") { return existing.state.task; } if (existing.state.phase === "stopping") { - return existing.state.task.then(() => this.prewarm(workspacePath, representativeJavaFile)); + return existing.state.task.then(() => this.prewarm(scope, representativeJavaFile)); } if (existing.state.phase === "stopFailed") { - return this.stop(workspacePath).then(() => - this.prewarm(workspacePath, representativeJavaFile), + return this.stop(scope).then(() => + this.prewarm(scope, representativeJavaFile), ); } this.owners.delete(key); @@ -114,6 +116,7 @@ export class JavaWorkspaceLanguageServerOwner { const operationId = crypto.randomUUID(); const operation = this.createOperationLog("workspacePrewarm", operationId, { + workspaceId: scope.workspaceId, workspacePath, languageId: JAVA_LANGUAGE_ID, }); @@ -121,7 +124,7 @@ export class JavaWorkspaceLanguageServerOwner { const owner: WorkspaceOwner = { operationId, operation, - workspacePath, + scope, representativeJavaFile, state: { phase: "created" }, }; @@ -135,9 +138,10 @@ export class JavaWorkspaceLanguageServerOwner { owner: WorkspaceOwner, key: string, ): Promise { - const { workspacePath, representativeJavaFile, operation } = owner; + const { scope, representativeJavaFile, operation } = owner; + const workspacePath = scope.root; try { - const startOutcome = await this.client.start(workspacePath, representativeJavaFile); + const startOutcome = await this.client.start(scope, representativeJavaFile); if (this.owners.get(key) !== owner) { operation.cancelled("superseded-owner"); return { kind: "cancelled", reason: "superseded-owner" }; @@ -152,7 +156,7 @@ export class JavaWorkspaceLanguageServerOwner { workspacePath, JAVA_LANGUAGE_ID, { kind: "unavailable" }, - () => void this.prewarm(workspacePath, representativeJavaFile), + () => void this.prewarm(scope, representativeJavaFile), ); if (this.owners.get(key) === owner) this.owners.delete(key); return { kind: "unavailable", reason: startOutcome.kind }; @@ -184,20 +188,22 @@ export class JavaWorkspaceLanguageServerOwner { kind: timedOut ? "timedOut" : "failed", detail: error instanceof Error ? error.message : String(error), }, - () => void this.prewarm(workspacePath, representativeJavaFile), + () => void this.prewarm(scope, representativeJavaFile), ); if (this.owners.get(key) === owner) this.owners.delete(key); return timedOut ? { kind: "timedOut", error } : { kind: "failed", error }; } } - async stop(workspacePath: string): Promise { - const key = workspaceKey(workspacePath); + async stop(scope: WorkspaceLaunchScope): Promise { + const workspacePath = scope.root; + const key = workspaceKey(scope); const owner = this.owners.get(key); if (owner?.state.phase === "stopping") return owner.state.task; const operationId = crypto.randomUUID(); const operation = this.createOperationLog("workspaceStop", operationId, { + workspaceId: scope.workspaceId, workspacePath, languageId: JAVA_LANGUAGE_ID, }); diff --git a/windows/tauri/src/features/editor/lsp/lsp-client.ts b/windows/tauri/src/features/editor/lsp/lsp-client.ts index 89f07cbe3..ad1d588aa 100644 --- a/windows/tauri/src/features/editor/lsp/lsp-client.ts +++ b/windows/tauri/src/features/editor/lsp/lsp-client.ts @@ -58,6 +58,10 @@ import { type WorkspaceEdit, } from "./workspace-edit"; import type { LspAdapterSessionPhase } from "@/platform/lsp-session-lifecycle"; +import { + workspaceScopesMatch, + type WorkspaceLaunchScope, +} from "@/features/workspace/types/workspace-launch-scope"; export type LspWorkspaceStartOutcome = | { kind: "ready" } @@ -128,10 +132,15 @@ type TrackedLspDocument = { }; type PendingFileStart = { - workspacePath: string; + scope: WorkspaceLaunchScope; task: Promise; }; +type PendingWorkspaceStart = { + scope: WorkspaceLaunchScope; + task: Promise; +}; + type LspFileStartIntent = "attach" | "manualRestart"; type LspFileStartAttempt = @@ -201,12 +210,13 @@ export class LspClient { private activeLanguages = new Set(); // Track active language IDs for status private activeServerFiles = new Map>(); // workspace:language -> tracked files private workspaceRepresentativeFiles = new Map(); + private serverScopes = new Map(); /** workspace:language -> failure timestamp (ms); expired after a short cooldown. */ private failedLanguageServers = new Map(); private repairLanguageServerPromises = new Map>(); private fileAttachmentIds = new Map(); private fileStartTasks = new Map(); - private workspaceStartTasks = new Map>(); + private workspaceStartTasks = new Map(); private documentOpenTasks = new Map(); private documents = new Map(); @@ -347,6 +357,7 @@ export class LspClient { if (trackedFiles.size === 0) { this.activeServerFiles.delete(existingKey); this.activeLanguageServers.delete(existingKey); + this.serverScopes.delete(existingKey); } } const trackedFiles = this.activeServerFiles.get(serverKey) ?? new Set(); @@ -357,8 +368,14 @@ export class LspClient { this.activeServerFiles.set(serverKey, trackedFiles); } - private registerActiveServer(serverKey: string, languageId: string, filePath?: string) { + private registerActiveServer( + serverKey: string, + languageId: string, + scope: WorkspaceLaunchScope, + filePath?: string, + ) { this.activeLanguageServers.add(serverKey); + this.serverScopes.set(serverKey, scope); if (filePath) this.addTrackedFile(serverKey, filePath); this.activeLanguages.add(getLanguageDisplayName(languageId)); this.updateLspStatus(); @@ -551,9 +568,10 @@ export class LspClient { } async start( - workspacePath: string, + scope: WorkspaceLaunchScope, representativeFilePath?: string, ): Promise { + const workspacePath = scope.root; try { logger.debug("LSPClient", "Starting LSP with workspace:", workspacePath); @@ -563,7 +581,7 @@ export class LspClient { } const launch = representativeFilePath - ? await resolveEditorLspLaunch(representativeFilePath, workspacePath) + ? await resolveEditorLspLaunch(representativeFilePath, scope) : null; if (!launch) { logger.debug("LSPClient", `No LSP server configured for workspace ${workspacePath}`); @@ -579,12 +597,15 @@ export class LspClient { if (representativeFilePath) { this.workspaceRepresentativeFiles.set(serverKey, representativeFilePath); } - this.registerActiveServer(serverKey, launch.languageId); + this.registerActiveServer(serverKey, launch.languageId, scope); return { kind: "ready" } as const; } const existingTask = this.workspaceStartTasks.get(serverKey); - if (existingTask) return existingTask; + if (existingTask && workspaceScopesMatch(existingTask.scope, scope)) { + return existingTask.task; + } + if (existingTask) await existingTask.task; const task: Promise = (async (): Promise => { logger.debug( @@ -605,20 +626,21 @@ export class LspClient { cacheDirectory: launch.cacheDirectory || null, environment: launch.environment || null, workspaceFingerprint: launch.workspaceFingerprint || null, + mavenContext: launch.mavenContext || null, }); if (representativeFilePath) { this.workspaceRepresentativeFiles.set(serverKey, representativeFilePath); } - this.registerActiveServer(serverKey, launch.languageId); + this.registerActiveServer(serverKey, launch.languageId, scope); logger.debug("LSPClient", "LSP started successfully for workspace:", workspacePath); return { kind: "ready" }; })().finally(() => { - if (this.workspaceStartTasks.get(serverKey) === task) { + if (this.workspaceStartTasks.get(serverKey)?.task === task) { this.workspaceStartTasks.delete(serverKey); } }); - this.workspaceStartTasks.set(serverKey, task); + this.workspaceStartTasks.set(serverKey, { scope, task }); return await task; } catch (error) { logger.error("LSPClient", "Failed to start LSP:", error); @@ -633,7 +655,7 @@ export class LspClient { const workspaceKey = trackedFileKey(workspacePath); const pendingStarts = [...this.workspaceStartTasks.entries()] .filter(([key]) => trackedFileKey(this.parseServerKey(key).workspacePath) === workspaceKey) - .map(([, task]) => task); + .map(([, pending]) => pending.task); if (pendingStarts.length > 0) await Promise.allSettled(pendingStarts); await invoke("lsp_stop", { workspacePath }); @@ -652,6 +674,7 @@ export class LspClient { this.activeLanguageServers.delete(server); this.activeServerFiles.delete(server); this.workspaceRepresentativeFiles.delete(server); + this.serverScopes.delete(server); const { languageId: language } = this.parseServerKey(server); if (language) { const displayName = getLanguageDisplayName(language); @@ -683,33 +706,35 @@ export class LspClient { async startForFile( filePath: string, - workspacePath: string, + scope: WorkspaceLaunchScope, intent: LspFileStartIntent = "attach", ): Promise { const attachmentKey = trackedFileKey(filePath); const currentAttachmentId = this.fileAttachmentIds.get(attachmentKey); const currentSession = getLspSessionSnapshot({ filePath }); + const currentServerKey = currentSession + ? `${currentSession.workspacePath}:${currentSession.languageId}` + : null; + const currentScope = currentServerKey ? this.serverScopes.get(currentServerKey) : undefined; if ( currentAttachmentId && currentSession && - trackedFileKey(currentSession.workspacePath) === trackedFileKey(workspacePath) + currentServerKey && + currentScope && + workspaceScopesMatch(currentScope, scope) ) { - this.registerActiveServer( - `${currentSession.workspacePath}:${currentSession.languageId}`, - currentSession.languageId, - filePath, - ); + this.registerActiveServer(currentServerKey, currentSession.languageId, scope, filePath); return { kind: "attached", attachmentId: currentAttachmentId }; } const pending = this.fileStartTasks.get(attachmentKey); - if (pending && trackedFileKey(pending.workspacePath) === trackedFileKey(workspacePath)) { + if (pending && workspaceScopesMatch(pending.scope, scope)) { return pending.task; } const attachmentId = crypto.randomUUID(); this.fileAttachmentIds.set(attachmentKey, attachmentId); - const task = this.startFileAttachment(filePath, workspacePath, { + const task = this.startFileAttachment(filePath, scope, { kind: intent, attachmentId, }).finally(() => { @@ -717,15 +742,16 @@ export class LspClient { this.fileStartTasks.delete(attachmentKey); } }); - this.fileStartTasks.set(attachmentKey, { workspacePath, task }); + this.fileStartTasks.set(attachmentKey, { scope, task }); return task; } private async startFileAttachment( filePath: string, - workspacePath: string, + scope: WorkspaceLaunchScope, attempt: LspFileStartAttempt, ): Promise { + const workspacePath = scope.root; const attachmentKey = trackedFileKey(filePath); const attachmentId = attempt.attachmentId; if (this.fileAttachmentIds.get(attachmentKey) !== attachmentId) { @@ -744,14 +770,14 @@ export class LspClient { let launch: Awaited> = null; try { - launch = await resolveEditorLspLaunch(filePath, workspacePath); + launch = await resolveEditorLspLaunch(filePath, scope); } catch (error) { if (attempt.kind !== "repairRetry" && !isBuiltInLspPath(filePath)) { const languageId = languageIdForEditorFile(filePath); if (languageId) { const repaired = await this.repairLanguageServerForFile(filePath, languageId); if (repaired.kind === "repaired") { - return this.startFileAttachment(filePath, workspacePath, { + return this.startFileAttachment(filePath, scope, { kind: "repairRetry", attachmentId, }); @@ -766,7 +792,7 @@ export class LspClient { if (languageId && attempt.kind !== "repairRetry" && !isBuiltInLspPath(filePath)) { const repaired = await this.repairLanguageServerForFile(filePath, languageId); if (repaired.kind === "repaired") { - return this.startFileAttachment(filePath, workspacePath, { + return this.startFileAttachment(filePath, scope, { kind: "repairRetry", attachmentId, }); @@ -833,6 +859,7 @@ export class LspClient { cacheDirectory: launch.cacheDirectory || null, environment: launch.environment || null, workspaceFingerprint: launch.workspaceFingerprint || null, + mavenContext: launch.mavenContext || null, attachmentId, }); if (!isCurrentAttachment()) { @@ -840,13 +867,13 @@ export class LspClient { return { kind: "cancelled", reason: "superseded" }; } clearLanguageServerFailure(this.failedLanguageServers, serverKey); - this.registerActiveServer(serverKey, languageId, filePath); + this.registerActiveServer(serverKey, languageId, scope, filePath); } catch (error) { recordLanguageServerFailure(this.failedLanguageServers, serverKey, Date.now()); if (attempt.kind !== "repairRetry" && this.isRepairableStartupError(error)) { const repaired = await this.repairLanguageServerForFile(filePath, languageId); if (repaired.kind === "repaired") { - return this.startFileAttachment(filePath, workspacePath, { + return this.startFileAttachment(filePath, scope, { kind: "repairRetry", attachmentId, }); @@ -916,7 +943,7 @@ export class LspClient { async ensureDocumentReady( target: LspDocumentTargetInput, - workspacePath: string, + scope: WorkspaceLaunchScope, content: string, feature?: string, ): Promise { @@ -929,7 +956,7 @@ export class LspClient { // that source document from virtual class text would corrupt synchronization. if (sessionFilePath !== document.filePath) return initial; - const attachment = await this.startForFile(sessionFilePath, workspacePath); + const attachment = await this.startForFile(sessionFilePath, scope); if (attachment.kind !== "attached") return this.getDocumentAvailability(document, feature); const { attachmentId } = attachment; @@ -975,6 +1002,7 @@ export class LspClient { }); if (!stillActiveForServer && !(languageId === JAVA_LANGUAGE_ID && workspaceSession)) { this.activeLanguageServers.delete(activeKey); + this.serverScopes.delete(activeKey); } clearLanguageServerFailure(this.failedLanguageServers, activeKey); } @@ -1008,7 +1036,11 @@ export class LspClient { await this.stopForFile(trackedFilePath); } - async restartForFile(filePath: string, workspacePath: string, content: string): Promise { + async restartForFile( + filePath: string, + scope: WorkspaceLaunchScope, + content: string, + ): Promise { const { actions } = useLspStore.getState(); try { @@ -1017,7 +1049,7 @@ export class LspClient { await this.notifyDocumentClose(filePath); await this.stopForFile(filePath); - const attachment = await this.startForFile(filePath, workspacePath, "manualRestart"); + const attachment = await this.startForFile(filePath, scope, "manualRestart"); if (attachment.kind !== "attached") { throw new Error("Language server failed to start."); } @@ -1036,16 +1068,24 @@ export class LspClient { if (!representativeFilePath) { throw new Error("No representative file for this language server"); } - const { workspacePath } = this.parseServerKey(serverKey); + const scope = this.serverScopes.get(serverKey); + if (!scope) { + throw new Error("No workspace scope for this language server"); + } + const workspacePath = scope.root; await this.stop(workspacePath); - await this.start(workspacePath, representativeFilePath); + await this.start(scope, representativeFilePath); return; } const filePath = trackedFilePath; const buffer = useBufferStore.getState().buffers.find((entry) => entry.path === filePath); const content = buffer && hasTextContent(buffer) ? buffer.content : ""; - await this.restartForFile(filePath, this.parseServerKey(serverKey).workspacePath, content); + const scope = this.serverScopes.get(serverKey); + if (!scope) { + throw new Error("No workspace scope for this language server"); + } + await this.restartForFile(filePath, scope, content); } async restartAllTrackedServers(): Promise { diff --git a/windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.test.ts b/windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.test.ts new file mode 100644 index 000000000..49ca9d6c9 --- /dev/null +++ b/windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.test.ts @@ -0,0 +1,51 @@ +import { afterEach, expect, mock, test } from "bun:test"; +import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; +import { resolveEditorLspLaunch } from "./resolve-editor-lsp-launch"; + +afterEach(() => workspaceRuntimeRegistry.resetForTests()); + +const resolveJavaLspLaunch = mock(async () => ({ + providerId: "java", + languageId: "java", + executablePath: "C:/Lithe/jdtls/bin/jdtls.bat", + arguments: [], + runtimeExecutablePath: "C:/Lithe/jdk/bin/java.exe", + cacheDirectory: "C:/Users/example/AppData/Local/Lithe/jdtls", + environment: { JAVA_HOME: "C:/Lithe/jdk" }, + workspaceFingerprint: "workspace-fingerprint", +})); +const mavenLaunchContextForWorkspace = mock(async () => ({ + version: 1 as const, + reactorPath: ".", + profiles: ["dev"], + settingsPath: "C:/Users/example/.m2/settings.xml", + skipTests: true, + mavenExecutablePath: "D:/Tools/apache-maven", + javaHomePath: "C:/Java/jdk-21", +})); + +test("resolves workspace A Maven context while workspace B is active", async () => { + workspaceRuntimeRegistry.activateWorkspace({ id: "workspace-b", name: "B" }, "ready"); + const launch = await resolveEditorLspLaunch( + "D:/work-a/src/App.java", + { + workspaceId: "workspace-a", + root: "D:/work-a", + }, + { resolveJavaLspLaunch, mavenLaunchContextForWorkspace }, + ); + + expect(workspaceRuntimeRegistry.getActiveWorkspaceId()).toBe("workspace-b"); + expect(mavenLaunchContextForWorkspace).toHaveBeenCalledWith( + "D:/work-a", + ["src/App.java"], + "workspace-a", + ); + expect(launch?.mavenContext).toEqual( + expect.objectContaining({ + profiles: ["dev"], + settingsPath: "C:/Users/example/.m2/settings.xml", + skipTests: true, + }), + ); +}); diff --git a/windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.ts b/windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.ts index 99be53ae4..fe44b6aa2 100644 --- a/windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.ts +++ b/windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.ts @@ -1,9 +1,10 @@ import type { BackendLanguageToolConfigSet } from "@/extensions/registry/extension-store-runtime"; import { isJavaSourcePath, JAVA_LANGUAGE_ID, JAVA_PROVIDER_ID } from "./built-in-language-support"; -import { - resolveJavaLspLaunch, - type JdtlsLaunchResources, -} from "./java-lsp-host-api"; +import { resolveJavaLspLaunch, type JdtlsLaunchResources } from "./java-lsp-host-api"; +import type { MavenLaunchContext } from "@/features/maven/types/maven.types"; +import { mavenLaunchContextForWorkspace } from "@/features/maven/stores/maven.store"; +import type { WorkspaceLaunchScope } from "@/features/workspace/types/workspace-launch-scope"; +import { getRelativePath } from "@/utils/path-helpers"; export interface EditorLspLaunch { providerId: string; @@ -18,14 +19,34 @@ export interface EditorLspLaunch { environment?: Record; /** Workspace structure digest forwarded to the Rust core. */ workspaceFingerprint?: string | null; + mavenContext?: MavenLaunchContext | null; +} + +export interface EditorLspLaunchDependencies { + resolveJavaLspLaunch: typeof resolveJavaLspLaunch; + mavenLaunchContextForWorkspace: typeof mavenLaunchContextForWorkspace; } +const defaultDependencies: EditorLspLaunchDependencies = { + resolveJavaLspLaunch, + mavenLaunchContextForWorkspace, +}; + export async function resolveEditorLspLaunch( filePath: string, - workspacePath: string, + scope: WorkspaceLaunchScope, + dependencies: EditorLspLaunchDependencies = defaultDependencies, ): Promise { + const workspacePath = scope.root; if (isJavaSourcePath(filePath)) { - const launch = await resolveJavaLspLaunch(workspacePath); + const [launch, mavenContext] = await Promise.all([ + dependencies.resolveJavaLspLaunch(workspacePath), + dependencies.mavenLaunchContextForWorkspace( + workspacePath, + [getRelativePath(filePath, workspacePath)], + scope.workspaceId, + ), + ]); const environment: Record = {}; if (launch.environment.JAVA_HOME) { environment.JAVA_HOME = launch.environment.JAVA_HOME; @@ -40,6 +61,7 @@ export async function resolveEditorLspLaunch( cacheDirectory: launch.cacheDirectory, environment, workspaceFingerprint: launch.workspaceFingerprint, + mavenContext, }; } diff --git a/windows/tauri/src/features/editor/services/save-workspace-before-launch.ts b/windows/tauri/src/features/editor/services/save-workspace-before-launch.ts new file mode 100644 index 000000000..c3608f6a6 --- /dev/null +++ b/windows/tauri/src/features/editor/services/save-workspace-before-launch.ts @@ -0,0 +1,56 @@ +import { isEditorContent } from "@/features/panes/types/pane-content.types"; +import { useBufferStore } from "../stores/buffer.store"; +import { useEditorAppStore } from "../stores/editor-app.store"; + +function hasActiveWritableSave(workspaceId: string): boolean { + return useBufferStore + .getStore(workspaceId) + .getState() + .buffers.some( + (buffer) => + isEditorContent(buffer) && + !buffer.readOnly && + buffer.documentLifecycle?.status === "saving", + ); +} + +async function waitForActiveWorkspaceSaves(workspaceId: string): Promise { + if (!hasActiveWritableSave(workspaceId)) return; + + const bufferStore = useBufferStore.getStore(workspaceId); + await new Promise((resolve) => { + let unsubscribe = () => {}; + const resolveWhenIdle = () => { + if (hasActiveWritableSave(workspaceId)) return; + unsubscribe(); + resolve(); + }; + unsubscribe = bufferStore.subscribe(resolveWhenIdle); + resolveWhenIdle(); + }); +} + +function unsavedWritableBufferNames(workspaceId: string): string[] { + const names = useBufferStore + .getStore(workspaceId) + .getState() + .buffers.filter( + (buffer) => isEditorContent(buffer) && buffer.isDirty && !buffer.readOnly, + ) + .map((buffer) => buffer.name); + return [...new Set(names)].sort(); +} + +export async function saveWorkspaceBeforeLaunch(workspaceId: string): Promise { + while (true) { + await waitForActiveWorkspaceSaves(workspaceId); + await useEditorAppStore.getStore(workspaceId).getState().actions.handleSaveAll(); + const unsavedNames = unsavedWritableBufferNames(workspaceId); + if (unsavedNames.length === 0) return; + if (hasActiveWritableSave(workspaceId)) continue; + + throw new Error( + `Unable to start because modified files could not be saved: ${unsavedNames.join(", ")}.`, + ); + } +} diff --git a/windows/tauri/src/features/editor/stores/editor-app.store.test.ts b/windows/tauri/src/features/editor/stores/editor-app.store.test.ts index 926c12b2b..b39d35430 100644 --- a/windows/tauri/src/features/editor/stores/editor-app.store.test.ts +++ b/windows/tauri/src/features/editor/stores/editor-app.store.test.ts @@ -1,7 +1,8 @@ -import { beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { toast } from "sonner"; import type { EditorContent } from "@/features/panes/types/pane-content.types"; import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; +import { saveWorkspaceBeforeLaunch } from "../services/save-workspace-before-launch"; import { getBufferById } from "../utils/buffer-index"; import { useBufferStore } from "./buffer.store"; import { useEditorAppStore } from "./editor-app.store"; @@ -54,6 +55,11 @@ beforeEach(() => { workspaceRuntimeRegistry.ensureWorkspace({ id: WORKSPACE_B, name: "Workspace B" }, "ready"); }); +afterEach(() => { + useEditorAppStore.getStore(WORKSPACE_A).getState().actions.cleanup(); + useEditorAppStore.getStore(WORKSPACE_B).getState().actions.cleanup(); +}); + describe("workspace-scoped editor actions", () => { test("routes content changes to the source workspace", async () => { setWorkspaceBuffers( @@ -178,4 +184,71 @@ describe("workspace-scoped editor actions", () => { expectedParseError.mockRestore(); } }); + + test("saves only the target workspace before an external launch", async () => { + setWorkspaceBuffers(WORKSPACE_A, [editorBuffer("a", "A edited", { isDirty: true })], "a"); + setWorkspaceBuffers(WORKSPACE_B, [editorBuffer("b", "B edited", { isDirty: true })], "b"); + + await saveWorkspaceBeforeLaunch(WORKSPACE_A); + + expect(getEditorBuffer(WORKSPACE_A, "a").isDirty).toBe(false); + expect(getEditorBuffer(WORKSPACE_B, "b").isDirty).toBe(true); + }); + + test("waits for an active auto-save before checking external launch readiness", async () => { + setWorkspaceBuffers(WORKSPACE_A, [editorBuffer("a", "A edited", { isDirty: true })], "a"); + const bufferActions = useBufferStore.getStore(WORKSPACE_A).getState().actions; + bufferActions.applyDocumentLifecycle("a", { + status: "saving", + revision: 1, + savedRevision: 0, + saveRevision: 1, + operationId: "auto-save-a", + }); + + const launchSaveState: { value: "pending" | "resolved" | "rejected" } = { + value: "pending", + }; + const launchSave = saveWorkspaceBeforeLaunch(WORKSPACE_A).then( + () => { + launchSaveState.value = "resolved"; + }, + () => { + launchSaveState.value = "rejected"; + }, + ); + await Promise.resolve(); + expect(launchSaveState.value).toBe("pending"); + + bufferActions.recordSuccessfulBufferSave("a", "A edited", { + status: "clean", + revision: 1, + }); + await launchSave; + + expect(launchSaveState.value).toBe("resolved"); + }, 1_000); + + test("rejects an external launch when a workspace file remains unsaved", async () => { + const saveFailureToast = spyOn(toast, "error").mockImplementation(() => "test-toast"); + const expectedParseError = spyOn(console, "error").mockImplementation(() => undefined); + setWorkspaceBuffers( + WORKSPACE_A, + [ + editorBuffer("settings", "not valid json", { + isDirty: true, + path: "settings://user-settings.json", + }), + ], + "settings", + ); + + try { + await expect(saveWorkspaceBeforeLaunch(WORKSPACE_A)).rejects.toThrow("settings.txt"); + expect(getEditorBuffer(WORKSPACE_A, "settings").isDirty).toBe(true); + } finally { + saveFailureToast.mockRestore(); + expectedParseError.mockRestore(); + } + }); }); diff --git a/windows/tauri/src/features/file-system/stores/file-system.store.ts b/windows/tauri/src/features/file-system/stores/file-system.store.ts index 4d24dc741..c051d9418 100644 --- a/windows/tauri/src/features/file-system/stores/file-system.store.ts +++ b/windows/tauri/src/features/file-system/stores/file-system.store.ts @@ -524,10 +524,11 @@ const initializeLocalWorkspaceInBackground = ( } const [{ getRelativePath, pathStartsWithRoot }, { resolveJavaWorkspacePolicy }, - { getJavaWorkspaceLanguageServerOwner }] = await Promise.all([ + { getJavaWorkspaceLanguageServerOwner }, { loadMavenProjectForWorkspace }] = await Promise.all([ import("@/utils/path-helpers"), import("@/platform/java-workspace-policy"), import("@/features/editor/lsp/java-workspace-language-server"), + import("@/features/maven/stores/maven.store"), ]); const workspaceFiles = projectFiles.filter( (entry) => !entry.isDir && pathStartsWithRoot(entry.path, path), @@ -535,6 +536,15 @@ const initializeLocalWorkspaceInBackground = ( const relativeToAbsolute = new Map( workspaceFiles.map((entry) => [getRelativePath(entry.path, path), entry.path]), ); + await loadMavenProjectForWorkspace(path, [...relativeToAbsolute.keys()], workspaceId); + if ( + activationVersion !== workspaceServiceActivationVersion || + workspaceRuntimeRegistry.getActiveWorkspaceId() !== workspaceId || + get().rootFolderPath !== path + ) { + operation.cancelled("workspace-activation-superseded"); + return; + } const policy = await resolveJavaWorkspacePolicy([...relativeToAbsolute.keys()]); const javaFile = policy.representativeJavaPath ? relativeToAbsolute.get(policy.representativeJavaPath) @@ -545,7 +555,10 @@ const initializeLocalWorkspaceInBackground = ( } operation.succeeded({ representativeJavaPath: policy.representativeJavaPath }); - await getJavaWorkspaceLanguageServerOwner().prewarm(path, javaFile); + await getJavaWorkspaceLanguageServerOwner().prewarm( + { workspaceId, root: path }, + javaFile, + ); } catch (error) { operation.failed(error); } @@ -3068,7 +3081,10 @@ const createFileSystemStore = (workspaceId: string): StoreApi { diff --git a/windows/tauri/src/features/keymaps/commands/navigation-command-actions.ts b/windows/tauri/src/features/keymaps/commands/navigation-command-actions.ts index 4ad88ae0a..ae154f831 100644 --- a/windows/tauri/src/features/keymaps/commands/navigation-command-actions.ts +++ b/windows/tauri/src/features/keymaps/commands/navigation-command-actions.ts @@ -35,6 +35,8 @@ import { } from "@/features/spring/utils/spring-navigation"; import { useUIState } from "@/features/window/stores/ui-state.store"; import { useProjectStore } from "@/features/window/stores/project.store"; +import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; +import type { WorkspaceLaunchScope } from "@/features/workspace/types/workspace-launch-scope"; import { createTranslator } from "@/i18n/locale"; import { logger } from "@/features/editor/utils/logger"; import { normalizePath } from "@/utils/path-helpers"; @@ -67,7 +69,7 @@ type LspNavigationClient = { ) => LspDocumentAvailability; ensureDocumentReady: ( target: LspDocumentTargetInput, - workspacePath: string, + scope: WorkspaceLaunchScope, content: string, feature?: string, ) => Promise; @@ -167,6 +169,10 @@ async function ensureNavigationLanguageServer( } const target = lspDocumentTargetForEditor(buffer); + const scope = { + workspaceId: workspaceRuntimeRegistry.getActiveWorkspaceId(), + root: workspacePath, + }; toast.info( navigationBlockMessage( { reason: "preparing", languageId: block.languageId }, @@ -179,7 +185,7 @@ async function ensureNavigationLanguageServer( // here. Continuing after readiness would move the editor long after the user // has switched context. void lspClient - .ensureDocumentReady(target, workspacePath, buffer.content, feature) + .ensureDocumentReady(target, scope, buffer.content, feature) .catch((error) => { logger.warn("LSPNavigation", "Background document attachment failed", error); }); diff --git a/windows/tauri/src/features/keymaps/commands/view-command-actions.ts b/windows/tauri/src/features/keymaps/commands/view-command-actions.ts index d3888564f..828797e01 100644 --- a/windows/tauri/src/features/keymaps/commands/view-command-actions.ts +++ b/windows/tauri/src/features/keymaps/commands/view-command-actions.ts @@ -37,6 +37,16 @@ export function toggleRunPane(): void { } } +export function toggleMavenPane(): void { + const state = useUIState.getState(); + if (state.isBottomPaneVisible && state.bottomPaneActiveTab === "maven") { + state.setIsBottomPaneVisible(false); + } else { + state.setBottomPaneActiveTab("maven"); + state.setIsBottomPaneVisible(true); + } +} + export function toggleTerminalPane(): void { const state = useUIState.getState(); if (state.isBottomPaneVisible && state.bottomPaneActiveTab === "terminal") { diff --git a/windows/tauri/src/features/layout/components/bottom-pane/bottom-pane.tsx b/windows/tauri/src/features/layout/components/bottom-pane/bottom-pane.tsx index 1a3dde8ca..765a3b7a5 100644 --- a/windows/tauri/src/features/layout/components/bottom-pane/bottom-pane.tsx +++ b/windows/tauri/src/features/layout/components/bottom-pane/bottom-pane.tsx @@ -7,6 +7,8 @@ import RunPane from "@/features/run/components/run-pane"; import { useBufferStore } from "@/features/editor/stores/buffer.store"; import { useTranslation } from "@/i18n/locale-provider"; import { GitLogToolWindow } from "@/features/git/components/log/git-log-tool-window"; +import MavenPane from "@/features/maven/components/maven-pane"; +import { useMavenStore } from "@/features/maven/stores/maven.store"; import { BOTTOM_PANE_ID } from "@/features/panes/constants/pane"; import { usePaneStore } from "@/features/panes/stores/pane.store"; import { activateBufferInPaneAndSync } from "@/features/panes/utils/pane-activation"; @@ -28,6 +30,8 @@ const BottomPane = () => { const { t } = useTranslation(); const isBottomPaneVisible = useUIState((state) => state.isBottomPaneVisible); const bottomPaneActiveTab = useUIState((state) => state.bottomPaneActiveTab); + const mavenProjectStatus = useMavenStore((state) => state.projectStatus); + const mavenProject = useMavenStore((state) => state.project); const rootFolderPath = useProjectStore((state) => state.rootFolderPath); const terminalEnabled = useSettingsStore((state) => state.settings.coreFeatures.terminal); const debuggerEnabled = useSettingsStore((state) => state.settings.coreFeatures.debugger); @@ -64,6 +68,17 @@ const BottomPane = () => { } }, [bottomPaneActiveTab, isBottomPaneVisible]); + useEffect(() => { + if ( + isBottomPaneVisible && + bottomPaneActiveTab === "maven" && + mavenProjectStatus === "ready" && + !mavenProject + ) { + useUIState.getState().setIsBottomPaneVisible(false); + } + }, [bottomPaneActiveTab, isBottomPaneVisible, mavenProject, mavenProjectStatus]); + useEffect(() => { if ( isBottomPaneVisible && @@ -276,6 +291,12 @@ const BottomPane = () => { )} + {bottomPaneActiveTab === "maven" && ( +
+ +
+ )} + {bottomPaneActiveTab === "diagnostics" && (
state.openSettingsDialog); const isBottomPaneVisible = useUIState((state) => state.isBottomPaneVisible); const bottomPaneActiveTab = useUIState((state) => state.bottomPaneActiveTab); + const mavenProject = useMavenStore((state) => state.project); + const mavenProjectStatus = useMavenStore((state) => state.projectStatus); const configuredActivityRailWidth = useSettingsStore((state) => state.settings.activityRailWidth); const askWhereToOpenProjects = useSettingsStore((state) => state.settings.askWhereToOpenProjects); const openFoldersInNewWindow = useSettingsStore((state) => state.settings.openFoldersInNewWindow); @@ -170,74 +180,34 @@ export const SidebarActivityRail = memo(({ expanded = false }: SidebarActivityRa openSidebarView(view); }; - const activityRailVisibilityItems = useMemo( - () => [ - { - id: "files", - label: t("workbench.project"), - icon: , - }, - ...(coreFeatures.search - ? [ - { - id: "search", - label: t("workbench.search"), - icon: , - }, - ] - : []), - ...(coreFeatures.git - ? [ - { - id: "git", - label: t("workbench.changes"), - icon: , - }, - { - id: "gitLog", - label: t("workbench.gitLog"), - icon: , - }, - ] - : []), - ...(coreFeatures.terminal - ? [ - { - id: "terminal", - label: t("workbench.terminal"), - icon: , - }, - ] - : []), - ...(coreFeatures.diagnostics - ? [ - { - id: "diagnostics", - label: t("workbench.diagnostics"), - icon: , - }, - ] - : []), - { - id: "run", - label: t("workbench.run"), - icon: , - }, - { - id: "settings", - label: t("workbench.settings"), - icon: , - }, - ], - [coreFeatures.diagnostics, coreFeatures.git, coreFeatures.search, coreFeatures.terminal, t], - ); + const activityRailVisibilityItems = useMemo(() => { + const items = new Map< + SidebarActivityItemId, + { id: SidebarActivityItemId; label: string; icon: ReactNode } + >([ + ["files", { id: "files", label: t("workbench.project"), icon: }], + ["git", { id: "git", label: t("workbench.changes"), icon: }], + ["search", { id: "search", label: t("workbench.search"), icon: }], + ["maven", { id: "maven", label: t("workbench.maven"), icon: }], + ["run", { id: "run", label: t("workbench.run"), icon: }], + [ + "terminal", + { id: "terminal", label: t("workbench.terminal"), icon: }, + ], + [ + "diagnostics", + { id: "diagnostics", label: t("workbench.diagnostics"), icon: }, + ], + ["gitLog", { id: "gitLog", label: t("workbench.gitLog"), icon: }], + ["settings", { id: "settings", label: t("workbench.settings"), icon: }], + ]); + return sidebarActivityVisibilityItemIds(coreFeatures).map((id) => items.get(id)!); + }, [coreFeatures.diagnostics, coreFeatures.git, coreFeatures.search, coreFeatures.terminal, t]); const setActivityRailItemVisible = useCallback( - (itemId: string, visible: boolean) => { + (itemId: SidebarActivityItemId, visible: boolean) => { const currentHiddenItems = useSettingsStore.getState().settings.hiddenSidebarActivityItems; - const nextHiddenItems = visible - ? currentHiddenItems.filter((hiddenItemId) => hiddenItemId !== itemId) - : Array.from(new Set([...currentHiddenItems, itemId])); + const nextHiddenItems = setSidebarActivityItemVisibility(currentHiddenItems, itemId, visible); void updateSetting("hiddenSidebarActivityItems", nextHiddenItems); }, @@ -666,6 +636,12 @@ export const SidebarActivityRail = memo(({ expanded = false }: SidebarActivityRa isDiagnosticsActive={isBottomPaneVisible && bottomPaneActiveTab === "diagnostics"} onRunClick={() => toggleRunPane()} isRunActive={isBottomPaneVisible && bottomPaneActiveTab === "run"} + onMavenClick={ + mavenProject || mavenProjectStatus === "failed" + ? () => toggleMavenPane() + : undefined + } + isMavenActive={isBottomPaneVisible && bottomPaneActiveTab === "maven"} compact={!expanded} showLabels={expanded} orientation="vertical" diff --git a/windows/tauri/src/features/layout/components/sidebar/sidebar-pane-selector.tsx b/windows/tauri/src/features/layout/components/sidebar/sidebar-pane-selector.tsx index 58188a02d..19af6b243 100644 --- a/windows/tauri/src/features/layout/components/sidebar/sidebar-pane-selector.tsx +++ b/windows/tauri/src/features/layout/components/sidebar/sidebar-pane-selector.tsx @@ -19,6 +19,7 @@ import { GitGraphIcon, FilesIcon, MagnifyingGlassIcon, + PackageIcon, TerminalWindowIcon, WarningIcon, } from "@/ui/icons"; @@ -70,6 +71,8 @@ interface SidebarPaneSelectorProps { isDiagnosticsActive?: boolean; onRunClick?: () => void; isRunActive?: boolean; + onMavenClick?: () => void; + isMavenActive?: boolean; compact?: boolean; showLabels?: boolean; orientation?: "horizontal" | "vertical"; @@ -92,6 +95,8 @@ export const SidebarPaneSelector = ({ isDiagnosticsActive = false, onRunClick, isRunActive = false, + onMavenClick, + isMavenActive = false, compact = false, showLabels = false, orientation = "horizontal", @@ -233,6 +238,22 @@ export const SidebarPaneSelector = ({ } satisfies SidebarPaneItem, ] : []), + ...(onMavenClick + ? [ + { + id: "maven", + label: showLabels ? t("workbench.maven") : undefined, + icon: , + isActive: isMavenActive, + onClick: onMavenClick, + ariaLabel: t("workbench.maven"), + tooltip: { + content: t("workbench.maven"), + side: tooltipSide, + }, + } satisfies SidebarPaneItem, + ] + : []), ...(onSettingsClick ? [ { @@ -267,8 +288,10 @@ export const SidebarPaneSelector = ({ onDiagnosticsClick, isDiagnosticsActive, onRunClick, + onMavenClick, onSettingsClick, isRunActive, + isMavenActive, onViewChange, showLabels, t, diff --git a/windows/tauri/src/features/layout/config/item-order.test.ts b/windows/tauri/src/features/layout/config/item-order.test.ts index cee9ec6c7..17d3ac3f7 100644 --- a/windows/tauri/src/features/layout/config/item-order.test.ts +++ b/windows/tauri/src/features/layout/config/item-order.test.ts @@ -4,6 +4,8 @@ import { SIDEBAR_ACTIVITY_ITEM_IDS, SIDEBAR_BOTTOM_ACTIVITY_ITEM_IDS, normalizeItemOrder, + setSidebarActivityItemVisibility, + sidebarActivityVisibilityItemIds, } from "./item-order"; describe("footer item order", () => { @@ -23,12 +25,41 @@ describe("footer item order", () => { }); describe("sidebar activity order", () => { + test("includes Maven in the default visibility order", () => { + expect( + sidebarActivityVisibilityItemIds({ + search: true, + git: true, + terminal: true, + diagnostics: true, + }), + ).toEqual([ + "files", + "git", + "search", + "maven", + "run", + "terminal", + "diagnostics", + "gitLog", + "settings", + ]); + }); + + test("hides and restores Maven independently", () => { + const hidden = setSidebarActivityItemVisibility([], "maven", false); + + expect(hidden).toEqual(["maven"]); + expect(setSidebarActivityItemVisibility(hidden, "maven", true)).toEqual([]); + }); + test("does not expose an unavailable Database placeholder", () => { expect([...SIDEBAR_ACTIVITY_ITEM_IDS]).not.toContain("database"); }); - test("places Run, Terminal, Diagnostics, Git Log, then Settings", () => { + test("places Maven, Run, Terminal, Diagnostics, Git Log, then Settings", () => { expect([...SIDEBAR_BOTTOM_ACTIVITY_ITEM_IDS]).toEqual([ + "maven", "run", "terminal", "diagnostics", diff --git a/windows/tauri/src/features/layout/config/item-order.ts b/windows/tauri/src/features/layout/config/item-order.ts index 98f1cf443..fa8edb7d4 100644 --- a/windows/tauri/src/features/layout/config/item-order.ts +++ b/windows/tauri/src/features/layout/config/item-order.ts @@ -2,6 +2,7 @@ export const SIDEBAR_ACTIVITY_ITEM_IDS = [ "files", "git", "search", + "maven", "run", "terminal", "diagnostics", @@ -9,6 +10,7 @@ export const SIDEBAR_ACTIVITY_ITEM_IDS = [ "settings", ] as const; export const SIDEBAR_BOTTOM_ACTIVITY_ITEM_IDS = [ + "maven", "run", "terminal", "diagnostics", @@ -29,6 +31,35 @@ export type SidebarActivityItemId = (typeof SIDEBAR_ACTIVITY_ITEM_IDS)[number]; export type FooterLeadingItemId = (typeof FOOTER_LEADING_ITEM_IDS)[number] | "debugger"; export type FooterTrailingItemId = (typeof FOOTER_TRAILING_ITEM_IDS)[number]; +interface SidebarActivityVisibilityFeatures { + search: boolean; + git: boolean; + terminal: boolean; + diagnostics: boolean; +} + +export function sidebarActivityVisibilityItemIds( + features: SidebarActivityVisibilityFeatures, +): SidebarActivityItemId[] { + return SIDEBAR_ACTIVITY_ITEM_IDS.filter((id) => { + if (id === "search") return features.search; + if (id === "git" || id === "gitLog") return features.git; + if (id === "terminal") return features.terminal; + if (id === "diagnostics") return features.diagnostics; + return true; + }); +} + +export function setSidebarActivityItemVisibility( + hiddenItemIds: readonly string[], + itemId: SidebarActivityItemId, + visible: boolean, +): string[] { + return visible + ? hiddenItemIds.filter((hiddenItemId) => hiddenItemId !== itemId) + : [...new Set([...hiddenItemIds, itemId])]; +} + export function normalizeItemOrder( persistedOrder: readonly T[] | undefined, defaultOrder: readonly T[], diff --git a/windows/tauri/src/features/maven/api/maven-core-api.test.ts b/windows/tauri/src/features/maven/api/maven-core-api.test.ts new file mode 100644 index 000000000..8c6774c1c --- /dev/null +++ b/windows/tauri/src/features/maven/api/maven-core-api.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; + +const executeCore = mock(async () => ({ + id: "request", + ok: true as const, + data: null, +})); + +mock.module("@/core/lithe-core-client", () => ({ executeCore })); + +const { createMavenLaunchPlan, scanMavenProject } = await import("./maven-core-api"); + +beforeEach(() => { + executeCore.mockClear(); +}); + +describe("Maven Core API", () => { + test("scans with the visible workspace-relative paths", async () => { + await scanMavenProject("D:/work", ["reactor/pom.xml", "reactor/app/src/App.java"]); + + expect(executeCore).toHaveBeenCalledWith( + expect.objectContaining({ + command: "maven.scan", + payload: { + root: "D:/work", + paths: ["reactor/pom.xml", "reactor/app/src/App.java"], + }, + }), + ); + }); + + test("forwards the complete context without assembling Maven arguments", async () => { + const context = { + version: 1 as const, + reactorPath: "reactor", + profiles: ["dev", "qa"], + settingsPath: "C:/Users/example/.m2/settings.xml", + skipTests: true, + mavenExecutablePath: "D:/Tools/apache-maven", + javaHomePath: "C:/Java/jdk-21", + }; + + await createMavenLaunchPlan("D:/work", context, ["verify"], "app"); + + expect(executeCore).toHaveBeenCalledWith( + expect.objectContaining({ + command: "maven.launchPlan", + payload: { + root: "D:/work", + context, + module: "app", + goals: ["verify"], + }, + }), + ); + }); +}); diff --git a/windows/tauri/src/features/maven/api/maven-core-api.ts b/windows/tauri/src/features/maven/api/maven-core-api.ts new file mode 100644 index 000000000..7b909fb0e --- /dev/null +++ b/windows/tauri/src/features/maven/api/maven-core-api.ts @@ -0,0 +1,61 @@ +import { executeCore } from "@/core/lithe-core-client"; +import type { + MavenDiagnostic, + MavenLaunchContext, + MavenLaunchPlan, + MavenProject, +} from "../types/maven.types"; + +let requestSequence = 0; + +function nextRequestId(prefix: string): string { + requestSequence += 1; + return `${prefix}-${Date.now()}-${requestSequence}`; +} + +async function mavenCore( + command: string, + payload: unknown, + timeoutMilliseconds = 30_000, +): Promise { + const response = await executeCore({ + id: nextRequestId(command), + operationId: nextRequestId(`${command}-op`), + timeoutMilliseconds, + command, + payload, + }); + if (!response.ok) { + const error = new Error(response.error.message) as Error & { code?: string; details?: string }; + error.code = response.error.code; + error.details = response.error.details; + throw error; + } + return response.data; +} + +export function scanMavenProject(root: string, paths: string[] = []) { + return mavenCore("maven.scan", { root, paths }, 60_000); +} + +export function createMavenLaunchPlan( + root: string, + context: MavenLaunchContext, + goals: string[], + module?: string | null, +) { + return mavenCore("maven.launchPlan", { + root, + context, + module: module ?? null, + goals, + }); +} + +export async function parseMavenDiagnostics(root: string, output: string) { + const result = await mavenCore<{ issues: MavenDiagnostic[] }>("maven.diagnostics", { + root, + output, + }); + return result.issues ?? []; +} diff --git a/windows/tauri/src/features/maven/api/maven-host-api.ts b/windows/tauri/src/features/maven/api/maven-host-api.ts new file mode 100644 index 000000000..e4eb900fd --- /dev/null +++ b/windows/tauri/src/features/maven/api/maven-host-api.ts @@ -0,0 +1,42 @@ +import { invoke } from "@/platform/tauri-core"; +import { resolveRunLaunch, startRunProcess, stopRunProcess } from "@/features/run/api/run-host-api"; +import type { + MavenLaunchContext, + MavenLaunchPlan, + MavenStoredConfiguration, +} from "../types/maven.types"; + +export function loadMavenConfiguration(root: string, reactorPath: string) { + return invoke("maven_load_configuration", { + root, + reactorPath, + }); +} + +export function writeMavenConfiguration( + root: string, + reactorPath: string, + configuration: MavenStoredConfiguration, +) { + return invoke("maven_write_configuration", { + args: { root, reactorPath, configuration }, + }); +} + +export async function resolveMavenLaunch( + root: string, + context: MavenLaunchContext, + plan: MavenLaunchPlan, +) { + return resolveRunLaunch({ + root, + executable: plan.executable, + workingDirectory: plan.workingDirectory, + javaHomePath: "", + mavenExecutablePath: context.mavenExecutablePath ?? "", + mavenJavaHomePath: context.javaHomePath ?? "", + environment: {}, + }); +} + +export { startRunProcess as startMavenProcess, stopRunProcess as stopMavenProcess }; diff --git a/windows/tauri/src/features/maven/components/maven-pane.tsx b/windows/tauri/src/features/maven/components/maven-pane.tsx new file mode 100644 index 000000000..02bcd39b6 --- /dev/null +++ b/windows/tauri/src/features/maven/components/maven-pane.tsx @@ -0,0 +1,752 @@ +import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { open } from "@tauri-apps/plugin-dialog"; +import { useFileSystemStore } from "@/features/file-system/stores/file-system.store"; +import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; +import { useActiveWorkspaceId } from "@/features/workspace/stores/create-workspace-scoped-store"; +import { workspaceScopeMatchesRoot } from "@/features/workspace/types/workspace-launch-scope"; +import { RunOutputText } from "@/features/run/components/run-output-text"; +import { useUIState } from "@/features/window/stores/ui-state.store"; +import { useTranslation } from "@/i18n/locale-provider"; +import { Button } from "@/ui/button"; +import { Checkbox } from "@/ui/checkbox"; +import Dialog from "@/ui/dialog"; +import Input from "@/ui/input"; +import { + ArrowClockwiseIcon, + ArrowCounterClockwiseIcon, + ArrowsInIcon, + CaretDownIcon, + CaretRightIcon, + FolderIcon, + GearIcon, + MinusIcon, + PackageIcon, + PlayIcon, + PlusIcon, + SlidersHorizontalIcon, + StopIcon, + TerminalIcon, + TrashIcon, + WarningIcon, +} from "@/ui/icons"; +import { ScrollArea } from "@/ui/scroll-area"; +import { Spinner } from "@/ui/spinner"; +import Tooltip from "@/ui/tooltip"; +import { joinPath } from "@/utils/path-helpers"; +import { cn } from "@/utils/cn"; +import { ensureMavenProcessListeners } from "../hooks/use-maven-process-events"; +import { availableMavenProfiles, useMavenStore } from "../stores/maven.store"; +import { + reloadJavaForMavenWorkspace, + reloadMavenWorkspaceProjects, +} from "../services/reload-maven-workspace"; +import { + MAVEN_LIFECYCLE_PHASES, + type MavenLifecyclePhase, + type MavenModule, + type MavenSettings, +} from "../types/maven.types"; + +interface TreeNodeProps { + id: string; + title: string; + subtitle?: string; + icon?: ReactNode; + selected?: boolean; + expanded: boolean; + onToggle: (id: string) => void; + onSelect?: () => void; + children?: ReactNode; +} + +function TreeNode({ + id, + title, + subtitle, + icon, + selected, + expanded, + onToggle, + onSelect, + children, +}: TreeNodeProps) { + return ( +
+
+ + +
+ {expanded && children ? ( +
{children}
+ ) : null} +
+ ); +} + +function MavenSettingsDialog({ + initial, + error, + onClose, + onSave, +}: { + initial: MavenSettings; + error: string | null; + onClose: () => void; + onSave: (settings: MavenSettings) => void; +}) { + const { t } = useTranslation(); + const [draft, setDraft] = useState(initial); + + const choosePath = async (field: keyof MavenSettings, directory: boolean) => { + const selected = await open({ + directory, + multiple: false, + ...(field === "settingsPath" + ? { filters: [{ name: "Maven settings", extensions: ["xml"] }] } + : {}), + }); + if (typeof selected === "string") setDraft((current) => ({ ...current, [field]: selected })); + }; + + const fields: Array<{ + id: string; + field: keyof MavenSettings; + label: string; + directory: boolean; + }> = [ + { id: "maven-settings-xml", field: "settingsPath", label: "settings.xml", directory: false }, + { + id: "maven-executable", + field: "mavenExecutablePath", + label: t("maven.mavenExecutable"), + directory: true, + }, + { + id: "maven-jdk-home", + field: "javaHomePath", + label: t("maven.javaHome"), + directory: true, + }, + ]; + + return ( + + {error ? ( + {error} + ) : ( + + )} + + + + } + > +
+ {fields.map(({ id, field, label, directory }) => ( + + ))} +
+
+ ); +} + +export default function MavenPane() { + const { t } = useTranslation(); + const workspaceId = useActiveWorkspaceId(); + const root = useMavenStore((state) => state.root); + const visiblePaths = useMavenStore((state) => state.visiblePaths); + const projectStatus = useMavenStore((state) => state.projectStatus); + const projectError = useMavenStore((state) => state.projectError); + const project = useMavenStore((state) => state.project); + const selectedProfiles = useMavenStore((state) => state.selectedProfiles); + const customProfiles = useMavenStore((state) => state.customProfiles); + const skipTests = useMavenStore((state) => state.skipTests); + const settingsPath = useMavenStore((state) => state.settingsPath); + const mavenExecutablePath = useMavenStore((state) => state.mavenExecutablePath); + const javaHomePath = useMavenStore((state) => state.javaHomePath); + const configurationSaveError = useMavenStore((state) => state.configurationSaveError); + const reloadRequired = useMavenStore((state) => state.reloadRequired); + const taskStatus = useMavenStore((state) => state.taskStatus); + const taskError = useMavenStore((state) => state.taskError); + const runningTitle = useMavenStore((state) => state.runningTitle); + const output = useMavenStore((state) => state.output); + const issues = useMavenStore((state) => state.issues); + const lastExitCode = useMavenStore((state) => state.lastExitCode); + const actions = useMavenStore((state) => state.actions); + const handleFileSelect = useFileSystemStore((state) => state.handleFileSelect); + const setIsBottomPaneVisible = useUIState((state) => state.setIsBottomPaneVisible); + const [selectedModule, setSelectedModule] = useState(null); + const [selectedPhase, setSelectedPhase] = useState("compile"); + const [expanded, setExpanded] = useState>(new Set()); + const [goalDialogOpen, setGoalDialogOpen] = useState(false); + const [settingsDialogOpen, setSettingsDialogOpen] = useState(false); + const [profileDialogOpen, setProfileDialogOpen] = useState(false); + const [customGoal, setCustomGoal] = useState(""); + const [customProfile, setCustomProfile] = useState(""); + const [reloadError, setReloadError] = useState(null); + + const profiles = useMemo( + () => availableMavenProfiles({ project, customProfiles }), + [customProfiles, project], + ); + const isRunning = taskStatus === "running" || taskStatus === "stopping"; + + useEffect(() => { + void ensureMavenProcessListeners(); + }, []); + + useEffect(() => { + setReloadError(null); + }, [root, workspaceId]); + + useEffect(() => { + if (!project) return; + const initial = new Set([`project:${project.relativePath}`]); + if (profiles.length > 0) initial.add("profiles"); + setExpanded(initial); + setSelectedModule(null); + setSelectedPhase("compile"); + }, [project?.relativePath]); + + const toggleExpanded = (id: string) => { + setExpanded((current) => { + const next = new Set(current); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const runPhase = (phase: MavenLifecyclePhase, module: MavenModule | null) => { + setSelectedModule(module?.relativePath ?? null); + setSelectedPhase(phase); + const target = module?.artifactId ?? project?.artifactId ?? t("maven.project"); + void actions.runGoals([phase], module?.relativePath ?? null, `${phase} · ${target}`); + }; + + const runSelected = () => { + const module = findMavenModule(project?.modules ?? [], selectedModule); + runPhase(selectedPhase, module); + }; + + const runCustomGoal = () => { + const goals = customGoal.trim().split(/\s+/).filter(Boolean); + if (goals.length === 0) return; + const module = findMavenModule(project?.modules ?? [], selectedModule); + const target = module?.artifactId ?? project?.artifactId ?? t("maven.project"); + setGoalDialogOpen(false); + void actions.runGoals(goals, module?.relativePath ?? null, `${customGoal.trim()} · ${target}`); + }; + + const reloadJava = async () => { + if (!root) return; + const scope = { workspaceId, root }; + setReloadError(null); + try { + await reloadJavaForMavenWorkspace(scope); + } catch (error) { + if ( + workspaceRuntimeRegistry.getActiveWorkspaceId() === workspaceId && + workspaceScopeMatchesRoot(scope, useMavenStore.getStore(workspaceId).getState().root) + ) { + setReloadError(error instanceof Error ? error.message : t("maven.reloadFailed")); + } + } + }; + + const reloadProjects = async () => { + if (!root) return; + const scope = { workspaceId, root }; + setReloadError(null); + try { + await reloadMavenWorkspaceProjects(scope); + } catch (error) { + if ( + workspaceRuntimeRegistry.getActiveWorkspaceId() === workspaceId && + workspaceScopeMatchesRoot(scope, useMavenStore.getStore(workspaceId).getState().root) + ) { + setReloadError(error instanceof Error ? error.message : t("maven.reloadFailed")); + } + } + }; + + const openIssue = (path: string, line: number, column?: number | null) => { + if (!root || !path) return; + const target = /^(?:[A-Za-z]:[\\/]|[\\/]{2}|\/)/.test(path) ? path : joinPath(root, path); + void handleFileSelect(target, false, line, column ?? undefined, undefined, false); + }; + + const renderLifecycle = (ownerId: string, module: MavenModule | null) => { + const id = `${ownerId}:lifecycle`; + return ( + } + expanded={expanded.has(id)} + onToggle={toggleExpanded} + > + {MAVEN_LIFECYCLE_PHASES.map((phase) => { + const selected = + selectedModule === (module?.relativePath ?? null) && selectedPhase === phase; + return ( + + ); + })} + + ); + }; + + const renderModule = (module: MavenModule): ReactNode => { + const id = `module:${module.relativePath}`; + return ( + setSelectedModule(module.relativePath)} + > + {renderLifecycle(id, module)} + {module.modules.map(renderModule)} + + ); + }; + + return ( +
+
+ +
+ {t("maven.title")} + {project ? ` · ${project.artifactId}` : ""} +
+ {projectStatus === "loading" ? : null} + {runningTitle ? ( + + {runningTitle} + + ) : null} + {taskStatus === "cancelled" ? ( + {t("maven.cancelled")} + ) : null} + {!isRunning && lastExitCode != null ? ( + + {lastExitCode === 0 ? t("run.succeeded") : t("run.failed")} + + ) : null} + + + + + + + + + + + + + + + + + + + + + + + + +
+ + {reloadRequired || configurationSaveError || reloadError || taskError ? ( +
+ + + {configurationSaveError ?? reloadError ?? taskError ?? t("maven.configurationChanged")} + + {reloadRequired || reloadError ? ( + + ) : null} +
+ ) : null} + + {projectStatus === "failed" ? ( +
+ +
{t("maven.loadFailed")}
+
{projectError}
+ +
+ ) : project ? ( +
+ +
+ {profiles.length > 0 ? ( + } + expanded={expanded.has("profiles")} + onToggle={toggleExpanded} + > +
+ + + + + + +
+ {profiles.map((profile) => ( + + ))} +
+ ) : null} + setSelectedModule(null)} + > + {renderLifecycle(`project:${project.relativePath}`, null)} + {project.modules.map(renderModule)} + +
+
+
+
+ {t("maven.buildOutput")} + {issues.length > 0 ? {issues.length} : null} +
+ {issues.length > 0 ? ( + +
+ {issues.map((issue, index) => ( + + ))} +
+
+ ) : null} + +
+ +
+
+
+
+ ) : ( +
+ {projectStatus === "loading" ? t("maven.scanning") : t("maven.notDetected")} +
+ )} + + {goalDialogOpen ? ( + setGoalDialogOpen(false)} + footer={ + <> + + + + } + > + setCustomGoal(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") runCustomGoal(); + }} + /> + + ) : null} + {profileDialogOpen ? ( + setProfileDialogOpen(false)} + footer={ + <> + + + + } + > + setCustomProfile(event.target.value)} + /> + + ) : null} + {settingsDialogOpen ? ( + setSettingsDialogOpen(false)} + onSave={actions.updateLocalConfiguration} + /> + ) : null} +
+ ); +} + +function findMavenModule( + modules: readonly MavenModule[], + relativePath: string | null, +): MavenModule | null { + if (!relativePath) return null; + for (const module of modules) { + if (module.relativePath === relativePath) return module; + const nested = findMavenModule(module.modules, relativePath); + if (nested) return nested; + } + return null; +} diff --git a/windows/tauri/src/features/maven/hooks/use-maven-process-events.ts b/windows/tauri/src/features/maven/hooks/use-maven-process-events.ts new file mode 100644 index 000000000..252e3c3b1 --- /dev/null +++ b/windows/tauri/src/features/maven/hooks/use-maven-process-events.ts @@ -0,0 +1,36 @@ +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import { mavenStoreForSession, releaseMavenSessionWorkspace } from "../stores/maven.store"; + +interface RunOutputEvent { + sessionId: string; + chunk: string; +} + +interface RunExitEvent { + sessionId: string; + exitCode: number; +} + +let outputUnlisten: UnlistenFn | undefined; +let exitUnlisten: UnlistenFn | undefined; + +export async function ensureMavenProcessListeners(): Promise { + if (!outputUnlisten) { + outputUnlisten = await listen("run-output", (event) => { + if (!event.payload.sessionId.startsWith("maven:")) return; + mavenStoreForSession(event.payload.sessionId) + .getState() + .actions.appendOutput(event.payload.sessionId, event.payload.chunk); + }); + } + if (!exitUnlisten) { + exitUnlisten = await listen("run-exit", (event) => { + const sessionId = event.payload.sessionId; + if (!sessionId.startsWith("maven:")) return; + mavenStoreForSession(sessionId) + .getState() + .actions.finishProcess(sessionId, event.payload.exitCode); + releaseMavenSessionWorkspace(sessionId); + }); + } +} diff --git a/windows/tauri/src/features/maven/services/reload-maven-workspace.test.ts b/windows/tauri/src/features/maven/services/reload-maven-workspace.test.ts new file mode 100644 index 000000000..37b15057d --- /dev/null +++ b/windows/tauri/src/features/maven/services/reload-maven-workspace.test.ts @@ -0,0 +1,156 @@ +import { afterEach, expect, mock, test } from "bun:test"; +import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; +import type { MavenProject } from "../types/maven.types"; +import { + reloadJavaForMavenWorkspace, + reloadMavenWorkspaceProjects, +} from "./reload-maven-workspace"; + +afterEach(() => workspaceRuntimeRegistry.resetForTests()); + +type Deferred = { + promise: Promise; + resolve(value: T): void; +}; + +function deferred(): Deferred { + let resolve!: (value: T) => void; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} + +function mavenProject(artifactId: string): MavenProject { + return { + relativePath: ".", + artifactId, + packaging: "jar", + modules: [], + profiles: [], + hasWrapper: true, + }; +} + +test("finishes workspace A reload without reading or mutating active workspace B", async () => { + const scanStarted = deferred(); + const finishScan = deferred(); + const acknowledgeA = mock(() => undefined); + const getFilesA = mock(async () => [ + { name: "Main.java", path: "D:/work-a/src/Main.java", isDir: false }, + ]); + const getFilesB = mock(async () => [ + { name: "Wrong.java", path: "D:/work-b/src/Wrong.java", isDir: false }, + ]); + const mavenA = { + root: "D:/work-a" as string | null, + visiblePaths: ["pom.xml"], + project: mavenProject("old-a") as MavenProject | null, + activeSessionId: null as string | null, + output: "A output", + actions: { + loadProject: mock(async () => { + scanStarted.resolve(undefined); + await finishScan.promise; + mavenA.project = mavenProject("new-a"); + }), + acknowledgeReload: acknowledgeA, + }, + }; + const mavenB = { + root: "D:/work-b" as string | null, + visiblePaths: ["pom.xml"], + project: mavenProject("project-b") as MavenProject | null, + activeSessionId: "session-b", + output: "B output", + actions: { + loadProject: mock(async () => undefined), + acknowledgeReload: mock(() => undefined), + }, + }; + const fileSystemA = { rootFolderPath: "D:/work-a", getAllProjectFiles: getFilesA }; + const fileSystemB = { rootFolderPath: "D:/work-b", getAllProjectFiles: getFilesB }; + const stop = mock(async () => undefined); + const prewarm = mock(async () => ({ kind: "ready" })); + const mavenStates = new Map([ + ["workspace-a", mavenA], + ["workspace-b", mavenB], + ]); + const fileSystemStates = new Map([ + ["workspace-a", fileSystemA], + ["workspace-b", fileSystemB], + ]); + const scopeA = { workspaceId: "workspace-a", root: "D:/work-a" }; + workspaceRuntimeRegistry.ensureWorkspace({ id: "workspace-a", name: "A" }, "ready"); + const getMavenState = mock((workspaceId: string) => mavenStates.get(workspaceId)!); + const getFileSystemState = mock((workspaceId: string) => fileSystemStates.get(workspaceId)!); + const reload = reloadMavenWorkspaceProjects(scopeA, { + hasWorkspace: (workspaceId) => workspaceRuntimeRegistry.hasWorkspace(workspaceId), + getMavenState, + getFileSystemState, + getJavaOwner: () => ({ stop, prewarm }), + }); + + try { + await scanStarted.promise; + workspaceRuntimeRegistry.activateWorkspace({ id: "workspace-b", name: "B" }, "ready"); + const workspaceBBefore = { + project: mavenB.project, + activeSessionId: mavenB.activeSessionId, + output: mavenB.output, + rootFolderPath: fileSystemB.rootFolderPath, + }; + finishScan.resolve(undefined); + + expect(await reload).toBe("completed"); + expect(workspaceRuntimeRegistry.getActiveWorkspaceId()).toBe("workspace-b"); + expect({ + project: mavenB.project, + activeSessionId: mavenB.activeSessionId, + output: mavenB.output, + rootFolderPath: fileSystemB.rootFolderPath, + }).toEqual(workspaceBBefore); + expect(mavenB.actions.loadProject).not.toHaveBeenCalled(); + expect(mavenB.actions.acknowledgeReload).not.toHaveBeenCalled(); + expect(getFilesB).not.toHaveBeenCalled(); + expect(getMavenState.mock.calls.every(([workspaceId]) => workspaceId === "workspace-a")).toBe( + true, + ); + expect( + getFileSystemState.mock.calls.every(([workspaceId]) => workspaceId === "workspace-a"), + ).toBe(true); + expect(stop).toHaveBeenCalledWith(scopeA); + expect(prewarm).toHaveBeenCalledWith(scopeA, "D:/work-a/src/Main.java"); + expect(acknowledgeA).toHaveBeenCalledTimes(1); + } finally { + finishScan.resolve(undefined); + await reload; + } +}); + +test("does not recreate stores after the workspace is closed", async () => { + const getMavenState = mock(() => { + throw new Error("Maven store must not be recreated"); + }); + const getFileSystemState = mock(() => { + throw new Error("File-system store must not be recreated"); + }); + const getJavaOwner = mock(() => { + throw new Error("Java owner must not be resolved"); + }); + + const outcome = await reloadJavaForMavenWorkspace( + { workspaceId: "closed-workspace", root: "D:/closed" }, + { + hasWorkspace: () => false, + getMavenState, + getFileSystemState, + getJavaOwner, + }, + ); + + expect(outcome).toBe("stale"); + expect(getMavenState).not.toHaveBeenCalled(); + expect(getFileSystemState).not.toHaveBeenCalled(); + expect(getJavaOwner).not.toHaveBeenCalled(); +}); diff --git a/windows/tauri/src/features/maven/services/reload-maven-workspace.ts b/windows/tauri/src/features/maven/services/reload-maven-workspace.ts new file mode 100644 index 000000000..1c3a8d672 --- /dev/null +++ b/windows/tauri/src/features/maven/services/reload-maven-workspace.ts @@ -0,0 +1,102 @@ +import { getJavaWorkspaceLanguageServerOwner } from "@/features/editor/lsp/java-workspace-language-server"; +import { useFileSystemStore } from "@/features/file-system/stores/file-system.store"; +import type { FileEntry } from "@/features/file-system/types/app.types"; +import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; +import { + workspaceScopeMatchesRoot, + type WorkspaceLaunchScope, +} from "@/features/workspace/types/workspace-launch-scope"; +import type { MavenProject } from "../types/maven.types"; +import { useMavenStore } from "../stores/maven.store"; + +interface MavenReloadState { + root: string | null; + visiblePaths: string[]; + project: MavenProject | null; + actions: { + loadProject(root: string, visiblePaths?: string[]): Promise; + acknowledgeReload(): void; + }; +} + +interface FileSystemReloadState { + rootFolderPath?: string; + getAllProjectFiles(): Promise; +} + +interface JavaWorkspaceReloadOwner { + stop(scope: WorkspaceLaunchScope): Promise; + prewarm(scope: WorkspaceLaunchScope, representativeJavaFile: string): Promise; +} + +export interface MavenWorkspaceReloadDependencies { + hasWorkspace(workspaceId: string): boolean; + getMavenState(workspaceId: string): MavenReloadState; + getFileSystemState(workspaceId: string): FileSystemReloadState; + getJavaOwner(): JavaWorkspaceReloadOwner; +} + +export type MavenWorkspaceReloadOutcome = "completed" | "noProject" | "stale"; + +const defaultDependencies: MavenWorkspaceReloadDependencies = { + hasWorkspace: (workspaceId) => workspaceRuntimeRegistry.hasWorkspace(workspaceId), + getMavenState: (workspaceId) => useMavenStore.getStore(workspaceId).getState(), + getFileSystemState: (workspaceId) => useFileSystemStore.getStore(workspaceId).getState(), + getJavaOwner: getJavaWorkspaceLanguageServerOwner, +}; + +function scopedStates( + scope: WorkspaceLaunchScope, + dependencies: MavenWorkspaceReloadDependencies, +): { maven: MavenReloadState; fileSystem: FileSystemReloadState } | null { + if (!dependencies.hasWorkspace(scope.workspaceId)) return null; + const maven = dependencies.getMavenState(scope.workspaceId); + const fileSystem = dependencies.getFileSystemState(scope.workspaceId); + return workspaceScopeMatchesRoot(scope, maven.root) && + workspaceScopeMatchesRoot(scope, fileSystem.rootFolderPath) + ? { maven, fileSystem } + : null; +} + +export async function reloadJavaForMavenWorkspace( + scope: WorkspaceLaunchScope, + dependencies: MavenWorkspaceReloadDependencies = defaultDependencies, +): Promise { + let states = scopedStates(scope, dependencies); + if (!states) return "stale"; + + const files = await states.fileSystem.getAllProjectFiles(); + states = scopedStates(scope, dependencies); + if (!states) return "stale"; + + const javaFile = files + .filter((entry) => !entry.isDir && entry.path.toLowerCase().endsWith(".java")) + .map((entry) => entry.path) + .sort()[0]; + const owner = dependencies.getJavaOwner(); + await owner.stop(scope); + + states = scopedStates(scope, dependencies); + if (!states) return "stale"; + if (javaFile) await owner.prewarm(scope, javaFile); + + states = scopedStates(scope, dependencies); + if (!states) return "stale"; + states.maven.actions.acknowledgeReload(); + return "completed"; +} + +export async function reloadMavenWorkspaceProjects( + scope: WorkspaceLaunchScope, + dependencies: MavenWorkspaceReloadDependencies = defaultDependencies, +): Promise { + let states = scopedStates(scope, dependencies); + if (!states) return "stale"; + + await states.maven.actions.loadProject(scope.root, [...states.maven.visiblePaths]); + states = scopedStates(scope, dependencies); + if (!states) return "stale"; + if (!states.maven.project) return "noProject"; + + return reloadJavaForMavenWorkspace(scope, dependencies); +} diff --git a/windows/tauri/src/features/maven/stores/maven.store.test.ts b/windows/tauri/src/features/maven/stores/maven.store.test.ts new file mode 100644 index 000000000..d56823c94 --- /dev/null +++ b/windows/tauri/src/features/maven/stores/maven.store.test.ts @@ -0,0 +1,404 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import type { + MavenDiagnostic, + MavenLaunchPlan, + MavenProject, + MavenStoredConfiguration, +} from "../types/maven.types"; +import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; +import { + createMavenStore, + mavenLaunchContext, + mavenLaunchContextForWorkspace, + useMavenStore, + type MavenStoreDependencies, +} from "./maven.store"; + +type Deferred = { + promise: Promise; + resolve: (value: T) => void; +}; + +function deferred(): Deferred { + let resolve!: (value: T) => void; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} + +const project: MavenProject = { + relativePath: "reactor", + groupId: "dev.lithe", + artifactId: "demo", + version: "1.0.0", + packaging: "pom", + hasWrapper: true, + profiles: [ + { id: "default", isActiveByDefault: true }, + { id: "dev", isActiveByDefault: false }, + ], + modules: [], +}; + +const launchPlan: MavenLaunchPlan = { + version: 1, + executable: { toolchain: "project-maven" }, + arguments: ["-B", "compile"], + workingDirectory: "reactor", + configurationFingerprint: "fixture-fingerprint", +}; + +const scanMavenProject = mock(async (_root: string, _paths?: string[]) => project); +const createMavenLaunchPlan = mock(async () => launchPlan); +const parseMavenDiagnostics = mock( + async (_root: string, _output: string): Promise => [], +); +const loadMavenConfiguration = mock(async () => ({})); +const writeMavenConfiguration = mock( + async ( + _root: string, + _reactorPath: string, + _configuration: MavenStoredConfiguration, + ): Promise => undefined, +); +const resolveMavenLaunch = mock(async () => ({ + executable: "D:/Tools/apache-maven/bin/mvn.cmd", + workingDirectory: "D:/work/reactor", + environment: {}, +})); +const saveWorkspaceBeforeLaunch = mock(async (_workspaceId: string): Promise => undefined); +const startMavenProcess = mock(async () => undefined); +const stopMavenProcess = mock(async () => undefined); + +const dependencies = { + createMavenLaunchPlan, + loadMavenConfiguration, + parseMavenDiagnostics, + resolveMavenLaunch, + saveWorkspaceBeforeLaunch, + scanMavenProject, + startMavenProcess, + stopMavenProcess, + writeMavenConfiguration, +} satisfies MavenStoreDependencies; + +beforeEach(() => { + scanMavenProject.mockReset(); + scanMavenProject.mockResolvedValue(project); + loadMavenConfiguration.mockReset(); + loadMavenConfiguration.mockResolvedValue({}); + writeMavenConfiguration.mockClear(); + createMavenLaunchPlan.mockReset(); + createMavenLaunchPlan.mockResolvedValue(launchPlan); + parseMavenDiagnostics.mockReset(); + parseMavenDiagnostics.mockResolvedValue([]); + resolveMavenLaunch.mockClear(); + saveWorkspaceBeforeLaunch.mockReset(); + saveWorkspaceBeforeLaunch.mockResolvedValue(undefined); + startMavenProcess.mockClear(); + stopMavenProcess.mockClear(); +}); + +afterEach(() => workspaceRuntimeRegistry.resetForTests()); + +describe("Maven workspace state", () => { + test("resolves workspace A without mutating active workspace B", async () => { + const workspaceA = useMavenStore.getStore("workspace-a"); + const workspaceB = useMavenStore.getStore("workspace-b"); + const loadWorkspaceB = mock(async () => undefined); + workspaceA.setState({ + root: "D:/work-a", + projectStatus: "ready", + project: { ...project, artifactId: "project-a" }, + }); + workspaceB.setState((state) => ({ + root: "D:/work-b", + projectStatus: "ready", + project: { ...project, artifactId: "project-b" }, + activeSessionId: "session-b", + output: "B output", + actions: { ...state.actions, loadProject: loadWorkspaceB }, + })); + workspaceRuntimeRegistry.activateWorkspace({ id: "workspace-b", name: "B" }, "ready"); + const workspaceBBefore = { + root: workspaceB.getState().root, + project: workspaceB.getState().project, + activeSessionId: workspaceB.getState().activeSessionId, + output: workspaceB.getState().output, + }; + + const context = await mavenLaunchContextForWorkspace( + "D:/work-a", + ["src/Main.java"], + "workspace-a", + ); + + expect(context?.reactorPath).toBe("reactor"); + expect(workspaceRuntimeRegistry.getActiveWorkspaceId()).toBe("workspace-b"); + expect({ + root: workspaceB.getState().root, + project: workspaceB.getState().project, + activeSessionId: workspaceB.getState().activeSessionId, + output: workspaceB.getState().output, + }).toEqual(workspaceBBefore); + expect(loadWorkspaceB).not.toHaveBeenCalled(); + }); + + test("restores portable selections and machine-local paths into one launch context", async () => { + loadMavenConfiguration.mockResolvedValue({ + portable: { + version: 1, + selectedProfiles: ["qa", "dev"], + customProfiles: ["qa"], + skipTests: true, + }, + local: { + version: 1, + settingsPath: "C:/Users/example/.m2/settings.xml", + mavenExecutablePath: "D:/Tools/apache-maven", + javaHomePath: "C:/Java/jdk-21", + }, + }); + const store = createMavenStore("workspace", dependencies); + + await store.getState().actions.loadProject("D:/work", ["reactor/pom.xml"]); + + expect(mavenLaunchContext(store.getState())).toEqual({ + version: 1, + reactorPath: "reactor", + profiles: ["dev", "qa"], + settingsPath: "C:/Users/example/.m2/settings.xml", + skipTests: true, + mavenExecutablePath: "D:/Tools/apache-maven", + javaHomePath: "C:/Java/jdk-21", + }); + }); + + test("persists portable and local values in separate documents", async () => { + const store = createMavenStore("workspace", dependencies); + await store.getState().actions.loadProject("D:/work", ["reactor/pom.xml"]); + const writeStarted = deferred(); + writeMavenConfiguration.mockImplementationOnce(async () => { + writeStarted.resolve(undefined); + }); + + store.getState().actions.updateLocalConfiguration({ + settingsPath: "C:/Users/example/.m2/settings.xml", + mavenExecutablePath: "D:/Tools/apache-maven", + javaHomePath: "C:/Java/jdk-21", + }); + await writeStarted.promise; + + const calls = writeMavenConfiguration.mock.calls; + const configuration = calls[calls.length - 1]?.[2]; + expect(configuration?.portable).toEqual({ + version: 1, + selectedProfiles: ["default"], + customProfiles: [], + skipTests: false, + }); + expect(configuration?.portable).not.toHaveProperty("settingsPath"); + expect(configuration?.local).toEqual({ + version: 1, + settingsPath: "C:/Users/example/.m2/settings.xml", + mavenExecutablePath: "D:/Tools/apache-maven", + javaHomePath: "C:/Java/jdk-21", + }); + }); + + test("serializes rapid configuration writes so the newest value wins", async () => { + const firstStarted = deferred(); + const firstWrite = deferred(); + const secondStarted = deferred(); + writeMavenConfiguration + .mockImplementationOnce(async () => { + firstStarted.resolve(undefined); + await firstWrite.promise; + }) + .mockImplementationOnce(async () => { + secondStarted.resolve(undefined); + }); + const store = createMavenStore("workspace", dependencies); + await store.getState().actions.loadProject("D:/work", ["reactor/pom.xml"]); + + store.getState().actions.setSkipTests(true); + store.getState().actions.setSkipTests(false); + try { + await firstStarted.promise; + expect(writeMavenConfiguration).toHaveBeenCalledTimes(1); + + firstWrite.resolve(undefined); + await secondStarted.promise; + + expect(writeMavenConfiguration).toHaveBeenCalledTimes(2); + expect(writeMavenConfiguration.mock.calls[1]?.[2].portable?.skipTests).toBe(false); + } finally { + firstWrite.resolve(undefined); + } + }); + + test("waits for a pending configuration write before reloading", async () => { + const firstStarted = deferred(); + const firstWrite = deferred(); + const reloadScanStarted = deferred(); + const reloadConfigurationStarted = deferred(); + writeMavenConfiguration.mockImplementationOnce(async () => { + firstStarted.resolve(undefined); + await firstWrite.promise; + }); + const store = createMavenStore("workspace", dependencies); + await store.getState().actions.loadProject("D:/work", ["reactor/pom.xml"]); + scanMavenProject.mockImplementationOnce(async () => { + reloadScanStarted.resolve(undefined); + return project; + }); + loadMavenConfiguration.mockImplementationOnce(async () => { + reloadConfigurationStarted.resolve(undefined); + return {}; + }); + let reload: Promise | undefined; + + try { + store.getState().actions.setSkipTests(true); + await firstStarted.promise; + reload = store.getState().actions.loadProject("D:/work", ["reactor/pom.xml"]); + await reloadScanStarted.promise; + + expect(loadMavenConfiguration).toHaveBeenCalledTimes(1); + firstWrite.resolve(undefined); + await reloadConfigurationStarted.promise; + await reload; + + expect(loadMavenConfiguration).toHaveBeenCalledTimes(2); + } finally { + firstWrite.resolve(undefined); + await reload; + } + }); + + test("does not let an older scan replace a newer workspace", async () => { + const firstScan = deferred(); + const secondScan = deferred(); + scanMavenProject + .mockImplementationOnce(() => firstScan.promise) + .mockImplementationOnce(() => secondScan.promise); + const store = createMavenStore("workspace", dependencies); + + const first = store.getState().actions.loadProject("D:/first", ["pom.xml"]); + const second = store.getState().actions.loadProject("D:/second", ["pom.xml"]); + secondScan.resolve({ ...project, artifactId: "second" }); + await second; + firstScan.resolve({ ...project, artifactId: "first" }); + await first; + + expect(store.getState().root).toBe("D:/second"); + expect(store.getState().project?.artifactId).toBe("second"); + }); + + test("cancels a pending launch without starting a stale process", async () => { + const pendingPlan = deferred(); + createMavenLaunchPlan.mockImplementationOnce(() => pendingPlan.promise); + const store = createMavenStore("workspace", dependencies); + await store.getState().actions.loadProject("D:/work", ["reactor/pom.xml"]); + + const run = store.getState().actions.runGoals(["compile"], null, "compile"); + await Promise.resolve(); + await store.getState().actions.stop(); + pendingPlan.resolve(launchPlan); + await run; + + expect(startMavenProcess).not.toHaveBeenCalled(); + expect(store.getState().taskStatus).toBe("cancelled"); + expect(store.getState().activeSessionId).toBeNull(); + expect(store.getState().output).toBe("Maven task cancelled.\n"); + + store.getState().actions.clearOutput(); + + expect(store.getState().taskStatus).toBe("idle"); + expect(store.getState().output).toBe(""); + }); + + test("waits for workspace files to save before creating a launch plan", async () => { + const pendingSave = deferred(); + saveWorkspaceBeforeLaunch.mockImplementationOnce(() => pendingSave.promise); + const store = createMavenStore("workspace", dependencies); + await store.getState().actions.loadProject("D:/work", ["reactor/pom.xml"]); + + const run = store.getState().actions.runGoals(["compile"], null, "compile"); + try { + await Promise.resolve(); + expect(saveWorkspaceBeforeLaunch).toHaveBeenCalledWith("workspace"); + expect(createMavenLaunchPlan).not.toHaveBeenCalled(); + } finally { + pendingSave.resolve(undefined); + await run; + } + + expect(createMavenLaunchPlan).toHaveBeenCalledTimes(1); + expect(startMavenProcess).toHaveBeenCalledTimes(1); + }); + + test("does not launch Maven when workspace files cannot be saved", async () => { + saveWorkspaceBeforeLaunch.mockRejectedValueOnce( + new Error("Unable to start because modified files could not be saved: App.java."), + ); + const store = createMavenStore("workspace", dependencies); + await store.getState().actions.loadProject("D:/work", ["reactor/pom.xml"]); + + await store.getState().actions.runGoals(["compile"], null, "compile"); + + expect(createMavenLaunchPlan).not.toHaveBeenCalled(); + expect(startMavenProcess).not.toHaveBeenCalled(); + expect(store.getState().taskStatus).toBe("failed"); + expect(store.getState().taskError).toContain("App.java"); + }); + + test("keeps cancellation when process exit arrives before stop completes", async () => { + const stopFinished = deferred(); + stopMavenProcess.mockImplementationOnce(() => stopFinished.promise); + const store = createMavenStore("workspace", dependencies); + await store.getState().actions.loadProject("D:/work", ["reactor/pom.xml"]); + await store.getState().actions.runGoals(["compile"], null, "compile"); + const sessionId = store.getState().activeSessionId; + expect(sessionId).not.toBeNull(); + + const stop = store.getState().actions.stop(); + try { + expect(store.getState().taskStatus).toBe("stopping"); + store.getState().actions.finishProcess(sessionId!, 143); + expect(store.getState().taskStatus).toBe("cancelled"); + + stopFinished.resolve(undefined); + await stop; + + expect(store.getState().output.match(/Maven task cancelled\./g)).toHaveLength(1); + expect(store.getState().lastExitCode).toBeNull(); + } finally { + stopFinished.resolve(undefined); + await stop; + } + }); + + test("does not let diagnostics from a completed task replace a newer run", async () => { + const pendingDiagnostics = + deferred>(); + parseMavenDiagnostics.mockImplementationOnce(() => pendingDiagnostics.promise); + const store = createMavenStore("workspace", dependencies); + await store.getState().actions.loadProject("D:/work", ["reactor/pom.xml"]); + await store.getState().actions.runGoals(["compile"], null, "compile"); + const completedSession = store.getState().activeSessionId; + expect(completedSession).not.toBeNull(); + + store.getState().actions.finishProcess(completedSession!, 1); + await store.getState().actions.runGoals(["test"], null, "test"); + pendingDiagnostics.resolve([ + { path: "src/Old.java", line: 3, severity: "error", message: "old task" }, + ]); + await pendingDiagnostics.promise; + await Promise.resolve(); + + expect(store.getState().issues).toEqual([]); + expect(store.getState().runningTitle).toBe("test"); + }); +}); diff --git a/windows/tauri/src/features/maven/stores/maven.store.ts b/windows/tauri/src/features/maven/stores/maven.store.ts new file mode 100644 index 000000000..1f4614012 --- /dev/null +++ b/windows/tauri/src/features/maven/stores/maven.store.ts @@ -0,0 +1,627 @@ +import { createStore } from "zustand/vanilla"; +import { saveWorkspaceBeforeLaunch } from "@/features/editor/services/save-workspace-before-launch"; +import { createWorkspaceScopedStore } from "@/features/workspace/stores/create-workspace-scoped-store"; +import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; +import { + createMavenLaunchPlan, + parseMavenDiagnostics, + scanMavenProject, +} from "../api/maven-core-api"; +import { + loadMavenConfiguration, + resolveMavenLaunch, + startMavenProcess, + stopMavenProcess, + writeMavenConfiguration, +} from "../api/maven-host-api"; +import type { + MavenDiagnostic, + MavenLaunchContext, + MavenLocalConfiguration, + MavenPortableConfiguration, + MavenProfile, + MavenProject, + MavenProjectStatus, + MavenSettings, + MavenStoredConfiguration, + MavenTaskStatus, +} from "../types/maven.types"; + +const MAXIMUM_OUTPUT_CHARACTERS = 500_000; +const mavenSessionWorkspaces = new Map(); + +interface MavenProjectLoad { + task: Promise; + hasVisiblePaths: boolean; +} + +const mavenProjectLoads = new Map(); + +export interface MavenStoreDependencies { + createMavenLaunchPlan: typeof createMavenLaunchPlan; + loadMavenConfiguration: typeof loadMavenConfiguration; + parseMavenDiagnostics: typeof parseMavenDiagnostics; + resolveMavenLaunch: typeof resolveMavenLaunch; + saveWorkspaceBeforeLaunch: typeof saveWorkspaceBeforeLaunch; + scanMavenProject: typeof scanMavenProject; + startMavenProcess: typeof startMavenProcess; + stopMavenProcess: typeof stopMavenProcess; + writeMavenConfiguration: typeof writeMavenConfiguration; +} + +const defaultMavenStoreDependencies: MavenStoreDependencies = { + createMavenLaunchPlan, + loadMavenConfiguration, + parseMavenDiagnostics, + resolveMavenLaunch, + saveWorkspaceBeforeLaunch, + scanMavenProject, + startMavenProcess, + stopMavenProcess, + writeMavenConfiguration, +}; + +export interface MavenState { + root: string | null; + visiblePaths: string[]; + projectStatus: MavenProjectStatus; + projectError: string | null; + project: MavenProject | null; + selectedProfiles: string[]; + customProfiles: string[]; + skipTests: boolean; + settingsPath: string; + mavenExecutablePath: string; + javaHomePath: string; + configurationSaveError: string | null; + reloadRequired: boolean; + taskStatus: MavenTaskStatus; + taskError: string | null; + activeSessionId: string | null; + runningTitle: string | null; + output: string; + issues: MavenDiagnostic[]; + lastExitCode: number | null; + actions: { + loadProject: (root: string, visiblePaths?: string[]) => Promise; + setSelectedProfiles: (profiles: string[]) => void; + addCustomProfile: (profile: string) => boolean; + restoreDefaultProfiles: () => void; + setSkipTests: (enabled: boolean) => void; + updateLocalConfiguration: (settings: MavenSettings) => void; + acknowledgeReload: () => void; + runGoals: (goals: string[], module: string | null, title: string) => Promise; + stop: () => Promise; + clearOutput: () => void; + appendOutput: (sessionId: string, chunk: string) => void; + finishProcess: (sessionId: string, exitCode: number) => void; + }; +} + +function normalizedProfile(value: string): string | null { + const profile = value.trim(); + const hasControlCharacter = [...profile].some((character) => { + const code = character.charCodeAt(0); + return code <= 0x1f || code === 0x7f; + }); + if (!profile || profile.includes(",") || hasControlCharacter) return null; + return profile; +} + +function normalizedProfiles(values: readonly string[]): string[] { + return [ + ...new Set(values.map(normalizedProfile).filter((value): value is string => !!value)), + ].sort(); +} + +function normalizedPath(value: string | null | undefined): string { + return value?.trim() ?? ""; +} + +export function availableMavenProfiles(state: Pick) { + const profiles = new Map(); + for (const profile of state.project?.profiles ?? []) profiles.set(profile.id, profile); + for (const id of state.customProfiles) { + if (!profiles.has(id)) profiles.set(id, { id, isActiveByDefault: false }); + } + return [...profiles.values()]; +} + +export function mavenLaunchContext(state: MavenState): MavenLaunchContext | null { + if (!state.project) return null; + return { + version: 1, + reactorPath: state.project.relativePath, + profiles: normalizedProfiles(state.selectedProfiles), + settingsPath: state.settingsPath || null, + skipTests: state.skipTests, + mavenExecutablePath: state.mavenExecutablePath || null, + javaHomePath: state.javaHomePath || null, + }; +} + +function storedConfiguration(state: MavenState): MavenStoredConfiguration { + const portable: MavenPortableConfiguration = { + version: 1, + selectedProfiles: normalizedProfiles(state.selectedProfiles), + customProfiles: normalizedProfiles(state.customProfiles), + skipTests: state.skipTests, + }; + const local: MavenLocalConfiguration = { + version: 1, + settingsPath: state.settingsPath || null, + mavenExecutablePath: state.mavenExecutablePath || null, + javaHomePath: state.javaHomePath || null, + }; + return { portable, local }; +} + +function displayArguments(arguments_: readonly string[]): string { + return arguments_ + .map((argument, index) => + index > 0 && arguments_[index - 1] === "-s" ? "" : argument, + ) + .join(" "); +} + +function trimOutput(output: string): string { + const normalized = output.replace(/\r/g, ""); + return normalized.length > MAXIMUM_OUTPUT_CHARACTERS + ? normalized.slice(normalized.length - MAXIMUM_OUTPUT_CHARACTERS) + : normalized; +} + +function cancelledOutput(output: string): string { + const separator = output && !output.endsWith("\n") ? "\n" : ""; + return trimOutput(`${output}${separator}Maven task cancelled.\n`); +} + +export const createMavenStore = ( + workspaceId = workspaceRuntimeRegistry.getActiveWorkspaceId(), + dependencies: MavenStoreDependencies = defaultMavenStoreDependencies, +) => { + let projectLoadRevision = 0; + let configurationRevision = 0; + let launchRevision = 0; + let diagnosticsRevision = 0; + let configurationWriteTask = Promise.resolve(); + + return createStore()((set, get) => { + const persistConfiguration = () => { + const state = get(); + if (!state.root || !state.project) return; + const revision = ++configurationRevision; + const configuration = storedConfiguration(state); + const root = state.root; + const reactorPath = state.project.relativePath; + configurationWriteTask = configurationWriteTask + .catch(() => undefined) + .then(() => dependencies.writeMavenConfiguration(root, reactorPath, configuration)); + void configurationWriteTask + .then(() => { + if (configurationRevision === revision) set({ configurationSaveError: null }); + }) + .catch((error) => { + if (configurationRevision !== revision) return; + set({ + configurationSaveError: + error instanceof Error ? error.message : "Unable to save Maven configuration.", + }); + }); + }; + + const configurationDidChange = () => { + set({ reloadRequired: true, configurationSaveError: null }); + persistConfiguration(); + }; + + return { + root: null, + visiblePaths: [], + projectStatus: "idle", + projectError: null, + project: null, + selectedProfiles: [], + customProfiles: [], + skipTests: false, + settingsPath: "", + mavenExecutablePath: "", + javaHomePath: "", + configurationSaveError: null, + reloadRequired: false, + taskStatus: "idle", + taskError: null, + activeSessionId: null, + runningTitle: null, + output: "", + issues: [], + lastExitCode: null, + actions: { + loadProject: async (root, visiblePaths = []) => { + const revision = ++projectLoadRevision; + configurationRevision += 1; + const previous = get(); + if (previous.root && previous.root !== root && previous.activeSessionId) { + launchRevision += 1; + diagnosticsRevision += 1; + await dependencies.stopMavenProcess(previous.activeSessionId).catch(() => undefined); + releaseMavenSessionWorkspace(previous.activeSessionId); + } + set({ + root, + visiblePaths: [...visiblePaths], + projectStatus: "loading", + projectError: null, + configurationSaveError: null, + ...(previous.root && previous.root !== root + ? { + project: null, + taskStatus: "idle" as const, + taskError: null, + activeSessionId: null, + runningTitle: null, + output: "", + issues: [], + lastExitCode: null, + } + : {}), + }); + try { + const project = await dependencies.scanMavenProject(root, visiblePaths); + if (projectLoadRevision !== revision || get().root !== root) return; + if (!project) { + set({ + projectStatus: "ready", + project: null, + selectedProfiles: [], + customProfiles: [], + skipTests: false, + settingsPath: "", + mavenExecutablePath: "", + javaHomePath: "", + reloadRequired: false, + }); + return; + } + await configurationWriteTask.catch(() => undefined); + if (projectLoadRevision !== revision || get().root !== root) return; + const stored = await dependencies.loadMavenConfiguration(root, project.relativePath); + if (projectLoadRevision !== revision || get().root !== root) return; + const customProfiles = normalizedProfiles(stored.portable?.customProfiles ?? []); + const knownProfiles = new Set([ + ...project.profiles.map((profile) => profile.id), + ...customProfiles, + ]); + const defaultProfiles = project.profiles + .filter((profile) => profile.isActiveByDefault) + .map((profile) => profile.id); + const selectedProfiles = normalizedProfiles( + stored.portable?.selectedProfiles ?? defaultProfiles, + ).filter((profile) => knownProfiles.has(profile)); + set({ + projectStatus: "ready", + projectError: null, + project, + selectedProfiles, + customProfiles, + skipTests: stored.portable?.skipTests ?? false, + settingsPath: normalizedPath(stored.local?.settingsPath), + mavenExecutablePath: normalizedPath(stored.local?.mavenExecutablePath), + javaHomePath: normalizedPath(stored.local?.javaHomePath), + reloadRequired: false, + }); + } catch (error) { + if (projectLoadRevision !== revision || get().root !== root) return; + set({ + projectStatus: "failed", + projectError: + error instanceof Error ? error.message : "Unable to scan the Maven project.", + project: null, + selectedProfiles: [], + customProfiles: [], + skipTests: false, + settingsPath: "", + mavenExecutablePath: "", + javaHomePath: "", + }); + } + }, + + setSelectedProfiles: (profiles) => { + const knownProfiles = new Set(availableMavenProfiles(get()).map((profile) => profile.id)); + const selectedProfiles = normalizedProfiles(profiles).filter((profile) => + knownProfiles.has(profile), + ); + if (selectedProfiles.join("\0") === get().selectedProfiles.join("\0")) return; + set({ selectedProfiles }); + configurationDidChange(); + }, + + addCustomProfile: (value) => { + const profile = normalizedProfile(value); + if (!profile) return false; + const state = get(); + set({ + customProfiles: normalizedProfiles([...state.customProfiles, profile]), + selectedProfiles: normalizedProfiles([...state.selectedProfiles, profile]), + }); + configurationDidChange(); + return true; + }, + + restoreDefaultProfiles: () => { + const defaults = normalizedProfiles( + get() + .project?.profiles.filter((profile) => profile.isActiveByDefault) + .map((profile) => profile.id) ?? [], + ); + if (defaults.join("\0") === get().selectedProfiles.join("\0")) return; + set({ selectedProfiles: defaults }); + configurationDidChange(); + }, + + setSkipTests: (enabled) => { + if (get().skipTests === enabled) return; + set({ skipTests: enabled }); + configurationDidChange(); + }, + + updateLocalConfiguration: (settings) => { + const next = { + settingsPath: normalizedPath(settings.settingsPath), + mavenExecutablePath: normalizedPath(settings.mavenExecutablePath), + javaHomePath: normalizedPath(settings.javaHomePath), + }; + const state = get(); + if ( + next.settingsPath === state.settingsPath && + next.mavenExecutablePath === state.mavenExecutablePath && + next.javaHomePath === state.javaHomePath + ) { + return; + } + set(next); + configurationDidChange(); + }, + + acknowledgeReload: () => set({ reloadRequired: false }), + + runGoals: async (goals, module, title) => { + const state = get(); + const context = mavenLaunchContext(state); + if (!state.root || !context || goals.length === 0) return; + const revision = ++launchRevision; + diagnosticsRevision += 1; + const previousSessionId = state.activeSessionId; + if (previousSessionId) { + await dependencies.stopMavenProcess(previousSessionId).catch(() => undefined); + releaseMavenSessionWorkspace(previousSessionId); + } + const sessionId = `maven:${crypto.randomUUID()}`; + bindMavenSessionWorkspace(sessionId, workspaceId); + set({ + taskStatus: "running", + taskError: null, + activeSessionId: sessionId, + runningTitle: title, + output: "", + issues: [], + lastExitCode: null, + }); + try { + await dependencies.saveWorkspaceBeforeLaunch(workspaceId); + const plan = await dependencies.createMavenLaunchPlan( + state.root, + context, + goals, + module, + ); + const resolved = await dependencies.resolveMavenLaunch(state.root, context, plan); + if (launchRevision !== revision || get().activeSessionId !== sessionId) { + releaseMavenSessionWorkspace(sessionId); + return; + } + const executableName = resolved.executable.split(/[\\/]/).pop() ?? "mvn"; + set({ output: `$ ${executableName} ${displayArguments(plan.arguments)}\n\n` }); + await dependencies.startMavenProcess({ + sessionId, + executable: resolved.executable, + arguments: plan.arguments, + workingDirectory: resolved.workingDirectory, + environment: resolved.environment, + }); + if (launchRevision !== revision || get().activeSessionId !== sessionId) { + await dependencies.stopMavenProcess(sessionId).catch(() => undefined); + releaseMavenSessionWorkspace(sessionId); + } + } catch (error) { + if (launchRevision !== revision || get().activeSessionId !== sessionId) { + releaseMavenSessionWorkspace(sessionId); + return; + } + const message = + error instanceof Error ? error.message : "Unable to start the Maven task."; + set({ + taskStatus: "failed", + taskError: message, + activeSessionId: null, + runningTitle: null, + lastExitCode: 1, + output: trimOutput(`${get().output}${message}\n`), + issues: [{ path: "", line: 1, column: null, severity: "error", message }], + }); + releaseMavenSessionWorkspace(sessionId); + } + }, + + stop: async () => { + launchRevision += 1; + diagnosticsRevision += 1; + const sessionId = get().activeSessionId; + if (!sessionId) return; + set({ taskStatus: "stopping" }); + try { + await dependencies.stopMavenProcess(sessionId); + if (get().activeSessionId === sessionId) { + set({ + taskStatus: "cancelled", + taskError: null, + activeSessionId: null, + runningTitle: null, + lastExitCode: null, + output: cancelledOutput(get().output), + }); + } + } catch (error) { + if (get().activeSessionId === sessionId) { + set({ + taskStatus: "running", + taskError: + error instanceof Error ? error.message : "Unable to stop the Maven task.", + }); + } + } finally { + releaseMavenSessionWorkspace(sessionId); + } + }, + + clearOutput: () => { + diagnosticsRevision += 1; + set((state) => ({ + output: "", + issues: [], + lastExitCode: null, + taskStatus: state.taskStatus === "cancelled" ? "idle" : state.taskStatus, + })); + }, + + appendOutput: (sessionId, chunk) => { + if (get().activeSessionId !== sessionId) return; + set({ output: trimOutput(get().output + chunk) }); + }, + + finishProcess: (sessionId, exitCode) => { + const state = get(); + if (state.activeSessionId !== sessionId || !state.root) return; + const root = state.root; + const output = state.output; + const revision = ++diagnosticsRevision; + if (state.taskStatus === "stopping") { + set({ + taskStatus: "cancelled", + taskError: null, + activeSessionId: null, + runningTitle: null, + lastExitCode: null, + output: cancelledOutput(output), + }); + releaseMavenSessionWorkspace(sessionId); + return; + } + set({ + taskStatus: exitCode === 0 ? "idle" : "failed", + taskError: exitCode === 0 ? null : `Maven exited with code ${exitCode}.`, + activeSessionId: null, + runningTitle: null, + lastExitCode: exitCode, + }); + void dependencies + .parseMavenDiagnostics(root, output) + .then((issues) => { + if (diagnosticsRevision === revision && get().root === root) set({ issues }); + }) + .catch((error) => { + if (diagnosticsRevision !== revision || get().root !== root) return; + set({ + taskError: + error instanceof Error + ? error.message + : "Unable to parse Maven build diagnostics.", + }); + }); + }, + }, + }; + }); +}; + +export const useMavenStore = createWorkspaceScopedStore("maven", createMavenStore); + +function workspaceRootKey(root: string): string { + const normalized = root.replace(/\\/g, "/").replace(/\/$/, ""); + return /^(?:[A-Za-z]:\/|\/\/)/.test(normalized) ? normalized.toLowerCase() : normalized; +} + +function mavenProjectLoadKey(root: string, workspaceId: string): string { + return `${workspaceId}\0${workspaceRootKey(root)}`; +} + +export function loadMavenProjectForWorkspace( + root: string, + visiblePaths: string[] = [], + workspaceId = workspaceRuntimeRegistry.getActiveWorkspaceId(), +): Promise { + const key = mavenProjectLoadKey(root, workspaceId); + const existing = mavenProjectLoads.get(key); + if (existing) { + if (visiblePaths.length === 0 || existing.hasVisiblePaths) return existing.task; + return existing.task.then(() => loadMavenProjectForWorkspace(root, visiblePaths, workspaceId)); + } + const task = useMavenStore + .getStore(workspaceId) + .getState() + .actions.loadProject(root, visiblePaths) + .finally(() => { + if (mavenProjectLoads.get(key)?.task === task) mavenProjectLoads.delete(key); + }); + mavenProjectLoads.set(key, { task, hasVisiblePaths: visiblePaths.length > 0 }); + return task; +} + +export async function mavenLaunchContextForWorkspace( + root: string, + visiblePaths: string[] = [], + workspaceId = workspaceRuntimeRegistry.getActiveWorkspaceId(), +): Promise { + const key = mavenProjectLoadKey(root, workspaceId); + const pending = mavenProjectLoads.get(key); + if (pending) await pending.task; + let state = useMavenStore.getStore(workspaceId).getState(); + const rootKey = workspaceRootKey(root); + if ( + state.root === null || + workspaceRootKey(state.root) !== rootKey || + state.projectStatus === "idle" || + (state.project === null && visiblePaths.length > 0) + ) { + await loadMavenProjectForWorkspace(root, visiblePaths, workspaceId); + state = useMavenStore.getStore(workspaceId).getState(); + } + return state.root && workspaceRootKey(state.root) === rootKey ? mavenLaunchContext(state) : null; +} + +export function currentMavenLaunchContext( + root: string, + workspaceId = workspaceRuntimeRegistry.getActiveWorkspaceId(), +): MavenLaunchContext | null { + const state = useMavenStore.getStore(workspaceId).getState(); + return state.root && workspaceRootKey(state.root) === workspaceRootKey(root) + ? mavenLaunchContext(state) + : null; +} + +export function bindMavenSessionWorkspace(sessionId: string, workspaceId?: string): void { + mavenSessionWorkspaces.set( + sessionId, + workspaceId ?? workspaceRuntimeRegistry.getActiveWorkspaceId(), + ); +} + +export function mavenStoreForSession(sessionId: string) { + const workspaceId = mavenSessionWorkspaces.get(sessionId); + return workspaceId ? useMavenStore.getStore(workspaceId) : useMavenStore; +} + +export function releaseMavenSessionWorkspace(sessionId: string): void { + mavenSessionWorkspaces.delete(sessionId); +} diff --git a/windows/tauri/src/features/maven/types/maven.types.test.ts b/windows/tauri/src/features/maven/types/maven.types.test.ts new file mode 100644 index 000000000..ad882018d --- /dev/null +++ b/windows/tauri/src/features/maven/types/maven.types.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, test } from "bun:test"; +import platformContract from "../../../../../../shared/fixtures/maven/platform-contract-v1.json"; +import { MAVEN_LIFECYCLE_PHASES } from "./maven.types"; + +describe("Maven platform contract", () => { + test("keeps the Windows lifecycle phases aligned with the shared fixture", () => { + expect(platformContract.lifecyclePhases).toEqual([...MAVEN_LIFECYCLE_PHASES]); + }); +}); diff --git a/windows/tauri/src/features/maven/types/maven.types.ts b/windows/tauri/src/features/maven/types/maven.types.ts new file mode 100644 index 000000000..b4506de5b --- /dev/null +++ b/windows/tauri/src/features/maven/types/maven.types.ts @@ -0,0 +1,92 @@ +export type MavenProjectStatus = "idle" | "loading" | "ready" | "failed"; +export type MavenTaskStatus = "idle" | "running" | "stopping" | "failed" | "cancelled"; + +export interface MavenProfile { + id: string; + isActiveByDefault: boolean; +} + +export interface MavenModule { + relativePath: string; + groupId?: string | null; + artifactId: string; + version?: string | null; + packaging: string; + modules: MavenModule[]; +} + +export interface MavenProject { + relativePath: string; + groupId?: string | null; + artifactId: string; + version?: string | null; + packaging: string; + modules: MavenModule[]; + profiles: MavenProfile[]; + hasWrapper: boolean; +} + +export interface MavenLaunchContext { + version: 1; + reactorPath: string; + profiles: string[]; + settingsPath?: string | null; + skipTests: boolean; + mavenExecutablePath?: string | null; + javaHomePath?: string | null; +} + +export interface MavenLaunchPlan { + version: 1; + executable: { toolchain: "project-maven" }; + arguments: string[]; + workingDirectory: string; + configurationFingerprint: string; +} + +export interface MavenDiagnostic { + path: string; + line: number; + column?: number | null; + severity: "error" | "warning"; + message: string; +} + +export interface MavenPortableConfiguration { + version: 1; + selectedProfiles: string[]; + customProfiles: string[]; + skipTests: boolean; +} + +export interface MavenLocalConfiguration { + version: 1; + settingsPath?: string | null; + mavenExecutablePath?: string | null; + javaHomePath?: string | null; +} + +export interface MavenStoredConfiguration { + portable?: MavenPortableConfiguration | null; + local?: MavenLocalConfiguration | null; +} + +export interface MavenSettings { + settingsPath: string; + mavenExecutablePath: string; + javaHomePath: string; +} + +export const MAVEN_LIFECYCLE_PHASES = [ + "clean", + "validate", + "compile", + "test", + "package", + "verify", + "install", + "site", + "deploy", +] as const; + +export type MavenLifecyclePhase = (typeof MAVEN_LIFECYCLE_PHASES)[number]; diff --git a/windows/tauri/src/features/run/api/run-core-api.test.ts b/windows/tauri/src/features/run/api/run-core-api.test.ts index dd5066e49..456e31395 100644 --- a/windows/tauri/src/features/run/api/run-core-api.test.ts +++ b/windows/tauri/src/features/run/api/run-core-api.test.ts @@ -8,7 +8,7 @@ const executeCore = mock(async () => ({ mock.module("@/core/lithe-core-client", () => ({ executeCore })); -const { saveRunConfigurationEditorChanges } = await import("./run-core-api"); +const { createLaunchPlan, saveRunConfigurationEditorChanges } = await import("./run-core-api"); const emptyToolchain = { javaHomePath: "", @@ -22,6 +22,32 @@ beforeEach(() => { }); describe("saveRunConfigurationEditorChanges", () => { + test("forwards the shared Maven context when creating a launch plan", async () => { + const mavenContext = { + version: 1 as const, + reactorPath: "reactor", + profiles: ["dev"], + settingsPath: "C:/Users/example/.m2/settings.xml", + skipTests: true, + mavenExecutablePath: "D:/Tools/apache-maven", + javaHomePath: "C:/Java/jdk-21", + }; + + await createLaunchPlan("D:/fixture/project", "spring", undefined, mavenContext); + + expect(executeCore).toHaveBeenCalledWith( + expect.objectContaining({ + command: "runConfig.createLaunchPlan", + payload: { + root: "D:/fixture/project", + configurationId: "spring", + currentFile: undefined, + mavenContext, + }, + }), + ); + }); + test("sends project-relative working directory and toolchain paths in project scope", async () => { await saveRunConfigurationEditorChanges( "D:/fixture/project", @@ -31,6 +57,7 @@ describe("saveRunConfigurationEditorChanges", () => { javaHomePath: "D:\\fixture\\project\\toolchains\\jdk", mavenExecutablePath: "D:/fixture/project/toolchains/maven/bin/mvn.cmd", mavenJavaHomePath: "D:/fixture/project/toolchains/maven-jdk", + mavenSkipTests: false, workingDirectoryPath: "D:/fixture/project/app", vmArguments: "-Xmx2g", programArguments: "--dev", @@ -51,6 +78,7 @@ describe("saveRunConfigurationEditorChanges", () => { arguments: "--dev", environment: { APP_ENV: "dev" }, mavenProfiles: [], + mavenSkipTests: false, javaHomePath: "toolchains/jdk", mavenExecutablePath: "toolchains/maven/bin/mvn.cmd", mavenJavaHomePath: "toolchains/maven-jdk", @@ -88,6 +116,34 @@ describe("saveRunConfigurationEditorChanges", () => { ); }); + test("uses empty working directory and null test override to inherit project defaults", async () => { + await saveRunConfigurationEditorChanges( + "D:/fixture/project", + "spring", + "project", + { + javaHomePath: "", + mavenExecutablePath: "", + mavenJavaHomePath: "", + mavenSkipTests: null, + workingDirectoryPath: "", + vmArguments: "", + programArguments: "", + environment: {}, + }, + emptyToolchain, + ); + + expect(executeCore).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + workingDirectory: "", + mavenSkipTests: null, + }), + }), + ); + }); + test("rejects a project path outside the workspace", () => { expect(() => saveRunConfigurationEditorChanges( diff --git a/windows/tauri/src/features/run/api/run-core-api.ts b/windows/tauri/src/features/run/api/run-core-api.ts index 5a6c9cf17..eb7d85ec8 100644 --- a/windows/tauri/src/features/run/api/run-core-api.ts +++ b/windows/tauri/src/features/run/api/run-core-api.ts @@ -8,6 +8,7 @@ import type { RunOptions, RunSaveScope, } from "../types/run.types"; +import type { MavenLaunchContext } from "@/features/maven/types/maven.types"; import { projectScopedPath } from "../utils/run-configuration"; let requestSequence = 0; @@ -56,11 +57,17 @@ export function resolveRunConfiguration( return runCore("runConfig.resolve", { root, toolchainCandidates }); } -export function createLaunchPlan(root: string, configurationId: string, currentFile?: string) { +export function createLaunchPlan( + root: string, + configurationId: string, + currentFile?: string, + mavenContext?: MavenLaunchContext | null, +) { return runCore("runConfig.createLaunchPlan", { root, configurationId, currentFile, + mavenContext: mavenContext ?? null, }); } @@ -88,8 +95,9 @@ export function saveRunConfigurationEditorChanges( } function scopedRunOptions(root: string, scope: RunSaveScope, options: RunOptions) { - const workingDirectory = - scope === "project" + const workingDirectory = !options.workingDirectoryPath.trim() + ? "" + : scope === "project" ? projectScopedPath(root, options.workingDirectoryPath) : options.workingDirectoryPath; const scopedToolchainPath = (value: string) => { @@ -113,6 +121,7 @@ function scopedRunOptions(root: string, scope: RunSaveScope, options: RunOptions arguments: options.programArguments, environment: options.environment, mavenProfiles: [], + mavenSkipTests: options.mavenSkipTests ?? null, javaHomePath, mavenExecutablePath, mavenJavaHomePath, diff --git a/windows/tauri/src/features/run/components/run-configuration-editor.tsx b/windows/tauri/src/features/run/components/run-configuration-editor.tsx index a2264c73f..c8b018592 100644 --- a/windows/tauri/src/features/run/components/run-configuration-editor.tsx +++ b/windows/tauri/src/features/run/components/run-configuration-editor.tsx @@ -353,6 +353,33 @@ export function RunConfigurationEditor({ onSelect={(value) => setDraft((current) => ({ ...current, mavenJavaHomePath: value }))} onPick={() => pickDirectory("mavenJavaHomePath")} /> + + {t("run.mavenTests")} + + setDraft((current) => ({ + ...current, + mavenSkipTests: + event.target.value === "inherit" ? null : event.target.value === "skip", + })) + } + > + + {t("run.mavenTestsProjectDefault")} + + {t("run.mavenTestsRun")} + {t("run.mavenTestsSkip")} + + {t("run.mavenTestsHint")} + ) : null} diff --git a/windows/tauri/src/features/run/stores/run-maven-context.test.ts b/windows/tauri/src/features/run/stores/run-maven-context.test.ts new file mode 100644 index 000000000..532645a6a --- /dev/null +++ b/windows/tauri/src/features/run/stores/run-maven-context.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, mock, test } from "bun:test"; +import type { MavenLaunchContext } from "@/features/maven/types/maven.types"; +import type { RunConfiguration } from "../types/run.types"; +import { createRunStore, type RunStoreDependencies } from "./run.store"; + +type Deferred = { + promise: Promise; + resolve: (value: T) => void; +}; + +function deferred(): Deferred { + let resolve!: (value: T) => void; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} + +const mavenContext: MavenLaunchContext = { + version: 1, + reactorPath: "reactor", + profiles: ["dev"], + settingsPath: "C:/Users/example/.m2/settings.xml", + skipTests: true, + mavenExecutablePath: "D:/Tools/apache-maven", + javaHomePath: "C:/Java/jdk-21", +}; + +const configuration: RunConfiguration = { + id: "spring", + name: "Spring Boot", + provider: "spring-boot.maven", + kindTitle: "Spring Boot", + execution: "service", + cwd: "", + args: [], + env: {}, + jvmArguments: [], + programArguments: [], + profiles: [], + mavenSkipTests: null, + javaHomePath: "", + mavenExecutablePath: "", + mavenJavaHomePath: "", + toolchains: { java: "project-jdk", maven: "project-maven" }, + source: "generated", + disabled: false, +}; + +describe("Maven-backed Run context", () => { + test("waits for the workspace Maven load before creating the launch plan", async () => { + const pendingContext = deferred(); + const events: string[] = []; + const createLaunchPlan = mock( + async (...args: Parameters) => { + events.push("plan-created"); + expect(args[3]).toEqual(mavenContext); + return { + executable: { toolchain: "project-maven" }, + arguments: ["-B", "spring-boot:run"], + workingDirectory: "reactor", + }; + }, + ); + const mavenLaunchContextForWorkspace = mock(async () => { + events.push("context-started"); + return pendingContext.promise; + }); + const resolveRunLaunch = mock(async () => ({ + executable: "D:/Tools/apache-maven/bin/mvn.cmd", + workingDirectory: "D:/work/reactor", + environment: {}, + })); + const saveWorkspaceBeforeLaunch = mock(async () => { + events.push("files-saved"); + }); + const startRunProcess = mock(async () => undefined); + const stopRunProcess = mock(async () => undefined); + const dependencies: RunStoreDependencies = { + createLaunchPlan, + mavenLaunchContextForWorkspace, + resolveRunLaunch, + saveWorkspaceBeforeLaunch, + startRunProcess, + stopRunProcess, + }; + const store = createRunStore("workspace", dependencies); + store.setState({ + root: "D:/work", + configurations: [configuration], + diagnostics: [], + effectiveRuntimeExecutablePaths: {}, + }); + + const run = store.getState().actions.runConfiguration(configuration.id); + try { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + expect(events).toEqual(["files-saved", "context-started"]); + expect(createLaunchPlan).not.toHaveBeenCalled(); + } finally { + pendingContext.resolve(mavenContext); + await run; + } + + expect(saveWorkspaceBeforeLaunch).toHaveBeenCalledWith("workspace"); + expect(mavenLaunchContextForWorkspace).toHaveBeenCalledWith("D:/work", [], "workspace"); + expect(createLaunchPlan).toHaveBeenCalledWith("D:/work", "spring", undefined, mavenContext); + expect(resolveRunLaunch).toHaveBeenCalledWith( + expect.objectContaining({ + mavenExecutablePath: "D:/Tools/apache-maven", + mavenJavaHomePath: "C:/Java/jdk-21", + }), + ); + }); + + test("does not create a launch plan when workspace files cannot be saved", async () => { + const createLaunchPlan = mock(async () => ({ + executable: { toolchain: "project-maven" as const }, + arguments: ["-B", "spring-boot:run"], + workingDirectory: "reactor", + })); + const startRunProcess = mock(async () => undefined); + const dependencies: RunStoreDependencies = { + createLaunchPlan, + mavenLaunchContextForWorkspace: mock(async () => mavenContext), + resolveRunLaunch: mock(async () => ({ + executable: "D:/Tools/apache-maven/bin/mvn.cmd", + workingDirectory: "D:/work/reactor", + environment: {}, + })), + saveWorkspaceBeforeLaunch: mock(async () => { + throw new Error("Unable to start because modified files could not be saved: App.java."); + }), + startRunProcess, + stopRunProcess: mock(async () => undefined), + }; + const store = createRunStore("workspace", dependencies); + store.setState({ + root: "D:/work", + configurations: [configuration], + diagnostics: [], + effectiveRuntimeExecutablePaths: {}, + }); + + await store.getState().actions.runConfiguration(configuration.id); + + expect(createLaunchPlan).not.toHaveBeenCalled(); + expect(startRunProcess).not.toHaveBeenCalled(); + expect(store.getState().sessions).toEqual([ + expect.objectContaining({ + id: configuration.id, + isRunning: false, + exitCode: 1, + output: expect.stringContaining("App.java"), + }), + ]); + }); +}); diff --git a/windows/tauri/src/features/run/stores/run.store.ts b/windows/tauri/src/features/run/stores/run.store.ts index 9d6677f1f..d51dbf175 100644 --- a/windows/tauri/src/features/run/stores/run.store.ts +++ b/windows/tauri/src/features/run/stores/run.store.ts @@ -1,6 +1,8 @@ import { createStore } from "zustand/vanilla"; +import { saveWorkspaceBeforeLaunch } from "@/features/editor/services/save-workspace-before-launch"; import { createWorkspaceScopedStore } from "@/features/workspace/stores/create-workspace-scoped-store"; import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; +import { mavenLaunchContextForWorkspace } from "@/features/maven/stores/maven.store"; import { createLaunchPlan, generateRunConfiguration, @@ -46,6 +48,7 @@ import { recoveryActionForError, recoveryPathFromMessage, selectedToolchainCandidates, + configurationUsesMaven, } from "../utils/run-configuration"; import { editorSaveFailureMessage, runEditorSaveWorkflow } from "../services/run-editor-save"; import { createOutputStamper, trimRunOutput, type OutputStamper } from "../utils/output-timestamper"; @@ -99,6 +102,24 @@ interface RunState { }; } +export interface RunStoreDependencies { + createLaunchPlan: typeof createLaunchPlan; + mavenLaunchContextForWorkspace: typeof mavenLaunchContextForWorkspace; + resolveRunLaunch: typeof resolveRunLaunch; + saveWorkspaceBeforeLaunch: typeof saveWorkspaceBeforeLaunch; + startRunProcess: typeof startRunProcess; + stopRunProcess: typeof stopRunProcess; +} + +const defaultRunStoreDependencies: RunStoreDependencies = { + createLaunchPlan, + mavenLaunchContextForWorkspace, + resolveRunLaunch, + saveWorkspaceBeforeLaunch, + startRunProcess, + stopRunProcess, +}; + interface ResolvedRunProject { configurations: RunConfiguration[]; diagnostics: RunDiagnostic[]; @@ -162,6 +183,7 @@ function optionsFromConfiguration(configuration: RunConfiguration): RunOptions { javaHomePath: configuration.javaHomePath, mavenExecutablePath: configuration.mavenExecutablePath, mavenJavaHomePath: configuration.mavenJavaHomePath, + mavenSkipTests: configuration.mavenSkipTests, workingDirectoryPath: configuration.cwd, vmArguments: configuration.jvmArguments.join(" "), programArguments: configuration.programArguments.join(" "), @@ -257,7 +279,10 @@ function readyRunState( }; } -export const createRunStore = () => +export const createRunStore = ( + workspaceId = workspaceRuntimeRegistry.getActiveWorkspaceId(), + dependencies: RunStoreDependencies = defaultRunStoreDependencies, +) => createStore()((set, get) => ({ root: null, status: "missing", @@ -400,18 +425,28 @@ export const createRunStore = () => } const sessionId = configuration.execution === "service" ? configuration.id : PRIMARY_SESSION_ID; - bindRunSessionWorkspace(sessionId); + bindRunSessionWorkspace(sessionId, workspaceId); resetOutputStamper(sessionId); - await stopRunProcess(sessionId).catch(() => undefined); + await dependencies.stopRunProcess(sessionId).catch(() => undefined); try { - const plan = await createLaunchPlan(root, configuration.id, currentFile); - const resolved = await resolveRunLaunch({ + await dependencies.saveWorkspaceBeforeLaunch(workspaceId); + const mavenContext = configurationUsesMaven(configuration) + ? await dependencies.mavenLaunchContextForWorkspace(root, [], workspaceId) + : null; + const plan = await dependencies.createLaunchPlan( + root, + configuration.id, + currentFile, + mavenContext, + ); + const resolved = await dependencies.resolveRunLaunch({ root, executable: plan.executable, workingDirectory: plan.workingDirectory, javaHomePath: configuration.javaHomePath, - mavenExecutablePath: configuration.mavenExecutablePath, - mavenJavaHomePath: configuration.mavenJavaHomePath, + mavenExecutablePath: + configuration.mavenExecutablePath || mavenContext?.mavenExecutablePath || "", + mavenJavaHomePath: configuration.mavenJavaHomePath || mavenContext?.javaHomePath || "", runtimeExecutablePaths: state.effectiveRuntimeExecutablePaths, environment: mergeLaunchEnvironment(configuration.env, plan), }); @@ -440,7 +475,7 @@ export const createRunStore = () => ], })); } - await startRunProcess({ + await dependencies.startRunProcess({ sessionId, executable: resolved.executable, arguments: plan.arguments, @@ -457,18 +492,27 @@ export const createRunStore = () => primaryOutput: trimOutput(`${get().primaryOutput}${message}\n`), }); } else { - set((current) => ({ - sessions: current.sessions.map((session) => - session.id === sessionId - ? { - ...session, - isRunning: false, - exitCode: 1, - output: trimOutput(`${session.output}${message}\n`), - } - : session, - ), - })); + set((current) => { + const existingSession = current.sessions.find( + (session) => session.id === sessionId, + ); + const failedSession: RunSession = { + id: sessionId, + configurationId: configuration.id, + title: configuration.name, + output: trimOutput(`${existingSession?.output ?? ""}${message}\n`), + isRunning: false, + exitCode: 1, + }; + return { + selectedSessionId: sessionId, + sessions: existingSession + ? current.sessions.map((session) => + session.id === sessionId ? failedSession : session, + ) + : [...current.sessions, failedSession], + }; + }); } } }, diff --git a/windows/tauri/src/features/run/types/run.types.ts b/windows/tauri/src/features/run/types/run.types.ts index f6bc4af4b..9f31ad7c4 100644 --- a/windows/tauri/src/features/run/types/run.types.ts +++ b/windows/tauri/src/features/run/types/run.types.ts @@ -30,6 +30,7 @@ export interface RunConfiguration { jvmArguments: string[]; programArguments: string[]; profiles: string[]; + mavenSkipTests: boolean | null; javaHomePath: string; mavenExecutablePath: string; mavenJavaHomePath: string; @@ -42,6 +43,7 @@ export interface RunOptions { javaHomePath: string; mavenExecutablePath: string; mavenJavaHomePath: string; + mavenSkipTests?: boolean | null; workingDirectoryPath: string; vmArguments: string; programArguments: string; @@ -142,6 +144,7 @@ export interface CoreResolvedConfiguration { jvmArguments?: string[]; programArguments?: string[]; profiles?: string[]; + skipTests?: boolean; }; java?: { homePath?: string; @@ -158,6 +161,7 @@ export const EMPTY_RUN_OPTIONS: RunOptions = { javaHomePath: "", mavenExecutablePath: "", mavenJavaHomePath: "", + mavenSkipTests: null, workingDirectoryPath: "", vmArguments: "", programArguments: "", diff --git a/windows/tauri/src/features/run/utils/run-configuration.test.ts b/windows/tauri/src/features/run/utils/run-configuration.test.ts index 86d2ce9bc..df002f175 100644 --- a/windows/tauri/src/features/run/utils/run-configuration.test.ts +++ b/windows/tauri/src/features/run/utils/run-configuration.test.ts @@ -23,7 +23,11 @@ describe("run configuration mapping", () => { execution: "service", source: "generated", extensions: { - maven: { module: ".", mainClass: "com.example.demo.DemoApplication" }, + maven: { + module: ".", + mainClass: "com.example.demo.DemoApplication", + skipTests: false, + }, }, }); @@ -31,6 +35,7 @@ describe("run configuration mapping", () => { expect(configuration.execution).toBe("service"); expect(configuration.mainClass).toBe("com.example.demo.DemoApplication"); expect(configuration.modulePath).toBeUndefined(); + expect(configuration.mavenSkipTests).toBe(false); }); test("groups runnable configurations and hides Current File", () => { diff --git a/windows/tauri/src/features/run/utils/run-configuration.ts b/windows/tauri/src/features/run/utils/run-configuration.ts index 6f4bc3f2c..db9bfc104 100644 --- a/windows/tauri/src/features/run/utils/run-configuration.ts +++ b/windows/tauri/src/features/run/utils/run-configuration.ts @@ -42,6 +42,7 @@ export function mapCoreConfiguration(value: CoreResolvedConfiguration): RunConfi jvmArguments: maven?.jvmArguments ?? [], programArguments: maven?.programArguments ?? value.args ?? [], profiles: maven?.profiles ?? [], + mavenSkipTests: maven?.skipTests ?? null, javaHomePath: java?.homePath ?? "", mavenExecutablePath: java?.mavenExecutablePath ?? "", mavenJavaHomePath: java?.mavenJavaHomePath ?? "", diff --git a/windows/tauri/src/features/window/stores/ui-state/types/ui-state.types.ts b/windows/tauri/src/features/window/stores/ui-state/types/ui-state.types.ts index 22fa67496..d049a451e 100644 --- a/windows/tauri/src/features/window/stores/ui-state/types/ui-state.types.ts +++ b/windows/tauri/src/features/window/stores/ui-state/types/ui-state.types.ts @@ -18,6 +18,7 @@ export type BottomPaneTab = | "references" | "buffers" | "run" + | "maven" | "gitLog"; export interface QuickEditSelection { diff --git a/windows/tauri/src/features/workspace/types/workspace-launch-scope.ts b/windows/tauri/src/features/workspace/types/workspace-launch-scope.ts new file mode 100644 index 000000000..9fca49956 --- /dev/null +++ b/windows/tauri/src/features/workspace/types/workspace-launch-scope.ts @@ -0,0 +1,26 @@ +import { normalizePath, stripTrailingPathSeparators } from "@/utils/path-helpers"; + +export interface WorkspaceLaunchScope { + workspaceId: string; + root: string; +} + +function workspaceRootKey(root: string): string { + const normalized = normalizePath(stripTrailingPathSeparators(root)); + return /^(?:[A-Za-z]:\/|\/\/)/.test(normalized) ? normalized.toLowerCase() : normalized; +} + +export function workspaceScopeMatchesRoot( + scope: WorkspaceLaunchScope, + root: string | null | undefined, +): boolean { + if (!root) return false; + return workspaceRootKey(root) === workspaceRootKey(scope.root); +} + +export function workspaceScopesMatch( + left: WorkspaceLaunchScope, + right: WorkspaceLaunchScope, +): boolean { + return left.workspaceId === right.workspaceId && workspaceScopeMatchesRoot(left, right.root); +} diff --git a/windows/tauri/src/i18n/locale.test.ts b/windows/tauri/src/i18n/locale.test.ts index eaeb9e503..3e0fbcdb9 100644 --- a/windows/tauri/src/i18n/locale.test.ts +++ b/windows/tauri/src/i18n/locale.test.ts @@ -37,6 +37,8 @@ describe("Windows display language", () => { expect(translate("git.log.headCurrentBranch")).toBe("HEAD(当前分支)"); expect(translate("run.identifyAndGenerate")).toBe("识别并生成"); expect(createTranslator("en-US")("workbench.run")).toBe("Run"); + expect(createTranslator("en-US")("workbench.maven")).toBe("Maven"); + expect(translate("workbench.maven")).toBe("Maven"); expect(createTranslator("en-US")("titleProject.closeProject", { name: "Lithe" })).toBe( "Close project Lithe", ); diff --git a/windows/tauri/src/i18n/locale.ts b/windows/tauri/src/i18n/locale.ts index bf8788644..6c31db585 100644 --- a/windows/tauri/src/i18n/locale.ts +++ b/windows/tauri/src/i18n/locale.ts @@ -1328,6 +1328,7 @@ const catalogs = { "workbench.database": "Database", "workbench.settings": "Settings", "workbench.run": "Run", + "workbench.maven": "Maven", "workbench.terminal": "Terminal", "workbench.diagnostics": "Diagnostics", "run.title": "Run", @@ -1379,6 +1380,12 @@ const catalogs = { "run.nodeExecutableHint": "Choose node.exe, or leave empty to use the detected Node.js runtime.", "run.mavenJdkHome": "Maven JDK Home", "run.mavenJdkHomeHint": "Leave empty to use the same JDK as the application.", + "run.mavenTests": "Maven tests", + "run.mavenTestsProjectDefault": "Use project default", + "run.mavenTestsRun": "Run tests", + "run.mavenTestsSkip": "Skip tests", + "run.mavenTestsHint": + "Override the Maven tool window's Skip Tests setting for this run configuration.", "run.toolchainAuto": "Auto-detect (leave empty)", "run.toolchainCurrent": "Current path", "run.runtimeSection": "Runtime (this PC)", @@ -1414,6 +1421,35 @@ const catalogs = { "run.newCustomAction": "New custom action", "run.runCell": "Run cell", "run.runChunk": "Run chunk", + "maven.title": "Maven", + "maven.project": "Project", + "maven.lifecycle": "Lifecycle", + "maven.profiles": "Profiles", + "maven.settings": "Maven Settings", + "maven.automatic": "Automatic", + "maven.mavenExecutable": "Maven home or executable", + "maven.javaHome": "Maven JDK Home", + "maven.stop": "Stop Maven task", + "maven.cancelled": "Cancelled", + "maven.runSelected": "Run selected lifecycle phase", + "maven.executeGoal": "Execute Maven goal", + "maven.reloadProjects": "Reload Maven projects", + "maven.skipTests": "Skip tests", + "maven.collapseAll": "Collapse all", + "maven.clearOutput": "Clear build output", + "maven.configurationChanged": "Maven configuration changed", + "maven.reloadJdt": "Reload JDT LS", + "maven.reloadFailed": "Unable to reload the Java language server.", + "maven.loadFailed": "Unable to load Maven project", + "maven.buildOutput": "Build Output", + "maven.processOutput": "Process output", + "maven.emptyOutput": "Run a Maven lifecycle phase to see output.", + "maven.scanning": "Scanning Maven project...", + "maven.notDetected": "No Maven project detected", + "maven.addProfile": "Add Maven profile", + "maven.restoreProfiles": "Restore default profiles", + "maven.add": "Add", + "maven.profileId": "Profile ID", "runActions.editRunAction": "Edit run action", "runActions.newRunAction": "New run action", "runActions.saveChanges": "Save changes", @@ -5113,6 +5149,7 @@ const catalogs = { "workbench.database": "数据库", "workbench.settings": "设置", "workbench.run": "运行", + "workbench.maven": "Maven", "workbench.terminal": "终端", "workbench.diagnostics": "诊断", "run.title": "运行", @@ -5164,6 +5201,11 @@ const catalogs = { "run.nodeExecutableHint": "可选择 node.exe;留空则使用自动检测到的 Node.js 运行时。", "run.mavenJdkHome": "Maven JDK 主目录", "run.mavenJdkHomeHint": "留空则与应用使用同一个 JDK。", + "run.mavenTests": "Maven 测试", + "run.mavenTestsProjectDefault": "使用项目默认值", + "run.mavenTestsRun": "运行测试", + "run.mavenTestsSkip": "跳过测试", + "run.mavenTestsHint": "为当前运行配置覆盖 Maven 工具窗口中的“跳过测试”设置。", "run.toolchainAuto": "自动检测(留空)", "run.toolchainCurrent": "当前路径", "run.runtimeSection": "运行环境(本机)", @@ -5198,6 +5240,35 @@ const catalogs = { "run.newCustomAction": "新建自定义操作", "run.runCell": "运行单元", "run.runChunk": "运行代码块", + "maven.title": "Maven", + "maven.project": "项目", + "maven.lifecycle": "生命周期", + "maven.profiles": "Profiles", + "maven.settings": "Maven 设置", + "maven.automatic": "自动检测", + "maven.mavenExecutable": "Maven 主目录 / 可执行文件", + "maven.javaHome": "Maven JDK 主目录", + "maven.stop": "停止 Maven 任务", + "maven.cancelled": "已取消", + "maven.runSelected": "运行选中的生命周期阶段", + "maven.executeGoal": "执行 Maven Goal", + "maven.reloadProjects": "重新加载 Maven 项目", + "maven.skipTests": "跳过测试", + "maven.collapseAll": "全部折叠", + "maven.clearOutput": "清除构建输出", + "maven.configurationChanged": "Maven 配置已更改", + "maven.reloadJdt": "重新加载 JDT LS", + "maven.reloadFailed": "无法重新加载 Java 语言服务器。", + "maven.loadFailed": "无法加载 Maven 项目", + "maven.buildOutput": "构建输出", + "maven.processOutput": "进程输出", + "maven.emptyOutput": "运行 Maven 生命周期阶段后将在这里显示输出。", + "maven.scanning": "正在扫描 Maven 项目...", + "maven.notDetected": "未检测到 Maven 项目", + "maven.addProfile": "添加 Maven Profile", + "maven.restoreProfiles": "恢复默认 Profiles", + "maven.add": "添加", + "maven.profileId": "Profile ID", "runActions.editRunAction": "编辑运行操作", "runActions.newRunAction": "新建运行操作", "runActions.saveChanges": "保存更改", diff --git a/windows/tauri/src/platform/lsp-core-adapter.ts b/windows/tauri/src/platform/lsp-core-adapter.ts index a1ae8a727..9e920f6ac 100644 --- a/windows/tauri/src/platform/lsp-core-adapter.ts +++ b/windows/tauri/src/platform/lsp-core-adapter.ts @@ -689,6 +689,7 @@ async function createSession(args: JsonRecord, key: string): Promise { jdtlsLaunchResources: args.jdtlsLaunchResources ?? null, cacheDirectory: args.cacheDirectory ?? null, workspaceFingerprint: args.workspaceFingerprint ?? null, + mavenContext: args.mavenContext ?? null, initializeTimeoutMilliseconds: INITIALIZE_TIMEOUT_MS, }, operationId, diff --git a/windows/tauri/src/platform/tauri-core.ts b/windows/tauri/src/platform/tauri-core.ts index 3e9291f23..e74d192e2 100644 --- a/windows/tauri/src/platform/tauri-core.ts +++ b/windows/tauri/src/platform/tauri-core.ts @@ -38,6 +38,8 @@ const nativeCommands = new Set([ "list_shells", "lsp_rebuild_java_index", "lsp_resolve_java_launch", + "maven_load_configuration", + "maven_write_configuration", "move_file", "open_log_directory", "open_file_external",