diff --git a/docs/development/macos-plugin-api-plan.md b/docs/development/macos-plugin-api-plan.md index 95461bff80..cdf56a2239 100644 --- a/docs/development/macos-plugin-api-plan.md +++ b/docs/development/macos-plugin-api-plan.md @@ -44,15 +44,15 @@ Windows 不在本轮,平台矩阵的 partial/pending 保持真实。 | API-19 | 部分完成 | 已有声明式设置 1/2;通用 settings.get/inspect/update/events 未做;Java 拆分计划(M1、D1):Maven 插件按真实配置读取、回显、写回 settings | | API-20 | 部分完成 | global/backend/workspace 可写目录与网页状态;语言服务器的按工作区状态目录按 `state.retention` 后台回收(Core 决定,仅 macOS,无用户可见重置入口);统一 KV/配额/session API 未做 | | API-21 | 既有能力 | 协议 2 credentials/get/set/delete;权限与插件隔离不变 | -| API-22 | 部分完成 | 既有函数进度与取消;语言服务器准备状态 `languageServer/setStatus`(仅百分比,无 actions,仅 macOS);独立 output channel、通用 log/progress API 未做;Java 拆分计划(C-RUN):输出链接匹配器 | -| API-23 | 部分完成 | 已有进程声明与运行时解析;通用 toolchains/process 服务未开放;Java 拆分计划(C-RUN):run/start、run/stop、工具 ID 引用与 requiresTools | +| API-22 | 部分完成 | 既有函数进度与取消;语言服务器准备状态 `languageServer/setStatus`(仅百分比,无 actions,仅 macOS);新增 `outputLinkMatchers:1`,插件声明的匹配器让 Run 与测试控制台出现链接(macOS);独立 output channel、通用 log/progress API 未做 | +| API-23 | 部分完成 | 已有进程声明与运行时解析;新增 `serverProcess:4` 的工具 ID 引用(清单 `requiresTools`)与后端发起的 `run/start`、`run/stop`、`run/didExit`(macOS,工具 ID 未找到或不可用时返回 -32025);通用 toolchains/process 服务未开放 | | API-24 | 部分完成 | 已公开插件自有原生终端 create/show/close/reopen/dispose/onExit(macOS、本机);不开放 sendText/输出读取,Windows 待接入 | | API-25 | 部分完成 | 既有语言/LSP 贡献;`languages:2` 的清单与包内 JSON 语法/编辑配置,仅支持 Monarch;新增 `languageServers:3` 与 `serverProcess:3`:后端经 `languageServer/request\|notify\|setStatus` 使用自己的服务器、后端启动(`launch.kind:"backend"`),会话策略由 Core 执行(仅 macOS,远程后端启动未支持,见 Agent Note 2026-11-04-plugin-owned-language-server-sessions);新增宿主方法 `lithe/host/editor/registerProviders`(权限 `editor.providers`,macOS,待运行验收)把 definition/hover/completion/codeLens/foldingRange/semanticTokens/documentLink 绑定到普通插件函数,语言服务器优先、插件只补充;符号、格式化、重命名、Code Action、签名帮助、内联提示和运行期传入 JSON 动态注册高亮未做,详见下方范围说明;Java 拆分计划(C-EDIT):Spring、MyBatis 只补上游缺失的 Provider | | API-26 | 未做 | 公共诊断 collection/events API 未开放;既有 LSP 诊断不等于该 API;`registerProviders` 不接受 `diagnostics` 类型(返回 unsupportedOption),推送式诊断集合另行设计;Java 拆分计划(C-EDIT):推送式 collection,带文档版本 | | API-27 | 部分完成 | 独立 `editor/registerActions` 绑定普通函数与文档范围,不依赖 LSP/Build/Run;版本 2 增加行首多图标(run/run-all/debug/test 与 test-passed/test-failed/test-skipped 结果态、navigate-super/navigate-implementations 导航)、同一标记的 `alternatives`(至多 4 个)和点击结果的 `reveal`(宿主展示位置);通用主题、片段、其他装饰未做;Java 拆分计划(C-EDIT):图标、alternatives、reveal、CodeLens、运行与测试标记 | | API-28 | 未做 | 公共 custom editor 文档 owner/保存/撤销/恢复未开放 | | API-29 | 部分完成 | #1517 BuildProvider 1–6/model inputs;普通编辑器动作不属于本领域项;新指南未齐全;Java 拆分不改协议:测试发现与运行沿用 #1517,由 Maven、Gradle 插件提供 | -| API-30 | 既有能力 | 已有 debugAdapters 1/2 与 DAP 宿主;本 PR 不重建调试协议;Java 拆分计划(C-RUN):debug/start、debugAdapters 3 的 features 与 defaults | +| API-30 | 部分完成 | 已有 debugAdapters 1/2 与 DAP 宿主;新增 `debugAdapters:3` 的 features/defaults(热代码替换、步进过滤器)与后端发起的 `debug/start`(macOS);不重建调试协议 | | API-31 | 既有能力 | 已有 vcsProviders:1;不包含内置 Git 公共读取面 | | API-32 | 未做 | 通用 ServiceProvider 仍为提案 | | API-33 | 部分完成 | projectGenerators 2 增加通用工具字段(`type:"tool"`,工具来自 `toolDefinitions`,选择写入项目的工具 pin),即 Java 拆分计划(C-PROJ)所需的版本;项目生成器与工具定义插件免重启生效;新 hooks 不在本轮 | @@ -227,8 +227,8 @@ Mac 当前声明的能力版本为:`backend:1`、`buildModelInputs:1`、`build | API-19 H/C/E | `settings.get/inspect/update/onDidChange`;已有 settings 1/2 和注册表 [S10] | 只读/写入分权;区分 Lithe 偏好与工具真实配置,显示来源/作用域/有效值。工具配置显式 Apply、冲突保护、写后重读,不建立无效影子设置 | M3;Java 拆分(M1、D1):Maven 插件内实现 | | API-20 H | `storage.global/workspace/session`、包内只读资源;已有平台 state store [S2/S4] | 插件命名空间和配额;状态放平台可写位置,迁移/卸载策略明确。工作区状态不是 v1 应用级 `${stateDir}` 的别名,不写插件包/bundle | M0:作用域;M3:扩展接口;Java 拆分(C-LS):仅新增 temporaryDirectory | | API-21 H | `secrets.get/set/delete`;已有函数协议 v2 credentials 服务 [S2] | 保持本插件隔离和现有凭据授权;不得读取 IDE/Agent/其他插件密钥,不将 secret 放日志、普通配置或状态。先补 SDK/文档一致性,不另造密码库 | M1 | -| API-22 H/E | `output.createChannel/append/dispose`、`log`、`progress.withCancellation`;已有函数进度与输出生命周期 [S2/S11] | 插件/操作归属、日志脱敏与字节预算;取消/超时后不再更新 UI。只复用已有进度状态机,不把 stdout 协议流与业务日志混用 | M1:命令结果/进度;M3:独立输出;Java 拆分(C-LS、C-RUN):setStatus、输出链接匹配器 | -| API-23 H | `toolchains.resolve/list`、受管 `process.run/cancel`;已有 executable/runtime 解析与进程链路 [S1/S11] | 显式执行授权,executable/argv 分离,cwd/环境白名单、输出和超时上限、进程树清理;不提供任意 shell 字符串,也不自动下载运行时 | M4;Java 拆分(C-RUN):run/start、run/stop、工具 ID 引用;Run 会话比受管进程多一层宿主展示 | +| API-22 H/E | `output.createChannel/append/dispose`、`log`、`progress.withCancellation`;已有函数进度与输出生命周期 [S2/S11] | 插件/操作归属、日志脱敏与字节预算;取消/超时后不再更新 UI。只复用已有进度状态机,不把 stdout 协议流与业务日志混用。输出链接由 Core 匹配、宿主只上样式和转发点击,`resolve` 函数在点击时才调用 | M1:命令结果/进度;M3:独立输出;Java 拆分(C-LS):setStatus | +| API-23 H | `toolchains.resolve/list`、受管 `process.run/cancel`;已有 executable/runtime 解析与进程链路 [S1/S11] | 显式执行授权,executable/argv 分离,cwd/环境白名单、输出和超时上限、进程树清理;不提供任意 shell 字符串,也不自动下载运行时。`run/start` 的可执行文件只能是工具定义 ID 或插件包内文件,工作目录限工作区内,由 Run 窗口展示 | M4 | | API-24 H/E | `terminal.create/show/close/reopen/dispose/onExit` 已复用原生终端模块 [S12];函数协议 3 宿主服务 | `terminal.create` 权限、创建逐操作确认;只控制本 backend session/context 的终端,禁用/关闭工作区清理。无 sendText 或输出读取;本机 macOS,Windows 与远程未实现 | 首版接线,原生验收见功能矩阵 | | API-25 C/P | 语言/语法/LSP、补全/悬浮/定义/符号/格式化/重命名/Code Action;已有语言服务器贡献 [S1/S13] | 优先接成熟 LSP 的完整能力;自定义 Provider 仅补上游确实缺失项,按文档版本取消旧请求。避免让 SDK 成为第二套语言/工程引擎 | M4:已支持能力文档;`languages:2` 声明式语法已实现;`editor/registerProviders` 已接线 macOS(Core 校验注册与结果形状,宿主按类型预算并发询问,合并规则见 Agent Note 2026-10-31-plugin-editor-extension-apis);后续:符号/格式化/重命名/Code Action、Windows;Java 拆分(C-LS、C-EDIT):后端启动解析器与向自有语言服务器的请求;Spring、MyBatis 属于上游缺失项 | | API-26 H/P/E | `diagnostics.createCollection/set/delete/onDidChange`;已有 Problems/诊断 owner [S13] | 只能写本插件 collection,带文档版本和稳定范围;编辑/卸载后旧诊断可撤回。不能覆盖其他 LSP 结果或伪造宿主诊断身份 | M4;Java 拆分(C-EDIT):推送式 collection | @@ -242,7 +242,7 @@ Mac 当前声明的能力版本为:`backend:1`、`buildModelInputs:1`、`build | ID / 方向 | 候选能力与现状/所有者 | 权限、作用域与验收 | 阶段 | | --- | --- | --- | --- | | API-29 P/C | Build/Task/Test/Run Preparation、只读工程模型输入;#1517 已合并,Mac 已声明 `buildProviders:1–6`、`buildModelInputs:1`、`debugAdapters:2` [S1/S11] | 本项只补文档、SDK 对齐和真实项目端到端验收,不重复定义协议。原生 Build/Test/Run 展示、准备、取消共用宿主;快照带源/版本,不能成为第二份 classpath/工程真相;远程工作区下的 Provider 启动限制见 API-40。编辑器动作是独立扩展点,不计入领域执行。 | M4;Java 拆分不改协议 | -| API-30 P/C/E | Debug Adapter 注册、配置和会话事件;已有 DAP 宿主 [S14] | 进程/连接授权、launch 与 attach 所有权、终止和断开区别;复用断点/调试 UI。高级内存/反汇编按独立提案,不阻塞基础 API | M4;Java 拆分(C-RUN):debug/start、features 与 defaults | +| API-30 P/C/E | Debug Adapter 注册、配置和会话事件;已有 DAP 宿主 [S14] | 进程/连接授权、launch 与 attach 所有权、终止和断开区别;复用断点/调试 UI。后端发起的 `debug/start` 只能用自己声明的适配器,`run/start` 的 `debug.target` 目前只支持 connection。高级内存/反汇编按独立提案,不阻塞基础 API | M4 | | API-31 P/C | VCS 仓库/状态/diff/提交等领域能力;已有 vcsProviders 1 [S14],只覆盖插件提供的 VCS,内置 Git 见 API-42 | 读取与修改仓库分权;危险操作有明确确认,结果有仓库/操作身份。插件私有函数不能代替宿主可理解的 VCS 协议 | M4 | | API-32 P/C/E | Services 的实例/状态/动作/日志;数据库已有自身路径,通用 ServiceProvider 仍为提案 [S17] | 插件拥有的连接/进程与用户外部服务分开;停用只清理前者,不默认停用户数据库/容器。服务接入现有 Services,不再画一套工作台 | 后续 | | API-33 P/C | 项目生成器发现、参数、预览与生成;已有 projectGenerators 1,projectGenerators 2 增加通用工具字段 [S14] | 写目标目录需确认,禁止路径逃逸/无提示覆盖;只生成选择的项目,不下载未声明程序。工具字段只通过工具定义的通用解析列出候选,选择写入项目已有的工具 pin,生成器不能自带工具发现。版本 1 的 `runtime` 字段保持不变,待 Java 逻辑迁出后再移除。先补现有文档,再评估新增 hooks | M4;Java 拆分(C-PROJ):projectGenerators 2 | diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index 23409e4623..a8238cd376 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -399,8 +399,10 @@ "Apply Patch" = "Apply Patch"; "Apply Patch…" = "Apply Patch…"; "Apply code changes" = "Apply code changes"; +"Apply code changes to the running program" = "Apply code changes to the running program"; "Apply code changes to this service" = "Apply code changes to this service"; "Apply to" = "Apply to"; +"Applying code changes…" = "Applying code changes…"; "Approval mode" = "Approval mode"; "Approve" = "Approve"; "Approve for me" = "Approve for me"; @@ -818,6 +820,7 @@ "Closing the project stops these services and the processes they started." = "Closing the project stops these services and the processes they started."; "Closing the terminal ends these programs." = "Closing the terminal ends these programs."; "Closing this terminal will stop its shell and any running command." = "Closing this terminal will stop its shell and any running command."; +"Code changes applied." = "Code changes applied."; "Code settings" = "Code settings"; "Codex" = "Codex"; "Codex Agents require the Responses API. Claude Agents require an Anthropic API key." = "Codex Agents require the Responses API. Claude Agents require an Anthropic API key."; @@ -1053,6 +1056,7 @@ "Copy session ID" = "Copy session ID"; "Copy table" = "Copy table"; "Copy worktree path" = "Copy worktree path"; +"Could not apply code changes: %@" = "Could not apply code changes: %@"; "Could not apply project indexing exclusions. Try reopening the project." = "Could not apply project indexing exclusions. Try reopening the project."; "Could not be determined" = "Could not be determined"; "Could not change the log directory. Try again." = "Could not change the log directory. Try again."; @@ -2593,6 +2597,7 @@ "No callable target at this location" = "No callable target at this location"; "No changed files" = "No changed files"; "No changes" = "No changes"; +"No code changes to apply." = "No code changes to apply."; "No command" = "No command"; "No commit selected" = "No commit selected"; "No commits" = "No commits"; @@ -2625,6 +2630,7 @@ "No implementations found" = "No implementations found"; "No installation of this tool was found." = "No installation of this tool was found."; "No installed plugin supports %@ yet." = "No installed plugin supports %@ yet."; +"No installed tool matches the one the plugin asked to run." = "No installed tool matches the one the plugin asked to run."; "No items" = "No items"; "No language dependency sources registered" = "No language dependency sources registered"; "No language provider handles .%@ files." = "No language provider handles .%@ files."; @@ -2700,6 +2706,7 @@ "No settings to import yet" = "No settings to import yet"; "No shell was found at %@. Choose a detected shell or run detection again." = "No shell was found at %@. Choose a detected shell or run detection again."; "No shelves" = "No shelves"; +"No single debug adapter attaches to the “%@” configuration type." = "No single debug adapter attaches to the “%@” configuration type."; "No skills in this scope." = "No skills in this scope."; "No subagent output yet" = "No subagent output yet"; "No subagents yet" = "No subagents yet"; @@ -3634,6 +3641,7 @@ "Saved, not verified" = "Saved, not verified"; "Saves, connects, and opens the remote folder in this window." = "Saves, connects, and opens the remote folder in this window."; "Saving build provider run configurations is unavailable." = "Saving build provider run configurations is unavailable."; +"Saving code changes…" = "Saving code changes…"; "Saving replaces the current GitHub metadata." = "Saving replaces the current GitHub metadata."; "Saving writes to" = "Saving writes to"; "Scala Support" = "Scala Support"; @@ -4282,10 +4290,12 @@ "The debug adapter connection is stopped." = "The debug adapter connection is stopped."; "The debug adapter connection timed out." = "The debug adapter connection timed out."; "The debug adapter connection was not approved." = "The debug adapter connection was not approved."; +"The debug adapter could not start. Check that the plugin that provides it is enabled." = "The debug adapter could not start. Check that the plugin that provides it is enabled."; "The debug adapter did not accept outgoing data in time." = "The debug adapter did not accept outgoing data in time."; "The debug adapter did not provide a terminal executable." = "The debug adapter did not provide a terminal executable."; "The debug adapter outgoing buffer is full." = "The debug adapter outgoing buffer is full."; "The debug adapter provided an invalid terminal working directory." = "The debug adapter provided an invalid terminal working directory."; +"The debug configuration is too large." = "The debug configuration is too large."; "The debugger did not return object fields in time." = "The debugger did not return object fields in time."; "The decoded contents contain non-text characters. Use a suitable file viewer." = "The decoded contents contain non-text characters. Use a suitable file viewer."; "The default ChangeList cannot be renamed." = "The default ChangeList cannot be renamed."; @@ -4372,6 +4382,8 @@ "The plugin backend failed to start." = "The plugin backend failed to start."; "The plugin backend is no longer running." = "The plugin backend is no longer running."; "The plugin backend stopped before it was ready." = "The plugin backend stopped before it was ready."; +"The plugin could not find a file for this link." = "The plugin could not find a file for this link."; +"The plugin declares no such debug adapter." = "The plugin declares no such debug adapter."; "The plugin does not provide the debug adapter %@." = "The plugin does not provide the debug adapter %@."; "The plugin download address is invalid." = "The plugin download address is invalid."; "The plugin host request could not be completed." = "The plugin host request could not be completed."; @@ -4383,6 +4395,7 @@ "The plugin package directory is invalid." = "The plugin package directory is invalid."; "The plugin package file list does not match its signature." = "The plugin package file list does not match its signature."; "The plugin package file was changed after signing: %@." = "The plugin package file was changed after signing: %@."; +"The plugin package has no such executable file." = "The plugin package has no such executable file."; "The plugin package is missing %@. Reinstall the plugin." = "The plugin package is missing %@. Reinstall the plugin."; "The plugin package is signed by key %@, which this version of Lithe does not trust." = "The plugin package is signed by key %@, which this version of Lithe does not trust."; "The plugin package manifest is invalid." = "The plugin package manifest is invalid."; @@ -4412,6 +4425,7 @@ "The process does not have an open standard-input pipe." = "The process does not have an open standard-input pipe."; "The process is not running." = "The process is not running."; "The program chosen in the “%@” setting of %@ is missing or not executable: %@" = "The program chosen in the “%@” setting of %@ is missing or not executable: %@"; +"The program did not open its debug port before the deadline. Check the Run output." = "The program did not open its debug port before the deadline. Check the Run output."; "The project archive from %@ is too large." = "The project archive from %@ is too large."; "The project directory is unavailable." = "The project directory is unavailable."; "The project folder is unavailable. Restore it, then retry." = "The project folder is unavailable. Restore it, then retry."; @@ -4643,6 +4657,7 @@ "This version of Lithe cannot read update manifest schema %@. Open the Release page and update manually." = "This version of Lithe cannot read update manifest schema %@. Open the Release page and update manually."; "This will start %lld detected services." = "This will start %lld detected services."; "This window" = "This window"; +"This window cannot start runs for plugins." = "This window cannot start runs for plugins."; "This working copy format is not supported. Upgrade it with the command-line client." = "This working copy format is not supported. Upgrade it with the command-line client."; "This working copy is no longer available. Refresh and try again." = "This working copy is no longer available. Refresh and try again."; "This workspace is not open in a window, so only global versions were read." = "This workspace is not open in a window, so only global versions were read."; @@ -5641,11 +5656,13 @@ "native.appliesTheNextTimeTheLanguageServerStarts" = "Applies the next time the language server starts."; "native.applyAIGeneratedContent" = "Apply AI-generated content?"; "native.applyCodeChanges" = "Apply code changes"; +"native.applyCodeChangesToTheRunningProgram" = "Apply code changes to the running program"; "native.applyCodeChangesToThisService" = "Apply code changes to this service"; "native.applyPatch" = "Apply Patch"; "native.applyPatch.variant2" = "Apply Patch…"; "native.applyTo" = "Apply to"; "native.applyToTheCurrentBranch" = "Apply %1$@ to the current branch."; +"native.applyingCodeChanges" = "Applying code changes…"; "native.approvalMode" = "Approval mode"; "native.approve" = "Approve"; "native.approveForMe" = "Approve for me"; @@ -6037,6 +6054,7 @@ "native.closingThisTerminalWillStopItsShellAndAnyRunningCommand" = "Closing this terminal will stop its shell and any running command."; "native.cmakeSupport" = "CMake Support"; "native.code" = "code"; +"native.codeChangesApplied" = "Code changes applied."; "native.codex" = "Codex"; "native.codexAgentsRequireTheResponsesAPIClaudeAgentsRequireAnAnthropicAPIKey" = "Codex Agents require the Responses API. Claude Agents require an Anthropic API key."; "native.codexArchivedHistoryCouldNotBeRead" = "Codex archived history could not be read."; @@ -6253,6 +6271,7 @@ "native.copyThreadName" = "Copy Thread Name"; "native.copyValue" = "Copy Value"; "native.copyWorktreePath" = "Copy worktree path"; +"native.couldNotApplyCodeChanges" = "Could not apply code changes: %1$@"; "native.couldNotBeReachedCheckYourInternetProxyOrVPNConnectionAndTryAgain" = "%1$@ could not be reached. Check your internet, proxy, or VPN connection and try again."; "native.couldNotCheckForUpdates" = "Could not check for updates"; "native.couldNotCheckForUpdates.variant2" = "Could not check for updates."; @@ -7824,6 +7843,7 @@ "native.noCallableTargetAtThisLocation" = "No callable target at this location"; "native.noChangedFiles" = "No changed files"; "native.noChanges" = "No changes"; +"native.noCodeChangesToApply" = "No code changes to apply."; "native.noCodexConfigurationWasFound" = "No Codex configuration was found"; "native.noColumns" = "No Columns"; "native.noCommand" = "No command"; @@ -7861,6 +7881,7 @@ "native.noImplementationsFound" = "No implementations found"; "native.noInstallationOfThisToolWasFound" = "No installation of this tool was found."; "native.noInstalledPluginSupportsYet" = "No installed plugin supports %1$@ yet."; +"native.noInstalledToolMatchesTheOneThePluginAskedToRun" = "No installed tool matches the one the plugin asked to run."; "native.noItems" = "No items"; "native.noJDKFoundSetJAVAHOMEInstallAJDKOrChooseAJDKDirectory" = "No JDK found. Set JAVA_HOME, install a JDK, or choose a JDK directory."; "native.noJDKIsConfiguredForThisProject" = "No JDK is configured for this project."; @@ -7945,6 +7966,7 @@ "native.noSelectableItems" = "No selectable items"; "native.noShellWasFoundAtChooseADetectedShellOrRunDetectionAgain" = "No shell was found at %1$@. Choose a detected shell or run detection again."; "native.noShelves" = "No shelves"; +"native.noSingleDebugAdapterAttachesToTheConfigurationType" = "No single debug adapter attaches to the “%1$@” configuration type."; "native.noSkillsInThisScope" = "No skills in this scope."; "native.noSpringBootOrMavenModuleConfigurations" = "No Spring Boot or Maven Module configurations"; "native.noSpringMvcEndpointsFound" = "No Spring MVC endpoints found"; @@ -8784,6 +8806,7 @@ "native.savedStashInvalidUtf8" = "Saved changes contain text that is not UTF-8. Inspect the stash with Git."; "native.savedTo" = "Saved to %1$@"; "native.savesConnectsAndOpensTheRemoteFolderInThisWindow" = "Saves, connects, and opens the remote folder in this window."; +"native.savingCodeChanges" = "Saving code changes…"; "native.savingReplacesTheCurrentGitHubMetadata" = "Saving replaces the current GitHub metadata."; "native.savingWritesTo" = "Saving writes to"; "native.says" = "“%1$@” says"; @@ -9313,6 +9336,7 @@ "native.theCurrentFileWillBeSavedToLocalHistoryBeforeItIsReplaced" = "The current file will be saved to Local History before it is replaced."; "native.theCurrentWorktreeCannotBeRemovedHere" = "The current worktree cannot be removed here."; "native.theDebugAdapterConnectionIsStopped" = "The debug adapter connection is stopped."; +"native.theDebugAdapterCouldNotStartCheckThatThePluginThatProvidesItIsEnabled" = "The debug adapter could not start. Check that the plugin that provides it is enabled."; "native.theDebugAdapterDidNotProvideATerminal" = "The debug adapter did not provide a terminal executable."; "native.theDebugAdapterIsNotInstalledYet" = "The %1$@ Debug Adapter is not installed yet."; "native.theDebugAdapterIsNotReady" = "The Debug Adapter is not ready."; @@ -9325,6 +9349,7 @@ "native.theDebugAdapterReturnedAnInvalidResponse" = "The Debug Adapter returned an invalid %1$@ response."; "native.theDebugAdapterStopped" = "The Debug Adapter stopped."; "native.theDebugAdapterUsesTheTransportWhichLitheCannotStartYet" = "The debug adapter %1$@ uses the %2$@ transport, which Lithe cannot start yet. Only stdio and language server hosted adapters are supported."; +"native.theDebugConfigurationIsTooLarge" = "The debug configuration is too large."; "native.theDebugProviderDoesNotSupport" = "The %1$@ debug provider does not support %2$@."; "native.theDebuggerDidNotReturnObjectFieldsIn" = "The debugger did not return object fields in time."; "native.theDefaultChangeListCannotBeRenamed" = "The default ChangeList cannot be renamed."; @@ -9429,6 +9454,8 @@ "native.thePluginBackendFailedToStart" = "The plugin backend failed to start."; "native.thePluginBackendIsNoLongerRunning" = "The plugin backend is no longer running."; "native.thePluginBackendStoppedBeforeItWasReady" = "The plugin backend stopped before it was ready."; +"native.thePluginCouldNotFindAFileForThisLink" = "The plugin could not find a file for this link."; +"native.thePluginDeclaresNoSuchDebugAdapter" = "The plugin declares no such debug adapter."; "native.thePluginDoesNotProvideTheDebugAdapter" = "The plugin does not provide the debug adapter %1$@."; "native.thePluginDownloadAddressIsInvalid" = "The plugin download address is invalid."; "native.thePluginIsNotAllowedToUseTheHostService" = "The plugin is not allowed to use the host service %1$@."; @@ -9438,6 +9465,7 @@ "native.thePluginPackageDirectoryIsInvalid" = "The plugin package directory is invalid."; "native.thePluginPackageFileListDoesNotMatch" = "The plugin package file list does not match its signature."; "native.thePluginPackageFileWasChangedAfterSigning" = "The plugin package file was changed after signing: %1$@."; +"native.thePluginPackageHasNoSuchExecutableFile" = "The plugin package has no such executable file."; "native.thePluginPackageIsMissingReinstallThePlugin" = "The plugin package is missing %1$@. Reinstall the plugin."; "native.thePluginPackageIsSignedByKeyWhich" = "The plugin package is signed by key %1$@, which this version of Lithe does not trust."; "native.thePluginPackageManifestIsInvalid" = "The plugin package manifest is invalid."; @@ -9461,6 +9489,7 @@ "native.theProcessDoesNotHaveAnOpenStandard" = "The process does not have an open standard-input pipe."; "native.theProcessIsNotRunning" = "The process is not running."; "native.theProgramChosenInTheSettingOfIsMissingOrNotExecutable" = "The program chosen in the “%1$@” setting of %2$@ is missing or not executable: %3$@"; +"native.theProgramDidNotOpenItsDebugPortBeforeTheDeadlineCheckTheRunOutput" = "The program did not open its debug port before the deadline. Check the Run output."; "native.theProjectArchiveFromIsTooLarge" = "The project archive from %1$@ is too large."; "native.theProjectDirectoryIsUnavailable" = "The project directory is unavailable."; "native.theProjectFolderOnTheHostBrowseConnectsInstallsTheLitheServerOnTheHostIfNeededAndLetsYouPi" = "The project folder on the host. Browse connects, installs the Lithe server on the host if needed, and lets you pick a folder."; @@ -9650,6 +9679,7 @@ "native.thisWillStartLldDetectedServices" = "This will start %1$lld detected services."; "native.thisWindow" = "This Window"; "native.thisWindow.variant2" = "This window"; +"native.thisWindowCannotStartRunsForPlugins" = "This window cannot start runs for plugins."; "native.thisWorkingCopyFormatIsNotSupportedUpgradeItWithTheCommandLineClient" = "This working copy format is not supported. Upgrade it with the command-line client."; "native.thisWorkingCopyIsNoLongerAvailableRefreshAndTryAgain" = "This working copy is no longer available. Refresh and try again."; "native.thisWorktreeHasADetachedHEADPublishItAsABranchBeforeCreatingAPullRequest" = "This worktree has a detached HEAD. Publish it as a branch before creating a pull request."; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 9418c24136..847fffcf32 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -399,8 +399,10 @@ "Apply Patch" = "应用补丁"; "Apply Patch…" = "应用补丁…"; "Apply code changes" = "应用代码更改"; +"Apply code changes to the running program" = "将代码更改应用到正在运行的程序"; "Apply code changes to this service" = "将代码更改应用到此服务"; "Apply to" = "应用目标"; +"Applying code changes…" = "正在应用代码更改…"; "Approval mode" = "权限模式"; "Approve" = "批准"; "Approve for me" = "替我审批"; @@ -818,6 +820,7 @@ "Closing the project stops these services and the processes they started." = "关闭项目会停止这些服务及其启动的进程。"; "Closing the terminal ends these programs." = "关闭终端会结束这些程序。"; "Closing this terminal will stop its shell and any running command." = "关闭此终端将停止其 Shell 和所有正在运行的命令。"; +"Code changes applied." = "代码更改已应用。"; "Code settings" = "代码设置"; "Codex" = "Codex"; "Codex Agents require the Responses API. Claude Agents require an Anthropic API key." = "Codex Agent 需要 Responses API,Claude Agent 需要 Anthropic API 密钥。"; @@ -1053,6 +1056,7 @@ "Copy session ID" = "复制会话 ID"; "Copy table" = "复制表格"; "Copy worktree path" = "复制工作树路径"; +"Could not apply code changes: %@" = "无法应用代码更改:%@"; "Could not apply project indexing exclusions. Try reopening the project." = "无法应用项目索引排除策略,请重新打开项目后重试。"; "Could not be determined" = "无法确定"; "Could not change the log directory. Try again." = "无法修改日志目录,请重试。"; @@ -2593,6 +2597,7 @@ "No callable target at this location" = "当前位置没有可调用目标"; "No changed files" = "没有更改的文件"; "No changes" = "没有更改"; +"No code changes to apply." = "没有需要应用的代码更改。"; "No command" = "无命令"; "No commit selected" = "未选择提交"; "No commits" = "没有提交记录"; @@ -2625,6 +2630,7 @@ "No implementations found" = "没有找到实现"; "No installation of this tool was found." = "未找到此工具的安装。"; "No installed plugin supports %@ yet." = "尚未安装支持 %@ 的插件。"; +"No installed tool matches the one the plugin asked to run." = "没有已安装的工具与插件要求运行的工具匹配。"; "No items" = "暂无项目"; "No language dependency sources registered" = "未注册语言依赖来源"; "No language provider handles .%@ files." = "没有语言 Provider 处理 .%@ 文件。"; @@ -2700,6 +2706,7 @@ "No settings to import yet" = "暂时没有可导入的设置"; "No shell was found at %@. Choose a detected shell or run detection again." = "在 %@ 未找到 Shell。请选择已检测到的 Shell,或重新执行检测。"; "No shelves" = "没有 Shelf"; +"No single debug adapter attaches to the “%@” configuration type." = "没有唯一的调试适配器可以附加到“%@”配置类型。"; "No skills in this scope." = "当前分类暂无技能。"; "No subagent output yet" = "尚未收到子代理输出"; "No subagents yet" = "暂无子代理"; @@ -3634,6 +3641,7 @@ "Saved, not verified" = "已保存,未验证"; "Saves, connects, and opens the remote folder in this window." = "保存、连接,并在当前窗口打开远程文件夹。"; "Saving build provider run configurations is unavailable." = "当前无法保存构建提供方运行配置。"; +"Saving code changes…" = "正在保存代码更改…"; "Saving replaces the current GitHub metadata." = "保存后将替换当前 GitHub 元数据。"; "Saving writes to" = "保存将写入"; "Scala Support" = "Scala 支持"; @@ -4282,10 +4290,12 @@ "The debug adapter connection is stopped." = "调试适配器的连接已停止。"; "The debug adapter connection timed out." = "调试适配器连接超时。"; "The debug adapter connection was not approved." = "未批准连接调试适配器。"; +"The debug adapter could not start. Check that the plugin that provides it is enabled." = "调试适配器无法启动。请确认提供它的插件已启用。"; "The debug adapter did not accept outgoing data in time." = "调试适配器未能及时接收待发送数据。"; "The debug adapter did not provide a terminal executable." = "调试适配器没有提供终端可执行文件。"; "The debug adapter outgoing buffer is full." = "调试适配器的发送缓冲区已满。"; "The debug adapter provided an invalid terminal working directory." = "调试适配器提供的终端工作目录无效。"; +"The debug configuration is too large." = "调试配置过大。"; "The debugger did not return object fields in time." = "调试器未能及时返回对象字段。"; "The decoded contents contain non-text characters. Use a suitable file viewer." = "解码后的内容包含非文本字符。请使用适合该格式的查看器。"; "The default ChangeList cannot be renamed." = "默认更改列表不能重命名。"; @@ -4372,6 +4382,8 @@ "The plugin backend failed to start." = "插件后端启动失败。"; "The plugin backend is no longer running." = "插件后端已不在运行。"; "The plugin backend stopped before it was ready." = "插件后端在就绪前已停止。"; +"The plugin could not find a file for this link." = "插件找不到这个链接对应的文件。"; +"The plugin declares no such debug adapter." = "插件没有声明这个调试适配器。"; "The plugin does not provide the debug adapter %@." = "插件没有提供调试适配器 %@。"; "The plugin download address is invalid." = "插件下载地址无效。"; "The plugin host request could not be completed." = "未能完成插件宿主请求。"; @@ -4383,6 +4395,7 @@ "The plugin package directory is invalid." = "插件包目录无效。"; "The plugin package file list does not match its signature." = "插件包文件列表与其签名不一致。"; "The plugin package file was changed after signing: %@." = "插件包文件在签名后被更改:%@。"; +"The plugin package has no such executable file." = "插件包中没有这个可执行文件。"; "The plugin package is missing %@. Reinstall the plugin." = "插件包缺少 %@。请重新安装该插件。"; "The plugin package is signed by key %@, which this version of Lithe does not trust." = "插件包由密钥 %@ 签名,当前版本的 Lithe 不信任该密钥。"; "The plugin package manifest is invalid." = "插件包清单无效。"; @@ -4412,6 +4425,7 @@ "The process does not have an open standard-input pipe." = "进程没有打开的标准输入管道。"; "The process is not running." = "进程未在运行。"; "The program chosen in the “%@” setting of %@ is missing or not executable: %@" = "在 %2$@ 的“%1$@”设置中选择的程序不存在或不可执行:%3$@"; +"The program did not open its debug port before the deadline. Check the Run output." = "程序没有在限定时间内打开调试端口。请查看“运行”输出。"; "The project archive from %@ is too large." = "来自 %@ 的项目压缩包过大。"; "The project directory is unavailable." = "项目目录不可用。"; "The project folder is unavailable. Restore it, then retry." = "项目目录不可用。请恢复目录后重试。"; @@ -4643,6 +4657,7 @@ "This version of Lithe cannot read update manifest schema %@. Open the Release page and update manually." = "此版本的 Lithe 无法读取版本为 %@ 的更新清单格式。请打开 Release 页面手动更新。"; "This will start %lld detected services." = "这将启动检测到的 %lld 个服务。"; "This window" = "此窗口"; +"This window cannot start runs for plugins." = "此窗口无法为插件启动运行。"; "This working copy format is not supported. Upgrade it with the command-line client." = "不支持此工作副本格式。请用命令行客户端升级。"; "This working copy is no longer available. Refresh and try again." = "此工作副本已不可用。请刷新后重试。"; "This workspace is not open in a window, so only global versions were read." = "此工作区未在窗口中打开,因此只读取了全局版本。"; @@ -5641,11 +5656,13 @@ "native.appliesTheNextTimeTheLanguageServerStarts" = "下次启动语言服务器时生效。"; "native.applyAIGeneratedContent" = "要应用 AI 生成的内容吗?"; "native.applyCodeChanges" = "应用代码更改"; +"native.applyCodeChangesToTheRunningProgram" = "将代码更改应用到正在运行的程序"; "native.applyCodeChangesToThisService" = "将代码更改应用到此服务"; "native.applyPatch" = "应用补丁"; "native.applyPatch.variant2" = "应用补丁…"; "native.applyTo" = "应用目标"; "native.applyToTheCurrentBranch" = "将提交 %1$@ 应用到当前分支。"; +"native.applyingCodeChanges" = "正在应用代码更改…"; "native.approvalMode" = "权限模式"; "native.approve" = "批准"; "native.approveForMe" = "替我审批"; @@ -6037,6 +6054,7 @@ "native.closingThisTerminalWillStopItsShellAndAnyRunningCommand" = "关闭此终端将停止其 Shell 和所有正在运行的命令。"; "native.cmakeSupport" = "CMake 支持"; "native.code" = "代码"; +"native.codeChangesApplied" = "代码更改已应用。"; "native.codex" = "Codex"; "native.codexAgentsRequireTheResponsesAPIClaudeAgentsRequireAnAnthropicAPIKey" = "Codex Agent 需要 Responses API,Claude Agent 需要 Anthropic API 密钥。"; "native.codexArchivedHistoryCouldNotBeRead" = "无法读取 Codex 的归档历史。"; @@ -6253,6 +6271,7 @@ "native.copyThreadName" = "复制线程名"; "native.copyValue" = "复制值"; "native.copyWorktreePath" = "复制工作树路径"; +"native.couldNotApplyCodeChanges" = "无法应用代码更改:%1$@"; "native.couldNotBeReachedCheckYourInternetProxyOrVPNConnectionAndTryAgain" = "无法连接 %1$@。请检查网络、代理或 VPN 后重试。"; "native.couldNotCheckForUpdates" = "无法检查更新"; "native.couldNotCheckForUpdates.variant2" = "无法检查更新。"; @@ -7824,6 +7843,7 @@ "native.noCallableTargetAtThisLocation" = "当前位置没有可调用目标"; "native.noChangedFiles" = "没有更改的文件"; "native.noChanges" = "没有更改"; +"native.noCodeChangesToApply" = "没有需要应用的代码更改。"; "native.noCodexConfigurationWasFound" = "未找到 Codex 配置"; "native.noColumns" = "没有列"; "native.noCommand" = "无命令"; @@ -7861,6 +7881,7 @@ "native.noImplementationsFound" = "没有找到实现"; "native.noInstallationOfThisToolWasFound" = "未找到此工具的安装。"; "native.noInstalledPluginSupportsYet" = "尚未安装支持 %1$@ 的插件。"; +"native.noInstalledToolMatchesTheOneThePluginAskedToRun" = "没有已安装的工具与插件要求运行的工具匹配。"; "native.noItems" = "暂无项目"; "native.noJDKFoundSetJAVAHOMEInstallAJDKOrChooseAJDKDirectory" = "未找到 JDK。请设置 JAVA_HOME、安装 JDK,或选择 JDK 目录。"; "native.noJDKIsConfiguredForThisProject" = "此项目没有配置 JDK。"; @@ -7945,6 +7966,7 @@ "native.noSelectableItems" = "没有可选项"; "native.noShellWasFoundAtChooseADetectedShellOrRunDetectionAgain" = "在 %1$@ 未找到 Shell。请选择已检测到的 Shell,或重新执行检测。"; "native.noShelves" = "没有 Shelf"; +"native.noSingleDebugAdapterAttachesToTheConfigurationType" = "没有唯一的调试适配器可以附加到“%1$@”配置类型。"; "native.noSkillsInThisScope" = "当前分类暂无技能。"; "native.noSpringBootOrMavenModuleConfigurations" = "没有 Spring Boot 或 Maven Module 配置"; "native.noSpringMvcEndpointsFound" = "未找到 Spring MVC 端点"; @@ -8784,6 +8806,7 @@ "native.savedStashInvalidUtf8" = "保存的更改包含非 UTF-8 文本,请使用 Git 查看该暂存记录。"; "native.savedTo" = "已保存到 %1$@"; "native.savesConnectsAndOpensTheRemoteFolderInThisWindow" = "保存、连接,并在当前窗口打开远程文件夹。"; +"native.savingCodeChanges" = "正在保存代码更改…"; "native.savingReplacesTheCurrentGitHubMetadata" = "保存后将替换当前 GitHub 元数据。"; "native.savingWritesTo" = "保存将写入"; "native.says" = "“%1$@”提示"; @@ -9313,6 +9336,7 @@ "native.theCurrentFileWillBeSavedToLocalHistoryBeforeItIsReplaced" = "替换当前文件前,会将其保存到本地历史。"; "native.theCurrentWorktreeCannotBeRemovedHere" = "不能在这里删除当前工作树。"; "native.theDebugAdapterConnectionIsStopped" = "调试适配器的连接已停止。"; +"native.theDebugAdapterCouldNotStartCheckThatThePluginThatProvidesItIsEnabled" = "调试适配器无法启动。请确认提供它的插件已启用。"; "native.theDebugAdapterDidNotProvideATerminal" = "调试适配器没有提供终端可执行文件。"; "native.theDebugAdapterIsNotInstalledYet" = "%1$@ 调试适配器尚未安装。"; "native.theDebugAdapterIsNotReady" = "调试适配器未就绪。"; @@ -9325,6 +9349,7 @@ "native.theDebugAdapterReturnedAnInvalidResponse" = "调试适配器返回了无效的 %1$@ 响应。"; "native.theDebugAdapterStopped" = "调试适配器已停止。"; "native.theDebugAdapterUsesTheTransportWhichLitheCannotStartYet" = "调试适配器 %1$@ 使用 %2$@ 传输方式,Lithe 暂时无法启动。目前只支持 stdio 和由语言服务器承载的适配器。"; +"native.theDebugConfigurationIsTooLarge" = "调试配置过大。"; "native.theDebugProviderDoesNotSupport" = "%1$@ 调试 Provider 不支持 %2$@。"; "native.theDebuggerDidNotReturnObjectFieldsIn" = "调试器未能及时返回对象字段。"; "native.theDefaultChangeListCannotBeRenamed" = "默认更改列表不能重命名。"; @@ -9429,6 +9454,8 @@ "native.thePluginBackendFailedToStart" = "插件后端启动失败。"; "native.thePluginBackendIsNoLongerRunning" = "插件后端已不在运行。"; "native.thePluginBackendStoppedBeforeItWasReady" = "插件后端在就绪前已停止。"; +"native.thePluginCouldNotFindAFileForThisLink" = "插件找不到这个链接对应的文件。"; +"native.thePluginDeclaresNoSuchDebugAdapter" = "插件没有声明这个调试适配器。"; "native.thePluginDoesNotProvideTheDebugAdapter" = "插件没有提供调试适配器 %1$@。"; "native.thePluginDownloadAddressIsInvalid" = "插件下载地址无效。"; "native.thePluginIsNotAllowedToUseTheHostService" = "插件无权使用宿主服务 %1$@。"; @@ -9438,6 +9465,7 @@ "native.thePluginPackageDirectoryIsInvalid" = "插件包目录无效。"; "native.thePluginPackageFileListDoesNotMatch" = "插件包文件列表与其签名不一致。"; "native.thePluginPackageFileWasChangedAfterSigning" = "插件包文件在签名后被更改:%1$@。"; +"native.thePluginPackageHasNoSuchExecutableFile" = "插件包中没有这个可执行文件。"; "native.thePluginPackageIsMissingReinstallThePlugin" = "插件包缺少 %1$@。请重新安装该插件。"; "native.thePluginPackageIsSignedByKeyWhich" = "插件包由密钥 %1$@ 签名,当前版本的 Lithe 不信任该密钥。"; "native.thePluginPackageManifestIsInvalid" = "插件包清单无效。"; @@ -9461,6 +9489,7 @@ "native.theProcessDoesNotHaveAnOpenStandard" = "进程没有打开的标准输入管道。"; "native.theProcessIsNotRunning" = "进程未在运行。"; "native.theProgramChosenInTheSettingOfIsMissingOrNotExecutable" = "在 %2$@ 的“%1$@”设置中选择的程序不存在或不可执行:%3$@"; +"native.theProgramDidNotOpenItsDebugPortBeforeTheDeadlineCheckTheRunOutput" = "程序没有在限定时间内打开调试端口。请查看“运行”输出。"; "native.theProjectArchiveFromIsTooLarge" = "来自 %1$@ 的项目压缩包过大。"; "native.theProjectDirectoryIsUnavailable" = "项目目录不可用。"; "native.theProjectFolderOnTheHostBrowseConnectsInstallsTheLitheServerOnTheHostIfNeededAndLetsYouPi" = "主机上的项目文件夹。“浏览”会连接主机,必要时在主机上安装 Lithe 远端服务,然后让你选择文件夹。"; @@ -9650,6 +9679,7 @@ "native.thisWillStartLldDetectedServices" = "这将启动检测到的 %1$lld 个服务。"; "native.thisWindow" = "此窗口"; "native.thisWindow.variant2" = "此窗口"; +"native.thisWindowCannotStartRunsForPlugins" = "此窗口无法为插件启动运行。"; "native.thisWorkingCopyFormatIsNotSupportedUpgradeItWithTheCommandLineClient" = "不支持此工作副本格式。请用命令行客户端升级。"; "native.thisWorkingCopyIsNoLongerAvailableRefreshAndTryAgain" = "此工作副本已不可用。请刷新后重试。"; "native.thisWorktreeHasADetachedHEADPublishItAsABranchBeforeCreatingAPullRequest" = "当前工作树处于分离 HEAD 状态。请先将它发布为分支,再创建拉取请求。"; diff --git a/macos/Sources/Lithe/Application/Composition/AppServices.swift b/macos/Sources/Lithe/Application/Composition/AppServices.swift index 05240e72ee..ed2ea2eb73 100644 --- a/macos/Sources/Lithe/Application/Composition/AppServices.swift +++ b/macos/Sources/Lithe/Application/Composition/AppServices.swift @@ -79,6 +79,9 @@ final class AppServices { /// Static plugin editor resources; `nil` when /// the platform loads no plugins. var pluginEditorLanguages: (any PluginEditorLanguageProviding)? + /// Console links from plugins' output link matchers; `nil` when + /// the platform loads no plugins. + var pluginOutputLinks: PluginOutputLinkService? let moduleRuntime: ModuleRuntime let pluginManager: any PluginManaging let pluginMarketplace: any PluginMarketplaceServicing diff --git a/macos/Sources/Lithe/Application/Features/PluginHostServiceDispatch.swift b/macos/Sources/Lithe/Application/Features/PluginHostServiceDispatch.swift index 277beae35c..f65312a4c0 100644 --- a/macos/Sources/Lithe/Application/Features/PluginHostServiceDispatch.swift +++ b/macos/Sources/Lithe/Application/Features/PluginHostServiceDispatch.swift @@ -58,6 +58,10 @@ enum PluginHostErrorCode: String, Sendable, CaseIterable { case rateLimited = "plugin.host.rateLimited" case cancelled = "plugin.host.cancelled" case timeout = "plugin.host.timeout" + /// The tool a run names (`{tool, reason}` in the details) has no usable + /// installation: `reason` is `notFound` when none is installed and + /// `unusable` when the chosen one cannot run. + case toolUnresolved = "plugin.host.toolUnresolved" /// The backend went away while the host worked on its request. case backendExited = "plugin.backend.exited" case failed = "plugin.host.failed" diff --git a/macos/Sources/Lithe/Application/Features/PluginOutputLinkService.swift b/macos/Sources/Lithe/Application/Features/PluginOutputLinkService.swift new file mode 100644 index 0000000000..1255987dd3 --- /dev/null +++ b/macos/Sources/Lithe/Application/Features/PluginOutputLinkService.swift @@ -0,0 +1,202 @@ +import Foundation +import LitheCoreContracts +import LitheModuleAPI + +/// Turns the text of the Run and test consoles into links, using the output link matchers the +/// enabled plugins declare in their manifests. +/// +/// Rust Core finds the matches and decides which file or backend function each one means; +/// this service cuts console text into lines, asks Core, and keeps what a click needs. +@MainActor +final class PluginOutputLinkService { + /// The console a text belongs to; a matcher without a scope applies to both. + enum Scope: String { + case run + case test + } + + /// What clicking a link does. + enum Destination: Equatable { + /// Opens a file that exists. + case file(path: String, line: Int?, column: Int?) + /// Asks the plugin's backend where the file is. + case ask(Question) + } + + /// A `resolve` link: the plugin function to call with the groups the matcher captured. + /// + /// It travels inside the text as a URL so a link carries all it needs and the service keeps + /// no table that could go stale while the console scrolls. + struct Question: Equatable { + let pluginID: String + let function: String + let groups: [String: String] + } + + /// A stretch of console text, counted in UTF-16 units as `NSRange` does, and its link. + struct Mark: Equatable { + let range: NSRange + let destination: Destination + } + + /// Read from the URL code below, which is not tied to the main actor. + nonisolated static let linkScheme = "lithe-plugin-link" + /// Core refuses a request with more lines, sources or roots than these. + private static let maxLinesPerRequest = 256 + private static let maxSources = 64 + private static let maxRoots = 16 + /// Core scans this many bytes of a line; a longer line is cut before it is sent. + private static let maxLineLength = 4096 + private static let resolveTimeout = Duration.seconds(10) + + private let matching: any PluginOutputLinkMatching + private let sources: @MainActor () -> [PluginOutputLinkSource] + + /// `sources` lists the plugins that are enabled right now and declare matchers. + init(matching: any PluginOutputLinkMatching, sources: @escaping @MainActor () -> [PluginOutputLinkSource]) { + self.matching = matching + self.sources = sources + } + + // MARK: Finding links + + /// The links in `text`. A link to a file the plugin named is kept only when that file exists. + func marks(in text: String, scope: Scope, workspaceRoots: [URL], fileExists: (URL) -> Bool) -> [Mark] { + let sources = Array(sources().prefix(Self.maxSources)) + // Most users have no matcher, and their console pays nothing for this feature. + guard !sources.isEmpty, !workspaceRoots.isEmpty, !text.isEmpty else { return [] } + let roots = workspaceRoots.prefix(Self.maxRoots).map(\.path) + var marks: [Mark] = [] + for batch in Self.batches(of: Self.lines(of: text)) { + let links: [PluginOutputLink] + do { + links = try matching.links(scope: scope.rawValue, workspaceRoots: roots, sources: sources, + lines: batch.map(\.text)) + // log-exempt: the console text stays readable and the matchers were validated when the plugin was installed + } catch { + continue + } + marks += links.compactMap { Self.mark(for: $0, in: batch, fileExists: fileExists) } + } + return marks + } + + private struct Line { + let offset: Int + let text: String + } + + private static func lines(of text: String) -> [Line] { + let string = text as NSString + var lines: [Line] = [] + string.enumerateSubstrings(in: NSRange(location: 0, length: string.length), options: .byLines) { _, range, _, _ in + guard range.length > 0 else { return } + let kept = clipped(range, in: string) + lines.append(Line(offset: kept.location, text: string.substring(with: kept))) + } + return lines + } + + /// Cuts a long line to `maxLineLength` without splitting a character. + private static func clipped(_ range: NSRange, in string: NSString) -> NSRange { + guard range.length > maxLineLength else { return range } + let head = string.rangeOfComposedCharacterSequences(for: NSRange(location: range.location, length: maxLineLength)) + return NSIntersectionRange(head, range) + } + + private static func batches(of lines: [Line]) -> [[Line]] { + stride(from: 0, to: lines.count, by: maxLinesPerRequest).map { + Array(lines[$0.. Bool) -> Mark? { + guard batch.indices.contains(link.line) else { return nil } + let range = NSRange(location: batch[link.line].offset + link.start, length: link.length) + switch link.target { + case .location(let paths, let line, let column): + guard let path = paths.first(where: { fileExists(URL(fileURLWithPath: $0)) }) else { return nil } + return Mark(range: range, destination: .file(path: path, line: line, column: column)) + case .resolve(let function, let groups): + return Mark(range: range, destination: .ask(Question(pluginID: link.pluginID, function: function, groups: groups))) + } + } + + // MARK: Answering a click + + /// Asks the plugin's backend for the location a `resolve` link means; nil when it has none. + /// A link that outlived its plugin, or a matcher the plugin dropped, is answered with nil + /// without calling anything. + func location(answering question: Question, backends: any PluginBackendRequesting, + context: PluginWorkspaceContextReference?) async throws -> PluginOutputLinkLocation? { + guard declares(question) else { return nil } + let groups = ToolingJSONValue.object(question.groups.mapValues { .string($0) }) + let reply = try await backends.invokePluginFunction( + question.function, params: .object(["groups": groups]), ofPlugin: question.pluginID, + context: context, timeout: Self.resolveTimeout, progress: { _ in }) + return try matching.location(ofResolution: reply) + } + + private func declares(_ question: Question) -> Bool { + sources().contains { source in + source.pluginID == question.pluginID && source.matchers.contains { $0.resolveFunction == question.function } + } + } +} + +// MARK: - Link URLs + +extension PluginOutputLinkService.Question { + private static let host = "resolve" + + /// `lithe-plugin-link://resolve?plugin=…&function=…&groups=` + var url: URL { + var components = URLComponents() + components.scheme = PluginOutputLinkService.linkScheme + components.host = Self.host + components.queryItems = [ + URLQueryItem(name: "plugin", value: pluginID), + URLQueryItem(name: "function", value: function), + URLQueryItem(name: "groups", value: Self.encode(groups)), + ] + // The three parts are plain strings, so the components always form a URL. + return components.url! + } + + init?(url: URL) { + guard url.scheme == PluginOutputLinkService.linkScheme, url.host == Self.host, + let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems, + let plugin = items.first(where: { $0.name == "plugin" })?.value, + let function = items.first(where: { $0.name == "function" })?.value, + let groups = items.first(where: { $0.name == "groups" })?.value.flatMap(Self.decode) else { return nil } + self.init(pluginID: plugin, function: function, groups: groups) + } + + // Sorted keys make equal questions equal URLs, so a re-render does not look like a change. + private static func encode(_ groups: [String: String]) -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = .sortedKeys + return (try? encoder.encode(groups)).flatMap { String(data: $0, encoding: .utf8) } ?? "{}" + } + + private static func decode(_ text: String) -> [String: String]? { + // log-exempt: a URL whose groups are not our JSON is not one of our links and is ignored + try? JSONDecoder().decode([String: String].self, from: Data(text.utf8)) + } +} + +// MARK: - Sources + +extension PluginOutputLinkSource { + /// The enabled plugins that declare matchers, in plugin ID order. A plugin of the live + /// registry replaces the same plugin registered at launch, which an update may have changed. + static func sources(launchRegistered: [InstalledPluginPackage], live: [InstalledPluginPackage]) -> [PluginOutputLinkSource] { + var byPlugin: [String: PluginOutputLinkSource] = [:] + for package in launchRegistered + live { + let pluginID = package.descriptor.id.rawValue + let matchers = package.descriptor.outputLinkMatchers + byPlugin[pluginID] = matchers.isEmpty ? nil : PluginOutputLinkSource(pluginID: pluginID, matchers: matchers) + } + return byPlugin.values.sorted { $0.pluginID < $1.pluginID } + } +} diff --git a/macos/Sources/Lithe/Application/Features/PluginRunHostService.swift b/macos/Sources/Lithe/Application/Features/PluginRunHostService.swift new file mode 100644 index 0000000000..64b849d9b9 --- /dev/null +++ b/macos/Sources/Lithe/Application/Features/PluginRunHostService.swift @@ -0,0 +1,325 @@ +import Foundation +import LitheCoreContracts +import LitheExecutionModule +import LitheModuleAPI + +/// Where a window shows the runs and debug sessions a plugin backend started. The window +/// owns the process, the console and the debugger; the service only decides what to start. +@MainActor +protocol PluginRunWindowPort: AnyObject { + /// Shows `launch` in the Run window and starts it. `debug` asks for a debugger that attaches + /// to the process once it listens. `onEnd` runs once when the process ends, however it ends. + func hostStartPluginRun(_ launch: PluginRunLaunch, debug: PluginRunStart.Debug?, + rerun: (@MainActor @Sendable () -> Void)?, + onEnd: @escaping @MainActor @Sendable (PluginRunEnd) -> Void) async throws + func hostStopPluginRun(runID: String) + /// Starts a debug session on `start.adapterID` and returns its ID. `onEnd` gets the ID when + /// the session is gone. + func hostStartPluginDebugSession(_ start: PluginDebugStart, + onEnd: @escaping @MainActor @Sendable (String) -> Void) async throws -> String + func hostStopPluginDebugSession(_ sessionID: String) + /// Tells the user about a failure no request is left to answer, such as a backend that + /// could not prepare the run again. + func hostReportPluginRunProblem(_ message: String) +} + +/// What the run service needs from the plugin backends: their manifests and packages, the +/// functions it calls to run again, and the way to tell a backend that a run ended. +@MainActor +protocol PluginRunBackendLink: PluginBackendRequesting { + /// The normalized manifest of the installed plugin; nil when it is not installed. + func manifest(ofPlugin pluginID: String) -> Data? + /// Where the installed plugin package lives. + func packageURL(ofPlugin pluginID: String) -> URL? + /// Sends `lithe/host/run/didExit` to the backend session that saw the run begin. A backend + /// that is gone, or started again since, hears nothing. + func notifyRunDidExit(pluginID: String, sessionID: String, params: ToolingJSONValue) +} + +/// `lithe/host/run/start`, `run/stop` and `debug/start`: a backend asks the host to run a +/// process in the Run window, or to start a debug session on one of the plugin's adapters. +/// +/// The host owns what it starts. Rust Core decides whether the request is well formed and what +/// the plugin may name; this service resolves tools, writes temporary files, hands the process +/// to the window and reports the end. A process keeps running when its plugin is disabled or its +/// backend exits: the backend then simply hears nothing more. +@MainActor +final class PluginRunHostService: PluginHostServiceHandling { + let service = "run" + let methods: Set = ["lithe/host/run/start", "lithe/host/run/stop", "lithe/host/debug/start"] + + /// A run that has started and not yet ended. + private struct Run { + let pluginID: String + /// The backend session that saw the run begin; only it hears `run/didExit`. + let sessionID: String + /// Holds the run's temporary files; removed when the run ends. + let temporaryDirectory: URL? + weak var window: (any PluginRunWindowPort)? + } + + private struct DebugSession { + let pluginID: String + weak var window: (any PluginRunWindowPort)? + } + + private let contexts: PluginWorkspaceContexts + private let decoder: any PluginRunStartDecoding + private let tools: any DefinedToolchainResolving & DefinedToolLaunching + private let fileStorage: any FileStorage + /// The environment children of the application get: the login shell's, with the runtime's changes. + private let baseEnvironment: @MainActor () -> [String: String] + weak var backends: (any PluginRunBackendLink)? + private var runs: [String: Run] = [:] + private var debugSessions: [String: DebugSession] = [:] + + init(contexts: PluginWorkspaceContexts, decoder: any PluginRunStartDecoding, + tools: any DefinedToolchainResolving & DefinedToolLaunching, fileStorage: any FileStorage, + baseEnvironment: @escaping @MainActor () -> [String: String]) { + self.contexts = contexts + self.decoder = decoder + self.tools = tools + self.fileStorage = fileStorage + self.baseEnvironment = baseEnvironment + } + + func answer(_ call: PluginHostCall) async -> Result { + do { + switch call.request.method { + case "lithe/host/run/start": return .success(try await startRun(call)) + case "lithe/host/debug/start": return .success(try await startDebug(call)) + default: return .success(try stop(call)) + } + } catch { + var failure = Self.failure(error) + failure.logError = error + return .failure(failure) + } + } + + // MARK: run/start + + private func startRun(_ call: PluginHostCall) async throws -> ToolingJSONValue { + let scope = try PluginHostAPIContext(call, contexts: contexts) + guard let window = scope.window as? any PluginRunWindowPort else { throw Self.windowCannotRun } + let runID = UUID().uuidString + let directory = fileStorage.temporaryDirectory().appendingPathComponent("lithe-plugin-run-\(runID)", isDirectory: true) + let start = try decoder.decodeRunStart(manifest: try manifest(of: call.pluginID), workspaceRoot: scope.snapshot.workspaceURL, + temporaryDirectory: directory, params: .object(call.params)) + // The adapter that starts the process itself is not implemented yet; say so before anything runs. + if start.debug?.target == .launch { + throw PluginHostServiceError(.unsupportedOption, L10n.string("The host does not support the requested option yet."), + details: ["option": .string("debug.target")]) + } + let launch = try await resolveLaunch(start, runID: runID, pluginID: call.pluginID, workspace: scope.snapshot.workspaceURL) + try Task.checkCancellation() + let temporaryDirectory = try writeTemporaryFiles(of: start, to: directory) + runs[runID] = Run(pluginID: call.pluginID, sessionID: call.sessionID, temporaryDirectory: temporaryDirectory, window: window) + do { + try await window.hostStartPluginRun(launch, debug: start.debug, + rerun: start.rerun.map { rerunAction($0, pluginID: call.pluginID, window: window) }, + onEnd: { [weak self] end in self?.runEnded(end) }) + // A cancelled request means the backend no longer waits for the run ID: that is a Stop. + try Task.checkCancellation() + } catch { + window.hostStopPluginRun(runID: runID) + forgetRun(runID) + throw error + } + return .object(["runID": .string(runID)]) + } + + /// The program, arguments, directory and environment of the process, with the tool resolved. + private func resolveLaunch(_ start: PluginRunStart, runID: String, pluginID: String, workspace: URL) async throws -> PluginRunLaunch { + let directory = start.workingDirectory == "." + ? workspace : workspace.appendingPathComponent(start.workingDirectory, isDirectory: true) + try Self.requireInside(directory, of: workspace) + var environment = baseEnvironment() + let program: String + var arguments = start.arguments + switch start.executable { + case .tool(let tool): + let selected = try await select(tool, start: start, workspace: workspace, environment: environment) + let expanded = try expand(selected, tool: tool, arguments: arguments) + program = expanded.executable + arguments = expanded.arguments + environment = selected.environment + case .packaged(let path): + program = try packagedExecutable(path, pluginID: pluginID).path + } + // What the plugin names explicitly wins over what the tool exports. + environment.merge(start.environment) { _, explicit in explicit } + return PluginRunLaunch(runID: runID, title: start.label, executableURL: URL(fileURLWithPath: program), + arguments: arguments, workingDirectory: directory, environment: environment) + } + + /// The installation the user's layers choose for `tool`. Project code is allowed, as for any Run. + private func select(_ tool: String, start: PluginRunStart, workspace: URL, + environment: [String: String]) async throws -> ResolvedDefinedToolchain { + do { + if let selected = try await tools.resolve(tool: tool, directory: start.workingDirectory, workspace: workspace, + environment: environment, minimumVersion: nil, projectCodeAllowed: true) { + return selected + } + } catch is BuildToolResolutionError { + // The user's choice names an installation that cannot run. + throw Self.toolUnresolved(tool, reason: "unusable") + } + throw Self.toolUnresolved(tool, reason: "notFound") + } + + /// Core expands the definition's launch template; a tool that cannot carry these arguments is unusable. + private func expand(_ selected: ResolvedDefinedToolchain, tool: String, arguments: [String]) throws -> DefinedToolLaunch { + do { + return try tools.launch(of: selected, tool: tool, arguments: arguments) + } catch is DefinedToolLaunchRejection { + throw Self.toolUnresolved(tool, reason: "unusable") + } + } + + private func packagedExecutable(_ path: String, pluginID: String) throws -> URL { + guard let package = backends?.packageURL(ofPlugin: pluginID) else { throw Self.backendGone } + let root = package.resolvingSymlinksInPath().standardizedFileURL + let file = root.appendingPathComponent(path).resolvingSymlinksInPath().standardizedFileURL + // Core refused `..`; a link inside the package must not lead out of it either. + guard file.path.hasPrefix(root.path + "/"), fileStorage.isExecutable(at: file) else { + throw PluginHostServiceError(.notFound, L10n.string("The plugin package has no such executable file.")) + } + return file + } + + /// Creates the directory only when the run has files to put in it. + private func writeTemporaryFiles(of start: PluginRunStart, to directory: URL) throws -> URL? { + guard !start.temporaryFiles.isEmpty else { return nil } + do { + try fileStorage.createDirectory(at: directory, withIntermediateDirectories: true) + for file in start.temporaryFiles { + try fileStorage.writeData(Data(file.contents.utf8), to: URL(fileURLWithPath: file.path), options: [.atomic]) + } + } catch { + removeTemporaryDirectory(directory) + throw error + } + return directory + } + + /// Run again: the backend prepares the restart and answers with a new `run/start`. + private func rerunAction(_ rerun: PluginRunStart.Rerun, pluginID: String, + window: any PluginRunWindowPort) -> @MainActor @Sendable () -> Void { + return { [weak self, weak window] in + guard let self, let window, let backends = self.backends, + let context = self.contexts.context(ownedBy: window)?.reference else { return } + Task { @MainActor in + do { + _ = try await backends.invokePluginFunction(rerun.function, params: rerun.data, ofPlugin: pluginID, + context: context, timeout: .seconds(30), progress: { _ in }) + } catch { + guard !(error is CancellationError) else { return } + window.hostReportPluginRunProblem(error.localizedDescription) + } + } + } + } + + // MARK: End of a run + + /// Reports the end to the backend that saw the run begin, and removes what the run left behind. + private func runEnded(_ end: PluginRunEnd) { + guard let run = runs[end.runID] else { return } + forgetRun(end.runID) + var params: [String: ToolingJSONValue] = ["runID": .string(end.runID)] + if let code = end.exitCode { params["exitCode"] = .integer(Int(code)) } + backends?.notifyRunDidExit(pluginID: run.pluginID, sessionID: run.sessionID, params: .object(params)) + } + + private func forgetRun(_ runID: String) { + guard let run = runs.removeValue(forKey: runID) else { return } + if let directory = run.temporaryDirectory { removeTemporaryDirectory(directory) } + } + + private func removeTemporaryDirectory(_ directory: URL) { + // log-exempt: 临时目录清理失败不影响运行结果,系统会回收临时目录 + try? fileStorage.removeItem(at: directory) + } + + // MARK: debug/start + + private func startDebug(_ call: PluginHostCall) async throws -> ToolingJSONValue { + let scope = try PluginHostAPIContext(call, contexts: contexts) + guard let window = scope.window as? any PluginRunWindowPort else { throw Self.windowCannotRun } + let start = try decoder.decodeDebugStart(manifest: try manifest(of: call.pluginID), params: .object(call.params)) + let sessionID = try await window.hostStartPluginDebugSession(start, onEnd: { [weak self] id in + self?.debugSessions[id] = nil + }) + debugSessions[sessionID] = DebugSession(pluginID: call.pluginID, window: window) + if Task.isCancelled { + window.hostStopPluginDebugSession(sessionID) + throw CancellationError() + } + return .object(["sessionID": .string(sessionID)]) + } + + // MARK: run/stop + + /// Stops a run or a debug session of the calling plugin. It names no context, so it still + /// works after the context it started in has closed. Anything else is ignored. + private func stop(_ call: PluginHostCall) throws -> ToolingJSONValue { + let args = try PluginHostArguments(call.params, allowed: ["runID", "sessionID"]) + switch (try args.optionalString("runID"), try args.optionalString("sessionID")) { + case (let runID?, nil): + if let run = runs[runID], run.pluginID == call.pluginID { run.window?.hostStopPluginRun(runID: runID) } + case (nil, let sessionID?): + if let session = debugSessions[sessionID], session.pluginID == call.pluginID { + session.window?.hostStopPluginDebugSession(sessionID) + } + default: + throw PluginHostArguments.invalid + } + return .null + } + + // MARK: Shared + + private func manifest(of pluginID: String) throws -> Data { + guard let manifest = backends?.manifest(ofPlugin: pluginID) else { throw Self.backendGone } + return manifest + } + + /// A directory the plugin named must still be inside the workspace once links are resolved. + private static func requireInside(_ directory: URL, of workspace: URL) throws { + let root = workspace.resolvingSymlinksInPath().standardizedFileURL.path + let target = directory.resolvingSymlinksInPath().standardizedFileURL.path + guard target == root || target.hasPrefix(root.hasSuffix("/") ? root : root + "/") else { + throw PluginHostError.outsideWorkspace + } + } + + private static var backendGone: PluginHostServiceError { + PluginHostServiceError(.backendExited, L10n.string("The plugin backend is no longer running.")) + } + + private static var windowCannotRun: PluginHostServiceError { + PluginHostServiceError(.unavailable, L10n.string("This window cannot start runs for plugins.")) + } + + private static func toolUnresolved(_ tool: String, reason: String) -> PluginHostServiceError { + PluginHostServiceError(.toolUnresolved, L10n.string("No installed tool matches the one the plugin asked to run."), + details: ["tool": .string(tool), "reason": .string(reason)]) + } + + /// Core's refusal reaches the backend as the `plugin.host` error of its kind, with Core's own + /// code and field in the details so the backend can tell which part of the request was wrong. + private static func failure(_ error: Error) -> PluginHostServiceError { + guard let rejection = error as? DeclarativePluginRejection else { return PluginHostAPIValues.failure(error) } + var details: [String: ToolingJSONValue] = ["code": .string(rejection.code)] + if let field = rejection.field { details["field"] = .string(field) } + switch rejection.code { + case "plugin.debug.unknownAdapter", "plugin.debug.foreignAdapter": + return PluginHostServiceError(.notFound, L10n.string("The plugin declares no such debug adapter."), details: details) + case "plugin.debug.limitExceeded": + return PluginHostServiceError(.limitExceeded, L10n.string("The debug configuration is too large."), details: details) + default: + return PluginHostServiceError(.invalidParams, L10n.string("The plugin host request parameters are invalid."), details: details) + } + } +} diff --git a/macos/Sources/Lithe/Core/Ports/DefinedToolchains.swift b/macos/Sources/Lithe/Core/Ports/DefinedToolchains.swift index 648c2d8a63..4dd7c07326 100644 --- a/macos/Sources/Lithe/Core/Ports/DefinedToolchains.swift +++ b/macos/Sources/Lithe/Core/Ports/DefinedToolchains.swift @@ -8,6 +8,30 @@ struct ResolvedDefinedToolchain: Sendable { let environment: [String: String] let projectCode: Bool let version: String? + /// The installation root the selection came from; the definition's `launch` + /// template can name it as `${home}`. Nil when the tool was chosen by executable. + var home: String? = nil +} + +/// The program and arguments a tool definition's `launch` template expands to for one +/// selected installation. +struct DefinedToolLaunch: Sendable, Equatable { + let executable: String + let arguments: [String] +} + +/// Core refused to expand a `launch` template. `code` is Core's own error code, such as +/// `plugin.toolchain.launchMismatch` when the template cannot carry what the caller supplied. +struct DefinedToolLaunchRejection: Error, Equatable, Sendable { + let code: String + let message: String +} + +/// Turns a resolved tool installation into the argv that starts it, following the +/// definition's `launch` template, so no caller branches on a tool name. +@MainActor +protocol DefinedToolLaunching: AnyObject { + func launch(of selection: ResolvedDefinedToolchain, tool: String, arguments: [String]) throws -> DefinedToolLaunch } /// Installation dependencies consume version and exports; they do not invoke diff --git a/macos/Sources/Lithe/Core/Ports/PluginRunDecoding.swift b/macos/Sources/Lithe/Core/Ports/PluginRunDecoding.swift new file mode 100644 index 0000000000..9020f535be --- /dev/null +++ b/macos/Sources/Lithe/Core/Ports/PluginRunDecoding.swift @@ -0,0 +1,104 @@ +import Foundation +import LitheCoreContracts + +/// A plugin backend's `lithe/host/run/start`, checked and normalized by Core. Paths are +/// still the plugin's own words: the working directory is workspace-relative, a tool is a +/// definition ID and a packaged executable is a path inside the plugin package. +struct PluginRunStart: Equatable, Sendable { + enum Executable: Equatable, Sendable { + case tool(String) + case packaged(String) + } + + struct TemporaryFile: Equatable, Sendable { + /// Final absolute path, inside the directory the host passed to Core. + let path: String + let contents: String + } + + struct Debug: Equatable, Sendable { + enum Target: Equatable, Sendable { + /// The process listens on `port` and the host attaches the adapter to it. + case connection(port: Int) + /// The adapter starts the process itself. + case launch + } + + let configurationType: String + let target: Target + } + + /// The backend function that prepares a new `run/start` when the user runs again. + struct Rerun: Equatable, Sendable { + let function: String + let data: ToolingJSONValue + } + + let label: String + let executable: Executable + let arguments: [String] + let workingDirectory: String + let environment: [String: String] + let temporaryFiles: [TemporaryFile] + let debug: Debug? + let rerun: Rerun? +} + +/// A plugin backend's `lithe/host/debug/start` on one of the plugin's own adapters. +struct PluginDebugStart: Equatable, Sendable { + /// The qualified `/` ID. + let adapterID: String + /// `launch` or `attach`. + let request: String + let label: String + let configuration: ToolingJSONValue +} + +/// Rust Core decides what a backend may start; a refusal is a `DeclarativePluginRejection` +/// whose code is `plugin.run.invalidStart`, `plugin.debug.invalidStart`, +/// `plugin.debug.unknownAdapter`, `plugin.debug.foreignAdapter` or `plugin.debug.limitExceeded`. +protocol PluginRunStartDecoding: Sendable { + /// `temporaryDirectory` is the directory the host created for this run; it is required + /// when the start carries temporary files. + func decodeRunStart(manifest: Data, workspaceRoot: URL, temporaryDirectory: URL?, + params: ToolingJSONValue) throws -> PluginRunStart + func decodeDebugStart(manifest: Data, params: ToolingJSONValue) throws -> PluginDebugStart +} + +/// One console link a plugin's matcher found. Positions count UTF-16 code units, the unit +/// `NSRange` uses. +struct PluginOutputLink: Equatable, Sendable { + enum Target: Equatable, Sendable { + /// Candidate files in order; the first that exists opens. + case location(paths: [String], line: Int?, column: Int?) + /// The plugin's function turns `groups` into a location when the link is clicked. + case resolve(function: String, groups: [String: String]) + } + + let line: Int + let start: Int + let length: Int + let pluginID: String + let matcherID: String + let target: Target +} + +/// The matchers one plugin declares in its manifest. +struct PluginOutputLinkSource: Equatable, Sendable { + let pluginID: String + let matchers: [DeclarativeOutputLinkMatcher] +} + +/// A file a `resolve` function found; `line` and `column` are 1-based. +struct PluginOutputLinkLocation: Equatable, Sendable { + let path: String + let line: Int? + let column: Int? +} + +protocol PluginOutputLinkMatching: Sendable { + func links(scope: String, workspaceRoots: [String], sources: [PluginOutputLinkSource], + lines: [String]) throws -> [PluginOutputLink] + /// The location of a `resolve` function's reply, or nil when it found nothing. + func location(ofResolution reply: ToolingJSONValue) throws -> PluginOutputLinkLocation? +} diff --git a/macos/Sources/Lithe/Core/Rust/RustDebugProtocolCore.swift b/macos/Sources/Lithe/Core/Rust/RustDebugProtocolCore.swift index 949719e152..55674b2bec 100644 --- a/macos/Sources/Lithe/Core/Rust/RustDebugProtocolCore.swift +++ b/macos/Sources/Lithe/Core/Rust/RustDebugProtocolCore.swift @@ -49,7 +49,8 @@ extension RustCoreBridge: DebugProtocolCore { supportsRunInTerminalRequest: Bool, supportsStartDebuggingRequest: Bool, disabledCapabilities: [String], - parentSessionID: String? + parentSessionID: String?, + declared: DebugAdapterDeclaredBehavior ) throws -> DebugCoreUpdate { try executeResult( command: "debug.createSession", @@ -60,7 +61,9 @@ extension RustCoreBridge: DebugProtocolCore { supportsRunInTerminalRequest: supportsRunInTerminalRequest, supportsStartDebuggingRequest: supportsStartDebuggingRequest, disabledCapabilities: disabledCapabilities, - parentSessionID: parentSessionID + parentSessionID: parentSessionID, + features: declared.features, + defaults: declared.defaults ) ).get() } @@ -403,12 +406,16 @@ private struct DebugCreateSessionPayload: Encodable { let supportsStartDebuggingRequest: Bool let disabledCapabilities: [String] let parentSessionID: String? + /// What the plugin declared for its adapter; Core flattens both keys into the request. + let features: DebugAdapterDeclaredBehavior.Features? + let defaults: DebugAdapterDeclaredBehavior.Defaults? private enum CodingKeys: String, CodingKey { case sessionID = "sessionId" case adapterID = "adapterId" case parentSessionID = "parentSessionId" case rootPath, supportsRunInTerminalRequest, supportsStartDebuggingRequest, disabledCapabilities + case features, defaults } } diff --git a/macos/Sources/Lithe/Core/Rust/RustPluginRunOperations.swift b/macos/Sources/Lithe/Core/Rust/RustPluginRunOperations.swift new file mode 100644 index 0000000000..81df4d493f --- /dev/null +++ b/macos/Sources/Lithe/Core/Rust/RustPluginRunOperations.swift @@ -0,0 +1,251 @@ +import Foundation +import LitheCoreContracts + +/// Typed `run.decodeStart`, `debug.decodeBackendConfiguration`, `outputLinks.match` and +/// `outputLinks.decodeResolution` operations. Rust Core decides what a plugin backend may +/// start and which console text links where for both products; this adapter only +/// translates payloads and outcomes. +struct RustPluginRunOperations: PluginRunStartDecoding, PluginOutputLinkMatching { + let core: RustCoreBridge + + // MARK: Run and debug starts + + func decodeRunStart(manifest: Data, workspaceRoot: URL, temporaryDirectory: URL?, + params: ToolingJSONValue) throws -> PluginRunStart { + let result: RunStartResult = try executeRejecting("run.decodeStart", payload: RunStartPayload( + manifest: try Self.json(manifest), workspaceRoot: workspaceRoot.path, + temporaryDirectory: temporaryDirectory?.path, params: params)) + return result.start.value + } + + func decodeDebugStart(manifest: Data, params: ToolingJSONValue) throws -> PluginDebugStart { + let result: DebugStartResult = try executeRejecting("debug.decodeBackendConfiguration", payload: DebugStartPayload( + manifest: try Self.json(manifest), params: params)) + return result.start.value + } + + // MARK: Console links + + func links(scope: String, workspaceRoots: [String], sources: [PluginOutputLinkSource], + lines: [String]) throws -> [PluginOutputLink] { + let payload = LinkPayload(scope: scope, platform: "macos", workspaceRoots: workspaceRoots, + sources: sources.map(LinkPayload.Source.init), lines: lines) + let result: LinkResult = try executeFailing("outputLinks.match", payload: payload) + return result.links.map(\.value) + } + + func location(ofResolution reply: ToolingJSONValue) throws -> PluginOutputLinkLocation? { + struct Payload: Encodable { let reply: ToolingJSONValue } + struct Location: Decodable { let path: String; let line: Int?; let column: Int? } + let found: Location? = try core.executeNullableResult( + command: "outputLinks.decodeResolution", payload: Payload(reply: reply)).get() + return found.map { PluginOutputLinkLocation(path: $0.path, line: $0.line, column: $0.column) } + } + + // MARK: Execution + + /// For commands whose refusal is an answer, `{status: "rejected", error}`, not a failure. + private func executeRejecting(_ command: String, payload: Payload) throws -> Value { + let outcome: Outcome = try executeFailing(command, payload: payload) + if let rejection = outcome.error { + throw DeclarativePluginRejection(code: rejection.code, message: rejection.message, field: rejection.field) + } + return outcome.value + } + + private func executeFailing(_ command: String, payload: Payload) throws -> Value { + do { + return try core.executeResult(command: command, payload: payload).get() + } catch { + throw DeclarativePluginRejection(code: "plugin.core.failed", message: error.localizedDescription) + } + } + + private static func json(_ manifest: Data) throws -> ToolingJSONValue { + do { + return try JSONDecoder().decode(ToolingJSONValue.self, from: manifest) + } catch { + throw DeclarativePluginRejection(code: "plugin.manifest.invalid", message: "plugin.json is not valid JSON.") + } + } + + // MARK: Wire types + + private struct RunStartPayload: Encodable { + let manifest: ToolingJSONValue + let workspaceRoot: String + let temporaryDirectory: String? + let params: ToolingJSONValue + } + + private struct DebugStartPayload: Encodable { + let manifest: ToolingJSONValue + let params: ToolingJSONValue + } + + private struct LinkPayload: Encodable { + struct Matcher: Encodable { + struct Resolve: Encodable { let function: String } + let id: String + let pattern: String + let scope: String? + let resolve: Resolve? + } + + struct Source: Encodable { + let pluginID: String + let matchers: [Matcher] + + init(_ source: PluginOutputLinkSource) { + pluginID = source.pluginID + matchers = source.matchers.map { + Matcher(id: $0.id, pattern: $0.pattern, scope: $0.scope, + resolve: $0.resolveFunction.map(Matcher.Resolve.init)) + } + } + } + + let scope: String + let platform: String + let workspaceRoots: [String] + let sources: [Source] + let lines: [String] + } + + private struct Rejection: Decodable { + let code: String + let message: String + let field: String? + } + + /// `{status: "ok", …}` with the operation's value at the top level, or + /// `{status: "rejected", error}`. + private struct Outcome: Decodable { + let error: Rejection? + private let decodedValue: Value? + + var value: Value { decodedValue! } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + if try container.decode(String.self, forKey: .status) == "rejected" { + error = try container.decode(Rejection.self, forKey: .error) + decodedValue = nil + } else { + error = nil + decodedValue = try Value(from: decoder) + } + } + + private enum CodingKeys: String, CodingKey { + case status, error + } + } + + private struct RunStartResult: Decodable { + let start: WireRunStart + } + + private struct DebugStartResult: Decodable { + let start: WireDebugStart + } + + private struct LinkResult: Decodable { + let links: [WireLink] + } + + private struct WireRunStart: Decodable { + struct Executable: Decodable { + let kind: String + let value: String + } + + struct File: Decodable { + let path: String + let contents: String + } + + struct Debug: Decodable { + struct Target: Decodable { + let kind: String + let port: Int? + } + + let configurationType: String + let target: Target + } + + struct Rerun: Decodable { + let function: String + let data: ToolingJSONValue + } + + let label: String + let executable: Executable + let arguments: [String] + let workingDirectory: String + let environment: [String: String] + let temporaryFiles: [File] + let debug: Debug? + let rerun: Rerun? + + var value: PluginRunStart { + PluginRunStart( + label: label, + // Core names a definition ID `toolchain`; the host-facing word is `tool`. + executable: executable.kind == "packaged" ? .packaged(executable.value) : .tool(executable.value), + arguments: arguments, workingDirectory: workingDirectory, environment: environment, + temporaryFiles: temporaryFiles.map { .init(path: $0.path, contents: $0.contents) }, + debug: debug.map { + .init(configurationType: $0.configurationType, + target: $0.target.port.map { .connection(port: $0) } ?? .launch) + }, + rerun: rerun.map { .init(function: $0.function, data: $0.data) } + ) + } + } + + private struct WireDebugStart: Decodable { + let adapterID: String + let request: String + let label: String + let configuration: ToolingJSONValue + + var value: PluginDebugStart { + PluginDebugStart(adapterID: adapterID, request: request, label: label, configuration: configuration) + } + } + + private struct WireLink: Decodable { + struct Location: Decodable { + let paths: [String] + let line: Int? + let column: Int? + } + + struct Resolve: Decodable { + let function: String + let groups: [String: String] + } + + let line: Int + let start: Int + let length: Int + let pluginID: String + let matcherID: String + let location: Location? + let resolve: Resolve? + + var value: PluginOutputLink { + let target: PluginOutputLink.Target + if let resolve { + target = .resolve(function: resolve.function, groups: resolve.groups) + } else { + let location = location ?? Location(paths: [], line: nil, column: nil) + target = .location(paths: location.paths, line: location.line, column: location.column) + } + return PluginOutputLink(line: line, start: start, length: length, pluginID: pluginID, + matcherID: matcherID, target: target) + } + } +} diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+Debugging.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+Debugging.swift index 0ba5d11c94..c86d5cb20a 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+Debugging.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+Debugging.swift @@ -29,6 +29,19 @@ extension AppModel { } } + /// The plugin of the active session declared how to apply changed code, so the host saves + /// the open documents and asks the adapter to apply them. + func applyDeclaredDebugCodeChanges() { + guard let feature = genericDebugFeatureIfActive, let identity = currentWorkspaceIdentity, + feature.canApplyDeclaredCodeChanges else { return } + Task { [weak self] in + await feature.applyDeclaredCodeChanges { + guard let self, self.isCurrentWorkspace(identity), await self.saveAllDocuments(), + self.isCurrentWorkspace(identity) else { throw CancellationError() } + } + } + } + func restartUpdatedDebugService() { guard let feature = genericDebugFeatureIfActive, let identity = currentWorkspaceIdentity, let sessionID = feature.activeSessionID else { return } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+ExecutionModules.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+ExecutionModules.swift index 18734d10be..2be0e23fce 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+ExecutionModules.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+ExecutionModules.swift @@ -138,9 +138,12 @@ extension AppModel { feature.onSessionStopped = { [weak self] debugSessionID in self?.stopDebugTerminalProcesses(for: debugSessionID) self?.stopProviderDebugProcess(for: debugSessionID) + self?.pluginDebugSessionEnded(debugSessionID) } feature.onSessionStateChanged = { [weak self] sessionID, state in - if state == .failed || state == .terminated { self?.stopProviderDebugProcess(for: sessionID) } + guard state == .failed || state == .terminated else { return } + self?.stopProviderDebugProcess(for: sessionID) + self?.pluginDebugSessionEnded(sessionID) } feature.onSessionRunInTerminalRequest = { [weak self] debugSessionID, request, completion in guard let self else { diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+PluginOutputLinks.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+PluginOutputLinks.swift new file mode 100644 index 0000000000..7e9911a25e --- /dev/null +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+PluginOutputLinks.swift @@ -0,0 +1,37 @@ +import Foundation +import LitheCoreContracts +import LitheModuleAPI + +/// Links in the Run and test consoles that enabled plugins declared matchers for. +@MainActor +extension AppModel { + func pluginOutputLinkMarks(in text: String, scope: PluginOutputLinkService.Scope, + searchRoots: [URL]) -> [PluginOutputLinkService.Mark] { + services.pluginOutputLinks?.marks( + in: text, scope: scope, workspaceRoots: searchRoots, fileExists: { fileExists(at: $0) }) ?? [] + } + + /// The user clicked a link only the plugin can place. The plugin answers; the window opens + /// the file it named, or says that none was found. + func openPluginOutputLink(_ url: URL) { + guard let question = PluginOutputLinkService.Question(url: url) else { return } + Task { @MainActor [weak self] in + guard let self else { return } + let location = await resolvePluginOutputLink(question) + let opened = location.map { pluginWebOpenFile(URL(fileURLWithPath: $0.path), line: $0.line, column: $0.column) } + if opened != true { showNotification(L10n.string("The plugin could not find a file for this link.")) } + } + } + + private func resolvePluginOutputLink(_ question: PluginOutputLinkService.Question) async -> PluginOutputLinkLocation? { + guard let service = services.pluginOutputLinks, let backends = pluginWebBackends else { return nil } + do { + return try await service.location(answering: question, backends: backends, context: pluginWebWorkspaceContext) + } catch { + if let plugin = LogSegment(question.pluginID) { + services.logSink?.emit(PluginsHostRequestFailed(error: error, plugin: plugin)) + } + return nil + } + } +} diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+PluginRun.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+PluginRun.swift new file mode 100644 index 0000000000..081c7e1251 --- /dev/null +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+PluginRun.swift @@ -0,0 +1,189 @@ +import Combine +import Foundation +import LitheCoreContracts +import LitheDebugModule +import LitheExecutionModule +import LitheModuleAPI + +/// What a window keeps for the runs and debug sessions plugin backends started. +@MainActor +final class PluginRunSessions { + struct Run { + let onEnd: @MainActor (PluginRunEnd) -> Void + /// Hears the end of this run only; dropping the run drops the subscription. + let endSubscription: AnyCancellable + /// Waits for the process to listen, then attaches the debugger; cancelled when the run ends. + var attaching: Task? + /// The debugger attached to the process. It stops with the run, and the run stops with it. + var debugSession: DebugSessionID? + } + + var runs: [String: Run] = [:] + /// Sessions started by `debug/start`, with what to tell the service when one is gone. + var debugSessionEnds: [DebugSessionID: @MainActor (String) -> Void] = [:] +} + +/// Why a plugin's run or debug session could not be set up. Each case is a sentence for the user. +private enum PluginRunSetupFailure: LocalizedError { + case portNotOpened + case noAdapter(configurationType: String) + case adapterUnavailable(String?) + + var errorDescription: String? { + switch self { + case .portNotOpened: + return L10n.string("The program did not open its debug port before the deadline. Check the Run output.") + case .noAdapter(let configurationType): + return L10n.string("No single debug adapter attaches to the “\(configurationType)” configuration type.") + case .adapterUnavailable(let reason): + return reason ?? L10n.string("The debug adapter could not start. Check that the plugin that provides it is enabled.") + } + } +} + +/// Shows the runs and debug sessions of plugin backends in this window: the process in the Run +/// tool window, a debugger attached to it, or a debug session on one of the plugin's adapters. +@MainActor +extension AppModel: PluginRunWindowPort { + // MARK: Runs + + func hostStartPluginRun(_ launch: PluginRunLaunch, debug: PluginRunStart.Debug?, rerun: (@MainActor @Sendable () -> Void)?, + onEnd: @escaping @MainActor @Sendable (PluginRunEnd) -> Void) async throws { + guard let identity = currentWorkspaceIdentity, + let run = await activateExecutionModule()?.runFeature, + isCurrentWorkspace(identity) else { throw CancellationError() } + try Task.checkCancellation() + let runID = launch.runID + // Listen before launching: the run may end at once, and the subject keeps no history. + let endSubscription = run.pluginRunEnded.filter { $0.runID == runID }.sink { [weak self] end in + self?.pluginRunEnded(end) + } + pluginRunSessions.runs[runID] = PluginRunSessions.Run(onEnd: onEnd, endSubscription: endSubscription) + do { + try run.launchPluginRun(launch, rerun: rerun) + } catch { + pluginRunSessions.runs[runID] = nil + throw error + } + showToolWindow(.run) + if let debug, case .connection(let number) = debug.target, let port = UInt16(exactly: number) { + pluginRunSessions.runs[runID]?.attaching = Task { [weak self] in + await self?.attachDebugger(toRun: runID, of: run, configurationType: debug.configurationType, + port: port, identity: identity) + } + } + } + + func hostStopPluginRun(runID: String) { + runFeatureIfActive?.stopPluginRun(runID) + } + + func hostReportPluginRunProblem(_ message: String) { + showNotification(message) + } + + /// The process is gone: nothing is left to attach to, so the debugger goes too. + private func pluginRunEnded(_ end: PluginRunEnd) { + guard let run = pluginRunSessions.runs.removeValue(forKey: end.runID) else { return } + run.attaching?.cancel() + if let session = run.debugSession { genericDebugFeatureIfActive?.stopSession(session) } + run.onEnd(end) + } + + /// Waits until the process listens on `port`, then attaches the one adapter that serves + /// `configurationType`. The run keeps going meanwhile; a failure stops it and says why. + private func attachDebugger(toRun runID: String, of run: RunFeatureModel, configurationType: String, + port: UInt16, identity: WorkspaceIdentity) async { + let isCurrent: @MainActor () -> Bool = { [weak self, weak run] in + guard let self, let run else { return false } + return self.isCurrentWorkspace(identity) && run.isPluginRunActive(runID) + } + do { + guard let debug = await activateDebugModule()?.genericFeature, isCurrent() else { return } + let connection = try prepareConnection(configurationType: configurationType, port: port) + try await waitForListener(port, run: run, runID: runID) { isCurrent() && connection.isCurrent() } + guard isCurrent(), connection.isCurrent() else { return } + guard debug.startAdditional(fileURL: identity.url, rootURL: identity.url, configuration: connection.configuration), + let session = debug.activeSessionID else { + throw PluginRunSetupFailure.adapterUnavailable(debug.errorMessage) + } + guard pluginRunSessions.runs[runID] != nil else { + // The run ended while the session started. + debug.stopSession(session) + return + } + pluginRunSessions.runs[runID]?.debugSession = session + showDebugToolWindow() + // log-exempt: the run was stopped or replaced while the debugger attached, which is no failure + } catch is CancellationError { + return + } catch { + guard isCurrent() else { return } + showNotification(error.localizedDescription) + run.stopPluginRun(runID) + } + } + + private func prepareConnection(configurationType: String, port: UInt16) throws -> PreparedProviderConnectionDebug { + do { + return try debugLaunchConfigurationResolver.resolveProviderConnection( + configurationType: configurationType, adapterID: nil, port: port) + } catch DebugLaunchConfigurationResolutionError.providerDebugUnavailable { + throw PluginRunSetupFailure.noAdapter(configurationType: configurationType) + } + } + + /// The checker's own deadline text is worded for one framework, so any failure but + /// cancellation is reported with a neutral sentence here. + private func waitForListener(_ port: UInt16, run: RunFeatureModel, runID: String, + isCurrent: @escaping @MainActor () -> Bool) async throws { + do { + try await debugPortAvailabilityChecker.waitForLoopbackListener( + port, processID: { [weak run] in run?.pluginRunProcessID(runID) }, isCurrent: isCurrent) + } catch is CancellationError { + throw CancellationError() + } catch { + throw PluginRunSetupFailure.portNotOpened + } + } + + // MARK: Debug sessions + + func hostStartPluginDebugSession(_ start: PluginDebugStart, + onEnd: @escaping @MainActor @Sendable (String) -> Void) async throws -> String { + guard let identity = currentWorkspaceIdentity, + let debug = await activateDebugModule()?.genericFeature, + isCurrentWorkspace(identity) else { throw CancellationError() } + try Task.checkCancellation() + // Core has checked both; a host that cannot read them back has drifted from Core. + guard let request = DebugRequestKind(rawValue: start.request) else { throw Self.invalidDebugStart(field: "request") } + guard case .object(let arguments) = start.configuration else { throw Self.invalidDebugStart(field: "configuration") } + let configuration = DebugLaunchConfiguration(name: start.label, request: request, arguments: arguments, + adapterProviderID: start.adapterID) + guard debug.startAdditional(fileURL: identity.url, rootURL: identity.url, configuration: configuration), + let session = debug.activeSessionID else { + throw PluginRunSetupFailure.adapterUnavailable(debug.errorMessage) + } + pluginRunSessions.debugSessionEnds[session] = onEnd + showDebugToolWindow() + return session.description + } + + private static func invalidDebugStart(field: String) -> DeclarativePluginRejection { + DeclarativePluginRejection(code: "plugin.debug.invalidStart", message: "The debug start has an unreadable \(field).", field: field) + } + + func hostStopPluginDebugSession(_ sessionID: String) { + guard let session = pluginRunSessions.debugSessionEnds.keys.first(where: { $0.description == sessionID }) else { return } + genericDebugFeatureIfActive?.stopSession(session) + } + + /// A debug session is gone, however it ended. Tells the service about a plugin's session, + /// and stops the process the debugger was attached to, as a build provider's debugger does. + func pluginDebugSessionEnded(_ session: DebugSessionID) { + pluginRunSessions.debugSessionEnds.removeValue(forKey: session)?(session.description) + guard let runID = pluginRunSessions.runs.first(where: { $0.value.debugSession == session })?.key else { return } + pluginRunSessions.runs[runID]?.debugSession = nil + runFeatureIfActive?.stopPluginRun(runID) + } +} diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+RunConfiguration.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+RunConfiguration.swift index a37820dd48..3074e12dfc 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+RunConfiguration.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+RunConfiguration.swift @@ -284,6 +284,11 @@ extension AppModel { } func restartSelectedRun() { + // A plugin's run restarts through the plugin, which knows how it was started. + if runFeatureIfActive?.restartPluginRun() == true { + showToolWindow(.run) + return + } if let configuration = runFeatureIfActive?.selectedConfiguration, configuration.kind != .currentFile { startRunConfiguration(configuration) diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index 081d84a6a9..3a5df491fb 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -216,6 +216,8 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { let debugPortAvailabilityChecker: any DebugPortAvailabilityChecking var providerDebugRunObservation: AnyCancellable? var providerDebugSession: (operation: String, session: DebugSessionID)? + /// The runs and debug sessions plugin backends started in this window. + let pluginRunSessions = PluginRunSessions() var workspaceFeature: WorkspaceFeatureModel { featureGraph.workspace } var githubFeature: GitHubFeatureModel { featureGraph.github } var editorTabOrderFeature: EditorTabOrderFeatureModel { featureGraph.editorTabOrder } diff --git a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index 1fb4dec7f3..347c11bc57 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -477,6 +477,11 @@ final class MacServiceContainer { hostDispatcher.register(workspaceHost) hostDispatcher.register(PluginDocumentHostService(contexts: .shared)) hostDispatcher.register(PluginTerminalHostService(contexts: .shared, metadata: workspaceHost)) + let runHost = PluginRunHostService( + contexts: .shared, decoder: RustPluginRunOperations(core: rustCore), tools: definedToolchains, + fileStorage: fileStorage, baseEnvironment: { runtimeService.processEnvironment() } + ) + hostDispatcher.register(runHost) hostDispatcher.register(PluginLanguageServerHostService(contexts: .shared)) let editorActions = PluginEditorActionHostService( contexts: .shared, decoder: declarativePluginOperations, host: .macOS) @@ -519,6 +524,7 @@ final class MacServiceContainer { hostDispatcher: hostDispatcher ) sessions.editorActions = editorActions + runHost.backends = sessions sessions.contentProviders = contentProviders sessions.editorProviders = editorProviders sessions.onActivationFailure = { pluginID, error in @@ -817,6 +823,7 @@ final class MacServiceContainer { consoleCommands: entry.adapter.consoleCommands, consoleCommandPrefix: entry.adapter.consoleCommandPrefix, disabledCapabilities: entry.adapter.disabledCapabilities, + declaredBehavior: entry.adapter.declaredBehavior, transport: MacPluginDebugAdapterTransport( adapterID: entry.adapter.id, resolve: { rootURL in @@ -886,6 +893,7 @@ final class MacServiceContainer { consoleCommands: entry?.adapter.consoleCommands ?? [], consoleCommandPrefix: entry?.adapter.consoleCommandPrefix, disabledCapabilities: entry?.adapter.disabledCapabilities ?? [], + declaredBehavior: entry?.adapter.declaredBehavior ?? .none, transport: try MacExternalDebugAdapterTransport.make( endpoint: endpoint, validate: { endpoint in @@ -1251,17 +1259,26 @@ final class MacServiceContainer { services.pluginEditorActions = pluginBackends.editorActions services.pluginContentProviders = pluginBackends.contentProviders services.pluginEditorProviders = pluginBackends.editorProviders + let isModuleUsable: @MainActor (ModuleID) -> Bool = { [weak moduleRuntime] id in + guard let snapshot = try? moduleRuntime?.snapshot(for: id) else { return false } + return snapshot.state != .disabled && !snapshot.isQuarantined && !snapshot.isSuppressedBySafeMode + } services.pluginEditorLanguages = MacPluginEditorLanguages( launchRegistered: pluginStartup.registeredPlugins, - isModuleUsable: { [weak moduleRuntime] id in - guard let snapshot = try? moduleRuntime?.snapshot(for: id) else { return false } - return snapshot.state != .disabled && !snapshot.isQuarantined && !snapshot.isSuppressedBySafeMode - }, + isModuleUsable: isModuleUsable, livePackages: { liveSnapshotBox.snapshot.activePackages }, decoder: declarativePluginOperations, triggers: [livePlugins.changes, editorLanguageModuleChanges.eraseToAnyPublisher()], logSink: logSink ) + // Output link matchers of enabled plugins; Rust Core finds the matches. + services.pluginOutputLinks = PluginOutputLinkService(matching: RustPluginRunOperations(core: rustCore)) { + PluginOutputLinkSource.sources( + launchRegistered: pluginStartup.registeredPlugins.filter { + isModuleUsable(.outputLinksExtension($0.descriptor.id.rawValue)) + }, + live: liveSnapshotBox.snapshot.activePackages) + } services.ideHostTransport = MacIdeHostTransport(core: rustCore, storage: fileStorage) services.proxyPasswordStore = proxyPasswordStore services.proxyConnectionChecker = proxyConnectionChecker @@ -1555,8 +1572,8 @@ final class MacServiceContainer { ) } } - // The build-systems, VCS-providers, and project-generators - // modules also carry the enabled state; the host reads it before + // The build-systems, VCS-providers, project-generators, and + // output-links modules also carry the enabled state; the host reads it before // handing the plugin to Rust Core or the New Project window. let workbench = descriptor.pluginManifest.modules.filter { !$0.contributions.isEmpty @@ -1567,6 +1584,7 @@ final class MacServiceContainer { || $0.manifest.id == .backendExtension(descriptor.id.rawValue) || $0.manifest.id == .toolchainsExtension(descriptor.id.rawValue) || $0.manifest.id == .projectGeneratorsExtension(descriptor.id.rawValue) + || $0.manifest.id == .outputLinksExtension(descriptor.id.rawValue) }.map { declaration in ModuleFactory(manifest: declaration.manifest, contributions: declaration.contributions) { DeclarativeWorkbenchModule(declaration: declaration) diff --git a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift index b4dea48ae3..ee3d1b2057 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift @@ -274,7 +274,7 @@ final class MacPluginBackendSessions: PluginBackendRequesting, PluginBackendActi object["ui"] == nil || object["ui"] is NSNull else { return false } for key in ["activationEvents", "languageServers", "buildSystems", "debugAdapters", "projectGenerators", "toolchainProviders", "toolDefinitions", "settings", "settingActions", "vcsProviders", - "executionProviders", "languages"] { + "executionProviders", "languages", "outputLinkMatchers"] { if let values = object[key] as? [Any], !values.isEmpty { return false } } return true @@ -896,3 +896,23 @@ final class MacPluginBackendSessions: PluginBackendRequesting, PluginBackendActi } } } + +/// What the plugin run service needs from the backends: the installed package a run belongs +/// to, and the one-way notice that a run ended. +extension MacPluginBackendSessions: PluginRunBackendLink { + func manifest(ofPlugin pluginID: String) -> Data? { + packages[pluginID]?.manifest + } + + func packageURL(ofPlugin pluginID: String) -> URL? { + packages[pluginID]?.packageURL + } + + /// Only the session that saw the run begin hears that it ended: a backend that started + /// again since knows nothing of the run, and one without the `run.start` grant never asked. + func notifyRunDidExit(pluginID: String, sessionID: String, params: ToolingJSONValue) { + guard let entry = running[pluginID], entry.hostSessionID == sessionID, + entry.hostServices.contains("lithe/host/run/start") else { return } + notify(pluginID: pluginID, method: "lithe/host/run/didExit", params: params) + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacDefinedToolchainResolver.swift b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacDefinedToolchainResolver.swift index 9f3fc959ba..3a674e7b1e 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacDefinedToolchainResolver.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacDefinedToolchainResolver.swift @@ -98,3 +98,37 @@ final class MacDefinedToolchainResolver: DefinedToolchainResolving { return try JSONDecoder().decode(FixedToolchainSelection.self, from: JSONSerialization.data(withJSONObject: fixed)) } } + +extension MacDefinedToolchainResolver: DefinedToolLaunching { + /// Core expands the template; this adapter only supplies the registry of the + /// installed definitions and the installation the user's layers selected. + func launch(of selection: ResolvedDefinedToolchain, tool: String, arguments: [String]) throws -> DefinedToolLaunch { + struct Payload: Encodable { + let registry: ToolingJSONValue + let tool: String + let executable: String + let home: String? + let runtimeArguments: [String] + let arguments: [String] + } + struct Expansion: Decodable { + struct Launch: Decodable { let executable: String; let arguments: [String] } + struct Rejection: Decodable { let code: String; let message: String } + let launch: Launch? + let error: Rejection? + } + guard let registry = ToolingJSONValue.fromFoundation(MacToolchainDiscovery.registry(of: sources())) else { + throw DefinedToolLaunchRejection(code: "plugin.toolchain.invalidParams", message: "The tool registry is invalid.") + } + let payload = Payload(registry: registry, tool: tool, executable: selection.executable.path, + home: selection.home, runtimeArguments: [], arguments: arguments) + let expansion: Expansion = try RustCoreBridge().executeResult( + command: "toolchains.instantiateLaunch", payload: payload).get() + if let launch = expansion.launch { + return DefinedToolLaunch(executable: launch.executable, arguments: launch.arguments) + } + let error = expansion.error + throw DefinedToolLaunchRejection(code: error?.code ?? "plugin.toolchain.invalidParams", + message: error?.message ?? "The tool launch could not be expanded.") + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacToolchainDiscovery+Resolution.swift b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacToolchainDiscovery+Resolution.swift index 070ed728dc..7cad16014e 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacToolchainDiscovery+Resolution.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacToolchainDiscovery+Resolution.swift @@ -11,12 +11,7 @@ extension MacToolchainDiscovery { func resolve(_ request: Request, tool: String, directory: String, minimumVersion: String?, local: ToolingJSONValue, projectCodeAllowed: Bool, fixed: FixedToolchainSelection? = nil, requiresDirectLaunch: Bool = true) async throws -> Selection? { - let plugins: [[String: Any]] = request.sources.filter(\.isEnabled).compactMap { source in - guard let manifest = try? JSONSerialization.jsonObject(with: source.manifest) as? [String: Any], - manifest["toolDefinitions"] != nil || manifest["toolchainProviders"] != nil else { return nil } - return ["manifest": manifest, "verifiedOfficial": source.verifiedOfficial] - } - let registry: [String: Any] = ["plugins": plugins] + let registry = Self.registry(of: request.sources) guard let plan = core("toolchains.discoveryPlan", ["registry": registry, "platform": "macos"])?["plan"] as? [String: Any], let tools = plan["tools"] as? [[String: Any]] else { throw Self.unavailable() } guard let definition = tools.first(where: { $0["tool"] as? String == tool }) else { return nil } @@ -184,7 +179,18 @@ extension MacToolchainDiscovery { let prepend = selected["pathPrepend"] as? [String] ?? [] if !prepend.isEmpty { environment["PATH"] = (prepend + [environment["PATH"] ?? ""]).joined(separator: ":") } return Selection(executable: URL(fileURLWithPath: executable), environment: environment, - projectCode: selected["projectCode"] as? Bool == true, version: selected["version"] as? String) + projectCode: selected["projectCode"] as? Bool == true, version: selected["version"] as? String, + home: selected["home"] as? String) + } + + /// The tool registry Core reads: the enabled plugins that define tools or providers. + static func registry(of sources: [ToolchainSettingsPluginSource]) -> [String: Any] { + let plugins: [[String: Any]] = sources.filter(\.isEnabled).compactMap { source in + guard let manifest = try? JSONSerialization.jsonObject(with: source.manifest) as? [String: Any], + manifest["toolDefinitions"] != nil || manifest["toolchainProviders"] != nil else { return nil } + return ["manifest": manifest, "verifiedOfficial": source.verifiedOfficial] + } + return ["plugins": plugins] } private static func unavailable() -> BuildToolResolutionError { diff --git a/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift b/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift index 770de9769b..e5ae604d57 100644 --- a/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift +++ b/macos/Sources/Lithe/Views/Debug/GenericDebugView.swift @@ -265,6 +265,16 @@ struct GenericDebugView: View { .help("Apply code changes to this service") .accessibilityLabel("Apply code changes") } + if feature.canApplyDeclaredCodeChanges { + Button { model.applyDeclaredDebugCodeChanges() } label: { + Image(systemName: "bolt.fill") + } + .litheToolbarIconButton() + .padding(.horizontal, 2) + .disabled(feature.updatingServiceSessionID != nil) + .help("Apply code changes to the running program") + .accessibilityLabel("Apply code changes") + } if feature.serviceUpdateFailed && feature.canUpdateJavaService { Button("Restart service") { model.restartUpdatedDebugService() } .disabled(feature.updatingServiceSessionID != nil) diff --git a/macos/Sources/Lithe/Views/Language/LanguageTestsView.swift b/macos/Sources/Lithe/Views/Language/LanguageTestsView.swift index 4ff9f5e6d8..d0ea2454a9 100644 --- a/macos/Sources/Lithe/Views/Language/LanguageTestsView.swift +++ b/macos/Sources/Lithe/Views/Language/LanguageTestsView.swift @@ -575,6 +575,7 @@ struct LanguageTestsView: View { ) { url, line, column in model.openSourceLocation(url: url, line: line, column: column) } + .pluginLinks(in: model, scope: .test, searchRoots: [workspaceURL]) } } } diff --git a/macos/Sources/Lithe/Views/Run/RunView.swift b/macos/Sources/Lithe/Views/Run/RunView.swift index 3c28399156..07890b6121 100644 --- a/macos/Sources/Lithe/Views/Run/RunView.swift +++ b/macos/Sources/Lithe/Views/Run/RunView.swift @@ -30,7 +30,7 @@ struct RunView: View { VStack(spacing: 0) { toolWindowHeader if (feature.configurationStatus == .ready && hasRunnableConfigurations) - || feature.isRunning || feature.lastExitCode != nil { + || feature.isRunning || feature.lastExitCode != nil || feature.hasPluginRun { runToolbar } if feature.serviceUpdateSessionID == selectedModuleSession?.id, @@ -51,9 +51,9 @@ struct RunView: View { configurationNoticeBanner(notice) } - if feature.configurationStatus != .ready && !feature.hasBuildProviderRun { + if feature.configurationStatus != .ready && !showsProviderRunOutput { configurationSetupView - } else if !hasRunnableConfigurations || (feature.hasBuildProviderRun && selectedSessionID == nil) { + } else if !hasRunnableConfigurations || (showsProviderRunOutput && selectedSessionID == nil) { OutputTextView( output: feature.output, searchRoots: feature.sourceSearchRoots, @@ -64,6 +64,7 @@ struct RunView: View { ) { url, line, column in model.openSourceLocation(url: url, line: line, column: column) } + .pluginLinks(in: model, scope: .run, searchRoots: feature.sourceSearchRoots) } else { RunServicesSplitView( isScopeCollapsed: isConfigurationListCollapsed, @@ -421,7 +422,7 @@ struct RunView: View { .padding(.horizontal, 2) .help(selectedSessionHasRun ? "Restart run" : "Run configuration") .accessibilityLabel(selectedSessionHasRun ? "Restart run" : "Run configuration") - .disabled(feature.isLoadingProject) + .disabled(feature.isLoadingProject || restartsNothing) if feature.hasBuildProviderRun && primaryOutputSelected && primaryRunMatchesSelection { Button(action: UserActionTrace.action("run.provider-configuration.save") { @@ -551,9 +552,20 @@ struct RunView: View { selectedSessionID == nil || selectedRunnableConfiguration?.buildProvider != nil } + /// A build provider's run or a plugin's run owns the primary console even when + /// the workspace has no generated run configurations. + private var showsProviderRunOutput: Bool { + feature.hasBuildProviderRun || feature.hasPluginRun + } + + /// A plugin run that gave no way to start it again has nothing for Restart to do. + private var restartsNothing: Bool { + primaryOutputSelected && feature.hasPluginRun && !feature.canRestartPluginRun + } + private var selectedSessionHasRun: Bool { primaryOutputSelected - ? primaryRunMatchesSelection && (feature.isRunning || feature.lastExitCode != nil || feature.hasBuildProviderRun) + ? primaryRunMatchesSelection && (feature.isRunning || feature.lastExitCode != nil || showsProviderRunOutput) : selectedModuleSession != nil } @@ -583,6 +595,7 @@ struct RunView: View { ) { url, line, column in model.openSourceLocation(url: url, line: line, column: column) } + .pluginLinks(in: model, scope: .run, searchRoots: feature.sourceSearchRoots) } } @@ -861,6 +874,7 @@ struct RunView: View { ) { url, line, column in model.openSourceLocation(url: url, line: line, column: column) } + .pluginLinks(in: model, scope: .run, searchRoots: feature.sourceSearchRoots) .id(configuration.id) } } diff --git a/macos/Sources/Lithe/Views/Workbench/OutputTextView+PluginLinks.swift b/macos/Sources/Lithe/Views/Workbench/OutputTextView+PluginLinks.swift new file mode 100644 index 0000000000..0e8db41dbf --- /dev/null +++ b/macos/Sources/Lithe/Views/Workbench/OutputTextView+PluginLinks.swift @@ -0,0 +1,35 @@ +import AppKit +import Foundation + +extension OutputTextView { + /// Makes console text a link where an enabled plugin's output link matcher finds one. + /// Rust Core does the matching; this only styles the ranges and routes the clicks. + @MainActor + func pluginLinks(in model: AppModel, scope: PluginOutputLinkService.Scope, searchRoots: [URL]) -> OutputTextView { + var view = self + let decorateBefore = decorateOutput + view.decorateOutput = { [weak model] text, isDark in + decorateBefore?(text, isDark) + guard let model else { return } + let marks = model.pluginOutputLinkMarks(in: text.string, scope: scope, searchRoots: searchRoots) + Self.apply(marks, to: text, isDark: isDark) + } + view.onOpenPluginLink = { [weak model] url in model?.openPluginOutputLink(url) } + return view + } + + /// A located file opens like the host's own console links; a link the plugin must resolve + /// carries its question and opens through `onOpenPluginLink`. + private static func apply(_ marks: [PluginOutputLinkService.Mark], to text: NSMutableAttributedString, isDark: Bool) { + for mark in marks where NSMaxRange(mark.range) <= text.length { + switch mark.destination { + case .file(let path, let line, let column): + text.addAttribute(.link, value: locationURL(path: path, line: line ?? 1, column: column), range: mark.range) + case .ask(let question): + text.addAttribute(.link, value: question.url, range: mark.range) + } + text.addAttribute(.foregroundColor, value: LitheConsoleStyle.hyperlink(dark: isDark), range: mark.range) + text.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: mark.range) + } + } +} diff --git a/macos/Sources/Lithe/Views/Workbench/OutputTextView.swift b/macos/Sources/Lithe/Views/Workbench/OutputTextView.swift index 6bd57dfa8c..531bcf37c3 100644 --- a/macos/Sources/Lithe/Views/Workbench/OutputTextView.swift +++ b/macos/Sources/Lithe/Views/Workbench/OutputTextView.swift @@ -61,7 +61,9 @@ struct OutputTextView: View { var consolePresentation: ConsoleFoldPresentation? = nil var standardInputSessionID: UUID? = nil var onSubmitStandardInput: ((String) -> Bool)? = nil - var decorateOutput: ((NSMutableAttributedString, Bool) -> Void)? + var decorateOutput: (@MainActor (NSMutableAttributedString, Bool) -> Void)? + /// Opens a link a plugin's matcher made, whose location the plugin's backend decides. + var onOpenPluginLink: (@MainActor (URL) -> Void)? /// Adds a soft-wrap toggle to the context menu. Callers that do not own a /// wrap preference leave it nil and keep the plain text menu. var onToggleWrapsLines: (() -> Void)? @@ -104,6 +106,7 @@ struct OutputTextView: View { return rendered }, onOpenLocation: onOpenLocation, + onOpenPluginLink: onOpenPluginLink, onBottomStateChange: { isAtBottom = $0 } ) .litheWorkbenchSurface(LitheTheme.editor, hasBackgroundImage: hasBackgroundImage) @@ -367,7 +370,7 @@ struct OutputTextView: View { // MARK: - 链接编码 - private static func locationURL(path: String, line: Int, column: Int?) -> URL { + static func locationURL(path: String, line: Int, column: Int?) -> URL { var components = URLComponents() components.scheme = "lithe-open" components.host = "file" @@ -725,6 +728,7 @@ private struct OutputTextStorageView: NSViewRepresentable { let onConsoleOccurrencesChange: (Bool) -> Void let render: (String, [URL], @escaping (URL) -> Bool, Bool, AppColorTheme, CGFloat) -> NSAttributedString let onOpenLocation: (URL, Int, Int?) -> Void + let onOpenPluginLink: (@MainActor (URL) -> Void)? let onBottomStateChange: @MainActor (Bool) -> Void func makeCoordinator() -> Coordinator { Coordinator(folds: consolePresentation ?? ConsoleFoldPresentation()) } @@ -788,6 +792,7 @@ private struct OutputTextStorageView: NSViewRepresentable { context.coordinator.textView?.insertionPointColor = LitheConsoleStyle.foreground(dark: colorScheme == .dark) } context.coordinator.onOpenLocation = onOpenLocation + context.coordinator.onOpenPluginLink = onOpenPluginLink context.coordinator.onBottomStateChange = onBottomStateChange context.coordinator.bottomThreshold = bottomThreshold context.coordinator.onToggleWrapsLines = onToggleWrapsLines @@ -831,6 +836,7 @@ private struct OutputTextStorageView: NSViewRepresentable { weak var scrollView: NSScrollView? weak var textView: NSTextView? var onOpenLocation: ((URL, Int, Int?) -> Void)? + var onOpenPluginLink: (@MainActor (URL) -> Void)? var onBottomStateChange: (@MainActor (Bool) -> Void)? var bottomThreshold: CGFloat = 80 var scrollToLatestRequest = 0 @@ -1209,6 +1215,10 @@ private struct OutputTextStorageView: NSViewRepresentable { toggleFold(url.lastPathComponent) return true } + if url.scheme == PluginOutputLinkService.linkScheme { + onOpenPluginLink?(url) + return true + } guard let location = OutputTextView.location(from: url) else { return false } var range = NSRange() if let storage = textView.textStorage, charIndex >= 0, charIndex < storage.length { diff --git a/macos/Sources/LitheCoreContracts/Debug/DebugAdapterContracts.swift b/macos/Sources/LitheCoreContracts/Debug/DebugAdapterContracts.swift index 287cf7cd94..1f75c6bb2c 100644 --- a/macos/Sources/LitheCoreContracts/Debug/DebugAdapterContracts.swift +++ b/macos/Sources/LitheCoreContracts/Debug/DebugAdapterContracts.swift @@ -1043,12 +1043,16 @@ public protocol DebugAdapterControllingSession: DebugAdapterSession { /// Text the adapter's console reads as "a debugger command follows"; nil when the /// plugin declared none. var consoleCommandPrefix: String? { get } + /// What the adapter's plugin declared beyond standard DAP (`features`, `defaults`); a view + /// reads it to decide which extra actions to offer. + var declaredBehavior: DebugAdapterDeclaredBehavior { get } func cancelPendingOperations() } public extension DebugAdapterControllingSession { var consoleCommands: [DebugConsoleCommand] { [] } var consoleCommandPrefix: String? { nil } + var declaredBehavior: DebugAdapterDeclaredBehavior { .none } func evaluateInConsole( _ expression: String, frameID: Int?, diff --git a/macos/Sources/LitheCoreContracts/Debug/DebugAdapterDeclaredBehavior.swift b/macos/Sources/LitheCoreContracts/Debug/DebugAdapterDeclaredBehavior.swift new file mode 100644 index 0000000000..1e2158c2d4 --- /dev/null +++ b/macos/Sources/LitheCoreContracts/Debug/DebugAdapterDeclaredBehavior.swift @@ -0,0 +1,100 @@ +import Foundation + +/// What a plugin declared its debug adapter does beyond standard DAP: the `features` and +/// `defaults` of a `debugAdapters` 3 declaration. +/// +/// Rust Core's debug engine follows this instead of recognizing an adapter by its ID. A host +/// only carries it from the manifest to `debug.createSession`, and reads `hotCodeReplace` to +/// decide whether to offer the Apply code changes action. +public struct DebugAdapterDeclaredBehavior: Codable, Equatable, Sendable { + /// A custom DAP request that applies changed code to the running program. + public struct HotCodeReplace: Codable, Equatable, Sendable { + /// The DAP command the engine sends. + public let request: String + /// A DAP event whose `message` the Debug Console shows as progress. + public let event: String? + /// The response body field that lists the replaced units. + public let changedField: String? + /// The response body field with a failure the adapter reports in a successful response. + public let errorField: String? + + public init(request: String, event: String? = nil, changedField: String? = nil, errorField: String? = nil) { + self.request = request + self.event = event + self.changedField = changedField + self.errorField = errorField + } + } + + /// The launch argument through which the adapter receives stepping filters. + public struct StepFilterArgument: Codable, Equatable, Sendable { + public let argument: String + + public init(argument: String) { + self.argument = argument + } + } + + public struct Features: Codable, Equatable, Sendable { + public let hotCodeReplace: HotCodeReplace? + public let stepFilters: StepFilterArgument? + + public init(hotCodeReplace: HotCodeReplace? = nil, stepFilters: StepFilterArgument? = nil) { + self.hotCodeReplace = hotCodeReplace + self.stepFilters = stepFilters + } + } + + /// Stepping filters the adapter starts with when neither the launch nor the user names any. + public struct DefaultStepFilters: 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 = false, skipStaticInitializers: Bool = false, + skipConstructors: Bool = false, hideFilteredStackFrames: Bool = false) { + self.classNameFilters = classNameFilters + self.skipSynthetics = skipSynthetics + self.skipStaticInitializers = skipStaticInitializers + self.skipConstructors = skipConstructors + self.hideFilteredStackFrames = hideFilteredStackFrames + } + + private enum CodingKeys: String, CodingKey { + case classNameFilters, skipSynthetics, skipStaticInitializers, skipConstructors, hideFilteredStackFrames + } + + /// A manifest may leave any of them out; each then keeps its default. + public init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + self.init( + classNameFilters: try values.decodeIfPresent([String].self, forKey: .classNameFilters) ?? [], + skipSynthetics: try values.decodeIfPresent(Bool.self, forKey: .skipSynthetics) ?? false, + skipStaticInitializers: try values.decodeIfPresent(Bool.self, forKey: .skipStaticInitializers) ?? false, + skipConstructors: try values.decodeIfPresent(Bool.self, forKey: .skipConstructors) ?? false, + hideFilteredStackFrames: try values.decodeIfPresent(Bool.self, forKey: .hideFilteredStackFrames) ?? false + ) + } + } + + public struct Defaults: Codable, Equatable, Sendable { + public let stepFilters: DefaultStepFilters? + + public init(stepFilters: DefaultStepFilters? = nil) { + self.stepFilters = stepFilters + } + } + + public let features: Features? + public let defaults: Defaults? + + /// An adapter that declared nothing keeps the behavior it had before `debugAdapters` 3. + public static let none = DebugAdapterDeclaredBehavior() + + public init(features: Features? = nil, defaults: Defaults? = nil) { + self.features = features + self.defaults = defaults + } +} diff --git a/macos/Sources/LitheCoreContracts/Debug/DebugProtocolCore.swift b/macos/Sources/LitheCoreContracts/Debug/DebugProtocolCore.swift index 621793cf74..e5372b31a2 100644 --- a/macos/Sources/LitheCoreContracts/Debug/DebugProtocolCore.swift +++ b/macos/Sources/LitheCoreContracts/Debug/DebugProtocolCore.swift @@ -452,7 +452,8 @@ public protocol DebugProtocolCore: DebugSteppingFilterResolving, Sendable { supportsRunInTerminalRequest: Bool, supportsStartDebuggingRequest: Bool, disabledCapabilities: [String], - parentSessionID: String? + parentSessionID: String?, + declared: DebugAdapterDeclaredBehavior ) throws -> DebugCoreUpdate func launchDebugSession( sessionID: String, diff --git a/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift b/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift index b79ebc6105..c04a6da42f 100644 --- a/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift +++ b/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift @@ -389,6 +389,24 @@ public struct DeclarativePluginLanguage: Equatable, Hashable, Sendable { } } +/// A pattern a plugin declares for text in the Run and Test consoles (`outputLinkMatchers`). +/// Rust Core finds the matches; the host only hands the declarations over. +public struct DeclarativeOutputLinkMatcher: Equatable, Sendable { + public let id: String + public let pattern: String + /// `run` or `test`; nil applies to both consoles. + public let scope: String? + /// A function of the plugin's backend that turns the matched groups into a location on click. + public let resolveFunction: String? + + public init(id: String, pattern: String, scope: String? = nil, resolveFunction: String? = nil) { + self.id = id + self.pattern = pattern + self.scope = scope + self.resolveFunction = resolveFunction + } +} + /// A settings button binds a function of the same plugin; its business stays private. public struct DeclarativePluginSettingAction: Equatable, Identifiable, Sendable { public let id: String @@ -762,6 +780,8 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { public let ui: DeclarativeUIContributions? /// Entries the plugin adds to the New Project window. public let projectGenerators: [DeclarativeProjectGenerator] + /// Console text the plugin makes clickable (`outputLinkMatchers` capability). + public let outputLinkMatchers: [DeclarativeOutputLinkMatcher] /// Whether the plugin declares its own backend process (`backend` 1). public let declaresBackend: Bool public let normalizedManifest: Data @@ -817,12 +837,16 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { consoleCommands: $0.consoleCommands, consoleCommandPrefix: $0.consoleCommandPrefix, launchExecutableSettingID: $0.launchExecutableSettingID, - disabledCapabilities: $0.disabledCapabilities + disabledCapabilities: $0.disabledCapabilities, + declaredBehavior: DebugAdapterDeclaredBehavior(features: $0.features, defaults: $0.defaults) ) } projectGenerators = decoded.projectGenerators.map { DeclarativeProjectGenerator(id: $0.id, displayName: $0.displayName, icon: $0.icon) } + outputLinkMatchers = decoded.outputLinkMatchers.map { + DeclarativeOutputLinkMatcher(id: $0.id, pattern: $0.pattern, scope: $0.scope, resolveFunction: $0.resolve?.function) + } ui = decoded.ui declaresBackend = decoded.backend != nil settings = try decoded.settings.map { try $0.declaration() } @@ -912,6 +936,16 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { ) )) } + if !outputLinkMatchers.isEmpty { + // Enablement only: the Run and Test consoles apply the matchers while it is on. + modules.append(PluginModuleDeclaration( + manifest: LanguageExtensionModuleManifests.outputLinks( + pluginID: decoded.id, + displayName: decoded.displayName, + enabledByDefault: enabledByDefault + ) + )) + } if !projectGenerators.isEmpty { modules.append(PluginModuleDeclaration( manifest: LanguageExtensionModuleManifests.projectGenerators( @@ -1002,10 +1036,11 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { } /// Whether the plugin can take effect in a running session. That holds when - /// debug adapters, language grammars, project generators and tool - /// definitions are its only contributions: the host reads adapters on demand - /// when a debug session starts, hands grammars to open editors when the - /// registry changes, lists generators when the New Project window loads, and + /// debug adapters, language grammars, console link matchers, project + /// generators and tool definitions are its only contributions: the host + /// reads adapters on demand when a debug session starts, hands grammars to + /// open editors when the registry changes, reads the matchers when a + /// console renders, lists generators when the New Project window loads and /// reads tool definitions each time a tool is resolved, so the module graph, /// the language-server and run/test modules, backend processes and UI /// composition built at launch do not change. Anything else waits for the @@ -1015,6 +1050,7 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { let live: Set = [ .debugAdaptersExtension(id.rawValue), .grammarsExtension(id.rawValue), + .outputLinksExtension(id.rawValue), .projectGeneratorsExtension(id.rawValue), .toolchainsExtension(id.rawValue), ] @@ -1336,6 +1372,9 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { let capabilities: Capabilities? let console: Console? let configurations: Configurations? + /// `debugAdapters` 3: what the adapter does beyond standard DAP. + let features: DebugAdapterDeclaredBehavior.Features? + let defaults: DebugAdapterDeclaredBehavior.Defaults? /// Every language any selector names; it classifies the plugin as /// having a debugger for them. @@ -1382,10 +1421,13 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { capabilities = try values.decodeIfPresent(Capabilities.self, forKey: .capabilities) console = try values.decodeIfPresent(Console.self, forKey: .console) configurations = try values.decodeIfPresent(Configurations.self, forKey: .configurations) + features = try values.decodeIfPresent(DebugAdapterDeclaredBehavior.Features.self, forKey: .features) + defaults = try values.decodeIfPresent(DebugAdapterDeclaredBehavior.Defaults.self, forKey: .defaults) } private enum CodingKeys: String, CodingKey { case configurationTypes, id, displayName, adapterID, requests, selectors, capabilities, console, configurations + case features, defaults } } @@ -1544,12 +1586,21 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { let settings: [Setting] let settingActions: [SettingAction] let projectGenerators: [ProjectGenerator] + let outputLinkMatchers: [OutputLinkMatcher] /// Launch fields stay with Rust Core; the host only needs to know /// a backend exists. let backend: ToolingJSONValue? /// Core owns definition semantics; this bit only supplies an enablement module. let hasToolDefinitions: Bool + struct OutputLinkMatcher: Decodable { + struct Resolve: Decodable { let function: String } + let id: String + let pattern: String + let scope: String? + let resolve: Resolve? + } + /// What plugin management lists. Fields and steps stay with Rust Core, /// which evaluates the New Project form and plans every step. struct ProjectGenerator: Decodable { @@ -1577,6 +1628,7 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { settings = try values.decodeIfPresent([Setting].self, forKey: .settings) ?? [] settingActions = try values.decodeIfPresent([SettingAction].self, forKey: .settingActions) ?? [] projectGenerators = try values.decodeIfPresent([ProjectGenerator].self, forKey: .projectGenerators) ?? [] + outputLinkMatchers = try values.decodeIfPresent([OutputLinkMatcher].self, forKey: .outputLinkMatchers) ?? [] backend = try values.decodeIfPresent(ToolingJSONValue.self, forKey: .backend) hasToolDefinitions = !(try values.decodeIfPresent([ToolingJSONValue].self, forKey: .toolDefinitions) ?? []).isEmpty } @@ -1584,7 +1636,7 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { private enum CodingKeys: String, CodingKey { case id, displayName, version, pluginAPIVersion, vendor, permissions, activationEvents case languages, languageServers, executionProviders, buildSystems, vcsProviders, ui, settings, settingActions - case projectGenerators, backend, debugAdapters, toolDefinitions + case projectGenerators, backend, debugAdapters, toolDefinitions, outputLinkMatchers } } } @@ -1620,6 +1672,9 @@ public struct DeclarativeDebugAdapter: Equatable, Sendable { /// session reports them to Rust Core, which removes them from what the /// adapter advertises so the UI never offers a feature the plugin vetoed. public let disabledCapabilities: [String] + /// The `features` and `defaults` the manifest declares (`debugAdapters` 3). The session hands + /// them to Rust Core, whose debug engine follows them for this adapter. + public let declaredBehavior: DebugAdapterDeclaredBehavior public init( id: String, @@ -1632,7 +1687,8 @@ public struct DeclarativeDebugAdapter: Equatable, Sendable { consoleCommands: [DebugConsoleCommand] = [], consoleCommandPrefix: String? = nil, launchExecutableSettingID: String? = nil, - disabledCapabilities: [String] = [] + disabledCapabilities: [String] = [], + declaredBehavior: DebugAdapterDeclaredBehavior = .none ) { self.id = id self.displayName = displayName @@ -1645,6 +1701,7 @@ public struct DeclarativeDebugAdapter: Equatable, Sendable { self.consoleCommandPrefix = consoleCommandPrefix self.launchExecutableSettingID = launchExecutableSettingID self.disabledCapabilities = disabledCapabilities + self.declaredBehavior = declaredBehavior } } diff --git a/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift b/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift index 48c4649a69..c83d6451dc 100644 --- a/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift +++ b/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift @@ -256,6 +256,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu public var canUpdateJavaService: Bool { guard let id = activeSessionID else { return false } return providerID == "java" && canControl && javaUpdateTargets[id] != nil + && !adapterDeclaredCodeChanges } public func applyJavaServiceUpdate( @@ -288,6 +289,50 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu } } + /// Whether the active session's adapter declared a way to apply changed code to the running + /// program (`features.hotCodeReplace`). The declaration wins over the host's own service + /// update, which only remains for adapters that declared nothing, so one button shows. + public var canApplyDeclaredCodeChanges: Bool { + canControl && adapterDeclaredCodeChanges + } + + private var adapterDeclaredCodeChanges: Bool { + activeSession?.declaredBehavior.features?.hotCodeReplace != nil + } + + /// Applies changed code through the request the adapter declared; Core sends it and reads + /// the reply by the declared field names. `prepare` runs first, so the caller can save the + /// documents the program is built from. + public func applyDeclaredCodeChanges(prepare: () async throws -> Void) async { + guard updatingServiceSessionID == nil, canApplyDeclaredCodeChanges, + let id = activeSessionID, let session = activeSession else { return } + updatingServiceSessionID = id + serviceUpdateFailed = false + serviceUpdateMessage = L10n.string("Saving code changes…") + defer { updatingServiceSessionID = nil } + do { + try await prepare() + guard activeSessionID == id, isSessionActive else { return } + serviceUpdateMessage = L10n.string("Applying code changes…") + let changed: [String] = try await withCheckedThrowingContinuation { continuation in + session.redefineClasses { continuation.resume(with: $0) } + } + guard activeSessionID == id, isSessionActive else { return } + // The reply may list nothing when the adapter saw no difference. + let message = changed.isEmpty + ? L10n.string("No code changes to apply.") : L10n.string("Code changes applied.") + serviceUpdateMessage = message + append(message + "\n") + if state == .paused { inspectThreads() } + } catch { + guard activeSessionID == id, isSessionActive else { return } + serviceUpdateFailed = true + let message = L10n.string("Could not apply code changes: \(error.localizedDescription)") + serviceUpdateMessage = message + append(message + "\n") + } + } + /// Explicit restart preserves the original launch options and re-resolves runtime paths. public func restartJavaService( prepare: (URL, URL) async throws -> JavaDebugLaunchTarget diff --git a/macos/Sources/LitheDebugModule/Runtime/CoreDebugAdapterProtocolSession.swift b/macos/Sources/LitheDebugModule/Runtime/CoreDebugAdapterProtocolSession.swift index 20ed5c7f2b..0cb5aab730 100644 --- a/macos/Sources/LitheDebugModule/Runtime/CoreDebugAdapterProtocolSession.swift +++ b/macos/Sources/LitheDebugModule/Runtime/CoreDebugAdapterProtocolSession.swift @@ -34,6 +34,8 @@ public final class CoreDebugAdapterProtocolSession: DebugAdapterControllingSessi /// Standard DAP capabilities the plugin turned off; Rust Core removes them /// from what the adapter advertises. private let disabledCapabilities: [String] + /// What the plugin declared for the adapter beyond standard DAP; Core follows it. + public let declaredBehavior: DebugAdapterDeclaredBehavior private let deadlineScheduler: any DebugOperationDeadlineScheduling private let operationTimeoutMilliseconds: Int private let initializationTimeoutMilliseconds: Int? @@ -74,6 +76,7 @@ public final class CoreDebugAdapterProtocolSession: DebugAdapterControllingSessi consoleCommands: [DebugConsoleCommand] = [], consoleCommandPrefix: String? = nil, disabledCapabilities: [String] = [], + declaredBehavior: DebugAdapterDeclaredBehavior = .none, transport: any DebugAdapterTransport, core: any DebugProtocolCore, sessionID: String = UUID().uuidString, @@ -86,6 +89,7 @@ public final class CoreDebugAdapterProtocolSession: DebugAdapterControllingSessi self.logSink = logSink self.adapterID = adapterID self.disabledCapabilities = disabledCapabilities + self.declaredBehavior = declaredBehavior self.consoleCommands = consoleCommands self.consoleCommandPrefix = consoleCommandPrefix self.transport = transport @@ -142,7 +146,8 @@ public final class CoreDebugAdapterProtocolSession: DebugAdapterControllingSessi supportsRunInTerminalRequest: onRunInTerminalRequest != nil, supportsStartDebuggingRequest: onStartDebuggingRequest != nil, disabledCapabilities: disabledCapabilities, - parentSessionID: parentCoreSessionID + parentSessionID: parentCoreSessionID, + declared: declaredBehavior ) ownsCoreSession = true try apply(update) diff --git a/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift b/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift index 3445b6638b..a9126c6d80 100644 --- a/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift +++ b/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift @@ -113,6 +113,17 @@ package final class RunFeatureModel: ObservableObject { package func isProviderDebugActive(_ operation: String) -> Bool { service.isProviderDebugActive(operation) } package func providerDebugProcessID(_ operation: String) -> Int32? { service.providerDebugProcessID(operation) } package func stopProviderDebug() { service.stopProviderDebug() } + package var hasPluginRun: Bool { service.hasPluginRun } + package var canRestartPluginRun: Bool { service.canRestartPluginRun } + package var pluginRunEnded: AnyPublisher { service.pluginRunEnded.eraseToAnyPublisher() } + package func launchPluginRun(_ launch: PluginRunLaunch, rerun: (@MainActor () -> Void)?) throws { + try service.launchPluginRun(launch, rerun: rerun) + } + @discardableResult + package func restartPluginRun() -> Bool { service.restartPluginRun() } + package func isPluginRunActive(_ runID: String) -> Bool { service.isPluginRunActive(runID) } + package func pluginRunProcessID(_ runID: String) -> Int32? { service.pluginRunProcessID(runID) } + package func stopPluginRun(_ runID: String) { service.stopPluginRun(runID) } package var moduleSessions: [RunSession] { service.moduleSessions } package var portConflicts: [RunPortConflict] { service.portConflicts } package var configurationStatus: ProjectRunConfigurationStatus { service.configurationStatus } diff --git a/macos/Sources/LitheExecutionModule/Services/RunService.swift b/macos/Sources/LitheExecutionModule/Services/RunService.swift index 820d5ac1d4..41ec775f97 100644 --- a/macos/Sources/LitheExecutionModule/Services/RunService.swift +++ b/macos/Sources/LitheExecutionModule/Services/RunService.swift @@ -162,6 +162,12 @@ package final class RunService: ObservableObject, BuildRunLaunching { package private(set) var providerDebugConfigurationID: String? private var activeProcessID: Int32? package let providerDebugEnded = PassthroughSubject() + /// The last run a plugin backend started. It stays after the process ends so the + /// console keeps its output and the rerun action keeps working. + private var pluginRun: PluginRunRecord? + /// Set only while the process is alive; clearing it is what makes `pluginRunEnded` fire once. + private var livePluginRunID: String? + package let pluginRunEnded = PassthroughSubject() private var moduleProviderSources: [String: BuildSystemPluginSource] = [:] /// Enabled package identities, rather than tool names, own their running processes. @@ -1340,6 +1346,76 @@ package final class RunService: ObservableObject, BuildRunLaunching { stop() } + /// Whether the primary console currently shows a run a plugin started. + package var hasPluginRun: Bool { + guard let pluginRun else { return false } + return pluginRun.runID == primaryExecutionID + } + + package var canRestartPluginRun: Bool { hasPluginRun && pluginRun?.rerun != nil } + + /// Runs the process a plugin backend asked for in the primary console. The plugin + /// never sees the process; it only learns about the end through `pluginRunEnded`. + package func launchPluginRun(_ launch: PluginRunLaunch, rerun: (@MainActor () -> Void)?) throws { + stop() + providerDebugConfigurationID = nil + output = "" + lastExitCode = nil + lastRunConfiguration = nil + lastCurrentFileURL = nil + selectedConfigurationID = RunConfiguration.currentFileID + primaryExecutionID = launch.runID + activeOperationID = launch.runID + pluginRun = PluginRunRecord(runID: launch.runID, rerun: rerun) + livePluginRunID = launch.runID + configurePrimaryProcess(operationID: launch.runID) + runningTitle = launch.title + isRunning = true + append("$ " + launch.executableURL.lastPathComponent + " " + launch.arguments.joined(separator: " ") + "\n\n") + do { + try process.start(ProcessRequest(operationID: launch.runID, executablePath: launch.executableURL.path, + arguments: launch.arguments, workingDirectory: launch.workingDirectory.path, + environment: launch.environment)) + } catch { + // The plugin gets the start error as the answer to its request, so no end event follows. + livePluginRunID = nil + pluginRun = nil + activeOperationID = nil + isRunning = false + runningTitle = nil + lastExitCode = 1 + append(error.localizedDescription + "\n") + throw error + } + } + + /// Asks the plugin to start the run again. `false` when the console shows no + /// plugin run, or the plugin gave no way to rerun it. + @discardableResult + package func restartPluginRun() -> Bool { + guard hasPluginRun, let rerun = pluginRun?.rerun else { return false } + rerun() + return true + } + + package func isPluginRunActive(_ runID: String) -> Bool { + livePluginRunID == runID && activeOperationID == runID && isRunning + } + + package func pluginRunProcessID(_ runID: String) -> Int32? { + isPluginRunActive(runID) ? activeProcessID : nil + } + + package func stopPluginRun(_ runID: String) { + if livePluginRunID == runID { stop() } + } + + private func endPluginRun(exitCode: Int32?) { + guard let runID = livePluginRunID else { return } + livePluginRunID = nil + pluginRunEnded.send(PluginRunEnd(runID: runID, exitCode: exitCode)) + } + package func restart(javaLaunch: JavaDebugLaunchTarget? = nil) { guard let lastRunConfiguration else { return } run( @@ -1627,6 +1703,7 @@ package final class RunService: ObservableObject, BuildRunLaunching { runningTitle = nil activeOperationID = nil if let debugOperation { providerDebugEnded.send(debugOperation) } + endPluginRun(exitCode: nil) } package func reset() { @@ -1669,6 +1746,7 @@ package final class RunService: ObservableObject, BuildRunLaunching { lastExitCode = nil lastRunConfiguration = nil primaryExecutionID = nil + pluginRun = nil lastCurrentFileURL = nil } @@ -1959,6 +2037,7 @@ package final class RunService: ObservableObject, BuildRunLaunching { lastExitCode = exitCode activeOperationID = nil if let debugOperation { providerDebugEnded.send(debugOperation) } + endPluginRun(exitCode: exitCode) } private func prepareJavaLaunch( @@ -1989,6 +2068,7 @@ package final class RunService: ObservableObject, BuildRunLaunching { if let message = event.message, !message.isEmpty { append("Unable to run: " + message + "\n") } + endPluginRun(exitCode: event.exitCode) } } @@ -2848,3 +2928,40 @@ private final class RegisteredLanguageRunExtension { /// Compatibility name retained while Java debug remains a provider-specific /// consumer of the generic run service. package typealias JavaRunService = RunService + +/// A process a plugin backend asked the host to run. The host has already +/// resolved the executable and merged the environment; the service only runs it. +package struct PluginRunLaunch { + package let runID: String + package let title: String + package let executableURL: URL + package let arguments: [String] + package let workingDirectory: URL + package let environment: [String: String] + + package init(runID: String, title: String, executableURL: URL, arguments: [String], + workingDirectory: URL, environment: [String: String]) { + self.runID = runID + self.title = title + self.executableURL = executableURL + self.arguments = arguments + self.workingDirectory = workingDirectory + self.environment = environment + } +} + +package struct PluginRunEnd: Equatable { + package let runID: String + /// `nil` when the user stopped the run or the process was replaced before it exited. + package let exitCode: Int32? + + package init(runID: String, exitCode: Int32?) { + self.runID = runID + self.exitCode = exitCode + } +} + +private struct PluginRunRecord { + let runID: String + let rerun: (@MainActor () -> Void)? +} diff --git a/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift b/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift index e8cebd1add..e4ed57ba30 100644 --- a/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift +++ b/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift @@ -403,6 +403,23 @@ public enum LanguageExtensionModuleManifests { ) } + /// The console link matchers of a plugin; owns no process: while it is + /// enabled the Run and Test consoles apply the matchers to their text. + public static func outputLinks( + pluginID: String, + displayName: String, + enabledByDefault: Bool = false + ) -> ModuleManifest { + ModuleManifest( + id: .outputLinksExtension(pluginID), + displayName: displayName, + scope: .workspace, + defaultState: enabledByDefault ? .enabled : .disabled, + activationPolicy: .onDemand, + dependencies: [.module(.workspace)] + ) + } + /// One module for all project generators of a plugin. The New Project /// window runs without a workspace, so the module is application-scoped /// and owns no process: Core plans and the host creates the project. diff --git a/macos/Sources/LitheModuleAPI/Lifecycle/ModuleTypes.swift b/macos/Sources/LitheModuleAPI/Lifecycle/ModuleTypes.swift index 4f605a15c4..b4da6836ea 100644 --- a/macos/Sources/LitheModuleAPI/Lifecycle/ModuleTypes.swift +++ b/macos/Sources/LitheModuleAPI/Lifecycle/ModuleTypes.swift @@ -335,6 +335,13 @@ public extension ModuleID { ModuleID("\(pluginID).grammars") } + /// The Run and Test console link matchers one declarative plugin declares, + /// enabled together. Nothing runs while it is enabled; it only gates the + /// matchers. + static func outputLinksExtension(_ pluginID: String) -> ModuleID { + ModuleID("\(pluginID).output-links") + } + /// The New Project entries one declarative plugin declares, enabled /// together. Nothing runs while it is enabled; it only gates the entries. static func projectGeneratorsExtension(_ pluginID: String) -> ModuleID { diff --git a/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift b/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift index 64021c052e..362376c33f 100644 --- a/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift +++ b/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift @@ -524,10 +524,11 @@ public struct PluginManifest: Equatable, Codable, Sendable { "pluginFunctions": [1], "backend": [1, 2], "buildProviders": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], "buildModelInputs": [1], - "buildSystems": [1, 2, 3, 4], "debugAdapters": [1, 2], "executableResolution": [1, 2], + "buildSystems": [1, 2, 3, 4], "debugAdapters": [1, 2, 3], "executableResolution": [1, 2], "executionProviders": [1], "languageServers": [1, 2, 3], "languages": [1, 2], + "outputLinkMatchers": [1], "projectGenerators": [1, 2], "serverProcess": [1, 3], "settings": [1, 2], diff --git a/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift b/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift index cb0f95c4c0..2ba179f694 100644 --- a/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift +++ b/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift @@ -487,6 +487,75 @@ struct DebugModuleTests { #expect(feature.serviceUpdateMessage == "Code changes applied.") } + @Test + func anAdapterThatDeclaredHotCodeReplaceOffersApplyingChangesAndSendsItAfterPreparing() async { + let session = DeferredInspectionDebugSession() + session.declaredBehavior = DebugAdapterDeclaredBehavior( + features: .init(hotCodeReplace: .init(request: "script.reload")) + ) + let feature = makeDeclaredFeature(session: session, rootPath: "/workspace/declared-reload") + defer { feature.stop() } + #expect(feature.canApplyDeclaredCodeChanges) + var prepared = false + + await feature.applyDeclaredCodeChanges { + #expect(session.redefineCount == 0) + prepared = true + } + + #expect(prepared) + #expect(session.redefineCount == 1) + #expect(session.executionCommands.isEmpty) + #expect(feature.serviceUpdateFailed == false) + #expect(feature.updatingServiceSessionID == nil) + } + + @Test + func aDeclaredHotCodeReplaceReplacesTheHostServiceUpdateSoOneButtonShows() async { + let session = DeferredInspectionDebugSession() + session.declaredBehavior = DebugAdapterDeclaredBehavior( + features: .init(hotCodeReplace: .init(request: "script.reload")) + ) + let feature = makeDeferredFeature(session: session, rootPath: "/workspace/declared-over-service") + defer { feature.stop() } + feature.registerJavaUpdateTarget(JavaDebugLaunchTarget(mainClass: "example.Main", projectName: "app")) + + #expect(feature.canApplyDeclaredCodeChanges) + #expect(!feature.canUpdateJavaService) + } + + @Test + func anAdapterThatDeclaredNothingOffersNoApplyChangesAction() async { + let session = DeferredInspectionDebugSession() + let feature = makeDeclaredFeature(session: session, rootPath: "/workspace/undeclared-reload") + defer { feature.stop() } + #expect(!feature.canApplyDeclaredCodeChanges) + + await feature.applyDeclaredCodeChanges { Issue.record("Nothing was declared, so nothing is prepared") } + + #expect(session.redefineCount == 0) + #expect(feature.serviceUpdateMessage == nil) + } + + @Test + func aFailedPreparationNeverAsksTheAdapterToApplyChanges() async { + let session = DeferredInspectionDebugSession() + session.declaredBehavior = DebugAdapterDeclaredBehavior( + features: .init(hotCodeReplace: .init(request: "script.reload")) + ) + let feature = makeDeclaredFeature(session: session, rootPath: "/workspace/declared-reload-failure") + defer { feature.stop() } + + await feature.applyDeclaredCodeChanges { + throw NSError(domain: "test", code: 1, userInfo: [NSLocalizedDescriptionKey: "Save failed"]) + } + + #expect(session.redefineCount == 0) + #expect(feature.serviceUpdateFailed) + #expect(feature.serviceUpdateMessage?.contains("Save failed") == true) + #expect(feature.updatingServiceSessionID == nil) + } + @Test func debuggeeOutputPreservesSGRButDiscardsTerminalControlSequences() { let manager = DebugAdapterSessionManager(providers: []) { _, _ in nil } @@ -1006,6 +1075,49 @@ struct DebugModuleTests { #expect(core.lastDisabledCapabilities == ["supportsStepBack", "supportsRestartRequest"]) } + @Test + func coreProtocolSessionHandsTheDeclaredFeaturesAndDefaultsToCore() throws { + let declared = DebugAdapterDeclaredBehavior( + features: .init( + hotCodeReplace: .init(request: "reloadModules", changedField: "modules"), + stepFilters: .init(argument: "justMyCode") + ), + defaults: .init(stepFilters: .init(classNameFilters: ["vendor.*"], skipConstructors: true)) + ) + let core = RecordingDebugProtocolCore() + let session = CoreDebugAdapterProtocolSession( + adapterID: "example-dap", + declaredBehavior: declared, + transport: RecordingTransport(), + core: core, + sessionID: "declared-session", + deadlineScheduler: RecordingDebugDeadlineScheduler() + ) + defer { session.stop() } + + try session.start(rootURL: URL(fileURLWithPath: "/tmp/declared-core", isDirectory: true)) + + #expect(core.lastDeclaredBehavior == declared) + #expect(session.declaredBehavior.features?.hotCodeReplace?.request == "reloadModules") + } + + @Test + func coreProtocolSessionDeclaresNothingUnlessThePluginDid() throws { + let core = RecordingDebugProtocolCore() + let session = CoreDebugAdapterProtocolSession( + adapterID: "example-dap", + transport: RecordingTransport(), + core: core, + sessionID: "undeclared-session", + deadlineScheduler: RecordingDebugDeadlineScheduler() + ) + defer { session.stop() } + + try session.start(rootURL: URL(fileURLWithPath: "/tmp/undeclared-core", isDirectory: true)) + + #expect(core.lastDeclaredBehavior == DebugAdapterDeclaredBehavior.none) + } + @Test func coreProtocolSessionProjectsRustUpdatesThroughInjectedTransport() throws { let transport = RecordingTransport() @@ -4988,6 +5100,22 @@ struct DebugModuleTests { return feature } + private func makeDeclaredFeature( + session: DeferredInspectionDebugSession, + rootPath: String + ) -> GenericDebugFeatureModel { + let descriptor = DebugProviderDescriptor(id: "example-dap", displayName: "Example", fileExtensions: ["ex"]) + let manager = DebugAdapterSessionManager(providers: [descriptor]) { _, _ in session } + let feature = GenericDebugFeatureModel(sessions: manager) + let root = URL(fileURLWithPath: rootPath, isDirectory: true) + precondition(feature.start( + fileURL: root.appendingPathComponent("main.ex"), + rootURL: root, + configuration: DebugLaunchConfiguration(name: "main", request: .launch, arguments: [:]) + )) + return feature + } + // MARK: Declarative adapter start @Test @@ -5386,6 +5514,7 @@ private struct RecordingDebugVariablePageRequest: Equatable { @MainActor private final class DeferredInspectionDebugSession: DebugAdapterControllingSession, DebugAdapterRunInTerminalSession { private(set) var redefineCount = 0 + var declaredBehavior: DebugAdapterDeclaredBehavior = .none func redefineClasses(_ completion: @escaping (Result<[String], Error>) -> Void) { redefineCount += 1 completion(.success(["example.Main"])) @@ -5695,6 +5824,7 @@ private final class RecordingDebugProtocolCore: DebugProtocolCore { private(set) var lastSupportsStartDebuggingRequest: Bool? private(set) var lastParentSessionID: String? private(set) var lastDisabledCapabilities: [String] = [] + private(set) var lastDeclaredBehavior: DebugAdapterDeclaredBehavior? private(set) var startDebuggingResults: [(requestID: String, succeeded: Bool)] = [] private(set) var runInTerminalCompletions: [RecordingRunInTerminalCompletion] = [] let defaultSteppingFilters = DebugSteppingFilters( @@ -5719,9 +5849,11 @@ private final class RecordingDebugProtocolCore: DebugProtocolCore { supportsRunInTerminalRequest: Bool, supportsStartDebuggingRequest: Bool, disabledCapabilities: [String], - parentSessionID: String? + parentSessionID: String?, + declared: DebugAdapterDeclaredBehavior ) throws -> DebugCoreUpdate { if refusesSessions { throw SessionRefused() } + lastDeclaredBehavior = declared lastDisabledCapabilities = disabledCapabilities lastParentSessionID = parentSessionID lastSupportsRunInTerminalRequest = supportsRunInTerminalRequest diff --git a/macos/Tests/LitheTests/DeclarativeDebugAdapterConsoleTests.swift b/macos/Tests/LitheTests/DeclarativeDebugAdapterConsoleTests.swift index 01ef239741..860cea7853 100644 --- a/macos/Tests/LitheTests/DeclarativeDebugAdapterConsoleTests.swift +++ b/macos/Tests/LitheTests/DeclarativeDebugAdapterConsoleTests.swift @@ -22,4 +22,35 @@ struct DeclarativeDebugAdapterConsoleTests { ]) #expect(adapter.consoleCommandPrefix == "`") } + + @Test + func hostReadsTheFeaturesAndDefaultsOfTheFeaturesFixtureForCore() throws { + let fixture = DeclarativePluginTestSupport.repositoryRoot + .appendingPathComponent("shared/contracts/plugins/v1/fixtures/debug-adapters/debug-adapter-features.plugin.json") + let descriptor = try DeclarativePluginTestSupport.operations.validate( + manifest: Data(contentsOf: fixture) + ) + + let declared = try #require(descriptor.debugAdapters.first).declaredBehavior + + #expect(declared.features?.hotCodeReplace?.request == "script.reload") + #expect(declared.features?.hotCodeReplace?.event == "script.reloaded") + #expect(declared.features?.hotCodeReplace?.changedField == "modules") + #expect(declared.features?.hotCodeReplace?.errorField == "error") + #expect(declared.features?.stepFilters?.argument == "stepFilters") + #expect(declared.defaults?.stepFilters?.classNameFilters == ["_vendor.*"]) + #expect(declared.defaults?.stepFilters?.skipSynthetics == true) + #expect(declared.defaults?.stepFilters?.skipConstructors == false) + } + + @Test + func anAdapterThatDeclaresNothingKeepsNoDeclaredBehavior() throws { + let fixture = DeclarativePluginTestSupport.repositoryRoot + .appendingPathComponent("shared/contracts/plugins/v1/fixtures/debug-adapters/debug-adapter.plugin.json") + let descriptor = try DeclarativePluginTestSupport.operations.validate( + manifest: Data(contentsOf: fixture) + ) + + #expect(try #require(descriptor.debugAdapters.first).declaredBehavior == DebugAdapterDeclaredBehavior.none) + } } diff --git a/macos/Tests/LitheTests/PluginDebugAdapterTests.swift b/macos/Tests/LitheTests/PluginDebugAdapterTests.swift index 7e83eb1dc0..e149ffb08c 100644 --- a/macos/Tests/LitheTests/PluginDebugAdapterTests.swift +++ b/macos/Tests/LitheTests/PluginDebugAdapterTests.swift @@ -124,7 +124,7 @@ struct PluginDebugAdapterTests { let id = "explicit-adapter-\(UUID().uuidString)" _ = try core.createDebugSession(sessionID: id, adapterID: "example-dap", rootPath: "/workspace/example", supportsRunInTerminalRequest: false, supportsStartDebuggingRequest: false, - disabledCapabilities: [], parentSessionID: nil) + disabledCapabilities: [], parentSessionID: nil, declared: .none) defer { core.destroyDebugSession(sessionID: id) } let body = Data(#"{"seq":1,"type":"response","request_seq":1,"command":"initialize","success":true,"body":{}}"#.utf8) var reply = Data("Content-Length: \(body.count)\r\n\r\n".utf8) diff --git a/macos/Tests/LitheTests/PluginOutputLinkTests.swift b/macos/Tests/LitheTests/PluginOutputLinkTests.swift new file mode 100644 index 0000000000..578eb4d797 --- /dev/null +++ b/macos/Tests/LitheTests/PluginOutputLinkTests.swift @@ -0,0 +1,350 @@ +import Foundation +@testable import Lithe +import LitheCoreContracts +import LitheModuleAPI +import Testing + +/// Console links from the output link matchers of plugins. The matching itself is tested in Rust +/// Core; these tests cover how the host cuts console text into requests and what it keeps. +@Suite("Plugin output links") +@MainActor +struct PluginOutputLinkTests { + private static let fixtures = DeclarativePluginTestSupport.repositoryRoot + .appendingPathComponent("shared/contracts/plugins/v1/fixtures", isDirectory: true) + + private static let traceback = DeclarativeOutputLinkMatcher( + id: "traceback", pattern: #"File "(?P[^"]+)", line (?P\d+)"#, scope: "run") + private static let panic = DeclarativeOutputLinkMatcher( + id: "panic", pattern: #"(?P[\w./]+)\((?P[\w.]+):(?P\d+)\)"#, resolveFunction: "links.resolve") + private static let roots = [URL(fileURLWithPath: "/work/app")] + + // MARK: Manifest + + @Test + func aPluginWithOnlyLocationMatchersDeclaresAnOutputLinksModuleThatActivatesLive() throws { + let descriptor = try Self.descriptor(Self.matcherOnlyManifest(id: "dev.example.links", matchers: [ + ["id": "traceback", "pattern": #"File "(?P[^"]+)", line (?P\d+)"#, "scope": "run"], + ["id": "assertion", "pattern": #"(?P\S+\.script):(?P\d+)"#], + ])) + // Core sorts matchers by ID. + #expect(descriptor.outputLinkMatchers.map(\.id) == ["assertion", "traceback"]) + #expect(descriptor.outputLinkMatchers.map(\.scope) == [nil, "run"]) + #expect(descriptor.outputLinkMatchers.allSatisfy { $0.resolveFunction == nil }) + #expect(descriptor.pluginManifest.modules.map(\.manifest.id) == [.outputLinksExtension(descriptor.id.rawValue)]) + #expect(descriptor.canActivateWithoutRestart) + } + + @Test + func aResolveMatcherNeedsABackendSoItWaitsForARestart() throws { + var object = try Self.editorActionsManifest(id: "dev.example.resolver") + object["outputLinkMatchers"] = [[ + "id": "frame", "pattern": #"(?P\w+)\((?P\w+\.script):(?P\d+)\)"#, + "resolve": ["function": "links.resolve"], + ] as [String: Any]] + let descriptor = try Self.descriptor(object) + #expect(descriptor.outputLinkMatchers == [ + DeclarativeOutputLinkMatcher(id: "frame", pattern: #"(?P\w+)\((?P\w+\.script):(?P\d+)\)"#, + resolveFunction: "links.resolve") + ]) + let moduleIDs = descriptor.pluginManifest.modules.map(\.manifest.id) + #expect(moduleIDs.contains(.outputLinksExtension(descriptor.id.rawValue))) + #expect(moduleIDs.contains(.backendExtension(descriptor.id.rawValue))) + #expect(!descriptor.canActivateWithoutRestart) + } + + @Test + func aPluginWithoutMatchersGetsNoOutputLinksModule() throws { + let descriptor = try Self.descriptor(try Self.editorActionsManifest(id: "dev.example.plain", requiresMatchers: false)) + #expect(descriptor.outputLinkMatchers.isEmpty) + #expect(!descriptor.pluginManifest.modules.map(\.manifest.id).contains(.outputLinksExtension(descriptor.id.rawValue))) + } + + @Test + func sourcesListEnabledPluginsInIDOrderAndALivePluginReplacesItsLaunchCopy() throws { + let alpha = try Self.package(id: "dev.example.alpha", matchers: [["id": "one", "pattern": #"(?P\S+):(?P\d+)"#]]) + let beta = try Self.package(id: "dev.example.beta", matchers: [["id": "two", "pattern": #"(?P\S+):(?P\d+)"#]]) + let betaUpdated = try Self.package(id: "dev.example.beta", matchers: [["id": "three", "pattern": #"(?P\S+):(?P\d+)"#]]) + let plain = try Self.package(from: DeclarativePluginTestSupport.manifestObject( + id: "dev.example.plain", version: PluginVersion(major: 1, minor: 0, patch: 0))) + + let sources = PluginOutputLinkSource.sources(launchRegistered: [beta, alpha, plain], live: [betaUpdated]) + #expect(sources.map(\.pluginID) == ["dev.example.alpha", "dev.example.beta"]) + #expect(sources.last?.matchers.map(\.id) == ["three"]) + } + + // MARK: Cutting text into requests + + @Test + func aConsoleWithoutMatchersNeverReachesCore() { + let matcher = StubMatching() + let service = PluginOutputLinkService(matching: matcher, sources: { [] }) + #expect(service.marks(in: "anything\n", scope: .run, workspaceRoots: Self.roots, fileExists: { _ in true }).isEmpty) + #expect(matcher.requests.isEmpty) + } + + @Test + func emptyLinesAreSkippedAndLinksKeepTheirOffsetInTheWholeText() { + let matcher = StubMatching(links: [ + PluginOutputLink(line: 1, start: 2, length: 4, pluginID: "dev.example.tb", matcherID: "traceback", + target: .location(paths: ["/work/app/a.py"], line: 3, column: nil)) + ]) + let service = Self.service(matcher) + let text = "ok\n\n File a.py\n" + let marks = service.marks(in: text, scope: .run, workspaceRoots: Self.roots, fileExists: { _ in true }) + #expect(matcher.requests.map(\.lines) == [["ok", " File a.py"]]) + #expect(matcher.requests.first?.scope == "run") + #expect(matcher.requests.first?.workspaceRoots == ["/work/app"]) + // Line one starts after "ok\n\n"; the link starts two units into it. + #expect(marks == [.init(range: NSRange(location: 6, length: 4), destination: .file(path: "/work/app/a.py", line: 3, column: nil))]) + } + + @Test + func aLocationLinkKeepsTheFirstCandidateThatExistsAndNoneMeansNoLink() { + let link = PluginOutputLink(line: 0, start: 0, length: 3, pluginID: "dev.example.tb", matcherID: "traceback", + target: .location(paths: ["/work/app/x.py", "/work/lib/x.py"], line: nil, column: 5)) + let service = Self.service(StubMatching(links: [link])) + let found = service.marks(in: "x.py", scope: .test, workspaceRoots: Self.roots) { $0.path == "/work/lib/x.py" } + #expect(found.map(\.destination) == [.file(path: "/work/lib/x.py", line: nil, column: 5)]) + #expect(service.marks(in: "x.py", scope: .test, workspaceRoots: Self.roots, fileExists: { _ in false }).isEmpty) + } + + @Test + func aResolveLinkCarriesTheQuestionWithoutCheckingAnyFile() { + let link = PluginOutputLink(line: 0, start: 0, length: 9, pluginID: "dev.example.tb", matcherID: "panic", + target: .resolve(function: "links.resolve", groups: ["symbol": "main.run", "line": "7"])) + let service = Self.service(StubMatching(links: [link])) + let marks = service.marks(in: "main.run:7", scope: .run, workspaceRoots: Self.roots, fileExists: { _ in false }) + let question = PluginOutputLinkService.Question(pluginID: "dev.example.tb", function: "links.resolve", + groups: ["symbol": "main.run", "line": "7"]) + #expect(marks == [.init(range: NSRange(location: 0, length: 9), destination: .ask(question))]) + } + + @Test + func longOutputGoesToCoreInRequestsOfAtMost256Lines() { + let lines = (0..<600).map { "line \($0)" } + let matcher = StubMatching(linksForRequest: { index in + // A link on the last line of every request. + [PluginOutputLink(line: index == 2 ? 87 : 255, start: 0, length: 4, pluginID: "dev.example.tb", matcherID: "traceback", + target: .location(paths: ["/work/app/f.py"], line: 1, column: nil))] + }) + let service = Self.service(matcher) + let text = lines.joined(separator: "\n") + let marks = service.marks(in: text, scope: .run, workspaceRoots: Self.roots, fileExists: { _ in true }) + #expect(matcher.requests.map(\.lines.count) == [256, 256, 88]) + let nsText = text as NSString + #expect(marks.count == 3) + #expect(marks.map { nsText.substring(with: $0.range) } == ["line", "line", "line"]) + #expect(marks.map { nsText.substring(with: NSRange(location: $0.range.location, length: 8)) } == ["line 255", "line 511", "line 599"]) + } + + @Test + func aLongLineIsCutBeforeCoreWithoutSplittingACharacter() throws { + let matcher = StubMatching() + let service = Self.service(matcher) + let emoji = String(repeating: "😀", count: 3000) + _ = service.marks(in: emoji + "\n" + String(repeating: "a", count: 5000), scope: .run, + workspaceRoots: Self.roots, fileExists: { _ in true }) + let sent = try #require(matcher.requests.first?.lines) + #expect(sent.count == 2) + #expect(sent[0].utf16.count == 4096) + #expect(sent[0].allSatisfy { $0 == "😀" }) + #expect(sent[1].utf16.count == 4096) + } + + @Test + func aFailingMatchLeavesTheConsolePlain() { + let service = Self.service(StubMatching(failure: DeclarativePluginRejection(code: "plugin.core.failed", message: "no"))) + #expect(service.marks(in: "text", scope: .run, workspaceRoots: Self.roots, fileExists: { _ in true }).isEmpty) + } + + // MARK: Link URLs + + @Test + func aQuestionSurvivesItsURL() throws { + let question = PluginOutputLinkService.Question( + pluginID: "dev.example.tb", function: "links.resolve", + groups: ["symbol": "pkg.Type&Name = ?", "file": "dir with space/ü.go", "line": "12"]) + #expect(PluginOutputLinkService.Question(url: question.url) == question) + #expect(question.url.scheme == PluginOutputLinkService.linkScheme) + } + + @Test + func onlyAPluginLinkURLIsAQuestion() { + let located = OutputTextView.locationURL(path: "/work/app/a.py", line: 3, column: nil) + #expect(PluginOutputLinkService.Question(url: located) == nil) + #expect(PluginOutputLinkService.Question(url: URL(string: "lithe-plugin-link://other?plugin=a&function=b&groups=%7B%7D")!) == nil) + #expect(PluginOutputLinkService.Question(url: URL(string: "lithe-plugin-link://resolve?plugin=a&function=b&groups=nope")!) == nil) + } + + // MARK: Answering a click + + @Test + func aClickAsksThePluginsFunctionForTheLocation() async throws { + let backends = StubBackends(reply: .object(["uri": .string("file:///work/app/main.go"), "range": .null])) + let matcher = StubMatching(location: PluginOutputLinkLocation(path: "/work/app/main.go", line: 7, column: nil)) + let service = Self.service(matcher, matchers: [Self.panic]) + let context = PluginWorkspaceContextReference(contextID: "ctx", generation: 2) + let question = PluginOutputLinkService.Question(pluginID: "dev.example.tb", function: "links.resolve", + groups: ["symbol": "main.run", "line": "7"]) + + let location = try await service.location(answering: question, backends: backends, context: context) + + #expect(location == PluginOutputLinkLocation(path: "/work/app/main.go", line: 7, column: nil)) + #expect(backends.calls.count == 1) + #expect(backends.calls.first?.pluginID == "dev.example.tb") + #expect(backends.calls.first?.function == "links.resolve") + #expect(backends.calls.first?.context == context) + #expect(backends.calls.first?.params == .object(["groups": .object(["symbol": .string("main.run"), "line": .string("7")])])) + #expect(matcher.resolutions == [backends.reply]) + } + + @Test + func aClickOnAMatcherThePluginNoLongerDeclaresCallsNothing() async throws { + let backends = StubBackends(reply: .null) + let question = PluginOutputLinkService.Question(pluginID: "dev.example.tb", function: "links.resolve", groups: [:]) + // Another function, another plugin, and no plugin at all. + for sources in [[Self.source(matchers: [Self.traceback])], + [PluginOutputLinkSource(pluginID: "dev.example.other", matchers: [Self.panic])], + []] { + let service = PluginOutputLinkService(matching: StubMatching(), sources: { sources }) + let location = try await service.location(answering: question, backends: backends, context: nil) + #expect(location == nil) + } + #expect(backends.calls.isEmpty) + } + + // MARK: Support + + private static func service(_ matching: StubMatching, matchers: [DeclarativeOutputLinkMatcher]? = nil) -> PluginOutputLinkService { + let source = source(matchers: matchers ?? [traceback]) + return PluginOutputLinkService(matching: matching, sources: { [source] }) + } + + private static func source(matchers: [DeclarativeOutputLinkMatcher]) -> PluginOutputLinkSource { + PluginOutputLinkSource(pluginID: "dev.example.tb", matchers: matchers) + } + + /// A plugin whose only contribution is console links, so it needs no backend and no restart. + private static func matcherOnlyManifest(id: String, matchers: [[String: Any]]) -> [String: Any] { + [ + "schemaVersion": 2, + "id": id, + "displayName": "Script Links", + "version": "1.0.0", + "pluginAPIVersion": 4, + "requires": ["outputLinkMatchers": 1], + "vendor": ["id": "org.example", "displayName": "Example", "signatureRequirement": "publisherPackage"], + "permissions": [], + "activationEvents": [], + "languages": [], + "outputLinkMatchers": matchers, + ] + } + + /// The editor actions fixture: a backend 2 plugin that matchers with a `resolve` function can join. + private static func editorActionsManifest(id: String, requiresMatchers: Bool = true) throws -> [String: Any] { + var object = try #require(JSONSerialization.jsonObject(with: Data(contentsOf: + fixtures.appendingPathComponent("editor/editor-actions.plugin.json"))) as? [String: Any]) + object["id"] = id + if requiresMatchers { + object["pluginAPIVersion"] = 4 + var requires = try #require(object["requires"] as? [String: Any]) + requires["outputLinkMatchers"] = 1 + object["requires"] = requires + } + return object + } + + private static func descriptor(_ manifest: [String: Any]) throws -> DeclarativePluginDescriptor { + try DeclarativePluginTestSupport.descriptor(manifest) + } + + private static func package(id: String, matchers: [[String: Any]]) throws -> InstalledPluginPackage { + try package(from: matcherOnlyManifest(id: id, matchers: matchers)) + } + + private static func package(from manifest: [String: Any]) throws -> InstalledPluginPackage { + let descriptor = try descriptor(manifest) + return InstalledPluginPackage( + descriptor: descriptor, + installation: PluginInstallationRecord( + pluginID: descriptor.id, activeVersion: descriptor.pluginManifest.version, origin: .marketplace), + packageURL: URL(fileURLWithPath: "/tmp/\(descriptor.id.rawValue)", isDirectory: true)) + } +} + +/// Answers `outputLinks.match` with canned links and records what the host asked. +private final class StubMatching: PluginOutputLinkMatching, @unchecked Sendable { + struct Request: Equatable { + let scope: String + let workspaceRoots: [String] + let lines: [String] + } + + private let lock = NSLock() + private var recordedRequests: [Request] = [] + private var recordedResolutions: [ToolingJSONValue] = [] + private let linksForRequest: @Sendable (Int) -> [PluginOutputLink] + private let failure: (any Error)? + private let location: PluginOutputLinkLocation? + + init(links: [PluginOutputLink] = [], linksForRequest: (@Sendable (Int) -> [PluginOutputLink])? = nil, + failure: (any Error)? = nil, location: PluginOutputLinkLocation? = nil) { + self.linksForRequest = linksForRequest ?? { _ in links } + self.failure = failure + self.location = location + } + + var requests: [Request] { lock.withLock { recordedRequests } } + var resolutions: [ToolingJSONValue] { lock.withLock { recordedResolutions } } + + func links(scope: String, workspaceRoots: [String], sources: [PluginOutputLinkSource], + lines: [String]) throws -> [PluginOutputLink] { + let index = lock.withLock { + recordedRequests.append(Request(scope: scope, workspaceRoots: workspaceRoots, lines: lines)) + return recordedRequests.count - 1 + } + if let failure { throw failure } + return linksForRequest(index) + } + + func location(ofResolution reply: ToolingJSONValue) throws -> PluginOutputLinkLocation? { + lock.withLock { recordedResolutions.append(reply) } + return location + } +} + +/// Answers every plugin function with one reply and records the calls. +@MainActor +private final class StubBackends: PluginBackendRequesting { + struct Call: Equatable { + let function: String + let pluginID: String + let params: ToolingJSONValue + let context: PluginWorkspaceContextReference? + } + + let reply: ToolingJSONValue + private(set) var calls: [Call] = [] + + init(reply: ToolingJSONValue) { self.reply = reply } + + func declaresBackend(_ pluginID: String) -> Bool { true } + func pluginFunctions(ofPlugin _: String) async throws -> [String] { [] } + + func invokePluginFunction( + _ functionID: String, params: ToolingJSONValue, ofPlugin pluginID: String, timeout _: Duration, + progress _: @escaping @MainActor @Sendable (PluginFunctionProgress) -> Void + ) async throws -> ToolingJSONValue { + try await invokePluginFunction(functionID, params: params, ofPlugin: pluginID, context: nil, timeout: .seconds(1), progress: { _ in }) + } + + func invokePluginFunction( + _ functionID: String, params: ToolingJSONValue, ofPlugin pluginID: String, + context: PluginWorkspaceContextReference?, timeout _: Duration, + progress _: @escaping @MainActor @Sendable (PluginFunctionProgress) -> Void + ) async throws -> ToolingJSONValue { + calls.append(Call(function: functionID, pluginID: pluginID, params: params, context: context)) + return reply + } +} diff --git a/macos/Tests/LitheTests/PluginRunHostServiceTests.swift b/macos/Tests/LitheTests/PluginRunHostServiceTests.swift new file mode 100644 index 0000000000..ff90021b40 --- /dev/null +++ b/macos/Tests/LitheTests/PluginRunHostServiceTests.swift @@ -0,0 +1,676 @@ +import Foundation +import Testing +import LitheCoreContracts +import LitheExecutionModule +@testable import Lithe + +/// `lithe/host/run/start`, `run/stop` and `debug/start`: what the host resolves, starts and reports. +/// Rust Core's checks of the request are not repeated here; the decoder is a stub. +@MainActor +struct PluginRunHostServiceTests { + // MARK: run/start + + @Test + func aToolRunExpandsTheLaunchTemplateAndLetsThePluginsEnvironmentWin() async throws { + let fixture = RunHostFixture() + defer { fixture.end() } + fixture.decoder.runStart = { _ in + makeRunStart(arguments: ["run", "."], workingDirectory: "sub", environment: ["A": "plugin", "B": "b"]) + } + + let result = try await fixture.call("run/start").get() + + let launch = try #require(fixture.window.launch) + #expect(result == .object(["runID": .string(launch.runID)])) + #expect(launch.title == "Run app") + #expect(launch.executableURL.path == "/opt/tool/bin/tool") + #expect(launch.arguments == ["--wrapped", "run", "."]) + #expect(launch.workingDirectory.path == "/fixture/workspace/sub") + #expect(launch.environment["PATH"] == "/opt/tool/bin") + #expect(launch.environment["A"] == "plugin") + #expect(launch.environment["B"] == "b") + // The user's layers pick the installation; project code is allowed as for any Run. + #expect(fixture.tools.resolutions == [ + RunTools.Resolution(tool: "go", directory: "sub", workspace: PluginHostFixture.workspaceURL.path, projectCodeAllowed: true), + ]) + #expect(fixture.decoder.workspaceRoots == [PluginHostFixture.workspaceURL]) + } + + @Test + func aPackagedExecutableRunsFromThePluginPackageWithoutResolvingATool() async throws { + let fixture = RunHostFixture() + defer { fixture.end() } + let package = try fixture.makePackage(executable: "bin/serve") + fixture.backends.packages[RunHostFixture.pluginID] = package + fixture.decoder.runStart = { _ in makeRunStart(executable: .packaged("bin/serve"), arguments: ["--port", "0"]) } + + _ = try await fixture.call("run/start").get() + + let launch = try #require(fixture.window.launch) + #expect(launch.executableURL.path == package.appendingPathComponent("bin/serve").resolvingSymlinksInPath().path) + #expect(launch.arguments == ["--port", "0"]) + #expect(fixture.tools.resolutions.isEmpty) + } + + @Test + func aPackagedExecutableThatIsMissingNotRunnableOrLinkedOutOfThePackageIsNotFound() async throws { + let fixture = RunHostFixture() + defer { fixture.end() } + let package = try fixture.makePackage(executable: "bin/serve") + try fixture.addFile("bin/data.txt", under: package, executable: false) + let outside = try fixture.makePackage(executable: "tool") + try FileManager.default.createSymbolicLink( + at: package.appendingPathComponent("bin/escape"), withDestinationURL: outside.appendingPathComponent("tool")) + fixture.backends.packages[RunHostFixture.pluginID] = package + + for path in ["bin/missing", "bin/data.txt", "bin/escape"] { + fixture.decoder.runStart = { _ in makeRunStart(executable: .packaged(path)) } + let error = await fixture.failure("run/start") + #expect(error?.code == .notFound, "\(path)") + } + #expect(fixture.window.launch == nil) + } + + @Test + func aToolWithNoUsableInstallationIsUnresolvedAndNothingStarts() async { + let fixture = RunHostFixture() + defer { fixture.end() } + + fixture.tools.selection = nil + let notInstalled = await fixture.failure("run/start") + #expect(notInstalled?.code == .toolUnresolved) + #expect(notInstalled?.details == ["tool": .string("go"), "reason": .string("notFound")]) + + // The installation the user chose cannot run. + fixture.tools.selection = RunTools.installation + fixture.tools.resolveFailure = BuildToolResolutionError("The selected installation is broken.") + let broken = await fixture.failure("run/start") + #expect(broken?.code == .toolUnresolved) + #expect(broken?.details == ["tool": .string("go"), "reason": .string("unusable")]) + + // The definition's launch template cannot carry the plugin's arguments. + fixture.tools.resolveFailure = nil + fixture.tools.launchFailure = DefinedToolLaunchRejection(code: "plugin.toolchain.launchMismatch", message: "mismatch") + let mismatch = await fixture.failure("run/start") + #expect(mismatch?.code == .toolUnresolved) + #expect(mismatch?.details == ["tool": .string("go"), "reason": .string("unusable")]) + + #expect(fixture.window.launch == nil) + } + + @Test + func aWorkingDirectoryThatLeavesTheWorkspaceIsRefused() async { + let fixture = RunHostFixture() + defer { fixture.end() } + fixture.decoder.runStart = { _ in makeRunStart(workingDirectory: "../elsewhere") } + + let error = await fixture.failure("run/start") + + #expect(error?.code == .permissionDenied) + #expect(fixture.window.launch == nil) + } + + @Test + func aConnectionDebugRunPassesTheDebugRequestToTheWindow() async throws { + let fixture = RunHostFixture() + defer { fixture.end() } + let debug = PluginRunStart.Debug(configurationType: "go", target: .connection(port: 40000)) + fixture.decoder.runStart = { _ in makeRunStart(debug: debug) } + + _ = try await fixture.call("run/start").get() + + #expect(fixture.window.debug == debug) + } + + @Test + func aDebugTargetTheHostCannotStartYetIsRefusedBeforeAnythingRuns() async { + let fixture = RunHostFixture() + defer { fixture.end() } + fixture.decoder.runStart = { _ in makeRunStart(debug: .init(configurationType: "go", target: .launch)) } + + let error = await fixture.failure("run/start") + + #expect(error?.code == .unsupportedOption) + #expect(error?.details == ["option": .string("debug.target")]) + #expect(fixture.tools.resolutions.isEmpty) + #expect(fixture.window.launch == nil) + } + + @Test + func aWindowThatCannotRunAndAPluginWithoutABackendAreAnsweredWithTheirOwnCodes() async { + let plain = FakePluginHostWindow(snapshot: PluginHostFixture.snapshot(windowID: UUID())) + let withoutRunPort = RunHostFixture(owner: plain) + defer { withoutRunPort.end() } + for method in ["run/start", "debug/start"] { + #expect(await withoutRunPort.failure(method)?.code == .unavailable, "\(method)") + } + + let withoutBackend = RunHostFixture() + defer { withoutBackend.end() } + withoutBackend.backends.manifests = [:] + for method in ["run/start", "debug/start"] { + #expect(await withoutBackend.failure(method)?.code == .backendExited, "\(method)") + } + #expect(withoutBackend.window.launch == nil) + } + + @Test + func coreRejectionsReachTheBackendAsHostErrorsWithTheirCodeAndField() async { + let fixture = RunHostFixture() + defer { fixture.end() } + let cases: [(code: String, field: String?, expected: PluginHostErrorCode)] = [ + ("plugin.run.invalidStart", "process.arguments", .invalidParams), + ("plugin.debug.invalidStart", nil, .invalidParams), + ("plugin.debug.unknownAdapter", "adapter", .notFound), + ("plugin.debug.foreignAdapter", "adapter", .notFound), + ("plugin.debug.limitExceeded", "configuration", .limitExceeded), + ] + for (code, field, expected) in cases { + let rejection = DeclarativePluginRejection(code: code, message: "Core says no", field: field) + fixture.decoder.runFailure = rejection + fixture.decoder.debugFailure = rejection + for method in ["run/start", "debug/start"] { + let error = await fixture.failure(method) + #expect(error?.code == expected, "\(method) \(code)") + #expect(error?.details?["code"] == .string(code), "\(method) \(code)") + #expect(error?.details?["field"] == field.map(ToolingJSONValue.string), "\(method) \(code)") + } + } + #expect(fixture.window.launch == nil) + #expect(fixture.window.debugStarts.isEmpty) + } + + // MARK: The end of a run + + @Test + func theEndIsReportedOnceToTheBackendSessionThatSawTheRunBegin() async throws { + let fixture = RunHostFixture() + defer { fixture.end() } + let runID = try await fixture.startRun(session: "session-7") + + fixture.window.finish(runID: runID, exitCode: 3) + fixture.window.finish(runID: runID, exitCode: 4) + + #expect(fixture.backends.exits == [ + RunBackends.Exit(pluginID: RunHostFixture.pluginID, sessionID: "session-7", + params: .object(["runID": .string(runID), "exitCode": .integer(3)])), + ]) + } + + @Test + func aRunTheUserStoppedIsReportedWithoutAnExitCode() async throws { + let fixture = RunHostFixture() + defer { fixture.end() } + let runID = try await fixture.startRun() + + fixture.window.finish(runID: runID, exitCode: nil) + + #expect(fixture.backends.exits.map(\.params) == [.object(["runID": .string(runID)])]) + } + + @Test + func theEndOfARunTheHostDoesNotKnowIsIgnored() async throws { + let fixture = RunHostFixture() + defer { fixture.end() } + _ = try await fixture.startRun() + + fixture.window.finish(runID: "another-run", exitCode: 1) + + #expect(fixture.backends.exits.isEmpty) + } + + // MARK: Temporary files + + @Test + func temporaryFilesAreWrittenForTheRunAndRemovedWhenItEnds() async throws { + let fixture = RunHostFixture() + defer { fixture.end() } + fixture.decoder.runStart = { directory in + let path = directory!.appendingPathComponent("settings.json").path + return makeRunStart(temporaryFiles: [PluginRunStart.TemporaryFile(path: path, contents: "{\"a\":1}")]) + } + + let runID = try await fixture.startRun() + + let directory = try #require(fixture.decoder.temporaryDirectories.last ?? nil) + fixture.scratch.append(directory) + #expect(try String(contentsOf: directory.appendingPathComponent("settings.json"), encoding: .utf8) == "{\"a\":1}") + fixture.window.finish(runID: runID, exitCode: 0) + #expect(!FileManager.default.fileExists(atPath: directory.path)) + } + + @Test + func aRunWithoutTemporaryFilesCreatesNoDirectory() async throws { + let fixture = RunHostFixture() + defer { fixture.end() } + + _ = try await fixture.call("run/start").get() + + let directory = try #require(fixture.decoder.temporaryDirectories.last ?? nil) + #expect(!FileManager.default.fileExists(atPath: directory.path)) + } + + @Test + func aWindowThatFailsToStartTheRunLeavesNoFilesAndNoRunToStop() async throws { + let fixture = RunHostFixture() + defer { fixture.end() } + fixture.window.startFailure = CocoaError(.fileWriteNoPermission) + fixture.decoder.runStart = { directory in + let path = directory!.appendingPathComponent("settings.json").path + return makeRunStart(temporaryFiles: [PluginRunStart.TemporaryFile(path: path, contents: "x")]) + } + + let error = await fixture.failure("run/start") + + #expect(error?.code == .failed) + let directory = try #require(fixture.decoder.temporaryDirectories.last ?? nil) + #expect(!FileManager.default.fileExists(atPath: directory.path)) + // The window was told to drop whatever it had begun, and the run is not known afterwards. + let runID = try #require(fixture.window.stoppedRuns.first) + _ = try await fixture.call("run/stop", ["runID": .string(runID)], context: false).get() + #expect(fixture.window.stoppedRuns == [runID]) + } + + @Test + func aRequestCancelledWhileTheRunStartsStopsTheRunAndStaysSilent() async throws { + let fixture = RunHostFixture() + defer { fixture.end() } + // The backend gave up waiting: the window cancels the task that serves the request. + fixture.window.duringStart = { withUnsafeCurrentTask { $0?.cancel() } } + + let request = Task { @MainActor in await fixture.failure("run/start") } + let error = await request.value + + #expect(error?.code == .cancelled) + let launch = try #require(fixture.window.launch) + #expect(fixture.window.stoppedRuns == [launch.runID]) + fixture.window.finish(runID: launch.runID, exitCode: nil) + #expect(fixture.backends.exits.isEmpty) + } + + // MARK: Run again + + @Test + func runningAgainCallsTheRerunFunctionWithItsDataInTheWindowsContext() async throws { + let fixture = RunHostFixture() + defer { fixture.end() } + let data = ToolingJSONValue.object(["target": .string("app")]) + fixture.decoder.runStart = { _ in makeRunStart(rerun: PluginRunStart.Rerun(function: "run.prepare", data: data)) } + _ = try await fixture.call("run/start").get() + + let rerun = try #require(fixture.window.rerun) + rerun() + try #require(await fixture.backends.called.waitUntilOpen()) + + #expect(fixture.backends.calls == [ + RunBackends.Call(function: "run.prepare", pluginID: RunHostFixture.pluginID, params: data, context: fixture.context.reference), + ]) + #expect(fixture.window.reportedProblems.isEmpty) + } + + @Test + func aRerunFunctionThatFailsIsReportedToTheUser() async throws { + let fixture = RunHostFixture() + defer { fixture.end() } + fixture.backends.functionFailure = RerunFailure.rerunFailed + fixture.decoder.runStart = { _ in + makeRunStart(rerun: PluginRunStart.Rerun(function: "run.prepare", data: .object([:]))) + } + _ = try await fixture.call("run/start").get() + + let rerun = try #require(fixture.window.rerun) + rerun() + try #require(await fixture.window.problemReported.waitUntilOpen()) + + #expect(fixture.window.reportedProblems == [RerunFailure.rerunFailed.localizedDescription]) + } + + // MARK: run/stop + + @Test + func stopEndsOnlyARunOfTheCallingPluginAndIgnoresOthers() async throws { + let fixture = RunHostFixture() + defer { fixture.end() } + let runID = try await fixture.startRun() + + _ = try await fixture.call("run/stop", ["runID": .string(runID)], plugin: "dev.example.other", context: false).get() + _ = try await fixture.call("run/stop", ["runID": .string("unknown")], context: false).get() + #expect(fixture.window.stoppedRuns.isEmpty) + + let reply = try await fixture.call("run/stop", ["runID": .string(runID)], context: false).get() + #expect(reply == .null) + #expect(fixture.window.stoppedRuns == [runID]) + } + + @Test + func stopNeedsExactlyOneOfRunIDAndSessionID() async { + let fixture = RunHostFixture() + defer { fixture.end() } + + let both = await fixture.failure("run/stop", ["runID": .string("a"), "sessionID": .string("b")], context: false) + let neither = await fixture.failure("run/stop", [:], context: false) + let extra = await fixture.failure("run/stop", ["runID": .string("a"), "force": .bool(true)], context: false) + + #expect(both?.code == .invalidParams) + #expect(neither?.code == .invalidParams) + // An option the host does not know is refused before anything stops, as the contract asks. + #expect(extra?.code == .unsupportedOption) + #expect(extra?.details == ["option": .string("force")]) + } + + // MARK: debug/start + + @Test + func aDebugStartGoesToTheWindowAndItsSessionCanBeStoppedByItsPlugin() async throws { + let fixture = RunHostFixture() + defer { fixture.end() } + let start = PluginDebugStart(adapterID: "dev.example.tool/go", request: "launch", label: "Debug app", + configuration: .object(["program": .string("main.go")])) + fixture.decoder.debugStart = start + fixture.window.debugSessionID = "debug-1" + + let reply = try await fixture.call("debug/start", ["adapter": .string("go")]).get() + + #expect(reply == .object(["sessionID": .string("debug-1")])) + #expect(fixture.window.debugStarts == [start]) + #expect(fixture.decoder.debugParams == [.object(["adapter": .string("go")])]) + + _ = try await fixture.call("run/stop", ["sessionID": .string("debug-1")], plugin: "dev.example.other", context: false).get() + #expect(fixture.window.stoppedSessions.isEmpty) + _ = try await fixture.call("run/stop", ["sessionID": .string("debug-1")], context: false).get() + #expect(fixture.window.stoppedSessions == ["debug-1"]) + + // Once the session is gone, stopping it is a no-op. + fixture.window.endDebugSession("debug-1") + _ = try await fixture.call("run/stop", ["sessionID": .string("debug-1")], context: false).get() + #expect(fixture.window.stoppedSessions == ["debug-1"]) + } + + @Test + func aDebugSessionTheWindowFailedToStartIsAnErrorNotASession() async { + let fixture = RunHostFixture() + defer { fixture.end() } + fixture.window.startFailure = CocoaError(.fileNoSuchFile) + + let error = await fixture.failure("debug/start") + + #expect(error?.code == .failed) + #expect(fixture.window.stoppedSessions.isEmpty) + } + + @Test + func aDebugStartCancelledWhileStartingStopsTheSession() async { + let fixture = RunHostFixture() + defer { fixture.end() } + fixture.window.debugSessionID = "debug-2" + fixture.window.duringStart = { withUnsafeCurrentTask { $0?.cancel() } } + + let request = Task { @MainActor in await fixture.failure("debug/start") } + let error = await request.value + + #expect(error?.code == .cancelled) + #expect(fixture.window.stoppedSessions == ["debug-2"]) + } +} + +// MARK: - Support + +private enum RerunFailure: Error, LocalizedError { + case rerunFailed + var errorDescription: String? { "The backend could not prepare the run." } +} + +/// A decoded `run/start` with the plain defaults of these tests. +private func makeRunStart( + executable: PluginRunStart.Executable = .tool("go"), arguments: [String] = [], + workingDirectory: String = ".", environment: [String: String] = [:], + temporaryFiles: [PluginRunStart.TemporaryFile] = [], debug: PluginRunStart.Debug? = nil, + rerun: PluginRunStart.Rerun? = nil +) -> PluginRunStart { + PluginRunStart(label: "Run app", executable: executable, arguments: arguments, workingDirectory: workingDirectory, + environment: environment, temporaryFiles: temporaryFiles, debug: debug, rerun: rerun) +} + +/// Stands in for Core: answers with what the test set, and records what the service asked. +private final class RunDecoder: PluginRunStartDecoding, @unchecked Sendable { + var runStart: (URL?) -> PluginRunStart = { _ in makeRunStart() } + var debugStart = PluginDebugStart(adapterID: "dev.example.tool/go", request: "launch", label: "Debug", configuration: .object([:])) + var runFailure: (any Error)? + var debugFailure: (any Error)? + private(set) var workspaceRoots: [URL] = [] + private(set) var temporaryDirectories: [URL?] = [] + private(set) var debugParams: [ToolingJSONValue] = [] + + func decodeRunStart(manifest _: Data, workspaceRoot: URL, temporaryDirectory: URL?, + params _: ToolingJSONValue) throws -> PluginRunStart { + workspaceRoots.append(workspaceRoot) + temporaryDirectories.append(temporaryDirectory) + if let runFailure { throw runFailure } + return runStart(temporaryDirectory) + } + + func decodeDebugStart(manifest _: Data, params: ToolingJSONValue) throws -> PluginDebugStart { + debugParams.append(params) + if let debugFailure { throw debugFailure } + return debugStart + } +} + +/// The installed tools: one installation by default, and whatever the test makes of it. +@MainActor +private final class RunTools: DefinedToolchainResolving, DefinedToolLaunching { + struct Resolution: Equatable { + let tool: String + let directory: String + let workspace: String + let projectCodeAllowed: Bool + } + + static let installation = ResolvedDefinedToolchain( + executable: URL(fileURLWithPath: "/opt/tool/bin/tool"), environment: ["PATH": "/opt/tool/bin", "A": "tool"], + projectCode: false, version: "1.0", home: "/opt/tool") + + var selection: ResolvedDefinedToolchain? = RunTools.installation + var resolveFailure: (any Error)? + var launchFailure: DefinedToolLaunchRejection? + private(set) var resolutions: [Resolution] = [] + + func resolve(tool: String, directory: String, workspace: URL, environment _: [String: String], + minimumVersion _: String?, projectCodeAllowed: Bool) async throws -> ResolvedDefinedToolchain? { + resolutions.append(Resolution(tool: tool, directory: directory, workspace: workspace.path, projectCodeAllowed: projectCodeAllowed)) + if let resolveFailure { throw resolveFailure } + return selection + } + + func launch(of selection: ResolvedDefinedToolchain, tool _: String, arguments: [String]) throws -> DefinedToolLaunch { + if let launchFailure { throw launchFailure } + return DefinedToolLaunch(executable: selection.executable.path, arguments: ["--wrapped"] + arguments) + } +} + +/// The plugin backends: their manifests and packages, the functions called, the exits heard. +@MainActor +private final class RunBackends: PluginRunBackendLink { + struct Call: Equatable { + let function: String + let pluginID: String + let params: ToolingJSONValue + let context: PluginWorkspaceContextReference? + } + + struct Exit: Equatable { + let pluginID: String + let sessionID: String + let params: ToolingJSONValue + } + + var manifests: [String: Data] = [:] + var packages: [String: URL] = [:] + var functionFailure: (any Error)? + private(set) var calls: [Call] = [] + private(set) var exits: [Exit] = [] + let called = TestGate() + + func manifest(ofPlugin pluginID: String) -> Data? { manifests[pluginID] } + func packageURL(ofPlugin pluginID: String) -> URL? { packages[pluginID] } + + func notifyRunDidExit(pluginID: String, sessionID: String, params: ToolingJSONValue) { + exits.append(Exit(pluginID: pluginID, sessionID: sessionID, params: params)) + } + + func declaresBackend(_: String) -> Bool { true } + func pluginFunctions(ofPlugin _: String) async throws -> [String] { [] } + + func invokePluginFunction( + _ functionID: String, params: ToolingJSONValue, ofPlugin pluginID: String, timeout _: Duration, + progress _: @escaping @MainActor @Sendable (PluginFunctionProgress) -> Void + ) async throws -> ToolingJSONValue { + try await invokePluginFunction(functionID, params: params, ofPlugin: pluginID, context: nil, timeout: .seconds(1), progress: { _ in }) + } + + func invokePluginFunction( + _ functionID: String, params: ToolingJSONValue, ofPlugin pluginID: String, + context: PluginWorkspaceContextReference?, timeout _: Duration, + progress _: @escaping @MainActor @Sendable (PluginFunctionProgress) -> Void + ) async throws -> ToolingJSONValue { + calls.append(Call(function: functionID, pluginID: pluginID, params: params, context: context)) + called.open() + if let functionFailure { throw functionFailure } + return .null + } +} + +/// A window that shows runs and debug sessions, and lets the test end them. +@MainActor +private final class RunWindow: PluginHostWindowPort, PluginRunWindowPort { + var hostSnapshot: PluginHostWindowSnapshot? = PluginHostFixture.snapshot(windowID: UUID()) + let hostMessages = PluginHostMessageFeatureModel() + var startFailure: (any Error)? + /// Runs while the window starts a run or a session, to model the request changing under it. + var duringStart: (@MainActor () -> Void)? + var debugSessionID = "debug-1" + private(set) var launch: PluginRunLaunch? + private(set) var debug: PluginRunStart.Debug? + private(set) var rerun: (@MainActor () -> Void)? + private(set) var onEnd: (@MainActor (PluginRunEnd) -> Void)? + private(set) var stoppedRuns: [String] = [] + private(set) var debugStarts: [PluginDebugStart] = [] + private(set) var stoppedSessions: [String] = [] + private(set) var reportedProblems: [String] = [] + private var debugEnd: (@MainActor (String) -> Void)? + let problemReported = TestGate() + + func hostStartPluginRun(_ launch: PluginRunLaunch, debug: PluginRunStart.Debug?, rerun: (@MainActor @Sendable () -> Void)?, + onEnd: @escaping @MainActor @Sendable (PluginRunEnd) -> Void) async throws { + self.launch = launch + self.debug = debug + self.rerun = rerun + self.onEnd = onEnd + duringStart?() + if let startFailure { throw startFailure } + } + + func hostStopPluginRun(runID: String) { stoppedRuns.append(runID) } + + func hostStartPluginDebugSession(_ start: PluginDebugStart, onEnd: @escaping @MainActor @Sendable (String) -> Void) async throws -> String { + debugStarts.append(start) + debugEnd = onEnd + duringStart?() + if let startFailure { throw startFailure } + return debugSessionID + } + + func hostStopPluginDebugSession(_ sessionID: String) { stoppedSessions.append(sessionID) } + + func hostReportPluginRunProblem(_ message: String) { + reportedProblems.append(message) + problemReported.open() + } + + /// The process of the run ended; `exitCode` is nil when the user stopped it. + func finish(runID: String, exitCode: Int32?) { + onEnd?(PluginRunEnd(runID: runID, exitCode: exitCode)) + } + + func endDebugSession(_ sessionID: String) { debugEnd?(sessionID) } + + func hostDocumentState(at url: URL, flushingEditor: Bool) async -> PluginHostDocumentState? { nil } + func hostOpenDocument(at url: URL, activation: PluginHostEditorActivation) async -> PluginHostDocumentState? { nil } + func hostPlaceCaret(in url: URL, at position: PluginHostPosition) {} + func hostNoteMessageLimit(pluginName: String) {} +} + +/// The service with one local window and every collaborator stubbed. +@MainActor +private final class RunHostFixture { + static let pluginID = "dev.example.tool" + + let contexts = PluginWorkspaceContexts() + let window = RunWindow() + let decoder = RunDecoder() + let tools = RunTools() + let backends = RunBackends() + let service: PluginRunHostService + let context: PluginWorkspaceContext + /// Held so the context keeps its owner: the contexts only know it weakly. + private let owner: any PluginHostWindowPort + private let subscriptions = PluginHostSubscriptions() + /// Directories this test created for itself, removed in `end()`. + var scratch: [URL] = [] + + /// `owner` is the window that owns the workspace context; by default the window that can run. + init(owner: (any PluginHostWindowPort)? = nil) { + self.owner = owner ?? window + service = PluginRunHostService( + contexts: contexts, decoder: decoder, tools: tools, fileStorage: MacFileStorage(), + baseEnvironment: { ["HOME": "/fixture/home"] }) + service.backends = backends + backends.manifests[Self.pluginID] = Data("{}".utf8) + context = contexts.open(owner: self.owner, rootURL: PluginHostFixture.workspaceURL, remote: nil) + } + + func end() { + contexts.close(owner: owner) + for url in scratch { try? FileManager.default.removeItem(at: url) } + if let launch = window.launch { + try? FileManager.default.removeItem(at: MacFileStorage().temporaryDirectory() + .appendingPathComponent("lithe-plugin-run-\(launch.runID)", isDirectory: true)) + } + } + + func call(_ method: String, _ params: [String: ToolingJSONValue] = [:], plugin: String = RunHostFixture.pluginID, + session: String = "session-1", context: Bool = true) async -> Result { + let request = PluginHostRequest(id: UUID().uuidString, method: "lithe/host/" + method, service: "run", params: .object(params)) + return await service.answer(PluginHostCall( + pluginID: plugin, sessionID: session, request: request, context: context ? self.context : nil, + params: params, subscriptions: subscriptions)) + } + + func failure(_ method: String, _ params: [String: ToolingJSONValue] = [:], context: Bool = true) async -> PluginHostServiceError? { + if case .failure(let error) = await call(method, params, context: context) { return error } + return nil + } + + /// Starts the default run and returns its ID. + func startRun(session: String = "session-1") async throws -> String { + let reply = try await call("run/start", session: session).get() + guard case .object(let object) = reply, case .string(let id)? = object["runID"] else { throw PluginHostArguments.invalid } + return id + } + + /// A plugin package directory holding one executable file. + func makePackage(executable relativePath: String) throws -> URL { + let package = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-run-host-test-\(UUID().uuidString)", isDirectory: true) + scratch.append(package) + try addFile(relativePath, under: package, executable: true) + return package + } + + func addFile(_ relativePath: String, under package: URL, executable: Bool) throws { + let file = package.appendingPathComponent(relativePath) + try FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data("#!/bin/sh\n".utf8).write(to: file) + try FileManager.default.setAttributes([.posixPermissions: executable ? 0o755 : 0o644], ofItemAtPath: file.path) + } +} diff --git a/rust/lithe-core/src/build/manifest.rs b/rust/lithe-core/src/build/manifest.rs index e0a5e12724..842d0b6856 100644 --- a/rust/lithe-core/src/build/manifest.rs +++ b/rust/lithe-core/src/build/manifest.rs @@ -1017,6 +1017,8 @@ pub(crate) struct DeclarationContext<'a> { pub(crate) declared_settings: &'a crate::plugins::settings::DeclaredSettings, /// Language IDs the plugin declares a language server for. pub(crate) language_servers: &'a BTreeSet, + /// Whether tool names are host runtimes or tool-definition IDs. + pub(crate) runtime_tools: crate::plugins::declarative::RuntimeTools<'a>, } pub(crate) fn validate_declarations( @@ -1532,6 +1534,7 @@ impl BuildSystemDeclaration { context.plugin_api_version, context.declared_settings, ServerRole::BuildProvider, + context.runtime_tools, ), } } diff --git a/rust/lithe-core/src/build/mod.rs b/rust/lithe-core/src/build/mod.rs index 511051affc..77829b7cc6 100644 --- a/rust/lithe-core/src/build/mod.rs +++ b/rust/lithe-core/src/build/mod.rs @@ -31,6 +31,7 @@ mod outputs; mod plans; mod project_options; mod run_preparation; +mod run_start; mod select; mod stored_options; mod template; @@ -43,4 +44,5 @@ pub(crate) use manifest::{ ProviderProcess, }; pub(crate) use project_options::project_options_command; +pub(crate) use run_start::run_decode_start_command; pub(crate) use stored_options::resolve_stored_options_command; diff --git a/rust/lithe-core/src/build/run_preparation.rs b/rust/lithe-core/src/build/run_preparation.rs index d11af23741..ef6d5ae819 100644 --- a/rust/lithe-core/src/build/run_preparation.rs +++ b/rust/lithe-core/src/build/run_preparation.rs @@ -10,9 +10,9 @@ use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::collections::BTreeMap; -const MAX_ARGUMENTS: usize = 1024; -const MAX_ARGUMENT_BYTES: usize = 65536; -const MAX_ENVIRONMENT: usize = 64; +pub(super) const MAX_ARGUMENTS: usize = 1024; +pub(super) const MAX_ARGUMENT_BYTES: usize = 65536; +pub(super) const MAX_ENVIRONMENT: usize = 64; /// Resolved user choices; only the owning provider interprets runtime semantics. #[derive(Debug, Deserialize, Serialize)] diff --git a/rust/lithe-core/src/build/run_start.rs b/rust/lithe-core/src/build/run_start.rs new file mode 100644 index 0000000000..f8861c3d4b --- /dev/null +++ b/rust/lithe-core/src/build/run_start.rs @@ -0,0 +1,377 @@ +//! `lithe/host/run/start`: the parameters a plugin backend hands the host to start a Run session. +//! +//! The process has the shape a build provider's run preparation returns, so both go through +//! the same bounded-argument, environment and workspace-directory checks. The executable is +//! either a tool the plugin required by definition ID or a file packaged with the plugin; the +//! host resolves it and owns the process. Nothing here starts a process or writes a file. + +use super::decode; +use super::run_preparation::{ + process_fields, valid_configuration_type, DecodeRunPreparationRequest, MAX_ARGUMENT_BYTES, +}; +use crate::lsp::interface::plugin_function::is_function_id; +use crate::plugins::declarative::{ + decode_payload, outcome, validate_manifest_value, PluginRejection, Validation, +}; +use crate::protocol::CoreError; +use serde::Deserialize; +use serde_json::{json, Map, Value}; +use std::collections::{BTreeMap, BTreeSet}; + +const MAX_LABEL_CHARS: usize = 256; +const MAX_TEMPORARY_FILES: usize = 4; +const MAX_TEMPORARY_FILE_BYTES: usize = 4 * 1024 * 1024; +const MAX_TEMPORARY_NAME_BYTES: usize = 64; +const MAX_PACKAGED_PATH_BYTES: usize = 4096; +const MAX_RERUN_DATA_BYTES: usize = 64 * 1024; +/// How a process argument or environment value names a temporary file. +const TEMPORARY_FILE_REFERENCE: &str = "${temporaryFile:"; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct DecodeStartPayload { + /// The plugin's manifest; its `requiresTools` limits the tools a run may start. + manifest: Value, + /// Absolute root of the workspace the run belongs to. + workspace_root: String, + /// Absolute directory the host created for this run's temporary files; required when the + /// run has any. + #[serde(default)] + temporary_directory: Option, + params: Value, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct StartParams { + /// A reference the host resolved before the service saw the request. + #[serde(default, rename = "context")] + _context: Option, + label: String, + process: Map, + #[serde(default)] + temporary_files: Vec, + #[serde(default)] + debug: Option, + #[serde(default)] + rerun: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct TemporaryFile { + name: String, + contents: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct StartDebug { + configuration_type: String, + target: Map, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct Rerun { + function: String, + data: Value, +} + +fn invalid(field: &str, message: impl Into) -> PluginRejection { + PluginRejection::at("plugin.run.invalidStart", field, message) +} + +/// `run.decodeStart`: the normalized start with its executable reference, process, the files +/// to write with their final paths, and the debug and rerun choices. +pub(crate) fn run_decode_start_command(payload: Value) -> Result { + let payload: DecodeStartPayload = decode_payload(payload, "run.decodeStart")?; + Ok(outcome( + validate_manifest_value(payload.manifest.clone()) + .and_then(|manifest| decode_start(&payload, manifest.required_tools())) + .map(|start| ("start", start)), + )) +} + +fn decode_start(payload: &DecodeStartPayload, required_tools: &[String]) -> Validation { + let params: StartParams = serde_json::from_value(payload.params.clone()) + .map_err(|_| invalid("params", "The run start parameters are malformed."))?; + check_label(¶ms.label)?; + let executable = executable(¶ms.process, required_tools)?; + let files = temporary_files( + ¶ms.temporary_files, + payload.temporary_directory.as_deref(), + )?; + for key in params.process.keys() { + if !["executable", "arguments", "workingDirectory", "environment"].contains(&key.as_str()) { + return Err(invalid( + &format!("process.{key}"), + "The process has no such field.", + )); + } + } + let fields = process_fields(&DecodeRunPreparationRequest { + workspace_root: payload.workspace_root.clone(), + root: ".".to_string(), + result: Value::Object(params.process.clone()), + launch_options: None, + }) + .map_err(|_| { + invalid( + "process", + "The process arguments, directory or environment are invalid.", + ) + })?; + let paths: BTreeMap<&str, &str> = files + .iter() + .map(|file| (file.name.as_str(), file.path.as_str())) + .collect(); + let arguments = fields["arguments"] + .as_array() + .into_iter() + .flatten() + .enumerate() + .map(|(index, argument)| { + substitute( + argument.as_str().unwrap_or_default(), + &paths, + &format!("process.arguments[{index}]"), + ) + }) + .collect::>>()?; + let mut environment = Map::new(); + for (key, value) in fields["environment"].as_object().into_iter().flatten() { + environment.insert( + key.clone(), + Value::String(substitute( + value.as_str().unwrap_or_default(), + &paths, + &format!("process.environment.{key}"), + )?), + ); + } + let mut start = json!({ + "label": params.label, + "executable": executable, + "arguments": arguments, + "workingDirectory": fields["workingDirectory"], + "environment": environment, + "temporaryFiles": files + .iter() + .map(|file| json!({"path": file.path, "contents": file.contents})) + .collect::>(), + }); + if let Some(debug) = ¶ms.debug { + start["debug"] = start_debug(debug)?; + } + if let Some(rerun) = ¶ms.rerun { + start["rerun"] = rerun_target(rerun)?; + } + Ok(start) +} + +fn check_label(label: &str) -> Validation<()> { + let characters = label.chars().count(); + if label.trim().is_empty() + || characters > MAX_LABEL_CHARS + || label.chars().any(char::is_control) + { + return Err(invalid( + "label", + "A label is 1 to 256 characters without control characters.", + )); + } + Ok(()) +} + +/// A tool the manifest requires, or a relative file inside the plugin's package. +fn executable(process: &Map, required_tools: &[String]) -> Validation { + let executable = process + .get("executable") + .and_then(Value::as_object) + .ok_or_else(|| invalid("process.executable", "The process names no executable."))?; + let text = |key: &str| executable.get(key).and_then(Value::as_str); + match (text("kind"), executable.len()) { + (Some("tool"), 2) => { + let tool = text("tool").unwrap_or_default(); + if !required_tools.iter().any(|required| required == tool) { + return Err(invalid( + "process.executable.tool", + "The tool is not listed in the manifest's requiresTools.", + )); + } + Ok(json!({"kind": "toolchain", "value": tool})) + } + (Some("packaged"), 2) => { + let path = text("path").unwrap_or_default(); + if path.is_empty() + || path.len() > MAX_PACKAGED_PATH_BYTES + || decode::request_path(path, "executable").map_or(true, |clean| clean != path) + { + return Err(invalid( + "process.executable.path", + "A packaged executable is a normalized relative path inside the plugin.", + )); + } + Ok(json!({"kind": "packaged", "value": path})) + } + _ => Err(invalid( + "process.executable", + "An executable is {kind:\"tool\",tool} or {kind:\"packaged\",path}.", + )), + } +} + +/// A temporary file the host writes before the process starts. +struct PlannedFile { + name: String, + path: String, + contents: String, +} + +fn temporary_files( + files: &[TemporaryFile], + directory: Option<&str>, +) -> Validation> { + if files.len() > MAX_TEMPORARY_FILES { + return Err(invalid( + "temporaryFiles", + "A run has at most 4 temporary files.", + )); + } + if files.is_empty() { + return Ok(Vec::new()); + } + let directory = directory + .filter(|directory| { + !directory.is_empty() + && directory.len() <= MAX_PACKAGED_PATH_BYTES + && !directory.contains('\0') + && std::path::Path::new(directory).is_absolute() + }) + .ok_or_else(|| { + invalid( + "temporaryDirectory", + "The host supplies an absolute temporary directory for temporary files.", + ) + })?; + let directory = directory.trim_end_matches(['/', '\\']); + let mut names = BTreeSet::new(); + let mut planned = Vec::new(); + for (index, file) in files.iter().enumerate() { + if !valid_temporary_name(&file.name) || !names.insert(file.name.as_str()) { + return Err(invalid( + &format!("temporaryFiles[{index}].name"), + "A temporary file name is unique, 1 to 64 letters, digits, '.', '_' or '-', and starts with a letter or digit.", + )); + } + if file.contents.len() > MAX_TEMPORARY_FILE_BYTES || file.contents.contains('\0') { + return Err(invalid( + &format!("temporaryFiles[{index}].contents"), + "A temporary file holds at most 4 MiB of text.", + )); + } + planned.push(PlannedFile { + name: file.name.clone(), + path: format!("{directory}{}{}", std::path::MAIN_SEPARATOR, file.name), + contents: file.contents.clone(), + }); + } + Ok(planned) +} + +fn valid_temporary_name(name: &str) -> bool { + name.len() <= MAX_TEMPORARY_NAME_BYTES + && name + .bytes() + .next() + .is_some_and(|first| first.is_ascii_alphanumeric()) + && name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) +} + +/// Replaces every `${temporaryFile:}` with the file's path. Any other `${...}` is data. +fn substitute(text: &str, paths: &BTreeMap<&str, &str>, field: &str) -> Validation { + let mut output = String::with_capacity(text.len()); + let mut rest = text; + while let Some(start) = rest.find(TEMPORARY_FILE_REFERENCE) { + output.push_str(&rest[..start]); + let after = &rest[start + TEMPORARY_FILE_REFERENCE.len()..]; + let (name, tail) = after + .split_once('}') + .ok_or_else(|| invalid(field, "A temporary file reference is not closed with '}'."))?; + let path = paths.get(name).ok_or_else(|| { + invalid( + field, + "A temporary file reference names no declared temporary file.", + ) + })?; + output.push_str(path); + rest = tail; + } + output.push_str(rest); + if output.len() > MAX_ARGUMENT_BYTES { + return Err(invalid( + field, + "The value is too long once temporary file paths are inserted.", + )); + } + Ok(output) +} + +fn start_debug(debug: &StartDebug) -> Validation { + if !valid_configuration_type(&debug.configuration_type) { + return Err(invalid( + "debug.configurationType", + "The configuration type is bounded text without control characters.", + )); + } + let kind = debug.target.get("kind").and_then(Value::as_str); + let target = match (kind, debug.target.len()) { + (Some("launch"), 1) => json!({"kind": "launch"}), + (Some("connection"), 2) => { + let port = debug + .target + .get("port") + .and_then(Value::as_u64) + .filter(|port| (1..=65535).contains(port)) + .ok_or_else(|| { + invalid( + "debug.target.port", + "A connection target names a port from 1 to 65535.", + ) + })?; + json!({"kind": "connection", "port": port}) + } + _ => { + return Err(invalid( + "debug.target", + "A debug target is {kind:\"launch\"} or {kind:\"connection\",port}.", + )) + } + }; + Ok(json!({"configurationType": debug.configuration_type, "target": target})) +} + +fn rerun_target(rerun: &Rerun) -> Validation { + if !is_function_id(&rerun.function) { + return Err(invalid( + "rerun.function", + "A rerun names one of the plugin's registered function IDs.", + )); + } + // The function protocol passes an object as the params of a function, so the data is one. + if !rerun.data.is_object() { + return Err(invalid( + "rerun.data", + "Rerun data is a JSON object, the params of the function.", + )); + } + if serde_json::to_vec(&rerun.data).map_or(usize::MAX, |bytes| bytes.len()) + > MAX_RERUN_DATA_BYTES + { + return Err(invalid("rerun.data", "Rerun data is at most 64 KiB.")); + } + Ok(json!({"function": rerun.function, "data": rerun.data})) +} diff --git a/rust/lithe-core/src/debug/adapters.rs b/rust/lithe-core/src/debug/adapters.rs index 058633a166..20129e6c0b 100644 --- a/rust/lithe-core/src/debug/adapters.rs +++ b/rust/lithe-core/src/debug/adapters.rs @@ -17,6 +17,7 @@ //! //! Note: the decision is recorded in .agents/notes/proposed/architecture/2026-10-25-unified-debug-engine-and-adapter-registry.md +use super::types::{DebugAdapterDefaults, DebugAdapterFeatures, DeclaredAdapterBehavior}; use crate::plugins::declarative::{ invalid, is_safe_relative_path, validate_process_arguments, PluginRejection, ServerProcess, ServerRole, Validation, @@ -165,6 +166,12 @@ pub(crate) struct DebugAdapterDeclaration { /// What the Debug Console offers while typing a debugger command. #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) console: Option, + /// DAP extensions the adapter implements; `debugAdapters` 3. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) features: Option, + /// Values the adapter starts with when the user set none; `debugAdapters` 3. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) defaults: Option, pub(crate) launch: DebugLaunch, } @@ -625,16 +632,16 @@ impl DebugProcess { context.plugin_api_version, context.declared_settings, ServerRole::DebugAdapter, + context.runtime_tools, ), Self::SelectedRuntime(process) => { let tool = process.launch.tool.as_str(); - if !SELECTED_RUNTIME_TOOLS.contains(&tool) { - return Err(PluginRejection::at( - "plugin.runtime.unsupported", - format!("{field}.launch.tool"), - format!("The host cannot start a debug adapter on {tool}."), - )); - } + context.runtime_tools.check( + tool, + SELECTED_RUNTIME_TOOLS, + format!("{field}.launch.tool"), + format!("The host cannot start a debug adapter on {tool}."), + )?; validate_process_arguments( &process.arguments, &process.validation_arguments, @@ -660,6 +667,8 @@ pub(crate) struct DeclarationContext<'a> { pub(crate) declared_settings: &'a DeclaredSettings, /// Language IDs of the plugin's `languageServers[]`. pub(crate) language_servers: &'a BTreeSet, + /// Whether tool names are host runtimes or tool-definition IDs. + pub(crate) runtime_tools: crate::plugins::declarative::RuntimeTools<'a>, } /// Validates every declaration and sorts them by ID. @@ -704,6 +713,14 @@ pub(crate) fn uses_selected_runtime(adapters: &[DebugAdapterDeclaration]) -> boo }) } +/// Whether any adapter declares `features` or `defaults`, which need +/// `debugAdapters` 3. +pub(crate) fn uses_declared_features(adapters: &[DebugAdapterDeclaration]) -> bool { + adapters + .iter() + .any(|adapter| adapter.features.is_some() || adapter.defaults.is_some()) +} + /// Whether any adapter serves `attach`, which needs its own permission. pub(crate) fn serves_attach(adapters: &[DebugAdapterDeclaration]) -> bool { adapters @@ -780,6 +797,13 @@ impl DebugAdapterDeclaration { if let Some(console) = &mut self.console { console.validate(&format!("{field}.console"))?; } + let declared = DeclaredAdapterBehavior { + features: self.features.clone(), + defaults: self.defaults.clone(), + }; + declared.check().map_err(|(declared_field, message)| { + invalid(format!("{field}.{declared_field}"), message) + })?; self.launch.validate(&format!("{field}.launch"), context) } @@ -1867,7 +1891,7 @@ impl DebugAdapterDeclaration { /// What the host needs besides the process: how to reach the adapter and /// what to show. The process itself is the launch plan's own part. pub(crate) fn plan_descriptor(&self, plugin_id: &str) -> Value { - json!({ + let mut descriptor = json!({ "id": self.id, "providerID": format!("{plugin_id}/{}", self.id), "displayName": self.display_name, @@ -1885,7 +1909,16 @@ impl DebugAdapterDeclaration { "pathMappings": binding.path_mappings.is_some(), "maxPathMappings": binding.path_mappings.as_ref().map_or(0, DebugPathMappingBinding::limit), })), - }) + }); + // The host hands these to the debug engine with the session, which then follows the + // declaration instead of recognizing the adapter by its ID. + if let Some(features) = &self.features { + descriptor["features"] = json!(features); + } + if let Some(defaults) = &self.defaults { + descriptor["defaults"] = json!(defaults); + } + descriptor } } diff --git a/rust/lithe-core/src/debug/backend_start.rs b/rust/lithe-core/src/debug/backend_start.rs new file mode 100644 index 0000000000..0ca8b76de7 --- /dev/null +++ b/rust/lithe-core/src/debug/backend_start.rs @@ -0,0 +1,135 @@ +//! `lithe/host/debug/start`: a plugin backend starts a debug session on an adapter it declares. +//! +//! The backend supplies the adapter's DAP launch or attach arguments. Core checks that the +//! adapter belongs to the plugin and serves the request, that the arguments fit the size +//! budget, and that they do not name keys the host owns. Starting the adapter, the transport +//! and the session are the generic debug engine's job; nothing here is specific to a language. + +use super::adapters::DebugRequest; +use crate::plugins::declarative::{ + decode_payload, is_identifier, outcome, validate_manifest_value, DeclarativePluginManifest, + PluginRejection, Validation, +}; +use crate::protocol::CoreError; +use serde::Deserialize; +use serde_json::{json, Map, Value}; + +/// Serialized size of `configuration`, in bytes. +const MAX_CONFIGURATION_BYTES: usize = 256 * 1024; +const MAX_LABEL_CHARS: usize = 256; +const MAX_ADAPTER_ID_BYTES: usize = 64; +/// DAP launch argument the host sets from the session mode (Run versus Debug). +const NO_DEBUG_KEY: &str = "noDebug"; +/// Keys with this prefix carry the host's own session bookkeeping across DAP. +const HOST_KEY_PREFIX: &str = "__"; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct DecodePayload { + manifest: Value, + params: Value, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct StartParams { + /// A reference the host resolved before the service saw the request. + #[serde(default, rename = "context")] + _context: Option, + label: String, + adapter: String, + request: DebugRequest, + configuration: Map, +} + +fn invalid(field: &str, message: impl Into) -> PluginRejection { + PluginRejection::at("plugin.debug.invalidStart", field, message) +} + +/// `debug.decodeBackendConfiguration`: the qualified adapter, request, label and checked +/// arguments of one backend-initiated debug start. +pub(crate) fn decode_backend_configuration_command(payload: Value) -> Result { + let payload: DecodePayload = decode_payload(payload, "debug.decodeBackendConfiguration")?; + Ok(outcome( + validate_manifest_value(payload.manifest) + .and_then(|manifest| decode(&manifest, payload.params)) + .map(|start| ("start", start)), + )) +} + +fn decode(manifest: &DeclarativePluginManifest, params: Value) -> Validation { + let params: StartParams = serde_json::from_value(params) + .map_err(|_| invalid("params", "The debug start parameters are malformed."))?; + let label = ¶ms.label; + if label.trim().is_empty() + || label.chars().count() > MAX_LABEL_CHARS + || label.chars().any(char::is_control) + { + return Err(invalid( + "label", + "A label is 1 to 256 characters without control characters.", + )); + } + let local = own_adapter(manifest, ¶ms.adapter)?; + let adapter = manifest + .debug_adapters() + .iter() + .find(|adapter| adapter.id == local) + .ok_or_else(|| { + PluginRejection::at( + "plugin.debug.unknownAdapter", + "adapter", + "The plugin declares no such debug adapter.", + ) + })?; + if !adapter.requests.contains(¶ms.request) { + return Err(invalid( + "request", + "The adapter does not serve this request.", + )); + } + if serde_json::to_vec(¶ms.configuration).map_or(usize::MAX, |bytes| bytes.len()) + > MAX_CONFIGURATION_BYTES + { + return Err(PluginRejection::at( + "plugin.debug.limitExceeded", + "configuration", + "The configuration is larger than 256 KiB.", + )); + } + if let Some(key) = params + .configuration + .keys() + .find(|key| key.as_str() == NO_DEBUG_KEY || key.starts_with(HOST_KEY_PREFIX)) + { + return Err(invalid( + &format!("configuration.{key}"), + "The key belongs to the host and cannot be set by a plugin.", + )); + } + Ok(json!({ + "adapterID": format!("{}/{}", manifest.id(), adapter.id), + "request": params.request, + "label": params.label, + "configuration": params.configuration, + })) +} + +/// The plugin-local adapter ID of `adapter`, which is either local or qualified by this plugin. +fn own_adapter<'a>(manifest: &DeclarativePluginManifest, adapter: &'a str) -> Validation<&'a str> { + let local = match adapter.split_once('/') { + Some((plugin, _)) if plugin != manifest.id() => { + return Err(PluginRejection::at( + "plugin.debug.foreignAdapter", + "adapter", + "A plugin starts only the debug adapters it declares itself.", + )); + } + Some((_, local)) => local, + None => adapter, + }; + if !is_identifier(local) || local.len() > MAX_ADAPTER_ID_BYTES { + return Err(invalid("adapter", "The adapter ID is malformed.")); + } + Ok(local) +} diff --git a/rust/lithe-core/src/debug/engine.rs b/rust/lithe-core/src/debug/engine.rs index 2792077ed7..0103ed748b 100644 --- a/rust/lithe-core/src/debug/engine.rs +++ b/rust/lithe-core/src/debug/engine.rs @@ -63,6 +63,13 @@ struct DebugSession { capability_states: BTreeMap, capability_revision: u64, stepping_filters: DebugSteppingFilters, + /// DAP extensions the adapter declared; empty for an undeclared adapter. + features: DebugAdapterFeatures, + /// Declared filters used when a launch names none. + default_stepping_filters: Option, + /// Neither features nor defaults were declared, so the engine keeps + /// recognizing adapters it knew before `debugAdapters` 3 by their ID. + undeclared: bool, debug_request_kind: Option, pending_launch: Option<(String, DebugLaunchConfiguration)>, outbound_frames: Vec>, @@ -122,6 +129,12 @@ pub(crate) fn create_session( validate_identifier(&request.adapter_id, "adapterId")?; validate_path(&request.root_path, "rootPath")?; let disabled_capabilities = validate_disabled(&request.disabled_capabilities)?; + request + .declared + .check() + .map_err(|(field, message)| invalid_request(message).with_details(field.to_string()))?; + let undeclared = request.declared.is_empty(); + let DeclaredAdapterBehavior { features, defaults } = request.declared; let mut sessions = sessions_lock()?; if sessions.contains_key(&request.session_id) { return Err(invalid_request( @@ -179,6 +192,11 @@ pub(crate) fn create_session( capability_states: BTreeMap::new(), capability_revision: 0, stepping_filters: DebugSteppingFilters::unfiltered(), + features: features.unwrap_or_default(), + default_stepping_filters: defaults + .and_then(|defaults| defaults.step_filters) + .map(|filters| DebugSteppingFilters::from(&filters)), + undeclared, debug_request_kind: None, pending_launch: None, outbound_frames: Vec::new(), @@ -776,9 +794,12 @@ pub(crate) fn inspect(request: InspectRequest) -> Result Result feature.request.clone(), + (kind, _) => kind.command().to_string(), + }; session.send_request( - request.kind.command(), + &command, Value::Object(arguments), PendingRequest::Inspect { operation_id: request.operation_id, @@ -1021,15 +1046,30 @@ impl DebugSession { ) -> Result<(), CoreError> { let request_kind = configuration.request; let mut arguments = configuration.arguments; + let legacy_java = self.undeclared && self.adapter_id == "java"; let filters = normalize_stepping_filters( configuration .stepping_filters - .unwrap_or_else(|| DebugSteppingFilters::defaults_for_adapter(&self.adapter_id)), + .or_else(|| self.default_stepping_filters.clone()) + .unwrap_or_else(|| { + if self.undeclared { + DebugSteppingFilters::defaults_for_adapter(&self.adapter_id) + } else { + DebugSteppingFilters::unfiltered() + } + }), )?; - if self.adapter_id == "java" && !arguments.contains_key("stepFilters") { - arguments.insert("stepFilters".to_string(), java_step_filters(&filters)); + let filter_argument = match &self.features.step_filters { + Some(feature) => Some(feature.argument.clone()), + None if legacy_java => Some("stepFilters".to_string()), + None => None, + }; + if let Some(name) = filter_argument { + if !arguments.contains_key(&name) { + arguments.insert(name, step_filter_argument(&filters)); + } } - if self.adapter_id == "java" && request_kind == DebugRequestKind::Launch { + if legacy_java && request_kind == DebugRequestKind::Launch { // A DevTools classloader restart would discard the debugger's hot-replacement target. let option = super::JAVA_DEBUG_DISABLE_DEVTOOLS_RESTART; let vm_args = arguments.entry("vmArgs").or_insert_with(|| json!("")); @@ -1461,8 +1501,12 @@ impl DebugSession { frame_id, } => { self.emit_inspection_projection(kind, thread_id, frame_id, &body); - let result = - normalize_inspection(kind, &body, &self.stepping_filters, &self.root_path); + let result = match (kind, &self.features.hot_code_replace) { + (DebugInspectKind::RedefineClasses, Some(feature)) => { + declared_replacement(feature, &body) + } + _ => normalize_inspection(kind, &body, &self.stepping_filters, &self.root_path), + }; // HCR rejection and unreadable memory are operation failures, not a broken // transport/session. if matches!( @@ -1602,6 +1646,26 @@ impl DebugSession { self.emit(DebugEventBody::Breakpoint { breakpoint }); } } + declared + if self + .features + .hot_code_replace + .as_ref() + .and_then(|feature| feature.event.as_deref()) + == Some(declared) => + { + // The declared progress event shows its message in the Debug Console. + if let Some(message) = body + .get("message") + .and_then(Value::as_str) + .filter(|message| !message.is_empty()) + { + self.emit(DebugEventBody::Output { + category: Some("console".to_string()), + output: format!("{message}\n"), + }); + } + } _ => {} } Ok(()) @@ -2485,7 +2549,46 @@ fn normalize_stepping_filters( Ok(filters) } -fn java_step_filters(filters: &DebugSteppingFilters) -> Value { +/// Reads a declared hot code replacement reply through the fields the +/// adapter named for its failure message and its replaced units. +fn declared_replacement( + feature: &HotCodeReplaceFeature, + body: &Value, +) -> Result { + if let Some(message) = feature + .error_field + .as_deref() + .and_then(|field| body.get(field)) + .and_then(Value::as_str) + .filter(|message| !message.is_empty()) + { + return Err(invalid_request(message)); + } + let mut changed = match feature.changed_field.as_deref() { + None => Vec::new(), + Some(field) => body + .get(field) + .and_then(Value::as_array) + .ok_or_else(|| invalid_request("The hot code replacement reply has no change list."))? + .iter() + .map(|value| { + value + .as_str() + .map(str::to_owned) + .ok_or_else(|| invalid_request("Invalid replaced unit name.")) + }) + .collect::, _>>()?, + }; + changed.sort(); + changed.dedup(); + Ok(DebugOperationResult::RedefineClasses { + changed_classes: changed, + }) +} + +/// The stepping filters in the launch-argument shape adapters that take +/// them as one argument read. +fn step_filter_argument(filters: &DebugSteppingFilters) -> Value { json!({ "skipClasses": filters.class_name_filters, "skipSynthetics": filters.skip_synthetics, @@ -3252,6 +3355,7 @@ mod tests { supports_start_debugging_request: false, disabled_capabilities: Vec::new(), parent_session_id: None, + declared: Default::default(), }) .unwrap(); // The guard always removes the process-free session, including on assertion failure. @@ -3416,6 +3520,7 @@ mod tests { supports_start_debugging_request: false, disabled_capabilities: Vec::new(), parent_session_id: None, + declared: Default::default(), }) .unwrap(); @@ -3606,6 +3711,7 @@ mod tests { supports_start_debugging_request: false, disabled_capabilities: Vec::new(), parent_session_id: None, + declared: Default::default(), }) .unwrap(); launch(LaunchRequest { @@ -3750,6 +3856,7 @@ mod tests { supports_start_debugging_request: false, disabled_capabilities: Vec::new(), parent_session_id: None, + declared: Default::default(), }) .unwrap(); assert_eq!(created.state, DebugSessionState::Initializing); @@ -4041,6 +4148,7 @@ mod tests { supports_start_debugging_request: false, disabled_capabilities: Vec::new(), parent_session_id: None, + declared: Default::default(), }) .unwrap(); // This reducer owns no processes; always destroy its registry entry below. @@ -4134,6 +4242,7 @@ mod tests { supports_start_debugging_request: true, disabled_capabilities: Vec::new(), parent_session_id: None, + declared: Default::default(), }) .unwrap(); // This reducer owns no processes; always destroy its registry entry below. @@ -4204,6 +4313,7 @@ mod tests { supports_start_debugging_request: supported, disabled_capabilities: Vec::new(), parent_session_id: None, + declared: Default::default(), }) .unwrap(); let update = receive_messages(session_id, vec![fixture["adapterRequest"].clone()]); @@ -4256,6 +4366,7 @@ mod tests { supports_start_debugging_request: true, disabled_capabilities: Vec::new(), parent_session_id: None, + declared: Default::default(), }) .unwrap(); receive_messages(session_id, vec![fixture["adapterRequest"].clone()]); @@ -4289,6 +4400,7 @@ mod tests { supports_start_debugging_request: false, disabled_capabilities: Vec::new(), parent_session_id: None, + declared: Default::default(), }) .unwrap(); // This reducer owns no processes; always destroy its registry entry below. @@ -4405,6 +4517,7 @@ mod tests { supports_start_debugging_request: false, disabled_capabilities: Vec::new(), parent_session_id: None, + declared: Default::default(), }) .unwrap(); set_data_breakpoints(SetDataBreakpointsRequest { @@ -4529,6 +4642,7 @@ mod tests { supports_start_debugging_request: false, disabled_capabilities: Vec::new(), parent_session_id: None, + declared: Default::default(), }) .unwrap(); launch(LaunchRequest { @@ -4660,6 +4774,7 @@ mod tests { supports_start_debugging_request: false, disabled_capabilities: Vec::new(), parent_session_id: None, + declared: Default::default(), }) .unwrap(); launch(LaunchRequest { @@ -4721,6 +4836,7 @@ mod tests { supports_start_debugging_request: false, disabled_capabilities: Vec::new(), parent_session_id: None, + declared: Default::default(), }) .unwrap(); launch(LaunchRequest { @@ -4852,6 +4968,7 @@ mod tests { supports_start_debugging_request: false, disabled_capabilities: Vec::new(), parent_session_id: None, + declared: Default::default(), }) .unwrap(); launch(LaunchRequest { @@ -4955,6 +5072,7 @@ mod tests { supports_start_debugging_request: false, disabled_capabilities: Vec::new(), parent_session_id: None, + declared: Default::default(), }) .unwrap(); launch(LaunchRequest { @@ -5230,6 +5348,7 @@ mod tests { supports_start_debugging_request: false, disabled_capabilities: Vec::new(), parent_session_id: None, + declared: Default::default(), }) .unwrap(); launch(LaunchRequest { @@ -5342,6 +5461,7 @@ mod tests { supports_start_debugging_request: false, disabled_capabilities: Vec::new(), parent_session_id: None, + declared: Default::default(), }) .unwrap(); launch(LaunchRequest { @@ -5441,6 +5561,7 @@ mod tests { supports_start_debugging_request: false, disabled_capabilities: Vec::new(), parent_session_id: None, + declared: Default::default(), }) .unwrap(); launch(LaunchRequest { @@ -5514,6 +5635,7 @@ mod tests { supports_start_debugging_request: false, disabled_capabilities: Vec::new(), parent_session_id: None, + declared: Default::default(), }) .unwrap(); assert_eq!( @@ -5597,6 +5719,7 @@ mod tests { supports_start_debugging_request: false, disabled_capabilities: Vec::new(), parent_session_id: None, + declared: Default::default(), }) .unwrap(); @@ -5633,6 +5756,7 @@ mod tests { supports_start_debugging_request: false, disabled_capabilities: Vec::new(), parent_session_id: None, + declared: Default::default(), }) .unwrap(); @@ -5676,6 +5800,7 @@ mod tests { supports_start_debugging_request: false, disabled_capabilities: vec!["supportsRestartRequest".to_string()], parent_session_id: None, + declared: Default::default(), }) .unwrap(); launch(LaunchRequest { @@ -5734,6 +5859,7 @@ mod tests { supports_start_debugging_request: false, disabled_capabilities: Vec::new(), parent_session_id: parent.map(str::to_string), + declared: Default::default(), } } @@ -5885,6 +6011,7 @@ mod tests { supports_start_debugging_request: false, disabled_capabilities: Vec::new(), parent_session_id: None, + declared: Default::default(), }) .unwrap(); launch(LaunchRequest { @@ -6414,6 +6541,7 @@ mod tests { supports_start_debugging_request: false, disabled_capabilities: Vec::new(), parent_session_id: None, + declared: Default::default(), }) .unwrap(); launch(LaunchRequest { @@ -6743,4 +6871,241 @@ mod tests { ))); assert_eq!(session_state(session_id), DebugSessionState::Paused); } + + /// A scripting-language adapter that declares the DAP extensions it implements. + fn scripting_adapter() -> DeclaredAdapterBehavior { + serde_json::from_value(json!({ + "features": { + "hotCodeReplace": { + "request": "script.reload", + "event": "script.reloaded", + "changedField": "modules", + "errorField": "error" + }, + "stepFilters": {"argument": "skipFilters"} + }, + "defaults": { + "stepFilters": { + "classNameFilters": ["site-packages/*"], + "skipSynthetics": true + } + } + })) + .unwrap() + } + + /// Creates a session, answers `initialize`, and launches with `arguments`. + /// Returns the launch request the adapter received. + fn launch_session( + session_id: &str, + adapter_id: &str, + declared: DeclaredAdapterBehavior, + arguments: Value, + ) -> Value { + create_session(CreateSessionRequest { + session_id: session_id.into(), + adapter_id: adapter_id.into(), + root_path: "/workspace".into(), + supports_run_in_terminal_request: false, + supports_start_debugging_request: false, + disabled_capabilities: Vec::new(), + parent_session_id: None, + declared, + }) + .unwrap(); + receive_messages( + session_id, + vec![response_message(1, "initialize", json!({}))], + ); + let launched = launch(LaunchRequest { + session_id: session_id.into(), + operation_id: "launch".into(), + configuration: DebugLaunchConfiguration { + name: "script".into(), + request: DebugRequestKind::Launch, + arguments: serde_json::from_value(arguments).unwrap(), + stepping_filters: None, + }, + }) + .unwrap(); + decode_frame(&launched.outbound_frames[0]) + } + + /// Answers the launch request and stops the debuggee at a breakpoint. + fn pause_session(session_id: &str, launch_frame: &Value) { + receive_messages( + session_id, + vec![ + response_message(launch_frame["seq"].as_i64().unwrap(), "launch", json!({})), + json!({"type": "event", "event": "stopped", + "body": {"reason": "breakpoint", "threadId": 1}}), + ], + ); + } + + fn redefine_request(session_id: &str, operation_id: &str) -> InspectRequest { + serde_json::from_value(json!({ + "sessionId": session_id, "operationId": operation_id, "kind": "redefineClasses" + })) + .unwrap() + } + + #[test] + fn declared_hot_code_replace_sends_the_declared_request_and_reads_its_fields() { + let session_id = "debug-declared-hot-replace"; + let _guard = SessionGuard(session_id); + let launched = launch_session(session_id, "debugpy", scripting_adapter(), json!({})); + pause_session(session_id, &launched); + + let update = inspect(redefine_request(session_id, "apply")).unwrap(); + let frame = decode_frame(&update.outbound_frames[0]); + assert_eq!(frame["command"], "script.reload"); + let replaced = receive_messages( + session_id, + vec![response_message( + frame["seq"].as_i64().unwrap(), + "script.reload", + json!({"modules": ["pkg.util", "pkg.app", "pkg.util"]}), + )], + ); + assert_eq!(replaced.state, DebugSessionState::Paused); + assert!(replaced.events.iter().any(|event| matches!( + &event.body, + DebugEventBody::OperationCompleted { + operation_id, + result: DebugOperationResult::RedefineClasses { changed_classes }, + } if operation_id == "apply" + && changed_classes == &["pkg.app".to_string(), "pkg.util".to_string()] + ))); + + // A failure the adapter reports inside a successful response fails the + // operation without breaking the session. + let update = inspect(redefine_request(session_id, "rejected")).unwrap(); + let rejected = receive_messages( + session_id, + vec![response_message( + request_sequence(&update), + "script.reload", + json!({"modules": [], "error": "Syntax error in pkg.app"}), + )], + ); + assert_eq!(rejected.state, DebugSessionState::Paused); + assert!(rejected.events.iter().any(|event| matches!( + &event.body, + DebugEventBody::OperationFailed { operation_id, message, .. } + if operation_id == "rejected" && message == "Syntax error in pkg.app" + ))); + } + + #[test] + fn declared_hot_code_replace_event_message_reaches_the_debug_console() { + let session_id = "debug-declared-hot-replace-event"; + let _guard = SessionGuard(session_id); + let launched = launch_session(session_id, "debugpy", scripting_adapter(), json!({})); + pause_session(session_id, &launched); + + let update = receive_messages( + session_id, + vec![json!({"type": "event", "event": "script.reloaded", + "body": {"message": "Reloaded 2 modules"}})], + ); + assert!(update.events.iter().any(|event| matches!( + &event.body, + DebugEventBody::Output { category: Some(category), output } + if category == "console" && output == "Reloaded 2 modules\n" + ))); + } + + #[test] + fn an_adapter_without_the_declaration_has_no_hot_code_replace() { + let session_id = "debug-undeclared-hot-replace"; + let _guard = SessionGuard(session_id); + let launched = launch_session( + session_id, + "debugpy", + DeclaredAdapterBehavior::default(), + json!({}), + ); + pause_session(session_id, &launched); + + assert!(inspect(redefine_request(session_id, "apply")).is_err()); + } + + #[test] + fn declared_step_filters_use_the_declared_argument_and_defaults() { + let session_id = "debug-declared-step-filters"; + let _guard = SessionGuard(session_id); + let launched = launch_session(session_id, "debugpy", scripting_adapter(), json!({})); + + let filters = &launched["arguments"]["skipFilters"]; + assert_eq!(filters["skipClasses"], json!(["site-packages/*"])); + assert_eq!(filters["skipSynthetics"], true); + assert_eq!(filters["skipConstructors"], false); + // The declaration names the argument; no other argument is invented. + assert!(launched["arguments"].get("stepFilters").is_none()); + assert!(launched["arguments"].get("vmArgs").is_none()); + } + + #[test] + fn declared_step_filters_keep_what_the_launch_configuration_sets() { + let session_id = "debug-declared-step-filters-explicit"; + let _guard = SessionGuard(session_id); + let launched = launch_session( + session_id, + "debugpy", + scripting_adapter(), + json!({"skipFilters": {"skipClasses": ["mine/*"]}}), + ); + + assert_eq!( + launched["arguments"]["skipFilters"], + json!({"skipClasses": ["mine/*"]}) + ); + } + + #[test] + fn a_declared_adapter_is_not_recognized_by_its_id() { + // Declaring behavior replaces the ID-based special cases kept for + // plugins that predate `debugAdapters` 3. + let session_id = "debug-declared-not-by-id"; + let _guard = SessionGuard(session_id); + let declared = serde_json::from_value(json!({ + "features": {"stepFilters": {"argument": "skipFilters"}} + })) + .unwrap(); + let launched = launch_session(session_id, "java", declared, json!({})); + + assert!(launched["arguments"].get("stepFilters").is_none()); + assert!(launched["arguments"].get("vmArgs").is_none()); + assert_eq!( + launched["arguments"]["skipFilters"]["skipClasses"], + json!([]) + ); + pause_session(session_id, &launched); + assert!(inspect(redefine_request(session_id, "apply")).is_err()); + } + + #[test] + fn invalid_declarations_are_refused_when_the_session_is_created() { + let declared = serde_json::from_value(json!({ + "features": {"hotCodeReplace": {"request": "not a command"}} + })) + .unwrap(); + let error = create_session(CreateSessionRequest { + session_id: "debug-invalid-declaration".into(), + adapter_id: "debugpy".into(), + root_path: "/workspace".into(), + supports_run_in_terminal_request: false, + supports_start_debugging_request: false, + disabled_capabilities: Vec::new(), + parent_session_id: None, + declared, + }) + .unwrap_err(); + assert!(matches!(error.code, ErrorCode::InvalidRequest)); + assert_eq!( + error.details.as_deref(), + Some("features.hotCodeReplace.request") + ); + } } diff --git a/rust/lithe-core/src/debug/mod.rs b/rust/lithe-core/src/debug/mod.rs index f8c774d61e..8748320896 100644 --- a/rust/lithe-core/src/debug/mod.rs +++ b/rust/lithe-core/src/debug/mod.rs @@ -1,6 +1,7 @@ //! Transport-neutral Debug Adapter Protocol state and normalized debugger models. pub(crate) mod adapters; +mod backend_start; mod breakpoint_relocation; mod capabilities; mod endpoint; @@ -12,6 +13,7 @@ mod shortcut; mod transport_lifecycle; mod types; +pub(crate) use backend_start::decode_backend_configuration_command; pub(crate) use breakpoint_relocation::*; pub(crate) use endpoint::*; pub(crate) use engine::*; diff --git a/rust/lithe-core/src/debug/types.rs b/rust/lithe-core/src/debug/types.rs index cb901c4786..09d1bd1399 100644 --- a/rust/lithe-core/src/debug/types.rs +++ b/rust/lithe-core/src/debug/types.rs @@ -38,6 +38,174 @@ pub struct CreateSessionRequest { /// The session whose adapter asked for this one through `startDebugging`. #[serde(default)] pub parent_session_id: Option, + /// What the plugin declared for its adapter, passed through by the host. + #[serde(flatten)] + pub declared: DeclaredAdapterBehavior, +} + +/// The `features` and `defaults` of a `debugAdapters` 3 declaration. The +/// engine follows them instead of recognizing an adapter by its ID. +#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DeclaredAdapterBehavior { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub features: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub defaults: Option, +} + +/// Adapter-specific DAP extensions the adapter declares it implements. +#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct DebugAdapterFeatures { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hot_code_replace: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub step_filters: Option, +} + +/// A custom request that applies changed code to the running debuggee. +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct HotCodeReplaceFeature { + /// DAP command the engine sends for a `redefineClasses` inspection. + pub request: String, + /// DAP event whose `message` the Debug Console shows as progress. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub event: Option, + /// Response body field listing the replaced units; none are reported + /// when absent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub changed_field: Option, + /// Response body field with a failure message the adapter reports in a + /// successful response. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error_field: Option, +} + +/// The launch argument through which the adapter receives stepping filters, +/// shaped `{skipClasses, skipSynthetics, skipStaticInitializers, skipConstructors}`. +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct StepFiltersFeature { + pub argument: String, +} + +/// Values the adapter starts with when the user configured none. +#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct DebugAdapterDefaults { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub step_filters: Option, +} + +/// Declared default stepping filters; the same fields as +/// `DebugSteppingFilters`, closed to unknown keys. +#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct DeclaredStepFilters { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub class_name_filters: Vec, + #[serde(default)] + pub skip_synthetics: bool, + #[serde(default)] + pub skip_static_initializers: bool, + #[serde(default)] + pub skip_constructors: bool, + #[serde(default)] + pub hide_filtered_stack_frames: bool, +} + +/// Longest DAP command, event, field, or argument name an adapter declares. +const MAX_DECLARED_NAME: usize = 64; +/// Most class-name patterns declared default filters carry. +const MAX_DECLARED_FILTERS: usize = 256; +/// Longest declared class-name pattern, in characters. +const MAX_DECLARED_FILTER: usize = 256; + +fn is_declared_name(value: &str) -> bool { + let mut chars = value.chars(); + value.len() <= MAX_DECLARED_NAME + && chars + .next() + .is_some_and(|first| first.is_ascii_alphabetic()) + && chars.all(|next| next.is_ascii_alphanumeric() || matches!(next, '_' | '.' | '-')) +} + +impl DeclaredAdapterBehavior { + /// Whether the adapter declared nothing, so the engine keeps the + /// behaviour it had before `debugAdapters` 3. + pub(crate) fn is_empty(&self) -> bool { + self.features.is_none() && self.defaults.is_none() + } + + /// Checks names and filters; an error names the declaration field and + /// what is wrong with it. + pub(crate) fn check(&self) -> Result<(), (&'static str, &'static str)> { + const NAME: &str = + "A DAP name is a letter followed by up to 63 letters, digits, dots, underscores, or hyphens."; + let features = self.features.as_ref(); + if let Some(feature) = features.and_then(|features| features.hot_code_replace.as_ref()) { + if !is_declared_name(&feature.request) { + return Err(("features.hotCodeReplace.request", NAME)); + } + for (field, value) in [ + ("features.hotCodeReplace.event", &feature.event), + ( + "features.hotCodeReplace.changedField", + &feature.changed_field, + ), + ("features.hotCodeReplace.errorField", &feature.error_field), + ] { + if value + .as_deref() + .is_some_and(|value| !is_declared_name(value)) + { + return Err((field, NAME)); + } + } + } + if let Some(feature) = features.and_then(|features| features.step_filters.as_ref()) { + if !is_declared_name(&feature.argument) { + return Err(("features.stepFilters.argument", NAME)); + } + } + if let Some(filters) = self + .defaults + .as_ref() + .and_then(|defaults| defaults.step_filters.as_ref()) + { + if filters.class_name_filters.len() > MAX_DECLARED_FILTERS { + return Err(( + "defaults.stepFilters.classNameFilters", + "Default stepping filters list at most 256 class-name patterns.", + )); + } + if filters.class_name_filters.iter().any(|filter| { + filter.trim().is_empty() + || filter.chars().count() > MAX_DECLARED_FILTER + || filter.chars().any(char::is_control) + }) { + return Err(( + "defaults.stepFilters.classNameFilters", + "A class-name pattern is one non-empty line of at most 256 characters.", + )); + } + } + Ok(()) + } +} + +impl From<&DeclaredStepFilters> for DebugSteppingFilters { + fn from(declared: &DeclaredStepFilters) -> Self { + Self { + class_name_filters: declared.class_name_filters.clone(), + skip_synthetics: declared.skip_synthetics, + skip_static_initializers: declared.skip_static_initializers, + skip_constructors: declared.skip_constructors, + hide_filtered_stack_frames: declared.hide_filtered_stack_frames, + } + } } #[derive(Debug, Clone, Deserialize)] diff --git a/rust/lithe-core/src/lsp/interface/mod.rs b/rust/lithe-core/src/lsp/interface/mod.rs index 3998cf31d3..b60881ff78 100644 --- a/rust/lithe-core/src/lsp/interface/mod.rs +++ b/rust/lithe-core/src/lsp/interface/mod.rs @@ -2,6 +2,8 @@ mod build_provider; mod client; +/// Decodes the path of a `file:` URI an extension reply names. +pub(crate) use client::percent_decode; mod document_uri; mod engine; mod engine_logging; diff --git a/rust/lithe-core/src/lsp/interface/plugin_function.rs b/rust/lithe-core/src/lsp/interface/plugin_function.rs index c8641e4677..86ef64a65a 100644 --- a/rust/lithe-core/src/lsp/interface/plugin_function.rs +++ b/rust/lithe-core/src/lsp/interface/plugin_function.rs @@ -122,6 +122,8 @@ pub(crate) const HOST_PERMISSIONS: &[&str] = &[ "window.messages", "terminal.create", "keymaps.readSelectedSources", + "run.start", + "debug.start", PERMISSION_LANGUAGE_SERVER_REQUEST, PERMISSION_DOCUMENTS_CONTENT_PROVIDER, ]; @@ -230,6 +232,9 @@ pub(crate) mod codes { /// The plugin made too many requests of this kind recently; the same /// request may succeed after a while. pub const HOST_RATE_LIMITED: &str = "plugin.host.rateLimited"; + /// A tool definition the request references resolves to no executable + /// for this workspace; `details` carries `{tool, reason}`. + pub const HOST_TOOL_UNRESOLVED: &str = "plugin.host.toolUnresolved"; /// The language server a `languageServer` request names is not running /// in the context: it is starting, stopped, crashed, or not started. pub const HOST_LANGUAGE_SERVER_UNAVAILABLE: &str = "plugin.host.languageServerUnavailable"; @@ -594,6 +599,27 @@ pub(crate) struct HostMethod { /// service adds its rows here, its grant to `GRANTABLE_HOST_SERVICES` and /// the manifest permission, and its handler to the host's dispatcher. pub(crate) const HOST_METHODS: &[HostMethod] = &[ + HostMethod { + method: RUN_START, + service: HOST_SERVICE_RUN, + permission: Some("run.start"), + since: CONTEXT_VERSION, + params: HostParams::Object, + }, + HostMethod { + method: "lithe/host/run/stop", + service: HOST_SERVICE_RUN, + permission: Some("run.start"), + since: CONTEXT_VERSION, + params: HostParams::Object, + }, + HostMethod { + method: "lithe/host/debug/start", + service: "debug", + permission: Some("debug.start"), + since: CONTEXT_VERSION, + params: HostParams::Object, + }, HostMethod { method: FUNCTIONS_PROVIDERS, service: HOST_SERVICE_FUNCTIONS, @@ -1040,10 +1066,10 @@ pub(crate) fn capabilities_answer(version: u64, granted: &BTreeSet) -> V }) { add(entry.service, "methods", entry.method); } - for entry in HOST_NOTIFICATIONS.iter().filter(|entry| { - version >= entry.since - && (is_implicit_service(entry.service) || holds_service(entry.service, granted)) - }) { + for entry in HOST_NOTIFICATIONS + .iter() + .filter(|entry| version >= entry.since && entry.admitted(granted)) + { add(entry.service, "notifications", entry.method); } json!({ @@ -1076,6 +1102,9 @@ pub(crate) enum NotificationParams { /// `{ commandID, context? }`: one of the plugin's own commands and the /// workspace context of the window it ran in. Command, + /// `{ runID, exitCode?, signal? }`: a Run session the backend started + /// ended with an exit status, a signal, or neither when the host lost it. + RunExit, /// `{ context, serverID, state, generation }`: a lifecycle change of one /// of the plugin's language servers. LanguageServerState, @@ -1089,13 +1118,33 @@ pub(crate) struct HostNotification { pub method: &'static str, /// Service whose grant admits sending it; implicit services need none. pub service: &'static str, + /// Host method whose individual grant admits it instead of `service`, + /// for a notification that reports on a method granted per permission. + pub grant: Option<&'static str>, pub since: u64, pub params: NotificationParams, } +impl HostNotification { + /// Whether the session's grants admit sending this notification. + fn admitted(&self, granted: &BTreeSet) -> bool { + match self.grant { + Some(method) => granted.contains(method), + None => is_implicit_service(self.service) || holds_service(self.service, granted), + } + } +} + const WORKSPACE_DID_CHANGE_CONTEXTS: &str = "lithe/host/workspace/didChangeContexts"; const SUBSCRIPTIONS_NOTIFY: &str = "lithe/host/subscriptions/notify"; const COMMANDS_DID_RUN: &str = "lithe/host/commands/didRun"; +/// Starts a host-owned Run session; its grant also admits `RUN_DID_EXIT`. +const RUN_START: &str = "lithe/host/run/start"; +const RUN_DID_EXIT: &str = "lithe/host/run/didExit"; +/// Host-owned Run sessions a backend starts and stops. +const HOST_SERVICE_RUN: &str = "run"; +/// Longest opaque run ID a host may hand out. +const MAX_RUN_ID_BYTES: usize = 128; /// The plugin's own UI commands reaching its backend. Every plugin-owned /// session of version 3 has it; a command names only the plugin's own IDs. const HOST_SERVICE_COMMANDS: &str = "commands"; @@ -1108,30 +1157,42 @@ pub(crate) const HOST_NOTIFICATIONS: &[HostNotification] = &[ HostNotification { method: WORKSPACE_DID_CHANGE_CONTEXTS, service: HOST_SERVICE_WORKSPACE, + grant: None, since: CONTEXT_VERSION, params: NotificationParams::Revision, }, HostNotification { method: SUBSCRIPTIONS_NOTIFY, service: HOST_SERVICE_SUBSCRIPTIONS, + grant: None, since: CONTEXT_VERSION, params: NotificationParams::SubscriptionEvent, }, HostNotification { method: COMMANDS_DID_RUN, service: HOST_SERVICE_COMMANDS, + grant: None, since: CONTEXT_VERSION, params: NotificationParams::Command, }, + HostNotification { + method: RUN_DID_EXIT, + service: HOST_SERVICE_RUN, + grant: Some(RUN_START), + since: CONTEXT_VERSION, + params: NotificationParams::RunExit, + }, HostNotification { method: LANGUAGE_SERVER_DID_CHANGE_STATE, service: HOST_SERVICE_LANGUAGE_SERVER, + grant: None, since: CONTEXT_VERSION, params: NotificationParams::LanguageServerState, }, HostNotification { method: LANGUAGE_SERVER_DID_RECEIVE_NOTIFICATION, service: HOST_SERVICE_LANGUAGE_SERVER, + grant: None, since: CONTEXT_VERSION, params: NotificationParams::LanguageServerNotification, }, @@ -1178,7 +1239,7 @@ pub(crate) fn host_notification( codes::HOST_UNAVAILABLE, )); }; - if !is_implicit_service(entry.service) && !holds_service(entry.service, granted) { + if !entry.admitted(granted) { return Err(refusal( ErrorCode::InvalidRequest, "The session was not granted the service of this notification.", @@ -1219,6 +1280,22 @@ pub(crate) fn host_notification( .is_some_and(is_command_id) && object.get("context").map_or(true, is_context_reference) } + NotificationParams::RunExit => { + only(&["runID", "exitCode", "signal"]) + && object + .get("runID") + .and_then(Value::as_str) + .is_some_and(|id| id.len() <= MAX_RUN_ID_BYTES && is_credential_key(id)) + && object.get("exitCode").map_or(true, |code| { + code.as_i64() + .is_some_and(|code| i32::try_from(code).is_ok()) + }) + && object.get("signal").map_or(true, |signal| { + signal + .as_u64() + .is_some_and(|signal| (1..=127).contains(&signal)) + }) + } NotificationParams::LanguageServerState => { only(&["context", "serverID", "state", "generation"]) && object.get("context").is_some_and(is_context_reference) @@ -1719,6 +1796,7 @@ pub(crate) fn host_rpc_code(code: &str) -> i64 { codes::HOST_LANGUAGE_SERVER_UNAVAILABLE => -32022, codes::HOST_LANGUAGE_SERVER_ERROR => -32023, codes::HOST_METHOD_NOT_DECLARED => -32024, + codes::HOST_TOOL_UNRESOLVED => -32025, codes::HOST_FOREIGN_CONTRIBUTION => -32026, codes::NOT_DECLARED => -32027, codes::NOT_EXPORTED => -32028, @@ -1775,6 +1853,7 @@ pub(crate) fn host_failure(code: Option<&str>) -> (&'static str, i64) { Some(codes::HOST_LANGUAGE_SERVER_UNAVAILABLE) => codes::HOST_LANGUAGE_SERVER_UNAVAILABLE, Some(codes::HOST_LANGUAGE_SERVER_ERROR) => codes::HOST_LANGUAGE_SERVER_ERROR, Some(codes::HOST_METHOD_NOT_DECLARED) => codes::HOST_METHOD_NOT_DECLARED, + Some(codes::HOST_TOOL_UNRESOLVED) => codes::HOST_TOOL_UNRESOLVED, Some(codes::HOST_FOREIGN_CONTRIBUTION) => codes::HOST_FOREIGN_CONTRIBUTION, // A backend exit is reported to pages, never to the backend itself. _ => codes::HOST_FAILED, diff --git a/rust/lithe-core/src/plugins/backend.rs b/rust/lithe-core/src/plugins/backend.rs index 1d768b7bd6..144b6b8996 100644 --- a/rust/lithe-core/src/plugins/backend.rs +++ b/rust/lithe-core/src/plugins/backend.rs @@ -68,6 +68,8 @@ pub(crate) struct DeclarationContext<'a> { pub(crate) activates_on_workspace_open: bool, /// A provider capability may start this backend for a host-owned query. pub(crate) provider_query_trigger: bool, + /// Whether tool names are host runtimes or tool-definition IDs. + pub(crate) runtime_tools: crate::plugins::declarative::RuntimeTools<'a>, } impl BackendDeclaration { @@ -103,6 +105,7 @@ impl BackendDeclaration { context.plugin_api_version, context.declared_settings, ServerRole::Backend, + context.runtime_tools, )?; // One process serves every workspace, so a launch argument cannot // name one of them; version 2 receives the directory per context. diff --git a/rust/lithe-core/src/plugins/capabilities.rs b/rust/lithe-core/src/plugins/capabilities.rs index aae7a16600..327b380f32 100644 --- a/rust/lithe-core/src/plugins/capabilities.rs +++ b/rust/lithe-core/src/plugins/capabilities.rs @@ -34,6 +34,8 @@ pub(crate) const LANGUAGE_SERVERS: &str = "languageServers"; /// Version 2 adds `runSelectedRuntime` for debug adapters; version 3 adds /// `launch.kind: "backend"`, a language server whose command line the /// plugin backend resolves. +/// Version 4 names runtimes by tool-definition ID (`requiresTools`) instead +/// of the host's closed runtime list. pub(crate) const SERVER_PROCESS: &str = "serverProcess"; /// `vendor.bundledSignatureRequirement`: origin-scoped host-seal trust for a /// package whose independently installed versions require a publisher signature. @@ -68,7 +70,8 @@ pub(crate) const BACKEND: &str = "backend"; pub(crate) const PROJECT_GENERATORS: &str = "projectGenerators"; /// `debugAdapters[]`: debug adapters implemented by a plugin process; see -/// `crate::debug::adapters`. Plugin API level 4. +/// `crate::debug::adapters`. Plugin API level 4. Version 3 adds declared +/// adapter `features` and `defaults`. pub(crate) const DEBUG_ADAPTERS: &str = "debugAdapters"; /// First plugin API level that may require `debugAdapters` or `serverProcess` 2. @@ -85,6 +88,9 @@ pub(crate) const PLUGIN_FUNCTIONS: &str = "pluginFunctions"; pub(crate) const TOOL_DEFINITIONS: &str = "toolDefinitions"; /// Backend facts that can influence the executable selected for a defined tool. pub(crate) const TOOLCHAIN_PROVIDERS: &str = "toolchainProviders"; +/// `outputLinkMatchers[]`: patterns that turn console output into links; see +/// `crate::plugins::output_links`. +pub(crate) const OUTPUT_LINK_MATCHERS: &str = "outputLinkMatchers"; /// Capability versions this Core build validates and plans. Version 1 of a /// capability keeps the meaning its section had at API level 2. @@ -93,16 +99,17 @@ pub const SUPPORTED_CAPABILITIES: &[(&str, &[u32])] = &[ (BUILD_MODEL_INPUTS, &[1]), (BUILD_PROVIDERS, &[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]), (BUILD_SYSTEMS, &[1, 2, 3, 4]), - (DEBUG_ADAPTERS, &[1, 2]), + (DEBUG_ADAPTERS, &[1, 2, 3]), (EXECUTION_PROVIDERS, &[1]), (EXECUTABLE_RESOLUTION, &[1, 2]), (KEYMAP_IMPORT_PROVIDERS, &[1]), (LANGUAGE_SERVERS, &[1, 2, 3]), (LANGUAGES, &[1, 2]), + (OUTPUT_LINK_MATCHERS, &[1]), (PACKAGE_TRUST, &[1]), (PLUGIN_FUNCTIONS, &[1]), (PROJECT_GENERATORS, &[1, 2]), - (SERVER_PROCESS, &[1, 2, 3]), + (SERVER_PROCESS, &[1, 2, 3, 4]), (SETTINGS, &[1, 2]), (TOOL_DEFINITIONS, &[1]), (TOOLCHAIN_PROVIDERS, &[1]), diff --git a/rust/lithe-core/src/plugins/declarative.rs b/rust/lithe-core/src/plugins/declarative.rs index da0149c945..1c289934e6 100644 --- a/rust/lithe-core/src/plugins/declarative.rs +++ b/rust/lithe-core/src/plugins/declarative.rs @@ -69,6 +69,9 @@ const PERMISSION_WORKSPACE_METADATA: &str = "workspace.metadata"; const PERMISSION_DEBUG_LAUNCH: &str = "debug.launch"; /// Attaching the plugin's adapter to a running process. `debugAdapters` 1. const PERMISSION_DEBUG_ATTACH: &str = "debug.attach"; +/// Letting the backend start a debug session on one of its own adapters +/// (`lithe/host/debug/start`). A host-method permission of `backend` 2. +const PERMISSION_DEBUG_START: &str = "debug.start"; const KNOWN_PERMISSIONS: &[&str] = &[ PERMISSION_BACKEND, PERMISSION_BUILD, @@ -96,6 +99,11 @@ pub(crate) const KNOWN_RUNTIME_TOOLS_V2: &[&str] = &["go", "java", "node"]; /// Runtimes that can start a packaged entry file themselves (`java -jar`, /// `node