diff --git a/.agents/notes/implemented/architecture/2026-10-17-build-system-plugin-contract.md b/.agents/notes/implemented/architecture/2026-10-17-build-system-plugin-contract.md index 1a84455315..d46bac82bb 100644 --- a/.agents/notes/implemented/architecture/2026-10-17-build-system-plugin-contract.md +++ b/.agents/notes/implemented/architecture/2026-10-17-build-system-plugin-contract.md @@ -69,7 +69,7 @@ Rust Core 负责校验声明、识别工程、展开每一条命令行,并把 插件可以自由描述(不需要改 Core): - **任何构建工具**:ID、标记文件、任务、选项、运行入口、测试过滤器、诊断正则、依赖命令都是数据。 -- **可执行文件**:按偏好顺序列出候选,有四种: +- **可执行文件**:按偏好顺序列出候选,有四种基础来源: - `projectFile`:工程里的 Wrapper(例如 `gradlew`),可以要求某些文件存在。它一律被视为执行仓库代码。 - `environment`:某个环境变量下的相对路径,例如 `MAVEN_HOME` 下的 `bin/mvn`。变量必须列在 `environmentInputs` 里,宿主只传这些变量。 - `command`:在 `PATH` 上查找的裸命令名。 @@ -365,3 +365,31 @@ macOS 接入规则(2026-10-18): - `shared/contracts/plugins/v1/` - `shared/contracts/rust-core-api.md` - `scripts/verify-shared-contracts.sh` + + +### 统一的候选解析扩展 + +在原有自动发现来源之上,可选 `executableResolution: 1` 增加 setting 来源与 +确定性排序;候选仍声明在同一个 executables 数组里,不增加新的 Build 覆盖路径。 +数组顺序是默认优先级,不需要路径覆盖的插件不用声明设置。 +共享类型与拓扑排序位于 `plugins::executables`,macOS 由 +`ExecutableCandidateResolver` 注入文件系统、环境、Runtime 和工具发现能力, +区分跳过、解析成功和终止失败。Provider 的可选 tool 提示继续复用宿主解析结果; +无显式选择的自动发现失败仍可让 Provider 自行查找,显式失败则停止本次模型或依赖加载。 +这个限制也覆盖前置 Runtime 先失败的情况:Provider 回退前检查整个调用计划里的 +工具和 Runtime 是否有非空 setting,不能仅依赖失败候选的 terminal 标志。 +例如用户选了工具 A,先解析的 Runtime 不可用时,应报告 Runtime 错误;不能丢掉 +A,发出不带 tool 的请求。空 setting 仍允许自动发现失败后交给 Provider。 +只在解析失败时检查这项约束,前面候选成功后不会被后面未使用的 setting 阻止。 +Runtime 每个候选先构造自己的环境,把所选目录加入 PATH,再以同一环境探测和 +执行;失败候选的 PATH 不传给下一个候选。否则同目录 helper 会在探测时缺失, +合法的 Runtime 就会被误判为版本不足。 +设置值非法时 Core 直接拒绝计划,普通设置的容错显示规则不变。 +自定义 Runtime 候选复用同一机制;其余进程模块的分步迁移边界见 +[插件设置面](2026-10-21-plugin-settings-surfaces.md)。 + +`BuildToolProviderResolutionTests` 使用真实 Core 计划、MacBuildToolResolver 和 +BuildSystemService 验证上述组合:前置 Runtime 缺失或版本不足时,工具或后置 +Runtime 的显式选择均阻止模型和依赖 Provider 请求;全部设置为空时仍发送无 tool 的请求。 +`MacBuildToolResolverTests.customRuntimeProbeAndExecutionUseTheSameSiblingHelper` +用临时可执行脚本验证带空格目录里的 Runtime 在探测和执行时都能找到同目录 helper。 diff --git a/.agents/notes/implemented/architecture/2026-10-17-plugin-capability-versions.md b/.agents/notes/implemented/architecture/2026-10-17-plugin-capability-versions.md index f86ba51a7e..874a938e90 100644 --- a/.agents/notes/implemented/architecture/2026-10-17-plugin-capability-versions.md +++ b/.agents/notes/implemented/architecture/2026-10-17-plugin-capability-versions.md @@ -136,3 +136,14 @@ macOS 宿主接受级别 1 到 3(`supportedDeclarativeAPIVersions = [1, 2, 3]` - `rust/lithe-core/src/plugins/marketplace.rs` - `shared/contracts/plugins/v1/` - `shared/contracts/rust-core-api.md` + + +### 共享可执行文件候选能力 + +`executableResolution: 1` 是候选解析机制的可选能力,不是所有构建插件都要列的新字段。 +只有使用 setting 来源或 id/priority/insertBefore/insertAfter 时才声明;自动发现数组 +保持原序和原来的 buildSystems 1。macOS 先通过 Build 与自定义 Runtime 适配接入, +Windows 暂不声明;Language Server、Execution Provider 的新候选语法需各自后续接入后 +才可开放。底层源类型、排序与终止失败规则共用,启动安全约束仍属于各适配器。 +已发布 Cargo 0.1.1 的 buildSystems 2 输入通过兼容适配转换为同一个候选列表, +不维持独立 configuredExecutable 输出;细节见[插件设置面](2026-10-21-plugin-settings-surfaces.md)。 diff --git a/.agents/notes/implemented/architecture/2026-10-21-plugin-settings-surfaces.md b/.agents/notes/implemented/architecture/2026-10-21-plugin-settings-surfaces.md index 97101f79b1..7f19d6e5b7 100644 --- a/.agents/notes/implemented/architecture/2026-10-21-plugin-settings-surfaces.md +++ b/.agents/notes/implemented/architecture/2026-10-21-plugin-settings-surfaces.md @@ -65,9 +65,73 @@ macOS 设置窗口现在可以由插件“注册”页面:插件在 `plugin.js 为了保证“立即生效”真的不需要新进程,Core 在校验时拒绝把 `immediate` 设置用在命令行参数或 `initializationOptions` 里。 -### 5. 宿主能力版本 - -macOS 的 `hostCapabilities` 声明 `settings: [1, 2]` 和 `languageServers: [1, 2]`。Windows 还没有这些界面和推送,所以仍只声明 1,需要 v2 的插件在 Windows 上会被市场隐藏,而不是装上后行为不完整。 +### 5. 构建工具的可执行文件路径 + +路径选择使用同一个 `invocation.executables` 数组。插件需要用户覆盖时,声明 +`kind: "setting"`、`settingID`,引用全局 file 设置(reload 或 nextRun);不需要 +覆盖的插件保持原来的自动发现列表,不额外声明设置。该通用机制按 +`executableResolution: 1` 协商,不要求所有构建插件升级。 + +Core 先校验完整候选集、排定顺序,再按平台过滤。数组从前到后是首选写法; +可选 priority 以较大值优先,同分按原序;insertBefore/insertAfter 使用同集合的稳定 +id 建立相对顺序约束。插入约束优先,不能在同一候选混用 priority 或两个插入字段; +重复 id、未知锚点、自环与循环都拒绝。契约正文定义完整的确定性算法,供后续使用方复用。 + +macOS 的 `ExecutableCandidateResolver`(候选文件解析器)由宿主注入文件系统、 +环境变量、Runtime 和工具发现服务,返回跳过、已解析或终止失败。设置值来自 Core +检查后的同一操作快照;空值跳过,非空无效值直接拒绝,不能经过普通设置解析的 +“无效则回默认值”。普通设置的显示与配置容错不变。平台检查权限与版本,显式路径 +失败时 Provider 不得独立发现另一安装。带空格路径保持完整参数,父目录进入 PATH。 + +共享源类型和排序在 `plugins::executables`;本次 Build 与自定义 Runtime Dependencies +使用共享解析器,已有 Build Provider 继续通过 ServerProcess 适配和 tool 提示复用。 +Language Server、Execution Provider 等后续可接入相同机制,各自保留包内文件边界、 +自动启动限制和工作区信任;当前不在这些清单位置开放新候选语法。 +参考 xiaoyumuxi 的 #1318、#1356、#1382 以及 Maven 插件 #3,宿主仍只负责通用机制, +Provider 继续接受宿主选中的工具,不重新实现一套覆盖路径。 + +已发布预览 Cargo 0.1.1 使用旧 `executableSetting`(buildSystems 2)。保留只读的兼容 +输入适配:转换为数组首位 setting 候选,后续只走共享解析器,不再返回 +configuredExecutable。新插件必须用第五种候选和新能力;既有发行包不覆盖,迁移以新版本发布。 + +路径偏好仍写既有用户存储,候选计划只在操作内存活,没有额外缓存或安装目录写入, +不影响签名和 Sparkle delta。Core、Swift 与独立 Cargo CI 覆盖统一的选择和失败语义。 + +### 6. 宿主能力版本 + +macOS 的 `hostCapabilities` 声明 `settings: [1, 2]`、`languageServers: [1, 2]` 和 `buildSystems: [1, 2]`。Windows 还没有这些界面和推送,所以仍只声明 1,需要 v2 的插件在 Windows 上会被市场隐藏,而不是装上后行为不完整。 + +### 7. 路径选择按钮保留完整宽度 + +插件详情页比完整设置窗口窄。路径输入框若固定占用 220 点,同时右侧作用域和 +生效标签保持完整,SwiftUI 会把“选择…”按钮压成没有文字的灰条。通用路径 +控件因此固定按钮的自然宽度,让输入框随剩余空间缩短;共享设置行的标题列 +以原来的 230 点为上限,窄面板时也允许缩短。宽面板仍保持原来的列宽。 +缩放只调整布局,不替换输入框实例,以免丢失尚未提交的路径。 + +这与 IDEA Community 的文本框加浏览按钮采用相同的空间分配:检查的版本是 +`c7f91397daa3a961b4e78bc634fe467a0a7d9ade`,源码为 +`platform/platform-api/src/com/intellij/openapi/ui/TextFieldWithBrowseButton.java` +及 `ComponentWithBrowseButton.java`。后者把文本框放在 `BorderLayout.CENTER`, +把 `FixedSizeButton` 放在 `BorderLayout.EAST`,让输入部分承担宽度变化。 +macOS 保留既有原生控件和主题,只在通用 owner 中修复压缩规则,不为 Cargo +增加专门的宽度或页面。 + +### 8. 插件详情不能越过分隔线 + +分隔容器分配了确定矩形,不代表子内容一定接受这个宽度。插件详情的表单 +有自己的最小宽度;窗口缩小后,原来的根容器会按更宽的内容居中,导致名称、 +页签和卡片跨进左侧列表,分隔线看起来穿过了详情。 + +详情根视图必须接受分隔容器给出的宽度,并在自己的边界内绘制。设置页的 +原生表单在宽面板填满可用宽度;面板不足以容纳完整设置行时,使用同一个 +滚动容器横向查看,保留输入框、选择器和右侧状态标签。只裁剪超出的表单会 +让部分设置无法访问;提高整个设置窗口的最小宽度又会限制其他页面,因此 +不采用这两种单独修补方式。 + +宽度只在详情正文的小容器里读取,用于分配可见区域。已构建的正文作为值 +传入,不在几何回调里重新加载插件、重建列表或保存宽度。拖动和持久化仍由 +既有 `LitheSplitPaneView` 负责,窄宽切换不替换表单实例。 ## 考虑过的备选方案 @@ -79,6 +143,11 @@ macOS 的 `hostCapabilities` 声明 `settings: [1, 2]` 和 `languageServers: [1, 插件已经可以声明网页 UI,理论上可以自己画设置页。但网页无法和宿主设置窗口的搜索、分组、项目/全局切换保持一致,用户偏好也是原生优先。网页 UI 仍可用于复杂的工具窗口,设置项本身走原生控件。 +### 顶层增加可执行文件覆盖旁路 + +单独增加 executableSetting/configuredExecutable 会让 Build 的设置路径绕过候选顺序, +其它进程模块也不能复用。只为已发布预览包保留输入适配,新 API 使用统一候选与宿主能力注入。 + ### 宿主自己合并取值 Swift 和 Windows 各写一遍合并规则最直接,但两边一旦有细微差异,界面显示的值和实际启动使用的值就会不同。所以合并只在 Core 做,宿主只负责存储和显示。 @@ -97,6 +166,8 @@ Swift 和 Windows 各写一遍合并规则最直接,但两边一旦有细微 - `cargo test -p lithe-core --lib plugin_configuration`:会话按 section 应答、更新推送、宿主会话拒绝更新。 - `./scripts/test-macos.sh --filter PluginSettingsTests`:项目层存储、作用域规则、页面注册、配置展开。 - `./scripts/test-macos.sh --filter SettingsSidebarLayoutTests`:静态页面仍各归一个分组。 +- `./scripts/test-macos.sh --filter PluginSettingsLayoutTests`:480、640、900 点宽的深浅主题下,路径输入框和完整选择按钮都在容器内,按钮可调用选择器并写入所选路径。 +- `./scripts/test-macos.sh --filter PluginDetailLayoutTests`:实际 Java 插件表单在深浅主题中往返缩放,详情边界始终跟随分隔条,窄面板下右侧设置可经原生横向滚动到达。 - 共享夹具:`shared/contracts/plugins/v1/fixtures/settings-v2.plugin.json`。 ## 适用范围 @@ -108,5 +179,6 @@ Swift 和 Windows 各写一遍合并规则最直接,但两边一旦有细微 - `macos/Sources/Lithe/Models/AppModel/AppModel+PluginSettings.swift` - `macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginSettingsStore.swift` - `macos/Sources/Lithe/Views/App/PluginSettingsSection.swift` +- `macos/Sources/Lithe/Views/Components/LitheSettingsControls.swift` - `macos/Sources/Lithe/Views/App/ContributedSettingsPageView.swift` - `shared/contracts/plugins/v1/README.md` diff --git a/docs/assets/plugin-executable-layout/after-detail-700-dark.png b/docs/assets/plugin-executable-layout/after-detail-700-dark.png new file mode 100644 index 0000000000..5b335a64a0 Binary files /dev/null and b/docs/assets/plugin-executable-layout/after-detail-700-dark.png differ diff --git a/docs/assets/plugin-executable-layout/after-detail-700-light.png b/docs/assets/plugin-executable-layout/after-detail-700-light.png new file mode 100644 index 0000000000..edd2b8caab Binary files /dev/null and b/docs/assets/plugin-executable-layout/after-detail-700-light.png differ diff --git a/docs/assets/plugin-executable-layout/after-path-480-dark.png b/docs/assets/plugin-executable-layout/after-path-480-dark.png new file mode 100644 index 0000000000..8bf72ea09f Binary files /dev/null and b/docs/assets/plugin-executable-layout/after-path-480-dark.png differ diff --git a/docs/assets/plugin-executable-layout/after-path-480-light.png b/docs/assets/plugin-executable-layout/after-path-480-light.png new file mode 100644 index 0000000000..7cceae5301 Binary files /dev/null and b/docs/assets/plugin-executable-layout/after-path-480-light.png differ diff --git a/docs/assets/plugin-executable-layout/before-detail-700-dark.png b/docs/assets/plugin-executable-layout/before-detail-700-dark.png new file mode 100644 index 0000000000..9fd8e40dd0 Binary files /dev/null and b/docs/assets/plugin-executable-layout/before-detail-700-dark.png differ diff --git a/docs/assets/plugin-executable-layout/before-detail-700-light.png b/docs/assets/plugin-executable-layout/before-detail-700-light.png new file mode 100644 index 0000000000..11bbe32576 Binary files /dev/null and b/docs/assets/plugin-executable-layout/before-detail-700-light.png differ diff --git a/docs/assets/plugin-executable-layout/before-path-480-dark.png b/docs/assets/plugin-executable-layout/before-path-480-dark.png new file mode 100644 index 0000000000..a343f436fb Binary files /dev/null and b/docs/assets/plugin-executable-layout/before-path-480-dark.png differ diff --git a/docs/assets/plugin-executable-layout/before-path-480-light.png b/docs/assets/plugin-executable-layout/before-path-480-light.png new file mode 100644 index 0000000000..3674bf8091 Binary files /dev/null and b/docs/assets/plugin-executable-layout/before-path-480-light.png differ diff --git a/docs/plugin-executable-layout-visual-check.md b/docs/plugin-executable-layout-visual-check.md new file mode 100644 index 0000000000..a4bac975be --- /dev/null +++ b/docs/plugin-executable-layout-visual-check.md @@ -0,0 +1,48 @@ +# 插件路径控件和详情分栏的原生截图 + +以下图像使用 `PluginSettingsLayoutTests`、`PluginDetailLayoutTests` 中相同的 +SwiftUI/AppKit 视图、示例数据、尺寸和主题。原布局取自 PR 基线 +`fe0b31c71f692bbb60bf938da9c47498bbd1d0ec` 的三个视图文件;当前布局包含 +`fc653d5c0` 和 `589906520` 两个独立 UI 修复提交。此次 Runtime/Provider 修复 +没有继续修改布局实现。 + +截图由原生视图绘制生成,测试宿主的左栏是占位文字;不代表完整产品窗口或 +真实桌面合成验收。前后图均在初始未滚动的位置采集。原布局只用于渲染对比; +当前布局另外运行完整的几何、输入实例、横向滚动与路径选择断言。 + +## 路径控件,480pt 宿主 + +原布局里的输入框固定占宽,按钮文字被挤掉。当前布局保留选择按钮的自然宽度, +标题与输入框随可用空间缩短,右侧的作用域与生效标签仍可见。 + +| 主题 | PR 基线 | 当前布局 | +| --- | --- | --- | +| 浅色 | ![浅色原路径控件](assets/plugin-executable-layout/before-path-480-light.png) | ![浅色修复后路径控件](assets/plugin-executable-layout/after-path-480-light.png) | +| 深色 | ![深色原路径控件](assets/plugin-executable-layout/before-path-480-dark.png) | ![深色修复后路径控件](assets/plugin-executable-layout/after-path-480-dark.png) | + +## 插件详情,700 × 700pt 分栏宿主 + +原布局的详情内容越过分隔线进入左栏。当前布局接受分栏宽度,内容留在分隔线 +右侧;窄设置表单可横向滚动。回归还验证 900、1000 和 1100pt 宽度,以及 +窗口高度变化时的顶部对齐和输入框实例保持。 + +| 主题 | PR 基线 | 当前布局 | +| --- | --- | --- | +| 浅色 | ![浅色原插件详情](assets/plugin-executable-layout/before-detail-700-light.png) | ![浅色修复后插件详情](assets/plugin-executable-layout/after-detail-700-light.png) | +| 深色 | ![深色原插件详情](assets/plugin-executable-layout/before-detail-700-dark.png) | ![深色修复后插件详情](assets/plugin-executable-layout/after-detail-700-dark.png) | + +## 验证出口 + +在仓库根目录运行完整断言及当前布局截图导出: + +```sh +LITHE_PLUGIN_SETTINGS_LAYOUT_CAPTURES="$PWD/.artifacts/plugin-layout-check" \ +LITHE_PLUGIN_DETAIL_LAYOUT_CAPTURES="$PWD/.artifacts/plugin-layout-check" \ + ./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- \ + --filter 'PluginSettingsLayoutTests|PluginDetailLayoutTests' +``` + +标准导出的详情图在横向滚动到末端后采集,便于检查右侧设置是否仍可访问。 +本表另在同一视图布局完成后、滚动前采集,以保持前后位置一致。 +截图是文档静态材料,不被产品加载,也不进入应用资源包、安装目录或工作树 +资源复用清单。测试窗口由各用例关闭,不保留临时应用或后台进程。 diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index f108093328..d1909c2483 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -2109,6 +2109,7 @@ "Reset Page Zoom (100%)" = "Reset Page Zoom (100%)"; "Page Zoom" = "Page Zoom"; +"The configured build tool is not executable: %@" = "The configured build tool is not executable: %@"; /* Embedded HTML preview browser: camera and microphone. */ "“%@” wants to use your camera." = "“%@” wants to use your camera."; "“%@” wants to use your microphone." = "“%@” wants to use your microphone."; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index b2219fa0df..b3010fd514 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -3686,6 +3686,7 @@ "Reset Page Zoom (100%)" = "恢复页面缩放(100%)"; "Page Zoom" = "页面缩放"; +"The configured build tool is not executable: %@" = "配置的构建工具无法执行:%@"; /* 内嵌 HTML 预览浏览器:摄像头和麦克风。 */ "“%@” wants to use your camera." = "“%@”想使用你的摄像头。"; "“%@” wants to use your microphone." = "“%@”想使用你的麦克风。"; diff --git a/macos/Sources/Lithe/Core/Rust/RustBuildSystemOperations.swift b/macos/Sources/Lithe/Core/Rust/RustBuildSystemOperations.swift index b348b153b7..84b2ca699f 100644 --- a/macos/Sources/Lithe/Core/Rust/RustBuildSystemOperations.swift +++ b/macos/Sources/Lithe/Core/Rust/RustBuildSystemOperations.swift @@ -41,11 +41,13 @@ struct RustBuildSystemOperations: BuildSystemPlanning { return outcome.value.languageImport } - func invocationPlan(_ target: BuildSystemTarget, root: String) throws -> BuildInvocation { + func invocationPlan( + _ target: BuildSystemTarget, root: String, settings: [String: ToolingJSONValue] = [:] + ) throws -> BuildInvocation { let outcome: Outcome = try execute( "build.invocationPlan", target: target, - request: InvocationRequest(root: root, platform: "macos") + request: InvocationRequest(root: root, platform: "macos", settings: settings) ) return try outcome.value.invocation.invocation() } @@ -289,6 +291,7 @@ struct RustBuildSystemOperations: BuildSystemPlanning { private struct InvocationRequest: Encodable { let root: String let platform: String + let settings: [String: ToolingJSONValue] } private struct VersionRequest: Encodable { @@ -470,8 +473,10 @@ struct RustBuildSystemOperations: BuildSystemPlanning { let variable: String? let command: String? let runtime: String? + let settingID: String? func executable() throws -> BuildInvocation.Executable { + if kind == "setting", let settingID { return .setting(id: settingID, path: path) } switch (kind, path, variable, command, runtime) { case ("projectFile", let path?, _, _, _): return .projectFile(path: path, requires: requires ?? []) diff --git a/macos/Sources/Lithe/Platform/MacOS/Build/MacBuildToolResolver.swift b/macos/Sources/Lithe/Platform/MacOS/Build/MacBuildToolResolver.swift index 342836daf8..019f5a81f8 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Build/MacBuildToolResolver.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Build/MacBuildToolResolver.swift @@ -46,48 +46,26 @@ final class MacBuildToolResolver: BuildToolResolving { } var problems: [String] = [] + let resolver = candidateResolver(runtimeExecutables) for candidate in invocation.executables { - switch candidate { - case .projectFile(let path, let requires): - let url = workspaceURL.appendingPathComponent(path).standardizedFileURL - guard requires.allSatisfy({ fileStorage.fileExists(at: workspaceURL.appendingPathComponent($0)) }), - fileStorage.isExecutable(at: url) else { continue } - return ResolvedBuildTool( - executableURL: url, - environment: environment, - executesProjectCode: true, - version: nil - ) - case .environment(let variable, let path): - guard let url = environmentExecutable( - variable: variable, path: path, invocation: invocation, environment: environment - ) else { continue } - if let tool = try await accept( - url, invocation: invocation, environment: environment, - checkVersion: checkVersion, problems: &problems - ) { - return tool - } - case .command(let command): - let candidates = commandCandidates(command) - if candidates.isEmpty { - problems.append(runtime.missingToolMessage(command)) - } - for url in candidates { - if let tool = try await accept( - url, invocation: invocation, environment: environment, - checkVersion: checkVersion, problems: &problems - ) { - return tool + switch resolver.resolve(candidate, workspaceURL: workspaceURL, environment: environment, environmentInputs: invocation.environmentInputs) { + case .skip: continue + case .terminalFailure(let message): throw BuildToolResolutionError(message, terminal: true) + case .resolved(let choices): + for choice in choices { + var selectedEnvironment = environment + if choice.terminal { + selectedEnvironment["PATH"] = choice.url.deletingLastPathComponent().path + ":" + (environment["PATH"] ?? "") } - } - case .runtime(let id): - guard let url = runtimeExecutables[id] else { continue } - if let tool = try await accept( - url, invocation: invocation, environment: environment, - checkVersion: checkVersion, problems: &problems - ) { - return tool + if choice.executesProjectCode { + return ResolvedBuildTool(executableURL: choice.url, environment: selectedEnvironment, executesProjectCode: true, version: nil) + } + do { + if let tool = try await accept(choice.url, invocation: invocation, environment: selectedEnvironment, + checkVersion: checkVersion, problems: &problems) { return tool } + } catch is CancellationError { throw CancellationError() } + catch { throw BuildToolResolutionError(error.localizedDescription, terminal: choice.terminal) } + if choice.terminal { throw BuildToolResolutionError(Self.unavailableMessage(displayName, problems: problems), terminal: true) } } } } @@ -98,27 +76,24 @@ final class MacBuildToolResolver: BuildToolResolving { /// must not run build code or a version probe merely by opening a project. func languageImportExecutable(_ invocation: BuildInvocation, workspaceURL: URL) -> URL? { let environment = runtime.processEnvironment() + let resolver = candidateResolver([:]) for candidate in invocation.executables { - switch candidate { - case .projectFile(let path, let requires): - let url = workspaceURL.appendingPathComponent(path).standardizedFileURL - if requires.allSatisfy({ fileStorage.fileExists(at: workspaceURL.appendingPathComponent($0)) }), - fileStorage.isExecutable(at: url) { return url } - case .environment(let variable, let path): - if let url = environmentExecutable( - variable: variable, path: path, invocation: invocation, environment: environment - ) { return url } - case .command(let command): - if let url = commandCandidates(command).first { return url } - case .runtime: - // Runtime selection can probe a JDK; it is unnecessary for an - // optional installation settings path and stays lazy. - continue + // No runtime selection or version probes during passive import. + switch resolver.resolve(candidate, workspaceURL: workspaceURL, environment: environment, environmentInputs: invocation.environmentInputs) { + case .skip: continue + case .terminalFailure: return nil + case .resolved(let choices): return choices.first?.url } } return nil } + private func candidateResolver(_ runtimes: [String: URL]) -> ExecutableCandidateResolver { + ExecutableCandidateResolver(fileExists: { [fileStorage] in fileStorage.fileExists(at: $0) }, + isExecutable: { [fileStorage] in fileStorage.metadata(for: $0)?.isDirectory == false && fileStorage.isExecutable(at: $0) }, + commands: { [self] in commandCandidates($0) }, runtimes: { runtimes[$0] }) + } + /// Keeps a candidate when the build tool declares no minimum version or /// its probe satisfies it. private func accept( @@ -151,66 +126,42 @@ final class MacBuildToolResolver: BuildToolResolving { checkVersion: @escaping @Sendable (String?, String) throws -> BuildVersionCheck ) async throws -> URL { let name = dependency.displayName ?? dependency.id - var javaHome: URL? - var candidates: [URL] = [] - /// Project-local runtimes run repository code, so they are never - /// probed; the user starts them by running a command. - var projectOwned: Set = [] - if dependency.hostManaged { - if dependency.id == "java" { - javaHome = runtime.javaHomeURL() - candidates = javaHome.map { [$0.appendingPathComponent("bin/java")] } ?? [] - } else { - candidates = commandCandidates(dependency.id) - } - } else { - for executable in dependency.executables { - switch executable { - case .command(let command): - candidates += commandCandidates(command) - case .environment(let variable, let path): - if let url = environmentExecutable( - variable: variable, path: path, invocation: invocation, environment: environment - ) { - candidates.append(url) - } - case .projectFile(let path, let requires): - let url = workspaceURL.appendingPathComponent(path).standardizedFileURL - guard requires.allSatisfy({ - fileStorage.fileExists(at: workspaceURL.appendingPathComponent($0)) - }), fileStorage.isExecutable(at: url) else { continue } - candidates.append(url) - projectOwned.insert(url) - case .runtime: - // A runtime is not another runtime. - continue - } - } - } - guard !candidates.isEmpty else { - throw BuildToolResolutionError(dependency.id == "java" - ? String(localized: "No JDK is configured for this project.") - : runtime.missingToolMessage(dependency.id)) - } - + let javaHome = dependency.hostManaged && dependency.id == "java" ? runtime.javaHomeURL() : nil + let sources: [ExecutableCandidate] = dependency.hostManaged + ? (dependency.id == "java" ? [.runtime("java")] : [.command(dependency.id)]) + : dependency.executables + let resolver = candidateResolver(javaHome.map { ["java": $0.appendingPathComponent("bin/java")] } ?? [:]) var problems: [String] = [] - for url in candidates { - if !projectOwned.contains(url), - let minimum = dependency.minimumVersion, let arguments = dependency.versionArguments { - let output = await probe(url, arguments: arguments, environment: environment) - let check = try checkVersion(dependency.id, output) - guard check.satisfied else { - problems.append(Self.tooOldMessage(url.path, found: check.version, minimum: minimum)) - continue + for source in sources { + switch resolver.resolve(source, workspaceURL: workspaceURL, environment: environment, environmentInputs: invocation.environmentInputs) { + case .skip: continue + case .terminalFailure(let message): throw BuildToolResolutionError(message, terminal: true) + case .resolved(let choices): + for choice in choices { + // Probes and execution need the same PATH, including helpers + // next to a selected runtime. Failed candidates must not + // contribute their environment to the next choice. + var selectedEnvironment = environment + if let javaHome { selectedEnvironment["JAVA_HOME"] = javaHome.path } + let directory = choice.url.deletingLastPathComponent().path + let path = environment["PATH"] ?? "" + selectedEnvironment["PATH"] = path.isEmpty ? directory : directory + ":" + path + if !choice.executesProjectCode, + let minimum = dependency.minimumVersion, let arguments = dependency.versionArguments { + let output = await probe(choice.url, arguments: arguments, environment: selectedEnvironment) + let check: BuildVersionCheck + do { check = try checkVersion(dependency.id, output) } + catch { throw BuildToolResolutionError(error.localizedDescription, terminal: choice.terminal) } + guard check.satisfied else { + problems.append(Self.tooOldMessage(choice.url.path, found: check.version, minimum: minimum)) + if choice.terminal { throw BuildToolResolutionError(Self.unavailableMessage(name, problems: problems), terminal: true) } + continue + } + } + environment = selectedEnvironment + return choice.url } } - if let javaHome { - environment["JAVA_HOME"] = javaHome.path - } - let directory = url.deletingLastPathComponent().path - let path = environment["PATH"] ?? "" - environment["PATH"] = path.isEmpty ? directory : directory + ":" + path - return url } throw BuildToolResolutionError(Self.unavailableMessage(name, problems: problems)) } @@ -221,19 +172,6 @@ final class MacBuildToolResolver: BuildToolResolving { .map(\.executableURL) } - /// Only variables the build system lists in `environmentInputs` are read. - private func environmentExecutable( - variable: String, - path: String, - invocation: BuildInvocation, - environment: [String: String] - ) -> URL? { - guard invocation.environmentInputs.contains(variable), - let base = environment[variable], base.hasPrefix("/") else { return nil } - let url = URL(fileURLWithPath: base).appendingPathComponent(path).standardizedFileURL - return fileStorage.isExecutable(at: url) ? url : nil - } - private func probe(_ url: URL, arguments: [String], environment: [String: String]) async -> String { let modified = fileStorage.metadata(for: url)?.modificationDate?.timeIntervalSince1970 ?? 0 let key = ([url.path, String(modified)] + arguments).joined(separator: "\u{1F}") diff --git a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index 0835bfc7e2..301ba6e0ec 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -409,6 +409,7 @@ final class MacServiceContainer { ), runner: MacBuildCommandRunner(processRegistry: processRegistry), providers: buildProviderSessions, + settings: { pluginSettings.values(forPlugin: $0) }, sources: { // Enablement is read on every detection, so turning a // plugin off takes its build systems away at once. diff --git a/macos/Sources/Lithe/Views/App/PluginDetailView.swift b/macos/Sources/Lithe/Views/App/PluginDetailView.swift index d05479a9b8..527e24c396 100644 --- a/macos/Sources/Lithe/Views/App/PluginDetailView.swift +++ b/macos/Sources/Lithe/Views/App/PluginDetailView.swift @@ -59,14 +59,11 @@ struct PluginDetailView: View { notices tabBar Rectangle().fill(LitheTheme.divider).frame(height: 1) - ScrollView { - tabContent - .padding(22) - .frame(maxWidth: .infinity, alignment: .leading) - } - .litheScrollViewChrome() + PluginDetailContent(scrollsHorizontally: tab == .settings, content: tabContent) } + .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .topLeading) .background(LitheTheme.settingsSurface) + .clipped() } // MARK: Header @@ -86,6 +83,7 @@ struct PluginDetailView: View { .padding(.horizontal, 22) .padding(.top, 20) .padding(.bottom, 12) + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) } private var identity: some View { @@ -718,3 +716,25 @@ struct PluginDetailView: View { PluginMarketplacePresentation.localized(key, locale: locale) } } + +/// Keep resize measurement local: the plugin's content is built before entering +/// this reader. A native settings row retains its controls and can scroll when +/// the detail pane is narrower than the complete form. +private struct PluginDetailContent: View { + let scrollsHorizontally: Bool + let content: Content + private let inset: CGFloat = 22 + + var body: some View { + GeometryReader { geometry in + ScrollView(scrollsHorizontally ? [.horizontal, .vertical] : .vertical) { + content + .frame(minWidth: max(0, geometry.size.width - 2 * inset), + maxWidth: scrollsHorizontally ? nil : .infinity, + minHeight: max(0, geometry.size.height - 2 * inset), alignment: .topLeading) + .padding(inset) + } + .litheScrollViewChrome() + } + } +} diff --git a/macos/Sources/Lithe/Views/App/PluginSettingsSection.swift b/macos/Sources/Lithe/Views/App/PluginSettingsSection.swift index dfd15b7edd..841cee788f 100644 --- a/macos/Sources/Lithe/Views/App/PluginSettingsSection.swift +++ b/macos/Sources/Lithe/Views/App/PluginSettingsSection.swift @@ -281,6 +281,7 @@ private struct PluginSettingRow: View { value: shownString, placeholder: placeholder, width: 220, + isFlexible: true, accessibilityLabel: declaration.title, savesWhileTyping: savesWhileTyping, accepts: { declaration.accepts(.string($0)) }, @@ -290,6 +291,7 @@ private struct PluginSettingRow: View { if let url = choosePath(directory) { write(.string(url.path)) } } .buttonStyle(LitheActionButtonStyle()) + .fixedSize() } } } @@ -451,6 +453,7 @@ private struct PluginTextSettingField: View { let value: String let placeholder: String? let width: CGFloat + var isFlexible = false let accessibilityLabel: String let savesWhileTyping: Bool let accepts: (String) -> Bool @@ -462,7 +465,7 @@ private struct PluginTextSettingField: View { HStack(spacing: 6) { TextField(placeholder ?? "", text: $text) .litheSettingsTextField() - .frame(width: width) + .frame(minWidth: isFlexible ? 0 : width, idealWidth: width, maxWidth: width) .focused($isFocused) .accessibilityLabel(Text(verbatim: accessibilityLabel)) .onSubmit(commit) diff --git a/macos/Sources/Lithe/Views/Components/LitheSettingsControls.swift b/macos/Sources/Lithe/Views/Components/LitheSettingsControls.swift index d944a987bc..df7a5c6cdf 100644 --- a/macos/Sources/Lithe/Views/Components/LitheSettingsControls.swift +++ b/macos/Sources/Lithe/Views/Components/LitheSettingsControls.swift @@ -127,7 +127,7 @@ private struct LitheSettingsCardLines: _VariadicView_UnaryViewRoot { } } -/// One setting: a fixed label column, the control after it, and optional +/// One setting: a bounded label column, the control after it, and optional /// status pills on the trailing edge (effective value, scope, when it takes /// effect). Matches the `.srow` row of the settings mockup. struct LitheSettingsRow: View { @@ -161,7 +161,7 @@ struct LitheSettingsRow: View { .fixedSize(horizontal: false, vertical: true) if let info { LitheInfoTip(info) } } - .frame(width: Self.labelWidth, alignment: .leading) + .frame(minWidth: 0, idealWidth: Self.labelWidth, maxWidth: Self.labelWidth, alignment: .leading) HStack(spacing: 6) { control } .frame(maxWidth: .infinity, alignment: .leading) HStack(spacing: 4) { badges } diff --git a/macos/Sources/LitheCoreContracts/Execution/BuildSystemContracts.swift b/macos/Sources/LitheCoreContracts/Execution/BuildSystemContracts.swift index fe90e005b7..84f5384aa5 100644 --- a/macos/Sources/LitheCoreContracts/Execution/BuildSystemContracts.swift +++ b/macos/Sources/LitheCoreContracts/Execution/BuildSystemContracts.swift @@ -427,16 +427,7 @@ package struct BuildModel: Equatable, Sendable, Decodable { /// The executables of one build system on this platform, in Core's order. package struct BuildInvocation: Equatable, Sendable { - package enum Executable: Equatable, Sendable { - /// A wrapper script inside the project. Always runs project code. - case projectFile(path: String, requires: [String]) - /// A path below the directory one environment variable names. - case environment(variable: String, path: String) - /// A bare command name the host's tool discovery resolves. - case command(String) - /// The executable of a declared runtime dependency. - case runtime(String) - } + package typealias Executable = ExecutableCandidate package struct RuntimeDependency: Equatable, Sendable { package let id: String @@ -464,6 +455,16 @@ package struct BuildInvocation: Equatable, Sendable { package let versionArguments: [String]? package let minimumVersion: String? + /// After resolution fails, a provider must not discard a configured tool + /// or runtime by doing its own lookup. Check this only on failure: an + /// earlier successful candidate still wins over a later setting. + package var hasExplicitExecutableSelection: Bool { + (executables + runtimeDependencies.flatMap(\.executables)).contains { + if case .setting(_, let path) = $0 { return path.map { !$0.isEmpty } ?? false } + return false + } + } + package init( executables: [Executable], environmentInputs: [String] = [], runtimeDependencies: [RuntimeDependency] = [], versionArguments: [String]? = nil, @@ -501,7 +502,8 @@ package struct ResolvedBuildTool: Equatable, Sendable { /// No candidate of a build system, or one of its runtimes, was usable. package struct BuildToolResolutionError: LocalizedError, Equatable, Sendable { package let message: String - package init(_ message: String) { self.message = message } + package let terminal: Bool + package init(_ message: String, terminal: Bool = false) { self.message = message; self.terminal = terminal } package var errorDescription: String? { message } } @@ -929,7 +931,9 @@ package protocol BuildSystemPlanning: Sendable { options: [String: ToolingJSONValue], executableURL: URL? ) throws -> BuildLanguageImport? - func invocationPlan(_ target: BuildSystemTarget, root: String) throws -> BuildInvocation + func invocationPlan( + _ target: BuildSystemTarget, root: String, settings: [String: ToolingJSONValue] + ) throws -> BuildInvocation func checkToolVersion(_ target: BuildSystemTarget, runtime: String?, output: String) throws -> BuildVersionCheck func resolveOptions( _ target: BuildSystemTarget, diff --git a/macos/Sources/LitheCoreContracts/Execution/ExecutableResolution.swift b/macos/Sources/LitheCoreContracts/Execution/ExecutableResolution.swift new file mode 100644 index 0000000000..28786863ac --- /dev/null +++ b/macos/Sources/LitheCoreContracts/Execution/ExecutableResolution.swift @@ -0,0 +1,68 @@ +import Foundation + +/// Core orders these before native discovery. Other plugin launch adapters can +/// reuse the sources without adopting Build's protocol or trust policy. +package enum ExecutableCandidate: Equatable, Sendable { + case projectFile(path: String, requires: [String]) + case environment(variable: String, path: String) + case command(String) + case runtime(String) + /// Nil is deliberately unconfigured. Core rejects invalid stored values. + case setting(id: String, path: String?) +} + +package struct ResolvedExecutableCandidate: Equatable, Sendable { + package let url: URL + package let executesProjectCode: Bool + package let terminal: Bool +} + +package enum ExecutableResolution: Equatable, Sendable { + case skip + case resolved([ResolvedExecutableCandidate]) + case terminalFailure(String) +} + +/// Pure native candidate lookup, supplied with capabilities by the platform. +/// No process runs here; each consumer keeps its own probing/trust constraints. +@MainActor +package struct ExecutableCandidateResolver { + package let fileExists: (URL) -> Bool + package let isExecutable: (URL) -> Bool + package let commands: (String) -> [URL] + package let runtimes: (String) -> URL? + + package init(fileExists: @escaping (URL) -> Bool, isExecutable: @escaping (URL) -> Bool, + commands: @escaping (String) -> [URL], runtimes: @escaping (String) -> URL?) { + self.fileExists = fileExists; self.isExecutable = isExecutable + self.commands = commands; self.runtimes = runtimes + } + + package func resolve(_ candidate: ExecutableCandidate, workspaceURL: URL, + environment: [String: String], environmentInputs: [String]) -> ExecutableResolution { + func found(_ urls: [URL], project: Bool = false, terminal: Bool = false) -> ExecutableResolution { + let selected = urls.filter(isExecutable).map { + ResolvedExecutableCandidate(url: $0, executesProjectCode: project, terminal: terminal) + } + return selected.isEmpty ? .skip : .resolved(selected) + } + switch candidate { + case .projectFile(let path, let requires): + guard requires.allSatisfy({ fileExists(workspaceURL.appendingPathComponent($0)) }) else { return .skip } + return found([workspaceURL.appendingPathComponent(path).standardizedFileURL], project: true) + case .environment(let variable, let path): + guard environmentInputs.contains(variable), let base = environment[variable], base.hasPrefix("/") else { return .skip } + return found([URL(fileURLWithPath: base).appendingPathComponent(path).standardizedFileURL]) + case .command(let command): return found(commands(command)) + case .runtime(let id): return found(runtimes(id).map { [$0] } ?? []) + case .setting(_, let path): + guard let path, !path.isEmpty else { return .skip } + guard path.hasPrefix("/"), path == path.trimmingCharacters(in: .whitespacesAndNewlines), + !path.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }), + isExecutable(URL(fileURLWithPath: path)) else { + return .terminalFailure(String(localized: "The configured build tool is not executable: \(path)")) + } + return found([URL(fileURLWithPath: path)], terminal: true) + } + } +} diff --git a/macos/Sources/LitheExecutionModule/Services/BuildSystemService.swift b/macos/Sources/LitheExecutionModule/Services/BuildSystemService.swift index 72bf44398c..04f6d9b90b 100644 --- a/macos/Sources/LitheExecutionModule/Services/BuildSystemService.swift +++ b/macos/Sources/LitheExecutionModule/Services/BuildSystemService.swift @@ -119,6 +119,7 @@ package final class BuildSystemService: ObservableObject { private let stateLocator: any BuildSystemStateLocating private let runner: any BuildCommandRunning private let providers: (any BuildProviderRequesting)? + private let settings: @MainActor (String) -> [String: ToolingJSONValue] private let waitForRedetection: @Sendable () async throws -> Void private let sources: @MainActor () -> [BuildSystemPluginSource] @@ -178,6 +179,7 @@ package final class BuildSystemService: ObservableObject { stateLocator: any BuildSystemStateLocating, runner: any BuildCommandRunning, providers: (any BuildProviderRequesting)? = nil, + settings: @escaping @MainActor (String) -> [String: ToolingJSONValue] = { _ in [:] }, waitForRedetection: @escaping @Sendable () async throws -> Void = { try await Task.sleep(for: .milliseconds(500)) }, @@ -189,6 +191,7 @@ package final class BuildSystemService: ObservableObject { self.stateLocator = stateLocator self.runner = runner self.providers = providers + self.settings = settings self.waitForRedetection = waitForRedetection self.sources = sources } @@ -331,7 +334,10 @@ package final class BuildSystemService: ObservableObject { var executables: [String: URL] = [:] for input in inputs { guard case .maven? = imports[input.entry.id], - let invocation = try? planner.invocationPlan(input.entry.target, root: input.entry.instance.root) + let invocation = try? planner.invocationPlan( + input.entry.target, root: input.entry.instance.root, + settings: self.settings(input.entry.source.pluginID) + ) else { continue } executables[input.entry.id] = self.tools.languageImportExecutable(invocation, workspaceURL: workspaceURL) } @@ -1192,7 +1198,9 @@ package final class BuildSystemService: ObservableObject { ) async throws -> BuildCommandRequest { let planner = planner let target = entry.target - let invocation = try planner.invocationPlan(target, root: entry.instance.root) + let invocation = try planner.invocationPlan( + target, root: entry.instance.root, settings: settings(entry.source.pluginID) + ) let tool = try await tools.resolve( invocation, displayName: entry.instance.displayName, @@ -1262,13 +1270,15 @@ package final class BuildSystemService: ObservableObject { } /// The executable the build system's invocation resolves to, as a run - /// would use it. A failure is not the provider's: it still loads and - /// looks the tool up itself, so the reason only goes to Build Output. + /// would use it. Automatic discovery may fall back to the provider's lookup; + /// an explicit selection must fail rather than use a different installation. private func providerTool(_ entry: BuildSystemEntry, workspaceURL: URL, owner: CommandOwner) async throws -> URL? { let planner = planner let target = entry.target + let invocation = try planner.invocationPlan( + target, root: entry.instance.root, settings: settings(entry.source.pluginID) + ) do { - let invocation = try planner.invocationPlan(target, root: entry.instance.root) guard !invocation.executables.isEmpty else { return nil } return try await tools.resolve( invocation, displayName: entry.instance.displayName, workspaceURL: workspaceURL @@ -1278,6 +1288,9 @@ package final class BuildSystemService: ObservableObject { } catch is CancellationError { throw CancellationError() } catch { + // Runtime resolution can fail before the selected tool is visited. + // Delegating then would lose the invocation's explicit selection. + if invocation.hasExplicitExecutableSelection || (error as? BuildToolResolutionError)?.terminal == true { throw error } if canAppend(owner) { appendOutput( "\n" + String(localized: "The provider looks up the build tool itself: \(error.localizedDescription)") + "\n", diff --git a/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift b/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift index c3c8a9e3ae..2366cb11ad 100644 --- a/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift +++ b/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift @@ -491,7 +491,7 @@ public struct PluginManifest: Equatable, Codable, Sendable { /// not running it, so an entry is added only with its host support. public static let hostCapabilities: [String: [Int]] = [ "buildProviders": [1], - "buildSystems": [1], + "buildSystems": [1, 2], "executableResolution": [1], "executionProviders": [1], "languageServers": [1, 2], "languages": [1], diff --git a/macos/Tests/LitheExecutionModuleTests/BuildSystemServiceTests.swift b/macos/Tests/LitheExecutionModuleTests/BuildSystemServiceTests.swift index 68b8c2d39a..1c3e076ede 100644 --- a/macos/Tests/LitheExecutionModuleTests/BuildSystemServiceTests.swift +++ b/macos/Tests/LitheExecutionModuleTests/BuildSystemServiceTests.swift @@ -1482,6 +1482,27 @@ struct BuildSystemServiceTests { #expect(harness.service.output.contains("Maven is not installed.")) } + @Test + func providerDoesNotFallBackWhenAnExplicitExecutableFails() async throws { + let planner = FakeBuildPlanner() + planner.detection = BuildDetection(instances: [try Self.instance(modelSource: "provider")]) + planner.providerRequest = Self.providerRequest(method: "lithe/build/model") + let providers = FakeBuildProviders() + let harness = Harness(planner: planner, providers: providers, settings: { pluginID in + #expect(pluginID == "dev.example") + return ["toolPath": .string("/custom tools/build")] + }) + harness.tools.failure = BuildToolResolutionError("Selected executable is unavailable.", terminal: true) + defer { harness.service.stop() } + harness.service.reset(workspaceURL: Self.workspace) + #expect(await harness.waitForEntries { $0.first?.modelState == .notLoaded }) + harness.service.loadModel(Self.instanceID) + #expect(await harness.waitForEntries { if case .failed = $0.first?.modelState { return true }; return false }) + #expect(providers.sent.isEmpty) + guard case .failed(let message)? = harness.service.entries.first?.modelState else { return } + #expect(message.contains("Selected executable is unavailable.")) + } + /// Command sources resolve their tool when the command runs; planning /// never resolves it a second time for them. @Test @@ -1726,6 +1747,7 @@ private final class Harness { runner: any BuildCommandRunning = FakeBuildRunner(), sources: [BuildSystemPluginSource] = [fixtureSource()], providers: (any BuildProviderRequesting)? = nil, + settings: @escaping @MainActor (String) -> [String: ToolingJSONValue] = { _ in [:] }, waitForRedetection: @escaping @Sendable () async throws -> Void = {} ) { let enabledSources = SourceList(sources) @@ -1737,6 +1759,7 @@ private final class Harness { stateLocator: stateLocator, runner: runner, providers: providers, + settings: settings, waitForRedetection: waitForRedetection, sources: { enabledSources.value } ) @@ -2032,8 +2055,9 @@ private final class FakeBuildPlanner: BuildSystemPlanning, @unchecked Sendable { )) } - func invocationPlan(_ target: BuildSystemTarget, root: String) throws -> BuildInvocation { - BuildInvocation(executables: [.command("fake")]) + func invocationPlan(_ target: BuildSystemTarget, root: String, settings: [String: ToolingJSONValue]) throws -> BuildInvocation { + let configured: String? = if case .string(let path)? = settings["toolPath"] { path } else { nil } + return BuildInvocation(executables: [.setting(id: "toolPath", path: configured), .command("fake")]) } func checkToolVersion(_ target: BuildSystemTarget, runtime: String?, output: String) throws -> BuildVersionCheck { diff --git a/macos/Tests/LitheTests/BuildToolProviderResolutionTests.swift b/macos/Tests/LitheTests/BuildToolProviderResolutionTests.swift new file mode 100644 index 0000000000..94e7454301 --- /dev/null +++ b/macos/Tests/LitheTests/BuildToolProviderResolutionTests.swift @@ -0,0 +1,174 @@ +import Combine +import Foundation +import LitheCoreContracts +import LitheExecutionModule +import Testing +@testable import Lithe + +/// Exercise the real Core plan, native resolver, and service together: an +/// earlier runtime failure must not let a provider discard a later selection. +@MainActor +struct BuildToolProviderResolutionTests { + @Test(arguments: ["tool", "runtime", "empty"], ["missing", "old"]) + func runtimeFailurePreservesExplicitSelections(selection: String, failure: String) async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try Data().write(to: root.appendingPathComponent("build.fixture")) + if failure == "old" { + let executable = root.appendingPathComponent("runtime") + try Data("#!/bin/sh\nprintf 'runtime 1.0\\n'\n".utf8).write(to: executable) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path) + } + let source = try Self.source(root: root) + let settings: [String: ToolingJSONValue] = [ + "toolPath": .string(selection == "tool" ? root.appendingPathComponent("missing tool").path : ""), + "runtimePath": .string(selection == "runtime" ? root.appendingPathComponent("missing runtime").path : "") + ] + let planner = RustBuildSystemOperations(core: RustCoreBridge()) + let runtime = ProjectRuntimeService(runtimeLocator: ProviderRuntimeLocator(root: root), store: ProviderEmptyStore()) + let resolver = MacBuildToolResolver(runtime: runtime, processRunner: MacProcessRunner(), fileStorage: MacFileStorage()) + let target = BuildSystemTarget(manifest: source.manifest, buildSystemID: "fixture") + let invocation = try planner.invocationPlan(target, root: ".", settings: settings) + // Verify the exact review scenario: failure happens in the first, + // automatic runtime, before any explicit candidate is inspected. + do { + _ = try await resolver.resolve(invocation, displayName: "Fixture", workspaceURL: root) { id, output in + try planner.checkToolVersion(target, runtime: id, output: output) + } + Issue.record("The prerequisite runtime must fail") + } catch let error as BuildToolResolutionError { + #expect(!error.terminal) + #expect(error.message.contains("Prerequisite runtime")) + } + let providers = ProviderRequestRecorder() + let service = BuildSystemService( + planner: planner, tools: resolver, optionsStore: MacBuildOptionsStore(store: ProviderEmptyStore()), + stateLocator: ProviderStateLocator(root: root), runner: ProviderUnexpectedCommandRunner(), + providers: providers, settings: { _ in settings }, sources: { [source] } + ) + defer { service.stop(); runtime.closeProject() } + service.reset(workspaceURL: root) + try #require(await Self.wait(service) { $0.first?.modelState == .notLoaded }) + let id = try #require(service.entries.first?.id) + service.loadModel(id) + try #require(await Self.wait(service) { + if selection == "empty" { return $0.first?.modelState == .loaded } + if case .failed = $0.first?.modelState { return true } + return false + }) + if selection == "empty" { + #expect(providers.requests.count == 1) + guard case .object(let params)? = providers.requests.first?.params else { + Issue.record("A provider request must have object params") + return + } + #expect(params["tool"] == nil) + } else { + #expect(providers.requests.isEmpty) + guard case .failed(let message)? = service.entries.first?.modelState else { return } + #expect(message.contains("Prerequisite runtime")) + } + service.loadDependencies(id, project: nil) + try #require(await Self.wait(service) { + if selection == "empty", case .loaded = $0.first?.dependencies["."] { return true } + if selection != "empty", case .failed = $0.first?.dependencies["."] { return true } + return false + }) + if selection == "empty" { + #expect(providers.requests.map(\.method) == ["lithe/build/model", "lithe/build/dependencies"]) + guard case .object(let params)? = providers.requests.last?.params else { + Issue.record("A dependency request must have object params") + return + } + #expect(params["tool"] == nil) + } else { + #expect(providers.requests.isEmpty) + guard case .failed(let message)? = service.entries.first?.dependencies["."] else { return } + #expect(message.contains("Prerequisite runtime")) + } + } + + private static func wait(_ service: BuildSystemService, until predicate: @escaping ([BuildSystemEntry]) -> Bool) async -> Bool { + let finished = TestGate() + let observation = service.$entries.sink { if predicate($0) { finished.open() } } + defer { observation.cancel() } + return await finished.waitUntilOpen(timeout: .seconds(5)) + } + + private static func source(root: URL) throws -> BuildSystemPluginSource { + let fixture = DeclarativePluginTestSupport.repositoryRoot.appendingPathComponent("shared/contracts/plugins/v1/fixtures/build-tool-path.plugin.json") + var manifest = try #require(JSONSerialization.jsonObject(with: Data(contentsOf: fixture)) as? [String: Any]) + manifest["requires"] = ["buildSystems": 1, "buildProviders": 1, "executableResolution": 1, "settings": 2, "serverProcess": 1] + manifest["permissions"] = ["process.build", "languageServer.launch"] + var settings = try #require(manifest["settings"] as? [[String: Any]]) + settings.append(["id": "runtimePath", "type": "file", "title": "Runtime", "default": "", "scope": "global", "effect": "reload"]) + manifest["settings"] = settings + var systems = try #require(manifest["buildSystems"] as? [[String: Any]]) + systems[0]["invocation"] = [ + "executables": [["kind": "setting", "settingID": "toolPath"]], + "environmentInputs": ["FIXTURE_RUNTIME"], + "runtimeDependencies": [ + ["id": "prerequisite", "displayName": "Prerequisite runtime", "minimumVersion": "2", + "executables": [["kind": "environment", "variable": "FIXTURE_RUNTIME", "path": "runtime"]], + "version": ["arguments": ["--version"], "pattern": "runtime ([0-9.]+)"]], + ["id": "selected-runtime", "executables": [["kind": "setting", "settingID": "runtimePath"]]] + ] + ] + systems[0]["model"] = ["source": "provider"] + systems[0]["dependencies"] = ["source": "provider", "scopes": ["workspace"]] + systems[0]["provider"] = ["launch": ["kind": "packaged", "path": "provider"], "executesProjectCode": false, "mayUseNetwork": false] + manifest["buildSystems"] = systems + let descriptor = try DeclarativePluginTestSupport.descriptor(manifest) + return BuildSystemPluginSource(pluginID: descriptor.id.rawValue, manifest: descriptor.normalizedManifest, + packageURL: root, buildSystems: descriptor.buildSystems) + } +} + +@MainActor +private final class ProviderRequestRecorder: BuildProviderRequesting { + var requests: [BuildProviderRequest] = [] + func send(_ request: BuildProviderRequest, to target: BuildProviderTarget, timeout: Duration) async throws -> ToolingJSONValue { + requests.append(request) + if request.method == "lithe/build/dependencies" { + return .object(["format": .string("litheDependencies"), "version": .integer(1), "dependencies": .array([])]) + } + return .object(["format": .string("litheBuildModel"), "version": .integer(1), + "projects": .array([.object(["path": .string("."), "name": .string("fixture")])])]) + } + func retainProviders(_ keep: (BuildProviderTarget) -> Bool) {} +} + +private actor ProviderUnexpectedCommandRunner: BuildCommandRunning { + func run(_ request: BuildCommandRequest, onOutput: @escaping @Sendable (String) -> Void) async throws -> BuildCommandResult { + Issue.record("Provider loading must not run a build command") + return BuildCommandResult(termination: .exited(1)) + } +} + +private struct ProviderStateLocator: BuildSystemStateLocating { + let root: URL + func workspaceStateDirectory(pluginID: String, buildSystemID: String, workspaceURL: URL) throws -> URL { root } + func allocateOutputFile(in workspaceStateDirectory: URL) throws -> URL { root.appendingPathComponent(UUID().uuidString) } + func removeOutputFile(_ url: URL) {} +} + +private struct ProviderRuntimeLocator: RuntimeLocator { + let root: URL + func environment() -> [String: String] { ["FIXTURE_RUNTIME": root.path] } + func discover() -> RuntimeDiscoveryResult { .init(javaRuntimes: [], mavenRuntimes: []) } + func validJavaHome(path: String) -> URL? { nil } + func javaRuntime(at homeURL: URL) -> JavaRuntimeCandidate? { nil } + func isExecutable(at url: URL) -> Bool { false } + func systemMavenExecutable() -> URL? { nil } + func mavenExecutable(forHomePath path: String) -> URL? { nil } + func mavenRuntime(at executableURL: URL) -> MavenRuntimeCandidate? { nil } +} + +private struct ProviderEmptyStore: KeyValueStore { + func data(forKey key: String) -> Data? { nil } + func object(forKey key: String) -> Any? { nil } + func string(forKey key: String) -> String? { nil } + func stringArray(forKey key: String) -> [String]? { nil } + func set(_ value: Any?, forKey key: String) {} +} diff --git a/macos/Tests/LitheTests/MacBuildToolResolverTests.swift b/macos/Tests/LitheTests/MacBuildToolResolverTests.swift index 7d95050082..4f425f3ac0 100644 --- a/macos/Tests/LitheTests/MacBuildToolResolverTests.swift +++ b/macos/Tests/LitheTests/MacBuildToolResolverTests.swift @@ -6,6 +6,149 @@ import Testing @Suite("Build tool resolver version output") @MainActor struct MacBuildToolResolverTests { + @Test + func emptySettingSkipsToWrapperAndPassiveSelectionNeverProbes() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let marker = root.appendingPathComponent("unexpected-probe") + let wrapper = root.appendingPathComponent("wrapper") + try Data("#!/bin/sh\ntouch '\(marker.path)'\n".utf8).write(to: wrapper) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: wrapper.path) + let runtime = ProjectRuntimeService(runtimeLocator: BuildProbeRuntimeLocator(home: root), store: BuildProbeEmptyStore()) + let resolver = MacBuildToolResolver(runtime: runtime, processRunner: MacProcessRunner(), fileStorage: MacFileStorage()) + let invocation = BuildInvocation(executables: [.setting(id: "toolPath", path: nil), .projectFile(path: "wrapper", requires: [])], + versionArguments: ["--version"], minimumVersion: "2") + #expect(resolver.languageImportExecutable(invocation, workspaceURL: root) == wrapper) + let result = try await resolver.resolve(invocation, displayName: "Fixture", workspaceURL: root) { _, _ in + Issue.record("A wrapper must not be probed") + return BuildVersionCheck(satisfied: false, version: nil) + } + #expect(result.executableURL == wrapper) + #expect(result.executesProjectCode) + #expect(!FileManager.default.fileExists(atPath: marker.path)) + let invalid = BuildInvocation(executables: [.setting(id: "toolPath", path: root.appendingPathComponent("missing").path), .projectFile(path: "wrapper", requires: [])]) + #expect(resolver.languageImportExecutable(invalid, workspaceURL: root) == nil) + } + + @Test + func sharedResolverUsesInjectedEnvironmentCommandAndRuntimeDiscovery() { + let custom = URL(fileURLWithPath: "/tools/build") + let resolver = ExecutableCandidateResolver(fileExists: { _ in true }, isExecutable: { $0 == custom }, + commands: { $0 == "build" ? [custom] : [] }, runtimes: { $0 == "build" ? custom : nil }) + let workspace = URL(fileURLWithPath: "/workspace") + for candidate in [ExecutableCandidate.command("build"), .runtime("build"), .environment(variable: "BUILD_HOME", path: "build")] { + guard case .resolved(let result) = resolver.resolve(candidate, workspaceURL: workspace, + environment: ["BUILD_HOME": "/tools"], environmentInputs: ["BUILD_HOME"]) else { + Issue.record("The injected source must resolve") + continue + } + #expect(result.first?.url == custom) + } + #expect(resolver.resolve(.environment(variable: "BUILD_HOME", path: "build"), workspaceURL: workspace, + environment: ["BUILD_HOME": "/tools"], environmentInputs: []) == .skip) + } + @Test + func configuredPathWithSpacesWinsOverDiscoveryAndSuppliesItsDirectory() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let executable = root.appendingPathComponent("custom tools/build-tool") + try FileManager.default.createDirectory(at: executable.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data("#!/bin/sh\nprintf 'fixture 2.0\\n'\n".utf8).write(to: executable) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path) + let runtime = ProjectRuntimeService(runtimeLocator: BuildProbeRuntimeLocator(home: root), store: BuildProbeEmptyStore()) + let resolver = MacBuildToolResolver(runtime: runtime, processRunner: MacProcessRunner(), fileStorage: MacFileStorage()) + let invocation = BuildInvocation( + executables: [.setting(id: "toolPath", path: executable.path), .command("unavailable")], versionArguments: ["--version"], + minimumVersion: "2" + ) + let resolved = try await resolver.resolve(invocation, displayName: "Fixture", workspaceURL: root) { _, output in + BuildVersionCheck(satisfied: output.contains("fixture 2.0"), version: "2.0") + } + #expect(resolved.executableURL == executable) + #expect(resolved.environment["PATH"]?.hasPrefix(executable.deletingLastPathComponent().path + ":") == true) + #expect(resolver.languageImportExecutable(invocation, workspaceURL: root) == executable) + } + + @Test(arguments: ["missing", "non-executable", "old", "directory"]) + func unusableConfiguredPathNeverFallsBackToAvailableWrapper(_ failure: String) async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let configured = root.appendingPathComponent("configured") + if failure == "directory" { + try FileManager.default.createDirectory(at: configured, withIntermediateDirectories: true) + } else if failure != "missing" { + try Data("#!/bin/sh\nprintf 'fixture 1.0\\n'\n".utf8).write(to: configured) + try FileManager.default.setAttributes([.posixPermissions: failure == "old" ? 0o755 : 0o644], ofItemAtPath: configured.path) + } + let fallback = root.appendingPathComponent("wrapper") + try Data("#!/bin/sh\nexit 0\n".utf8).write(to: fallback) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: fallback.path) + let runtime = ProjectRuntimeService(runtimeLocator: BuildProbeRuntimeLocator(home: root), store: BuildProbeEmptyStore()) + let resolver = MacBuildToolResolver(runtime: runtime, processRunner: MacProcessRunner(), fileStorage: MacFileStorage()) + let invocation = BuildInvocation( + executables: [.setting(id: "toolPath", path: configured.path), .projectFile(path: "wrapper", requires: [])], versionArguments: ["--version"], + minimumVersion: "2" + ) + await #expect(throws: BuildToolResolutionError.self) { + try await resolver.resolve(invocation, displayName: "Fixture", workspaceURL: root) { _, _ in + BuildVersionCheck(satisfied: false, version: "1.0") + } + } + } + + @Test + func customRuntimeUsesTheSharedSettingCandidateAndDoesNotFallBack() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let runtime = ProjectRuntimeService(runtimeLocator: BuildProbeRuntimeLocator(home: root), store: BuildProbeEmptyStore()) + let resolver = MacBuildToolResolver(runtime: runtime, processRunner: MacProcessRunner(), fileStorage: MacFileStorage()) + let invocation = BuildInvocation(executables: [.runtime("custom")], runtimeDependencies: [ + .init(id: "custom", hostManaged: false, executables: [.setting(id: "runtimePath", path: root.appendingPathComponent("missing").path), .command("sh")]) + ]) + do { + _ = try await resolver.resolve(invocation, displayName: "Fixture", workspaceURL: root) { _, _ in + BuildVersionCheck(satisfied: true, version: nil) + } + Issue.record("An explicit runtime failure must terminate discovery") + } catch let error as BuildToolResolutionError { #expect(error.terminal) } + } + + @Test + func customRuntimeProbeAndExecutionUseTheSameSiblingHelper() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let executable = root.appendingPathComponent("custom runtime/runtime") + let helper = executable.deletingLastPathComponent().appendingPathComponent("fixture-helper") + try FileManager.default.createDirectory(at: executable.deletingLastPathComponent(), withIntermediateDirectories: true) + for (url, script) in [(executable, "#!/bin/sh\nexec fixture-helper\n"), + (helper, "#!/bin/sh\nprintf 'fixture 2.0\\n'\n")] { + try Data(script.utf8).write(to: url) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) + } + // The injected environment has no PATH entry for the helper. Only the + // candidate-specific environment can make this version probe succeed. + let runtime = ProjectRuntimeService(runtimeLocator: BuildProbeRuntimeLocator(home: root), store: BuildProbeEmptyStore()) + let runner = MacProcessRunner() + let resolver = MacBuildToolResolver(runtime: runtime, processRunner: runner, fileStorage: MacFileStorage()) + let invocation = BuildInvocation(executables: [.runtime("custom")], runtimeDependencies: [ + .init(id: "custom", hostManaged: false, minimumVersion: "2", + executables: [.setting(id: "runtimePath", path: executable.path)], versionArguments: ["--version"]) + ]) + let resolved = try await resolver.resolve(invocation, displayName: "Fixture", workspaceURL: root) { id, output in + #expect(id == "custom") + #expect(output == "fixture 2.0\n") + return BuildVersionCheck(satisfied: output == "fixture 2.0\n", version: "2.0") + } + #expect(resolved.environment["PATH"] == executable.deletingLastPathComponent().path) + let request = ProcessRequest(executablePath: resolved.executableURL.path, arguments: [], + environment: resolved.environment, timeoutMilliseconds: 2_000) + let output = await Task.detached { runner.run(request).output }.value + #expect(output == "fixture 2.0\n") + } + @Test func languageImportDiscoversWrapperWithoutExecutingItOrRuntimeProbes() throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) diff --git a/macos/Tests/LitheTests/PluginDetailLayoutTests.swift b/macos/Tests/LitheTests/PluginDetailLayoutTests.swift index ecb3b614da..245d8d297e 100644 --- a/macos/Tests/LitheTests/PluginDetailLayoutTests.swift +++ b/macos/Tests/LitheTests/PluginDetailLayoutTests.swift @@ -1,13 +1,100 @@ import AppKit -import SwiftUI -import Testing import LitheCoreContracts import LitheModuleAPI +import SwiftUI +import Testing @testable import Lithe +/// A real plugin form must respect the same edge as the split handle when resized. @MainActor -@Suite("Plugin detail action layout", .serialized) +@Suite(.serialized) struct PluginDetailLayoutTests { + @Test(arguments: [false, true]) + func settingsStayBesideTheDividerWhenTheWindowShrinks(dark: Bool) throws { + let descriptor = try DeclarativePluginTestSupport.officialDescriptor("JavaSupport") + var plugin = PluginManagementSnapshot( + manifest: descriptor.pluginManifest, origin: .bundled, installationStatus: .installed, + isEnabled: true, isRequired: false, isRunning: false, isQuarantined: false, + isSuppressedBySafeMode: false, requiresRestart: false, canRollback: false, + statusMessage: "Enabled", marketplace: nil + ) + plugin.settings = descriptor.settings.map { .resolve($0, global: nil, project: nil) } + let markers = PluginDetailLayoutMarkers() + let host = NSHostingView(rootView: LitheSplitPaneView( + axis: .horizontal, placement: .leading, defaultSize: 420, + minimum: PluginManagementView.listMinimumWidth, maximum: PluginManagementView.listMaximumWidth, + flexibleMinimum: PluginManagementView.detailMinimumWidth, + sized: { + Text("Installed plugins").frame(maxWidth: .infinity, maxHeight: .infinity) + .background(PluginDetailLayoutMarker { markers.sidebar = $0 }) + }, + flexible: { + PluginDetailView( + plugin: plugin, summary: "", isEnabled: true, hasPendingChange: false, + isBusy: false, isApplyingChanges: false, updateVersion: nil, sourceName: nil, error: nil, + actions: PluginDetailActions( + toggleEnabled: {}, update: nil, reinstall: nil, uninstall: {}, restore: nil, rollback: nil, + settings: PluginSettingsHandlers(setValue: { _, _, _ in nil }, + restartLanguageServers: {}, runAction: { _ in }, choosePath: { _ in nil }), + hasProject: true, openSettings: { _ in } + ), tab: .constant(.settings) + ) { + Image(systemName: "cup.and.saucer").frame(width: 48, height: 48) + } + .background(PluginDetailLayoutMarker { markers.detail = $0 }) + } + ).environment(\.colorScheme, dark ? .dark : .light)) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 1100, height: 650), + styleMask: [.borderless], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.appearance = NSAppearance(named: dark ? .darkAqua : .aqua) + window.contentView = host + defer { window.close() } + host.layoutSubtreeIfNeeded() + let originalField = try #require(descendants(host).compactMap { $0 as? NSTextField }.first { $0.isEditable }) + var initialTopInset: CGFloat? + for (width, height) in [(1100.0, 650.0), (900, 500), (700, 700), (1000, 650)] { + window.setContentSize(CGSize(width: width, height: height)) + host.layoutSubtreeIfNeeded() + let sidebar = try #require(markers.sidebar) + let detail = try #require(markers.detail) + let left = sidebar.convert(sidebar.bounds, to: host) + let right = detail.convert(detail.bounds, to: host) + let divider = left.maxX + SplitHandleView.thickness + #expect(abs(right.minX - divider) < 1, "\(width): detail \(right) must start after sidebar \(left)") + #expect(right.maxX <= host.bounds.maxX + 1) + for field in descendants(host).compactMap({ $0 as? NSTextField }).filter(\.isEditable) { + let frame = field.convert(field.bounds, to: host) + #expect(frame.minX >= divider) + #expect(field === originalField) + } + let scroller = try #require(descendants(host).compactMap { $0 as? NSScrollView }.last { + $0.hasHorizontalScroller && $0.hasVerticalScroller + }) + let document = try #require(scroller.documentView) + let fieldFrame = originalField.convert(originalField.bounds, to: host) + let viewport = scroller.convert(scroller.bounds, to: host) + let topInset = host.isFlipped ? fieldFrame.minY - viewport.minY : viewport.maxY - fieldFrame.maxY + if let initialTopInset { + #expect(abs(topInset - initialTopInset) < 1, "A short form stays at the top when the pane height changes") + } else { + initialTopInset = topInset + } + scroller.contentView.scroll(to: CGPoint(x: document.bounds.maxX - scroller.contentView.bounds.width, y: 0)) + scroller.reflectScrolledClipView(scroller.contentView) + #expect(abs(scroller.contentView.bounds.maxX - document.bounds.maxX) < 1, + "The trailing settings remain reachable rather than being clipped") + if let directory = ProcessInfo.processInfo.environment["LITHE_PLUGIN_DETAIL_LAYOUT_CAPTURES"] { + let bitmap = try #require(host.bitmapImageRepForCachingDisplay(in: host.bounds)) + host.cacheDisplay(in: host.bounds, to: bitmap) + let data = try #require(bitmap.representation(using: .png, properties: [:])) + let output = URL(fileURLWithPath: directory, isDirectory: true) + try FileManager.default.createDirectory(at: output, withIntermediateDirectories: true) + try data.write(to: output.appendingPathComponent("detail-\(Int(width))-\(dark ? "dark" : "light").png")) + } + } + } + @Test(arguments: ["en", "zh-Hans"], [ColorScheme.dark, .light]) func longVersionAndAllActionsFitTheMinimumSettingsWindow(language: String, scheme: ColorScheme) async throws { MacBundledFontRegistry.registerFonts() @@ -74,4 +161,24 @@ struct PluginDetailLayoutTests { } } } + + private func descendants(_ view: NSView) -> [NSView] { + view.subviews.flatMap { [$0] + descendants($0) } + } +} + +@MainActor +private final class PluginDetailLayoutMarkers { + weak var sidebar: NSView? + weak var detail: NSView? +} + +private struct PluginDetailLayoutMarker: NSViewRepresentable { + let record: (NSView) -> Void + func makeNSView(context: Context) -> NSView { + let view = NSView() + record(view) + return view + } + func updateNSView(_ view: NSView, context: Context) {} } diff --git a/macos/Tests/LitheTests/PluginSettingsLayoutTests.swift b/macos/Tests/LitheTests/PluginSettingsLayoutTests.swift new file mode 100644 index 0000000000..fc73e565a6 --- /dev/null +++ b/macos/Tests/LitheTests/PluginSettingsLayoutTests.swift @@ -0,0 +1,101 @@ +import AppKit +import LitheCoreContracts +import LitheModuleAPI +import SwiftUI +import Testing +@testable import Lithe + +/// A file picker remains a usable native button when plugin details are narrow. +@Suite("Plugin settings path layout", .serialized) +@MainActor +struct PluginSettingsLayoutTests { + @Test(arguments: [480.0, 640.0, 900.0], [false, true]) + func pathPickerFitsAndStillWritesTheSelectedPath(width: Double, dark: Bool) async throws { + var pickedDirectory: Bool? + var saved: ToolingJSONValue? + let selected = URL(fileURLWithPath: "/fixture/custom tools/build-tool") + let declaration = DeclarativePluginSetting( + id: "toolPath", title: "Build executable", summary: "Select a file or leave empty for discovery.", + kind: .path(directory: false, placeholder: "Automatic"), defaultValue: .string(""), + scope: .global, effect: .reload + ) + let view = PluginSettingsSection( + settings: [.resolve(declaration, global: nil, project: nil)], hasProject: true, + handlers: PluginSettingsHandlers( + setValue: { id, value, layer in + #expect(id == "toolPath" && layer == .global) + saved = value + return nil + }, + restartLanguageServers: {}, runAction: { _ in }, + choosePath: { directory in pickedDirectory = directory; return selected } + ) + ) + .padding(14) + .frame(width: width, height: 180, alignment: .topLeading) + .environment(\.colorScheme, dark ? .dark : .light) + let host = NSHostingView(rootView: view) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: width, height: 180), + styleMask: [.borderless], backing: .buffered, defer: false + ) + window.isReleasedWhenClosed = false + window.appearance = NSAppearance(named: dark ? .darkAqua : .aqua) + window.contentView = host + defer { window.close() } + let application = NSApplication.shared + let attribute = NSAccessibility.Attribute(rawValue: "AXEnhancedUserInterface") + let previous = (application.accessibilityAttributeValue(attribute) as? Bool) ?? false + application.accessibilitySetValue(true, forAttribute: attribute) + defer { application.accessibilitySetValue(previous, forAttribute: attribute) } + host.layoutSubtreeIfNeeded() + let button = try #require(await chooseButton(in: host)) + let field = try #require(descendants(host).compactMap { $0 as? NSTextField }.first { $0.isEditable }) + let screenFrame = try #require(button.accessibilityFrame?()) + let buttonFrame = host.convert(window.convertFromScreen(screenFrame), from: nil) + let fieldFrame = field.convert(field.bounds, to: host) + let titleWidth = (String(localized: "Choose…") as NSString).size(withAttributes: [.font: NSFont.systemFont(ofSize: NSFont.smallSystemFontSize)]).width + #expect(buttonFrame.width >= titleWidth) + #expect(buttonFrame.minX >= fieldFrame.maxX) + #expect(host.bounds.contains(buttonFrame)) + #expect(host.bounds.contains(fieldFrame)) + #expect(fieldFrame.width > 0) + #expect(button.accessibilityPerformPress?() == true) + #expect(pickedDirectory == false) + #expect(saved == .string(selected.path)) + + if let directory = ProcessInfo.processInfo.environment["LITHE_PLUGIN_SETTINGS_LAYOUT_CAPTURES"] { + let bitmap = try #require(host.bitmapImageRepForCachingDisplay(in: host.bounds)) + host.cacheDisplay(in: host.bounds, to: bitmap) + let data = try #require(bitmap.representation(using: .png, properties: [:])) + let output = URL(fileURLWithPath: directory, isDirectory: true) + try FileManager.default.createDirectory(at: output, withIntermediateDirectories: true) + try data.write(to: output.appendingPathComponent("path-\(Int(width))-\(dark ? "dark" : "light").png")) + } + } + + private func descendants(_ view: NSView) -> [NSView] { + view.subviews.flatMap { [$0] + descendants($0) } + } + + private func chooseButton(in host: NSView) async -> AnyObject? { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(2)) + repeat { + host.layoutSubtreeIfNeeded() + if let button = accessibleElements(in: host).first(where: { + $0.accessibilityRole?() == .button + && ($0.accessibilityLabel?() ?? $0.accessibilityTitle?()) == String(localized: "Choose…") + }) { return button } + await Task.yield() + } while clock.now < deadline + return nil + } + + private func accessibleElements(in element: Any) -> [AnyObject] { + let accessible = element as AnyObject + let children = accessible.accessibilityChildren?() ?? [] + let descendants: [Any] = children.isEmpty ? ((element as? NSView)?.subviews ?? []) : children + return [accessible] + descendants.flatMap { accessibleElements(in: $0) } + } +} diff --git a/macos/Tests/LitheTests/RustBuildSystemOperationsTests.swift b/macos/Tests/LitheTests/RustBuildSystemOperationsTests.swift index 85cd849038..fc629d2ee5 100644 --- a/macos/Tests/LitheTests/RustBuildSystemOperationsTests.swift +++ b/macos/Tests/LitheTests/RustBuildSystemOperationsTests.swift @@ -68,6 +68,14 @@ struct RustBuildSystemOperationsTests { #expect(resolution.dropped == ["features"]) } + @Test + func invocationUsesTheGlobalExecutableSetting() throws { + let target = try Self.target("build-tool-path", "fixture") + let custom = try operations.invocationPlan(target, root: ".", settings: ["toolPath": .string("/opt/custom tools/build")]) + #expect(custom.executables.first == .setting(id: "toolPath", path: "/opt/custom tools/build")) + #expect(try operations.invocationPlan(target, root: ".").executables.first == .setting(id: "toolPath", path: nil)) + } + @Test func invocationAndVersionProbe() throws { let target = try Self.target("cargo-build", "cargo") diff --git a/macos/Tests/LitheTests/WindowFullScreenCapabilityGuardTests.swift b/macos/Tests/LitheTests/WindowFullScreenCapabilityGuardTests.swift index 74c5033a70..fa8bfb55a3 100644 --- a/macos/Tests/LitheTests/WindowFullScreenCapabilityGuardTests.swift +++ b/macos/Tests/LitheTests/WindowFullScreenCapabilityGuardTests.swift @@ -115,6 +115,7 @@ struct WindowLayoutFullScreenTransitionTests { func requestCloseActiveWorkbenchItem() -> Bool { false } func requestCloseActiveSession() -> Bool { true } func resetForProjectWindowClose() async {} + func confirmRunServicesBeforeClose() async -> Bool { true } func noteWindowBecameKey() {} } diff --git a/rust/lithe-core/src/build/commands.rs b/rust/lithe-core/src/build/commands.rs index ca59ff02eb..e8d7181236 100644 --- a/rust/lithe-core/src/build/commands.rs +++ b/rust/lithe-core/src/build/commands.rs @@ -17,7 +17,8 @@ use super::outputs::{ }; use super::plans::{self, OutputPlanRequest, TaskPlanRequest, TestPlanRequest}; use crate::plugins::declarative::{ - decode_payload, outcome, validate_manifest_value, PluginRejection, Validation, + decode_payload, outcome, validate_manifest_value, DeclarativePluginManifest, PluginRejection, + Validation, }; use crate::protocol::CoreError; use serde::de::DeserializeOwned; @@ -51,6 +52,17 @@ fn with_build_system( command: &str, key: &'static str, run: impl FnOnce(&BuildSystemDeclaration, &T) -> Validation, +) -> Result { + with_manifest(payload, command, key, |build_system, request, _| { + run(build_system, request) + }) +} + +fn with_manifest( + payload: Value, + command: &str, + key: &'static str, + run: impl FnOnce(&BuildSystemDeclaration, &T, &DeclarativePluginManifest) -> Validation, ) -> Result { let payload: BuildPayload = decode_payload(payload, command)?; Ok(outcome(validate_manifest_value(payload.manifest).and_then( @@ -68,7 +80,7 @@ fn with_build_system( ), ) })?; - run(build_system, &payload.request).map(|value| (key, value)) + run(build_system, &payload.request, &manifest).map(|value| (key, value)) }, ))) } @@ -84,12 +96,12 @@ pub(crate) fn build_detect_command(payload: Value) -> Result { /// `build.invocationPlan`: executable candidates and version probes. pub(crate) fn build_invocation_plan_command(payload: Value) -> Result { - with_build_system( + with_manifest( payload, "build.invocationPlan", "invocation", - |build_system, request: &InvocationRequest| { - invocation::invocation_plan(build_system, request) + |build_system, request: &InvocationRequest, manifest| { + invocation::invocation_plan(build_system, request, manifest) }, ) } diff --git a/rust/lithe-core/src/build/invocation.rs b/rust/lithe-core/src/build/invocation.rs index 19a9f422c7..4fddc96485 100644 --- a/rust/lithe-core/src/build/invocation.rs +++ b/rust/lithe-core/src/build/invocation.rs @@ -8,13 +8,17 @@ use super::decode::{self, join_relative}; use super::manifest::{ - BuildSystemDeclaration, ExecutableCandidate, Platform, RuntimeDependency, VersionProbe, + BuildSystemDeclaration, ExecutableCandidate, ExecutableSource, Platform, RuntimeDependency, + VersionProbe, }; use crate::plugins::declarative::{ - reported_version, validate_minimum_version, version_components, PluginRejection, Validation, + reported_version, validate_minimum_version, version_components, DeclarativePluginManifest, + PluginRejection, Validation, }; +use crate::plugins::executables::ordered_indices; use serde::Deserialize; use serde_json::{json, Value}; +use std::collections::BTreeMap; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -22,6 +26,9 @@ pub(crate) struct InvocationRequest { /// Instance root, workspace-relative. pub(crate) root: String, pub(crate) platform: Platform, + /// Global plugin settings. Project files cannot select a local executable. + #[serde(default)] + pub(crate) settings: BTreeMap, } #[derive(Debug, Deserialize)] @@ -34,34 +41,29 @@ pub(crate) struct VersionCheckRequest { pub(crate) output: String, } -fn candidates(candidates: &[ExecutableCandidate], root: &str, platform: Platform) -> Vec { - candidates - .iter() - .filter(|candidate| { - candidate.platforms().is_empty() || candidate.platforms().contains(&platform) - }) - .map(|candidate| match candidate { - ExecutableCandidate::ProjectFile { path, requires, .. } => json!({ - "kind": "projectFile", - "path": join_relative(root, path), - "requires": requires.iter().map(|file| join_relative(root, file)).collect::>(), - "executesProjectCode": true, - }), - ExecutableCandidate::Environment { variable, path, .. } => json!({ - "kind": "environment", - "variable": variable, - "path": path, - }), - ExecutableCandidate::Command { command, .. } => json!({ - "kind": "command", - "command": command, - }), - ExecutableCandidate::Runtime { runtime, .. } => json!({ - "kind": "runtime", - "runtime": runtime, - }), - }) - .collect() +fn candidates( + candidates: &[ExecutableCandidate], + root: &str, + platform: Platform, + manifest: &DeclarativePluginManifest, + settings: &BTreeMap, +) -> Validation> { + ordered_indices(candidates, "executables")?.into_iter() + .filter(|&index| candidates[index].platforms.is_empty() || candidates[index].platforms.contains(&platform)) + .map(|index| { + let candidate = &candidates[index]; + Ok(match &candidate.source { + ExecutableSource::ProjectFile { path, requires } => json!({ + "kind": "projectFile", "path": join_relative(root, path), + "requires": requires.iter().map(|file| join_relative(root, file)).collect::>(), "executesProjectCode": true, + }), + ExecutableSource::Environment { variable, path } => json!({ "kind": "environment", "variable": variable, "path": path }), + ExecutableSource::Command { command } => json!({ "kind": "command", "command": command }), + ExecutableSource::Runtime { runtime } => json!({ "kind": "runtime", "runtime": runtime }), + ExecutableSource::Setting { setting_id } => json!({ "kind": "setting", "settingID": setting_id, + "path": manifest.executable_setting_value(setting_id, settings)? }), + }) + }).collect() } /// Arguments that make a host-managed runtime print its version and exit. @@ -78,7 +80,13 @@ pub(crate) fn host_runtime_version_arguments(id: &str) -> Option<&'static [&'sta } } -fn runtime(runtime: &RuntimeDependency, root: &str, platform: Platform) -> Value { +fn runtime( + runtime: &RuntimeDependency, + root: &str, + platform: Platform, + manifest: &DeclarativePluginManifest, + settings: &BTreeMap, +) -> Validation { let version = if runtime.is_host_managed() { host_runtime_version_arguments(&runtime.id) .map(|arguments| json!({ "arguments": arguments })) @@ -88,14 +96,14 @@ fn runtime(runtime: &RuntimeDependency, root: &str, platform: Platform) -> Value .as_ref() .map(|probe| json!({ "arguments": probe.arguments })) }; - json!({ + Ok(json!({ "id": runtime.id, "displayName": runtime.display_name, "hostManaged": runtime.is_host_managed(), "minimumVersion": runtime.minimum_version, - "executables": candidates(&runtime.executables, root, platform), + "executables": candidates(&runtime.executables, root, platform, manifest, settings)?, "version": version, - }) + })) } /// `build.invocationPlan`: the candidates for this platform, in order, with @@ -103,10 +111,21 @@ fn runtime(runtime: &RuntimeDependency, root: &str, platform: Platform) -> Value pub(crate) fn invocation_plan( build_system: &BuildSystemDeclaration, request: &InvocationRequest, + manifest: &DeclarativePluginManifest, ) -> Validation { let root = decode::request_path(&request.root, "root")?; let invocation = &build_system.invocation; - let executables = candidates(&invocation.executables, &root, request.platform); + let mut executables = candidates( + &invocation.executables, + &root, + request.platform, + manifest, + &request.settings, + )?; + // Published v2 preview manifests adapt into the same candidate path. + if let Some(id) = &invocation.executable_setting { + executables.insert(0, json!({ "kind": "setting", "settingID": id, "path": manifest.executable_setting_value(id, &request.settings)? })); + } if executables.is_empty() { return Err(PluginRejection::new( "plugin.build.unavailable", @@ -124,8 +143,8 @@ pub(crate) fn invocation_plan( "runtimeDependencies": invocation .runtime_dependencies .iter() - .map(|dependency| runtime(dependency, &root, request.platform)) - .collect::>(), + .map(|dependency| runtime(dependency, &root, request.platform, manifest, &request.settings)) + .collect::>>()?, "version": invocation.version.as_ref().map(|probe| json!({ "arguments": probe.arguments })), "minimumVersion": invocation.minimum_version, })) diff --git a/rust/lithe-core/src/build/manifest.rs b/rust/lithe-core/src/build/manifest.rs index 79cab6abbc..5a9bd8760d 100644 --- a/rust/lithe-core/src/build/manifest.rs +++ b/rust/lithe-core/src/build/manifest.rs @@ -192,6 +192,10 @@ pub(crate) enum Nesting { #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub(crate) struct Invocation { + /// Compatibility adapter for published buildSystems 2 previews. New + /// plugins declare a setting candidate in executables instead. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) executable_setting: Option, /// Candidates in preference order; the host takes the first that exists. pub(crate) executables: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -207,74 +211,7 @@ pub(crate) struct Invocation { pub(crate) minimum_version: Option, } -/// One place an executable may come from. -#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] -#[serde( - tag = "kind", - rename_all = "camelCase", - rename_all_fields = "camelCase", - deny_unknown_fields -)] -pub(crate) enum ExecutableCandidate { - /// A file inside the instance root, such as a wrapper script. Running it - /// always counts as running project code. - ProjectFile { - path: String, - /// Further files that must exist for the candidate to apply. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - requires: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - platforms: Vec, - }, - /// A path below a directory named by a declared environment input. - Environment { - variable: String, - path: String, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - platforms: Vec, - }, - /// A bare command resolved through the host's tool discovery. - Command { - command: String, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - platforms: Vec, - }, - /// The executable of a declared runtime dependency itself. - Runtime { - runtime: String, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - platforms: Vec, - }, -} - -impl ExecutableCandidate { - pub(crate) fn platforms(&self) -> &[Platform] { - match self { - Self::ProjectFile { platforms, .. } - | Self::Environment { platforms, .. } - | Self::Command { platforms, .. } - | Self::Runtime { platforms, .. } => platforms, - } - } - - fn platforms_mut(&mut self) -> &mut Vec { - match self { - Self::ProjectFile { platforms, .. } - | Self::Environment { platforms, .. } - | Self::Command { platforms, .. } - | Self::Runtime { platforms, .. } => platforms, - } - } -} - -/// Host operating systems a candidate applies to; empty means all. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] -#[serde(rename_all = "lowercase")] -pub(crate) enum Platform { - Macos, - Linux, - Windows, -} +pub(crate) use crate::plugins::executables::{ExecutableCandidate, ExecutableSource, Platform}; /// A runtime the tool needs. Runtimes the host already manages (`java`, /// `node`, `go`) take only an ID and an optional minimum version and follow @@ -965,6 +902,7 @@ fn check_target_tags(tags: &mut Vec, scopes: &[Scope], field: &str) -> V /// against. pub(crate) struct DeclarationContext<'a> { pub(crate) plugin_api_version: u32, + pub(crate) build_systems_version: u32, pub(crate) official: bool, /// Setting IDs a provider's launch templates may name. pub(crate) declared_settings: &'a crate::plugins::settings::DeclaredSettings, @@ -1242,7 +1180,7 @@ impl BuildSystemDeclaration { self.validate_provider(field, context)?; self.validate_detection(field)?; let option_ids = self.validate_options(field)?; - self.validate_invocation(field, &option_ids)?; + self.validate_invocation(field, &option_ids, context)?; let model_templates = self.validate_tasks(field, &option_ids)?; self.validate_model(field, &option_ids)?; self.validate_run(field, &option_ids, &model_templates)?; @@ -1411,9 +1349,29 @@ impl BuildSystemDeclaration { Ok(ids) } - fn validate_invocation(&mut self, field: &str, options: &BTreeSet) -> Validation<()> { + fn validate_invocation( + &mut self, + field: &str, + options: &BTreeSet, + context: &DeclarationContext<'_>, + ) -> Validation<()> { let invocation = &mut self.invocation; let field = format!("{field}.invocation"); + if let Some(id) = &invocation.executable_setting { + if invocation + .executables + .iter() + .any(ExecutableCandidate::uses_resolution) + { + return Err(invalid(&field, "Do not combine the legacy executableSetting adapter with new candidate metadata or setting sources.")); + } + if context.build_systems_version < 2 || !context.declared_settings.is_executable(id) { + return Err(invalid( + format!("{field}.executableSetting"), + "An executable setting requires buildSystems 2 and a global file setting with effect reload or nextRun.", + )); + } + } if invocation.environment_inputs.len() > MAX_LIST { return Err(invalid( format!("{field}.environmentInputs"), @@ -1471,7 +1429,7 @@ impl BuildSystemDeclaration { } for (candidate_index, candidate) in runtime.executables.iter_mut().enumerate() { let candidate_field = format!("{field}.executables[{candidate_index}]"); - if matches!(candidate, ExecutableCandidate::Runtime { .. }) { + if matches!(candidate.source, ExecutableSource::Runtime { .. }) { return Err(invalid( candidate_field, "A runtime cannot be provided by another runtime.", @@ -1510,6 +1468,25 @@ impl BuildSystemDeclaration { &runtimes, )?; } + for (list, location) in + std::iter::once((&invocation.executables, format!("{field}.executables"))).chain( + invocation.runtime_dependencies.iter().map(|runtime| { + ( + &runtime.executables, + format!("{field}.runtimeDependencies.{}.executables", runtime.id), + ) + }), + ) + { + crate::plugins::executables::ordered_indices(list, &location)?; + for candidate in list { + if let ExecutableSource::Setting { setting_id } = &candidate.source { + if !context.declared_settings.is_executable(setting_id) { + return Err(invalid(&location, "A setting candidate needs a global file setting with effect reload or nextRun.")); + } + } + } + } if let Some(probe) = &invocation.version { probe.validate(&format!("{field}.version"))?; } @@ -2020,8 +1997,8 @@ fn check_candidate( environment_inputs: &[String], runtimes: &BTreeSet, ) -> Validation<()> { - match candidate { - ExecutableCandidate::ProjectFile { path, requires, .. } => { + match &mut candidate.source { + ExecutableSource::ProjectFile { path, requires, .. } => { *requires = sorted_unique(std::mem::take(requires)); if !is_safe_relative_path(path) || !requires.iter().all(|value| is_safe_relative_path(value)) @@ -2029,7 +2006,7 @@ fn check_candidate( return Err(executable_path(field)); } } - ExecutableCandidate::Environment { variable, path, .. } => { + ExecutableSource::Environment { variable, path, .. } => { if !environment_inputs.contains(variable) { return Err(invalid( format!("{field}.variable"), @@ -2040,7 +2017,7 @@ fn check_candidate( return Err(executable_path(field)); } } - ExecutableCandidate::Command { command, .. } => { + ExecutableSource::Command { command, .. } => { if !is_bare_command(command) { return Err(invalid( format!("{field}.command"), @@ -2048,7 +2025,12 @@ fn check_candidate( )); } } - ExecutableCandidate::Runtime { runtime, .. } => { + ExecutableSource::Setting { setting_id } => { + if !is_setting_identifier(setting_id) { + return Err(invalid(format!("{field}.settingID"), "Invalid setting ID.")); + } + } + ExecutableSource::Runtime { runtime, .. } => { if !runtimes.contains(runtime) { return Err(invalid( format!("{field}.runtime"), @@ -2057,7 +2039,7 @@ fn check_candidate( } } } - let platforms = candidate.platforms_mut(); + let platforms = &mut candidate.platforms; platforms.sort(); platforms.dedup(); Ok(()) diff --git a/rust/lithe-core/src/build/plans.rs b/rust/lithe-core/src/build/plans.rs index 6c5f0179a1..15e4244980 100644 --- a/rust/lithe-core/src/build/plans.rs +++ b/rust/lithe-core/src/build/plans.rs @@ -816,9 +816,11 @@ pub(crate) fn dependency_plan( /// entries then need no executable resolution of their own. fn sole_command(build_system: &BuildSystemDeclaration) -> Option<&str> { match build_system.invocation.executables.as_slice() { - [ExecutableCandidate::Command { command, platforms }] if platforms.is_empty() => { - Some(command) - } + [ExecutableCandidate { + source: super::manifest::ExecutableSource::Command { command }, + platforms, + .. + }] if platforms.is_empty() => Some(command), _ => None, } } diff --git a/rust/lithe-core/src/plugins/capabilities.rs b/rust/lithe-core/src/plugins/capabilities.rs index cfae7aad21..69eaaf0d6b 100644 --- a/rust/lithe-core/src/plugins/capabilities.rs +++ b/rust/lithe-core/src/plugins/capabilities.rs @@ -42,12 +42,16 @@ pub(crate) const BUILD_SYSTEMS: &str = "buildSystems"; /// `buildSystems[].provider`, whether it owns or shares a language server process. pub(crate) const BUILD_PROVIDERS: &str = "buildProviders"; +/// Shared executable candidate order and strict explicit selection. +pub(crate) const EXECUTABLE_RESOLUTION: &str = "executableResolution"; + /// Capability versions this Core build validates and plans. Version 1 of a /// capability keeps the meaning its section had at API level 2. pub const SUPPORTED_CAPABILITIES: &[(&str, &[u32])] = &[ (BUILD_PROVIDERS, &[1]), - (BUILD_SYSTEMS, &[1]), + (BUILD_SYSTEMS, &[1, 2]), (EXECUTION_PROVIDERS, &[1]), + (EXECUTABLE_RESOLUTION, &[1]), (LANGUAGE_SERVERS, &[1, 2]), (LANGUAGES, &[1]), (SERVER_PROCESS, &[1]), diff --git a/rust/lithe-core/src/plugins/declarative.rs b/rust/lithe-core/src/plugins/declarative.rs index 5dabae94f1..f302d6ac9a 100644 --- a/rust/lithe-core/src/plugins/declarative.rs +++ b/rust/lithe-core/src/plugins/declarative.rs @@ -895,6 +895,7 @@ impl DeclarativePluginManifest { &mut self.build_systems, &crate::build::DeclarationContext { plugin_api_version: self.plugin_api_version, + build_systems_version: self.requires.get("buildSystems").copied().unwrap_or(1), official: is_official_namespace(&self.id), declared_settings: &declared_settings, language_servers: &server_languages, @@ -909,6 +910,23 @@ impl DeclarativePluginManifest { /// server and every own build provider is a `serverProcess`. fn used_capabilities(&self) -> BTreeSet<&'static str> { let sections = [ + ( + capabilities::EXECUTABLE_RESOLUTION, + self.build_systems.iter().any(|system| { + system + .invocation + .executables + .iter() + .chain( + system + .invocation + .runtime_dependencies + .iter() + .flat_map(|runtime| &runtime.executables), + ) + .any(|candidate| candidate.uses_resolution()) + }), + ), (capabilities::LANGUAGES, !self.languages.is_empty()), ( capabilities::LANGUAGE_SERVERS, @@ -1248,6 +1266,26 @@ impl DeclarativePluginManifest { Ok(()) } + /// Strict executable selection preserves invalid stored values instead of + /// applying the UI settings resolver's tolerant fallback-to-default rule. + pub(crate) fn executable_setting_value( + &self, + id: &str, + values: &BTreeMap, + ) -> Validation> { + let setting = self + .settings + .iter() + .find(|setting| setting.id == id) + .ok_or_else(|| { + invalid( + "executables.settingID", + "The executable setting is not declared.", + ) + })?; + setting.executable_value(values.get(id)) + } + /// Effective value of every declared setting and the layer it came from. pub(crate) fn resolve_settings(&self, layers: SettingLayers<'_>) -> Vec { super::settings::resolve(&self.settings, layers) diff --git a/rust/lithe-core/src/plugins/executables.rs b/rust/lithe-core/src/plugins/executables.rs new file mode 100644 index 0000000000..57fddb5a7d --- /dev/null +++ b/rust/lithe-core/src/plugins/executables.rs @@ -0,0 +1,200 @@ +//! Shared executable candidate declarations and deterministic ordering. +//! Native discovery, trust and probing remain injected host responsibilities. + +use super::declarative::{invalid, is_identifier, Validation}; +use serde::{Deserialize, Deserializer, Serialize}; +use serde_json::Value; +use std::collections::{BTreeMap, BTreeSet}; + +/// A source plus optional ordering constraints within one plugin-owned set. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ExecutableCandidate { + #[serde(flatten)] + pub(crate) source: ExecutableSource, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) id: Option, + /// Higher priorities run first; absent means 0. Ties use array order. + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) priority: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) insert_before: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) insert_after: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub(crate) platforms: Vec, +} + +impl<'de> Deserialize<'de> for ExecutableCandidate { + fn deserialize>(deserializer: D) -> Result { + let mut value = Value::deserialize(deserializer)?; + let map = value + .as_object_mut() + .ok_or_else(|| serde::de::Error::custom("An executable is an object."))?; + // Decode metadata separately so source-specific unknown fields still + // fail closed instead of disappearing through serde flatten. + let id = map + .remove("id") + .map(serde_json::from_value) + .transpose() + .map_err(serde::de::Error::custom)?; + let priority = map + .remove("priority") + .map(serde_json::from_value) + .transpose() + .map_err(serde::de::Error::custom)?; + let insert_before = map + .remove("insertBefore") + .map(serde_json::from_value) + .transpose() + .map_err(serde::de::Error::custom)?; + let insert_after = map + .remove("insertAfter") + .map(serde_json::from_value) + .transpose() + .map_err(serde::de::Error::custom)?; + let platforms = map + .remove("platforms") + .map(serde_json::from_value) + .transpose() + .map_err(serde::de::Error::custom)? + .unwrap_or_default(); + let source = serde_json::from_value(value).map_err(serde::de::Error::custom)?; + Ok(Self { + source, + id, + priority, + insert_before, + insert_after, + platforms, + }) + } +} + +/// Platform-neutral source. Adapters supply each native discovery capability. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde( + tag = "kind", + rename_all = "camelCase", + rename_all_fields = "camelCase", + deny_unknown_fields +)] +pub(crate) enum ExecutableSource { + ProjectFile { + path: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + requires: Vec, + }, + Environment { + variable: String, + path: String, + }, + Command { + command: String, + }, + Runtime { + runtime: String, + }, + /// Global file preference; empty skips, any invalid nonempty value fails. + Setting { + #[serde(rename = "settingID")] + setting_id: String, + }, +} + +/// Operating system filter, applied after validating the complete candidate set. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum Platform { + Macos, + Linux, + Windows, +} + +impl ExecutableCandidate { + pub(crate) fn uses_resolution(&self) -> bool { + matches!(self.source, ExecutableSource::Setting { .. }) + || self.id.is_some() + || self.priority.is_some() + || self.insert_before.is_some() + || self.insert_after.is_some() + } +} + +/// Stable topological sort: explicit edges win, otherwise higher priority then +/// original array index wins. No cross-set anchors or arbitrary host code. +pub(crate) fn ordered_indices( + candidates: &[ExecutableCandidate], + field: &str, +) -> Validation> { + let mut ids = BTreeMap::new(); + for (index, candidate) in candidates.iter().enumerate() { + if let Some(id) = &candidate.id { + if !is_identifier(id) || ids.insert(id.as_str(), index).is_some() { + return Err(invalid( + field, + "Candidate IDs must be valid and unique within their set.", + )); + } + } + if (candidate.priority.is_some() + && (candidate.insert_before.is_some() || candidate.insert_after.is_some())) + || (candidate.insert_before.is_some() && candidate.insert_after.is_some()) + { + return Err(invalid( + field, + "Use priority or one relative insertion constraint, not both.", + )); + } + } + let mut edges = vec![BTreeSet::new(); candidates.len()]; + let mut incoming = vec![0usize; candidates.len()]; + for (index, candidate) in candidates.iter().enumerate() { + for (anchor, before) in [ + (candidate.insert_before.as_deref(), true), + (candidate.insert_after.as_deref(), false), + ] { + let Some(anchor) = anchor else { continue }; + let other = *ids.get(anchor).ok_or_else(|| { + invalid( + field, + "An insertion anchor must name a candidate in this set.", + ) + })?; + let (from, to) = if before { + (index, other) + } else { + (other, index) + }; + if edges[from].insert(to) { + incoming[to] += 1; + } + } + } + let mut result = Vec::with_capacity(candidates.len()); + let mut remaining: BTreeSet<_> = (0..candidates.len()).collect(); + while !remaining.is_empty() { + let next = remaining + .iter() + .copied() + .filter(|&index| incoming[index] == 0) + .min_by_key(|&index| { + ( + std::cmp::Reverse(candidates[index].priority.unwrap_or(0)), + index, + ) + }) + .ok_or_else(|| { + invalid( + field, + "Candidate insertion constraints contain a cycle or contradiction.", + ) + })?; + remaining.remove(&next); + result.push(next); + for &to in &edges[next] { + incoming[to] -= 1; + } + } + Ok(result) +} diff --git a/rust/lithe-core/src/plugins/mod.rs b/rust/lithe-core/src/plugins/mod.rs index 7431ebc539..2a228ddd41 100644 --- a/rust/lithe-core/src/plugins/mod.rs +++ b/rust/lithe-core/src/plugins/mod.rs @@ -2,6 +2,7 @@ pub mod capabilities; pub mod declarative; +pub(crate) mod executables; pub mod marketplace; pub(crate) mod settings; diff --git a/rust/lithe-core/src/plugins/settings.rs b/rust/lithe-core/src/plugins/settings.rs index f3044c4e48..da196f9069 100644 --- a/rust/lithe-core/src/plugins/settings.rs +++ b/rust/lithe-core/src/plugins/settings.rs @@ -15,7 +15,7 @@ //! //! Note: the decision is recorded in .agents/notes/implemented/architecture/2026-10-21-plugin-settings-surfaces.md -use super::declarative::{invalid, Validation}; +use super::declarative::{invalid, PluginRejection, Validation}; use regex::Regex; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -246,6 +246,8 @@ struct SettingOption { /// Setting IDs a template may name, with the restrictions their kinds carry. #[derive(Debug, Clone, Default)] pub(crate) struct DeclaredSettings { + /// Global paths that may explicitly select a build executable. + executables: BTreeSet, /// Settings a launch reads when it starts. launch: BTreeSet, /// Settings that are only pushed to a running server. @@ -256,6 +258,11 @@ pub(crate) struct DeclaredSettings { } impl DeclaredSettings { + /// Whether the global file setting may select the build executable. + pub(crate) fn is_executable(&self, id: &str) -> bool { + self.executables.contains(id) + } + /// Whether a start-time template may name the setting. pub(crate) fn contains(&self, id: &str) -> bool { self.launch.contains(id) @@ -317,6 +324,31 @@ pub(crate) struct SettingLayers<'a> { } impl SettingDeclaration { + /// Unlike display/configuration settings, an explicit invalid executable + /// value must terminate resolution rather than silently choose a default. + pub(crate) fn executable_value(&self, stored: Option<&Value>) -> Validation> { + let value = stored.unwrap_or(&self.default); + let valid = self.accepts(value) + && value.as_str().is_some_and(|path| { + path.is_empty() + || (path.trim() == path + && !path.chars().any(char::is_control) + && (path.starts_with('/') + || (path.len() >= 3 + && path.as_bytes()[0].is_ascii_alphabetic() + && path.as_bytes()[1] == b':' + && matches!(path.as_bytes()[2], b'/' | b'\\')) + || path.starts_with("\\\\"))) + }); + if !valid { + return Err(PluginRejection::at("plugin.build.invalidSelection", format!("settings.{}", self.id), "The configured executable must be an absolute file path satisfying the setting constraints.")); + } + Ok(value + .as_str() + .filter(|path| !path.is_empty()) + .map(str::to_owned)) + } + fn is_version_2_kind(&self) -> bool { matches!( self.kind.as_str(), @@ -606,6 +638,15 @@ impl SettingDeclaration { pub(crate) fn declared(settings: &[SettingDeclaration]) -> DeclaredSettings { let mut declared = DeclaredSettings::default(); for setting in settings { + if setting.kind == "file" + && setting.scope() == SettingScope::Global + && matches!( + setting.effect(), + SettingEffect::Reload | SettingEffect::NextRun + ) + { + declared.executables.insert(setting.id.clone()); + } if setting.effect() == SettingEffect::Immediate { declared.immediate.insert(setting.id.clone()); } else { diff --git a/rust/lithe-core/src/tests/build_systems.rs b/rust/lithe-core/src/tests/build_systems.rs index 33df31c95c..943e5c81f1 100644 --- a/rust/lithe-core/src/tests/build_systems.rs +++ b/rust/lithe-core/src/tests/build_systems.rs @@ -1592,3 +1592,195 @@ fn task_categories_and_option_placements_are_display_only() { assert_eq!(plan["status"], "ok", "{plan}"); assert!(plan.to_string().contains("\":run\""), "{plan}"); } + +/// A global executable selection is validated by the same settings rules as +/// launch settings. No filesystem access or per-tool branch belongs in Core. +#[test] +fn explicit_build_tool_path_requires_supported_global_file_setting() { + let manifest: Value = + serde_json::from_str(contract_file!("fixtures/build-tool-path.plugin.json")).unwrap(); + assert_eq!(validate(manifest.clone())["status"], "ok"); + let invoke = |settings: Value| { + call( + "invocationPlan", + manifest.clone(), + "fixture", + json!({ + "root": ".", "platform": "macos", "settings": settings + }), + ) + }; + assert_eq!( + invoke(json!({}))["invocation"]["executables"][0]["path"], + Value::Null + ); + assert_eq!( + invoke(json!({"toolPath": ""}))["invocation"]["executables"][0]["path"], + Value::Null + ); + assert_eq!( + invoke(json!({"toolPath": "/opt/custom tools/build-tool"}))["invocation"]["executables"][0] + ["path"], + "/opt/custom tools/build-tool" + ); + for value in [ + json!("relative"), + json!(42), + json!(null), + json!([]), + json!(" /bad"), + json!("/bad\npath"), + json!("/".repeat(1025)), + ] { + assert_eq!( + invoke(json!({"toolPath": value}))["status"], + "rejected", + "{value}" + ); + } + assert_eq!( + invoke(json!({"toolPath": "C:\\Tools\\build.exe"}))["status"], + "ok" + ); + for (field, value) in [ + ("scope", json!("project")), + ("type", json!("directory")), + ("effect", json!("restart")), + ] { + let mut invalid = manifest.clone(); + invalid["settings"][0][field] = value; + assert_eq!(validate(invalid)["status"], "rejected", "{field}"); + } + let mut old = manifest.clone(); + old["requires"] + .as_object_mut() + .unwrap() + .remove("executableResolution"); + assert_eq!(validate(old)["status"], "rejected"); + let mut missing = manifest; + missing["buildSystems"][0]["invocation"]["executables"][0]["settingID"] = json!("missing"); + assert_eq!(validate(missing)["status"], "rejected"); +} + +#[test] +fn executable_candidates_order_and_constraints_are_shared_and_deterministic() { + let base: Value = + serde_json::from_str(contract_file!("fixtures/build-tool-path.plugin.json")).unwrap(); + let invoke = |candidates: Value| { + let mut manifest = base.clone(); + manifest["buildSystems"][0]["invocation"]["executables"] = candidates; + call( + "invocationPlan", + manifest, + "fixture", + json!({"root":".","platform":"macos"}), + ) + }; + let result = invoke(json!([ + {"id":"a","kind":"command","command":"a"}, + {"id":"b","kind":"command","command":"b","priority":10}, + {"kind":"command","command":"c","insertBefore":"b"}, + {"kind":"command","command":"d","insertAfter":"a"} + ])); + assert_eq!(result["status"], "ok", "{result}"); + let commands: Vec<_> = result["invocation"]["executables"] + .as_array() + .unwrap() + .iter() + .map(|item| item["command"].as_str().unwrap()) + .collect(); + // Relative constraints are edges, not adjacency: ready-node ties retain + // array order, so a precedes c; c unlocks the higher-priority b. + assert_eq!(commands, ["a", "c", "b", "d"]); + for candidates in [ + json!([{"id":"a","kind":"command","command":"a"},{"id":"a","kind":"command","command":"b"}]), + json!([{"kind":"command","command":"a","insertBefore":"missing"}]), + json!([{"id":"a","kind":"command","command":"a","insertBefore":"a"}]), + json!([{"id":"a","kind":"command","command":"a","insertBefore":"b"},{"id":"b","kind":"command","command":"b","insertBefore":"a"}]), + json!([{"id":"a","kind":"command","command":"a","priority":2,"insertAfter":"a"}]), + json!([{"id":"a","kind":"command","command":"a","insertBefore":"a","insertAfter":"a"}]), + ] { + assert_eq!( + invoke(candidates.clone())["status"], + "rejected", + "{candidates}" + ); + } + let result = invoke(json!([ + {"kind":"command","command":"a","priority":1}, + {"kind":"command","command":"b","priority":9}, + {"kind":"command","command":"c","priority":9} + ])); + assert_eq!(result["invocation"]["executables"][0]["command"], "b"); + assert_eq!(result["invocation"]["executables"][1]["command"], "c"); +} + +#[test] +fn published_build_v2_paths_adapt_to_candidates_and_preserve_invalid_values() { + let mut manifest: Value = + serde_json::from_str(contract_file!("fixtures/build-tool-path.plugin.json")).unwrap(); + manifest["requires"] + .as_object_mut() + .unwrap() + .remove("executableResolution"); + manifest["buildSystems"][0]["invocation"]["executables"] + .as_array_mut() + .unwrap() + .remove(0); + manifest["requires"]["buildSystems"] = json!(2); + manifest["buildSystems"][0]["invocation"]["executableSetting"] = json!("toolPath"); + let run = |value| { + call( + "invocationPlan", + manifest.clone(), + "fixture", + json!({"root":".","platform":"macos","settings":{"toolPath":value}}), + ) + }; + let result = run(json!("/tools/custom build")); + assert_eq!(result["status"], "ok", "{result}"); + assert_eq!(result["invocation"]["executables"][0]["kind"], "setting"); + assert!(result["invocation"].get("configuredExecutable").is_none()); + assert_eq!(run(json!(false))["status"], "rejected"); + // The existing settings UI keeps its tolerant display behavior. + let displayed = crate::plugins::declarative::resolve_settings_command( + json!({"manifest":manifest,"settings":{"toolPath":false}}), + ) + .unwrap(); + assert_eq!(displayed["status"], "ok", "{displayed}"); + assert_eq!(displayed["settings"][0]["value"], "", "{displayed}"); +} + +#[test] +fn executable_resolution_is_opt_in_and_custom_runtimes_share_the_same_plan() { + let mut manifest: Value = + serde_json::from_str(contract_file!("fixtures/build-tool-path.plugin.json")).unwrap(); + let old_host = validate_command( + json!({"manifest":manifest,"hostCapabilities":{"buildSystems":[1,2],"settings":[1,2]}}), + ) + .unwrap(); + assert_eq!(old_host["error"]["code"], "plugin.api.unsupported"); + manifest["buildSystems"][0]["invocation"]["runtimeDependencies"] = json!([{ + "id":"custom", "executables":[ + {"id":"selected","kind":"setting","settingID":"toolPath"}, + {"kind":"command","command":"custom"} + ] + }]); + let result = call( + "invocationPlan", + manifest.clone(), + "fixture", + json!({"root":".","platform":"macos","settings":{"toolPath":"/custom runtime/tool"}}), + ); + assert_eq!(result["status"], "ok", "{result}"); + assert_eq!( + result["invocation"]["runtimeDependencies"][0]["executables"][0]["path"], + "/custom runtime/tool" + ); + // Anchors never escape into a different candidate collection. + manifest["buildSystems"][0]["invocation"]["executables"][1]["insertBefore"] = + json!("runtime-only"); + manifest["buildSystems"][0]["invocation"]["runtimeDependencies"][0]["executables"][0]["id"] = + json!("runtime-only"); + assert_eq!(validate(manifest)["status"], "rejected"); +} diff --git a/shared/contracts/plugins/v1/README.md b/shared/contracts/plugins/v1/README.md index ba81f07cdf..3b66a6332a 100644 --- a/shared/contracts/plugins/v1/README.md +++ b/shared/contracts/plugins/v1/README.md @@ -1471,3 +1471,85 @@ macOS 动作的可见、可用、勾选和激活中状态由宿主计算,不 条件脚本。窗口尚在激活时,其打开命令在工具栏、菜单和命令面板禁用;窗口入口 仍可取消显示。多个窗口同时请求同一插件模块时只构造一次;禁用或关闭会取消 并清理在途激活,旧的菜单或命令引用不能重新调用已禁用的贡献。 + +#### Shared executable candidates (`executableResolution` 1) + +Declare executable sources in `invocation.executables`, in preference order. +Array order is the recommended form; no custom path setting is required for a +plugin that only needs automatic discovery. Existing `projectFile`, `environment`, +`command` and `runtime` sources retain their v1 behavior. The shared +`executableResolution: 1` capability adds `setting` and ordering metadata: + +```json +"executables": [ + { "id": "selected", "kind": "setting", "settingID": "toolPath" }, + { "id": "wrapper", "kind": "projectFile", "path": "mvnw" }, + { "id": "system", "kind": "command", "command": "mvn" } +] +``` + +A `setting` references a declared **global file** setting with `effect: "reload"` +or `"nextRun"` (`settings: 2`). Core receives raw `request.settings`. An absent +value uses the declared default; an explicit empty string emits a setting candidate +with `path: null`, which skips. A nonempty value must satisfy the setting rules, +be absolute, and have no surrounding whitespace or controls. Invalid stored +values reject the invocation instead of invoking the normal settings display +resolver's fallback-to-default behavior. Project overrides never select tools. +The host checks file permissions and versions: a configured failure is terminal, +including a failure to probe, and must not fall back to subsequent candidates or +a Provider's own lookup. Paths with spaces remain single paths. The selected +setting's directory precedes PATH. Runtime candidates construct their environment +before version probing and reuse it for execution; a rejected candidate's +environment does not leak into the next choice. Provider hints and task/test +execution use this same plan/selection mechanism. If resolution fails before a +configured candidate is reached (for example, an earlier Runtime is missing), +any nonempty setting in the tool or Runtime candidate sets still forbids the +Provider's independent lookup. All-empty settings retain automatic-discovery +fallback. This check applies after failure, so an earlier successful candidate +is unaffected by a later unused setting. + +Optional metadata applies within **one candidate set owned by one plugin**: + +- `id` is a unique stable identifier; anonymous candidates cannot be anchors. +- `priority` is a signed 32-bit integer; higher wins, absent means 0. Ties retain + original array order. +- `insertBefore` or `insertAfter` names an ID in this set. One candidate cannot + combine either with `priority`, or declare both insertion fields. Constraints + mean relative order, not immediate adjacency. +- Normalize the complete set into a directed graph before platform filtering. + Repeatedly select a zero-indegree node by descending priority, then original + index; emit it and remove its edges. Explicit edges take precedence over + priority. Duplicate/invalid IDs, unknown anchors, cycles/self-edges and + conflicting metadata are manifest errors. Filter platforms only after sorting, + so anchors remain valid even if they do not apply to the current platform. + +`plugins::executables` owns the reusable source types and deterministic order; +`ExecutableCandidateResolver` receives file-system, Setting (already validated by +Core), Environment, Runtime and command-discovery capabilities from the host. +Results distinguish `skip`, `resolved` and `terminalFailure`; plugins declare data +and cannot inject host native code. Each operation uses a value snapshot for its +settings/environment and ordered plan. Passive import does not resolve/probe +runtimes or execute project wrappers. A project candidate remains project code +and is only executed through the consumer's existing trust/user-action workflow. + +This PR connects Build Systems and their custom Runtime Dependencies. Own Build +Providers already adapt command/runtime launches through BuildToolResolver and +receive the selected build tool as `tool.executable`; their ServerProcess protocol +is unchanged. Language Servers, Execution Providers, and other ServerProcess +launch forms can later adapt into this same resolver without changing their +manifest structure all at once: preserve package-realpath/executable checks, +workspace trust, auto-start restrictions, runtime ownership and cancellation. +They do **not** advertise support for new candidate declarations until their +adapter is implemented; this capability currently authorizes the Build candidate +positions only. Windows does not advertise it yet. + +Compatibility: Cargo 0.1.1 already shipped as a preview with `buildSystems: 2` +and `invocation.executableSetting`. That field is deprecated and supported only +as a v2 input adapter: prepend an equivalent setting candidate to the normal +list; never emit `configuredExecutable` or use a separate resolution branch. +New plugins use the fifth candidate and `executableResolution: 1` (Build v1 +remains sufficient). Legacy manifests do not need the new capability; hosts +without it reject new-style plugins before decoding unknown fields. Published +0.1.0/0.1.1 archives remain immutable; migration is a new plugin version. + +See [`build-tool-path.plugin.json`](fixtures/build-tool-path.plugin.json). diff --git a/shared/contracts/plugins/v1/fixtures/build-tool-path.plugin.json b/shared/contracts/plugins/v1/fixtures/build-tool-path.plugin.json new file mode 100644 index 0000000000..3115aac8a5 --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/build-tool-path.plugin.json @@ -0,0 +1,74 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.fixture.build-tool-path", + "displayName": "Build path fixture", + "version": "0.1.0", + "pluginAPIVersion": 3, + "requires": { + "buildSystems": 1, + "settings": 2, + "executableResolution": 1 + }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "process.build" + ], + "activationEvents": [ + "onBuildSystem:fixture" + ], + "languages": [], + "settings": [ + { + "id": "toolPath", + "type": "file", + "title": "Build tool", + "default": "", + "scope": "global", + "effect": "reload", + "page": { + "buildSystemID": "fixture" + } + } + ], + "buildSystems": [ + { + "id": "fixture", + "displayName": "Fixture", + "detection": { + "markers": [ + "build.fixture" + ] + }, + "invocation": { + "executables": [ + { + "id": "selected", + "kind": "setting", + "settingID": "toolPath" + }, + { + "kind": "command", + "command": "fixture-build" + } + ] + }, + "tasks": [ + { + "id": "build", + "displayName": "Build", + "group": "build", + "scopes": [ + "workspace" + ], + "arguments": [ + "build" + ] + } + ] + } + ] +} diff --git a/shared/contracts/plugins/v1/fixtures/invalid-manifests.json b/shared/contracts/plugins/v1/fixtures/invalid-manifests.json index 967ce21f2f..e87056a025 100644 --- a/shared/contracts/plugins/v1/fixtures/invalid-manifests.json +++ b/shared/contracts/plugins/v1/fixtures/invalid-manifests.json @@ -825,7 +825,7 @@ { "name": "future capability version", "base": "cargo-build.plugin.json", - "edits": [{ "op": "set", "path": "/requires/buildSystems", "value": 2 }], + "edits": [{ "op": "set", "path": "/requires/buildSystems", "value": 3 }], "expectedCode": "plugin.api.unsupported" }, { diff --git a/shared/contracts/plugins/v1/plugin-manifest.schema.json b/shared/contracts/plugins/v1/plugin-manifest.schema.json index 1b45e5dadd..04634debf2 100644 --- a/shared/contracts/plugins/v1/plugin-manifest.schema.json +++ b/shared/contracts/plugins/v1/plugin-manifest.schema.json @@ -22,8 +22,9 @@ "additionalProperties": false, "properties": { "buildProviders": { "description": "Required exactly when any buildSystems[].provider is present, including own-process and shared language-server providers. Declares host support for Provider orchestration, independent of declarative buildSystems and serverProcess launch support.", "enum": [1] }, - "buildSystems": { "description": "Declarative build detection, models, and command plans; does not imply buildProviders support.", "enum": [1] }, - "executionProviders": { "enum": [1] }, + "buildSystems": { "description": "Declarative build detection, models, and command plans; does not imply buildProviders support.", "enum": [1, 2] }, + "executableResolution": { "enum": [1], "description": "Shared executable candidate ordering and strict selection." }, + "executionProviders": { "enum": [1] }, "languageServers": { "enum": [1] }, "languages": { "enum": [1] }, "serverProcess": { "description": "The launch fields of language servers and of build providers that start their own process.", "enum": [1] }, @@ -583,7 +584,7 @@ }, "maxItems": 64 }, - "buildExecutable": { + "executableCandidate": { "oneOf": [ { "type": "object", @@ -618,8 +619,52 @@ "windows" ] } + }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "priority": { + "type": "integer", + "minimum": -2147483648, + "maximum": 2147483647 + }, + "insertBefore": { + "type": "string", + "minLength": 1 + }, + "insertAfter": { + "type": "string", + "minLength": 1 } - } + }, + "allOf": [ + { + "not": { + "required": [ + "priority", + "insertBefore" + ] + } + }, + { + "not": { + "required": [ + "priority", + "insertAfter" + ] + } + }, + { + "not": { + "required": [ + "insertBefore", + "insertAfter" + ] + } + } + ] }, { "type": "object", @@ -652,8 +697,52 @@ "windows" ] } + }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "priority": { + "type": "integer", + "minimum": -2147483648, + "maximum": 2147483647 + }, + "insertBefore": { + "type": "string", + "minLength": 1 + }, + "insertAfter": { + "type": "string", + "minLength": 1 } - } + }, + "allOf": [ + { + "not": { + "required": [ + "priority", + "insertBefore" + ] + } + }, + { + "not": { + "required": [ + "priority", + "insertAfter" + ] + } + }, + { + "not": { + "required": [ + "insertBefore", + "insertAfter" + ] + } + } + ] }, { "type": "object", @@ -679,8 +768,52 @@ "windows" ] } + }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "priority": { + "type": "integer", + "minimum": -2147483648, + "maximum": 2147483647 + }, + "insertBefore": { + "type": "string", + "minLength": 1 + }, + "insertAfter": { + "type": "string", + "minLength": 1 } - } + }, + "allOf": [ + { + "not": { + "required": [ + "priority", + "insertBefore" + ] + } + }, + { + "not": { + "required": [ + "priority", + "insertAfter" + ] + } + }, + { + "not": { + "required": [ + "insertBefore", + "insertAfter" + ] + } + } + ] }, { "type": "object", @@ -707,11 +840,127 @@ "windows" ] } + }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "priority": { + "type": "integer", + "minimum": -2147483648, + "maximum": 2147483647 + }, + "insertBefore": { + "type": "string", + "minLength": 1 + }, + "insertAfter": { + "type": "string", + "minLength": 1 } - } + }, + "allOf": [ + { + "not": { + "required": [ + "priority", + "insertBefore" + ] + } + }, + { + "not": { + "required": [ + "priority", + "insertAfter" + ] + } + }, + { + "not": { + "required": [ + "insertBefore", + "insertAfter" + ] + } + } + ] + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "settingID" + ], + "properties": { + "kind": { + "const": "setting" + }, + "settingID": { + "type": "string", + "minLength": 1 + }, + "platforms": { + "type": "array", + "items": { + "enum": [ + "macos", + "linux", + "windows" + ] + } + }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "priority": { + "type": "integer", + "minimum": -2147483648, + "maximum": 2147483647 + }, + "insertBefore": { + "type": "string", + "minLength": 1 + }, + "insertAfter": { + "type": "string", + "minLength": 1 + } + }, + "allOf": [ + { + "not": { + "required": [ + "priority", + "insertBefore" + ] + } + }, + { + "not": { + "required": [ + "priority", + "insertAfter" + ] + } + }, + { + "not": { + "required": [ + "insertBefore", + "insertAfter" + ] + } + } + ] } ] }, + "buildExecutable": {"$ref": "#/$defs/executableCandidate"}, "buildVersionProbe": { "type": "object", "additionalProperties": false, @@ -1095,6 +1344,13 @@ "executables" ], "properties": { + "executableSetting": { + "deprecated": true, + "type": "string", + "description": "buildSystems 2: global file setting with effect reload or nextRun; empty uses discovery, nonempty must be used without fallback.", + "pattern": "^[a-z][a-zA-Z0-9]*$", + "maxLength": 120 + }, "executables": { "type": "array", "items": { diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index c01db8ed90..b5807098c8 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -3089,6 +3089,14 @@ selections the declaration does not allow are `plugin.build.invalidSelection`, output that does not fit its decoder is `plugin.build.invalidOutput`, and a malformed payload is an `invalid_request` failure. +Executable candidates are normalized by the shared plugin executable-resolution +mechanism before platform filtering. A `setting` candidate emits +`{kind: "setting", settingID, path: absolutePath | null}` in the same `executables` +list; null skips, invalid stored values reject the plan, and native validation +failure is terminal. There is no separate `configuredExecutable` result. +See the plugin contract's **Shared executable candidates** section for ordering, +capability negotiation and the compatibility adapter for published v2 previews. + Host-managed runtime probes use `java -version` (including Java 8), `node --version`, and `go version`. Hosts execute the returned arguments and pass combined stdout and stderr as `build.checkToolVersion.request.output`: diff --git a/shared/platform-feature-matrix/features/build-system-plugins.json b/shared/platform-feature-matrix/features/build-system-plugins.json index 801252cc65..e6b33c26de 100644 --- a/shared/platform-feature-matrix/features/build-system-plugins.json +++ b/shared/platform-feature-matrix/features/build-system-plugins.json @@ -2,7 +2,7 @@ "id": "build-system-plugins", "area": "扩展", "group": "插件与主题", - "capability": "构建系统插件(API 3 buildSystems):插件用 JSON 声明构建工具的识别、可执行文件、选项、任务、运行入口、测试和输出格式,或由插件自带、与语言服务器同类的 Provider 进程回答模型和依赖,Rust Core 统一校验、生成命令行或 Provider 请求并解码结果,宿主为每个构建系统打开一个按声明(icon、presentation、category)布局的工具窗口", + "capability": "构建系统插件(API 3 buildSystems):插件用 JSON 声明构建工具的识别、可执行文件、选项、任务、运行入口、测试和输出格式,或由插件自带、与语言服务器同类的 Provider 进程回答模型和依赖,Rust Core 统一校验、生成命令行或 Provider 请求并解码结果,宿主为每个构建系统打开一个按声明(icon、presentation、category)布局的工具窗口;通过共享 executableResolution 候选解析支持可选设置路径、优先级与相对排序,空值自动发现、显式失败不回退", "macos": { "evidence": [ "rust/lithe-core/src/build", @@ -23,7 +23,12 @@ "rust/lithe-core/src/lsp/interface/build_provider.rs", "macos/Sources/Lithe/Platform/MacOS/Build/MacBuildProviderSessions.swift", "macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginLanguageServerLaunchResolver.swift", - "macos/Tests/LitheTests/PluginMarketplaceServiceTests.swift" + "macos/Tests/LitheTests/PluginMarketplaceServiceTests.swift", + "shared/contracts/plugins/v1/fixtures/build-tool-path.plugin.json", + "rust/lithe-core/src/plugins/executables.rs", + "macos/Sources/LitheCoreContracts/Execution/ExecutableResolution.swift", + "macos/Tests/LitheTests/BuildToolProviderResolutionTests.swift", + "macos/Tests/LitheTests/MacBuildToolResolverTests.swift" ], "implementationStatus": "partial", "verificationStatus": "pending" @@ -38,5 +43,5 @@ "verificationStatus": "not-applicable" }, "owner": "Extensions", - "verification": "macOS 已接入 static、command、hostIntegration、provider 四种模型来源:宿主向 Core 声明 buildSystems 与 buildProviders 能力,插件市场因此列出兼容的 API 3 声明式和 Provider 版本;Provider 进程按插件、构建系统和工作区各自独立,经各自 stdio 用 JSON-RPC(LSP 基础协议)与宿主通信,initialize 协商 capabilities.experimental.litheBuild 后宿主才经 lsp.buildRequest 发送 lithe/build/model、lithe/build/dependencies,回复交给 Core 解码;共享写法只复用同一插件的语言服务器会话;进程只在用户加载模型或依赖时启动,切换工作区、关闭项目窗口、停止执行模块、停用或更新插件、退出应用时停止;ExecutionFeatureGraph.stop 经 BuildSystemService.stop 同时回收已就绪和初始化中的 Provider,停止后的初始化完成也不得重新登记会话;状态写入插件状态目录;每个已启用的构建系统插件在打开工作区后各有一个右侧工具窗口(标题为 displayName、图标为 icon),不依赖识别结果,工作区没有对应工程时窗口内显示未检测到该构建系统,工具栏放运行、参数任务、刷新和 presentation=toolbar 的开关,树顶部是 presentation=tree 的勾选选项,工程下按 category(缺省按 group)分级列出任务,再列测试、目标和依赖;内置 Maven 窗口与共享 Build 窗口已删除,Build Output 显示输出、诊断与测试结果。窗口恢复先激活所属插件模块,激活中的命令面板项禁用;参数任务遵守 workspace/project/target 作用范围与目标标签,模型或工作区变化后清除旧选择。隔离回归覆盖这些边界、窗口布局恢复及包内图标读取前后文件清单与字节不变;多实例的根目录选择器保留每个实例的模型候选和生效值,编辑多选时保留兄弟实例独有的已选值;显式清空按实例根持久化为空覆盖,检测、同步指纹、任务计划和语言服务器导入使用同一覆盖,兄弟编辑或重开不会恢复非空默认值,非空编辑清当前实例标记而共享编辑/重置清该选项全部标记;真实 Core、生产存储与服务回归核对最终 argv 和导入值;列表单选与多选支持 allowCustom 原生输入和移除已选自定义值,文本/文件/目录清空提交显式空字符串,只有重置删除覆盖。插件选项在项目打开、选项修改和模型加载完成后通过 Core build.languageImport 归一化为 mavenContext/javaGradle,与构建计划使用同一生效值,并在 Java 启动前等待同一工作区代次的最新配置;运行时准备后再等一次,覆盖等待期间失效。唯一发布回调优先 Maven 原地热更新,被拒绝时串行恢复重启;停用最后一个 Maven 插件时,仅清除曾带 Maven 输入的同一会话,纯 Gradle 首次启动保持 nil。Gradle 在下次启动生效,取消/重置拒绝旧检测与恢复任务,被动工具路径发现不执行 Wrapper/版本探测;没有对应插件导入输入时兼容原 Maven 设置。模型 run 入口与 lithe/build/execute 尚未接入,Provider 无操作系统级沙盒;Windows 宿主尚未调用 build.* 命令。自动化:cargo test -p lithe-core --lib build_systems、cargo test -p lithe-core --lib lsp、scripts/verify-shared-contracts.sh、./scripts/test-macos.sh --filter 'BuildSystemToolWindowTests|RustBuildSystemOperationsTests|BuildSystemServiceTests|PluginMarketplaceServiceTests'。手动验证:安装 fixtures 中 cargo-build、go-build 形态的插件包,确认每个构建系统各出现一个工具窗口,列出工程、分级任务、选项和依赖,停用插件后窗口消失;执行任务与测试的 argv 与 build-plans.json 一致,停止与输出上限生效;JUnit 报告与 lineMatcher 诊断可跳转到源码;命令模型和项目内 Wrapper 只在用户点击加载或运行后启动;禁用插件后实例消失;Windows 接入后另需验证未信任工作区不启动任何模型命令或 Wrapper;gradle-provider 形态的自有 Provider 只在用户操作或工作区信任后启动、停用插件时停止,mix-provider 形态复用语言服务器会话而不启动第二个进程;运行前后 app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。" + "verification": "macOS 已接入 static、command、hostIntegration、provider 四种模型来源:宿主向 Core 声明 buildSystems 与 buildProviders 能力,插件市场因此列出兼容的 API 3 声明式和 Provider 版本;Provider 进程按插件、构建系统和工作区各自独立,经各自 stdio 用 JSON-RPC(LSP 基础协议)与宿主通信,initialize 协商 capabilities.experimental.litheBuild 后宿主才经 lsp.buildRequest 发送 lithe/build/model、lithe/build/dependencies,回复交给 Core 解码;共享写法只复用同一插件的语言服务器会话;进程只在用户加载模型或依赖时启动,切换工作区、关闭项目窗口、停止执行模块、停用或更新插件、退出应用时停止;ExecutionFeatureGraph.stop 经 BuildSystemService.stop 同时回收已就绪和初始化中的 Provider,停止后的初始化完成也不得重新登记会话;状态写入插件状态目录;每个已启用的构建系统插件在打开工作区后各有一个右侧工具窗口(标题为 displayName、图标为 icon),不依赖识别结果,工作区没有对应工程时窗口内显示未检测到该构建系统,工具栏放运行、参数任务、刷新和 presentation=toolbar 的开关,树顶部是 presentation=tree 的勾选选项,工程下按 category(缺省按 group)分级列出任务,再列测试、目标和依赖;内置 Maven 窗口与共享 Build 窗口已删除,Build Output 显示输出、诊断与测试结果。窗口恢复先激活所属插件模块,激活中的命令面板项禁用;参数任务遵守 workspace/project/target 作用范围与目标标签,模型或工作区变化后清除旧选择。隔离回归覆盖这些边界、窗口布局恢复及包内图标读取前后文件清单与字节不变;多实例的根目录选择器保留每个实例的模型候选和生效值,编辑多选时保留兄弟实例独有的已选值;显式清空按实例根持久化为空覆盖,检测、同步指纹、任务计划和语言服务器导入使用同一覆盖,兄弟编辑或重开不会恢复非空默认值,非空编辑清当前实例标记而共享编辑/重置清该选项全部标记;真实 Core、生产存储与服务回归核对最终 argv 和导入值;列表单选与多选支持 allowCustom 原生输入和移除已选自定义值,文本/文件/目录清空提交显式空字符串,只有重置删除覆盖。插件选项在项目打开、选项修改和模型加载完成后通过 Core build.languageImport 归一化为 mavenContext/javaGradle,与构建计划使用同一生效值,并在 Java 启动前等待同一工作区代次的最新配置;运行时准备后再等一次,覆盖等待期间失效。唯一发布回调优先 Maven 原地热更新,被拒绝时串行恢复重启;停用最后一个 Maven 插件时,仅清除曾带 Maven 输入的同一会话,纯 Gradle 首次启动保持 nil。Gradle 在下次启动生效,取消/重置拒绝旧检测与恢复任务,被动工具路径发现不执行 Wrapper/版本探测;没有对应插件导入输入时兼容原 Maven 设置。模型 run 入口与 lithe/build/execute 尚未接入,Provider 无操作系统级沙盒;Windows 宿主尚未调用 build.* 命令。自动化:cargo test -p lithe-core --lib build_systems、cargo test -p lithe-core --lib lsp、scripts/verify-shared-contracts.sh、./scripts/test-macos.sh --filter 'BuildSystemToolWindowTests|RustBuildSystemOperationsTests|BuildSystemServiceTests|PluginMarketplaceServiceTests'。手动验证:安装 fixtures 中 cargo-build、go-build 形态的插件包,确认每个构建系统各出现一个工具窗口,列出工程、分级任务、选项和依赖,停用插件后窗口消失;执行任务与测试的 argv 与 build-plans.json 一致,停止与输出上限生效;JUnit 报告与 lineMatcher 诊断可跳转到源码;命令模型和项目内 Wrapper 只在用户点击加载或运行后启动;禁用插件后实例消失;Windows 接入后另需验证未信任工作区不启动任何模型命令或 Wrapper;gradle-provider 形态的自有 Provider 只在用户操作或工作区信任后启动、停用插件时停止,mix-provider 形态复用语言服务器会话而不启动第二个进程;运行前后 app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。;新路径选择使用 executableResolution 1 + settings 2,已发布 buildSystems 2 旧字段只作统一候选输入适配:MacBuildToolResolverTests 验证带空格绝对路径、权限与版本失败不回退,BuildSystemServiceTests 验证 Provider 失败不独立发现,RustBuildSystemOperationsTests 验证全局值接入。Cargo 独立仓库以显式本地 Core bridge 验证自选路径用于 metadata 和全部构建/测试/运行/清理;发布前需把独立仓库 sdk.lock.json 的宿主 pin 更新到包含该能力的已发布提交。;回归增加非法存储值不回默认、数组/priority/插入顺序与拒绝循环、宿主注入发现、空设置跳过和被动选择不探测;Language Server/Execution Provider 新清单语法尚未开放,Windows 不声明 executableResolution。;真实 Core + macOS resolver + BuildSystemService 组合回归覆盖前置 Runtime 缺失/版本不足时保留工具或后置 Runtime 的非空 setting 约束,Provider 不发送独立发现请求;全部设置为空时仍可回退。自定义 Runtime 先构造含所选目录的 PATH,再以同一环境探测与执行,同目录 helper 的带空格路径回归通过。" } diff --git a/shared/platform-feature-matrix/features/cargo-build-plugin.json b/shared/platform-feature-matrix/features/cargo-build-plugin.json index ef40e77227..02edfaaae3 100644 --- a/shared/platform-feature-matrix/features/cargo-build-plugin.json +++ b/shared/platform-feature-matrix/features/cargo-build-plugin.json @@ -2,14 +2,17 @@ "id": "cargo-build-plugin", "area": "扩展", "group": "插件与主题", - "capability": "Cargo 社区构建插件:由 Cargo metadata 提供 workspace/package/target/features,使用原生构建窗口执行编译、检查、测试、清理和 binary/example 运行,支持 Release、Features、Offline;用户安装 Cargo 与 Node,插件独立打包且运行时只读", + "capability": "Cargo 社区构建插件:由 Cargo metadata 提供 workspace/package/target/features,使用原生构建窗口执行编译、检查、测试、清理和 binary/example 运行,支持 Release、Features、Offline;用户安装 Cargo 与 Node,插件独立打包且运行时只读;通过共享 executableResolution 候选解析支持可选设置路径、优先级与相对排序,空值自动发现、显式失败不回退", "macos": { "implementationStatus": "implemented", "verificationStatus": "pending", "evidence": [ "Plugins/mac/README.md", "docs/ci-builds.md", - ".agents/notes/implemented/feature/2026-10-22-cargo-build-plugin.md" + ".agents/notes/implemented/feature/2026-10-22-cargo-build-plugin.md", + "shared/contracts/plugins/v1/fixtures/build-tool-path.plugin.json", + "rust/lithe-core/src/plugins/executables.rs", + "macos/Sources/LitheCoreContracts/Execution/ExecutableResolution.swift" ] }, "windows": { @@ -20,5 +23,5 @@ ] }, "owner": "Plugin Platform", - "verification": "独立源码与预发布包由 https://github.com/Lithe-IDEA/lithe-plugin-cargo 维护;插件 CI 按 sdk.lock.json 的宿主提交构建公开 JSON API 测试桥接,运行 3 项 Cargo 专属 Core 兼容性回归(清单/安装审查/能力拒绝、识别/模型解码、任务与测试选择),并以 LITHE_CARGO_INTEGRATION=1 执行真实双包 workspace:模型由 Core 规划请求并解码,编译/检查/测试/同名 binary 与 example/单测试/清理由 Core 生成参数,验证 Release/Features/Offline、编译失败及诊断解码、后端退出和包字节不变;SDK 固定源码和哈希、打包校验及逐测试 HTML/JUnit 由插件仓库 CI 持有。固定宿主基线不代表已验证未来 preview。主仓库只保留通用插件契约回归;scripts/verify-runtime-bundle-immutability.sh。原生验收待完成:macOS 从磁盘安装预发布 ZIP、确认 Cargo/Node、重启并启用;打开插件仓库 fixture workspace,检查 Cargo 原生窗口、模型与选项,执行构建/检查/测试/运行,制造错误并跳转源码;停止运行、关闭项目与停用插件后确认无后端和 Cargo 子进程。基础版不提供依赖树、调试或逐用例图形报告;Windows 尚无 API 3 构建宿主。" + "verification": "独立源码与预发布包由 https://github.com/Lithe-IDEA/lithe-plugin-cargo 维护;插件 CI 按 sdk.lock.json 的宿主提交构建公开 JSON API 测试桥接,运行 3 项 Cargo 专属 Core 兼容性回归(清单/安装审查/能力拒绝、识别/模型解码、任务与测试选择),并以 LITHE_CARGO_INTEGRATION=1 执行真实双包 workspace:模型由 Core 规划请求并解码,编译/检查/测试/同名 binary 与 example/单测试/清理由 Core 生成参数,验证 Release/Features/Offline、编译失败及诊断解码、后端退出和包字节不变;SDK 固定源码和哈希、打包校验及逐测试 HTML/JUnit 由插件仓库 CI 持有。固定宿主基线不代表已验证未来 preview。主仓库只保留通用插件契约回归;scripts/verify-runtime-bundle-immutability.sh。原生验收待完成:macOS 从磁盘安装预发布 ZIP、确认 Cargo/Node、重启并启用;打开插件仓库 fixture workspace,检查 Cargo 原生窗口、模型与选项,执行构建/检查/测试/运行,制造错误并跳转源码;停止运行、关闭项目与停用插件后确认无后端和 Cargo 子进程。基础版不提供依赖树、调试或逐用例图形报告;Windows 尚无 API 3 构建宿主。;新路径选择使用 executableResolution 1 + settings 2,已发布 buildSystems 2 旧字段只作统一候选输入适配:MacBuildToolResolverTests 验证带空格绝对路径、权限与版本失败不回退,BuildSystemServiceTests 验证 Provider 失败不独立发现,RustBuildSystemOperationsTests 验证全局值接入。Cargo 独立仓库以显式本地 Core bridge 验证自选路径用于 metadata 和全部构建/测试/运行/清理;发布前需把独立仓库 sdk.lock.json 的宿主 pin 更新到包含该能力的已发布提交。;回归增加非法存储值不回默认、数组/priority/插入顺序与拒绝循环、宿主注入发现、空设置跳过和被动选择不探测;Language Server/Execution Provider 新清单语法尚未开放,Windows 不声明 executableResolution。" } diff --git a/shared/platform-feature-matrix/features/plugin-detail-tabs.json b/shared/platform-feature-matrix/features/plugin-detail-tabs.json index c039b0b367..407ae0be01 100644 --- a/shared/platform-feature-matrix/features/plugin-detail-tabs.json +++ b/shared/platform-feature-matrix/features/plugin-detail-tabs.json @@ -2,7 +2,7 @@ "id": "plugin-detail-tabs", "area": "插件", "group": "插件管理", - "capability": "已安装插件详情分为概览、功能、设置、权限与程序、版本与来源五个页签,信任与迁移提示固定在头部;已安装列表可按能力、更新、来源和启用状态过滤,搜索框输入 / 列出过滤选项", + "capability": "已安装插件详情分为概览、功能、设置、权限与程序、版本与来源五个页签,信任与迁移提示固定在头部;已安装列表可按能力、更新、来源和启用状态过滤,搜索框输入 / 列出过滤选项;窗口缩放及调整列表宽度时详情保持在分隔线右侧,过窄的设置表单可横向滚动查看完整控件和状态", "macos": { "evidence": [ "macos/Sources/Lithe/Views/App/PluginDetailView.swift", @@ -10,7 +10,8 @@ "macos/Sources/Lithe/Application/Features/PluginManagement.swift", "macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginManager.swift", "macos/Tests/LitheTests/PluginManagementPresentationTests.swift", - "macos/Tests/LitheTests/PluginManagerTests.swift" + "macos/Tests/LitheTests/PluginManagerTests.swift", + "macos/Tests/LitheTests/PluginDetailLayoutTests.swift" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -23,5 +24,5 @@ "verificationStatus": "not-applicable" }, "owner": "Plugin Platform", - "verification": "macOS:打开设置 › 插件 › 已安装,选中 Java Support。头部显示名称、官方徽标、版本和状态胶囊,右侧有启用/禁用、卸载按钮。依次切换五个页签:概览显示状态、提供的能力、来源和设置数量,“在设置中配置”可跳到 LSP 和运行配置页;功能页签列出 Java 语言、语言服务器(使用插件自带的 java 运行)以及由 Lithe 提供的运行、测试、调试;设置页签显示最大内存和日志级别,修改后出现“重启语言服务器”,“恢复默认值”可一键还原;权限与程序页签列出 languageServer.launch 与 JDT LS 启动命令,内容与安装确认弹窗一致;版本与来源显示当前版本、插件 ID 和签名。安装一个社区插件,确认社区提示固定在页签上方,任何页签都能看到。在左侧点“语言服务”“社区 / 磁盘”“已禁用”等过滤芯片,确认列表随之变化并按“已启用 / 已禁用”分组;在搜索框输入 /,确认出现过滤选项,选中后搜索框清空并切换过滤。更新过的插件在版本与来源页签显示上一版本和“回滚…”,确认后需要重启 Lithe。Windows:扩展管理尚未拆分页签。" + "verification": "macOS:打开设置 › 插件 › 已安装,选中 Java Support。头部显示名称、官方徽标、版本和状态胶囊,右侧有启用/禁用、卸载按钮。依次切换五个页签:概览显示状态、提供的能力、来源和设置数量,“在设置中配置”可跳到 LSP 和运行配置页;功能页签列出 Java 语言、语言服务器(使用插件自带的 java 运行)以及由 Lithe 提供的运行、测试、调试;设置页签显示最大内存和日志级别,修改后出现“重启语言服务器”,“恢复默认值”可一键还原;权限与程序页签列出 languageServer.launch 与 JDT LS 启动命令,内容与安装确认弹窗一致;版本与来源显示当前版本、插件 ID 和签名。安装一个社区插件,确认社区提示固定在页签上方,任何页签都能看到。在左侧点“语言服务”“社区 / 磁盘”“已禁用”等过滤芯片,确认列表随之变化并按“已启用 / 已禁用”分组;在搜索框输入 /,确认出现过滤选项,选中后搜索框清空并切换过滤。更新过的插件在版本与来源页签显示上一版本和“回滚…”,确认后需要重启 Lithe。布局:在浅色和深色下缩放设置窗口、往返拖动插件列表分隔条,切换 Java/Cargo 与各页签,确认标题、页签和正文不越过分隔线;窄设置表单可横向滚动至末尾,输入控件保持同一实例,扩大后恢复填满正文。运行 PluginDetailLayoutTests 检查这些边界。Windows:扩展管理尚未拆分页签。" } diff --git a/shared/platform-feature-matrix/features/plugin-registered-settings-pages.json b/shared/platform-feature-matrix/features/plugin-registered-settings-pages.json index 996eccf2ef..4576d8d480 100644 --- a/shared/platform-feature-matrix/features/plugin-registered-settings-pages.json +++ b/shared/platform-feature-matrix/features/plugin-registered-settings-pages.json @@ -2,7 +2,7 @@ "id": "plugin-registered-settings-pages", "area": "插件", "group": "插件管理", - "capability": "插件通过 settings 2 在 plugin.json 里把设置项和按钮注册到“语言与框架 › <语言>”或“构建、执行、部署 › <构建系统>”页,按 section 分组;设置可声明项目级覆盖(写入项目 .lithe/plugin-settings.json)和生效方式(重启、立即、重新加载、下次运行),“立即生效”的值经 workspace/configuration 推给正在运行的语言服务器;按钮调用插件自己的插件函数。宿主只提供通用页面、控件和语言服务状态卡,不内置任何语言或构建工具的页面", + "capability": "插件通过 settings 2 在 plugin.json 里把设置项和按钮注册到“语言与框架 › <语言>”或“构建、执行、部署 › <构建系统>”页,按 section 分组;设置可声明项目级覆盖(写入项目 .lithe/plugin-settings.json)和生效方式(重启、立即、重新加载、下次运行),“立即生效”的值经 workspace/configuration 推给正在运行的语言服务器;按钮调用插件自己的插件函数。宿主只提供通用页面、控件和语言服务状态卡,不内置任何语言或构建工具的页面;路径选择按钮保持完整可见,窄面板由输入框和标题列缩短以容纳状态标签", "macos": { "evidence": [ "rust/lithe-core/src/plugins/settings.rs", @@ -13,7 +13,9 @@ "macos/Sources/Lithe/Views/App/PluginSettingsSection.swift", "macos/Sources/Lithe/Views/App/SettingsView.swift", "macos/Tests/LitheTests/PluginSettingsTests.swift", - "shared/contracts/plugins/v1/fixtures/settings-v2.plugin.json" + "shared/contracts/plugins/v1/fixtures/settings-v2.plugin.json", + "macos/Sources/Lithe/Views/Components/LitheSettingsControls.swift", + "macos/Tests/LitheTests/PluginSettingsLayoutTests.swift" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -26,5 +28,5 @@ "verificationStatus": "not-applicable" }, "owner": "Plugin Platform", - "verification": "macOS:先使用 Swift 6.3.3 构建 Release,确认插件页选择回调不触发编译器崩溃;在 Release 应用中选择插件设置页,确认其侧栏分组自动展开。macOS:安装一个声明了 settings 2 页面(page/section/scope/effect/settingActions)的插件并启用,确认设置侧栏的对应分组下出现该语言或构建系统页,页内按插件和 section 分组显示设置与按钮;搜索设置标题能定位到该页。打开项目后把 scope 为 project 的设置切到“此项目”并修改,确认项目下生成 .lithe/plugin-settings.json 且显示“已在此项目覆盖”,恢复默认后文件被删除。修改 effect 为 immediate 的设置,确认语言服务器不重启而配置生效;修改 restart 设置时出现重启提示。点击带 confirm 的按钮先弹确认,再调用插件函数并给出结果通知。停用插件后页面消失。Windows:宿主仍只声明 settings/languageServers 1,需要 v2 的插件在市场中隐藏。" + "verification": "macOS:先使用 Swift 6.3.3 构建 Release,确认插件页选择回调不触发编译器崩溃;在 Release 应用中选择插件设置页,确认其侧栏分组自动展开。macOS:安装一个声明了 settings 2 页面(page/section/scope/effect/settingActions)的插件并启用,确认设置侧栏的对应分组下出现该语言或构建系统页,页内按插件和 section 分组显示设置与按钮;搜索设置标题能定位到该页。打开项目后把 scope 为 project 的设置切到“此项目”并修改,确认项目下生成 .lithe/plugin-settings.json 且显示“已在此项目覆盖”,恢复默认后文件被删除。修改 effect 为 immediate 的设置,确认语言服务器不重启而配置生效;修改 restart 设置时出现重启提示。点击带 confirm 的按钮先弹确认,再调用插件函数并给出结果通知。停用插件后页面消失。macOS 布局回归:运行 PluginSettingsLayoutTests,检查 480/640/900 点宽、深浅主题的路径控件与选择按钮不重叠且留在容器内,点击选择按钮写入所选路径;在插件详情的窄设置页确认“选择…”文字完整。Windows:宿主仍只声明 settings/languageServers 1,需要 v2 的插件在市场中隐藏。" }