From 7719b606fcd2fbbfeccb525293b16b0ea1a77493 Mon Sep 17 00:00:00 2001 From: FxRayHughes <25192142+FxRayHughes@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:30:36 +0800 Subject: [PATCH] =?UTF-8?q?feat(plugins):=20macOS=20=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E9=A1=B5=E6=8E=A5=E5=85=A5=E5=B7=A5=E5=85=B7=E9=93=BE=E6=8F=90?= =?UTF-8?q?=E4=BE=9B=E6=96=B9=E6=9F=A5=E8=AF=A2=20(#1581)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Core: lsp.toolchainRequest 与 litheToolchains 协商,原样回传回复交 toolchains.decodeReply - Core: candidates 接受 providerInstallations 并保留提供方归属 - 修复别名链接 home 只有链接路径事实时被判 unsafePath(机器人 P2) - macOS: 启动或复用插件后端查询,全局+逐目录独立 operationID/代次,超时与取消 - macOS: watchFiles 与回复 watch 变化整份重查,短时间事件合并 - 设置页显示提供方要求、版本与查询结果;文案走 shared/i18n - 更新契约、功能矩阵与 Agent Note --- .../2026-10-31-plugin-injected-toolchains.md | 16 +- macos/Resources/en.lproj/Localizable.strings | 32 +- .../zh-Hans.lproj/Localizable.strings | 32 +- .../Composition/AppModelFeatureGraph.swift | 3 +- .../Application/Composition/AppServices.swift | 1 + .../ToolchainSettingsFeatureModel.swift | 56 +++- .../Lithe/Core/Ports/ToolchainSettings.swift | 30 ++ .../Lithe/Core/Rust/RustCoreBridge.swift | 24 ++ .../RustLanguageServerRuntimeAdapter.swift | 43 +++ .../AppModel+LanguageServerRuntime.swift | 1 + .../Platform/MacOS/MacServiceContainer.swift | 27 +- .../Plugins/MacPluginBackendSessions.swift | 22 +- .../MacToolchainDiscovery+Providers.swift | 205 ++++++++++++ .../MacOS/Runtime/MacToolchainDiscovery.swift | 80 ++++- .../Runtime/MacToolchainInputWatcher.swift | 68 ++++ .../Runtime/MacToolchainSettingsLoader.swift | 23 +- .../ToolchainEnvironmentSettingsView.swift | 40 ++- .../App/ToolchainSourcesSettingsView.swift | 12 +- .../LanguageServerRuntimeContracts.swift | 58 ++++ .../Runtime/LanguageServerSession.swift | 68 +++- .../MacToolchainDiscoveryTests.swift | 116 +++++++ .../ToolchainSettingsFeatureTests.swift | 92 ++++++ rust/lithe-core/src/lsp/interface/client.rs | 33 +- rust/lithe-core/src/lsp/interface/engine.rs | 298 +++++++++++++++++- .../lsp/interface/engine_real_jdt_tests.rs | 1 + rust/lithe-core/src/lsp/interface/mod.rs | 1 + .../src/lsp/interface/toolchain_provider.rs | 120 +++++++ rust/lithe-core/src/lsp/interface/types.rs | 9 + rust/lithe-core/src/protocol/command.rs | 4 + rust/lithe-core/src/runtime/dispatcher.rs | 18 ++ .../src/tests/toolchain_resolution.rs | 57 ++++ rust/lithe-core/src/toolchain/resolution.rs | 39 ++- .../v1/proposals/toolchains/protocol.md | 2 +- shared/contracts/plugins/v1/toolchains.md | 29 +- shared/contracts/rust-core-api.md | 15 + shared/i18n/locales/en-US.json | 18 +- shared/i18n/locales/zh-CN.json | 18 +- shared/i18n/messages.json | 113 ++++++- .../features/plugin-injected-toolchains.json | 12 +- 39 files changed, 1746 insertions(+), 90 deletions(-) create mode 100644 macos/Sources/Lithe/Platform/MacOS/Runtime/MacToolchainDiscovery+Providers.swift create mode 100644 macos/Sources/Lithe/Platform/MacOS/Runtime/MacToolchainInputWatcher.swift create mode 100644 rust/lithe-core/src/lsp/interface/toolchain_provider.rs diff --git a/.agents/notes/proposed/architecture/2026-10-31-plugin-injected-toolchains.md b/.agents/notes/proposed/architecture/2026-10-31-plugin-injected-toolchains.md index 191575e623..2e16faae16 100644 --- a/.agents/notes/proposed/architecture/2026-10-31-plugin-injected-toolchains.md +++ b/.agents/notes/proposed/architecture/2026-10-31-plugin-injected-toolchains.md @@ -4,7 +4,7 @@ ## 先说结论 -从 Dock 启动时找不到版本管理器安装的运行时、项目要求的版本未生效,不能靠宿主逐个补目录和版本文件解析器解决。本方案把工具定义、安装事实提供方和使用方分开:插件说明工具是什么,版本管理器回答当前事实,宿主只提供统一选择、查询和环境展示框架;Java、Maven 等定义全部由插件贡献,不保留宿主内置定义表或兼容别名。当前已实现 Core 基础切片及 macOS 只读设置页;macOS 设置页已按声明式路线真实扫描本机安装、运行定义的版本探测并交 Core 选择,尚未接入两端的查询进程、文件监听、保存和运行时消费者。完整方案仍在本提案中跟踪,不能把页面出现解释成产品已能使用 mise 或已经完成工具迁移。 +从 Dock 启动时找不到版本管理器安装的运行时、项目要求的版本未生效,不能靠宿主逐个补目录和版本文件解析器解决。本方案把工具定义、安装事实提供方和使用方分开:插件说明工具是什么,版本管理器回答当前事实,宿主只提供统一选择、查询和环境展示框架;Java、Maven 等定义全部由插件贡献,不保留宿主内置定义表或兼容别名。当前已实现 Core 基础切片及 macOS 只读设置页;macOS 设置页已按声明式路线真实扫描本机安装、运行定义的版本探测并交 Core 选择,并通过插件后端查询提供方、监听其输入后整份重查;Windows 查询、保存和运行时消费者尚未接入。完整方案仍在本提案中跟踪,不能把页面出现解释成产品已能使用 mise 或已经完成工具迁移。 ## 问题 @@ -106,9 +106,9 @@ Core 基础切片先独立验证固定值,因此低层提供方超时不能撤 插件停用或卸载后,撤销其工具行、使用方、导出变量和设置页,取消它拥有的查询及监听;写下的固定值保留,重新启用再校验并恢复。存在固定值不代表定义仍然有效,不能靠残留固定继续导出环境或启动程序。所有用户文案实现时进入 `shared/i18n/`,不把原型里的字符串直接写进视图或服务。 -当前 macOS 页面先交付通用只读框架:构建分组新增“运行环境”和“环境来源”,保留旧项目页,直到真实使用方迁移后再删除旧链路。加载器只读取已注册且已启用插件的声明与本机 `local.json`;固定继承仍调用 Core,不在 Swift 维护第二份规则。页面只显示读到的事实:存在的版本文件只说明“版本未查询”,固定值显示“已保存,未验证”;全局默认列出登录 Shell 里定义声明的 home 变量及其探测出的版本,再列 `PATH` 上按声明顺序找到的第一个命令并标“版本未验证”(不运行它);自动检测列出声明式扫描找到并通过 Core 校验的安装,版本最高的打勾,同版本不同 home 标冲突,未通过校验的只计数并在悬停里给原因。提供方的全局默认与安装在查询客户端接入前不显示;使用方与变量没有事实就不显示。添加、切换、保存和查询还没有真实实现,所以界面上直接不放这些控件,也不放禁用的占位按钮。目录树只列项目根目录、自己有版本文件或固定值的目录及其上级,扫描与行数有上限,超出时用诊断说明覆盖了多少目录。隔离预览(`--toolchain-settings-demo`,仅 Debug)只用样例插件清单代替已安装插件:工作区是真实文件(临时生成或 `--toolchain-demo-workspace` 指定),固定、版本文件、自动检测和登录 Shell 都走生产加载器、Core 和启动时的同一读取流程;它不进入生产插件表或运行流程,退出时删除临时目录。远程工作区在读本机文件前拒绝。 +当前 macOS 页面先交付通用只读框架:构建分组新增“运行环境”和“环境来源”,保留旧项目页,直到真实使用方迁移后再删除旧链路。加载器只读取已注册且已启用插件的声明与本机 `local.json`;固定继承仍调用 Core,不在 Swift 维护第二份规则。页面只显示读到的事实:存在的版本文件在提供方回答前说明“版本未查询”,回答后显示提供方原文的要求和对应安装版本,固定值显示“已保存,未验证”;全局默认列出登录 Shell 里定义声明的 home 变量及其探测出的版本,再列 `PATH` 上按声明顺序找到的第一个命令并标“版本未验证”(不运行它);自动检测列出声明式扫描找到并通过 Core 校验的安装,版本最高的打勾,同版本不同 home 标冲突,未通过校验的只计数并在悬停里给原因。提供方报告的全局要求列在全局步骤,安装并入自动检测并保留提供方归属;使用方与变量没有事实就不显示。添加、切换、保存和查询还没有真实实现,所以界面上直接不放这些控件,也不放禁用的占位按钮。目录树只列项目根目录、自己有版本文件或固定值的目录及其上级,扫描与行数有上限,超出时用诊断说明覆盖了多少目录。隔离预览(`--toolchain-settings-demo`,仅 Debug)只用样例插件清单代替已安装插件:工作区是真实文件(临时生成或 `--toolchain-demo-workspace` 指定),固定、版本文件、自动检测和登录 Shell 都走生产加载器、Core 和启动时的同一读取流程;它不进入生产插件表或运行流程,退出时删除临时目录。远程工作区在读本机文件前拒绝。 -来源页只展示两类已有事实。第一类是登录 Shell 读取:使用的 Shell、读取状态、变量数量、耗时与时间,以及 `PATH` 各目录来自登录 Shell 还是 Lithe 的启动环境。其他变量可能含有密钥,只计数不显示值。“重新读取”只刷新子进程缺少的变量和下次启动用的 `PATH` 缓存,不改本进程的 `PATH`(线程启动后 `setenv` 不安全);发现 `PATH` 变了就提示重启。第二类是插件声明的版本管理器:它监视的文件、本项目里实际找到的文件,以及来自哪个插件。提供方的运行状态、查询命令、耗时和全局默认,在原生查询客户端接入前一律不显示,不保留占位徽标。 +来源页只展示两类已有事实。第一类是登录 Shell 读取:使用的 Shell、读取状态、变量数量、耗时与时间,以及 `PATH` 各目录来自登录 Shell 还是 Lithe 的启动环境。其他变量可能含有密钥,只计数不显示值。“重新读取”只刷新子进程缺少的变量和下次启动用的 `PATH` 缓存,不改本进程的 `PATH`(线程启动后 `setenv` 不安全);发现 `PATH` 变了就提示重启。第二类是插件声明的版本管理器:它监视的文件、本项目里实际找到的文件,以及来自哪个插件。每个版本管理器显示最近一次查询的结果:耗时,或提供方原样给出的失败原因(如未受信任的配置),不替用户执行 trust 或安装。 macOS 的声明式路线由 `MacToolchainDiscovery` 执行,宿主不认识任何工具名,只照 `discoveryPlan` 做三件事: @@ -147,7 +147,7 @@ macOS 的声明式路线由 `MacToolchainDiscovery` 执行,宿主不认识任 | 阶段 | 交付内容 | 必须证明 | | --- | --- | --- | | 1:提案 | 本笔记、隔离的清单贡献、查询和解析、`pins` 契约草案及 fixture | Java 风格定义、mise 提供方、未定义工具反例可读且一致;未实施的草案字段不能被误当成已支持能力 | -| 2:Core 与查询客户端,部分完成 | Core 已支持定义/答案校验、目录固定、四层解析、同时列出扫描规则与提供方的发现计划、设置页候选(`toolchains.candidates`)、只报告安装的提供方和版本校验;macOS 设置页已接入声明式扫描与版本探测;macOS 查询客户端和监听未接入 | 已有纯数据测试保护固定失效、来源与最低版本、冲突、路径和数量边界,以及只扫描、只由定义插件自供、两条路线报告同一 home 时只算一份;macOS 用真实临时目录和注入的探测证明扫描、工作区排除、只探测 Core 返回的文件和冲突;取消、真实超时、文件新建/删除、全量查询及进程回收仍须平台实现证明;不新增宿主工具表 | +| 2:Core 与查询客户端,部分完成 | Core 已支持定义/答案校验、目录固定、四层解析、同时列出扫描规则与提供方的发现计划、设置页候选(`toolchains.candidates`)、只报告安装的提供方和版本校验;macOS 设置页已接入声明式扫描、版本探测、提供方查询客户端和输入监听;Windows 未接入 | 已有纯数据测试保护固定失效、来源与最低版本、冲突、路径和数量边界,以及只扫描、只由定义插件自供、两条路线报告同一 home 时只算一份;macOS 用真实临时目录和注入的探测证明扫描、工作区排除、只探测 Core 返回的文件和冲突;macOS 已用脚本后端与注入查询证明协商、原样回复、独立代次、整份重查与合并;真实超时、进程回收与 Windows 仍须平台实现证明;不新增宿主工具表 | | 3:统一选择与设置界面,部分完成 | macOS 环境/来源页和只读加载器已接入;实际启动与展示共用快照、Java/Maven 定义迁移尚未完成 | 现有目录、插件撤销、错误及过期快照测试;手动添加、确认保存、冲突回读和消费者切换仍须后续验证;同项删除被替换的专属路径 | | 4:两个真实提供方 | mise 后端调用上游输出,SDKMAN 作为不同机制的验收插件,设置各自注入插件页 | 第二个提供方只改插件;SDKMAN 路径从 `MacRuntimeDiscovery` 迁出;原生工作流、离线/未信任/未安装/取消、进程清理与安装包哈希不变 | @@ -190,3 +190,11 @@ macOS 的声明式路线由 `MacToolchainDiscovery` 执行,宿主不认识任 - `windows/tauri/src/platform/` - `Plugins/mac/` - `Plugins/win/` + +## macOS 查询客户端的取舍 + +- 复用 `MacPluginBackendSessions` 的单插件单进程,不另起进程:查询与页面调用共用同一后端和设置重启规则。协商结果在 Core 会话状态里,未协商的提供方由 Core 拒绝,不写到进程。 +- Core 只搬运原始回复(`{id,response}`),所有成功与错误规则留给 `toolchains.decodeReply`;否则引擎和解码器会各维护一份错误码表。 +- 每个提供方先发一次全局查询(`workspace:null`),再对根目录和有自己版本文件的目录逐个查询(上限 32)。这样项目配置不可用时仍能列出安装;未打开窗口的工作区没有 context,只做全局查询并说明原因。 +- 宿主只读取回复与定义点名的路径(home、可执行布局、`${home}` 前缀、来源文件)作为事实。别名链接(如 mise 的 `latest`)只有链接路径的事实,Core 由链接事实推出规范目标再校验,避免宿主为规范路径再读一遍。 +- 失效用整份重载:工作区内按 `watchFiles` 尾部匹配,工作区外按回复的 `watch` 用一个 FSEvents 流监听其最近存在的父目录;短时间事件合并为一次重载,关闭页面即释放监听。 diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index 999513f884..4b9fe4d159 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -16,6 +16,7 @@ "%@ build files changed" = "%@ build files changed"; "%@ could not be reached. Check your internet, proxy, or VPN connection and try again." = "%@ could not be reached. Check your internet, proxy, or VPN connection and try again."; "%@ debug adapter is unavailable." = "%@ debug adapter is unavailable."; +"%@ did not answer: %@" = "%@ did not answer: %@"; "%@ did not finish within %lld seconds." = "%@ did not finish within %lld seconds."; "%@ did not report a version; %@ or newer is required." = "%@ did not report a version; %@ or newer is required."; "%@ disabled" = "%@ disabled"; @@ -350,6 +351,7 @@ "Another Hook update is running. Try again." = "Another Hook update is running. Try again."; "Another global entry uses this name with a different definition." = "Another global entry uses this name with a different definition."; "Another instruction save is running. Try again." = "Another instruction save is running. Try again."; +"Answered in %lld ms" = "Answered in %lld ms"; "Any Time" = "Any Time"; "Appearance" = "Appearance"; "Appearance & Behavior" = "Appearance & Behavior"; @@ -1304,7 +1306,7 @@ "Each repository has its own commit. Completed steps are kept if another repository fails." = "Each repository has its own commit. Completed steps are kept if another repository fails."; "Each row explains the setting, the value Git is actually using, and where that value comes from." = "Each row explains the setting, the value Git is actually using, and where that value comes from."; "Each submodule is pushed before its parent." = "Each submodule is pushed before its parent."; -"Each tool uses the first source that has a value: a fixed version, then project version files, then global defaults, then automatic discovery. Installed versions are checked by running each tool's own version command. Version managers are not queried yet, so the versions that version files ask for are not shown." = "Each tool uses the first source that has a value: a fixed version, then project version files, then global defaults, then automatic discovery. Installed versions are checked by running each tool's own version command. Version managers are not queried yet, so the versions that version files ask for are not shown."; +"Each tool uses the first source that has a value: a fixed version, then project version files, then global defaults, then automatic discovery. Installed versions are checked by running each tool's own version command. Enabled version managers report what version files ask for." = "Each tool uses the first source that has a value: a fixed version, then project version files, then global defaults, then automatic discovery. Installed versions are checked by running each tool's own version command. Enabled version managers report what version files ask for."; "Earlier" = "Earlier"; "Earlier Git commands were truncated to limit memory use." = "Earlier Git commands were truncated to limit memory use."; "Earlier Git output was omitted to limit memory use." = "Earlier Git output was omitted to limit memory use."; @@ -2629,6 +2631,7 @@ "Only the current branch can be updated" = "Only the current branch can be updated"; "Only the files below are collected. Credentials, tokens, and home-directory paths are redacted automatically; workspace source, editor buffers, and terminal history are never included." = "Only the files below are collected. Credentials, tokens, and home-directory paths are redacted automatically; workspace source, editor buffers, and terminal history are never included."; "Only the first %lld directories are shown." = "Only the first %lld directories are shown."; +"Only the first %lld directories were queried." = "Only the first %lld directories were queried."; "Only the first %lld discovered entries were checked." = "Only the first %lld discovered entries were checked."; "Only the first %lld discovery locations were scanned." = "Only the first %lld discovery locations were scanned."; "Only the first %lld installation versions were checked." = "Only the first %lld installation versions were checked."; @@ -2985,6 +2988,7 @@ "Push to %@" = "Push to %@"; "Push…" = "Push…"; "Python Support" = "Python Support"; +"Query" = "Query"; "Quick Information" = "Quick Information"; "Quick responses with basic reasoning" = "Quick responses with basic reasoning"; "Quit" = "Quit"; @@ -4061,7 +4065,6 @@ "The generated message will replace the text currently in the editor." = "The generated message will replace the text currently in the editor."; "The generated title or description would replace text you already entered." = "The generated title or description would replace text you already entered."; "The global instruction file must use UTF-8 encoding." = "The global instruction file must use UTF-8 encoding."; -"The home variables and commands this tool's plugin declares, read from the login shell. A home's version comes from the tool's own version command; a command found on PATH is not run. Version managers' global versions are not queried yet." = "The home variables and commands this tool's plugin declares, read from the login shell. A home's version comes from the tool's own version command; a command found on PATH is not run. Version managers' global versions are not queried yet."; "The host cannot provide an execution session for %@." = "The host cannot provide an execution session for %@."; "The host could not be reached. Check the host name, the port, and your network." = "The host could not be reached. Check the host name, the port, and your network."; "The host could not create the plugin's workspace state directory." = "The host could not create the plugin's workspace state directory."; @@ -4103,6 +4106,7 @@ "The patch changes the base commit's tree into the target commit's tree." = "The patch changes the base commit's tree into the target commit's tree."; "The plugin archive expands beyond the allowed size: %@" = "The plugin archive expands beyond the allowed size: %@"; "The plugin archive is larger than %@ MB." = "The plugin archive is larger than %@ MB."; +"The plugin backend did not offer this version manager." = "The plugin backend did not offer this version manager."; "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."; @@ -4158,6 +4162,7 @@ "The remote terminal closed before it started." = "The remote terminal closed before it started."; "The remote terminal is busy. Wait before sending more input." = "The remote terminal is busy. Wait before sending more input."; "The remote terminal must be started asynchronously." = "The remote terminal must be started asynchronously."; +"The reply could not be read." = "The reply could not be read."; "The reported file changes have been updated. Review them again before rolling back." = "The reported file changes have been updated. Review them again before rolling back."; "The repository changed or another Git operation is running. Preview the patch again." = "The repository changed or another Git operation is running. Preview the patch again."; "The repository changed or another Git operation is running. Refresh before retrying." = "The repository changed or another Git operation is running. Refresh before retrying."; @@ -4211,6 +4216,7 @@ "The switch controls the Lithe shared block, not the Agent or its own instructions. Save shared changes before switching. Open a row to edit the complete native file." = "The switch controls the Lithe shared block, not the Agent or its own instructions. Save shared changes before switching. Open a row to edit the complete native file."; "The terminal directory is outside the remote workspace." = "The terminal directory is outside the remote workspace."; "The test file is outside the current workspace: %@" = "The test file is outside the current workspace: %@"; +"The toolchain query could not be built." = "The toolchain query could not be built."; "The update could not be installed. Download the release manually and try again." = "The update could not be installed. Download the release manually and try again."; "The update package could not be downloaded. Check your internet connection and try again." = "The update package could not be downloaded. Check your internet connection and try again."; "The update package could not be downloaded. Check your internet, proxy, or VPN connection and try again." = "The update package could not be downloaded. Check your internet, proxy, or VPN connection and try again."; @@ -4349,6 +4355,7 @@ "This window" = "This window"; "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."; "This worktree has a detached HEAD. Publish it as a branch before creating a pull request." = "This worktree has a detached HEAD. Publish it as a branch before creating a pull request."; "This worktree has no uncommitted changes." = "This worktree has no uncommitted changes."; "Threads" = "Threads"; @@ -4614,10 +4621,10 @@ "Version control provider" = "Version control provider"; "Version file" = "Version file"; "Version files" = "Version files"; -"Version files in this directory or its parents, watched by the version managers of this tool. The versions they ask for are not queried yet." = "Version files in this directory or its parents, watched by the version managers of this tool. The versions they ask for are not queried yet."; +"Version files in this directory or its parents, watched by the version managers of this tool, and the versions those managers report for them." = "Version files in this directory or its parents, watched by the version managers of this tool, and the versions those managers report for them."; "Version files were checked in the first %lld directories only." = "Version files were checked in the first %lld directories only."; "Version managers" = "Version managers"; -"Version managers come from enabled plugins. Lithe looks for the files they watch in this project; it does not run or query them yet." = "Version managers come from enabled plugins. Lithe looks for the files they watch in this project; it does not run or query them yet."; +"Version managers come from enabled plugins. Lithe asks them what is installed and what this project requires; they never install or trust anything for you." = "Version managers come from enabled plugins. Lithe asks them what is installed and what this project requires; they never install or trust anything for you."; "Version not queried" = "Version not queried"; "Version not verified" = "Version not verified"; "Version status" = "Version status"; @@ -4669,6 +4676,7 @@ "What Fetch removes or downloads before you start working." = "What Fetch removes or downloads before you start working."; "What Fetch will do" = "What Fetch will do"; "What does this pull request change?" = "What does this pull request change?"; +"What version managers report as global, then the home variables and commands this tool's plugin declares, read from the login shell. A home's version comes from the tool's own version command; a command found on PATH is not run." = "What version managers report as global, then the home variables and commands this tool's plugin declares, read from the login shell. A home's version comes from the tool's own version command; a command found on PATH is not run."; "When a session starts" = "When a session starts"; "When a turn ends" = "When a turn ends"; "When quitting Lithe" = "When quitting Lithe"; @@ -9132,8 +9140,10 @@ "native.toolWindow" = "Tool Window"; "native.toolWindows" = "Tool Windows"; "native.toolbarActions" = "Toolbar Actions"; +"native.toolchainSettings.answeredIn" = "Answered in %1$lld ms"; "native.toolchainSettings.automaticDiscovery" = "Automatic discovery"; "native.toolchainSettings.automaticStepInfo" = "Installations found in the locations this tool's plugin declares. Each version comes from the tool's own version command, and the highest one is used. Installations inside the project are never run."; +"native.toolchainSettings.backendDidNotOfferProvider" = "The plugin backend did not offer this version manager."; "native.toolchainSettings.consumers" = "Consumers"; "native.toolchainSettings.couldNotReadToolchainSettings" = "Could not read toolchain settings."; "native.toolchainSettings.definition" = "Definition"; @@ -9142,13 +9152,14 @@ "native.toolchainSettings.discovered" = "Discovered"; "native.toolchainSettings.discoveryDeclarationsUnchecked" = "Installed toolchain declarations could not be checked for discovery."; "native.toolchainSettings.exportedEnvironment" = "Exported environment"; +"native.toolchainSettings.firstDirectoriesQueried" = "Only the first %1$lld directories were queried."; "native.toolchainSettings.firstMatch" = "First match"; "native.toolchainSettings.fixed" = "Fixed"; "native.toolchainSettings.fixedInParent" = "Fixed in parent"; "native.toolchainSettings.fixedInfo" = "Fixed versions are saved on this computer in .lithe/toolchains/local.json and also apply to the directories below. This page reads them without changing the file."; "native.toolchainSettings.fromDirectory" = "From %1$@"; "native.toolchainSettings.globalDefaults" = "Global defaults"; -"native.toolchainSettings.globalStepInfo" = "The home variables and commands this tool's plugin declares, read from the login shell. A home's version comes from the tool's own version command; a command found on PATH is not run. Version managers' global versions are not queried yet."; +"native.toolchainSettings.globalStepInfo" = "What version managers report as global, then the home variables and commands this tool's plugin declares, read from the login shell. A home's version comes from the tool's own version command; a command found on PATH is not run."; "native.toolchainSettings.installationsCouldNotBeChecked" = "Installations of %1$@ could not be checked."; "native.toolchainSettings.launchEnvironment" = "Launch environment"; "native.toolchainSettings.loginShell" = "Login shell"; @@ -9178,12 +9189,16 @@ "native.toolchainSettings.pinnedStepInfo" = "A version saved for this directory on this computer. It applies first, also in the directories below."; "native.toolchainSettings.projectRequirements" = "Project requirements"; "native.toolchainSettings.projectRootDirectory" = "Project root directory"; -"native.toolchainSettings.projectStepInfo" = "Version files in this directory or its parents, watched by the version managers of this tool. The versions they ask for are not queried yet."; +"native.toolchainSettings.projectStepInfo" = "Version files in this directory or its parents, watched by the version managers of this tool, and the versions those managers report for them."; +"native.toolchainSettings.providerDidNotAnswer" = "%1$@ did not answer: %2$@"; +"native.toolchainSettings.query" = "Query"; "native.toolchainSettings.queryMilliseconds" = "%1$lld ms"; +"native.toolchainSettings.queryNotBuilt" = "The toolchain query could not be built."; "native.toolchainSettings.readAgain" = "Read again"; "native.toolchainSettings.readResult" = "Read result"; "native.toolchainSettings.reading" = "Reading…"; "native.toolchainSettings.remoteUnavailable" = "Toolchain settings are not available for remote workspaces yet."; +"native.toolchainSettings.replyUnreadable" = "The reply could not be read."; "native.toolchainSettings.reportsInstalledVersionsOnly" = "Reports installed versions only"; "native.toolchainSettings.runtimeEnvironment" = "Runtime Environment"; "native.toolchainSettings.runtimeEnvironmentIsolatedPreview" = "Runtime Environment — Isolated Preview"; @@ -9196,7 +9211,7 @@ "native.toolchainSettings.tooManyFilesForOneTool" = "Too many files to check for one tool; some installations were skipped."; "native.toolchainSettings.toolDefinitionInfo" = "This row comes from the tool definition of an enabled plugin. Lithe has no built-in tools."; "native.toolchainSettings.toolchainSources" = "Toolchain Sources"; -"native.toolchainSettings.toolsInfo" = "Each tool uses the first source that has a value: a fixed version, then project version files, then global defaults, then automatic discovery. Installed versions are checked by running each tool's own version command. Version managers are not queried yet, so the versions that version files ask for are not shown."; +"native.toolchainSettings.toolsInfo" = "Each tool uses the first source that has a value: a fixed version, then project version files, then global defaults, then automatic discovery. Installed versions are checked by running each tool's own version command. Enabled version managers report what version files ask for."; "native.toolchainSettings.turnedOffBySkipVariable" = "Turned off by LITHE_SKIP_SHELL_ENV"; "native.toolchainSettings.usingCachedPath" = "Using the PATH saved last time"; "native.toolchainSettings.usingLaunchEnvironment" = "Using the environment Lithe was launched with"; @@ -9209,12 +9224,13 @@ "native.toolchainSettings.versionFilesInfo" = "Files declared by enabled version managers that exist in this directory. A directory without its own uses the nearest parent's."; "native.toolchainSettings.versionManagerDefinitionInfo" = "This version manager comes from an enabled plugin. Lithe has no built-in version managers."; "native.toolchainSettings.versionManagers" = "Version managers"; -"native.toolchainSettings.versionManagersInfo" = "Version managers come from enabled plugins. Lithe looks for the files they watch in this project; it does not run or query them yet."; +"native.toolchainSettings.versionManagersInfo" = "Version managers come from enabled plugins. Lithe asks them what is installed and what this project requires; they never install or trust anything for you."; "native.toolchainSettings.versionNotQueried" = "Version not queried"; "native.toolchainSettings.versionNotVerified" = "Version not verified"; "native.toolchainSettings.versionTooOld" = "Version too old"; "native.toolchainSettings.versionsNotCheckedInTime" = "Some installation versions were not checked in time."; "native.toolchainSettings.watchedFiles" = "Watched files"; +"native.toolchainSettings.workspaceNotOpenGlobalOnly" = "This workspace is not open in a window, so only global versions were read."; "native.tools" = "Tools"; "native.toolsSent" = "Tools sent"; "native.total" = "Total"; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index b782f1329e..ad8b540366 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -16,6 +16,7 @@ "%@ build files changed" = "%@ 构建文件已更改"; "%@ could not be reached. Check your internet, proxy, or VPN connection and try again." = "无法连接 %@。请检查网络、代理或 VPN 后重试。"; "%@ debug adapter is unavailable." = "%@ 调试适配器不可用。"; +"%@ did not answer: %@" = "%@ 未回答:%@"; "%@ did not finish within %lld seconds." = "%1$@ 未在 %2$lld 秒内完成。"; "%@ did not report a version; %@ or newer is required." = "%1$@ 没有报告版本,需要 %2$@ 或更高版本。"; "%@ disabled" = "%@ 已停用"; @@ -350,6 +351,7 @@ "Another Hook update is running. Try again." = "另一个 Hook 更新正在进行,请稍后重试。"; "Another global entry uses this name with a different definition." = "另一个全局条目使用了相同名称但定义不同。"; "Another instruction save is running. Try again." = "另一项指令保存正在进行,请重试。"; +"Answered in %lld ms" = "%lld 毫秒内已回答"; "Any Time" = "不限时间"; "Appearance" = "外观"; "Appearance & Behavior" = "外观与行为"; @@ -1304,7 +1306,7 @@ "Each repository has its own commit. Completed steps are kept if another repository fails." = "每个仓库会分别提交。某个仓库失败时,已完成的步骤仍会保留。"; "Each row explains the setting, the value Git is actually using, and where that value comes from." = "每一行都会说明设置用途、Git 实际采用的值,以及这个值来自哪里。"; "Each submodule is pushed before its parent." = "先推送子模块,再推送父仓库。"; -"Each tool uses the first source that has a value: a fixed version, then project version files, then global defaults, then automatic discovery. Installed versions are checked by running each tool's own version command. Version managers are not queried yet, so the versions that version files ask for are not shown." = "每个工具采用第一个有值的来源,依次为固定版本、项目版本文件、全局默认、自动检测。已安装的版本通过运行该工具自己的版本命令来检查。版本管理器暂未接入查询,因此不显示版本文件所要求的版本。"; +"Each tool uses the first source that has a value: a fixed version, then project version files, then global defaults, then automatic discovery. Installed versions are checked by running each tool's own version command. Enabled version managers report what version files ask for." = "每个工具采用第一个有值的来源,依次为固定版本、项目版本文件、全局默认、自动检测。已安装的版本通过运行该工具自己的版本命令来检查。已启用的版本管理器会报告版本文件所要求的版本。"; "Earlier" = "更早"; "Earlier Git commands were truncated to limit memory use." = "为限制内存占用,较早的 Git 执行记录已截断,无法展开。"; "Earlier Git output was omitted to limit memory use." = "为限制内存占用,已省略较早的 Git 输出。"; @@ -2629,6 +2631,7 @@ "Only the current branch can be updated" = "只能更新当前分支"; "Only the files below are collected. Credentials, tokens, and home-directory paths are redacted automatically; workspace source, editor buffers, and terminal history are never included." = "仅收集下列文件。凭据、令牌和用户目录路径会被自动脱敏;工作区源代码、编辑器缓冲区内容和终端历史绝不会被包含。"; "Only the first %lld directories are shown." = "只显示前 %lld 个目录。"; +"Only the first %lld directories were queried." = "只查询了前 %lld 个目录。"; "Only the first %lld discovered entries were checked." = "只检查了前 %lld 个发现的条目。"; "Only the first %lld discovery locations were scanned." = "只扫描了前 %lld 个检测位置。"; "Only the first %lld installation versions were checked." = "只检查了前 %lld 个安装的版本。"; @@ -2985,6 +2988,7 @@ "Push to %@" = "推送到 %@"; "Push…" = "推送…"; "Python Support" = "Python 支持"; +"Query" = "查询"; "Quick Information" = "快速信息"; "Quick responses with basic reasoning" = "快速响应,基础推理"; "Quit" = "退出"; @@ -4061,7 +4065,6 @@ "The generated message will replace the text currently in the editor." = "生成的提交信息将替换编辑器中的当前文本。"; "The generated title or description would replace text you already entered." = "生成的标题或描述可能会替换你已经输入的内容。"; "The global instruction file must use UTF-8 encoding." = "全局指令文件必须使用 UTF-8 编码。"; -"The home variables and commands this tool's plugin declares, read from the login shell. A home's version comes from the tool's own version command; a command found on PATH is not run. Version managers' global versions are not queried yet." = "插件为该工具声明的主目录变量和命令,从登录 Shell 中读取。主目录的版本来自该工具自己的版本命令;在 PATH 中找到的命令不会运行。版本管理器的全局版本暂未接入查询。"; "The host cannot provide an execution session for %@." = "宿主无法为 %@ 提供执行会话。"; "The host could not be reached. Check the host name, the port, and your network." = "无法连接到主机,请检查主机名、端口和网络。"; "The host could not create the plugin's workspace state directory." = "宿主无法创建插件的工作区状态目录。"; @@ -4103,6 +4106,7 @@ "The patch changes the base commit's tree into the target commit's tree." = "补丁将基准提交的文件内容变为目标提交的文件内容。"; "The plugin archive expands beyond the allowed size: %@" = "插件压缩包解压后超出允许的大小:%@"; "The plugin archive is larger than %@ MB." = "插件压缩包超过 %@ MB。"; +"The plugin backend did not offer this version manager." = "插件后端没有提供此版本管理器。"; "The plugin backend failed to start." = "插件后端启动失败。"; "The plugin backend is no longer running." = "插件后端已不在运行。"; "The plugin backend stopped before it was ready." = "插件后端在就绪前已停止。"; @@ -4158,6 +4162,7 @@ "The remote terminal closed before it started." = "远程终端在启动完成前已关闭。"; "The remote terminal is busy. Wait before sending more input." = "远程终端正忙,请稍后再发送输入。"; "The remote terminal must be started asynchronously." = "远程终端需要异步启动。"; +"The reply could not be read." = "无法读取答复。"; "The reported file changes have been updated. Review them again before rolling back." = "Agent 上报的文件修改已更新,请重新确认后再回撤。"; "The repository changed or another Git operation is running. Preview the patch again." = "仓库已更改或有其他 Git 操作正在运行。请重新预览补丁。"; "The repository changed or another Git operation is running. Refresh before retrying." = "仓库已更改或有其他 Git 操作正在运行。请刷新后重试。"; @@ -4211,6 +4216,7 @@ "The switch controls the Lithe shared block, not the Agent or its own instructions. Save shared changes before switching. Open a row to edit the complete native file." = "开关控制是否使用 Lithe 共享区块,不会停用 Agent 或它的自有指令。请先保存共享修改再操作开关。点击行可编辑完整原生文件。"; "The terminal directory is outside the remote workspace." = "终端目录不在远程工作区内。"; "The test file is outside the current workspace: %@" = "测试文件在当前工作区之外:%@"; +"The toolchain query could not be built." = "无法构建工具链查询。"; "The update could not be installed. Download the release manually and try again." = "无法安装更新,请手动下载版本后重试。"; "The update package could not be downloaded. Check your internet connection and try again." = "无法下载更新包,请检查网络连接后重试。"; "The update package could not be downloaded. Check your internet, proxy, or VPN connection and try again." = "无法下载更新包。请检查网络、代理或 VPN 连接后重试。"; @@ -4349,6 +4355,7 @@ "This window" = "此窗口"; "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." = "此工作区未在窗口中打开,因此只读取了全局版本。"; "This worktree has a detached HEAD. Publish it as a branch before creating a pull request." = "当前工作树处于分离 HEAD 状态。请先将它发布为分支,再创建拉取请求。"; "This worktree has no uncommitted changes." = "此工作树没有未提交的更改。"; "Threads" = "线程"; @@ -4614,10 +4621,10 @@ "Version control provider" = "版本控制 Provider"; "Version file" = "版本文件"; "Version files" = "版本文件"; -"Version files in this directory or its parents, watched by the version managers of this tool. The versions they ask for are not queried yet." = "此目录或上级目录中、由该工具的版本管理器监视的版本文件。其中要求的版本暂未查询。"; +"Version files in this directory or its parents, watched by the version managers of this tool, and the versions those managers report for them." = "此目录或上级目录中、由该工具的版本管理器监视的版本文件,以及版本管理器报告的版本。"; "Version files were checked in the first %lld directories only." = "只在前 %lld 个目录中检查了版本文件。"; "Version managers" = "版本管理器"; -"Version managers come from enabled plugins. Lithe looks for the files they watch in this project; it does not run or query them yet." = "版本管理器来自已启用的插件。Lithe 会在此项目中查找它们监视的文件,但暂不运行或查询它们。"; +"Version managers come from enabled plugins. Lithe asks them what is installed and what this project requires; they never install or trust anything for you." = "版本管理器来自已启用的插件。Lithe 会询问它们已安装的版本和此项目的要求;它们不会替你安装或信任任何内容。"; "Version not queried" = "版本未查询"; "Version not verified" = "版本未验证"; "Version status" = "版本状态"; @@ -4669,6 +4676,7 @@ "What Fetch removes or downloads before you start working." = "开始工作前,Fetch 会清理或下载什么。"; "What Fetch will do" = "Fetch 会做什么"; "What does this pull request change?" = "这个拉取请求更改了什么?"; +"What version managers report as global, then the home variables and commands this tool's plugin declares, read from the login shell. A home's version comes from the tool's own version command; a command found on PATH is not run." = "版本管理器报告的全局版本,以及插件为该工具声明的主目录变量和命令(从登录 Shell 中读取)。主目录的版本来自该工具自己的版本命令;在 PATH 中找到的命令不会运行。"; "When a session starts" = "会话开始时"; "When a turn ends" = "一轮结束时"; "When quitting Lithe" = "退出 Lithe 时"; @@ -9132,8 +9140,10 @@ "native.toolWindow" = "工具窗口"; "native.toolWindows" = "工具窗口"; "native.toolbarActions" = "工具栏操作"; +"native.toolchainSettings.answeredIn" = "%1$lld 毫秒内已回答"; "native.toolchainSettings.automaticDiscovery" = "自动检测"; "native.toolchainSettings.automaticStepInfo" = "在插件为该工具声明的位置中找到的安装。每个版本来自该工具自己的版本命令,使用其中最高的版本。项目内的安装永远不会运行。"; +"native.toolchainSettings.backendDidNotOfferProvider" = "插件后端没有提供此版本管理器。"; "native.toolchainSettings.consumers" = "使用方"; "native.toolchainSettings.couldNotReadToolchainSettings" = "无法读取工具链设置。"; "native.toolchainSettings.definition" = "定义"; @@ -9142,13 +9152,14 @@ "native.toolchainSettings.discovered" = "自动发现"; "native.toolchainSettings.discoveryDeclarationsUnchecked" = "无法检查已安装的工具链声明,未进行自动检测。"; "native.toolchainSettings.exportedEnvironment" = "导出环境变量"; +"native.toolchainSettings.firstDirectoriesQueried" = "只查询了前 %1$lld 个目录。"; "native.toolchainSettings.firstMatch" = "首个命中"; "native.toolchainSettings.fixed" = "固定"; "native.toolchainSettings.fixedInParent" = "上级目录固定"; "native.toolchainSettings.fixedInfo" = "固定版本只保存在这台电脑的 .lithe/toolchains/local.json 中,并作用于其下的子目录。此页面只读取,不会修改该文件。"; "native.toolchainSettings.fromDirectory" = "来自 %1$@"; "native.toolchainSettings.globalDefaults" = "全局默认"; -"native.toolchainSettings.globalStepInfo" = "插件为该工具声明的主目录变量和命令,从登录 Shell 中读取。主目录的版本来自该工具自己的版本命令;在 PATH 中找到的命令不会运行。版本管理器的全局版本暂未接入查询。"; +"native.toolchainSettings.globalStepInfo" = "版本管理器报告的全局版本,以及插件为该工具声明的主目录变量和命令(从登录 Shell 中读取)。主目录的版本来自该工具自己的版本命令;在 PATH 中找到的命令不会运行。"; "native.toolchainSettings.installationsCouldNotBeChecked" = "无法检查 %1$@ 的安装。"; "native.toolchainSettings.launchEnvironment" = "启动时环境"; "native.toolchainSettings.loginShell" = "登录 Shell"; @@ -9178,12 +9189,16 @@ "native.toolchainSettings.pinnedStepInfo" = "在这台电脑上为此目录保存的版本,优先级最高,也作用于其下的子目录。"; "native.toolchainSettings.projectRequirements" = "项目要求"; "native.toolchainSettings.projectRootDirectory" = "项目根目录"; -"native.toolchainSettings.projectStepInfo" = "此目录或上级目录中、由该工具的版本管理器监视的版本文件。其中要求的版本暂未查询。"; +"native.toolchainSettings.projectStepInfo" = "此目录或上级目录中、由该工具的版本管理器监视的版本文件,以及版本管理器报告的版本。"; +"native.toolchainSettings.providerDidNotAnswer" = "%1$@ 未回答:%2$@"; +"native.toolchainSettings.query" = "查询"; "native.toolchainSettings.queryMilliseconds" = "%1$lld 毫秒"; +"native.toolchainSettings.queryNotBuilt" = "无法构建工具链查询。"; "native.toolchainSettings.readAgain" = "重新读取"; "native.toolchainSettings.readResult" = "读取结果"; "native.toolchainSettings.reading" = "正在读取…"; "native.toolchainSettings.remoteUnavailable" = "远程工作区暂不支持工具链设置。"; +"native.toolchainSettings.replyUnreadable" = "无法读取答复。"; "native.toolchainSettings.reportsInstalledVersionsOnly" = "只报告已安装的版本"; "native.toolchainSettings.runtimeEnvironment" = "运行环境"; "native.toolchainSettings.runtimeEnvironmentIsolatedPreview" = "运行环境 — 隔离预览"; @@ -9196,7 +9211,7 @@ "native.toolchainSettings.tooManyFilesForOneTool" = "单个工具需要检查的文件过多,已跳过部分安装。"; "native.toolchainSettings.toolDefinitionInfo" = "这一行来自已启用插件的工具定义,Lithe 不内置任何工具。"; "native.toolchainSettings.toolchainSources" = "环境来源"; -"native.toolchainSettings.toolsInfo" = "每个工具采用第一个有值的来源,依次为固定版本、项目版本文件、全局默认、自动检测。已安装的版本通过运行该工具自己的版本命令来检查。版本管理器暂未接入查询,因此不显示版本文件所要求的版本。"; +"native.toolchainSettings.toolsInfo" = "每个工具采用第一个有值的来源,依次为固定版本、项目版本文件、全局默认、自动检测。已安装的版本通过运行该工具自己的版本命令来检查。已启用的版本管理器会报告版本文件所要求的版本。"; "native.toolchainSettings.turnedOffBySkipVariable" = "已由 LITHE_SKIP_SHELL_ENV 关闭"; "native.toolchainSettings.usingCachedPath" = "仍在使用上次保存的 PATH"; "native.toolchainSettings.usingLaunchEnvironment" = "仍在使用 Lithe 启动时的环境"; @@ -9209,12 +9224,13 @@ "native.toolchainSettings.versionFilesInfo" = "已启用的版本管理器所声明、且存在于此目录中的文件。没有自己版本文件的目录沿用最近上级目录的。"; "native.toolchainSettings.versionManagerDefinitionInfo" = "此版本管理器来自已启用的插件,Lithe 不内置任何版本管理器。"; "native.toolchainSettings.versionManagers" = "版本管理器"; -"native.toolchainSettings.versionManagersInfo" = "版本管理器来自已启用的插件。Lithe 会在此项目中查找它们监视的文件,但暂不运行或查询它们。"; +"native.toolchainSettings.versionManagersInfo" = "版本管理器来自已启用的插件。Lithe 会询问它们已安装的版本和此项目的要求;它们不会替你安装或信任任何内容。"; "native.toolchainSettings.versionNotQueried" = "版本未查询"; "native.toolchainSettings.versionNotVerified" = "版本未验证"; "native.toolchainSettings.versionTooOld" = "版本过低"; "native.toolchainSettings.versionsNotCheckedInTime" = "部分安装的版本未能及时检查。"; "native.toolchainSettings.watchedFiles" = "监视的文件"; +"native.toolchainSettings.workspaceNotOpenGlobalOnly" = "此工作区未在窗口中打开,因此只读取了全局版本。"; "native.tools" = "工具"; "native.toolsSent" = "发送的工具"; "native.total" = "总计"; diff --git a/macos/Sources/Lithe/Application/Composition/AppModelFeatureGraph.swift b/macos/Sources/Lithe/Application/Composition/AppModelFeatureGraph.swift index c347cc7f99..166960c30b 100644 --- a/macos/Sources/Lithe/Application/Composition/AppModelFeatureGraph.swift +++ b/macos/Sources/Lithe/Application/Composition/AppModelFeatureGraph.swift @@ -87,7 +87,8 @@ final class AppModelFeatureGraph { ) runtime = RuntimeSettingsFeatureModel(service: services.projectRuntimeService) toolchainSettings = ToolchainSettingsFeatureModel( - loader: services.toolchainSettingsLoader, loginShell: services.loginShellEnvironmentInspector + loader: services.toolchainSettingsLoader, loginShell: services.loginShellEnvironmentInspector, + inputWatcher: services.toolchainInputWatcher ) languageTooling = LanguageToolingFeatureModel( catalogSource: services.languageProviderCatalogSource, diff --git a/macos/Sources/Lithe/Application/Composition/AppServices.swift b/macos/Sources/Lithe/Application/Composition/AppServices.swift index 59ab2dcd3b..7e242c412f 100644 --- a/macos/Sources/Lithe/Application/Composition/AppServices.swift +++ b/macos/Sources/Lithe/Application/Composition/AppServices.swift @@ -30,6 +30,7 @@ final class AppServices { var projectGeneratorSources: (@MainActor () -> [ProjectGeneratorPluginSource])? /// Read-only plugin toolchain projection; absent until platform composition. var toolchainSettingsLoader: (any ToolchainSettingsLoading)? + var toolchainInputWatcher: (any ToolchainInputWatching)? /// Existing workspace tree projection; settings must not start a second filesystem scan. var toolchainSettingsDirectories: (@MainActor () -> [String])? /// The startup login-shell read, for settings; absent on platforms that do not read one. diff --git a/macos/Sources/Lithe/Application/Features/ToolchainSettingsFeatureModel.swift b/macos/Sources/Lithe/Application/Features/ToolchainSettingsFeatureModel.swift index bd10bdc830..a4751f8e68 100644 --- a/macos/Sources/Lithe/Application/Features/ToolchainSettingsFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/ToolchainSettingsFeatureModel.swift @@ -1,5 +1,7 @@ import Combine +import Darwin import Foundation +import LitheCoreContracts /// Owns only settings presentation and read lifecycle, not runtime discovery or launch selection. @MainActor @@ -19,11 +21,22 @@ final class ToolchainSettingsFeatureModel: ObservableObject { private let loader: (any ToolchainSettingsLoading)? private let loginShellInspector: (any LoginShellEnvironmentInspecting)? + private let inputWatcher: (any ToolchainInputWatching)? + private let reloadDelay: Duration private var generation = UUID() + private var reloadTask: Task? + private var inputObservation: AnyObject? - init(loader: (any ToolchainSettingsLoading)? = nil, loginShell: (any LoginShellEnvironmentInspecting)? = nil) { + init( + loader: (any ToolchainSettingsLoading)? = nil, + loginShell: (any LoginShellEnvironmentInspecting)? = nil, + inputWatcher: (any ToolchainInputWatching)? = nil, + reloadDelay: Duration = .milliseconds(350) + ) { self.loader = loader self.loginShellInspector = loginShell + self.inputWatcher = inputWatcher + self.reloadDelay = reloadDelay } var selectedDirectory: ToolchainSettingsSnapshot.Directory? { @@ -40,6 +53,8 @@ final class ToolchainSettingsFeatureModel: ObservableObject { /// Each load revokes older results; callers retain and cancel their structured UI task. func load(workspace: URL?) async { + reloadTask?.cancel() + reloadTask = nil await loadSnapshot(workspace: workspace) // A read the app started at launch is still running: show its outcome when it lands. if loginShell?.state == .reading, !Task.isCancelled { await rereadLoginShell() } @@ -56,6 +71,41 @@ final class ToolchainSettingsFeatureModel: ObservableObject { await loadSnapshot(workspace: workspaceURL) } + /// Any provider input revokes the whole snapshot, so old installations never mix with + /// new requirements. Nothing runs before the pages first load one. + func workspaceFilesChanged(_ changes: [WorkspaceFileChange]) { + guard let root = snapshot?.workspaceURL?.standardizedFileURL.path else { return } + let patterns = providers.flatMap(\.watchFiles) + let touched = changes.contains { change in + let path = change.fileURL.standardizedFileURL.path + guard path.hasPrefix(root + "/") else { return false } + let parts = path.dropFirst(root.count + 1).split(separator: "/").map(String.init) + return patterns.contains { pattern in + let count = pattern.split(separator: "/").count + return count <= parts.count && fnmatch(pattern, parts.suffix(count).joined(separator: "/"), FNM_PATHNAME) == 0 + } + } + if touched { scheduleReload() } + } + + /// Bursts of file events collapse into one reload; the latest reload replaces earlier ones. + private func scheduleReload() { + guard snapshot != nil else { return } + reloadTask?.cancel() + let delay = reloadDelay + reloadTask = Task { [weak self] in + try? await Task.sleep(for: delay) + guard !Task.isCancelled, let self else { return } + await self.loadSnapshot(workspace: self.workspaceURL) + } + } + + private func observeInputs(of snapshot: ToolchainSettingsSnapshot) { + inputObservation = inputWatcher?.watch(paths: snapshot.watch) { [weak self] in + Task { @MainActor [weak self] in self?.scheduleReload() } + } + } + private func loadSnapshot(workspace: URL?) async { let request = UUID() generation = request @@ -78,6 +128,7 @@ final class ToolchainSettingsFeatureModel: ObservableObject { throw SnapshotError.workspaceMismatch } snapshot = next + observeInputs(of: next) if !next.directories.contains(where: { $0.id == selectedDirectoryID }) { selectedDirectoryID = next.directories.first?.id ?? "." } @@ -110,6 +161,9 @@ final class ToolchainSettingsFeatureModel: ObservableObject { /// Revocation happens before any suspended load can return to this actor. func close() { generation = UUID() + reloadTask?.cancel() + reloadTask = nil + inputObservation = nil snapshot = nil workspaceURL = nil loadError = nil diff --git a/macos/Sources/Lithe/Core/Ports/ToolchainSettings.swift b/macos/Sources/Lithe/Core/Ports/ToolchainSettings.swift index c968a5f6ba..ed59ee3bd9 100644 --- a/macos/Sources/Lithe/Core/Ports/ToolchainSettings.swift +++ b/macos/Sources/Lithe/Core/Ports/ToolchainSettings.swift @@ -18,6 +18,17 @@ struct ToolchainSettingsSnapshot: Equatable, Sendable { var versionFiles: [VersionFile] = [] } + /// What a provider says one source asks for. `request` stays the manager's own syntax. + struct Requirement: Equatable, Sendable { + let request: String + /// The absolute file that states it. + let source: String + let providerID: String + /// The installation it resolves to; `nil` means the manager has none installed. + var home: String? = nil + var version: String? = nil + } + /// A machine-local saved choice, validated by Core but never probed. struct Pin: Equatable, Sendable { let version: String @@ -82,6 +93,9 @@ struct ToolchainSettingsSnapshot: Equatable, Sendable { var pin: Pin? = nil /// Version files watched by providers of this tool, nearest directory first. var versionFiles: [VersionFile] = [] + /// Provider answers for this directory, from the nearest queried one. + var requirements: [Requirement] = [] + var globalRequirements: [Requirement] = [] var candidate: Candidate? = nil /// `nil` when discovery did not run or could not check this tool. var detection: Detection? = nil @@ -90,12 +104,20 @@ struct ToolchainSettingsSnapshot: Equatable, Sendable { } struct Provider: Identifiable, Equatable, Sendable { + enum QueryState: Equatable, Sendable { + case notQueried + case answered(milliseconds: Int) + /// The provider's own message, shown unmodified. + case failed(String) + } + let id: String let title: String let pluginID: String let pluginName: String var tools: [String] = [] var watchFiles: [String] = [] + var queryState: QueryState = .notQueried } var workspaceURL: URL? = nil @@ -103,6 +125,8 @@ struct ToolchainSettingsSnapshot: Equatable, Sendable { var tools: [Tool] = [] var providers: [Provider] = [] var diagnostics: [String] = [] + /// Native inputs providers named; any change revokes this snapshot. + var watch: [String] = [] } /// A platform-owned, cancellable read operation; the application model owns stale-result rejection. @@ -110,6 +134,12 @@ protocol ToolchainSettingsLoading: Sendable { func load(workspace: URL?) async throws -> ToolchainSettingsSnapshot } +/// Reports changes to provider inputs outside the workspace, such as global configuration +/// and installation roots. Dropping the returned object ends the observation. +protocol ToolchainInputWatching: Sendable { + func watch(paths: [String], onChange: @escaping @Sendable () -> Void) -> AnyObject? +} + /// What the settings pages may show about the login-shell read. Only `PATH`'s /// directories are exposed; other variables may hold secrets and appear as a count. struct LoginShellEnvironmentSnapshot: Equatable, Sendable { diff --git a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift index 92f3600e7c..478ebc8131 100644 --- a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift +++ b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift @@ -1965,6 +1965,8 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { let workspaceConfiguration: ToolingJSONValue? /// Host services a plugin backend may request; omitted when none. let hostServices: [String]? + /// Manifest providers offered to a plugin backend; omitted when none. + let toolchainProviders: [String]? let runtimeExecutablePath: String? let jdtlsLaunchResources: LspJdtlsLaunchResourcesRequest? let cacheDirectory: String? @@ -2111,6 +2113,12 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { let timeoutMilliseconds: Int } + private struct LspToolchainRequest: Encodable { + let sessionId: String + let params: ToolingJSONValue + let timeoutMilliseconds: Int + } + private struct LspVcsRequest: Encodable { let sessionId: String let vcs: String @@ -3849,6 +3857,7 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { initializationOptions: ToolingJSONValue? = nil, workspaceConfiguration: ToolingJSONValue? = nil, hostServices: [String] = [], + toolchainProviders: [String] = [], runtimeExecutableURL: URL? = nil, jdtlsLaunchResources: JDTLSLaunchResources? = nil, cacheDirectoryURL: URL? = nil, @@ -3872,6 +3881,7 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { initializationOptions: initializationOptions, workspaceConfiguration: workspaceConfiguration, hostServices: hostServices.isEmpty ? nil : hostServices, + toolchainProviders: toolchainProviders.isEmpty ? nil : toolchainProviders, runtimeExecutablePath: runtimeExecutableURL?.standardizedFileURL.path, jdtlsLaunchResources: jdtlsLaunchResources.map { LspJdtlsLaunchResourcesRequest( @@ -4181,6 +4191,20 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { ) } + /// Sends one `toolchains/query` to a session that negotiated its provider. + func lspToolchainRequest( + sessionID: String, + params: ToolingJSONValue, + timeout: TimeInterval + ) -> Result { + executeResult( + command: "lsp.toolchainRequest", + payload: LspToolchainRequest( + sessionId: sessionID, params: params, timeoutMilliseconds: max(1, Self.milliseconds(timeout)) + ) + ) + } + /// Invokes a plugin function. Core refuses it unless the session belongs /// to `pluginID` and registered the function. func lspFunctionRequest( diff --git a/macos/Sources/Lithe/Core/Rust/RustLanguageServerRuntimeAdapter.swift b/macos/Sources/Lithe/Core/Rust/RustLanguageServerRuntimeAdapter.swift index c4d2f955b5..8642a40396 100644 --- a/macos/Sources/Lithe/Core/Rust/RustLanguageServerRuntimeAdapter.swift +++ b/macos/Sources/Lithe/Core/Rust/RustLanguageServerRuntimeAdapter.swift @@ -321,6 +321,49 @@ extension RustCoreBridge: LanguageServerRuntimeCore { }.mapError(Self.runtimeFailure) } + func requestToolchainProvider( + sessionID: String, + params: ToolingJSONValue, + timeout: TimeInterval + ) -> Result { + lspToolchainRequest(sessionID: sessionID, params: params, timeout: timeout) + .map { LanguageServerRuntimeOperation(operationID: $0.operationId) } + .mapError(Self.runtimeFailure) + } + + func startPluginToolchainBackend( + pluginID: String, + providerID: String, + executableURL: URL, + arguments: [String], + environment: [String: String], + rootURL: URL, + initializationOptions: ToolingJSONValue?, + hostServices: [String], + toolchainProviders: [String], + initializeTimeout: TimeInterval, + requestTimeout: TimeInterval, + shutdownTimeout: TimeInterval + ) -> Result { + lspStartServer( + providerID: providerID, + pluginID: pluginID, + executableURL: executableURL, + arguments: arguments, + environment: environment, + rootURL: rootURL, + workingDirectoryURL: rootURL, + initializationOptions: initializationOptions, + hostServices: hostServices, + toolchainProviders: toolchainProviders, + initializeTimeout: initializeTimeout, + requestTimeout: requestTimeout, + shutdownTimeout: shutdownTimeout + ).map { + LanguageServerRuntimeStart(sessionID: $0.sessionId, state: $0.state, processID: $0.processId) + }.mapError(Self.runtimeFailure) + } + func startPluginLanguageServer( pluginID: String, providerID: String, diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+LanguageServerRuntime.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+LanguageServerRuntime.swift index e73638c5c4..ab730fd04e 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+LanguageServerRuntime.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+LanguageServerRuntime.swift @@ -201,6 +201,7 @@ extension AppModel { runFeatureIfActive?.markDependencyFilesChanged(changes) handleBuildSystemFileChanges(changes) handleVcsProviderFileChanges(changes) + toolchainSettingsFeature.workspaceFilesChanged(changes) let maven = mavenFeatureIfActive let forwarded = changes.filter { change in if Self.isWorkspaceDependencyMetadata( diff --git a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index 880d16e033..d736349fee 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -1274,8 +1274,33 @@ final class MacServiceContainer { } }, directories: { [weak services] _ in services?.toolchainSettingsDirectories?() ?? [] }, - isRemoteWorkspace: { remoteWorkspaces.contains($0) } + isRemoteWorkspace: { remoteWorkspaces.contains($0) }, + discover: { [pluginBackends, fileStorage] request in + let rootIDs = PluginWorkspaceRootIDs( + secret: MacPluginRootIDSecret.load(from: MacPluginRootIDSecret.fileURL(fileStorage: fileStorage))) + return await MacToolchainDiscovery( + queryProvider: { pluginID, providerID, params, timeout in + try await pluginBackends.queryToolchainProvider( + providerID, params: params, ofPlugin: pluginID, timeout: timeout) + }, + workspaceContext: { workspace, pluginID in + await MainActor.run { + let path = workspace.standardizedFileURL.path + for context in PluginWorkspaceContexts.shared.contexts { + guard case .local = context.authority, + let folder = context.folders.first(where: { $0.localURL.standardizedFileURL.path == path }) + else { continue } + return MacToolchainDiscovery.WorkspaceContext( + contextID: context.contextID, generation: context.generation, + rootID: rootIDs.rootID(of: folder.location, pluginID: pluginID)) + } + return nil + } + } + ).discover(request) + } ) + services.toolchainInputWatcher = MacToolchainInputWatcher() services.projectGeneratorSources = { [pluginManager] in // Enablement is read on every call, so a plugin enabled from the // window shows its entries and satisfies requirements at once. diff --git a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift index 8523b178d8..6798ee444a 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift @@ -210,6 +210,25 @@ final class MacPluginBackendSessions: PluginBackendRequesting, PluginBackendActi ) } + /// Provider IDs a normalized manifest declares, offered during `initialize`. + static func toolchainProviderIDs(ofManifest manifest: Data) -> [String] { + guard let object = try? JSONSerialization.jsonObject(with: manifest) as? [String: Any], + let providers = object["toolchainProviders"] as? [[String: Any]] else { return [] } + return providers.compactMap { $0["id"] as? String } + } + + /// Asks a plugin's provider, starting or reusing its one backend process. + /// Returns `{ id, response }` unvalidated; see `toolchains.decodeReply`. + func queryToolchainProvider( + _ providerID: String, params: ToolingJSONValue, ofPlugin pluginID: String, timeout: TimeInterval + ) async throws -> ToolingJSONValue { + let session = try await session(for: pluginID) + guard session.toolchainProviders.contains(providerID) else { + throw UnavailableError(message: L10n.string("The plugin backend did not offer this version manager.")) + } + return try await session.toolchainRequest(params: params, timeout: timeout) + } + /// Plugins whose backend starts on `event`, such as `onWorkspaceOpen` /// or `onCommand:`. `owner` limits a command to the plugin whose UI /// module declares it. @@ -482,7 +501,8 @@ final class MacPluginBackendSessions: PluginBackendRequesting, PluginBackendActi // are the backend's only callers. moduleID: ModuleID("\(pluginID).ui"), pluginID: pluginID, - hostServices: hostServices + hostServices: hostServices, + toolchainProviders: packages[pluginID].map { Self.toolchainProviderIDs(ofManifest: $0.manifest) } ?? [] ) let endBackendProcesses = endBackendProcesses let hostDispatcher = hostDispatcher diff --git a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacToolchainDiscovery+Providers.swift b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacToolchainDiscovery+Providers.swift new file mode 100644 index 0000000000..3977a007c3 --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacToolchainDiscovery+Providers.swift @@ -0,0 +1,205 @@ +import Foundation +import LitheCoreContracts +import LitheModuleAPI + +// Note: 查询、失效与过期结果的决策见 .agents/notes/proposed/architecture/2026-10-31-plugin-injected-toolchains.md + +/// Queries the enabled providers for the settings pages. A global query always runs, so +/// an unusable project still lists installations; each directory then gets its own query. +/// Every reply passes `toolchains.decodeReply` with native facts read here, so a provider +/// never decides what a path is. Providers are only asked; nothing here installs or trusts. +extension MacToolchainDiscovery { + private struct Provider { + let pluginID: String + let id: String + let tools: [String] + var qualified: String { "\(pluginID):\(id)" } + } + + /// Returns accepted installations, each `{tool, home, provider}`. + func queryProviders( + _ request: Request, registry: [String: Any], tools: [[String: Any]], root: String, caseSensitive: Bool, + into result: inout Result + ) async -> [[String: Any]] { + guard queryProvider != nil else { return [] } + let providers = Self.providers(of: tools) + var directories = Array(request.directories.prefix(Self.queryDirectoryBudget)) + if request.directories.count > directories.count, !providers.isEmpty { + result.diagnostics.append(L10n.string("Only the first \(Self.queryDirectoryBudget) directories were queried.")) + } + if !directories.contains(".") { directories.insert(".", at: 0) } + var installations: [String: [String: Any]] = [:] + var watch: Set = [] + for provider in providers { + let context = await workspaceContext(request.workspace, provider.pluginID) + var failure: String? + let started = ContinuousClock.now + for directory in [nil] + directories.map(Optional.some) where directory == nil || context != nil { + if Task.isCancelled { return [] } + let scope = directory.flatMap { directory in context.map { (directory, $0) } } + switch await query(provider, scope: scope, registry: registry, tools: tools, root: root, + caseSensitive: caseSensitive) { + case .failure(let message): + failure = failure ?? message + case .success(let snapshot): + for row in snapshot["installations"] as? [[String: Any]] ?? [] { + guard let tool = row["tool"] as? String, let home = row["home"] as? String else { continue } + installations["\(tool)\n\(home)"] = ["tool": tool, "home": home, "provider": provider.qualified] + } + let versions = Self.versions(snapshot) + for row in snapshot["statements"] as? [[String: Any]] ?? [] { + guard let requirement = Self.requirement(row, versions: versions, provider: provider) else { continue } + let tool = row["tool"] as? String ?? "" + if row["scope"] as? String == "global" { + if !(result.globalRequirements[tool] ?? []).contains(requirement) { + result.globalRequirements[tool, default: []].append(requirement) + } + } else if let directory { + result.requirements[directory, default: [:]][tool, default: []].append(requirement) + } + } + watch.formUnion(snapshot["watch"] as? [String] ?? []) + } + } + if context == nil { + failure = failure ?? L10n.string("This workspace is not open in a window, so only global versions were read.") + } + let elapsed = started.duration(to: .now).components + let milliseconds = Int(elapsed.seconds) * 1_000 + Int(elapsed.attoseconds / 1_000_000_000_000_000) + result.providerStates[provider.qualified] = failure.map { .failed($0) } ?? .answered(milliseconds: milliseconds) + if let failure { result.diagnostics.append(L10n.string("\(provider.id) did not answer: \(failure)")) } + } + result.watch = watch.sorted() + return installations.keys.sorted().compactMap { installations[$0] } + } + + private enum Outcome { + case success([String: Any]) + case failure(String) + } + + /// One query and its validation. `scope` is `nil` for a global-only query. + private func query( + _ provider: Provider, scope: (directory: String, context: WorkspaceContext)?, registry: [String: Any], + tools: [[String: Any]], root: String, caseSensitive: Bool + ) async -> Outcome { + guard let queryProvider else { return .failure("") } + let generation = Self.generations.withLock { value -> UInt64 in + value += 1 + return value + } + let params: [String: Any] = [ + "operationID": "toolchain-\(generation)", "generation": generation, "providerID": provider.id, + "authority": ["kind": "local"], + "context": scope.map { ["contextID": $0.context.contextID, "generation": $0.context.generation] as [String: Any] } + ?? NSNull(), + "workspace": scope.map { ["rootID": $0.context.rootID, "root": root, "directory": $0.directory] as [String: Any] } + ?? NSNull(), + "tools": provider.tools, + ] + guard let wire = ToolingJSONValue.fromFoundation(params) else { + return .failure(L10n.string("The toolchain query could not be built.")) + } + let reply: [String: Any] + do { + let value = try await queryProvider(provider.pluginID, provider.id, wire, Self.queryTimeout) + guard let object = value.foundationObject as? [String: Any], object["id"] is NSNumber, + let response = object["response"] as? [String: Any] + else { return .failure(L10n.string("The reply could not be read.")) } + reply = ["id": object["id"]!, "response": response] + } catch { + return .failure(error.localizedDescription) + } + var facts = Self.FactList(caseSensitive: caseSensitive) + facts.add(root) + if let directory = scope?.directory, directory != "." { facts.add(root + "/" + directory) } + Self.addFacts(of: reply["response"] as? [String: Any] ?? [:], tools: tools, to: &facts) + let outcome = decodeReply([ + "registry": registry, "pluginID": provider.pluginID, + "request": ["jsonrpc": "2.0", "id": reply["id"]!, "method": "toolchains/query", "params": params], + "response": reply["response"]!, "currentGeneration": generation, "filesystem": facts.values, + "platform": "macos", "filesystemCaseSensitive": caseSensitive, "workspaceRoots": [root], + ]) + if outcome?["status"] as? String == "ok", let snapshot = outcome?["snapshot"] as? [String: Any] { + return .success(snapshot) + } + let error = outcome?["error"] as? [String: Any] + return .failure(error?["message"] as? String ?? L10n.string("The reply could not be read.")) + } + + private struct FactList { + let caseSensitive: Bool + var values: [[String: Any]] = [] + private var keys: Set = [] + + init(caseSensitive: Bool) { self.caseSensitive = caseSensitive } + + mutating func add(_ path: String) { + guard path.hasPrefix("/"), path.utf8.count <= 4_096, values.count < MacToolchainDiscovery.factBudget, + keys.insert(caseSensitive ? path : path.lowercased()).inserted + else { return } + values.append(MacToolchainDiscovery.fact(of: path)) + } + } + + /// Reads the paths a reply names. An unreadable shape adds nothing, and Core refuses it. + private static func addFacts(of response: [String: Any], tools: [[String: Any]], to facts: inout FactList) { + guard let result = response["result"] as? [String: Any] else { return } + // Executable layouts and `${home}` prepends: what resolving a provider home reads. + let layouts = Dictionary(tools.compactMap { tool -> (String, [String])? in + guard let id = tool["tool"] as? String else { return nil } + let executables = (tool["executableLayouts"] as? [[String: Any]] ?? []).filter(applies) + .compactMap { $0["path"] as? String } + let prepends = (tool["pathPrepend"] as? [String] ?? []).compactMap { template -> String? in + guard template.hasPrefix("${home}/") else { return nil } + return String(template.dropFirst("${home}/".count)) + } + return (id, executables + prepends) + }, uniquingKeysWith: { first, _ in first }) + for row in result["installations"] as? [[String: Any]] ?? [] { + guard let home = row["home"] as? String else { continue } + facts.add(home) + for layout in layouts[row["tool"] as? String ?? ""] ?? [] { facts.add(home + "/" + layout) } + } + for row in result["statements"] as? [[String: Any]] ?? [] { + if let source = row["source"] as? String { facts.add(source) } + } + } + + /// Providers of enabled definitions, each with the sorted tools it may be asked about. + private static func providers(of tools: [[String: Any]]) -> [Provider] { + var byProvider: [String: Set] = [:] + for tool in tools { + guard let id = tool["tool"] as? String else { continue } + for provider in tool["providers"] as? [String] ?? [] { byProvider[provider, default: []].insert(id) } + } + return byProvider.keys.sorted().compactMap { qualified in + guard let (plugin, provider) = qualified.lastColonSplit, let tools = byProvider[qualified] else { return nil } + return Provider(pluginID: plugin, id: provider, tools: tools.sorted()) + } + } + + private static func versions(_ snapshot: [String: Any]) -> [String: String] { + var versions: [String: String] = [:] + for row in snapshot["installations"] as? [[String: Any]] ?? [] { + if let home = row["home"] as? String, let version = row["version"] as? String { versions[home] = version } + } + return versions + } + + private static func requirement( + _ row: [String: Any], versions: [String: String], provider: Provider + ) -> ToolchainSettingsSnapshot.Requirement? { + guard let request = row["request"] as? String, let source = row["source"] as? String else { return nil } + let home = row["installation"] as? String + return .init(request: request, source: source, providerID: provider.qualified, home: home, + version: home.flatMap { versions[$0] }) + } +} + +private extension String { + var lastColonSplit: (String, String)? { + guard let index = lastIndex(of: ":") else { return nil } + return (String(self[.. String = { NSHomeDirectory() } /// One `toolchains.*` command; `nil` when Core refuses the input. var core: @Sendable (String, [String: Any]) -> [String: Any]? = Self.callCore /// The output of a successful probe; failures are not cached and Core reports them. var probe: @Sendable (_ executable: String, _ arguments: [String]) -> String? = Self.runProbe + /// Asks a plugin's provider and returns `{ id, response }`; `nil` leaves providers unqueried. + var queryProvider: (@Sendable (_ pluginID: String, _ providerID: String, _ params: ToolingJSONValue, + _ timeout: TimeInterval) async throws -> ToolingJSONValue)? = nil + var workspaceContext: @Sendable (_ workspace: URL, _ pluginID: String) async -> WorkspaceContext? = { _, _ in nil } + /// `toolchains.decodeReply`, keeping a refusal's code and message. + var decodeReply: @Sendable ([String: Any]) -> [String: Any]? = Self.callDecodeReply func discover(_ request: Request) async -> Result { var result = Result() @@ -66,10 +95,13 @@ struct MacToolchainDiscovery: Sendable { .volumeSupportsCaseSensitiveNames ?? false var scanner = Scanner(home: homeDirectory(), environment: request.environment, workspace: root, caseSensitive: caseSensitive) + let answers = await queryProviders( + request, registry: registry, tools: tools, root: root, caseSensitive: caseSensitive, into: &result) + if Task.isCancelled { return result } var inputs: [Input] = [] for tool in tools { guard let id = tool["tool"] as? String else { continue } - inputs.append(scanner.input(tool: id, plan: tool)) + inputs.append(scanner.input(tool: id, plan: tool, reported: answers.filter { $0["tool"] as? String == id })) } result.diagnostics += scanner.diagnostics @@ -111,6 +143,8 @@ struct MacToolchainDiscovery: Sendable { let tool: String let arguments: [String] let installations: [[String: Any]] + /// Homes `toolchains.decodeReply` accepted, with their qualified provider. + let reported: [[String: Any]] let environment: [String: String] let facts: [[String: Any]] /// Canonical scanned entry by the canonical home Core reports, for homes below their entry. @@ -129,7 +163,7 @@ struct MacToolchainDiscovery: Sendable { let payload: [String: Any] = [ "registry": registry, "tool": input.tool, "platform": "macos", "filesystemCaseSensitive": caseSensitive, "workspaceRoot": root, "filesystem": input.facts, "systemInstallations": input.installations, - "environment": input.environment, "probes": probes + "providerInstallations": input.reported, "environment": input.environment, "probes": probes ] return core("toolchains.candidates", payload)?["candidates"] as? [String: Any] } @@ -213,7 +247,7 @@ struct MacToolchainDiscovery: Sendable { self.caseSensitive = caseSensitive } - mutating func input(tool: String, plan: [String: Any]) -> Input { + mutating func input(tool: String, plan: [String: Any], reported: [[String: Any]]) -> Input { var facts = FactSet(caseSensitive: caseSensitive) facts.add(fact(workspace)) let layouts = (plan["executableLayouts"] as? [[String: Any]] ?? []).filter(MacToolchainDiscovery.applies) @@ -246,7 +280,8 @@ struct MacToolchainDiscovery: Sendable { else { continue } variables[variable] = value } - for home in homes + variables.values.map(Self.normalized) { + let reportedHomes = reported.compactMap { $0["home"] as? String } + for home in homes + reportedHomes + variables.values.map(Self.normalized) { let found = fact(home) facts.add(found) let canonical = found["kind"] as? String == "directory" ? found["canonicalPath"] as? String : nil @@ -263,7 +298,9 @@ struct MacToolchainDiscovery: Sendable { } let version = plan["version"] as? [String: Any] return Input(tool: tool, arguments: version?["arguments"] as? [String] ?? [], - installations: homes.map { ["tool": tool, "home": $0] }, environment: variables, + installations: homes.map { ["tool": tool, "home": $0] }, + reported: reported.map { ["tool": tool, "home": $0["home"] ?? "", "provider": $0["provider"] ?? ""] }, + environment: variables, facts: facts.values, locations: locations) } @@ -325,16 +362,7 @@ struct MacToolchainDiscovery: Sendable { /// Metadata of the object a path resolves to, as `execve` and Core's canonical form see it. private mutating func fact(_ path: String) -> [String: Any] { if let fact = known[path] { return fact } - var status = stat() - let fact: [String: Any] - if stat(path, &status) == 0, let canonical = MacToolchainDiscovery.realpath(path) { - fact = status.st_mode & S_IFMT == S_IFDIR - ? ["path": path, "canonicalPath": canonical, "kind": "directory"] - : ["path": path, "canonicalPath": canonical, "kind": "file", - "executable": status.st_mode & S_IFMT == S_IFREG && access(path, X_OK) == 0] - } else { - fact = ["path": path, "canonicalPath": path, "kind": "missing"] - } + let fact = MacToolchainDiscovery.fact(of: path) known[path] = fact return fact } @@ -364,6 +392,18 @@ struct MacToolchainDiscovery: Sendable { } } + /// Metadata of the object a path resolves to, as `execve` and Core's canonical form see it. + static func fact(of path: String) -> [String: Any] { + var status = stat() + guard stat(path, &status) == 0, let canonical = realpath(path) else { + return ["path": path, "canonicalPath": path, "kind": "missing"] + } + return status.st_mode & S_IFMT == S_IFDIR + ? ["path": path, "canonicalPath": canonical, "kind": "directory"] + : ["path": path, "canonicalPath": canonical, "kind": "file", + "executable": status.st_mode & S_IFMT == S_IFREG && access(path, X_OK) == 0] + } + /// Facts sent to Core: one per path under the platform's case policy, within its limit. private struct FactSet { let caseSensitive: Bool @@ -385,7 +425,7 @@ struct MacToolchainDiscovery: Sendable { } } - private static func applies(_ value: [String: Any]) -> Bool { + static func applies(_ value: [String: Any]) -> Bool { (value["platforms"] as? [String]).map { $0.contains("macos") } ?? true } @@ -424,6 +464,14 @@ struct MacToolchainDiscovery: Sendable { return object } + @Sendable static func callDecodeReply(_ payload: [String: Any]) -> [String: Any]? { + guard let value = ToolingJSONValue.fromFoundation(payload) else { return nil } + let outcome: Swift.Result = RustCoreBridge() + .executeResult(command: "toolchains.decodeReply", payload: value) + guard case let .success(data) = outcome else { return nil } + return data.foundationObject as? [String: Any] + } + /// Runs the definition's version arguments without a shell, outside the workspace, with /// a five-second deadline. The cache key includes the arguments and the file identity. @Sendable static func runProbe(_ executable: String, _ arguments: [String]) -> String? { diff --git a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacToolchainInputWatcher.swift b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacToolchainInputWatcher.swift new file mode 100644 index 0000000000..7866fbce3a --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacToolchainInputWatcher.swift @@ -0,0 +1,68 @@ +import CoreServices +import Foundation + +/// Watches provider inputs outside the workspace with one FSEvents stream. A named file +/// is watched through its parent, so creating an absent input is reported too. +struct MacToolchainInputWatcher: ToolchainInputWatching { + /// Bounds the stream; providers may name at most 256 paths per reply. + static let pathBudget = 1_024 + + func watch(paths: [String], onChange: @escaping @Sendable () -> Void) -> AnyObject? { + let roots = Self.roots(of: paths) + return roots.isEmpty ? nil : Observation(roots: roots, onChange: onChange) + } + + /// The nearest existing directory of each path, without repeats. + static func roots(of paths: [String]) -> [String] { + var roots: Set = [] + for path in paths.prefix(pathBudget) where path.hasPrefix("/") { + var current = path + var isDirectory: ObjCBool = false + while current != "/" && !(FileManager.default.fileExists(atPath: current, isDirectory: &isDirectory) && isDirectory.boolValue) { + current = (current as NSString).deletingLastPathComponent + } + if current != "/" { roots.insert(current) } + } + return roots.sorted() + } + + private final class Observation { + private final class Callback { + let onChange: @Sendable () -> Void + init(_ onChange: @escaping @Sendable () -> Void) { self.onChange = onChange } + } + + private let queue = DispatchQueue(label: "app.lithe.toolchain-inputs", qos: .utility) + private let callback: Callback + private var stream: FSEventStreamRef? + + init(roots: [String], onChange: @escaping @Sendable () -> Void) { + callback = Callback(onChange) + var context = FSEventStreamContext( + version: 0, info: Unmanaged.passUnretained(callback).toOpaque(), + retain: nil, release: nil, copyDescription: nil + ) + guard let stream = FSEventStreamCreate( + nil, + { _, info, _, _, _, _ in + guard let info else { return } + Unmanaged.fromOpaque(info).takeUnretainedValue().onChange() + }, + &context, roots as CFArray, FSEventStreamEventId(kFSEventStreamEventIdSinceNow), 0.35, + FSEventStreamCreateFlags(kFSEventStreamCreateFlagNoDefer) + ) else { return } + FSEventStreamSetDispatchQueue(stream, queue) + FSEventStreamStart(stream) + self.stream = stream + } + + deinit { + guard let stream else { return } + FSEventStreamStop(stream) + FSEventStreamInvalidate(stream) + FSEventStreamRelease(stream) + // Invalidation stops new callbacks; this drains one already on the queue. + queue.sync {} + } + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacToolchainSettingsLoader.swift b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacToolchainSettingsLoader.swift index 86782745a2..f26c155174 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacToolchainSettingsLoader.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacToolchainSettingsLoader.swift @@ -33,8 +33,8 @@ enum ToolchainSettingsLoadError: LocalizedError, Equatable { } /// Reads inert declarations, saved pins and filesystem facts. The injected discovery -/// expands definition scans and probes versions Core asks for; this loader never -/// queries a provider, writes a file or selects a runtime for a launch. +/// expands definition scans, queries providers and probes versions Core asks for; this +/// loader never writes a file or selects a runtime for a launch. struct MacToolchainSettingsLoader: ToolchainSettingsLoading { /// Bounds the existence checks for version files; the tree projection can hold 1,024 directories. static let versionFileCheckBudget = 16_384 @@ -211,7 +211,8 @@ struct MacToolchainSettingsLoader: ToolchainSettingsLoading { let environment = io.environment() let candidates = definitions.map { candidate(for: $0, environment: environment, isExecutable: io.isExecutable) } let discovered = definitions.isEmpty ? MacToolchainDiscovery.Result() - : await io.discover(.init(workspace: workspace, sources: enabled, environment: environment)) + : await io.discover(.init(workspace: workspace, sources: enabled, environment: environment, + directories: ["."] + found.keys.filter { $0 != "." }.sorted(by: treeOrder))) try Task.checkCancellation() diagnostics += discovered.diagnostics for definition in definitions where discovered.unchecked.contains(definition.id) { @@ -230,14 +231,24 @@ struct MacToolchainSettingsLoader: ToolchainSettingsLoading { files += (found[id] ?? []).filter { !watching.isDisjoint(with: $0.providerIDs) } current = id == "." ? nil : parent(id) } + // A directory without its own version file answers as its nearest queried ancestor. + var queried = directory.id + while queried != ".", found[queried] == nil { queried = parent(queried) } rows.append(.init(id: definition.id, directoryID: directory.id, displayName: definition.name, pluginID: definition.pluginID, pluginName: definition.pluginName, iconName: definition.icon, pin: saved.map { .init(version: $0.version, home: $0.home, directory: $0.directory) }, - versionFiles: files, candidate: candidate, detection: discovered.detections[definition.id])) + versionFiles: files, requirements: discovered.requirements[queried]?[definition.id] ?? [], + globalRequirements: discovered.globalRequirements[definition.id] ?? [], + candidate: candidate, detection: discovered.detections[definition.id])) } } - return .init(workspaceURL: workspace, directories: directories, tools: rows, providers: providers, - diagnostics: diagnostics) + let queried = providers.map { provider in + var result = provider + result.queryState = discovered.providerStates[provider.id] ?? .notQueried + return result + } + return .init(workspaceURL: workspace, directories: directories, tools: rows, providers: queried, + diagnostics: diagnostics, watch: discovered.watch) } /// Literal provider watch files that exist as regular files; glob patterns stay with the provider. diff --git a/macos/Sources/Lithe/Views/App/ToolchainEnvironmentSettingsView.swift b/macos/Sources/Lithe/Views/App/ToolchainEnvironmentSettingsView.swift index b5e3fa7a74..4c04449afd 100644 --- a/macos/Sources/Lithe/Views/App/ToolchainEnvironmentSettingsView.swift +++ b/macos/Sources/Lithe/Views/App/ToolchainEnvironmentSettingsView.swift @@ -189,7 +189,7 @@ struct ToolchainEnvironmentSettingsView: View { private func toolsGroup(_ snapshot: ToolchainSettingsSnapshot) -> some View { let tools = feature.tools return ToolchainSettingsGroup(Text("Tools"), accessory: { - LitheInfoTip("Each tool uses the first source that has a value: a fixed version, then project version files, then global defaults, then automatic discovery. Installed versions are checked by running each tool's own version command. Version managers are not queried yet, so the versions that version files ask for are not shown.") + LitheInfoTip("Each tool uses the first source that has a value: a fixed version, then project version files, then global defaults, then automatic discovery. Installed versions are checked by running each tool's own version command. Enabled version managers report what version files ask for.") Spacer(minLength: 8) Button(action: refresh) { HStack(spacing: 4) { @@ -406,10 +406,10 @@ private struct ToolchainToolRow: View { private var projectStep: some View { ToolchainResolutionStep( number: 2, title: "Project requirements", - info: "Version files in this directory or its parents, watched by the version managers of this tool. The versions they ask for are not queried yet.", - isFirstMatch: firstMatch == 2, hasFact: !tool.versionFiles.isEmpty + info: "Version files in this directory or its parents, watched by the version managers of this tool, and the versions those managers report for them.", + isFirstMatch: firstMatch == 2, hasFact: !tool.versionFiles.isEmpty || !tool.requirements.isEmpty ) { - if tool.versionFiles.isEmpty { + if tool.versionFiles.isEmpty && tool.requirements.isEmpty { Text("No version file").foregroundStyle(LitheTheme.tertiaryText) } else { ForEach(tool.versionFiles, id: \.path) { file in @@ -420,20 +420,46 @@ private struct ToolchainToolRow: View { monospaced(file.path, size: 11, color: LitheTheme.primaryText, truncation: .head) } } - Text("Version not queried").font(LitheTheme.uiFont(size: 10.5)).foregroundStyle(LitheTheme.tertiaryText) + if tool.requirements.isEmpty { + Text("Version not queried").font(LitheTheme.uiFont(size: 10.5)).foregroundStyle(LitheTheme.tertiaryText) + } } + requirementRows(tool.requirements) } .accessibilityElement(children: .contain) .accessibilityIdentifier("toolchain-step:" + tool.id + ":project") } + /// A provider's answer: the manager's own request text and what it resolves to. + @ViewBuilder + private func requirementRows(_ requirements: [ToolchainSettingsSnapshot.Requirement]) -> some View { + ForEach(requirements, id: \.source) { requirement in + VStack(alignment: .leading, spacing: 1) { + HStack(spacing: 4) { + if let title = providers.first(where: { $0.id == requirement.providerID })?.title { + ToolchainSettingsTag(verbatim: title) + } + monospaced(requirement.request, size: 11, color: LitheTheme.primaryText, truncation: .tail) + if let version = requirement.version { + Text(verbatim: version).font(LitheTheme.uiFont(size: 10.5)).foregroundStyle(LitheTheme.secondaryText) + } else if requirement.home == nil { + Text("Not installed").font(LitheTheme.uiFont(size: 10.5)).foregroundStyle(LitheTheme.tertiaryText) + } + } + monospaced(ToolchainSettingsText.abbreviated(requirement.source), size: 10.5, + color: LitheTheme.tertiaryText, truncation: .head) + } + } + } + private var globalStep: some View { let shell = detection?.shell ?? [] return ToolchainResolutionStep( number: 3, title: "Global defaults", - info: "The home variables and commands this tool's plugin declares, read from the login shell. A home's version comes from the tool's own version command; a command found on PATH is not run. Version managers' global versions are not queried yet.", - isFirstMatch: firstMatch == 3, hasFact: !shell.isEmpty || tool.candidate != nil + info: "What version managers report as global, then the home variables and commands this tool's plugin declares, read from the login shell. A home's version comes from the tool's own version command; a command found on PATH is not run.", + isFirstMatch: firstMatch == 3, hasFact: !shell.isEmpty || tool.candidate != nil || !tool.globalRequirements.isEmpty ) { + requirementRows(tool.globalRequirements) ForEach(shell, id: \.variable) { row in // A long variable name leaves no room for the version beside it in a narrow card. ViewThatFits(in: .horizontal) { diff --git a/macos/Sources/Lithe/Views/App/ToolchainSourcesSettingsView.swift b/macos/Sources/Lithe/Views/App/ToolchainSourcesSettingsView.swift index 798b2b5fc6..65c8f25a03 100644 --- a/macos/Sources/Lithe/Views/App/ToolchainSourcesSettingsView.swift +++ b/macos/Sources/Lithe/Views/App/ToolchainSourcesSettingsView.swift @@ -148,7 +148,7 @@ struct ToolchainSourcesSettingsView: View { private var providersGroup: some View { let providers = feature.providers return ToolchainSettingsGroup(Text("Version managers"), accessory: { - LitheInfoTip("Version managers come from enabled plugins. Lithe looks for the files they watch in this project; it does not run or query them yet.") + LitheInfoTip("Version managers come from enabled plugins. Lithe asks them what is installed and what this project requires; they never install or trust anything for you.") }) { if providers.isEmpty { HStack(spacing: 10) { @@ -233,6 +233,16 @@ struct ToolchainSourcesSettingsView: View { } } } + ToolchainSettingsMetaLine("Query") { + switch provider.queryState { + case .notQueried: + Text("Not queried").foregroundStyle(LitheTheme.tertiaryText) + case .answered(let milliseconds): + Text("Answered in \(milliseconds) ms").foregroundStyle(LitheTheme.secondaryText) + case .failed(let message): + Text(verbatim: message).foregroundStyle(LitheTheme.tertiaryText).textSelection(.enabled) + } + } ToolchainSettingsMetaLine("Definition", info: "This version manager comes from an enabled plugin. Lithe has no built-in version managers.") { LitheSettingsPill(verbatim: provider.pluginName, wraps: true) Text(verbatim: provider.id) diff --git a/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift b/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift index fcc55e628a..2feec3f66b 100644 --- a/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift +++ b/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift @@ -487,6 +487,29 @@ package protocol LanguageServerRuntimeCore: Sendable { arguments: ToolingJSONValue, timeout: TimeInterval ) -> Result + /// Sends one `toolchains/query`. Its completion is a `requestCompleted` + /// event whose `result.value` is `{ id, response }`, the raw reply the + /// caller passes to `toolchains.decodeReply` with the same JSON-RPC ID. + func requestToolchainProvider( + sessionID: String, + params: ToolingJSONValue, + timeout: TimeInterval + ) -> Result + /// Starts a plugin backend and offers it the manifest's toolchain providers. + func startPluginToolchainBackend( + pluginID: String, + providerID: String, + executableURL: URL, + arguments: [String], + environment: [String: String], + rootURL: URL, + initializationOptions: ToolingJSONValue?, + hostServices: [String], + toolchainProviders: [String], + initializeTimeout: TimeInterval, + requestTimeout: TimeInterval, + shutdownTimeout: TimeInterval + ) -> Result /// Starts a process a plugin owns. Only such a session negotiates plugin /// functions, and Core lets it serve that plugin's functions alone. func startPluginLanguageServer( @@ -728,6 +751,41 @@ package extension LanguageServerRuntimeCore { )) } + func requestToolchainProvider( + sessionID _: String, + params _: ToolingJSONValue, + timeout _: TimeInterval + ) -> Result { + .failure(LanguageServerRuntimeFailure( + code: "not_supported", + message: "Toolchain-provider requests are not supported by this runtime." + )) + } + + /// Runtimes without the protocol start the backend without the offer, so + /// it negotiates no provider and receives no query. + func startPluginToolchainBackend( + pluginID: String, + providerID: String, + executableURL: URL, + arguments: [String], + environment: [String: String], + rootURL: URL, + initializationOptions: ToolingJSONValue?, + hostServices: [String], + toolchainProviders _: [String], + initializeTimeout: TimeInterval, + requestTimeout: TimeInterval, + shutdownTimeout: TimeInterval + ) -> Result { + startPluginLanguageServer( + pluginID: pluginID, providerID: providerID, executableURL: executableURL, arguments: arguments, + environment: environment, rootURL: rootURL, initializationOptions: initializationOptions, + hostServices: hostServices, + initializeTimeout: initializeTimeout, requestTimeout: requestTimeout, shutdownTimeout: shutdownTimeout + ) + } + /// Runtimes without plugin ownership start a host-owned session, which /// serves no plugin functions. func startPluginLanguageServer( diff --git a/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift b/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift index ed38c27299..9ed04a75d0 100644 --- a/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift +++ b/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift @@ -53,6 +53,8 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { private let pluginID: String? /// Host services Core lets this plugin backend request. private let hostServices: [String] + /// Manifest providers this backend is offered during `initialize`. + private let offeredToolchainProviders: [String] private var sessionID: String? private var startupGeneration: UUID? @@ -130,6 +132,8 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { /// The VCS provider the process negotiated in `initialize`, as Core's /// `vcsProvider` event reported it; nil for every other session. package private(set) var vcsNegotiation: VcsProviderNegotiation? + /// Providers the backend selected, from the `toolchainProviders` event. + package private(set) var toolchainProviders: [String] = [] package var onServerInfoChange: ((LanguageServerInfo?) -> Void)? package var javaTestRunnerURL: URL? { hostIntegrationLaunch?.coreLaunch?.jdtlsLaunchResources?.javaTestRunnerURL @@ -152,7 +156,8 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { processRegistry: (any LanguageServerProcessRegistry)? = nil, moduleID: ModuleID = .languageIntelligence, pluginID: String? = nil, - hostServices: [String] = [] + hostServices: [String] = [], + toolchainProviders: [String] = [] ) { self.providerID = providerID self.executableURL = executableURL @@ -171,6 +176,7 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { self.moduleID = moduleID self.pluginID = pluginID self.hostServices = hostServices + self.offeredToolchainProviders = toolchainProviders } /// Derived from the last lifecycle state Rust published: there is no local @@ -279,6 +285,7 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { let core = core let pluginID = pluginID let hostServices = hostServices + let offeredToolchainProviders = offeredToolchainProviders let providerID = providerID let executableURL = executableURL let environment = environment @@ -291,7 +298,22 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { let resolvedOptions = initializationOptions let resolvedConfiguration = workspaceConfiguration let launch: @Sendable () -> Result = { - if let pluginID { + if let pluginID, !offeredToolchainProviders.isEmpty { + core.startPluginToolchainBackend( + pluginID: pluginID, + providerID: providerID, + executableURL: executableURL, + arguments: resolvedArguments, + environment: environment, + rootURL: normalizedRoot, + initializationOptions: resolvedOptions, + hostServices: hostServices, + toolchainProviders: offeredToolchainProviders, + initializeTimeout: initializeTimeout, + requestTimeout: requestTimeout, + shutdownTimeout: shutdownTimeout + ) + } else if let pluginID { core.startPluginLanguageServer( pluginID: pluginID, providerID: providerID, @@ -880,6 +902,40 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { } } + /// Sends one `toolchains/query` and returns `{ id, response }`, the raw + /// reply `toolchains.decodeReply` validates. Cancelling the task sends + /// `$/cancelRequest`; a late reply then finds no pending request. + package func toolchainRequest(params: ToolingJSONValue, timeout: TimeInterval) async throws -> ToolingJSONValue { + guard let sessionID, state == .ready else { + throw LanguageServerRuntimeSessionError.notReady + } + let started: LanguageServerRuntimeOperation + switch await send(timeout: timeout, { core in + core.requestToolchainProvider(sessionID: sessionID, params: params, timeout: timeout) + }) { + case .success(let operation): started = operation + case .failure(let error): + sendsInFlight -= 1 + try Task.checkCancellation() + throw LanguageServerRuntimeSessionError.requestRejected(error.userMessage) + } + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + registerPendingOperation(started, name: "toolchains/query") { result in + continuation.resume(with: result.flatMap { event in + guard case .object(let object)? = event.result, let value = object["value"] else { + return .failure(LanguageServerRuntimeSessionError.missingResult) + } + return .success(value) + }) + } + sendsInFlight -= 1 + } + } onCancel: { + Task { @MainActor [weak self] in self?.cancelOffMain(started.operationID) } + } + } + /// Invokes a plugin function of the plugin that owns this session. Core /// checks ownership and registration; a refusal or a failed call throws /// `PluginFunctionFailure`, and cancelling the task cancels the call. @@ -1442,6 +1498,13 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { case .failure(let error): onLog?(.error, "Invalid VCS provider event", error.localizedDescription, nil) } return false + case "toolchainProviders": + if case .object(let object)? = event.result, case .array(let providers)? = object["providers"] { + toolchainProviders = providers.compactMap { + if case .string(let provider) = $0 { provider } else { nil } + } + } + return false case "featuresChanged": updateFeatures(capabilityNames: event.capabilities ?? []) return false @@ -1533,6 +1596,7 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { onFunctionsChange?([]) } vcsNegotiation = nil + toolchainProviders = [] let released = onReleased onReleased = nil released?() diff --git a/macos/Tests/LitheTests/MacToolchainDiscoveryTests.swift b/macos/Tests/LitheTests/MacToolchainDiscoveryTests.swift index 47392301c8..547619c698 100644 --- a/macos/Tests/LitheTests/MacToolchainDiscoveryTests.swift +++ b/macos/Tests/LitheTests/MacToolchainDiscoveryTests.swift @@ -2,6 +2,7 @@ import Darwin import Foundation import Testing @testable import Lithe +import LitheCoreContracts import LitheModuleAPI /// Real directories and the real Core; only the home directory and version probes are injected, @@ -105,6 +106,96 @@ struct MacToolchainDiscoveryTests { #expect(probes.calls.isEmpty) } + /// The definition plugin as its own provider, as a version-manager plugin declares one. + private static func providerSource() throws -> ToolchainSettingsPluginSource { + var manifest = try #require(try JSONSerialization.jsonObject(with: manifest()) as? [String: Any]) + manifest["requires"] = ["toolDefinitions": 1, "executableResolution": 2, "toolchainProviders": 1, + "backend": 2, "serverProcess": 1] + manifest["permissions"] = ["backend.launch", "toolchains.provide"] + manifest["toolchainProviders"] = [["id": "kits", "displayName": "Kits", "tools": ["compiler"], + "watchFiles": [["pattern": ".compiler-version", "reader": "native"]]]] + manifest["backend"] = ["launch": ["kind": "packaged", "path": "bin/kits"]] + return .init(pluginID: pluginID, name: "Discovery fixture", + manifest: try JSONSerialization.data(withJSONObject: manifest), isEnabled: true, verifiedOfficial: true) + } + + /// A provider reports an alias link; Core keeps the canonical home, the project + /// requirement and the global one apart, and the installation joins the candidates. + @Test func providerAnswersAreValidatedAndJoinTheCandidates() async throws { + let root = try DiscoveryFixtureRoot() + defer { root.remove() } + let installed = try root.installation("manager/installs/compiler/21.0.1") + try FileManager.default.createSymbolicLink(atPath: root.path + "/manager/installs/compiler/latest", + withDestinationPath: installed) + let alias = root.path + "/manager/installs/compiler/latest" + let versionFile = root.path + "/workspace/.compiler-version" + try Data("21\n".utf8).write(to: URL(fileURLWithPath: versionFile)) + let globalFile = root.path + "/home/.compiler-version" + try Data("21\n".utf8).write(to: URL(fileURLWithPath: globalFile)) + let queries = ProviderQueries { params in + let project = params["workspace"] is [String: Any] + let statements: [[String: Any]] = [ + ["tool": "compiler", "scope": "global", "source": globalFile, "request": "21", "installation": alias, + "kind": "declaration", "reader": "native"] + ] + (project ? [["tool": "compiler", "scope": "project", "source": versionFile, "request": "21", + "installation": alias, "kind": "declaration", "reader": "native"]] : []) + return ["installations": [["tool": "compiler", "version": "21.0.1", "home": alias]], + "statements": statements, "watch": [root.path + "/manager/installs"]] + } + let probes = DiscoveryProbes(outputs: [installed + "/bin/compiler": "compiler 21.0.1"]) + var discovery = discovery(root: root, probes: probes) + discovery.queryProvider = { plugin, provider, params, _ in try queries.answer(pluginID: plugin, providerID: provider, params: params) } + discovery.workspaceContext = { _, _ in .init(contextID: "context-1", generation: 3, rootID: "root-1") } + let result = await discovery.discover(.init( + workspace: root.url("workspace"), sources: [try Self.providerSource()], environment: [:])) + + let provider = Self.pluginID + ":kits" + #expect(result.diagnostics.isEmpty) + let detection = try #require(result.detections["compiler"]) + #expect(detection.installations.map(\.home) == [installed]) + #expect(detection.installations.first?.provider == provider) + #expect(result.requirements["."]?["compiler"] == [.init(request: "21", source: versionFile, providerID: provider, + home: installed, version: "21.0.1")]) + #expect(result.globalRequirements["compiler"] == [.init(request: "21", source: globalFile, providerID: provider, + home: installed, version: "21.0.1")]) + #expect(result.watch == [root.path + "/manager/installs"]) + guard case .answered? = result.providerStates[provider] else { + Issue.record("the provider should have answered") + return + } + // A global query, then one per directory, each with its own operation and generation. + let sent = queries.sent + #expect(sent.count == 2) + #expect(sent.first?["workspace"] is NSNull && sent.first?["context"] is NSNull) + let workspace = try #require(sent.last?["workspace"] as? [String: Any]) + #expect(workspace["rootID"] as? String == "root-1" && workspace["directory"] as? String == ".") + #expect(Set(sent.compactMap { $0["operationID"] as? String }).count == 2) + } + + /// A refused reply names the failure and contributes nothing, not even its valid parts. + @Test func aRejectedReplyAddsNoInstallationAndIsReported() async throws { + let root = try DiscoveryFixtureRoot() + defer { root.remove() } + let inside = try root.installation("workspace/vendor/compiler") + let queries = ProviderQueries { _ in + ["installations": [["tool": "compiler", "version": "1", "home": inside]], "statements": [], "watch": []] + } + var discovery = discovery(root: root, probes: DiscoveryProbes(outputs: [:])) + discovery.queryProvider = { plugin, provider, params, _ in try queries.answer(pluginID: plugin, providerID: provider, params: params) } + let result = await discovery.discover(.init( + workspace: root.url("workspace"), sources: [try Self.providerSource()], environment: [:])) + #expect(result.detections["compiler"]?.installations.isEmpty == true) + guard case .failed? = result.providerStates[Self.pluginID + ":kits"] else { + Issue.record("a workspace home must be refused") + return + } + // Without an open context only the global query is sent. + #expect(queries.sent.count == 1) + // The refusal is the reported reason; a missing context does not hide it. + #expect(result.diagnostics.count == 1) + #expect(result.diagnostics.first?.contains("kits") == true) + } + @Test func globsMatchOneComponentLikeCore() { #expect(MacToolchainDiscovery.matches("*.kit", "a.kit")) #expect(MacToolchainDiscovery.matches("*", "anything")) @@ -189,3 +280,28 @@ private final class DiscoveryProbes: @unchecked Sendable { } } } + +/// Answers queries like a backend would; each result echoes the request's identity. +private final class ProviderQueries: @unchecked Sendable { + private let lock = NSLock() + private let result: @Sendable ([String: Any]) -> [String: Any] + private var recorded: [[String: Any]] = [] + private var nextID = 0 + + init(result: @escaping @Sendable ([String: Any]) -> [String: Any]) { self.result = result } + + var sent: [[String: Any]] { lock.withLock { recorded } } + + func answer(pluginID _: String, providerID _: String, params: ToolingJSONValue) throws -> ToolingJSONValue { + let params = params.foundationObject as? [String: Any] ?? [:] + let id = lock.withLock { () -> Int in + recorded.append(params) + nextID += 1 + return nextID + } + var body = result(params) + for key in ["operationID", "generation", "providerID"] { body[key] = params[key] } + let reply: [String: Any] = ["id": id, "response": ["jsonrpc": "2.0", "id": id, "result": body]] + return try #require(ToolingJSONValue.fromFoundation(reply)) + } +} diff --git a/macos/Tests/LitheTests/ToolchainSettingsFeatureTests.swift b/macos/Tests/LitheTests/ToolchainSettingsFeatureTests.swift index 5c19e04003..bfa02c7a09 100644 --- a/macos/Tests/LitheTests/ToolchainSettingsFeatureTests.swift +++ b/macos/Tests/LitheTests/ToolchainSettingsFeatureTests.swift @@ -1,4 +1,5 @@ import Foundation +import LitheCoreContracts import Testing @testable import Lithe @@ -179,6 +180,45 @@ struct ToolchainSettingsFeatureTests { #expect(loader.workspaces == [root, root]) } + /// A burst of edits to watched files reloads once; other files and other roots do not. + @Test + func watchedFileChangesReloadTheWholeSnapshotOnce() async { + let loader = SettingsSnapshotLoader(snapshots: [snapshot(), snapshot()]) + let model = ToolchainSettingsFeatureModel(loader: loader, reloadDelay: .zero) + defer { model.close() } + model.workspaceFilesChanged([.init(fileURL: root.appendingPathComponent(".fixture-version"), kind: .changed)]) + #expect(loader.workspaces.isEmpty) + await model.load(workspace: root) + let reloaded = AsyncGate() + loader.beforeReturn = { index in if index == 1 { reloaded.open() } } + model.workspaceFilesChanged([ + .init(fileURL: root.appendingPathComponent("README.md"), kind: .changed), + .init(fileURL: URL(fileURLWithPath: "/elsewhere/.fixture-version"), kind: .changed) + ]) + model.workspaceFilesChanged([.init(fileURL: root.appendingPathComponent("apps/web/.fixture-version"), kind: .created)]) + model.workspaceFilesChanged([.init(fileURL: root.appendingPathComponent(".fixture-version"), kind: .deleted)]) + #expect(await reloaded.wait(timeout: .seconds(5))) + #expect(loader.workspaces == [root, root]) + } + + /// Inputs outside the workspace are watched per snapshot; closing ends the observation. + @Test + func providerInputsOutsideTheWorkspaceAreWatchedUntilClose() async { + var watched = snapshot() + watched.watch = ["/fixture/manager/installs"] + let loader = SettingsSnapshotLoader(snapshots: [watched, watched]) + let watcher = InputWatcherStub() + let model = ToolchainSettingsFeatureModel(loader: loader, inputWatcher: watcher, reloadDelay: .zero) + await model.load(workspace: root) + #expect(watcher.paths == [["/fixture/manager/installs"]]) + let reloaded = AsyncGate() + loader.beforeReturn = { index in if index == 1 { reloaded.open() } } + watcher.fire() + #expect(await reloaded.wait(timeout: .seconds(5))) + model.close() + #expect(watcher.liveObservations == 0) + } + private func snapshot() -> ToolchainSettingsSnapshot { let provider = "dev.lithe.fixture.provider:fixture" let file = ToolchainSettingsSnapshot.VersionFile(path: "apps/web/.fixture-version", providerIDs: [provider]) @@ -246,3 +286,55 @@ private final class LoginShellStub: LoginShellEnvironmentInspecting, @unchecked } } } + +/// Opened once by the loader; a test waits for it with its own deadline. +@MainActor +private final class AsyncGate { + private var isOpen = false + private var waiter: CheckedContinuation? + + func open() { + isOpen = true + waiter?.resume(returning: true) + waiter = nil + } + + func wait(timeout: Duration) async -> Bool { + if isOpen { return true } + let deadline = Task { @MainActor [weak self] in + // test-stability: allow(swift-real-sleep) reason: this only bounds the wait; the loader opens the gate. + try? await Task.sleep(for: timeout) + self?.waiter?.resume(returning: false) + self?.waiter = nil + } + defer { deadline.cancel() } + return await withCheckedContinuation { waiter = $0 } + } +} + +private final class InputWatcherStub: ToolchainInputWatching, @unchecked Sendable { + private final class Observation { + weak var owner: InputWatcherStub? + init(owner: InputWatcherStub) { self.owner = owner } + deinit { owner?.lock.withLock { owner?.live -= 1 } } + } + + private let lock = NSLock() + private var recorded: [[String]] = [] + private var live = 0 + private var latest: (@Sendable () -> Void)? + + var paths: [[String]] { lock.withLock { recorded } } + var liveObservations: Int { lock.withLock { live } } + + func watch(paths: [String], onChange: @escaping @Sendable () -> Void) -> AnyObject? { + lock.withLock { + recorded.append(paths) + live += 1 + latest = onChange + } + return Observation(owner: self) + } + + func fire() { lock.withLock { latest }?() } +} diff --git a/rust/lithe-core/src/lsp/interface/client.rs b/rust/lithe-core/src/lsp/interface/client.rs index 0f731fa9bc..369a4243d6 100644 --- a/rust/lithe-core/src/lsp/interface/client.rs +++ b/rust/lithe-core/src/lsp/interface/client.rs @@ -130,7 +130,7 @@ pub(super) fn client_initialize_for_plugin( "window": { "workDoneProgress": true }, - "experimental": experimental_capabilities(plugin_owned) + "experimental": experimental_capabilities(plugin_owned, &state.offered_toolchain_providers) }, "initializationOptions": request.initialization_options }), @@ -663,6 +663,27 @@ pub(super) fn client_apply_server_message_for_plugin( .server_capabilities .retain(|feature| feature != super::vcs_provider::VCS_PROVIDER_FEATURE), } + state.toolchain_providers = plugin_owned + .then(|| { + super::toolchain_provider::negotiate( + result.get("capabilities").unwrap_or(&Value::Null), + &state.offered_toolchain_providers, + ) + }) + .flatten() + .unwrap_or_default(); + if !state.toolchain_providers.is_empty() { + events.push(LspClientEvent { + kind: super::toolchain_provider::NEGOTIATED_EVENT.to_string(), + request_id: None, + method: Some("initialize".to_string()), + uri: None, + version: None, + diagnostics: None, + result: Some(json!({ "providers": state.toolchain_providers })), + error: None, + }); + } state.text_document_sync = text_document_sync_kind(result.get("capabilities").unwrap_or(&Value::Null)); state.text_document_save_include_text = match result @@ -804,13 +825,21 @@ pub(super) fn json_rpc_result(id: &Value, result: Value) -> Result Value { +fn experimental_capabilities( + plugin_owned: bool, + toolchain_providers: &std::collections::BTreeSet, +) -> Value { let mut experimental = super::build_provider::client_experimental_capabilities(); if plugin_owned { let (functions_key, functions_offer) = plugin_function::client_experimental_capabilities(); experimental[functions_key] = functions_offer; let (vcs_key, vcs_offer) = super::vcs_provider::client_experimental_capabilities(); experimental[vcs_key] = vcs_offer; + if let Some((toolchain_key, toolchain_offer)) = + super::toolchain_provider::client_experimental_capabilities(toolchain_providers) + { + experimental[toolchain_key] = toolchain_offer; + } } experimental } diff --git a/rust/lithe-core/src/lsp/interface/engine.rs b/rust/lithe-core/src/lsp/interface/engine.rs index 48e8fecdc5..a4962edd38 100644 --- a/rust/lithe-core/src/lsp/interface/engine.rs +++ b/rust/lithe-core/src/lsp/interface/engine.rs @@ -68,6 +68,8 @@ const MAX_BUILD_PROVIDER_TIMEOUT_MS: u64 = 60 * 60_000; /// working copy over a slow link can take long, but never stays pending /// without a deadline. const MAX_VCS_PROVIDER_TIMEOUT_MS: u64 = 60 * 60_000; +/// Longest toolchain query a host may ask for; queries only read manager state. +const MAX_TOOLCHAIN_QUERY_TIMEOUT_MS: u64 = 60_000; const MONITOR_INTERVAL_MS: u64 = 10; /// JSON-RPC method carrying Java Debug Server build commands. const JAVA_BUILD_METHOD: &str = "workspace/executeCommand"; @@ -110,6 +112,10 @@ pub struct StartServerRequest { /// requests to the host. #[serde(default)] pub host_services: Vec, + /// Provider IDs of the plugin's verified manifest. Only a plugin-owned + /// session with some is offered `litheToolchains`. + #[serde(default)] + pub toolchain_providers: Vec, pub executable_path: String, #[serde(default)] pub arguments: Vec, @@ -432,6 +438,23 @@ pub struct BuildProviderRequest { pub timeout_milliseconds: Option, } +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +/// One `toolchains/query`, addressed to a session that negotiated the provider +/// named in `params`. The result arrives as a `requestCompleted` event whose +/// `result.value` is `{ id, response }`, the complete untrusted JSON-RPC response, +/// which the host passes to `toolchains.decodeReply`. +pub struct ToolchainProviderRequest { + pub session_id: String, + #[serde(default)] + pub operation_id: Option, + /// Query parameters; `toolchains.decodeReply` validates them with the reply. + pub params: Value, + /// Deadline of this request, capped at `MAX_TOOLCHAIN_QUERY_TIMEOUT_MS`. + #[serde(default)] + pub timeout_milliseconds: Option, +} + #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] /// One VCS-provider request. Core plans the method and params from the @@ -1395,6 +1418,20 @@ pub fn build_provider_request( Ok(OperationResponse { operation_id }) } +/// Sends one `toolchains/query` to a session that negotiated its provider. +pub fn toolchain_provider_request( + request: ToolchainProviderRequest, +) -> Result { + let operation_id = request + .operation_id + .clone() + .unwrap_or_else(|| engine().next_operation_id()); + engine() + .session(&request.session_id)? + .toolchain_request(request, operation_id.clone())?; + Ok(OperationResponse { operation_id }) +} + /// Sends one VCS-provider request to a session that negotiated the protocol. pub fn vcs_provider_request(request: VcsProviderRequest) -> Result { let operation_id = request @@ -1725,7 +1762,14 @@ impl LspEngine { checked_workspace_configuration(request.workspace_configuration)?; let initialize = client_initialize_for_plugin( ClientInitializeRequest { - state: LspClientState::default(), + state: LspClientState { + offered_toolchain_providers: if request.plugin_id.is_some() { + request.toolchain_providers.iter().cloned().collect() + } else { + Default::default() + }, + ..LspClientState::default() + }, root_uri: request.root_uri.clone(), process_id: (!crate::remote::workspace::is_remote()) .then_some(std::process::id() as i64), @@ -2437,6 +2481,75 @@ impl RuntimeSession { /// Writes a VCS-provider request planned by `crate::vcs::plan`, which /// checks the operation's arguments and negotiated capability. + fn toolchain_request( + &self, + request: ToolchainProviderRequest, + operation_id: String, + ) -> Result<(), CoreError> { + let outbound_order = self.lock_outbound_order()?; + let (messages, request_id) = { + let mut state = self.lock_state()?; + if state.lifecycle != LspLifecycleState::Ready || !state.client.initialized { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Language server is not ready.", + )); + } + if state.request_by_operation.contains_key(&operation_id) + || state.java_builds.contains(&operation_id) + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "The language-server operation ID is already pending.", + )); + } + let provider = request.params["providerID"].as_str().unwrap_or_default(); + if !state + .client + .toolchain_providers + .iter() + .any(|negotiated| negotiated == provider) + { + return Err(CoreError::new( + ErrorCode::NotSupported, + "The process did not negotiate this toolchain provider.", + ) + .with_details("plugin.toolchain.providerUnavailable")); + } + let timeout = request + .timeout_milliseconds + .map(|milliseconds| { + Duration::from_millis(milliseconds.clamp(1, MAX_TOOLCHAIN_QUERY_TIMEOUT_MS)) + }) + .unwrap_or(state.request_timeout); + let response = allocate_raw_request( + state.client.clone(), + super::toolchain_provider::QUERY_METHOD, + request.params, + )?; + let request_id = (response.state.next_request_id - 1).to_string(); + let now = Instant::now(); + state.client = response.state; + state.pending.insert( + request_id.clone(), + PendingRequest { + kind: PendingKind::Feature, + operation_id: Some(operation_id.clone()), + method: super::toolchain_provider::QUERY_METHOD.to_string(), + document_uri: None, + document_version: None, + created_at: now, + deadline: now + timeout, + }, + ); + state + .request_by_operation + .insert(operation_id, request_id.clone()); + (response.messages, request_id) + }; + self.send_request_or_fail(&outbound_order, messages, &request_id) + } + fn vcs_request( &self, request: VcsProviderRequest, @@ -3585,6 +3698,25 @@ impl RuntimeSession { } } } + Some(PendingKind::Feature) + if pending_before.as_ref().is_some_and(|pending| { + pending.method == super::toolchain_provider::QUERY_METHOD + }) => + { + if let Some(pending) = pending_before.as_ref() { + if let Some(operation_id) = &pending.operation_id { + let result = super::toolchain_provider::reply_outcome(&value); + push_request_event( + self, + &mut state, + operation_id, + &pending.method, + Some(result), + None, + ); + } + } + } Some(PendingKind::Feature) => { if let Some(pending) = pending_before.as_ref() { if let Some(operation_id) = &pending.operation_id { @@ -4218,6 +4350,12 @@ impl RuntimeSession { )?); } else if event.kind == super::vcs_provider::NEGOTIATED_EVENT { push_vcs_provider_event(self, &mut state, event.result.unwrap_or_default()); + } else if event.kind == super::toolchain_provider::NEGOTIATED_EVENT { + push_toolchain_provider_event( + self, + &mut state, + event.result.unwrap_or_default(), + ); } else if event.kind == "diagnostics" { if let Some(uri) = event.uri.as_deref() { push_diagnostics_event( @@ -6165,6 +6303,40 @@ fn push_vcs_provider_event(session: &RuntimeSession, state: &mut SessionState, r ); } +/// Publishes the providers a backend selected, so the host queries only those. +fn push_toolchain_provider_event( + session: &RuntimeSession, + state: &mut SessionState, + result: Value, +) { + let sequence = take_sequence(state); + enqueue_runtime_event( + session, + state, + LspRuntimeEvent { + kind: "toolchainProviders".to_string(), + sequence, + provider_id: session.provider_id.clone(), + session_id: session.id.clone(), + state: None, + operation_id: None, + method: None, + uri: None, + version: None, + diagnostics: None, + result: Some(result), + error: None, + capabilities: None, + server_info: None, + level: None, + message: None, + detail: None, + maven_profile_project: None, + maven_profile_task: None, + }, + ); +} + /// Handles a backend's `$/cancelRequest` for one of its own pending host /// requests: the backend gets `plugin.host.cancelled` at once, so the /// pending slot frees without waiting for the host, and the host gets a @@ -6879,6 +7051,7 @@ fn clear_runtime_state(session: &RuntimeSession, state: &mut SessionState) { state.function_by_operation.clear(); clear_function_registry(session, state); state.client.vcs_provider = None; + state.client.toolchain_providers.clear(); state.linked_document_paths = LinkedDocumentPaths::default(); state.java_navigation_marker_batches.clear(); state.java_navigation_marker_cache.clear(); @@ -7006,6 +7179,7 @@ mod tests { provider_id: "gopls".to_string(), plugin_id: None, host_services: Vec::new(), + toolchain_providers: Vec::new(), executable_path: "/usr/bin/scripted-server".to_string(), arguments: vec!["--stdio".to_string()], environment: BTreeMap::new(), @@ -10933,6 +11107,7 @@ public class Main { provider_id: "java".to_string(), plugin_id: None, host_services: Vec::new(), + toolchain_providers: Vec::new(), executable_path, arguments: Vec::new(), environment: BTreeMap::from([( @@ -11675,6 +11850,7 @@ public class Main { provider_id: String::new(), plugin_id: None, host_services: Vec::new(), + toolchain_providers: Vec::new(), executable_path: "/bin/server".to_string(), arguments: Vec::new(), environment: BTreeMap::new(), @@ -12629,6 +12805,126 @@ public class Main { assert!(harness.server.await_notification("$/cancelRequest")); } + // Toolchain providers against a scripted backend. Only a plugin-owned + // session launched with declared providers is offered `litheToolchains`, + // and only a provider its process selected receives `toolchains/query`. + + fn toolchain_backend(answer: Value) -> Harness { + let mut harness = Harness::start(|request| { + request.plugin_id = Some("dev.lithe.fixture.toolchain-provider".to_string()); + request.toolchain_providers = vec!["mise".to_string()]; + }); + harness + .server + .complete_initialize(json!({ "experimental": { "litheToolchains": answer } })); + harness.await_state(LspLifecycleState::Ready); + harness + } + + fn toolchain_query(harness: &Harness, provider: &str) -> Result { + let operation_id = harness.engine.next_operation_id(); + harness + .session() + .toolchain_request( + ToolchainProviderRequest { + session_id: harness.session_id.clone(), + operation_id: Some(operation_id.clone()), + params: json!({ "operationID": "query-1", "generation": 1, "providerID": provider }), + timeout_milliseconds: None, + }, + operation_id.clone(), + ) + .map(|()| operation_id) + } + + fn no_toolchain_query_written(harness: &Harness) -> bool { + harness + .server + .messages() + .iter() + .all(|message| message["method"] != "toolchains/query") + } + + #[test] + fn host_sessions_are_not_offered_the_toolchain_protocol() { + let harness = Harness::start(|request| { + request.toolchain_providers = vec!["mise".to_string()]; + }); + harness.server.complete_initialize(json!({})); + let initialize = harness + .server + .messages() + .into_iter() + .find(|message| message["method"] == "initialize") + .expect("initialize should have been written"); + assert!(initialize["params"]["capabilities"]["experimental"] + .get("litheToolchains") + .is_none()); + } + + #[test] + fn a_query_reaches_only_a_selected_provider_and_returns_the_raw_response() { + let mut harness = toolchain_backend(json!({ "version": 1, "providers": ["mise"] })); + let initialize = harness + .server + .messages() + .into_iter() + .find(|message| message["method"] == "initialize") + .expect("initialize should have been written"); + assert_eq!( + initialize["params"]["capabilities"]["experimental"]["litheToolchains"], + json!({ "versions": [1], "providers": ["mise"] }) + ); + let event = harness + .await_event(|event| event.kind == "toolchainProviders") + .clone(); + assert_eq!(event.result, Some(json!({ "providers": ["mise"] }))); + + let refused = toolchain_query(&harness, "asdf").expect_err("not selected"); + assert_eq!( + refused.details.as_deref(), + Some("plugin.toolchain.providerUnavailable") + ); + assert!(no_toolchain_query_written(&harness)); + + let operation = toolchain_query(&harness, "mise").expect("written"); + let id = harness + .server + .await_request("toolchains/query") + .expect("the query is written"); + let wire_id: u64 = id.parse().expect("a numeric wire ID"); + // Even a provider error stays a raw reply: decodeReply owns its mapping. + let response = json!({ + "jsonrpc": "2.0", "id": wire_id, + "error": { "code": -32073, "message": "untrusted", "data": { "code": "x" } } + }); + harness.server.send(response.clone()); + let event = harness + .await_event(|event| { + event.kind == "requestCompleted" + && event.operation_id.as_deref() == Some(operation.as_str()) + }) + .clone(); + assert!(event.error.is_none()); + assert_eq!( + event.result, + Some(json!({ "value": { "id": wire_id, "response": response } })) + ); + } + + #[test] + fn an_undeclared_toolchain_provider_enables_nothing() { + let mut harness = toolchain_backend(json!({ "version": 1, "providers": ["mise", "asdf"] })); + harness.poll(); + assert!(harness + .events + .iter() + .all(|event| event.kind != "toolchainProviders")); + let error = toolchain_query(&harness, "mise").expect_err("refused"); + assert!(matches!(error.code, ErrorCode::NotSupported)); + assert!(no_toolchain_query_written(&harness)); + } + // VCS providers against a scripted backend. Only a plugin-owned session // is offered `litheVcs`, and only one whose process answered with a VCS // ID and every required capability receives requests, each planned by diff --git a/rust/lithe-core/src/lsp/interface/engine_real_jdt_tests.rs b/rust/lithe-core/src/lsp/interface/engine_real_jdt_tests.rs index b78beba4e3..11e612f18d 100644 --- a/rust/lithe-core/src/lsp/interface/engine_real_jdt_tests.rs +++ b/rust/lithe-core/src/lsp/interface/engine_real_jdt_tests.rs @@ -340,6 +340,7 @@ fn real_start_request( provider_id: "java".to_string(), plugin_id: None, host_services: Vec::new(), + toolchain_providers: Vec::new(), executable_path: toolchain .root .join("bin") diff --git a/rust/lithe-core/src/lsp/interface/mod.rs b/rust/lithe-core/src/lsp/interface/mod.rs index a31b8219e5..dacfa9ac19 100644 --- a/rust/lithe-core/src/lsp/interface/mod.rs +++ b/rust/lithe-core/src/lsp/interface/mod.rs @@ -8,6 +8,7 @@ pub(crate) mod process; pub(crate) mod remote_process; #[cfg(test)] mod scripted; +mod toolchain_provider; mod transport; mod types; mod vcs_provider; diff --git a/rust/lithe-core/src/lsp/interface/toolchain_provider.rs b/rust/lithe-core/src/lsp/interface/toolchain_provider.rs new file mode 100644 index 0000000000..52fed5a606 --- /dev/null +++ b/rust/lithe-core/src/lsp/interface/toolchain_provider.rs @@ -0,0 +1,120 @@ +//! The toolchain-provider protocol a plugin backend speaks next to its functions. +//! +//! The manifest lists the providers; `initialize` offers them under +//! `capabilities.experimental.litheToolchains`, and the backend selects a version +//! and a subset of the offer. `toolchains/query` is written only to a session whose +//! answer passed [`negotiate`]. Replies stay untrusted here: Core transports them +//! unchanged, and `toolchains.decodeReply` validates them against native facts. +//! The rules live in `shared/contracts/plugins/v1/toolchains.md`. + +use serde_json::{json, Value}; +use std::collections::BTreeSet; + +/// Key under `capabilities.experimental` on both sides of the exchange. +const EXPERIMENTAL_KEY: &str = "litheToolchains"; + +/// Toolchain-provider protocol versions this client speaks. +const SUPPORTED_VERSIONS: [u64; 1] = [1]; + +/// The only method this protocol writes. +pub(crate) const QUERY_METHOD: &str = "toolchains/query"; + +/// Client event that carries the negotiated providers to the engine. +pub(crate) const NEGOTIATED_EVENT: &str = "toolchainProvidersNegotiated"; + +/// The offer for a session launched with `providers`; `None` when it declares none. +pub(crate) fn client_experimental_capabilities( + providers: &BTreeSet, +) -> Option<(&'static str, Value)> { + (!providers.is_empty()).then(|| { + ( + EXPERIMENTAL_KEY, + json!({ "versions": SUPPORTED_VERSIONS, "providers": providers }), + ) + }) +} + +/// The providers a backend selected, or `None` unless it chose an offered version +/// and a non-empty, duplicate-free subset of `offered`. A partly valid answer +/// enables nothing, so no query reaches a provider the manifest did not declare. +pub(crate) fn negotiate(capabilities: &Value, offered: &BTreeSet) -> Option> { + let answer = capabilities.pointer(&format!("/experimental/{EXPERIMENTAL_KEY}"))?; + let version = answer.get("version").and_then(Value::as_u64)?; + if !SUPPORTED_VERSIONS.contains(&version) { + return None; + } + let mut selected = BTreeSet::new(); + for provider in answer.get("providers")?.as_array()? { + let provider = provider.as_str()?; + if !offered.contains(provider) || !selected.insert(provider.to_string()) { + return None; + } + } + (!selected.is_empty()).then(|| selected.into_iter().collect()) +} + +/// Wraps the complete JSON-RPC response for `toolchains.decodeReply`, which owns +/// every success and error rule, with the wire ID the request was written under. +pub(crate) fn reply_outcome(response: &Value) -> Value { + json!({ "value": { "id": response.get("id"), "response": response } }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn offered() -> BTreeSet { + BTreeSet::from(["mise".to_string(), "asdf".to_string()]) + } + + fn answer(value: Value) -> Value { + json!({ "experimental": { "litheToolchains": value } }) + } + + #[test] + fn only_a_session_with_declared_providers_is_offered_the_protocol() { + assert_eq!(client_experimental_capabilities(&BTreeSet::new()), None); + let (key, offer) = client_experimental_capabilities(&offered()).expect("offered"); + assert_eq!(key, "litheToolchains"); + assert_eq!( + offer, + json!({ "versions": [1], "providers": ["asdf", "mise"] }) + ); + } + + #[test] + fn negotiation_accepts_only_a_complete_subset_of_the_offer() { + assert_eq!( + negotiate( + &answer(json!({ "version": 1, "providers": ["mise"], "future": true })), + &offered() + ), + Some(vec!["mise".to_string()]) + ); + for rejected in [ + json!({ "version": 2, "providers": ["mise"] }), + json!({ "version": 1 }), + json!({ "version": 1, "providers": [] }), + json!({ "version": 1, "providers": "mise" }), + json!({ "version": 1, "providers": ["mise", "mise"] }), + json!({ "version": 1, "providers": ["mise", "undeclared"] }), + json!({ "version": 1, "providers": ["mise", 7] }), + ] { + assert_eq!( + negotiate(&answer(rejected.clone()), &offered()), + None, + "{rejected}" + ); + } + assert_eq!(negotiate(&json!({}), &offered()), None); + } + + #[test] + fn replies_keep_the_whole_response_and_its_wire_id() { + let response = json!({ "jsonrpc": "2.0", "id": 7, "error": { "code": -32073 } }); + assert_eq!( + reply_outcome(&response), + json!({ "value": { "id": 7, "response": response } }) + ); + } +} diff --git a/rust/lithe-core/src/lsp/interface/types.rs b/rust/lithe-core/src/lsp/interface/types.rs index 1123a2175e..0687f265b3 100644 --- a/rust/lithe-core/src/lsp/interface/types.rs +++ b/rust/lithe-core/src/lsp/interface/types.rs @@ -155,6 +155,13 @@ pub struct LspClientState { /// `litheVcs`; `None` when the protocol was not negotiated. #[serde(default, skip_serializing_if = "Option::is_none")] pub vcs_provider: Option, + /// Manifest providers `initialize` offers under `litheToolchains`, from + /// `lsp.startServer.toolchainProviders`. + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + pub offered_toolchain_providers: BTreeSet, + /// The offered providers the backend selected; empty until negotiated. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub toolchain_providers: Vec, } impl Default for LspClientState { @@ -176,6 +183,8 @@ impl Default for LspClientState { plugin_function_version: None, host_services: BTreeSet::new(), vcs_provider: None, + offered_toolchain_providers: BTreeSet::new(), + toolchain_providers: Vec::new(), } } } diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index a0bbd65606..80caeff7b5 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -365,6 +365,8 @@ pub enum CoreCommand { LspBuildRequest, /// Queues one VCS-provider request (`lsp.vcsRequest`). LspVcsRequest, + /// Queues one toolchain-provider query (`lsp.toolchainRequest`). + LspToolchainRequest, /// Invokes one plugin function (`lsp.functionRequest`). LspFunctionRequest, /// Answers a plugin backend's host-service request (`lsp.hostResponse`). @@ -728,6 +730,7 @@ impl CoreCommand { "lsp.request" => Some(Self::LspRequest), "lsp.buildRequest" => Some(Self::LspBuildRequest), "lsp.vcsRequest" => Some(Self::LspVcsRequest), + "lsp.toolchainRequest" => Some(Self::LspToolchainRequest), "lsp.functionRequest" => Some(Self::LspFunctionRequest), "lsp.hostResponse" => Some(Self::LspHostResponse), "lsp.hostNotify" => Some(Self::LspHostNotify), @@ -844,6 +847,7 @@ mod tests { "lsp.request", "lsp.buildRequest", "lsp.vcsRequest", + "lsp.toolchainRequest", "lsp.functionRequest", "lsp.hostResponse", "lsp.hostNotify", diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index 6a6025d73f..599ea1c990 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -1914,6 +1914,24 @@ fn execute(request: &str) -> CoreResponse { Err(error) => CoreResponse::failure(id, error), } } + CoreCommand::LspToolchainRequest => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid toolchain-provider request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::lsp::toolchain_provider_request) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Toolchain response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } CoreCommand::LspVcsRequest => { match serde_json::from_value::(parsed.payload) .map_err(|error| { diff --git a/rust/lithe-core/src/tests/toolchain_resolution.rs b/rust/lithe-core/src/tests/toolchain_resolution.rs index fbafe44bb2..cf0c4de739 100644 --- a/rust/lithe-core/src/tests/toolchain_resolution.rs +++ b/rust/lithe-core/src/tests/toolchain_resolution.rs @@ -392,6 +392,9 @@ fn listing(input: &Value) -> Value { ] { value[key] = input[key].clone(); } + if let Some(reported) = input.get("providerInstallations") { + value["providerInstallations"] = reported.clone(); + } value } @@ -464,6 +467,35 @@ fn toolchain_candidates_list_versions_and_share_the_resolver_choice() { assert_eq!(resolve(&value)["outcome"]["selection"]["home"], HOME); } +#[test] +fn toolchain_candidates_keep_provider_attribution_and_count_one_home_once() { + let mut value = input(); + self_provided(&mut value); + installation(&mut value, HOME, "21.0.4"); + installation(&mut value, OLD, "17.0.12"); + let provider = "dev.lithe.fixture.java-support:installs"; + value["providerInstallations"] = json!([{"tool":"java","home":HOME,"provider":provider}]); + value["systemInstallations"] = json!([{"tool":"java","home":HOME},{"tool":"java","home":OLD}]); + let listed = candidates(&value); + let installations = listed["installations"].as_array().unwrap(); + assert_eq!(installations.len(), 2); + assert_eq!(installations[0]["home"], HOME); + assert_eq!(installations[0]["provider"], provider); + assert_eq!(installations[1]["provider"], "host:system"); + assert_eq!(listed["selected"], HOME); +} + +#[test] +fn toolchain_candidates_refuse_a_provider_outside_the_registry() { + let mut value = input(); + self_provided(&mut value); + for provider in ["dev.example.other:installs", "installs", "host:system"] { + value["providerInstallations"] = json!([{"tool":"java","home":HOME,"provider":provider}]); + let result = candidates_command(listing(&value)).unwrap(); + assert_eq!(result["status"], "rejected", "{provider}"); + } +} + #[test] fn toolchain_candidates_report_equal_versions_as_a_conflict() { let mut value = input(); @@ -517,6 +549,31 @@ fn installs_query(input: &Value, homes: &[(&str, &str)]) -> Value { value } +/// A provider may name a version manager's alias link. The host reads only the paths the +/// reply and definition name, under the link; Core derives the canonical target facts. +#[test] +fn toolchain_resolution_accepts_a_provider_home_reached_through_an_alias_link() { + let mut value = input(); + self_provided(&mut value); + installation(&mut value, HOME, "21.0.4"); + let alias = "/opt/lithe-fixtures/installs/java/latest"; + let facts = value["filesystem"].as_array_mut().unwrap(); + facts.retain(|fact| !fact["path"].as_str().unwrap().starts_with(HOME)); + facts.push(json!({"path":alias,"canonicalPath":HOME,"kind":"directory"})); + facts.push(json!({"path":format!("{alias}/bin"),"canonicalPath":format!("{HOME}/bin"),"kind":"directory"})); + facts.push( + json!({"path":format!("{alias}/bin/java"),"canonicalPath":format!("{HOME}/bin/java"), + "kind":"file","executable":true}), + ); + value["queries"] = json!([installs_query(&value, &[(alias, "21.0.4")])]); + let result = resolve(&value); + assert_eq!(result["outcome"]["selection"]["home"], HOME, "{result}"); + assert_eq!( + result["outcome"]["selection"]["executable"], + format!("{HOME}/bin/java") + ); +} + #[test] fn toolchain_resolution_definition_plugin_can_be_its_own_installation_provider() { let mut value = input(); diff --git a/rust/lithe-core/src/toolchain/resolution.rs b/rust/lithe-core/src/toolchain/resolution.rs index 38eb31c0c8..e6082ba79e 100644 --- a/rust/lithe-core/src/toolchain/resolution.rs +++ b/rust/lithe-core/src/toolchain/resolution.rs @@ -596,6 +596,40 @@ fn system_installations(input: &Value, tool: &str) -> Validation> { Ok(installations) } +/// Homes `toolchains.decodeReply` accepted from a provider declaring `tool`, kept +/// under that provider's identity; one the registry does not list is refused. +fn provider_installations(input: &Value, tool: &str, registry: &Value) -> Validation> { + let mut installations = Vec::new(); + for value in array(input, "providerInstallations", 512)? { + fields(value, &["tool", "home", "provider"])?; + if value["tool"] != tool { + continue; + } + let home = value["home"] + .as_str() + .ok_or_else(|| reject("providerInstallations"))?; + let provider = value["provider"] + .as_str() + .and_then(|provider| provider.rsplit_once(':')) + .filter(|(plugin, provider)| { + registry["providers"].as_array().is_some_and(|entries| { + entries.iter().any(|entry| { + entry["pluginID"] == *plugin + && entry["provider"]["id"] == *provider + && entry["provider"]["tools"] + .as_array() + .is_some_and(|tools| tools.iter().any(|known| known == tool)) + }) + }) + }) + .ok_or_else(|| reject("providerInstallations"))?; + installations.push( + json!({"tool":tool,"home":home,"provider":format!("{}:{}", provider.0, provider.1)}), + ); + } + Ok(installations) +} + /// The automatic layer's candidate set, keyed by canonical home. struct Automatic { eligible: BTreeMap, @@ -721,6 +755,7 @@ pub(crate) fn candidates(input: &Value) -> Validation { "minimumVersion", "probes", "systemInstallations", + "providerInstallations", "environment", ], )?; @@ -760,7 +795,9 @@ pub(crate) fn candidates(input: &Value) -> Validation { } shell.push(row); } - let set = automatic_set(&snapshot, &system_installations(input, tool)?)?; + let mut reported = provider_installations(input, tool, ®istry)?; + reported.extend(system_installations(input, tool)?); + let set = automatic_set(&snapshot, &reported)?; let mut installations: Vec<_> = set .eligible .values() diff --git a/shared/contracts/plugins/v1/proposals/toolchains/protocol.md b/shared/contracts/plugins/v1/proposals/toolchains/protocol.md index 409dd71780..dd806536d5 100644 --- a/shared/contracts/plugins/v1/proposals/toolchains/protocol.md +++ b/shared/contracts/plugins/v1/proposals/toolchains/protocol.md @@ -1,6 +1,6 @@ # Proposed toolchain query, resolution and local pins -**Status: broader proposal; native querying and SDK support are not implemented.** +**Status: broader proposal; the macOS settings pages query providers read-only, and SDK support is not implemented.** The Core-only validation/resolution subset is specified by [the active contract](../../toolchains.md). These files remain review vectors, not an advertised native capability or an SDK guide. The existing local-toolchain diff --git a/shared/contracts/plugins/v1/toolchains.md b/shared/contracts/plugins/v1/toolchains.md index b10781f461..472ba1afe4 100644 --- a/shared/contracts/plugins/v1/toolchains.md +++ b/shared/contracts/plugins/v1/toolchains.md @@ -1,12 +1,15 @@ # Plugin-owned toolchains: Core foundation -**Status: shared Core implemented; the macOS settings pages run the declarative -route read-only.** Core validates data and resolves captured facts; it does not -launch providers, probe programs, parse manager files, watch paths, write pins, -or download tools. The macOS settings host expands `discoveryPlan` scans, runs the -definition's version probe on executables Core returns from `candidates`, and -shows the result. No provider is queried, nothing is watched or saved, and no -build, run or language-server launch uses the result yet. Windows is not connected. +**Status: shared Core implemented; the macOS settings pages run both routes +read-only.** Core validates data and resolves captured facts; it does not launch +providers, probe programs, parse manager files, watch paths, write pins, or +download tools. The macOS settings host expands `discoveryPlan` scans, starts or +reuses each enabled provider's plugin backend, sends `toolchains/query` through +`lsp.toolchainRequest`, passes every raw reply with native facts to `decodeReply`, +runs the definition's version probe on executables Core returns from +`candidates`, and shows the result. Any change to a watched input reloads the +whole snapshot. Nothing is saved, and no build, run or language-server launch +uses the result yet. Windows is not connected. The [broader proposal](proposals/toolchains/README.md) still describes unsupported consumer migration, native lifecycle, persistence and manager-plugin work. The active shape is [plugin-manifest.schema.json](plugin-manifest.schema.json), @@ -33,8 +36,8 @@ Provider entries contain `{id, displayName, tools, watchFiles?}`; each watch ent is `{pattern, reader: "native" | "compatible"}`. A present `watchFiles` lists 1-32 unique patterns; an absent one makes an installation-only provider, whose replies may not contain statements. Providers share the plugin's -backend; admission permits a host-query trigger without a page or startup event. -This is not implemented native activation. Bootstrap must be `packaged`, or +backend; admission permits a host-query trigger without a page or startup event, +and the macOS host starts the backend on the first query. Bootstrap must be `packaged`, or `runtime` with `packagedRuntime`, without discovered runtime dependencies. Existing runtime launch tool restrictions remain; generic consumer launches are not added. @@ -212,8 +215,12 @@ are 256 roots, 4096 entries and 64 ancestors for the host adapter. `candidates` reports what a settings page shows for one tool's global and automatic layers without resolving a directory. It requires `{registry,tool, platform,workspaceRoot,filesystem}` and accepts `workspaceRoots`, -`filesystemCaseSensitive`, `minimumVersion`, `probes`, `systemInstallations` and -`environment`, with the same meaning and checks as in `resolve`. The output is +`filesystemCaseSensitive`, `minimumVersion`, `probes`, `systemInstallations`, +`providerInstallations` and `environment`, with the same meaning and checks as in +`resolve`. `providerInstallations` (at most 512) are `{tool,home,provider}` rows a +host copies from `decodeReply` snapshots; `provider` must name a registry provider +`pluginID:providerID` declaring the tool, and the row keeps that attribution when +a scan reports the same canonical home. The output is `{tool,shell,installations,skipped,selected,conflicts}`: - `shell` lists each declared home variable present in `environment` as diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index bf7564e5bd..912943e1dd 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -1052,6 +1052,7 @@ package manager owns the download and Lithe does not infer bytes from logs. | `lsp.functionRequest` | Invoke one registered plugin function on a session its plugin started | | `lsp.hostResponse` | Answer one host-service request a plugin backend sent | | `lsp.hostNotify` | Send one host notification to a plugin backend that negotiated it | +| `lsp.toolchainRequest` | Send one `toolchains/query` to a plugin backend that negotiated the provider | | `lsp.vcsRequest` | Send one VCS operation, planned by Core, to a session that negotiated the VCS-provider protocol | | `java.prepareLaunch` | Prepare a saved Java source through shared entry selection, serialized build and runtime path resolution | | `java.navigationMarkers` | Resolve versioned Java gutter markers from bounded JDT LS semantic requests | @@ -2907,6 +2908,20 @@ are validated as `{exitCode: Int32, output: string}` and progress is mapped from a Core-owned wire request ID to `requestProgress` for the still-pending operation. See the [provider execution contract](plugins/v1/README.md#provider-task-execution-buildproviders-2). +`lsp.toolchainRequest` accepts `{ sessionId, operationId?, params, +timeoutMilliseconds? }` and returns `{ operationId }`. `lsp.startServer` takes +`toolchainProviders`, the manifest's provider IDs; only a plugin-owned session +with some offers `capabilities.experimental.litheToolchains: { versions: [1], +providers }`. A backend that answers `{ version: 1, providers }` with a non-empty, +duplicate-free subset of the offer emits a `toolchainProviders` runtime event +with `result: { providers }`; any other answer enables none. A request whose +`params.providerID` was not negotiated is `not_supported` with details +`plugin.toolchain.providerUnavailable`, and nothing is written. The +`requestCompleted` event carries `result.value: { id, response }`: the JSON-RPC +ID and the complete, unvalidated response, error replies included, which the host +passes to `toolchains.decodeReply` with the same `params`. The timeout is capped +at 60 seconds; cancellation and late results follow `lsp.buildRequest`. + `lsp.vcsRequest` accepts `{ sessionId, operationId?, vcs, operation, root, tool?, arguments?, timeoutMilliseconds? }` and returns `{ operationId }`. `operation` is a [VCS provider contract](vcs-provider-v1.md#lspvcsrequest) diff --git a/shared/i18n/locales/en-US.json b/shared/i18n/locales/en-US.json index ab6fb38562..ca8767a84f 100644 --- a/shared/i18n/locales/en-US.json +++ b/shared/i18n/locales/en-US.json @@ -9765,16 +9765,16 @@ "native.toolchainSettings.fixedInfo": "Fixed versions are saved on this computer in .lithe/toolchains/local.json and also apply to the directories below. This page reads them without changing the file.", "native.toolchainSettings.fromDirectory": "From {arg1}", "native.toolchainSettings.theProjectRoot": "the project root", - "native.toolchainSettings.toolsInfo": "Each tool uses the first source that has a value: a fixed version, then project version files, then global defaults, then automatic discovery. Installed versions are checked by running each tool's own version command. Version managers are not queried yet, so the versions that version files ask for are not shown.", + "native.toolchainSettings.toolsInfo": "Each tool uses the first source that has a value: a fixed version, then project version files, then global defaults, then automatic discovery. Installed versions are checked by running each tool's own version command. Enabled version managers report what version files ask for.", "native.toolchainSettings.noEnabledPluginDefinesTools": "No enabled plugin defines tools.", "native.toolchainSettings.toolDefinitionInfo": "This row comes from the tool definition of an enabled plugin. Lithe has no built-in tools.", "native.toolchainSettings.versionNotQueried": "Version not queried", "native.toolchainSettings.fixedInParent": "Fixed in parent", "native.toolchainSettings.versionFile": "Version file", "native.toolchainSettings.pinnedStepInfo": "A version saved for this directory on this computer. It applies first, also in the directories below.", - "native.toolchainSettings.projectStepInfo": "Version files in this directory or its parents, watched by the version managers of this tool. The versions they ask for are not queried yet.", + "native.toolchainSettings.projectStepInfo": "Version files in this directory or its parents, watched by the version managers of this tool, and the versions those managers report for them.", "native.toolchainSettings.noVersionFile": "No version file", - "native.toolchainSettings.globalStepInfo": "The home variables and commands this tool's plugin declares, read from the login shell. A home's version comes from the tool's own version command; a command found on PATH is not run. Version managers' global versions are not queried yet.", + "native.toolchainSettings.globalStepInfo": "What version managers report as global, then the home variables and commands this tool's plugin declares, read from the login shell. A home's version comes from the tool's own version command; a command found on PATH is not run.", "native.toolchainSettings.automaticStepInfo": "Installations found in the locations this tool's plugin declares. Each version comes from the tool's own version command, and the highest one is used. Installations inside the project are never run.", "native.toolchainSettings.versionNotVerified": "Version not verified", "native.toolchainSettings.firstMatch": "First match", @@ -9790,7 +9790,7 @@ "native.toolchainSettings.usingLaunchEnvironment": "Using the environment Lithe was launched with", "native.toolchainSettings.variableCount": "{arg1} variables", "native.toolchainSettings.launchEnvironment": "Launch environment", - "native.toolchainSettings.versionManagersInfo": "Version managers come from enabled plugins. Lithe looks for the files they watch in this project; it does not run or query them yet.", + "native.toolchainSettings.versionManagersInfo": "Version managers come from enabled plugins. Lithe asks them what is installed and what this project requires; they never install or trust anything for you.", "native.toolchainSettings.noEnabledPluginProvidesVersionManager": "No enabled plugin provides a version manager.", "native.toolchainSettings.notUsedInThisProject": "Not used in this project", "native.toolchainSettings.reportsInstalledVersionsOnly": "Reports installed versions only", @@ -9813,7 +9813,15 @@ "native.toolchainSettings.onlyFirstInstallationsChecked": "Only the first {arg1} installations of a tool were checked.", "native.toolchainSettings.tooManyFilesForOneTool": "Too many files to check for one tool; some installations were skipped.", "native.toolchainSettings.onlyFirstLocationsScanned": "Only the first {arg1} discovery locations were scanned.", - "native.toolchainSettings.onlyFirstEntriesChecked": "Only the first {arg1} discovered entries were checked." + "native.toolchainSettings.onlyFirstEntriesChecked": "Only the first {arg1} discovered entries were checked.", + "native.toolchainSettings.query": "Query", + "native.toolchainSettings.answeredIn": "Answered in {milliseconds} ms", + "native.toolchainSettings.backendDidNotOfferProvider": "The plugin backend did not offer this version manager.", + "native.toolchainSettings.workspaceNotOpenGlobalOnly": "This workspace is not open in a window, so only global versions were read.", + "native.toolchainSettings.providerDidNotAnswer": "{provider} did not answer: {message}", + "native.toolchainSettings.firstDirectoriesQueried": "Only the first {count} directories were queried.", + "native.toolchainSettings.queryNotBuilt": "The toolchain query could not be built.", + "native.toolchainSettings.replyUnreadable": "The reply could not be read." }, "overrides": { "windows": { diff --git a/shared/i18n/locales/zh-CN.json b/shared/i18n/locales/zh-CN.json index 2747385064..870e60b79e 100644 --- a/shared/i18n/locales/zh-CN.json +++ b/shared/i18n/locales/zh-CN.json @@ -9765,16 +9765,16 @@ "native.toolchainSettings.fixedInfo": "固定版本只保存在这台电脑的 .lithe/toolchains/local.json 中,并作用于其下的子目录。此页面只读取,不会修改该文件。", "native.toolchainSettings.fromDirectory": "来自 {arg1}", "native.toolchainSettings.theProjectRoot": "项目根目录", - "native.toolchainSettings.toolsInfo": "每个工具采用第一个有值的来源,依次为固定版本、项目版本文件、全局默认、自动检测。已安装的版本通过运行该工具自己的版本命令来检查。版本管理器暂未接入查询,因此不显示版本文件所要求的版本。", + "native.toolchainSettings.toolsInfo": "每个工具采用第一个有值的来源,依次为固定版本、项目版本文件、全局默认、自动检测。已安装的版本通过运行该工具自己的版本命令来检查。已启用的版本管理器会报告版本文件所要求的版本。", "native.toolchainSettings.noEnabledPluginDefinesTools": "没有已启用的插件定义工具。", "native.toolchainSettings.toolDefinitionInfo": "这一行来自已启用插件的工具定义,Lithe 不内置任何工具。", "native.toolchainSettings.versionNotQueried": "版本未查询", "native.toolchainSettings.fixedInParent": "上级目录固定", "native.toolchainSettings.versionFile": "版本文件", "native.toolchainSettings.pinnedStepInfo": "在这台电脑上为此目录保存的版本,优先级最高,也作用于其下的子目录。", - "native.toolchainSettings.projectStepInfo": "此目录或上级目录中、由该工具的版本管理器监视的版本文件。其中要求的版本暂未查询。", + "native.toolchainSettings.projectStepInfo": "此目录或上级目录中、由该工具的版本管理器监视的版本文件,以及版本管理器报告的版本。", "native.toolchainSettings.noVersionFile": "没有版本文件", - "native.toolchainSettings.globalStepInfo": "插件为该工具声明的主目录变量和命令,从登录 Shell 中读取。主目录的版本来自该工具自己的版本命令;在 PATH 中找到的命令不会运行。版本管理器的全局版本暂未接入查询。", + "native.toolchainSettings.globalStepInfo": "版本管理器报告的全局版本,以及插件为该工具声明的主目录变量和命令(从登录 Shell 中读取)。主目录的版本来自该工具自己的版本命令;在 PATH 中找到的命令不会运行。", "native.toolchainSettings.automaticStepInfo": "在插件为该工具声明的位置中找到的安装。每个版本来自该工具自己的版本命令,使用其中最高的版本。项目内的安装永远不会运行。", "native.toolchainSettings.versionNotVerified": "版本未验证", "native.toolchainSettings.firstMatch": "首个命中", @@ -9790,7 +9790,7 @@ "native.toolchainSettings.usingLaunchEnvironment": "仍在使用 Lithe 启动时的环境", "native.toolchainSettings.variableCount": "{arg1} 个变量", "native.toolchainSettings.launchEnvironment": "启动时环境", - "native.toolchainSettings.versionManagersInfo": "版本管理器来自已启用的插件。Lithe 会在此项目中查找它们监视的文件,但暂不运行或查询它们。", + "native.toolchainSettings.versionManagersInfo": "版本管理器来自已启用的插件。Lithe 会询问它们已安装的版本和此项目的要求;它们不会替你安装或信任任何内容。", "native.toolchainSettings.noEnabledPluginProvidesVersionManager": "没有已启用的插件提供版本管理器。", "native.toolchainSettings.notUsedInThisProject": "此项目未使用", "native.toolchainSettings.reportsInstalledVersionsOnly": "只报告已安装的版本", @@ -9813,7 +9813,15 @@ "native.toolchainSettings.onlyFirstInstallationsChecked": "每个工具只检查了前 {arg1} 个安装。", "native.toolchainSettings.tooManyFilesForOneTool": "单个工具需要检查的文件过多,已跳过部分安装。", "native.toolchainSettings.onlyFirstLocationsScanned": "只扫描了前 {arg1} 个检测位置。", - "native.toolchainSettings.onlyFirstEntriesChecked": "只检查了前 {arg1} 个发现的条目。" + "native.toolchainSettings.onlyFirstEntriesChecked": "只检查了前 {arg1} 个发现的条目。", + "native.toolchainSettings.query": "查询", + "native.toolchainSettings.answeredIn": "{milliseconds} 毫秒内已回答", + "native.toolchainSettings.backendDidNotOfferProvider": "插件后端没有提供此版本管理器。", + "native.toolchainSettings.workspaceNotOpenGlobalOnly": "此工作区未在窗口中打开,因此只读取了全局版本。", + "native.toolchainSettings.providerDidNotAnswer": "{provider} 未回答:{message}", + "native.toolchainSettings.firstDirectoriesQueried": "只查询了前 {count} 个目录。", + "native.toolchainSettings.queryNotBuilt": "无法构建工具链查询。", + "native.toolchainSettings.replyUnreadable": "无法读取答复。" }, "overrides": { "macos": { diff --git a/shared/i18n/messages.json b/shared/i18n/messages.json index 3ac8408ea2..001e70ac23 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -93554,7 +93554,7 @@ "macos" ], "macos": { - "key": "Each tool uses the first source that has a value: a fixed version, then project version files, then global defaults, then automatic discovery. Installed versions are checked by running each tool's own version command. Version managers are not queried yet, so the versions that version files ask for are not shown.", + "key": "Each tool uses the first source that has a value: a fixed version, then project version files, then global defaults, then automatic discovery. Installed versions are checked by running each tool's own version command. Enabled version managers report what version files ask for.", "arguments": [] } }, @@ -93631,7 +93631,7 @@ "macos" ], "macos": { - "key": "Version files in this directory or its parents, watched by the version managers of this tool. The versions they ask for are not queried yet.", + "key": "Version files in this directory or its parents, watched by the version managers of this tool, and the versions those managers report for them.", "arguments": [] } }, @@ -93653,7 +93653,7 @@ "macos" ], "macos": { - "key": "The home variables and commands this tool's plugin declares, read from the login shell. A home's version comes from the tool's own version command; a command found on PATH is not run. Version managers' global versions are not queried yet.", + "key": "What version managers report as global, then the home variables and commands this tool's plugin declares, read from the login shell. A home's version comes from the tool's own version command; a command found on PATH is not run.", "arguments": [] } }, @@ -93839,7 +93839,7 @@ "macos" ], "macos": { - "key": "Version managers come from enabled plugins. Lithe looks for the files they watch in this project; it does not run or query them yet.", + "key": "Version managers come from enabled plugins. Lithe asks them what is installed and what this project requires; they never install or trust anything for you.", "arguments": [] } }, @@ -94140,6 +94140,111 @@ "arg1" ] } + }, + { + "id": "native.toolchainSettings.query", + "description": "Toolchain provider query: Query", + "platforms": [ + "macos" + ], + "macos": { + "key": "Query", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.answeredIn", + "description": "Toolchain provider query: Answered in %lld ms", + "platforms": [ + "macos" + ], + "macos": { + "key": "Answered in %lld ms", + "arguments": [ + "milliseconds" + ] + }, + "parameters": { + "milliseconds": "integer" + } + }, + { + "id": "native.toolchainSettings.backendDidNotOfferProvider", + "description": "Toolchain provider query: The plugin backend did not offer this version manager.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The plugin backend did not offer this version manager.", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.workspaceNotOpenGlobalOnly", + "description": "Toolchain provider query: This workspace is not open in a window, so only global versions were read.", + "platforms": [ + "macos" + ], + "macos": { + "key": "This workspace is not open in a window, so only global versions were read.", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.providerDidNotAnswer", + "description": "Toolchain provider query: %@ did not answer: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "%@ did not answer: %@", + "arguments": [ + "provider", + "message" + ] + }, + "parameters": { + "provider": "string", + "message": "string" + } + }, + { + "id": "native.toolchainSettings.firstDirectoriesQueried", + "description": "Toolchain provider query: Only the first %lld directories were queried.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Only the first %lld directories were queried.", + "arguments": [ + "count" + ] + }, + "parameters": { + "count": "integer" + } + }, + { + "id": "native.toolchainSettings.queryNotBuilt", + "description": "Toolchain provider query: The toolchain query could not be built.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The toolchain query could not be built.", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.replyUnreadable", + "description": "Toolchain provider query: The reply could not be read.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The reply could not be read.", + "arguments": [] + } } ] } diff --git a/shared/platform-feature-matrix/features/plugin-injected-toolchains.json b/shared/platform-feature-matrix/features/plugin-injected-toolchains.json index 6b664d4f6f..e8e24a8436 100644 --- a/shared/platform-feature-matrix/features/plugin-injected-toolchains.json +++ b/shared/platform-feature-matrix/features/plugin-injected-toolchains.json @@ -2,7 +2,7 @@ "id": "plugin-injected-toolchains", "area": "扩展", "group": "插件与主题", - "capability": "工具链领域框架:共享 Core 校验插件贡献、来源与四层解析;macOS 提供只读的运行环境和环境来源设置页,目录、工具、版本文件、固定值、登录 Shell 与版本管理器均来自已启用插件的声明和磁盘上的真实文件,自动检测按插件声明真实扫描本机安装并运行定义的版本探测,由 Core 选出最高版本。提供方查询、Lithe 全局默认、保存与运行时消费者仍未接入,不能把只读页面当作已生效配置。", + "capability": "工具链领域框架:共享 Core 校验插件贡献、来源与四层解析;macOS 提供只读的运行环境和环境来源设置页,目录、工具、版本文件、固定值、登录 Shell 与版本管理器均来自已启用插件的声明和磁盘上的真实文件。自动检测按插件声明真实扫描本机安装并运行定义的版本探测;版本管理器通过插件后端的 toolchains/query 报告安装和项目/全局要求,回复经 Core 校验后显示,监视的文件变化时整份重查。Lithe 全局默认、保存与运行时消费者仍未接入,不能把只读页面当作已生效配置。", "macos": { "implementationStatus": "partial", "verificationStatus": "pending", @@ -26,7 +26,11 @@ "macos/Tests/LitheTests/ToolchainSettingsFeatureTests.swift", "macos/Tests/LitheTests/ToolchainSettingsLoaderTests.swift", "macos/Tests/LitheTests/ToolchainSettingsLayoutTests.swift", - "macos/Tests/LitheTests/MacToolchainDiscoveryTests.swift" + "macos/Tests/LitheTests/MacToolchainDiscoveryTests.swift", + "rust/lithe-core/src/lsp/interface/toolchain_provider.rs", + "macos/Sources/Lithe/Platform/MacOS/Runtime/MacToolchainDiscovery+Providers.swift", + "macos/Sources/Lithe/Platform/MacOS/Runtime/MacToolchainInputWatcher.swift", + "macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift" ] }, "windows": { @@ -42,6 +46,6 @@ ] }, "owner": "Plugin Platform", - "verification": "共享 Core:cargo test --manifest-path rust/Cargo.toml -p lithe-core --lib toolchain,核对定义独占与官方短 ID、第二种通用工具、旧候选边界、答案身份/代次、Windows 路径大小写、工作区排除、固定值失效不回落、最近项目要求、冲突选择、最低版本、环境隔离和纯声明扫描;按逐项计时工具生成 HTML/JUnit。macOS 与 Windows 原生后续验收均需先实现对应 adapter:启动提供方并协商、取消/超时和过期结果隔离、输入新建/删除后整体重查、目录固定保存/取消/冲突回读、插件停用撤回、真实 Build/LSP/Run 消费者取得同一选择、发行目录哈希不变与子进程清理。未完成原生流程前保持 pending,不以 Core 测试代替实机验证。 原生布局回归 ToolchainSettingsLayoutTests:同一设置宿主按900/1180/1400/900重复缩放,真实分隔条拖动;来源页按500/666/946/500缩放,检查AX控件边界、卡片不重叠、选择和快照不改变。此几何验证不代表提供方查询或保存功能已完成。 声明式扫描 MacToolchainDiscoveryTests:真实临时目录、注入的用户目录与探测,覆盖 homeLayouts 首个匹配、隐藏与未匹配条目、链接到工作区的条目不探测、只探测 Core 返回的可执行文件、同版本冲突、被拒声明不扫描,以及隔离预览样例清单通过 Core 准入;不运行本机安装的工具。", - "notes": "本条仅记录 #1581 重设计的 Core 基础切片。两端共享同一确定性模型但均未接原生产品,不广告工具链 hostCapabilities;现有 Java/Maven/Node 发现与选择未迁移。安装来源支持两条途径:工具定义里的声明式扫描(由宿主展开)和提供方查询(toolchains/query,可只报告安装);Core 在自动层把两条途径按规范化目录合并;macOS 设置页已接入声明式扫描(宿主按 discoveryPlan 展开、收集文件事实、只探测 toolchains.candidates 返回的可执行文件,扫描与探测有数量和时长上限),提供方查询两端都还没接。Core 不运行 mise/SDKMAN、不解析其文件格式、不下载、不监听或写配置。通用 runtime 消费者、环境/来源界面、项目环境 symlink 完整原生验证及带后端免重启仍未完成;没有新增运行时资源或可复用下载目录。 macOS 设置页只读:运行环境页按目录列出工具,展开后按固定、项目文件、全局、自动四个来源排列并框出首个命中,提供方版本显示未查询、固定值显示已保存未验证;环境来源页显示登录 Shell 的读取状态、变量数(不显示值)、耗时和 PATH 各目录的来源,以及插件声明的版本管理器在本项目找到的版本文件。未实现的添加、切换、保存和查询入口不显示。Debug 隔离预览只有插件清单是样例,工作区文件、固定值和登录 Shell 均真实读取,不启动工具、不写真实配置。Core 独立位于 rust/lithe-core/src/toolchain/,插件只是声明贡献入口。" + "verification": "共享 Core:cargo test --manifest-path rust/Cargo.toml -p lithe-core --lib toolchain,核对定义独占与官方短 ID、第二种通用工具、旧候选边界、答案身份/代次、Windows 路径大小写、工作区排除、固定值失效不回落、最近项目要求、冲突选择、最低版本、环境隔离和纯声明扫描;按逐项计时工具生成 HTML/JUnit。macOS 与 Windows 原生后续验收均需先实现对应 adapter:启动提供方并协商、取消/超时和过期结果隔离、输入新建/删除后整体重查、目录固定保存/取消/冲突回读、插件停用撤回、真实 Build/LSP/Run 消费者取得同一选择、发行目录哈希不变与子进程清理。未完成原生流程前保持 pending,不以 Core 测试代替实机验证。 提供方查询:cargo test -p lithe-core --lib toolchain(含 lsp 引擎脚本后端:只有插件后端被提供 litheToolchains、未协商的提供方不写请求、错误回复原样返回;别名链接只有链接路径事实时仍选中规范 home);MacToolchainDiscoveryTests 用真实临时目录与注入的查询验证全局与目录查询各带独立 operationID/代次、链接 home 规范化、工作区内 home 整份拒绝;ToolchainSettingsFeatureTests 验证监视文件连续变化只重载一次、外部输入观察随关闭释放。真实 mise 插件端到端尚未在应用内验证。 原生布局回归 ToolchainSettingsLayoutTests:同一设置宿主按900/1180/1400/900重复缩放,真实分隔条拖动;来源页按500/666/946/500缩放,检查AX控件边界、卡片不重叠、选择和快照不改变。此几何验证不代表提供方查询或保存功能已完成。 声明式扫描 MacToolchainDiscoveryTests:真实临时目录、注入的用户目录与探测,覆盖 homeLayouts 首个匹配、隐藏与未匹配条目、链接到工作区的条目不探测、只探测 Core 返回的可执行文件、同版本冲突、被拒声明不扫描,以及隔离预览样例清单通过 Core 准入;不运行本机安装的工具。", + "notes": "本条仅记录 #1581 重设计的 Core 基础切片。两端共享同一确定性模型但均未接原生产品,不广告工具链 hostCapabilities;现有 Java/Maven/Node 发现与选择未迁移。安装来源支持两条途径:工具定义里的声明式扫描(由宿主展开)和提供方查询(toolchains/query,可只报告安装);Core 在自动层把两条途径按规范化目录合并;macOS 设置页已接入声明式扫描(宿主按 discoveryPlan 展开、收集文件事实、只探测 toolchains.candidates 返回的可执行文件,扫描与探测有数量和时长上限),macOS 设置页已接入提供方查询(启动或复用插件后端、协商 litheToolchains、每次查询独立 operationID 与代次、超时 15 秒、取消发送 $/cancelRequest,回复交 toolchains.decodeReply 用宿主读取的文件事实校验,失败原样显示提供方原因);Windows 仍未接。Core 不运行 mise/SDKMAN、不解析其文件格式、不下载、不监听或写配置。通用 runtime 消费者、环境/来源界面、项目环境 symlink 完整原生验证及带后端免重启仍未完成;没有新增运行时资源或可复用下载目录。 macOS 设置页只读:运行环境页按目录列出工具,展开后按固定、项目文件、全局、自动四个来源排列并框出首个命中,提供方报告的要求与版本显示在项目与全局步骤中、固定值显示已保存未验证;环境来源页显示登录 Shell 的读取状态、变量数(不显示值)、耗时和 PATH 各目录的来源,以及插件声明的版本管理器在本项目找到的版本文件。未实现的添加、切换、保存和查询入口不显示。Debug 隔离预览只有插件清单是样例,工作区文件、固定值和登录 Shell 均真实读取,不启动工具、不写真实配置。Core 独立位于 rust/lithe-core/src/toolchain/,插件只是声明贡献入口。" }