diff --git a/.agents/notes/implemented/architecture/2026-10-26-plugin-live-activation.md b/.agents/notes/implemented/architecture/2026-10-26-plugin-live-activation.md index 698d234faa..2241824d26 100644 --- a/.agents/notes/implemented/architecture/2026-10-26-plugin-live-activation.md +++ b/.agents/notes/implemented/architecture/2026-10-26-plugin-live-activation.md @@ -4,7 +4,7 @@ ## 先说结论 -安装、更新、回退、卸载、启用、停用一个插件时,只有“它的每一项贡献在宿主里都有运行期实现”的插件才免重启生效。判断由宿主后端决定,写在 `PluginDescriptor.canActivateWithoutRestart`,不是 `plugin.json` 里的字段,所以插件契约没有变化,也不需要同步 lithe-plugin-docs。这条规则目前落地了两类:第一阶段的“纯调试适配器插件”(只贡献 `debugAdapters`,以及它声明的语言和设置,没有后端、没有其他模块),和第二阶段的“纯语法高亮插件”(只贡献带 `grammar` 或编辑配置的语言,见 [独立插件编辑器 API](2026-10-31-plugin-editor-extension-apis.md))。两者也可以出现在同一个插件里。 +安装、更新、回退、卸载、启用、停用一个插件时,只有“它的每一项贡献在宿主里都有运行期实现”的插件才免重启生效。判断由宿主后端决定,写在 `PluginDescriptor.canActivateWithoutRestart`,不是 `plugin.json` 里的字段,所以插件契约没有变化,也不需要同步 lithe-plugin-docs。这条规则目前落地了四类:第一阶段的“纯调试适配器插件”(只贡献 `debugAdapters`,以及它声明的语言和设置,没有后端、没有其他模块),第二阶段的“纯语法高亮插件”(只贡献带 `grammar` 或编辑配置的语言,见 [独立插件编辑器 API](2026-10-31-plugin-editor-extension-apis.md)),以及“纯项目生成器插件”和“纯工具定义插件”(见[插件注入的新建项目向导](../feature/2026-10-23-plugin-project-generators.md)的工具字段一节)。这几类也可以出现在同一个插件里。 满足条件的插件不进模块图,而是登记在一个**内存实时注册表**(`MacLivePluginRegistry`)里。注册表用后台线程扫描已安装目录,主线程只读取扫描结果快照。其他插件的路径完全不变:仍然写成 `.updateStaged`,等重启才生效。 @@ -23,7 +23,7 @@ ### 1. 由后端能力决定,不是清单字段 -`canActivateWithoutRestart` 现在的定义是:没有 `backend` 声明、模块列表非空,且每个模块都是 `debugAdaptersExtension(<插件 id>)` 或 `grammarsExtension(<插件 id>)`,并且没有其他贡献。语法模块只承载启用状态,它的运行期登记、撤回和查找由 `MacPluginEditorLanguages` 完成(编辑器每次变化都收到完整的语言列表,撤回就是从列表里拿掉)。 +`canActivateWithoutRestart` 现在的定义是:没有 `backend` 声明、模块列表非空,且每个模块都是 `debugAdaptersExtension`、`grammarsExtension`、`projectGeneratorsExtension` 或 `toolchainsExtension`(参数都是插件 id)之一,并且没有其他贡献。语法模块只承载启用状态,它的运行期登记、撤回和查找由 `MacPluginEditorLanguages` 完成(编辑器每次变化都收到完整的语言列表,撤回就是从列表里拿掉)。生成器和工具定义同样只是数据:`MacServiceContainer` 把启动时登记的插件和实时注册表里的插件合并成“当前生效的插件”,新建项目的生成器列表、工具链发现和工具 pin 的只读目录检查每次调用都读它;新建项目窗口监听 `pluginRegistrationChanges`,插件变化时重新载入条目。 - 正确做法:新增一种贡献(例如语言服务器)的热激活时,先实现它的运行期登记和撤回,再在这个谓词里放行。 - 错误做法:在 `plugin.json` 里加 `restartRequired: false` 让插件作者声明。作者不了解宿主哪些贡献有运行期实现;声明错了,用户看到的是“装上了但没生效”。 @@ -97,7 +97,9 @@ 5. 传给 Core 的 `registrationGeneration` 是准备调试适配器时注册表的 `generation`,Core 只比较是否相等,不解释数值。 6. `stopSessions(ofPlugin:)` 只停该插件拥有的会话。会话记录的所有者是创建时选中的插件;同一个语言下另一个插件的会话继续运行。 7. debug-only 声明里的文件扩展名会并入语言;扩展名集合变化会让该语言的服务器按原有规则重启。 -8. 目前仍然需要重启的贡献:语言服务器、运行/测试、编辑器动作后端(因为它依赖 `backend`)、构建系统、VCS、项目生成器、界面贡献、`backend`、网页窗口。后续阶段按“先有运行期登记和撤回,再放进谓词”的顺序逐类加入,每类单独验证。 +8. 目前仍然需要重启的贡献:语言服务器、运行/测试、编辑器动作后端(因为它依赖 `backend`)、构建系统、VCS、界面贡献、`backend`、网页窗口。后续阶段按“先有运行期登记和撤回,再放进谓词”的顺序逐类加入,每类单独验证。 +9. 设置里的工具链页面和新建项目窗口都监听 `pluginRegistrationChanges`:工具定义插件热激活后,已打开的工具链页面按文件变化同样的合并规则重读一次快照,页面还没加载过时什么都不做。 +10. 只含生成器或工具定义的插件属于热激活范围,安装、更新、卸载它们不再设置“需要重启”标记;同时含语言服务器等其他贡献的插件(例如 Java Support)仍然不满足谓词。 ## 验证 diff --git a/.agents/notes/implemented/feature/2026-10-23-plugin-project-generators.md b/.agents/notes/implemented/feature/2026-10-23-plugin-project-generators.md index 4d43a75a55..0575ccfd16 100644 --- a/.agents/notes/implemented/feature/2026-10-23-plugin-project-generators.md +++ b/.agents/notes/implemented/feature/2026-10-23-plugin-project-generators.md @@ -10,6 +10,8 @@ 界面复用 #1395 设置窗口的原生 SwiftUI 组件。为此把设置侧栏行、插件字段控件和文件夹选择器抽成了共享组件,设置窗口和克隆仓库窗口改用它们。 +后来(计划 API-33)新增 `projectGenerators: 2`,字段多了一种**工具字段**(`type: "tool"`):生成器只写一个工具定义 ID 和最低版本,宿主用已有的工具解析列出本机安装,用户的选择写进新项目的工具 pin(项目自己记住“用哪个安装”的那份设置)。这样 Python、Node、JDK 等任何工具都能被生成器选择,宿主和 Core 里不出现任何语言名。版本 1 的 `runtime`(只能选 Java)保持原样,等 Java 逻辑迁出宿主后再移除。 + ## 问题 - **入口什么都没做。** 欢迎页和项目切换器的「New Project…」调用 `chooseProject(title: "New Project")`,与「打开」只差一个标题。 @@ -56,7 +58,7 @@ 1. Core 用 `project.generationPlan` 校验表单,展开所有文件、argv 和 URL。 2. 宿主在目标父目录下建 `..lithe-creating-<随机>` 临时目录,按步骤执行。压缩包由 Core 的 `project.extractArchive` 解压:拒绝目录外路径、符号链接、覆盖已有文件,并限制实际写出的字节数。 3. 全部成功才原子移动到最终位置;目标已存在就拒绝。失败或取消时删除临时目录。 -4. 所选 JDK 通过 `ProjectRuntimeService.presetJavaHome` 写成新项目的 JDK;勾选 Git 时用 `git.initialize` 建仓库;`openFiles` 写进新项目的工作区会话,项目在哪个窗口打开都会恢复这些标签。 +4. 所选 JDK(版本 1 的 `runtime` 字段)通过 `ProjectRuntimeService.presetJavaHome` 写成新项目的 JDK;声明了 `pinAs` 的工具字段按计划里的 `toolPins` 写成新项目的工具 pin(见下文第 7 节);勾选 Git 时用 `git.initialize` 建仓库;`openFiles` 写进新项目的工作区会话,项目在哪个窗口打开都会恢复这些标签。 压缩包边下载边计数,超过 256 MiB 立即取消,不先占满磁盘;服务器拒绝请求时最多读 64 KiB 的错误说明。 @@ -80,6 +82,36 @@ 左栏固定宽度,不用每帧 `GeometryReader`,符合[可调整面板性能边界](../architecture/2026-09-13-resizable-ui-performance-boundaries.md)。插件提供的标题和说明走 `LocalizedStringKey`,与插件设置一样可以在宿主的 `Localizable.strings` 里翻译。 +### 7. 工具字段(`projectGenerators` 2) + +字段写法:`{"id": "sdk", "type": "tool", "tool": "python", "minimumVersion": "3.11", "pinAs": "project"}`。`tool` 是工具定义 ID(已验证的官方定义用短 ID,其余是 `<插件 id>:<定义 id>`),没有封闭列表;没人声明的工具就是没有安装可选。 + +谁做什么: + +| 部分 | 负责方 | 做什么 | +| --- | --- | --- | +| 字段校验、默认选择、版本比较、`${field.}` 与 `${field..executable}` 展开、`toolPins` | Rust Core | 在已有的 `project.generatorForm` 和 `project.generationPlan` 里;选择规则:用户的选择还在候选里就保留,否则取版本最新的,同版本保持宿主给的顺序;没有候选报 `required`(可选字段除外),低于 `minimumVersion` 报 `toolTooOld`。版本按“发行号”比较,末尾的 0 不算(`3.11`、`3.11.0`、`3.11.0.0` 相同),和工具链解析一致。可选字段没有够新的候选时保持为空、不写 pin,和“完全没装”一样,机器上有旧版本不会反而挡住创建;用户明确选了旧版本仍报 `toolTooOld` | +| 列出候选安装 | macOS 宿主(I/O) | `MacProjectToolInstallations` 复用设置页的工具发现;新建项目没有工作区,所以在一个空的临时目录里发现,用完删除。临时目录建不出来时记日志并抛错,窗口显示“无法查找已安装的工具”和“重试”,而不是“没有安装”;删不掉只记日志,列出的结果照常使用 | +| 保存 pin | macOS 宿主(I/O) | `MacProjectToolPins` 通过设置页同一个 `ToolchainPreferencesStoring` 写 `.lithe/run/local.json`,不新增存储 | +| 找不到工具时的提示 | Core 给出提供它的插件,宿主按已有方式显示 | 有定义但插件停用显示“启用”,没有定义显示“在插件市场中查找” | + +宿主不判断“这是哪种语言”,也不判断版本够不够:它把发现的安装交给 Core,按 Core 返回的 `installations[].meetsMinimum` 画下拉:太旧的仍然列出(让用户看到为什么没用它),但灰掉且不能选;Core 把可选字段留空时,下拉第一项显示“无”。 + +pin 写失败不会让项目创建失败:项目已经生成并移动到位,回滚没有意义。创建结果带一个 `toolPinWarning`,窗口提示“项目已创建,但无法保存你选择的工具”,用户可以到设置的工具页再选一次。 + +**热激活。** 只含生成器、或只含工具定义的插件原来要重启才生效。生成器清单和工具定义都是宿主每次用到时才读的数据,所以它们加入了 `canActivateWithoutRestart`(见[插件热激活](../architecture/2026-10-26-plugin-live-activation.md))。新建项目窗口监听插件变化,自动重新列出条目和工具候选;窗口打开时安装工具定义插件,下拉随即多出新的安装。插件变化会丢掉所有条目已列出的工具候选,不只是当前条目的:之后切到别的条目会重新查找,不会沿用已停用定义找到的安装;工具重新列出之前“创建”不可用。含语言服务器、构建、界面等其他贡献的插件(例如 Java Support)仍然要重启。 + +#### 正确做法 + +- Python 生成器想让用户选解释器:写 `{"type": "tool", "tool": "python", "minimumVersion": "3.11", "pinAs": "project"}`,步骤里用 `${field.sdk.executable}` 调 `venv`。 +- 生成器只需要路径、不想改项目的工具设置:不写 `pinAs`,选择只用于步骤。 + +#### 不要这样做 + +- 在生成器里自带发现命令或固定路径。发现归工具定义和 provider,生成器只引用工具 ID。 +- 在宿主里按工具名判断版本、默认值或提示。这些是 Core 的职责,宿主只执行 I/O 和画界面。 +- 在同一个生成器里同时用 `runtime` 和 `tool`:Core 拒绝这样的清单。`runtime` 只属于版本 1。 + ### 正确做法 - 新增一个 Cargo 生成器:在 Cargo 插件里声明一条 `command` 步骤调用 `cargo init --vcs none`,`requirements` 写 `{"buildSystemID": "cargo"}`,`requires` 加 `projectGenerators: 1`。宿主不用改。 @@ -110,6 +142,14 @@ 最有吸引力的地方:宿主几乎不用改。没有采用:插件界面原生优先,网页只作为可选方式;名称、位置、校验和提交需要宿主统一控制。 +### 给 `runtime` 字段继续增加取值(`python`、`node` ……) + +最有吸引力的地方:改动最小,Java 已经这样做。没有采用:`runtime` 取值是 Core 里的封闭列表,每支持一种工具都要改 Core 和宿主,社区插件无法自己带新工具;Core 和宿主里会出现一份又一份语言词汇。工具字段直接引用工具定义 ID,工具的发现和版本规则本来就在工具定义里。 + +### 生成器自带发现命令 + +最有吸引力的地方:生成器作者完全自主。没有采用:发现一个工具的方式(PATH、版本管理器、固定目录)已经由工具定义和 provider 描述并被设置页使用;生成器再写一份会让新建项目看到的安装和设置页不一致,选了也无法写成项目的工具 pin。 + ### 宿主自己解压(`ditto`) 最有吸引力的地方:少一个 Core 命令。没有采用:压缩包来自用户可修改的服务器,属于不可信输入;放在 Core 里,路径穿越、符号链接、覆盖和大小上限的检查只写一次,Windows 接入时直接复用。 @@ -120,6 +160,9 @@ - 收益:设置窗口、克隆窗口和向导共用侧栏行、字段控件和文件夹选择器,样式不会各自漂移。 - 代价:Spring Boot 的版本和依赖列表写在插件里,会随时间过时,要靠插件发版更新;动态列表要等 Provider 能力。 - 代价:Java Support 的 `requires` 多了 `projectGenerators: 1`,不支持生成器的宿主装不了这一版;停用 Java Support 时模板一起停用。 +- 收益:任何带工具定义的工具都能被生成器选择,新增工具不需要改 Core 和宿主;选择直接成为项目的工具 pin,建完就能用同一个安装构建运行。 +- 代价:使用工具字段的生成器要求 `projectGenerators: 2`,不支持它的宿主装不了这个插件;版本 1 的 `runtime` 字段和 Java 专用路径还在,等 Java 逻辑迁出宿主后再删。 +- 代价:只含生成器或工具定义的插件热激活后,已打开的工具链设置页要重读整份快照(和工作区版本文件变化走同一条合并重载路径),而不是只增删变化的那个工具。 - 暂不支持:JavaFX、Quarkus、Micronaut、Jakarta EE 等其他 Java 生成器;Windows 宿主(不声明 `projectGenerators`,需要它的插件在 Windows 市场中隐藏)。 ## 验证 @@ -127,7 +170,7 @@ ```bash cargo test --manifest-path rust/Cargo.toml -p lithe-core project_generators ./scripts/architecture/verify-shared-contracts.sh -./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'ProjectGenerationTests|PluginManagerTests' +./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'ProjectGenerationTests|ProjectToolAdaptersTests|PluginManagerTests|PluginLiveActivationTests' ./scripts/plugins/verify-official-plugins.sh ./scripts/platform-feature-matrix/verify-platform-feature-matrix.sh ``` @@ -142,6 +185,6 @@ cargo test --manifest-path rust/Cargo.toml -p lithe-core project_generators - `macos/Sources/Lithe/Views/Workspace/NewProjectView.swift` - `macos/Sources/Lithe/Application/Features/NewProjectFeatureModel.swift` - `macos/Sources/Lithe/Services/ProjectGeneration/` -- `macos/Sources/Lithe/Platform/MacOS/ProjectGeneration/` +- `macos/Sources/Lithe/Platform/MacOS/ProjectGeneration/`(含 `MacProjectToolInstallations.swift`、`MacProjectToolPins.swift`) - `macos/Sources/Lithe/Views/Components/LitheSidebarList.swift` - `macos/Sources/Lithe/Views/Components/PluginFieldControls.swift` diff --git a/docs/development/macos-plugin-api-plan.md b/docs/development/macos-plugin-api-plan.md index b74ed392ff..065bbcebbb 100644 --- a/docs/development/macos-plugin-api-plan.md +++ b/docs/development/macos-plugin-api-plan.md @@ -55,7 +55,7 @@ Windows 不在本轮,平台矩阵的 partial/pending 保持真实。 | API-30 | 既有能力 | 已有 debugAdapters 1/2 与 DAP 宿主;本 PR 不重建调试协议;Java 拆分计划(C-RUN):debug/start、debugAdapters 3 的 features 与 defaults | | API-31 | 既有能力 | 已有 vcsProviders:1;不包含内置 Git 公共读取面 | | API-32 | 未做 | 通用 ServiceProvider 仍为提案 | -| API-33 | 既有能力 | 已有 projectGenerators:1;新 hooks 不在本轮;Java 拆分计划(C-PROJ):projectGenerators 2 增加 type:"tool" | +| API-33 | 部分完成 | projectGenerators 2 增加通用工具字段(`type:"tool"`,工具来自 `toolDefinitions`,选择写入项目的工具 pin),即 Java 拆分计划(C-PROJ)所需的版本;项目生成器与工具定义插件免重启生效;新 hooks 不在本轮 | | API-34 | 未做 | 插件公共 IDE/AI/MCP 授权面未开放;独立 broker 不能直接转授 | | API-35 | 第一阶段进行中 | 第一阶段是 Java 拆分所需的 Core 中转插件函数调用(插件 1 → Core → 插件 2,C-IMPORT,进行中,不是安装依赖);依赖/导出安装闭包、版本求解、远程 Provider 剩余接续未完成 | | API-36 | 未做 | WASM/WASI 非本轮前置,未引入运行时 | @@ -157,7 +157,7 @@ Windows 不在本轮,平台矩阵的 partial/pending 保持真实。 | 内置 Git | Git 在 `LitheGitModule`,按 2026-10-22 评审不接入统一 VCS 层;IDE broker 的 13 个工具没有 Git 工具 [S15/S21] | API-31 只覆盖插件提供的 VCS,插件没有读取内置 Git 状态的公共入口 | | Agent 侧 | Skills、Hooks、MCP 是用户管理的 Codex/Claude 原生配置,宿主只读取并编辑用户选定的来源 [S22] | 插件没有贡献来源;不能让插件绕过用户确认写入这些配置 | -Mac 当前声明的能力版本为:`backend:1`、`buildModelInputs:1`、`buildProviders:1–6`、`buildSystems:1/2`、`debugAdapters:1/2`、`executionProviders:1`、`executableResolution:1`、`languageServers:1/2`、`languages:1`、`packageTrust:1`、`projectGenerators:1`、`serverProcess:1`、`settings:1/2`、`ui:1/2/3`、`vcsProviders:1`。Core 能校验某版本不等于 Mac 会执行它:Core 的 `serverProcess` 声明 1/2,而 Mac 只声明 1,不能因此给 Mac 标记支持 `serverProcess:2`。以上以 `PluginTypes.swift` 的 `hostCapabilities` 为准,与 Core 的 `SUPPORTED_CAPABILITIES` 逐项比对过,唯一差异是 `serverProcess`。 +Mac 当前声明的能力版本为:`backend:1`、`buildModelInputs:1`、`buildProviders:1–6`、`buildSystems:1/2`、`debugAdapters:1/2`、`executionProviders:1`、`executableResolution:1`、`languageServers:1/2`、`languages:1`、`packageTrust:1`、`projectGenerators:1/2`、`serverProcess:1`、`settings:1/2`、`ui:1/2/3`、`vcsProviders:1`。Core 能校验某版本不等于 Mac 会执行它:Core 的 `serverProcess` 声明 1/2,而 Mac 只声明 1,不能因此给 Mac 标记支持 `serverProcess:2`。以上以 `PluginTypes.swift` 的 `hostCapabilities` 为准,与 Core 的 `SUPPORTED_CAPABILITIES` 逐项比对过,唯一差异是 `serverProcess`。 当前 Web bridge 的方法白名单为 `build.instances/refresh/runTask/runTests/cancel/dependencies/setOptions`、`workspace.openFile`、`ui.getState/setState`、`functions.list/invoke/cancel`、`dialog.openFile/saveFile`。后两个选择文件方法已在 Mac bridge v2 实现;盘点不能只依据较早 README 的列表。网页调用权限也不自动转授给后端进程。 @@ -244,7 +244,7 @@ Mac 当前声明的能力版本为:`backend:1`、`buildModelInputs:1`、`build | API-30 P/C/E | Debug Adapter 注册、配置和会话事件;已有 DAP 宿主 [S14] | 进程/连接授权、launch 与 attach 所有权、终止和断开区别;复用断点/调试 UI。高级内存/反汇编按独立提案,不阻塞基础 API | M4;Java 拆分(C-RUN):debug/start、features 与 defaults | | API-31 P/C | VCS 仓库/状态/diff/提交等领域能力;已有 vcsProviders 1 [S14],只覆盖插件提供的 VCS,内置 Git 见 API-42 | 读取与修改仓库分权;危险操作有明确确认,结果有仓库/操作身份。插件私有函数不能代替宿主可理解的 VCS 协议 | M4 | | API-32 P/C/E | Services 的实例/状态/动作/日志;数据库已有自身路径,通用 ServiceProvider 仍为提案 [S17] | 插件拥有的连接/进程与用户外部服务分开;停用只清理前者,不默认停用户数据库/容器。服务接入现有 Services,不再画一套工作台 | 后续 | -| API-33 P/C | 项目生成器发现、参数、预览与生成;已有 projectGenerators 1 [S14] | 写目标目录需确认,禁止路径逃逸/无提示覆盖;只生成选择的项目,不下载未声明程序。先补现有文档,再评估新增 hooks | M4;Java 拆分(C-PROJ):projectGenerators 2 | +| API-33 P/C | 项目生成器发现、参数、预览与生成;已有 projectGenerators 1,projectGenerators 2 增加通用工具字段 [S14] | 写目标目录需确认,禁止路径逃逸/无提示覆盖;只生成选择的项目,不下载未声明程序。工具字段只通过工具定义的通用解析列出候选,选择写入项目已有的工具 pin,生成器不能自带工具发现。版本 1 的 `runtime` 字段保持不变,待 Java 逻辑迁出后再移除。先补现有文档,再评估新增 hooks | M4;Java 拆分(C-PROJ):projectGenerators 2 | | API-34 H/P | 受限 IDE/AI/MCP 能力;已有单独 IDE broker [S15] | 项目 opt-in 和 read/configure/execute 分权不变;不转交 broker token、全量 dispatcher 或 Agent 历史。是否开放、哪些调用方可用需独立威胁建模 | 后续 | | API-35 H/P/C | 插件依赖、显式导出服务;远程 Provider 适配的剩余部分(原生 Java 集成、Agent、SSH 调试)[S17/S20] | 跨插件调用需要新授权/版本/循环依赖规则。远程 authority 基线已移到 API-40(M0),本项只在其上扩展;未实现的环境继续明确拒绝,不静默本地执行。第一阶段(Java 拆分,进行中)只做 Core 中转的插件函数调用:接收方在清单里显式导出函数 ID,未列出的函数仍然私有;调用方声明函数 ID(相当于接口名),安装和启用确认时显示会匹配到哪些插件;Core 校验双方都已声明且已启用,记录调用链并拒绝环路,限制深度、并发、参数与结果字节数和超时,取消沿调用链向下传;信任不传递,被调方用自己的权限执行,调用方不获得任何权限;远程工作区按 API-40 拒绝;旧的 `wrongPlugin` 与 `recursiveCall` 检查原样保留,`lithe/build/*` 仍然不对后端开放;它不是安装依赖,不参与安装闭包,也不做版本求解 | 第一阶段:Core 中转的插件函数调用(进行中);后续:依赖闭包与导出服务 | | API-36 运行时 | 可选 WASM Component/WASI 宿主;当前能力表无 WASM [S1] | 复用相同公共 API/权限;独立验证 WIT 版本、fuel/内存/时间预算、取消、沙盒可达性、签名、工具链和分发。不得预挂载整个 HOME/工作区或绕过主进程隔离 | 后续,非 M0–M4 前置 | diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index 551b1f08a5..b9d252d8da 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -12,6 +12,7 @@ "%@ (%lld)" = "%@ (%lld)"; "%@ (default)" = "%@ (default)"; "%@ (not available for this Lithe or Mac)" = "%@ (not available for this Lithe or Mac)"; +"%@ (older than required)" = "%@ (older than required)"; "%@ / %@ rows · line %@" = "%@ / %@ rows · line %@"; "%@ already exists. Choose another name or location." = "%@ already exists. Choose another name or location."; "%@ answered with status %lld. %@" = "%@ answered with status %lld. %@"; @@ -1079,6 +1080,7 @@ "Could not load service dependencies" = "Could not load service dependencies"; "Could not load the changed files." = "Could not load the changed files."; "Could not load this image" = "Could not load this image"; +"Could not look for installed tools: %@" = "Could not look for installed tools: %@"; "Could not move the editor tab. Its content could not be synchronized." = "Could not move the editor tab. Its content could not be synchronized."; "Could not open %@: %@" = "Could not open %@: %@"; "Could not open %@: %@ %@" = "Could not open %@: %@ %@"; @@ -2306,6 +2308,7 @@ "Looked in: %@" = "Looked in: %@"; "Looking for Git…" = "Looking for Git…"; "Looking for installed JDKs…" = "Looking for installed JDKs…"; +"Looking for installed tools…" = "Looking for installed tools…"; "Looking up the location sends this address to ipinfo.io. Lithe does this only when you click Look Up." = "Looking up the location sends this address to ipinfo.io. Lithe does this only when you click Look Up."; "Low" = "Low"; "Low (recommended)" = "Low (recommended)"; @@ -2614,6 +2617,7 @@ "No history recorded yet" = "No history recorded yet"; "No host yet" = "No host yet"; "No implementations found" = "No implementations found"; +"No installation of this tool was found." = "No installation of this tool was found."; "No installed plugin supports %@ yet." = "No installed plugin supports %@ yet."; "No items" = "No items"; "No language dependency sources registered" = "No language dependency sources registered"; @@ -4401,6 +4405,7 @@ "The project folder on the host; empty uses the home directory. Browse connects, installs the Lithe server on the host if needed, and lets you pick a folder." = "The project folder on the host; empty uses the home directory. Browse connects, installs the Lithe server on the host if needed, and lets you pick a folder."; "The project model is not loaded." = "The project model is not loaded."; "The project was created, but Git could not be set up: %@" = "The project was created, but Git could not be set up: %@"; +"The project was created, but the tool you chose could not be saved for it: %@" = "The project was created, but the tool you chose could not be saved for it: %@"; "The provider looks up the build tool itself: %@" = "The provider looks up the build tool itself: %@"; "The proxy answered with HTTP %@." = "The proxy answered with HTTP %@."; "The proxy server's host name or IP address, for example 127.0.0.1." = "The proxy server's host name or IP address, for example 127.0.0.1."; @@ -4575,6 +4580,7 @@ "This folder cannot be read." = "This folder cannot be read."; "This folder is empty" = "This folder is empty"; "This host cannot receive a compatible plugin package. Update the remote server." = "This host cannot receive a compatible plugin package. Update the remote server."; +"This installation is older than the generator requires." = "This installation is older than the generator requires."; "This native configuration source is read-only. Installed applications and plugin packages cannot be edited." = "This native configuration source is read-only. Installed applications and plugin packages cannot be edited."; "This page" = "This page"; "This panel needs exactly one matching plugin instance in the workspace." = "This panel needs exactly one matching plugin instance in the workspace."; @@ -6251,6 +6257,7 @@ "native.couldNotLoadServiceDependencies" = "Could not load service dependencies"; "native.couldNotLoadTheChangedFiles" = "Could not load the changed files."; "native.couldNotLoadThisImage" = "Could not load this image"; +"native.couldNotLookForInstalledTools" = "Could not look for installed tools: %1$@"; "native.couldNotMoveTheEditorTabItsContentCouldNotBeSynchronized" = "Could not move the editor tab. Its content could not be synchronized."; "native.couldNotOpenHTMLInBrowser" = "Could not open HTML in browser"; "native.couldNotOpenTheRemoteFolder" = "Could not open the remote folder: %1$@"; @@ -7536,6 +7543,7 @@ "native.lookedIn" = "Looked in: %1$@"; "native.lookingForGit" = "Looking for Git…"; "native.lookingForInstalledJDKs" = "Looking for installed JDKs…"; +"native.lookingForInstalledTools" = "Looking for installed tools…"; "native.lookingUpTheLocationSendsThisAddressToIpinfoIoLitheDoesThisOnlyWhenYouClickLookUp" = "Looking up the location sends this address to ipinfo.io. Lithe does this only when you click Look Up."; "native.low" = "Low"; "native.lowEffortAndASmallOutputLimitAreRecommendedForFastCommitMessageGeneration" = "Low effort and a small output limit are recommended for fast commit-message generation."; @@ -7831,6 +7839,7 @@ "native.noHooksYet" = "No Hooks yet."; "native.noHostYet" = "No host yet"; "native.noImplementationsFound" = "No implementations found"; +"native.noInstallationOfThisToolWasFound" = "No installation of this tool was found."; "native.noInstalledPluginSupportsYet" = "No installed plugin supports %1$@ yet."; "native.noItems" = "No items"; "native.noJDKFoundSetJAVAHOMEInstallAJDKOrChooseAJDKDirectory" = "No JDK found. Set JAVA_HOME, install a JDK, or choose a JDK directory."; @@ -7981,6 +7990,7 @@ "native.officialPluginDeclaresASignaturePolicyThatDoes" = "Official plugin %1$@ declares a signature policy that does not match the host trust policy."; "native.olderCommitsAreNotListed" = "Older commits are not listed."; "native.olderCommitsAreOutsideTheLoadedHistory" = "Older commits are outside the loaded history"; +"native.olderThanRequired" = "%1$@ (older than required)"; "native.on" = "On"; "native.oneArgumentPerLineArgumentsKeepSpacesWithoutQuoting" = "One argument per line. Arguments keep spaces without quoting."; "native.oneEntryPerLineDirectoryNamesHideMatchingFoldersFileEntriesSupportAnd" = "One entry per line. Directory names hide matching folders; file entries support * and ?."; @@ -9428,6 +9438,7 @@ "native.theProjectFolderOnTheHostEmptyUsesTheHomeDirectoryBrowseConnectsInstallsTheLitheServerOnTh" = "The project folder on the host; empty uses the home directory. Browse connects, installs the Lithe server on the host if needed, and lets you pick a folder."; "native.theProjectModelIsNotLoaded" = "The project model is not loaded."; "native.theProjectWasCreatedButGitCouldNotBeSetUp" = "The project was created, but Git could not be set up: %1$@"; +"native.theProjectWasCreatedButTheToolYouChoseCouldNotBeSavedForIt" = "The project was created, but the tool you chose could not be saved for it: %1$@"; "native.theProviderDoesNotSupport" = "The %1$@ provider does not support %2$@."; "native.theProviderLooksUpTheBuildToolItself" = "The provider looks up the build tool itself: %1$@"; "native.thePublishedUpdateManifestIsInvalidAndCannotBeTrustedOpenTheReleasePageAndDowna6815c0bf9" = "The published update manifest is invalid and cannot be trusted. Open the Release page and download the update manually."; @@ -9562,6 +9573,7 @@ "native.thisFolderCannotBeRead" = "This folder cannot be read."; "native.thisFolderIsEmpty" = "This folder is empty"; "native.thisHookNoLongerExistsReloadTheLibrary" = "This Hook no longer exists. Reload the library."; +"native.thisInstallationIsOlderThanTheGeneratorRequires" = "This installation is older than the generator requires."; "native.thisMac" = "This Mac"; "native.thisMacOnly" = "This Mac only"; "native.thisMarkdownDocumentIsReadOnly" = "This Markdown document is read-only"; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 00893ddb9e..8aa13e6b10 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -12,6 +12,7 @@ "%@ (%lld)" = "%@(%lld)"; "%@ (default)" = "%@(默认)"; "%@ (not available for this Lithe or Mac)" = "%@(当前 Lithe 或这台 Mac 无法使用)"; +"%@ (older than required)" = "%@(低于要求的版本)"; "%@ / %@ rows · line %@" = "%@ / %@ 条 · 第 %@ 行"; "%@ already exists. Choose another name or location." = "%@ 已存在,请更换名称或位置。"; "%@ answered with status %lld. %@" = "%1$@ 返回状态 %2$lld。%3$@"; @@ -1079,6 +1080,7 @@ "Could not load service dependencies" = "无法加载服务依赖"; "Could not load the changed files." = "无法加载更改的文件。"; "Could not load this image" = "无法加载此图片"; +"Could not look for installed tools: %@" = "无法查找已安装的工具:%@"; "Could not move the editor tab. Its content could not be synchronized." = "无法移动编辑标签,未能同步其内容。"; "Could not open %@: %@" = "无法打开 %@:%@"; "Could not open %@: %@ %@" = "无法打开 %@:%@ %@"; @@ -2306,6 +2308,7 @@ "Looked in: %@" = "已查找:%@"; "Looking for Git…" = "正在查找 Git…"; "Looking for installed JDKs…" = "正在查找已安装的 JDK…"; +"Looking for installed tools…" = "正在查找已安装的工具…"; "Looking up the location sends this address to ipinfo.io. Lithe does this only when you click Look Up." = "查询地区会把这个地址发送给 ipinfo.io。只有你点击“查询地区”时 Lithe 才会这样做。"; "Low" = "低"; "Low (recommended)" = "低(推荐)"; @@ -2614,6 +2617,7 @@ "No history recorded yet" = "还没有历史记录"; "No host yet" = "尚未填写主机"; "No implementations found" = "没有找到实现"; +"No installation of this tool was found." = "未找到此工具的安装。"; "No installed plugin supports %@ yet." = "尚未安装支持 %@ 的插件。"; "No items" = "暂无项目"; "No language dependency sources registered" = "未注册语言依赖来源"; @@ -4401,6 +4405,7 @@ "The project folder on the host; empty uses the home directory. Browse connects, installs the Lithe server on the host if needed, and lets you pick a folder." = "主机上的项目文件夹,留空时使用主目录。“浏览”会连接主机,必要时在主机上安装 Lithe 远端服务,然后让你选择文件夹。"; "The project model is not loaded." = "项目模型尚未加载。"; "The project was created, but Git could not be set up: %@" = "项目已创建,但无法初始化 Git 仓库:%@"; +"The project was created, but the tool you chose could not be saved for it: %@" = "项目已创建,但无法为它保存你选择的工具:%@"; "The provider looks up the build tool itself: %@" = "Provider 自行查找构建工具:%@"; "The proxy answered with HTTP %@." = "代理返回了 HTTP %@。"; "The proxy server's host name or IP address, for example 127.0.0.1." = "代理服务器的主机名或 IP 地址,例如 127.0.0.1。"; @@ -4575,6 +4580,7 @@ "This folder cannot be read." = "无法读取此文件夹。"; "This folder is empty" = "此文件夹为空"; "This host cannot receive a compatible plugin package. Update the remote server." = "此主机无法接收兼容的插件包,请更新远程服务。"; +"This installation is older than the generator requires." = "此安装版本低于模板要求。"; "This native configuration source is read-only. Installed applications and plugin packages cannot be edited." = "此原生配置来源为只读。不能编辑已安装的应用和插件包。"; "This page" = "此页面"; "This panel needs exactly one matching plugin instance in the workspace." = "此面板要求工作区中恰好有一个匹配的插件实例。"; @@ -6251,6 +6257,7 @@ "native.couldNotLoadServiceDependencies" = "无法加载服务依赖"; "native.couldNotLoadTheChangedFiles" = "无法加载更改的文件。"; "native.couldNotLoadThisImage" = "无法加载此图片"; +"native.couldNotLookForInstalledTools" = "无法查找已安装的工具:%1$@"; "native.couldNotMoveTheEditorTabItsContentCouldNotBeSynchronized" = "无法移动编辑标签,未能同步其内容。"; "native.couldNotOpenHTMLInBrowser" = "无法在浏览器中打开 HTML"; "native.couldNotOpenTheRemoteFolder" = "无法打开远程文件夹:%1$@"; @@ -7536,6 +7543,7 @@ "native.lookedIn" = "已查找:%1$@"; "native.lookingForGit" = "正在查找 Git…"; "native.lookingForInstalledJDKs" = "正在查找已安装的 JDK…"; +"native.lookingForInstalledTools" = "正在查找已安装的工具…"; "native.lookingUpTheLocationSendsThisAddressToIpinfoIoLitheDoesThisOnlyWhenYouClickLookUp" = "查询地区会把这个地址发送给 ipinfo.io。只有你点击“查询地区”时 Lithe 才会这样做。"; "native.low" = "低"; "native.lowEffortAndASmallOutputLimitAreRecommendedForFastCommitMessageGeneration" = "为了快速生成提交信息,建议使用低推理强度和较小的输出限制。"; @@ -7831,6 +7839,7 @@ "native.noHooksYet" = "还没有 Hook。"; "native.noHostYet" = "尚未填写主机"; "native.noImplementationsFound" = "没有找到实现"; +"native.noInstallationOfThisToolWasFound" = "未找到此工具的安装。"; "native.noInstalledPluginSupportsYet" = "尚未安装支持 %1$@ 的插件。"; "native.noItems" = "暂无项目"; "native.noJDKFoundSetJAVAHOMEInstallAJDKOrChooseAJDKDirectory" = "未找到 JDK。请设置 JAVA_HOME、安装 JDK,或选择 JDK 目录。"; @@ -7981,6 +7990,7 @@ "native.officialPluginDeclaresASignaturePolicyThatDoes" = "官方插件 %1$@ 声明的签名策略与宿主信任策略不一致。"; "native.olderCommitsAreNotListed" = "更早的提交未列出。"; "native.olderCommitsAreOutsideTheLoadedHistory" = "更早的提交不在当前加载的历史范围内"; +"native.olderThanRequired" = "%1$@(低于要求的版本)"; "native.on" = "开启"; "native.oneArgumentPerLineArgumentsKeepSpacesWithoutQuoting" = "每行一个参数。参数中的空格无需额外引号。"; "native.oneEntryPerLineDirectoryNamesHideMatchingFoldersFileEntriesSupportAnd" = "每行输入一项。目录名用于隐藏匹配的文件夹;文件条目支持 * 和 ?。"; @@ -9428,6 +9438,7 @@ "native.theProjectFolderOnTheHostEmptyUsesTheHomeDirectoryBrowseConnectsInstallsTheLitheServerOnTh" = "主机上的项目文件夹,留空时使用主目录。“浏览”会连接主机,必要时在主机上安装 Lithe 远端服务,然后让你选择文件夹。"; "native.theProjectModelIsNotLoaded" = "项目模型尚未加载。"; "native.theProjectWasCreatedButGitCouldNotBeSetUp" = "项目已创建,但无法初始化 Git 仓库:%1$@"; +"native.theProjectWasCreatedButTheToolYouChoseCouldNotBeSavedForIt" = "项目已创建,但无法为它保存你选择的工具:%1$@"; "native.theProviderDoesNotSupport" = "%1$@ Provider 不支持 %2$@。"; "native.theProviderLooksUpTheBuildToolItself" = "Provider 自行查找构建工具:%1$@"; "native.thePublishedUpdateManifestIsInvalidAndCannotBeTrustedOpenTheReleasePageAndDowna6815c0bf9" = "已发布的更新清单无效,无法信任。请打开 Release 页面手动下载更新。"; @@ -9562,6 +9573,7 @@ "native.thisFolderCannotBeRead" = "无法读取此文件夹。"; "native.thisFolderIsEmpty" = "此文件夹为空"; "native.thisHookNoLongerExistsReloadTheLibrary" = "该 Hook 已不存在,请重新加载资源库。"; +"native.thisInstallationIsOlderThanTheGeneratorRequires" = "此安装版本低于模板要求。"; "native.thisMac" = "本机"; "native.thisMacOnly" = "仅本机"; "native.thisMarkdownDocumentIsReadOnly" = "此 Markdown 文档为只读"; diff --git a/macos/Sources/Lithe/Application/Composition/AppServices.swift b/macos/Sources/Lithe/Application/Composition/AppServices.swift index 785dfeaa5f..d18f17e944 100644 --- a/macos/Sources/Lithe/Application/Composition/AppServices.swift +++ b/macos/Sources/Lithe/Application/Composition/AppServices.swift @@ -44,6 +44,10 @@ final class AppServices { /// Installed plugins with their enabled state, for the New Project /// catalog and the plugins that provide a generator's requirements. var projectGeneratorSources: (@MainActor () -> [ProjectGeneratorPluginSource])? + /// Installations of the tools a generator's `tool` fields pick, by tool + /// definition ID, found without a workspace; `nil` when the platform + /// cannot discover tools. + var projectToolInstallations: (@MainActor ([String]) async throws -> [String: [ProjectToolInstallation]])? /// Read-only plugin toolchain projection; absent until platform composition. var toolchainSettingsLoader: (any ToolchainSettingsLoading)? var toolchainPreferences: (any ToolchainPreferencesStoring)? diff --git a/macos/Sources/Lithe/Application/Features/NewProjectFeatureModel.swift b/macos/Sources/Lithe/Application/Features/NewProjectFeatureModel.swift index 3eaccc3829..ece5d7ddba 100644 --- a/macos/Sources/Lithe/Application/Features/NewProjectFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/NewProjectFeatureModel.swift @@ -1,3 +1,4 @@ +import Combine import Foundation import LitheCoreContracts import LitheModuleAPI @@ -18,6 +19,14 @@ struct NewProjectEnvironment { let openMarketplace: @MainActor (String) -> Void let openProject: @MainActor (CreatedProject) -> Void let store: any KeyValueStore + /// Installations of the tools `tool` fields pick, by tool definition ID and + /// best first. A new project has no workspace yet, so this is the + /// machine-wide discovery; a tool nothing defines is simply absent. Throws + /// when discovery could not run, which is not the same as nothing found. + var toolInstallations: @MainActor ([String]) async throws -> [String: [ProjectToolInstallation]] = { _ in [:] } + /// Fires after the installed plugins or their enablement changed, so the + /// entry list follows an install, update, or removal without reopening. + var pluginChanges: AnyPublisher = Empty().eraseToAnyPublisher() } /// State of the New Project window: the entry list, the host-owned name, @@ -85,8 +94,20 @@ final class NewProjectFeatureModel: ObservableObject { @Published private(set) var progressOutput = "" @Published private(set) var errorMessage: String? @Published private(set) var enablingPluginID: PluginID? + /// True from the moment tool installations are requested until they arrive, + /// so an empty list reads as "none installed" rather than "still looking". + @Published private(set) var isListingTools = false + /// Why the installations of a tool could not be looked up, by tool + /// definition ID. Such a tool has no listing, so Create stays disabled. + @Published private(set) var toolListingFailures: [String: String] = [:] private let environment: NewProjectEnvironment + /// Installations found per tool definition ID. A listing replaces the + /// entries it asked for; the others keep their last answer until a plugin + /// change drops them all. A tool with no entry has not been listed yet. + private var listedTools: [String: [ProjectToolInstallation]] = [:] + private var toolListing: Task? + private var pluginChangeObservation: AnyCancellable? /// Inputs per generator, so switching entries keeps what the user typed. private var inputs: [String: ProjectGeneratorFormInput] = [:] private var creation: Task? @@ -99,6 +120,11 @@ final class NewProjectFeatureModel: ObservableObject { self.environment = environment location = environment.store.string(forKey: Self.locationKey) ?? Self.defaultLocation createGitRepository = environment.store.object(forKey: Self.createGitKey) as? Bool ?? false + pluginChangeObservation = environment.pluginChanges + .receive(on: DispatchQueue.main) + .sink { [weak self] in + MainActor.assumeIsolated { self?.reloadCatalog() } + } } // MARK: Catalog @@ -130,7 +156,14 @@ final class NewProjectFeatureModel: ObservableObject { if case .generator(let id) = selection, !entries.contains(where: { $0.id == id }) { selection = .empty } + // A plugin change may add or remove the definition of any tool, not + // only the selected entry's, so every listing is dropped: an entry + // selected later is looked up again instead of offering a home the + // removed definition found. + listedTools = [:] + toolListingFailures = [:] evaluate() + listToolInstallations() } var selectedEntry: ProjectGeneratorEntry? { @@ -225,7 +258,10 @@ final class NewProjectFeatureModel: ObservableObject { guard !isCreating, nameProblem == nil, formError == nil, !location.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return false } guard let entry = selectedEntry else { return selection == .empty } + // Until its tools are listed the form has judged an empty list, so an + // optional tool would read as "none" and be left out of the project. return entry.pluginEnabled && formState?.valid == true + && toolIDs(of: entry).allSatisfy { listedTools[$0] != nil } } private var parentURL: URL { @@ -236,6 +272,11 @@ final class NewProjectFeatureModel: ObservableObject { private func input(for entry: ProjectGeneratorEntry) -> ProjectGeneratorFormInput { var input = inputs[entry.id] ?? ProjectGeneratorFormInput(projectName: projectName) input.projectName = projectName + // Core accepts only installations the host listed, so each tool field + // is sent what was found for its tool. + for field in entry.generator.fields where field.kind == .tool { + input.tools[field.id] = field.tool.flatMap { listedTools[$0] } ?? [] + } // Every runtime field starts on the best JDK the generator accepts. for field in entry.generator.fields where field.kind == .runtime && input.runtimes[field.id] == nil { if let runtime = Self.preferredRuntime(javaRuntimes, minimum: field.minimumVersion) { @@ -257,6 +298,10 @@ final class NewProjectFeatureModel: ObservableObject { errorMessage = nil showsAdvancedFields = false evaluate() + // Tools another generator already needed are not looked up again. + if toolIDs(of: selectedEntry).contains(where: { listedTools[$0] == nil }) { + listToolInstallations() + } } /// Asks Rust Core for the form after every change. @@ -305,6 +350,101 @@ final class NewProjectFeatureModel: ObservableObject { ) } + // MARK: Tool fields + + /// The installations a tool field offers, with Core's verdict on each. + func toolChoices(for field: ProjectGeneratorField) -> [ProjectGeneratorFormState.ToolChoice] { + formState?.field(field.id)?.installations ?? [] + } + + /// The homes a tool field's picker lists. Core leaves an optional field + /// empty when every installation is too old, so "" comes first then and + /// the picker shows that rather than a blank control. + func toolOptions(for field: ProjectGeneratorField) -> [String] { + let homes = toolChoices(for: field).map(\.home) + guard case .string(let chosen) = value(of: field), !chosen.isEmpty else { return [""] + homes } + return homes + } + + /// Whether the picker lets the user choose `home`: "no tool" and the + /// installations Core says are new enough. An older one stays listed so the + /// user sees why it is not used. + func isToolChoosable(_ home: String, for field: ProjectGeneratorField) -> Bool { + home.isEmpty || toolChoices(for: field).first { $0.home == home }?.meetsMinimum == true + } + + /// What to offer when a tool field found no installation because no enabled + /// plugin defines its tool: enable the plugin that does, or look for one. + /// Nil while listing, when installations exist, and when the defining + /// plugin is already enabled (the tool is then just not installed). + func toolNoticeAction(for field: ProjectGeneratorField) -> RequirementNotice.Action? { + guard !isListingTools, toolListingError(for: field) == nil, toolChoices(for: field).isEmpty, + let tool = selectedEntry?.tools.first(where: { $0.fieldID == field.id }), + !tool.providers.contains(where: \.enabled) else { return nil } + if let provider = tool.providers.first { + return .enable(PluginID(provider.pluginID), name: provider.pluginName) + } + // A third-party definition is `:`; the plugin is what the + // marketplace lists. + let query = tool.tool.split(separator: ":").first.map(String.init) ?? tool.tool + return .findInMarketplace(query) + } + + /// The distinct tools the entry's tool fields pick, in declaration order. + private func toolIDs(of entry: ProjectGeneratorEntry?) -> [String] { + var seen = Set() + return (entry?.generator.fields ?? []) + .filter { $0.kind == .tool } + .compactMap(\.tool) + .filter { seen.insert($0).inserted } + } + + /// Why the installations of the field's tool could not be looked up, or + /// nil when they were (or are still being) looked up. + func toolListingError(for field: ProjectGeneratorField) -> String? { + guard !isListingTools else { return nil } + return field.tool.flatMap { toolListingFailures[$0] } + } + + /// Looks the selected generator's tools up again after a failure. + func retryToolListing() { + listToolInstallations() + } + + /// Looks up the installations of the selected generator's tools and + /// re-evaluates the form with them. A newer request replaces an older one. + private func listToolInstallations() { + toolListing?.cancel() + let tools = toolIDs(of: selectedEntry) + guard !tools.isEmpty else { + isListingTools = false + return + } + isListingTools = true + toolListing = Task { [weak self] in + guard let self else { return } + do { + let found = try await environment.toolInstallations(tools) + guard !Task.isCancelled else { return } + for tool in tools { + listedTools[tool] = found[tool] ?? [] + toolListingFailures[tool] = nil + } + } catch { + guard !Task.isCancelled else { return } + // The tools stay unlisted, so the field says the lookup failed + // instead of "not installed", and Create waits for a retry. + // A tool listed before keeps that listing. + let message = String( + format: L10n.string("Could not look for installed tools: %@"), error.localizedDescription + ) + for tool in tools where listedTools[tool] == nil { toolListingFailures[tool] = message } + } + isListingTools = false + evaluate() + } + } + // MARK: Actions func enablePlugin(_ pluginID: PluginID) async { diff --git a/macos/Sources/Lithe/Application/Features/ToolchainSettingsFeatureModel.swift b/macos/Sources/Lithe/Application/Features/ToolchainSettingsFeatureModel.swift index 778d337d25..825e968b58 100644 --- a/macos/Sources/Lithe/Application/Features/ToolchainSettingsFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/ToolchainSettingsFeatureModel.swift @@ -91,6 +91,12 @@ final class ToolchainSettingsFeatureModel: ObservableObject { if touched { scheduleReload() } } + /// A plugin that applies without a restart can add or remove tool definitions, + /// so an open page rereads its snapshot. Nothing runs before the page first loads one. + func pluginsChanged() { + scheduleReload() + } + /// Bursts of file events collapse into one reload; the latest reload replaces earlier ones. private func scheduleReload() { guard snapshot != nil else { return } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+NewProject.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+NewProject.swift index 974461be38..16be16d282 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+NewProject.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+NewProject.swift @@ -1,3 +1,4 @@ +import Combine import Foundation import LitheCoreContracts import LitheModuleAPI @@ -31,7 +32,9 @@ extension AppModel { }, openMarketplace: { [weak self] query in self?.showPluginMarketplace(searching: query) }, openProject: { [weak self] project in self?.openCreatedProject(project) }, - store: services.store + store: services.store, + toolInstallations: { [weak self] tools in try await self?.services.projectToolInstallations?(tools) ?? [:] }, + pluginChanges: services.pluginRegistrationChanges ?? Empty().eraseToAnyPublisher() )) } @@ -61,6 +64,9 @@ extension AppModel { if let warning = project.gitWarning { showNotification(String(format: L10n.string("The project was created, but Git could not be set up: %@"), warning), logReference: notificationRejection(operation: "project_creation", reason: "git_setup_failed")) } + if let warning = project.toolPinWarning { + showNotification(String(format: L10n.string("The project was created, but the tool you chose could not be saved for it: %@"), warning), logReference: notificationRejection(operation: "project_creation", reason: "tool_pin_failed")) + } openProject(project.url) } } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index 4ef06dfd33..bf141c3273 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -614,10 +614,12 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { guard let self, let root = self.workspaceURL else { return } self.searchFeatureIfActive?.invalidateIndex(at: root, visibilityRules: self.settings.searchEverywhereVisibilityRules.searchRules) } - // The language catalog follows plugins that apply without a restart, so - // a debugger installed now is offered now. + // The language catalog and the toolchain page follow plugins that apply + // without a restart, so a debugger or tool definition installed now is + // offered now. livePluginObservation = services.pluginRegistrationChanges?.sink { [weak self] in self?.languageToolingFeature.refreshCatalog() + self?.toolchainSettingsFeature.pluginsChanged() } suggestDatabasePluginIfNeeded() detectedAIConfigurations = loadAIConfigurations() diff --git a/macos/Sources/Lithe/Models/ProjectGeneration/ProjectGenerationModels.swift b/macos/Sources/Lithe/Models/ProjectGeneration/ProjectGenerationModels.swift index 49ecb57785..7dca658cd4 100644 --- a/macos/Sources/Lithe/Models/ProjectGeneration/ProjectGenerationModels.swift +++ b/macos/Sources/Lithe/Models/ProjectGeneration/ProjectGenerationModels.swift @@ -32,6 +32,8 @@ enum ProjectGeneratorSection: String, Decodable, Sendable { struct ProjectGeneratorField: Decodable, Equatable, Sendable, Identifiable { enum Kind: String, Decodable, Sendable { case text, `enum`, boolean, multiSelect, runtime + /// An installation of the tool `tool` names (projectGenerators 2). + case tool } enum Presentation: String, Decodable, Sendable { @@ -54,11 +56,13 @@ struct ProjectGeneratorField: Decodable, Equatable, Sendable, Identifiable { let placeholder: String? let optional: Bool let runtime: String? + /// The tool definition a `tool` field picks an installation of. + let tool: String? let minimumVersion: String? private enum CodingKeys: String, CodingKey { case id, kind = "type", title, description, advanced, options, presentation, placeholder, optional - case runtime, minimumVersion + case runtime, tool, minimumVersion } init(from decoder: Decoder) throws { @@ -73,6 +77,7 @@ struct ProjectGeneratorField: Decodable, Equatable, Sendable, Identifiable { placeholder = try values.decodeIfPresent(String.self, forKey: .placeholder) optional = try values.decodeIfPresent(Bool.self, forKey: .optional) ?? false runtime = try values.decodeIfPresent(String.self, forKey: .runtime) + tool = try values.decodeIfPresent(String.self, forKey: .tool) minimumVersion = try values.decodeIfPresent(String.self, forKey: .minimumVersion) } } @@ -115,6 +120,14 @@ struct ProjectGeneratorRequirement: Decodable, Equatable, Sendable { } } +/// The plugins that declare the tool one `tool` field selects. +struct ProjectToolProviders: Decodable, Equatable, Sendable { + let fieldID: String + /// Tool definition ID, as the generator wrote it. + let tool: String + let providers: [ProjectRequirementProvider] +} + /// One entry of `project.generatorCatalog`. struct ProjectGeneratorEntry: Decodable, Equatable, Sendable, Identifiable { struct Generator: Decodable, Equatable, Sendable { @@ -145,6 +158,8 @@ struct ProjectGeneratorEntry: Decodable, Equatable, Sendable, Identifiable { let pluginEnabled: Bool let generator: Generator let requirements: [ProjectGeneratorRequirement] + /// One element per `tool` field of the generator. + let tools: [ProjectToolProviders] let usesNetwork: Bool var id: String { "\(pluginID)/\(generator.id)" } @@ -171,6 +186,7 @@ struct CoreRejection: Decodable, Equatable, Sendable, Error { /// Why Rust Core does not accept a value. Hosts localize by code. enum ProjectFieldProblem: String, Decodable, Equatable, Sendable { case required, tooLong, invalidFormat, unsupportedValue, runtimeTooOld, versionTooHigh + case toolTooOld } /// A selected runtime installation of a runtime field. @@ -179,6 +195,14 @@ struct ProjectRuntimeSelection: Encodable, Equatable, Sendable { let version: String } +/// An installation of a tool the host found, offered to a `tool` field. Core +/// accepts only a `home` that is in this list. +struct ProjectToolInstallation: Codable, Equatable, Sendable { + let home: String + let executable: String + let version: String +} + /// What the user entered, sent to `project.generatorForm` and /// `project.generationPlan`. struct ProjectGeneratorFormInput: Encodable, Equatable, Sendable { @@ -187,9 +211,12 @@ struct ProjectGeneratorFormInput: Encodable, Equatable, Sendable { /// Text fields the user typed in; the others follow their defaults. var edited: Set = [] var runtimes: [String: ProjectRuntimeSelection] = [:] + /// The installations found for each `tool` field, by field ID. A tool + /// field's entry in `values` is the `home` of the one the user chose. + var tools: [String: [ProjectToolInstallation]] = [:] private enum CodingKeys: String, CodingKey { - case projectName, values, edited, runtimes + case projectName, values, edited, runtimes, tools } func encode(to encoder: Encoder) throws { @@ -198,6 +225,7 @@ struct ProjectGeneratorFormInput: Encodable, Equatable, Sendable { try container.encode(values, forKey: .values) try container.encode(edited.sorted(), forKey: .edited) try container.encode(runtimes, forKey: .runtimes) + try container.encode(tools, forKey: .tools) } } @@ -207,11 +235,34 @@ struct ProjectGeneratorFormState: Decodable, Equatable, Sendable { let problem: ProjectFieldProblem? } + /// An installation a `tool` field offers, with Core's verdict on its version. + struct ToolChoice: Decodable, Equatable, Sendable { + let home: String + let executable: String + let version: String + let meetsMinimum: Bool + } + struct FieldState: Decodable, Equatable, Sendable { let id: String let visible: Bool let value: ToolingJSONValue let problem: ProjectFieldProblem? + /// Only `tool` fields list installations; Core omits the key otherwise. + let installations: [ToolChoice] + + private enum CodingKeys: String, CodingKey { + case id, visible, value, problem, installations + } + + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + id = try values.decode(String.self, forKey: .id) + visible = try values.decode(Bool.self, forKey: .visible) + value = try values.decode(ToolingJSONValue.self, forKey: .value) + problem = try values.decodeIfPresent(ProjectFieldProblem.self, forKey: .problem) + installations = try values.decodeIfPresent([ToolChoice].self, forKey: .installations) ?? [] + } } let projectName: NameState @@ -277,9 +328,21 @@ struct ProjectGenerationPlan: Decodable, Equatable, Sendable { let version: String } + /// The installation a `tool` field chose, to record in the project's tool + /// pins once the project exists. + struct ToolPin: Decodable, Equatable, Sendable { + let field: String + /// Tool definition ID, as the generator wrote it. + let tool: String + let pinAs: String + let home: String + let executable: String + } + let projectName: String let steps: [Step] let openFiles: [String] let runtimes: [Runtime] + let toolPins: [ToolPin] let usesNetwork: Bool } diff --git a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index e3fd665dec..b7eb189cab 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -1293,6 +1293,25 @@ final class MacServiceContainer { }, servers: MacRemoteServerBundleLocator.locate() ) + let installedPlugins = pluginStartup.registeredPlugins + // The plugins whose declarations apply right now. Ones registered at + // launch follow the plugin manager's enablement; ones that apply + // without a restart (generators, tool definitions) follow the live + // registry. It is read on every call, so a plugin installed, enabled or + // removed from the UI shows up in the next list at once. + let pluginsInEffect: @MainActor @Sendable () -> [(package: InstalledPluginPackage, isEnabled: Bool)] = { [pluginManager] in + let enabled = Set(pluginManager.snapshots.filter(\.isEnabled).map(\.id)) + let registered = installedPlugins.map { (package: $0, isEnabled: enabled.contains($0.descriptor.id)) } + let live = liveSnapshotBox.snapshot.entries.values + .sorted { $0.package.manifest.id < $1.package.manifest.id } + .map { (package: $0.package, isEnabled: $0.isEnabled) } + return registered + live + } + let toolchainPreferences = MacToolchainPreferencesStore(core: rustCore, storage: fileStorage, + immutableRoots: { [pluginPackageStore] in + [Bundle.main.bundleURL] + pluginPackageStore.runtimeReadOnlyDirectories + + pluginsInEffect().map(\.package.packageURL) + }) // New Project window. Plugin packages are read-only; projects are // written into a staging directory inside the folder the user chose. services.projectGeneration = ProjectGenerationService( @@ -1309,21 +1328,22 @@ final class MacServiceContainer { }, recordJavaHome: { root, home in try await runtimeService.presetJavaHome(home, for: root) + }, + saveToolPin: { root, pin in + try await MacProjectToolPins.save(pin, in: root, using: toolchainPreferences) } ) - let installedPlugins = pluginStartup.registeredPlugins services.loginShellEnvironmentInspector = MacLoginShellEnvironment.shared - let toolchainSources: @MainActor @Sendable () -> [ToolchainSettingsPluginSource] = { [pluginManager] in - let enabled = Set(pluginManager.snapshots.filter(\.isEnabled).map(\.id)) - return installedPlugins.map { plugin in + let toolchainSources: @MainActor @Sendable () -> [ToolchainSettingsPluginSource] = { + pluginsInEffect().map { plugin in ToolchainSettingsPluginSource( - pluginID: plugin.descriptor.id.rawValue, - name: plugin.manifest.displayName, - manifest: plugin.descriptor.normalizedManifest, - isEnabled: enabled.contains(plugin.descriptor.id), + pluginID: plugin.package.descriptor.id.rawValue, + name: plugin.package.manifest.displayName, + manifest: plugin.package.descriptor.normalizedManifest, + isEnabled: plugin.isEnabled, // Short tool IDs need an official catalog entry installed through a verified channel. - verifiedOfficial: OfficialPluginCatalog.trustPolicy(for: plugin.descriptor.id) != nil - && [.bundled, .marketplace].contains(plugin.installation.origin) + verifiedOfficial: OfficialPluginCatalog.trustPolicy(for: plugin.package.descriptor.id) != nil + && [.bundled, .marketplace].contains(plugin.package.installation.origin) ) } } @@ -1364,20 +1384,20 @@ final class MacServiceContainer { discover: { request in await toolchainDiscovery.discover(request) } ) services.toolchainInputWatcher = MacToolchainInputWatcher() - services.toolchainPreferences = MacToolchainPreferencesStore(core: rustCore, storage: fileStorage, - immutableRoots: { [pluginPackageStore] in - [Bundle.main.bundleURL] + pluginPackageStore.runtimeReadOnlyDirectories + installedPlugins.map(\.packageURL) - }) - services.projectGeneratorSources = { [pluginManager] in - // Enablement is read on every call, so a plugin enabled from the - // window shows its entries and satisfies requirements at once. - let enabled = Set(pluginManager.snapshots.filter(\.isEnabled).map(\.id)) - return installedPlugins.map { plugin in + services.toolchainPreferences = toolchainPreferences + services.projectToolInstallations = { tools in + try await MacProjectToolInstallations( + discover: { await toolchainDiscovery.discover($0) }, + report: { logSink?.emit($0) } + ).installations(of: tools, sources: toolchainSources()) + } + services.projectGeneratorSources = { + pluginsInEffect().map { plugin in ProjectGeneratorPluginSource( - pluginID: plugin.descriptor.id, - manifest: plugin.descriptor.normalizedManifest, - packageURL: plugin.packageURL, - isEnabled: enabled.contains(plugin.descriptor.id) + pluginID: plugin.package.descriptor.id, + manifest: plugin.package.descriptor.normalizedManifest, + packageURL: plugin.package.packageURL, + isEnabled: plugin.isEnabled ) } } diff --git a/macos/Sources/Lithe/Platform/MacOS/ProjectGeneration/MacProjectToolInstallations.swift b/macos/Sources/Lithe/Platform/MacOS/ProjectGeneration/MacProjectToolInstallations.swift new file mode 100644 index 0000000000..e993f0366f --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/ProjectGeneration/MacProjectToolInstallations.swift @@ -0,0 +1,82 @@ +import Foundation +import LitheModuleAPI + +// Note: 新建项目向导与项目生成器见 .agents/notes/implemented/feature/2026-10-23-plugin-project-generators.md + +/// Finds installations of tools for the New Project window, where there is no +/// project yet to resolve them against. +/// +/// It runs the toolchain discovery the settings pages use, with an empty +/// scratch directory standing in for the workspace. Discovery needs a real +/// directory because Core refuses homes inside it; an empty one matches +/// nothing, so only machine-wide installations are found. The directory lives +/// in the temporary directory and is removed afterwards, so nothing is written +/// to a plugin package, the app bundle, or a user workspace. +struct MacProjectToolInstallations: Sendable { + private let discover: @Sendable (MacToolchainDiscovery.Request) async -> MacToolchainDiscovery.Result + private let environment: @Sendable () -> [String: String] + private let scratchParent: URL + private let report: @Sendable (any LogEvent) -> Void + + init( + discover: @escaping @Sendable (MacToolchainDiscovery.Request) async -> MacToolchainDiscovery.Result, + environment: @escaping @Sendable () -> [String: String] = { + MacLoginShellEnvironment.shared.merged(ProcessInfo.processInfo.environment) + }, + scratchParent: URL = FileManager.default.temporaryDirectory, + report: @escaping @Sendable (any LogEvent) -> Void = { _ in } + ) { + self.discover = discover + self.environment = environment + self.scratchParent = scratchParent + self.report = report + } + + /// The installations of each tool, best first. A tool no enabled plugin + /// defines, or one with nothing installed, maps to an empty list. + /// + /// Throws when discovery could not run because the scratch directory could + /// not be created, so the window does not mistake that for "not installed". + func installations( + of tools: [String], sources: [ToolchainSettingsPluginSource] + ) async throws -> [String: [ProjectToolInstallation]] { + let enabled = sources.filter(\.isEnabled) + guard !tools.isEmpty, !enabled.isEmpty else { return [:] } + let scratch = try makeScratchDirectory() + defer { removeScratchDirectory(scratch) } + + let request = MacToolchainDiscovery.Request( + workspace: scratch, sources: enabled, environment: environment() + ) + let result = await discover(request) + + var found: [String: [ProjectToolInstallation]] = [:] + for tool in tools { + found[tool] = (result.detections[tool]?.installations ?? []).map { + ProjectToolInstallation(home: $0.home, executable: $0.executable, version: $0.version) + } + } + return found + } + + private func makeScratchDirectory() throws -> URL { + let directory = scratchParent.appendingPathComponent( + "lithe-new-project-tools-\(UUID().uuidString)", isDirectory: true + ) + do { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false) + } catch { + report(AppStorageMaintenanceFailed(error: error, stage: "new-project-tools-scratch-create")) + throw error + } + return directory + } + + /// Discovery only reads the directory, so one left behind is empty and the + /// listing it served is still right: the failure is recorded, not raised. + private func removeScratchDirectory(_ directory: URL) { + do { try FileManager.default.removeItem(at: directory) } + catch let error as CocoaError where error.code == .fileNoSuchFile { /* Already gone; nothing is left behind. */ } + catch { report(AppStorageMaintenanceFailed(error: error, stage: "new-project-tools-scratch-remove")) } + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/ProjectGeneration/MacProjectToolPins.swift b/macos/Sources/Lithe/Platform/MacOS/ProjectGeneration/MacProjectToolPins.swift new file mode 100644 index 0000000000..d7cd1abdcc --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/ProjectGeneration/MacProjectToolPins.swift @@ -0,0 +1,29 @@ +import Foundation +import LitheCoreContracts +import LitheModuleAPI + +// Note: 新建项目向导与项目生成器见 .agents/notes/implemented/feature/2026-10-23-plugin-project-generators.md + +/// Records the installation a `tool` field chose in the new project's tool +/// pins, the same document the Toolchains settings page edits. +/// +/// Nothing new is stored: the pin is the workspace-level default of that tool in +/// `.lithe/run/local.json`, so the project resolves the tool the way the user +/// picked it from the first open, and the settings page shows and changes it. +@MainActor +enum MacProjectToolPins { + /// Writes `pin` into `project`. It reads the document first and saves + /// against that revision, so a file somebody else changed in between is + /// reported as a conflict and never overwritten. + static func save( + _ pin: ProjectGenerationPlan.ToolPin, + in project: URL, + using store: any ToolchainPreferencesStoring + ) async throws { + let snapshot = try await store.load(workspace: project) + // `pinAs: "project"` is the only pin kind a generator can ask for, and + // it is the workspace default: directory "." with no consumer. + let address = ToolchainPreferenceAddress(directory: ".", consumer: nil, tool: pin.tool) + _ = try await store.save(.object(["home": .string(pin.home)]), at: address, expecting: snapshot) + } +} diff --git a/macos/Sources/Lithe/Services/ProjectGeneration/ProjectGenerationService.swift b/macos/Sources/Lithe/Services/ProjectGeneration/ProjectGenerationService.swift index 0f3b72809f..dd52e62476 100644 --- a/macos/Sources/Lithe/Services/ProjectGeneration/ProjectGenerationService.swift +++ b/macos/Sources/Lithe/Services/ProjectGeneration/ProjectGenerationService.swift @@ -12,6 +12,10 @@ struct CreatedProject: Equatable, Sendable { /// Set when the project exists but its Git repository could not be /// created; the project is still opened. let gitWarning: String? + /// Set when the project exists but the tool installations its generator + /// chose could not be saved as the project's pins; the project is still + /// opened and resolves those tools as it would without a pin. + var toolPinWarning: String? = nil } /// Runs what Rust Core planned for the New Project window. Every step @@ -24,21 +28,26 @@ final class ProjectGenerationService: Sendable { private let platform: any ProjectGenerationPlatform private let initializeGitRepository: @Sendable (URL) -> String? private let recordJavaHome: @Sendable (URL, String) async throws -> Void + private let saveToolPin: @Sendable (URL, ProjectGenerationPlan.ToolPin) async throws -> Void /// - Parameters: /// - initializeGitRepository: Creates a repository at the URL and /// returns an error message, or nil on success. /// - recordJavaHome: Stores a JDK as the new project's runtime. + /// - saveToolPin: Stores the installation a `tool` field chose in the + /// project at the URL, where every later tool resolution reads it. init( planner: any ProjectGeneratorPlanning, platform: any ProjectGenerationPlatform, initializeGitRepository: @escaping @Sendable (URL) -> String?, - recordJavaHome: @escaping @Sendable (URL, String) async throws -> Void + recordJavaHome: @escaping @Sendable (URL, String) async throws -> Void, + saveToolPin: @escaping @Sendable (URL, ProjectGenerationPlan.ToolPin) async throws -> Void = { _, _ in } ) { self.planner = planner self.platform = platform self.initializeGitRepository = initializeGitRepository self.recordJavaHome = recordJavaHome + self.saveToolPin = saveToolPin } /// The host's own empty project: a directory, optionally with Git. @@ -100,13 +109,31 @@ final class ProjectGenerationService: Sendable { for runtime in plan.runtimes where runtime.runtime == "java" { try await recordJavaHome(destination, runtime.home) } + let toolPinWarning = await pinTools(of: plan, in: destination) return CreatedProject( url: destination, openFiles: plan.openFiles.map { destination.appendingPathComponent($0) }, - gitWarning: createGitRepository ? initializeGitRepository(destination) : nil + gitWarning: createGitRepository ? initializeGitRepository(destination) : nil, + toolPinWarning: toolPinWarning ) } + /// Records the chosen tool installations in the new project. The project is + /// complete by now, so a failure is reported next to it instead of undoing + /// it; the user can pin the tool later in the project's settings. + /// - Returns: The first failure's message, or nil when every pin was saved. + private func pinTools(of plan: ProjectGenerationPlan, in destination: URL) async -> String? { + var firstFailure: String? + for pin in plan.toolPins { + do { + try await saveToolPin(destination, pin) + } catch { + firstFailure = firstFailure ?? error.localizedDescription + } + } + return firstFailure + } + private func run( _ step: ProjectGenerationPlan.Step, in staging: URL, @@ -152,6 +179,7 @@ final class ProjectGenerationService: Sendable { case .invalidFormat: L10n.string("The value is not in the expected format.") case .unsupportedValue: L10n.string("Choose one of the offered values.") case .runtimeTooOld: L10n.string("This runtime is older than the generator requires.") + case .toolTooOld: L10n.string("This installation is older than the generator requires.") case .versionTooHigh: L10n.string("This version is newer than the selected runtime supports.") } } diff --git a/macos/Sources/Lithe/Views/Components/LitheSettingsControls.swift b/macos/Sources/Lithe/Views/Components/LitheSettingsControls.swift index 1ecf4f4f4a..fa8ef01c6e 100644 --- a/macos/Sources/Lithe/Views/Components/LitheSettingsControls.swift +++ b/macos/Sources/Lithe/Views/Components/LitheSettingsControls.swift @@ -388,6 +388,8 @@ struct LitheSettingsSelect: View { private let expandsToFitOptions: Bool private let isAvailable: (Value) -> Bool private let onUnavailableSelection: ((Value) -> Void)? + /// Why an unavailable option cannot be chosen, shown when hovering it. + private let unavailableHelp: LocalizedStringKey /// When set, the popup shows a search field above the rows and filters the /// options as the user types. Existing callers leave it nil and keep the /// plain, unfiltered list. @@ -408,6 +410,7 @@ struct LitheSettingsSelect: View { expandsToFitOptions: Bool = false, isAvailable: @escaping (Value) -> Bool = { _ in true }, onUnavailableSelection: ((Value) -> Void)? = nil, + unavailableHelp: LocalizedStringKey = "Shell is not available at this path", searchPrompt: LocalizedStringKey? = nil, searchText: ((Value) -> String)? = nil ) { @@ -420,6 +423,7 @@ struct LitheSettingsSelect: View { self.expandsToFitOptions = expandsToFitOptions self.isAvailable = isAvailable self.onUnavailableSelection = onUnavailableSelection + self.unavailableHelp = unavailableHelp self.searchPrompt = searchPrompt self.searchText = searchText ?? title } @@ -494,6 +498,7 @@ struct LitheSettingsSelect: View { localizesTitles: localizesTitles, expandsToFitOptions: expandsToFitOptions, isAvailable: isAvailable, + unavailableHelp: unavailableHelp, searchPrompt: searchPrompt ) let popupHeight = searchPrompt == nil @@ -694,6 +699,7 @@ private struct LitheSettingsSelectPopupContent: View { let localizesTitles: Bool let expandsToFitOptions: Bool let isAvailable: (Value) -> Bool + let unavailableHelp: LocalizedStringKey let searchPrompt: LocalizedStringKey? var body: some View { @@ -773,7 +779,7 @@ private struct LitheSettingsSelectPopupContent: View { if hovering { state.highlightedIndex = row } } .id(row) - .help(isAvailable(option) ? "" : "Shell is not available at this path") + .help(isAvailable(option) ? "" : unavailableHelp) } } .padding(SettingsSelectMetrics.popupPadding) diff --git a/macos/Sources/Lithe/Views/Workspace/NewProjectView.swift b/macos/Sources/Lithe/Views/Workspace/NewProjectView.swift index faa286e974..6f2f5a71b4 100644 --- a/macos/Sources/Lithe/Views/Workspace/NewProjectView.swift +++ b/macos/Sources/Lithe/Views/Workspace/NewProjectView.swift @@ -347,6 +347,8 @@ struct NewProjectView: View { ) { feature.setValue(.array($0.map(ToolingJSONValue.string)), for: field) } case .runtime: runtimeControl(field) + case .tool: + toolControl(field) case .boolean: EmptyView() } @@ -381,6 +383,90 @@ struct NewProjectView: View { } } + /// Picks one installation of the tool the field names. Core marks the one + /// the form uses and which are new enough; an older one is listed but + /// cannot be chosen. When none is found the field says why and what to do. + @ViewBuilder private func toolControl(_ field: ProjectGeneratorField) -> some View { + let choices = feature.toolChoices(for: field) + if let error = feature.toolListingError(for: field) { + toolListingFailure(error) + } else if !choices.isEmpty { + LitheSettingsSelect( + selection: Binding( + get: { feature.value(of: field).stringValue }, + set: { feature.setValue(.string($0), for: field) } + ), + options: feature.toolOptions(for: field), + width: 420, + accessibilityLabel: field.title, + title: { home in + if home.isEmpty { return L10n.string("None") } + return choices.first(where: { $0.home == home }).map(Self.toolChoiceTitle) ?? home + }, + localizesTitles: false, + isAvailable: { feature.isToolChoosable($0, for: field) }, + unavailableHelp: "This installation is older than the generator requires." + ) + } else if feature.isListingTools { + HStack(spacing: 6) { + ProgressView().controlSize(.small) + Text(LocalizedStringKey("Looking for installed tools…")) + .foregroundStyle(LitheTheme.secondaryText) + } + } else { + toolMissing(field) + } + } + + /// No installation was found. Without a plugin that defines the tool, the + /// notice offers to enable or find one; otherwise the tool is just absent. + @ViewBuilder private func toolMissing(_ field: ProjectGeneratorField) -> some View { + switch feature.toolNoticeAction(for: field) { + case .enable(let pluginID, let name): + pluginNotice( + Text(String(format: L10n.string("%@ is needed for this project but %@ is disabled."), field.title, name)), + actionTitle: "Enable", + pluginID: pluginID + ) + case .findInMarketplace(let query): + HStack(spacing: 8) { + Image(systemName: "info.circle") + .foregroundStyle(LitheTheme.secondaryText) + Text(String(format: L10n.string("No installed plugin supports %@ yet."), field.title)) + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 8) + Button(LocalizedStringKey("Find in Marketplace")) { feature.openMarketplace(searching: query) } + .buttonStyle(LitheActionButtonStyle()) + .fixedSize() + } + .modifier(NoticeChrome()) + case nil: + Text(LocalizedStringKey("No installation of this tool was found.")) + .foregroundStyle(LitheTheme.secondaryText) + } + } + + /// Discovery could not run, which is not the same as nothing installed. + private func toolListingFailure(_ message: String) -> some View { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(LitheTheme.error) + Text(verbatim: message) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + Spacer(minLength: 8) + Button(LocalizedStringKey("Try Again")) { feature.retryToolListing() } + .buttonStyle(LitheActionButtonStyle()) + .fixedSize() + } + .modifier(NoticeChrome()) + } + + private static func toolChoiceTitle(_ choice: ProjectGeneratorFormState.ToolChoice) -> String { + let title = "\(choice.version) — \((choice.home as NSString).abbreviatingWithTildeInPath)" + return choice.meetsMinimum ? title : String(format: L10n.string("%@ (older than required)"), title) + } + @ViewBuilder private var progress: some View { if let error = feature.errorMessage { notice(error, systemImage: "exclamationmark.triangle.fill", color: LitheTheme.error) diff --git a/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift b/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift index b39158a566..5437d5c478 100644 --- a/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift +++ b/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift @@ -898,15 +898,22 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { } /// Whether the plugin can take effect in a running session. That holds when - /// debug adapters and language grammars are its only contributions: the - /// host reads adapters on demand when a debug session starts and hands - /// grammars to open editors when the registry changes, so the module graph, + /// debug adapters, language grammars, project generators and tool + /// definitions are its only contributions: the host reads adapters on demand + /// when a debug session starts, hands grammars to open editors when the + /// registry changes, lists generators when the New Project window loads, and + /// reads tool definitions each time a tool is resolved, so the module graph, /// the language-server and run/test modules, backend processes and UI /// composition built at launch do not change. Anything else waits for the /// next launch. public var canActivateWithoutRestart: Bool { let modules = pluginManifest.modules - let live: Set = [.debugAdaptersExtension(id.rawValue), .grammarsExtension(id.rawValue)] + let live: Set = [ + .debugAdaptersExtension(id.rawValue), + .grammarsExtension(id.rawValue), + .projectGeneratorsExtension(id.rawValue), + .toolchainsExtension(id.rawValue), + ] return !modules.isEmpty && !declaresBackend && modules.allSatisfy { live.contains($0.manifest.id) && $0.contributions.isEmpty } diff --git a/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift b/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift index 9131612bac..9c873e18b1 100644 --- a/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift +++ b/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift @@ -527,7 +527,7 @@ public struct PluginManifest: Equatable, Codable, Sendable { "executionProviders": [1], "languageServers": [1, 2], "languages": [1, 2], - "projectGenerators": [1], + "projectGenerators": [1, 2], "serverProcess": [1], "settings": [1, 2], "toolDefinitions": [1], diff --git a/macos/Tests/LitheTests/PluginLiveActivationTests.swift b/macos/Tests/LitheTests/PluginLiveActivationTests.swift index 943f8742d0..a64eb2e524 100644 --- a/macos/Tests/LitheTests/PluginLiveActivationTests.swift +++ b/macos/Tests/LitheTests/PluginLiveActivationTests.swift @@ -68,13 +68,32 @@ struct PluginLiveActivationTests { @Test(arguments: [ "languages/language-packaged-server", "languages/language-system-command", - "build-providers/build-provider", "vcs/vcs-provider", "manifest/hello-lithe", - "project-generators/project-generator" + "build-providers/build-provider", "vcs/vcs-provider", "manifest/hello-lithe" ]) func pluginsWithOtherContributionsStillNeedARestart(_ fixture: String) throws { - let url = DeclarativePluginTestSupport.repositoryRoot - .appendingPathComponent("shared/contracts/plugins/v1/fixtures/\(fixture).plugin.json") - let descriptor = try DeclarativePluginTestSupport.operations.validate(manifest: Data(contentsOf: url)) + let descriptor = try Self.fixtureDescriptor(fixture) + #expect(!descriptor.canActivateWithoutRestart) + } + + /// Generators are listed when the New Project window loads, and tool + /// definitions are read each time a tool is resolved, so neither needs the + /// module graph built at launch. + @Test(arguments: [ + "project-generators/project-generator", "project-generators/project-generator-tool-field", + "toolchains/tool-definition" + ]) + func generatorsAndToolDefinitionsActivateWithoutARestart(_ fixture: String) throws { + let descriptor = try Self.fixtureDescriptor(fixture) + #expect(!descriptor.declaresBackend) + #expect(descriptor.canActivateWithoutRestart) + } + + /// A toolchain provider is a backend process, which only starts with the + /// module graph. + @Test + func aToolchainProviderStillNeedsARestart() throws { + let descriptor = try Self.fixtureDescriptor("toolchains/toolchain-provider") + #expect(descriptor.declaresBackend) #expect(!descriptor.canActivateWithoutRestart) } @@ -397,6 +416,39 @@ struct PluginLiveActivationTests { #expect(snapshot.isEnabled) } + @Test + func installingAGeneratorPluginActivatesItAtOnce() async throws { + let fixture = try LiveFixture(acceptance: .startupLoader) + defer { fixture.cleanUp() } + let manager = fixture.makeManager() + let source = try fixture.writeSource(Self.generatorManifest(), name: "generator") + + let arrived = try await fixture.awaitChange { try manager.installPackage(at: source) } + + #expect(arrived) + // The New Project window lists it from this registry, with no restart. + let entry = try #require(fixture.registry.snapshot.entries[Self.generatorPluginID]) + #expect(entry.isEnabled) + let snapshot = try #require(manager.snapshots.first { $0.id == Self.generatorPluginID }) + #expect(snapshot.installationStatus == .installed) + #expect(!snapshot.requiresRestart) + #expect(!snapshot.hasChangesPendingRestart) + } + + @Test + func uninstallingAGeneratorPluginRemovesItFromTheRegistryAtOnce() async throws { + let fixture = try LiveFixture(acceptance: .startupLoader) + defer { fixture.cleanUp() } + let manager = fixture.makeManager() + let source = try fixture.writeSource(Self.generatorManifest(), name: "generator") + _ = try await fixture.awaitChange { try manager.installPackage(at: source) } + + let removed = try await fixture.awaitChange { try await manager.uninstall(Self.generatorPluginID) } + + #expect(removed) + #expect(fixture.registry.snapshot.entries[Self.generatorPluginID] == nil) + } + @Test func installingAnyOtherPluginStillWaitsForARestart() throws { let fixture = try LiveFixture(acceptance: .startupLoader) @@ -493,6 +545,47 @@ struct PluginLiveActivationTests { // MARK: - Fixtures + private static let generatorPluginID = PluginID("dev.example.generator") + + private static func fixtureDescriptor(_ name: String) throws -> DeclarativePluginDescriptor { + let url = DeclarativePluginTestSupport.repositoryRoot + .appendingPathComponent("shared/contracts/plugins/v1/fixtures/\(name).plugin.json") + return try DeclarativePluginTestSupport.operations.validate(manifest: Data(contentsOf: url)) + } + + /// A generator-only plugin: one command step, no templates to ship. + private static func generatorManifest() -> [String: Any] { + [ + "schemaVersion": 2, + "id": generatorPluginID.rawValue, + "displayName": "Example Generator", + "version": "0.1.0", + "pluginAPIVersion": 3, + "requires": ["projectGenerators": 2], + "vendor": [ + "id": "dev.example", + "displayName": "Example", + "signatureRequirement": PluginSignatureRequirement.publisherPackage.rawValue + ], + "permissions": ["project.generate"], + "activationEvents": [], + "projectGenerators": [[ + "id": "app", + "displayName": "App", + "section": "generator", + "fields": [ + ["id": "sdk", "type": "tool", "title": "SDK", "tool": "dev.example.generator:sdk", "pinAs": "project"] + ], + "steps": [[ + "kind": "command", + "command": "example-init", + "arguments": ["--sdk", "${field.sdk.executable}"], + "mayUseNetwork": false + ]] + ]] + ] + } + private static func debugManifest( id: String = pluginID.rawValue, version: PluginVersion = PluginVersion(major: 0, minor: 1, patch: 0), diff --git a/macos/Tests/LitheTests/ProjectGenerationTests.swift b/macos/Tests/LitheTests/ProjectGenerationTests.swift index da9c43d3f2..f1e2ebe9da 100644 --- a/macos/Tests/LitheTests/ProjectGenerationTests.swift +++ b/macos/Tests/LitheTests/ProjectGenerationTests.swift @@ -1,3 +1,4 @@ +import Combine import Foundation import LitheCoreContracts import LitheModuleAPI @@ -8,6 +9,18 @@ import Testing @MainActor struct ProjectGenerationTests { private static let javaSupportDirectory = "JavaSupport" + /// The published projectGenerators 2 fixture: `python-app` asks for a + /// `python` tool of at least 3.11, `node-app` for an optional `node` tool. + private static let toolFieldPluginID = PluginID("dev.lithe.fixture.project-generator-tool-field") + /// The published fixture that defines a tool, for tests where a generator + /// points its tool field at a tool another plugin defines. + private static let toolDefinitionPluginID = PluginID("dev.lithe.fixture.java-support") + private static let python310 = ProjectToolInstallation( + home: "/opt/python-3.10", executable: "/opt/python-3.10/bin/python3", version: "3.10.14" + ) + private static let python312 = ProjectToolInstallation( + home: "/opt/python-3.12", executable: "/opt/python-3.12/bin/python3", version: "3.12.4" + ) /// Generator-only copy of Java Support's entries, so window tests can /// keep the template plugin and the language provider apart the way a /// community template plugin would be. @@ -319,6 +332,310 @@ struct ProjectGenerationTests { #expect(feature.value(of: artifact) == .string("billing-api")) } + // MARK: Tool fields: Rust Core + + @Test + func catalogNamesThePluginsThatDefineEachTool() throws { + let catalog = try Self.planner.catalog([try Self.toolFieldSource(enabled: true)]) + #expect(catalog.generators.map(\.generator.id) == ["python-app", "node-app"]) + // Nothing installed defines either tool. + #expect(catalog.generators[0].tools == [ProjectToolProviders(fieldID: "python", tool: "python", providers: [])]) + #expect(catalog.generators[1].tools == [ProjectToolProviders(fieldID: "node", tool: "node", providers: [])]) + } + + @Test + func toolFieldListsTheInstallationsAndFlagsOldOnes() throws { + let target = try Self.toolFieldTarget("python-app") + var input = ProjectGeneratorFormInput(projectName: "demo") + input.tools["python"] = [Self.python310, Self.python312] + + let form = try Self.planner.form(target, input: input) + let python = try #require(form.field("python")) + #expect(python.installations.map(\.home) == [Self.python310.home, Self.python312.home]) + #expect(python.installations.map(\.meetsMinimum) == [false, true]) + // Without a choice the field holds the newest installation. + #expect(python.value == .string(Self.python312.home)) + #expect(form.valid) + + input.values["python"] = .string(Self.python310.home) + let old = try Self.planner.form(target, input: input) + #expect(old.field("python")?.problem == .toolTooOld) + #expect(!old.valid) + } + + @Test + func aToolFieldWithoutInstallationsIsRequiredUnlessOptional() throws { + let input = ProjectGeneratorFormInput(projectName: "demo") + + let required = try Self.planner.form(try Self.toolFieldTarget("python-app"), input: input) + #expect(required.field("python")?.problem == .required) + #expect(required.field("python")?.installations == []) + #expect(!required.valid) + + let optional = try Self.planner.form(try Self.toolFieldTarget("node-app"), input: input) + #expect(optional.field("node")?.problem == nil) + #expect(optional.valid) + } + + @Test + func theCommandsGetTheChosenInstallationAndItIsPinned() throws { + var input = ProjectGeneratorFormInput(projectName: "demo") + input.tools["python"] = [Self.python312] + + let plan = try Self.planner.plan(try Self.toolFieldTarget("python-app"), input: input) + + #expect(plan.toolPins == [ + ProjectGenerationPlan.ToolPin( + field: "python", tool: "python", pinAs: "project", + home: Self.python312.home, executable: Self.python312.executable + ) + ]) + let commands = plan.steps.compactMap { step -> ProjectGenerationPlan.Command? in + if case .command(let command) = step { return command } + return nil + } + let command = try #require(commands.first) + #expect(command.arguments == ["venv", "--python", Self.python312.executable]) + #expect(command.environment["PYTHON_HOME"] == Self.python312.home) + } + + @Test + func anOptionalToolWithoutInstallationIsNotPinned() throws { + let plan = try Self.planner.plan( + try Self.toolFieldTarget("node-app"), input: ProjectGeneratorFormInput(projectName: "demo") + ) + #expect(plan.toolPins.isEmpty) + } + + // MARK: Tool fields: creating projects + + @Test + func savesTheChosenToolInstallationInTheNewProject() async throws { + let directory = try TemporaryDirectory() + defer { directory.remove() } + let pins = ToolPinRecorder() + let runner = ScaffoldingRunner(creates: nil) + let service = Self.service(runner: runner, saveToolPin: { root, pin in + await pins.record(root: root, pin: pin) + }) + var input = ProjectGeneratorFormInput(projectName: "demo") + input.tools["python"] = [Self.python312] + + let project = try await service.create( + try Self.toolFieldTarget("python-app"), input: input, in: directory.url, createGitRepository: false + ) + + #expect(project.toolPinWarning == nil) + let saved = await pins.saved + #expect(saved.map(\.root) == [project.url.path]) + #expect(saved.map(\.pin.tool) == ["python"]) + #expect(saved.map(\.pin.home) == [Self.python312.home]) + // The step that uses the tool ran with the chosen installation. + let request = try #require(runner.requests.first) + #expect(request.arguments == ["venv", "--python", Self.python312.executable]) + #expect(request.environment["PYTHON_HOME"] == Self.python312.home) + } + + @Test + func aFailedPinIsReportedWithoutUndoingTheProject() async throws { + let directory = try TemporaryDirectory() + defer { directory.remove() } + let service = Self.service(saveToolPin: { _, _ in + throw ProjectGenerationError(message: "local.json is locked") + }) + var input = ProjectGeneratorFormInput(projectName: "demo") + input.tools["python"] = [Self.python312] + + let project = try await service.create( + try Self.toolFieldTarget("python-app"), input: input, in: directory.url, createGitRepository: false + ) + + #expect(project.toolPinWarning == "local.json is locked") + #expect(FileManager.default.fileExists(atPath: project.url.appendingPathComponent("README.md").path)) + } + + // MARK: Tool fields: window + + @Test + func aToolFieldOffersWhatTheHostFound() async throws { + let requests = ToolListingRequests() + let feature = NewProjectFeatureModel(environment: try Self.environment( + plugins: PluginStates(enabled: [Self.toolFieldPluginID]), + sources: [try Self.toolFieldSource(enabled: true)], + toolInstallations: { tools in + requests.record(tools) + return ["python": [Self.python310, Self.python312]] + } + )) + await feature.load() + let python = try #require(feature.selectedEntry?.generator.fields.first { $0.id == "python" }) + await Self.waitUntil("the python installations") { feature.toolChoices(for: python).count == 2 } + + #expect(requests.requested.first == ["python"]) + #expect(feature.toolChoices(for: python).map(\.meetsMinimum) == [false, true]) + #expect(feature.value(of: python) == .string(Self.python312.home)) + #expect(feature.canCreate) + // Both are listed; only the one new enough can be chosen. + #expect(feature.toolOptions(for: python) == [Self.python310.home, Self.python312.home]) + #expect(!feature.isToolChoosable(Self.python310.home, for: python)) + #expect(feature.isToolChoosable(Self.python312.home, for: python)) + + // The window does not offer an installation below the minimum; one set + // anyway is still judged by Core and blocks creating. + feature.setValue(.string(Self.python310.home), for: python) + #expect(feature.problem(of: python) == .toolTooOld) + #expect(!feature.canCreate) + } + + @Test + func anOptionalToolWithOnlyOldInstallationsOffersNone() async throws { + let node16 = ProjectToolInstallation(home: "/opt/node-16", executable: "/opt/node-16/bin/node", version: "16.20.2") + let feature = NewProjectFeatureModel(environment: try Self.environment( + plugins: PluginStates(enabled: [Self.toolFieldPluginID]), + sources: [try Self.toolFieldSource(enabled: true)], + toolInstallations: { _ in ["node": [node16]] } + )) + await feature.load() + let entry = try #require(feature.entries.first { $0.generator.id == "node-app" }) + feature.selection = .generator(entry.id) + let node = try #require(entry.generator.fields.first { $0.id == "node" }) + await Self.waitUntil("the node installations") { feature.toolChoices(for: node).count == 1 } + + // Node.js is optional and 16 is below 18, so the project goes without it. + #expect(feature.value(of: node) == .string("")) + #expect(feature.toolOptions(for: node) == ["", node16.home]) + #expect(feature.isToolChoosable("", for: node)) + #expect(!feature.isToolChoosable(node16.home, for: node)) + #expect(feature.problem(of: node) == nil) + #expect(feature.canCreate) + } + + @Test + func aToolNobodyDefinesSendsTheUserToTheMarketplace() async throws { + let feature = NewProjectFeatureModel(environment: try Self.environment( + plugins: PluginStates(enabled: [Self.toolFieldPluginID]), + sources: [try Self.toolFieldSource(enabled: true)] + )) + await feature.load() + let python = try #require(feature.selectedEntry?.generator.fields.first { $0.id == "python" }) + await Self.waitUntil("the tool lookup") { !feature.isListingTools } + + #expect(feature.toolChoices(for: python).isEmpty) + #expect(feature.toolNoticeAction(for: python) == .findInMarketplace("python")) + #expect(!feature.canCreate) + } + + @Test + func aDisabledPluginThatDefinesTheToolIsOfferedForEnabling() async throws { + let plugins = PluginStates(enabled: [Self.toolFieldPluginID]) + let feature = NewProjectFeatureModel(environment: try Self.environment( + plugins: plugins, + sources: [try Self.toolFieldSource(tool: "java", enabled: true), try Self.toolDefinitionSource()] + )) + await feature.load() + let field = try #require(feature.selectedEntry?.generator.fields.first { $0.id == "python" }) + await Self.waitUntil("the tool lookup") { !feature.isListingTools } + #expect(feature.toolNoticeAction(for: field) + == .enable(Self.toolDefinitionPluginID, name: "Tool definition fixture")) + + // Once the defining plugin is on, the tool is merely not installed. + plugins.enabled.insert(Self.toolDefinitionPluginID) + feature.reloadCatalog() + await Self.waitUntil("the tool lookup") { !feature.isListingTools } + #expect(feature.toolNoticeAction(for: field) == nil) + } + + @Test + func aPluginChangeReloadsTheEntriesAndLooksForToolsAgain() async throws { + let plugins = PluginStates(enabled: []) + let changes = PassthroughSubject() + let requests = ToolListingRequests() + let feature = NewProjectFeatureModel(environment: try Self.environment( + plugins: plugins, + sources: [try Self.toolFieldSource(enabled: true)], + toolInstallations: { tools in + requests.record(tools) + return [:] + }, + pluginChanges: changes.eraseToAnyPublisher() + )) + await feature.load() + await Self.waitUntil("the first tool lookup") { requests.requested.count == 1 } + #expect(feature.entries.first?.pluginEnabled == false) + + plugins.enabled.insert(Self.toolFieldPluginID) + changes.send() + await Self.mainQueueTurn() + + #expect(feature.entries.first?.pluginEnabled == true) + await Self.waitUntil("the second tool lookup") { requests.requested.count == 2 } + } + + @Test + func aPluginChangeDropsTheToolsListedForOtherEntries() async throws { + let plugins = PluginStates(enabled: [Self.toolFieldPluginID, Self.toolDefinitionPluginID]) + let changes = PassthroughSubject() + let requests = ToolListingRequests() + let feature = NewProjectFeatureModel(environment: try Self.environment( + plugins: plugins, + sources: [try Self.toolFieldSource(tool: "java", enabled: true), try Self.toolDefinitionSource()], + toolInstallations: { tools in + requests.record(tools) + // Like discovery, only an enabled definition finds anything. + let defined = plugins.enabled.contains(Self.toolDefinitionPluginID) + return Dictionary(uniqueKeysWithValues: tools.map { ($0, defined ? [Self.python312] : []) }) + }, + pluginChanges: changes.eraseToAnyPublisher() + )) + await feature.load() + let entry = try #require(feature.selectedEntry) + let field = try #require(entry.generator.fields.first { $0.id == "python" }) + await Self.waitUntil("the first tool lookup") { feature.toolChoices(for: field).count == 1 } + + // The definition goes away while another entry is selected. + feature.selection = .empty + plugins.enabled.remove(Self.toolDefinitionPluginID) + changes.send() + await Self.mainQueueTurn() + feature.selection = .generator(entry.id) + + // The installation the removed definition found is not offered again. + #expect(!feature.canCreate) + await Self.waitUntil("the tools to be looked up again") { + requests.requested.count == 2 && !feature.isListingTools + } + #expect(feature.toolChoices(for: field).isEmpty) + #expect(feature.toolNoticeAction(for: field) == .enable(Self.toolDefinitionPluginID, name: "Tool definition fixture")) + #expect(!feature.canCreate) + } + + @Test + func aFailedToolLookupIsShownAndCanBeRetried() async throws { + let lookup = ToolLookupOutcome() + let feature = NewProjectFeatureModel(environment: try Self.environment( + plugins: PluginStates(enabled: [Self.toolFieldPluginID]), + sources: [try Self.toolFieldSource(enabled: true)], + toolInstallations: { _ in + if lookup.fails { throw CocoaError(.fileWriteNoPermission) } + return ["python": [Self.python312]] + } + )) + await feature.load() + let python = try #require(feature.selectedEntry?.generator.fields.first { $0.id == "python" }) + await Self.waitUntil("the failed tool lookup") { !feature.isListingTools } + + // A lookup that did not run is not "nothing installed". + #expect(feature.toolListingError(for: python) != nil) + #expect(feature.toolNoticeAction(for: python) == nil) + #expect(!feature.canCreate) + + lookup.fails = false + feature.retryToolListing() + await Self.waitUntil("the retried tool lookup") { feature.toolChoices(for: python).count == 1 } + #expect(feature.toolListingError(for: python) == nil) + #expect(feature.canCreate) + } + // MARK: Helpers private static var planner: RustProjectGeneratorOperations { @@ -398,26 +715,100 @@ struct ProjectGenerationTests { private static func platform(runner: any BuildCommandRunning = ScaffoldingRunner(creates: nil)) -> MacProjectGenerationPlatform { MacProjectGenerationPlatform( environment: { $0.merging(["PATH": "/usr/bin:/bin"]) { override, _ in override } }, - executable: { $0 == "mvn" ? URL(fileURLWithPath: "/usr/local/bin/mvn") : nil }, + // The scaffolding tools the tests' generators run by name. + executable: { ["mvn", "uv"].contains($0) ? URL(fileURLWithPath: "/usr/local/bin/\($0)") : nil }, runner: runner ) } private static func service( runner: any BuildCommandRunning = ScaffoldingRunner(creates: nil), - recorder: JavaHomeRecorder = JavaHomeRecorder() + recorder: JavaHomeRecorder = JavaHomeRecorder(), + saveToolPin: @escaping @Sendable (URL, ProjectGenerationPlan.ToolPin) async throws -> Void = { _, _ in } ) -> ProjectGenerationService { ProjectGenerationService( planner: planner, platform: platform(runner: runner), initializeGitRepository: { _ in nil }, - recordJavaHome: { root, home in await recorder.record(root: root, home: home) } + recordJavaHome: { root, home in await recorder.record(root: root, home: home) }, + saveToolPin: saveToolPin ) } + // MARK: Tool field fixtures + + /// The project-generator fixtures. The tool-field manifest's + /// `templates/tool-field` source resolves against this directory. + private static var generatorFixturesURL: URL { + DeclarativePluginTestSupport.repositoryRoot + .appendingPathComponent("shared/contracts/plugins/v1/fixtures/project-generators", isDirectory: true) + } + + private static var toolchainFixturesURL: URL { + DeclarativePluginTestSupport.repositoryRoot + .appendingPathComponent("shared/contracts/plugins/v1/fixtures/toolchains", isDirectory: true) + } + + /// The tool-field fixture's `plugin.json`. `tool` points its `python` field + /// at another tool, to test fields whose tool a different plugin defines. + private static func toolFieldManifest(tool: String? = nil) throws -> Data { + let text = try String( + contentsOf: generatorFixturesURL.appendingPathComponent("project-generator-tool-field.plugin.json"), + encoding: .utf8 + ) + guard let tool else { return Data(text.utf8) } + return Data(text.replacingOccurrences(of: "\"tool\": \"python\"", with: "\"tool\": \"\(tool)\"").utf8) + } + + private static func toolFieldSource(tool: String? = nil, enabled: Bool) throws -> ProjectGeneratorPluginSource { + ProjectGeneratorPluginSource( + pluginID: toolFieldPluginID, manifest: try toolFieldManifest(tool: tool), + packageURL: generatorFixturesURL, isEnabled: enabled + ) + } + + private static func toolFieldTarget(_ generatorID: String) throws -> ProjectGeneratorTarget { + ProjectGeneratorTarget( + pluginID: toolFieldPluginID, generatorID: generatorID, + manifest: try toolFieldManifest(), packageURL: generatorFixturesURL + ) + } + + /// A disabled plugin that only defines a tool. + private static func toolDefinitionSource() throws -> ProjectGeneratorPluginSource { + ProjectGeneratorPluginSource( + pluginID: toolDefinitionPluginID, + manifest: try Data(contentsOf: toolchainFixturesURL.appendingPathComponent("tool-definition.plugin.json")), + packageURL: toolchainFixturesURL, isEnabled: false + ) + } + + /// Waits for `condition` with a two second deadline and records an issue + /// naming what never happened, instead of leaving a stuck test to the CI + /// timeout. + private static func waitUntil(_ what: String, _ condition: @MainActor () -> Bool) async { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(2)) + while clock.now < deadline { + if condition() { return } + await Task.yield() + } + if !condition() { Issue.record("Timed out after two seconds waiting for \(what).") } + } + + /// Returns after the work already queued on the main queue, such as a + /// Combine `receive(on: .main)` delivery sent just before. + private static func mainQueueTurn() async { + await withCheckedContinuation { continuation in + DispatchQueue.main.async { continuation.resume() } + } + } + private static func environment( plugins: PluginStates, - sources: [ProjectGeneratorPluginSource]? = nil + sources: [ProjectGeneratorPluginSource]? = nil, + toolInstallations: @escaping @MainActor ([String]) async throws -> [String: [ProjectToolInstallation]] = { _ in [:] }, + pluginChanges: AnyPublisher = Empty().eraseToAnyPublisher() ) throws -> NewProjectEnvironment { let split = try splitJavaSupport() let installed = sources ?? [split.templates, split.language] @@ -436,7 +827,9 @@ struct ProjectGenerationTests { setPluginEnabled: { _ = plugins.enabled.insert($0) }, openMarketplace: { _ in }, openProject: { _ in }, - store: InMemoryStore() + store: InMemoryStore(), + toolInstallations: toolInstallations, + pluginChanges: pluginChanges ) } @@ -507,6 +900,27 @@ private actor JavaHomeRecorder { } } +private struct SavedToolPin: Equatable, Sendable { + let root: String + let pin: ProjectGenerationPlan.ToolPin +} + +private actor ToolPinRecorder { + private(set) var saved: [SavedToolPin] = [] + + func record(root: URL, pin: ProjectGenerationPlan.ToolPin) { + saved.append(SavedToolPin(root: root.path, pin: pin)) + } +} + +/// The tools the window asked the host to look up, one entry per request. +@MainActor +private final class ToolListingRequests { + private(set) var requested: [[String]] = [] + + func record(_ tools: [String]) { requested.append(tools) } +} + @MainActor private final class PluginStates { var enabled: Set @@ -514,6 +928,11 @@ private final class PluginStates { init(enabled: Set) { self.enabled = enabled } } +/// Whether the stand-in tool lookup fails, switched by the test. +private final class ToolLookupOutcome { + var fails = true +} + /// Stands in for a scaffolding tool: records the request and creates one /// file in its working directory, as `mvn archetype:generate` creates the /// project in a subdirectory. diff --git a/macos/Tests/LitheTests/ProjectToolAdaptersTests.swift b/macos/Tests/LitheTests/ProjectToolAdaptersTests.swift new file mode 100644 index 0000000000..6628fbfed8 --- /dev/null +++ b/macos/Tests/LitheTests/ProjectToolAdaptersTests.swift @@ -0,0 +1,234 @@ +import Foundation +import LitheCoreContracts +import LitheModuleAPI +import Testing +@testable import Lithe + +/// The two macOS adapters behind a New Project `tool` field: finding the +/// installations to offer, and recording the chosen one in the new project. +@Suite("New project tool adapters") +@MainActor +struct ProjectToolAdaptersTests { + // The discovery stand-in below runs off the main actor and reads these. + nonisolated private static let python = ProjectToolInstallation( + home: "/opt/python-3.12", executable: "/opt/python-3.12/bin/python3", version: "3.12.4" + ) + + // MARK: Finding installations + + @Test + func discoversInAnEmptyScratchDirectoryAndRemovesIt() async throws { + let parent = try Self.temporaryDirectory() + defer { try? FileManager.default.removeItem(at: parent) } + let log = DiscoveryLog() + let finder = MacProjectToolInstallations( + discover: { request in + await log.record(request) + return Self.detection(of: "python", Self.python) + }, + environment: { ["PATH": "/usr/bin"] }, + scratchParent: parent + ) + + let found = try await finder.installations(of: ["python", "node"], sources: [Self.source(enabled: true)]) + + // A tool with nothing installed is listed with no installations. + #expect(found == ["python": [Self.python], "node": []]) + let request = try #require(await log.requests.first) + #expect(request.environment == ["PATH": "/usr/bin"]) + #expect(request.sources.map(\.pluginID) == ["dev.example.tools"]) + // Discovery ran in a new, empty directory below the given parent, ... + #expect(request.workspace.deletingLastPathComponent().standardizedFileURL == parent.standardizedFileURL) + #expect(await log.scratchDirectoriesWereEmpty == [true]) + // ... and nothing of it is left behind. + #expect(try FileManager.default.contentsOfDirectory(atPath: parent.path).isEmpty) + } + + @Test + func doesNotRunDiscoveryWithoutEnabledPluginsOrTools() async throws { + let parent = try Self.temporaryDirectory() + defer { try? FileManager.default.removeItem(at: parent) } + let log = DiscoveryLog() + let finder = MacProjectToolInstallations( + discover: { request in + await log.record(request) + return MacToolchainDiscovery.Result() + }, + scratchParent: parent + ) + + let withoutEnabledPlugin = try await finder.installations(of: ["python"], sources: [Self.source(enabled: false)]) + let withoutTools = try await finder.installations(of: [], sources: [Self.source(enabled: true)]) + + #expect(withoutEnabledPlugin.isEmpty) + #expect(withoutTools.isEmpty) + #expect(await log.requests.isEmpty) + #expect(try FileManager.default.contentsOfDirectory(atPath: parent.path).isEmpty) + } + + @Test + func reportsAndThrowsWhenTheScratchDirectoryCannotBeCreated() async throws { + let parent = try Self.temporaryDirectory() + defer { try? FileManager.default.removeItem(at: parent) } + let log = DiscoveryLog() + let events = TypedLogRecorder() + let finder = MacProjectToolInstallations( + discover: { request in + await log.record(request) + return Self.detection(of: "python", Self.python) + }, + // The parent does not exist, so the scratch directory cannot be made. + scratchParent: parent.appendingPathComponent("missing", isDirectory: true), + report: { events.emit($0) } + ) + + // Not running discovery is not the same as finding nothing installed. + await #expect(throws: (any Error).self) { + try await finder.installations(of: ["python"], sources: [Self.source(enabled: true)]) + } + #expect(await log.requests.isEmpty) + #expect(Self.stages(events) == ["new-project-tools-scratch-create"]) + } + + @Test + func reportsAScratchDirectoryItCouldNotRemove() async throws { + let parent = try Self.temporaryDirectory() + let locked = LockedValue(nil) + defer { + // Make the directory removable again before cleaning up. + if let directory = locked.value { + try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: directory.path) + } + try? FileManager.default.removeItem(at: parent) + } + let events = TypedLogRecorder() + let finder = MacProjectToolInstallations( + discover: { request in + // A read-only directory holding a file cannot be emptied. + let directory = request.workspace.appendingPathComponent("locked", isDirectory: true) + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false) + FileManager.default.createFile(atPath: directory.appendingPathComponent("file").path, contents: Data()) + try? FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: directory.path) + locked.value = directory + return Self.detection(of: "python", Self.python) + }, + scratchParent: parent, + report: { events.emit($0) } + ) + + let found = try await finder.installations(of: ["python"], sources: [Self.source(enabled: true)]) + + // The listing is still right; the leftover directory is recorded. + #expect(found == ["python": [Self.python]]) + #expect(Self.stages(events) == ["new-project-tools-scratch-remove"]) + } + + // MARK: Pinning + + @Test + func pinsTheChosenInstallationAsTheProjectDefault() async throws { + let project = try Self.temporaryDirectory() + defer { try? FileManager.default.removeItem(at: project) } + let store = Self.store() + let pin = Self.pin(tool: "dev.example.tools:python", Self.python) + + try await MacProjectToolPins.save(pin, in: project, using: store) + + let snapshot = try await store.load(workspace: project) + let address = ToolchainPreferenceAddress(directory: ".", consumer: nil, tool: pin.tool) + #expect(try store.inspect(address, snapshot: snapshot).fixed == .installation(home: Self.python.home)) + // It is the document the Toolchains settings page edits, not a new store. + #expect(FileManager.default.fileExists(atPath: project.appendingPathComponent(".lithe/run/local.json").path)) + } + + @Test + func pinningAnotherToolKeepsThePinsAlreadySaved() async throws { + let project = try Self.temporaryDirectory() + defer { try? FileManager.default.removeItem(at: project) } + let store = Self.store() + let node = ProjectToolInstallation(home: "/opt/node-22", executable: "/opt/node-22/bin/node", version: "22.11.0") + let pins = [Self.pin(tool: "dev.example.tools:python", Self.python), Self.pin(tool: "dev.example.tools:node", node)] + + for pin in pins { try await MacProjectToolPins.save(pin, in: project, using: store) } + + let snapshot = try await store.load(workspace: project) + for (pin, installation) in zip(pins, [Self.python, node]) { + let address = ToolchainPreferenceAddress(directory: ".", consumer: nil, tool: pin.tool) + #expect(try store.inspect(address, snapshot: snapshot).fixed == .installation(home: installation.home)) + } + } + + // MARK: Helpers + + private static func source(enabled: Bool) -> ToolchainSettingsPluginSource { + ToolchainSettingsPluginSource( + pluginID: "dev.example.tools", name: "Example tools", manifest: Data("{}".utf8), isEnabled: enabled + ) + } + + nonisolated private static func detection( + of tool: String, _ installation: ProjectToolInstallation + ) -> MacToolchainDiscovery.Result { + MacToolchainDiscovery.Result(detections: [ + tool: .init(installations: [ + .init( + home: installation.home, executable: installation.executable, + version: installation.version, provider: "host:system" + ) + ]) + ]) + } + + private static func pin(tool: String, _ installation: ProjectToolInstallation) -> ProjectGenerationPlan.ToolPin { + ProjectGenerationPlan.ToolPin( + field: "sdk", tool: tool, pinAs: "project", home: installation.home, executable: installation.executable + ) + } + + private static func store() -> MacToolchainPreferencesStore { + MacToolchainPreferencesStore(core: RustCoreBridge(), storage: MacFileStorage(), immutableRoots: { [] }) + } + + /// The `stage` of each storage event the adapter reported, in order. + private static func stages(_ recorder: TypedLogRecorder) -> [String] { + recorder.events.compactMap { event in + guard event is AppStorageMaintenanceFailed, case .token(let stage)? = event.fields["stage"] else { + return nil + } + return stage + } + } + + private static func temporaryDirectory() throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-project-tool-adapters-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } +} + +/// A value the discovery stand-in sets off the main actor and the test reads. +private final class LockedValue: @unchecked Sendable { + private let lock = NSLock() + private var stored: Value + + init(_ value: Value) { stored = value } + + var value: Value { + get { lock.withLock { stored } } + set { lock.withLock { stored = newValue } } + } +} + +/// Stands in for toolchain discovery: remembers each request and whether the +/// directory it was asked to look in was empty at that moment. +private actor DiscoveryLog { + private(set) var requests: [MacToolchainDiscovery.Request] = [] + private(set) var scratchDirectoriesWereEmpty: [Bool] = [] + + func record(_ request: MacToolchainDiscovery.Request) { + requests.append(request) + let contents = try? FileManager.default.contentsOfDirectory(atPath: request.workspace.path) + scratchDirectoriesWereEmpty.append(contents?.isEmpty == true) + } +} diff --git a/macos/Tests/LitheTests/ToolchainPluginAdmissionTests.swift b/macos/Tests/LitheTests/ToolchainPluginAdmissionTests.swift index 6969538085..80d5dfa464 100644 --- a/macos/Tests/LitheTests/ToolchainPluginAdmissionTests.swift +++ b/macos/Tests/LitheTests/ToolchainPluginAdmissionTests.swift @@ -22,12 +22,14 @@ struct ToolchainPluginAdmissionTests { #expect(review.programs.map(\.path) == ["bin/mise-provider"]) } - @Test func standaloneDefinitionHasEnablementAndRequiresRestart() throws { + /// Definitions are data the host reads each time a tool is resolved, so a + /// plugin that only carries them applies without a restart. + @Test func standaloneDefinitionHasEnablementAndActivatesWithoutARestart() throws { let descriptor = try checkAdmission(fixture("toolchains/tool-definition.plugin.json")) #expect(!descriptor.declaresBackend) #expect(descriptor.pluginManifest.modules.map(\.manifest.id) == [.toolchainsExtension(descriptor.id.rawValue)]) #expect(descriptor.pluginManifest.modules.allSatisfy { $0.manifest.defaultState == .disabled }) - #expect(!descriptor.canActivateWithoutRestart) + #expect(descriptor.canActivateWithoutRestart) let registry = try ValidatedPluginCatalog(manifests: [descriptor.pluginManifest], hostVersion: BuiltInPluginCatalog.hostVersion) #expect(registry.manifests.count == 1) diff --git a/macos/Tests/LitheTests/ToolchainSettingsFeatureTests.swift b/macos/Tests/LitheTests/ToolchainSettingsFeatureTests.swift index bfa02c7a09..fe9d677ce9 100644 --- a/macos/Tests/LitheTests/ToolchainSettingsFeatureTests.swift +++ b/macos/Tests/LitheTests/ToolchainSettingsFeatureTests.swift @@ -201,6 +201,22 @@ struct ToolchainSettingsFeatureTests { #expect(loader.workspaces == [root, root]) } + /// A live plugin change rereads an open page, and does nothing before the page loads. + @Test + func pluginChangesReloadAnOpenPageOnly() async { + let loader = SettingsSnapshotLoader(snapshots: [snapshot(), snapshot()]) + let model = ToolchainSettingsFeatureModel(loader: loader, reloadDelay: .zero) + defer { model.close() } + model.pluginsChanged() + #expect(loader.workspaces.isEmpty) + await model.load(workspace: root) + let reloaded = AsyncGate() + loader.beforeReturn = { index in if index == 1 { reloaded.open() } } + model.pluginsChanged() + #expect(await reloaded.wait(timeout: .seconds(5))) + #expect(loader.workspaces == [root, root]) + } + /// Inputs outside the workspace are watched per snapshot; closing ends the observation. @Test func providerInputsOutsideTheWorkspaceAreWatchedUntilClose() async { diff --git a/rust/lithe-core/src/plugins/capabilities.rs b/rust/lithe-core/src/plugins/capabilities.rs index 47f366d4d1..0748637b61 100644 --- a/rust/lithe-core/src/plugins/capabilities.rs +++ b/rust/lithe-core/src/plugins/capabilities.rs @@ -57,7 +57,9 @@ pub(crate) const VCS_PROVIDERS: &str = "vcsProviders"; /// `workspace` host service; it needs plugin-function protocol 3 for that. pub(crate) const BACKEND: &str = "backend"; -/// `projectGenerators[]`: entries of the New Project window. +/// `projectGenerators[]`: entries of the New Project window. Version 1 lets a +/// field select a host-managed runtime; version 2 selects installations of +/// any plugin-defined tool with a `tool` field instead. pub(crate) const PROJECT_GENERATORS: &str = "projectGenerators"; /// `debugAdapters[]`: debug adapters implemented by a plugin process; see @@ -90,7 +92,7 @@ pub const SUPPORTED_CAPABILITIES: &[(&str, &[u32])] = &[ (LANGUAGE_SERVERS, &[1, 2]), (LANGUAGES, &[1, 2]), (PACKAGE_TRUST, &[1]), - (PROJECT_GENERATORS, &[1]), + (PROJECT_GENERATORS, &[1, 2]), (SERVER_PROCESS, &[1, 2]), (SETTINGS, &[1, 2]), (TOOL_DEFINITIONS, &[1]), diff --git a/rust/lithe-core/src/plugins/declarative.rs b/rust/lithe-core/src/plugins/declarative.rs index 97f5331933..06c74182e6 100644 --- a/rust/lithe-core/src/plugins/declarative.rs +++ b/rust/lithe-core/src/plugins/declarative.rs @@ -1065,7 +1065,13 @@ impl DeclarativePluginManifest { "Project generators require plugin API level 3.", )); } - crate::project_generators::validate_declarations(&self.project_generators)?; + crate::project_generators::validate_declarations( + &self.project_generators, + self.requires + .get(capabilities::PROJECT_GENERATORS) + .copied() + .unwrap_or(1), + )?; crate::build::validate_declarations( &mut self.build_systems, &crate::build::DeclarationContext { @@ -1723,6 +1729,21 @@ impl DeclarativePluginManifest { self.languages.iter().map(|language| language.id.as_str()) } + /// Whether the plugin declares a definition that `tool` names. A + /// definition is addressed by its short ID (verified official plugins) or + /// as `:`; which of the two the host honors is its registry's + /// decision, so both spellings count as a declaration here. + pub(crate) fn declares_tool(&self, tool: &str) -> bool { + // `Some(local)` when `tool` is spelled `:`. + let local_name = tool + .strip_prefix(self.id.as_str()) + .and_then(|rest| rest.strip_prefix(':')); + self.tool_definitions + .iter() + .filter_map(|definition| definition["id"].as_str()) + .any(|id| tool == id || local_name == Some(id)) + } + /// Validated project generators, in declaration order. pub(crate) fn project_generators( &self, diff --git a/rust/lithe-core/src/project_generators/commands.rs b/rust/lithe-core/src/project_generators/commands.rs index 5d1b311b36..1e1ae0f701 100644 --- a/rust/lithe-core/src/project_generators/commands.rs +++ b/rust/lithe-core/src/project_generators/commands.rs @@ -7,7 +7,7 @@ use super::archive; use super::form::{self, FormInput}; -use super::manifest::{GeneratorSection, ProjectGeneratorDeclaration}; +use super::manifest::{GeneratorSection, ProjectGeneratorDeclaration, FIELD_TOOL}; use super::plan; use crate::plugins::capabilities::HostCapabilities; use crate::plugins::declarative::{ @@ -139,6 +139,7 @@ pub(crate) fn project_generator_catalog_command(payload: Value) -> Result Result Result bool) -> Vec, +) -> Vec { + generator + .fields + .iter() + .filter(|field| field.is(FIELD_TOOL)) + .filter_map(|field| { + let tool = field.tool.as_deref()?; + Some(json!({ + "fieldID": field.id, + "tool": tool, + "providers": providers(&|candidate| candidate.declares_tool(tool)), + })) + }) + .collect() +} + /// `project.generatorForm`: field values, visibility, and problems. pub(crate) fn project_generator_form_command(payload: Value) -> Result { let payload: FormPayload = decode_payload(payload, "project.generatorForm")?; diff --git a/rust/lithe-core/src/project_generators/form.rs b/rust/lithe-core/src/project_generators/form.rs index 23e52c4cd6..d8cb79554f 100644 --- a/rust/lithe-core/src/project_generators/form.rs +++ b/rust/lithe-core/src/project_generators/form.rs @@ -7,7 +7,7 @@ use super::manifest::{ Conditions, GeneratorField, ProjectGeneratorDeclaration, FIELD_BOOLEAN, FIELD_ENUM, - FIELD_MULTI_SELECT, FIELD_RUNTIME, FIELD_TEXT, + FIELD_MULTI_SELECT, FIELD_RUNTIME, FIELD_TEXT, FIELD_TOOL, }; use super::template::{FieldValue, Values}; use crate::plugins::declarative::version_components; @@ -26,13 +26,24 @@ pub(crate) struct RuntimeSelection { pub(crate) version: String, } +/// An installation of a tool that the host found through the tool +/// definition's discovery, so the host has already checked that it is real. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct ToolInstallation { + pub(crate) home: String, + pub(crate) executable: String, + pub(crate) version: String, +} + /// What the user has entered so far. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub(crate) struct FormInput { pub(crate) project_name: String, /// Values by field ID. A text value counts only if its ID is in - /// `edited`; otherwise the field keeps following its default. + /// `edited`; otherwise the field keeps following its default. A tool + /// field's value is the home of the installation the user chose. #[serde(default)] pub(crate) values: BTreeMap, #[serde(default)] @@ -40,6 +51,9 @@ pub(crate) struct FormInput { /// Selected installations of runtime fields. #[serde(default)] pub(crate) runtimes: BTreeMap, + /// Installations the host found for each tool field, by field ID. + #[serde(default)] + pub(crate) tools: BTreeMap>, } /// Why a value is not accepted. Hosts localize by code. @@ -51,6 +65,8 @@ pub(crate) enum FieldProblem { InvalidFormat, UnsupportedValue, RuntimeTooOld, + /// The selected tool installation is older than the field's `minimumVersion`. + ToolTooOld, /// The value names a newer version than `maximumVersion` allows. VersionTooHigh, } @@ -63,6 +79,7 @@ impl FieldProblem { Self::InvalidFormat => "invalidFormat", Self::UnsupportedValue => "unsupportedValue", Self::RuntimeTooOld => "runtimeTooOld", + Self::ToolTooOld => "toolTooOld", Self::VersionTooHigh => "versionTooHigh", } } @@ -76,6 +93,20 @@ pub(crate) struct FieldState { pub(crate) value: Value, #[serde(skip_serializing_if = "Option::is_none")] pub(crate) problem: Option, + /// A tool field's candidates, for the host to list. Empty for other fields. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub(crate) installations: Vec, +} + +/// One installation a tool field offers, with Core's verdict on its version. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ToolChoice { + home: String, + executable: String, + version: String, + /// False when the version is older than the field's `minimumVersion`. + meets_minimum: bool, } /// The evaluated form. @@ -120,6 +151,7 @@ pub(crate) fn evaluate(generator: &ProjectGeneratorDeclaration, input: &FormInpu visible, value, problem: problem.filter(|_| visible), + installations: tool_choices(field, input), }); } let active_requirements = generator @@ -255,9 +287,10 @@ fn resolve( } FIELD_RUNTIME => match input.runtimes.get(&field.id) { Some(selection) if !selection.home.trim().is_empty() => { - let too_old = field.minimum_version.as_deref().is_some_and(|minimum| { - version_components(&selection.version) < version_components(minimum) - }); + let too_old = field + .minimum_version + .as_deref() + .is_some_and(|minimum| release(&selection.version) < release(minimum)); ( json!(selection.home), FieldValue::Runtime { @@ -276,10 +309,102 @@ fn resolve( (!field.optional).then_some(FieldProblem::Required), ), }, + FIELD_TOOL => resolve_tool(field, input), _ => (Value::Null, FieldValue::Text(String::new()), None), } } +/// A tool field holds the installation the user chose, or else the newest +/// one the host found. A choice the host no longer lists (the tool was +/// uninstalled meanwhile) falls back to the newest, and the field shows it. +/// +/// An optional field whose newest installation is below the minimum stays +/// empty, as if the tool were not installed: an old copy on the machine must +/// not block a project that can be created without the tool. Choosing that +/// copy explicitly is still reported. +fn resolve_tool( + field: &GeneratorField, + input: &FormInput, +) -> (Value, FieldValue, Option) { + let found = tool_installations(field, input); + let chosen = input + .values + .get(&field.id) + .and_then(Value::as_str) + .and_then(|home| found.iter().find(|installation| installation.home == home)); + let fallback = + newest(found).filter(|installation| !field.optional || meets_minimum(field, installation)); + match chosen.or(fallback) { + Some(installation) => ( + json!(installation.home), + FieldValue::Tool { + home: installation.home.clone(), + executable: installation.executable.clone(), + }, + (!meets_minimum(field, installation)).then_some(FieldProblem::ToolTooOld), + ), + None => ( + json!(""), + FieldValue::Tool { + home: String::new(), + executable: String::new(), + }, + (!field.optional).then_some(FieldProblem::Required), + ), + } +} + +fn tool_installations<'a>(field: &GeneratorField, input: &'a FormInput) -> &'a [ToolInstallation] { + input + .tools + .get(&field.id) + .map(Vec::as_slice) + .unwrap_or_default() +} + +/// The highest version; among equal versions the host's first one, because +/// the host lists installations in the order it prefers them. +fn newest(found: &[ToolInstallation]) -> Option<&ToolInstallation> { + // `max_by_key` keeps the last of several maxima, so walk backwards. + found + .iter() + .rev() + .max_by_key(|installation| release(&installation.version)) +} + +fn meets_minimum(field: &GeneratorField, installation: &ToolInstallation) -> bool { + field + .minimum_version + .as_deref() + .is_none_or(|minimum| release(&installation.version) >= release(minimum)) +} + +/// Version numbers without trailing zeros, so `3.11`, `3.11.0` and +/// `3.11.0.0` compare as the same release, as toolchain resolution treats +/// them. Plain vector comparison would put `3.11` below `3.11.0`. +fn release(version: &str) -> Vec { + let mut numbers = version_components(version); + while numbers.last() == Some(&0) { + numbers.pop(); + } + numbers +} + +fn tool_choices(field: &GeneratorField, input: &FormInput) -> Vec { + if !field.is(FIELD_TOOL) { + return Vec::new(); + } + tool_installations(field, input) + .iter() + .map(|installation| ToolChoice { + home: installation.home.clone(), + executable: installation.executable.clone(), + version: installation.version.clone(), + meets_minimum: meets_minimum(field, installation), + }) + .collect() +} + /// Compares a value with the field's `maximumVersion`, expanded over the /// earlier fields. An empty value or bound (no JDK chosen yet) is left to the /// fields' own checks. @@ -299,8 +424,7 @@ fn version_too_high( if bound.is_empty() || value.is_empty() { return None; } - (version_components(&value) > version_components(&bound)) - .then_some(FieldProblem::VersionTooHigh) + (release(&value) > release(&bound)).then_some(FieldProblem::VersionTooHigh) } fn text_problem(field: &GeneratorField, text: &str) -> Option { @@ -339,6 +463,7 @@ pub(crate) fn project_name_problem(name: &str) -> Option { #[cfg(test)] mod tests { + use super::super::manifest::KNOWN_RUNTIMES; use super::*; fn generator(fields: Value) -> ProjectGeneratorDeclaration { @@ -358,6 +483,15 @@ mod tests { values: serde_json::from_value(values).unwrap(), edited: edited.iter().map(|id| id.to_string()).collect(), runtimes: BTreeMap::new(), + tools: BTreeMap::new(), + } + } + + fn installation(home: &str, version: &str) -> ToolInstallation { + ToolInstallation { + home: home.into(), + executable: format!("{home}/bin/tool"), + version: version.into(), } } @@ -474,6 +608,193 @@ mod tests { } } + fn tool_generator(optional: bool) -> ProjectGeneratorDeclaration { + generator(json!([{ + "id": "sdk", "type": "tool", "title": "Python", "tool": "python", + "minimumVersion": "3.11", "pinAs": "project", "optional": optional + }])) + } + + fn with_tools(mut form: FormInput, found: &[(&str, &str)]) -> FormInput { + let installations = found + .iter() + .map(|(home, version)| installation(home, version)) + .collect(); + form.tools.insert("sdk".into(), installations); + form + } + + #[test] + fn a_tool_field_follows_the_newest_installation_until_chosen() { + let generator = tool_generator(false); + let form = with_tools( + input("demo", json!({}), &[]), + &[ + ("/py/3.11", "3.11.9"), + ("/py/3.13", "3.13.1"), + ("/py/3.12", "3.12.4"), + ], + ); + let state = evaluate(&generator, &form); + assert_eq!(field(&state, "sdk").value, json!("/py/3.13")); + assert_eq!(field(&state, "sdk").problem, None); + assert_eq!(field(&state, "sdk").installations.len(), 3); + assert_eq!( + state.values["sdk"], + FieldValue::Tool { + home: "/py/3.13".into(), + executable: "/py/3.13/bin/tool".into(), + } + ); + + let mut chosen = form.clone(); + chosen.values.insert("sdk".into(), json!("/py/3.12")); + let state = evaluate(&generator, &chosen); + assert_eq!(field(&state, "sdk").value, json!("/py/3.12")); + } + + #[test] + fn equal_versions_keep_the_hosts_order() { + let form = with_tools( + input("demo", json!({}), &[]), + &[("/first", "3.12.0"), ("/second", "3.12.0")], + ); + let state = evaluate(&tool_generator(false), &form); + assert_eq!(field(&state, "sdk").value, json!("/first")); + } + + /// A choice the host no longer lists is not trusted: the field falls + /// back to what is installed instead of keeping a stale path. + #[test] + fn a_vanished_choice_falls_back_to_the_newest() { + let mut form = with_tools( + input("demo", json!({"sdk": "/py/gone"}), &[]), + &[("/py/3.12", "3.12.4")], + ); + let state = evaluate(&tool_generator(false), &form); + assert_eq!(field(&state, "sdk").value, json!("/py/3.12")); + + form.values.insert("sdk".into(), json!(7)); + let state = evaluate(&tool_generator(false), &form); + assert_eq!(field(&state, "sdk").value, json!("/py/3.12")); + } + + #[test] + fn a_tool_below_the_minimum_is_reported() { + let form = with_tools( + input("demo", json!({}), &[]), + &[("/py/3.9", "3.9.18"), ("/py/3.10", "3.10.14")], + ); + let state = evaluate(&tool_generator(false), &form); + assert_eq!(field(&state, "sdk").problem, Some(FieldProblem::ToolTooOld)); + assert!(!state.is_valid()); + assert!(field(&state, "sdk") + .installations + .iter() + .all(|choice| !choice.meets_minimum)); + + // Choosing a sufficient installation clears the problem. + let form = with_tools( + input("demo", json!({"sdk": "/py/3.11"}), &[]), + &[("/py/3.9", "3.9.18"), ("/py/3.11", "3.11.0")], + ); + let state = evaluate(&tool_generator(false), &form); + assert_eq!(field(&state, "sdk").problem, None); + let meets: Vec = field(&state, "sdk") + .installations + .iter() + .map(|choice| choice.meets_minimum) + .collect(); + assert_eq!(meets, [false, true]); + } + + /// `3.11` and `3.11.0` are one release: neither falls below a minimum + /// written the other way, and equal releases keep the host's order. + #[test] + fn trailing_zeros_do_not_change_a_release() { + let tool = generator(json!([{ + "id": "sdk", "type": "tool", "title": "Python", "tool": "python", + "minimumVersion": "3.11.0", "pinAs": "project" + }])); + let form = with_tools(input("demo", json!({}), &[]), &[("/py/3.11", "3.11")]); + let state = evaluate(&tool, &form); + assert_eq!(field(&state, "sdk").problem, None); + assert!(field(&state, "sdk").installations[0].meets_minimum); + + let form = with_tools( + input("demo", json!({}), &[]), + &[("/first", "3.12"), ("/second", "3.12.0.0")], + ); + let state = evaluate(&tool_generator(false), &form); + assert_eq!(field(&state, "sdk").value, json!("/first")); + + // Runtime fields compare the same way; the runtime is whichever one the + // manifest accepts, so the test names none itself. + let runtime = generator(json!([ + {"id": "sdk", "type": "runtime", "title": "SDK", "runtime": KNOWN_RUNTIMES[0], "minimumVersion": "17.0"} + ])); + let mut form = input("demo", json!({}), &[]); + form.runtimes.insert( + "sdk".into(), + RuntimeSelection { + home: "/sdk".into(), + version: "17".into(), + }, + ); + assert_eq!(field(&evaluate(&runtime, &form), "sdk").problem, None); + } + + /// An old copy of an optional tool must not block a project that could be + /// created without the tool: the field stays empty and leaves no pin, the + /// same as when nothing is installed. Choosing the old copy is reported. + #[test] + fn an_optional_tool_with_only_old_installations_stays_empty() { + let form = with_tools(input("demo", json!({}), &[]), &[("/py/3.10", "3.10.14")]); + let state = evaluate(&tool_generator(true), &form); + assert_eq!(field(&state, "sdk").problem, None); + assert_eq!(field(&state, "sdk").value, json!("")); + assert!(state.is_valid()); + assert_eq!( + state.values["sdk"], + FieldValue::Tool { + home: String::new(), + executable: String::new(), + } + ); + assert!(!field(&state, "sdk").installations[0].meets_minimum); + + let mut cleared = form.clone(); + cleared.values.insert("sdk".into(), json!("")); + assert_eq!( + field(&evaluate(&tool_generator(true), &cleared), "sdk").value, + json!("") + ); + + let mut chosen = form; + chosen.values.insert("sdk".into(), json!("/py/3.10")); + let state = evaluate(&tool_generator(true), &chosen); + assert_eq!(field(&state, "sdk").problem, Some(FieldProblem::ToolTooOld)); + } + + #[test] + fn a_tool_field_without_candidates_is_required_unless_optional() { + let form = input("demo", json!({}), &[]); + let state = evaluate(&tool_generator(false), &form); + assert_eq!(field(&state, "sdk").problem, Some(FieldProblem::Required)); + assert_eq!(field(&state, "sdk").value, json!("")); + + let state = evaluate(&tool_generator(true), &form); + assert_eq!(field(&state, "sdk").problem, None); + assert!(state.is_valid()); + assert_eq!( + state.values["sdk"], + FieldValue::Tool { + home: String::new(), + executable: String::new(), + } + ); + } + #[test] fn project_names_are_single_directory_names() { assert_eq!(project_name_problem("demo"), None); diff --git a/rust/lithe-core/src/project_generators/manifest.rs b/rust/lithe-core/src/project_generators/manifest.rs index c2c9b4aca7..24f688caf2 100644 --- a/rust/lithe-core/src/project_generators/manifest.rs +++ b/rust/lithe-core/src/project_generators/manifest.rs @@ -83,12 +83,26 @@ pub(crate) const FIELD_ENUM: &str = "enum"; pub(crate) const FIELD_BOOLEAN: &str = "boolean"; pub(crate) const FIELD_MULTI_SELECT: &str = "multiSelect"; pub(crate) const FIELD_RUNTIME: &str = "runtime"; +pub(crate) const FIELD_TOOL: &str = "tool"; /// Host-managed runtimes a runtime field may select. The host discovers /// installations; Core only checks the version. -const KNOWN_RUNTIMES: &[&str] = &["java"]; +pub(super) const KNOWN_RUNTIMES: &[&str] = &["java"]; +/// The `requires.projectGenerators` version that introduces tool fields and +/// retires runtime fields. A generator declared with version 2 selects every +/// tool through its definition; version 1 keeps the runtime field. +const TOOL_FIELDS_VERSION: u32 = 2; + +/// Where a tool field's selection is saved besides the generation steps. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum ToolPin { + /// The created project's own tool choice, so its builds and runs use + /// the installation the user picked here. + Project, +} /// One input of a generator. The types follow plugin settings, minus -/// integer and paths, plus `runtime`. +/// integer and paths, plus `runtime` (version 1) and `tool` (version 2). #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub(crate) struct GeneratorField { @@ -123,15 +137,22 @@ pub(crate) struct GeneratorField { pub(crate) max_length: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) placeholder: Option, - /// A text or runtime field may be left empty. + /// A text, runtime, or tool field may be left empty. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub(crate) optional: bool, /// Runtime a runtime field selects. #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) runtime: Option, - /// Oldest acceptable runtime version. + /// Tool definition ID a tool field selects an installation of. Which + /// plugin defines it is not fixed here, so any plugin may add a tool. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) tool: Option, + /// Oldest acceptable runtime or tool version. #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) minimum_version: Option, + /// Saves a tool field's selection once the project exists. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) pin_as: Option, /// Highest version a text or enum value may name, as a template over /// earlier fields, such as `${field.jdk.major}` so a target Java /// release never exceeds the selected JDK. An empty bound checks nothing. @@ -406,7 +427,13 @@ impl GeneratorField { /// Validates every generator and sorts fields-free collections that have no /// meaningful order. Field and step order are kept: fields are drawn and /// defaults evaluated in order, steps run in order. -pub(crate) fn validate_declarations(generators: &[ProjectGeneratorDeclaration]) -> Validation<()> { +/// +/// `version` is the manifest's `requires.projectGenerators`; it decides +/// whether fields select a runtime (1) or a tool (2). +pub(crate) fn validate_declarations( + generators: &[ProjectGeneratorDeclaration], + version: u32, +) -> Validation<()> { if generators.len() > MAX_GENERATORS { return Err(invalid( "projectGenerators", @@ -422,13 +449,13 @@ pub(crate) fn validate_declarations(generators: &[ProjectGeneratorDeclaration]) "A project generator is declared more than once.", )); } - generator.validate(&field)?; + generator.validate(&field, version)?; } Ok(()) } impl ProjectGeneratorDeclaration { - fn validate(&self, field: &str) -> Validation<()> { + fn validate(&self, field: &str, version: u32) -> Validation<()> { if !is_generator_id(&self.id) { return Err(invalid( format!("{field}.id"), @@ -463,7 +490,7 @@ impl ProjectGeneratorDeclaration { let item_field = format!("{field}.fields[{index}]"); // A field reads only fields declared before it, so defaults are // evaluated in one pass and visibility never forms a cycle. - item.validate(&item_field, &declared)?; + item.validate(&item_field, &declared, version)?; if declared.insert(item.id.as_str(), item).is_some() { return Err(invalid( format!("{item_field}.id"), @@ -510,7 +537,13 @@ impl ProjectGeneratorDeclaration { } impl GeneratorField { - fn validate(&self, field: &str, earlier: &BTreeMap<&str, &GeneratorField>) -> Validation<()> { + fn validate( + &self, + field: &str, + earlier: &BTreeMap<&str, &GeneratorField>, + version: u32, + ) -> Validation<()> { + self.check_kind_for_version(field, version)?; if !is_setting_identifier(&self.id) { return Err(invalid( format!("{field}.id"), @@ -533,8 +566,10 @@ impl GeneratorField { "options" => matches!(kind, FIELD_ENUM | FIELD_MULTI_SELECT), "presentation" => kind == FIELD_ENUM, "text" => kind == FIELD_TEXT, - "optional" => matches!(kind, FIELD_TEXT | FIELD_RUNTIME), + "optional" => matches!(kind, FIELD_TEXT | FIELD_RUNTIME | FIELD_TOOL), "runtime" => kind == FIELD_RUNTIME, + "tool" => kind == FIELD_TOOL, + "minimumVersion" => matches!(kind, FIELD_RUNTIME | FIELD_TOOL), "maximumVersion" => matches!(kind, FIELD_TEXT | FIELD_ENUM), _ => false, }; @@ -546,10 +581,9 @@ impl GeneratorField { self.pattern.is_some() || self.max_length.is_some() || self.placeholder.is_some(), ), ("optional", self.optional), - ( - "runtime", - self.runtime.is_some() || self.minimum_version.is_some(), - ), + ("runtime", self.runtime.is_some()), + ("tool", self.tool.is_some() || self.pin_as.is_some()), + ("minimumVersion", self.minimum_version.is_some()), ("maximumVersion", self.maximum_version.is_some()), ]; if let Some((name, _)) = present @@ -684,15 +718,58 @@ impl GeneratorField { )); } } + FIELD_TOOL => { + if !self + .tool + .as_deref() + .is_some_and(crate::toolchain::definitions::tool_id) + { + return Err(invalid( + format!("{field}.tool"), + "A tool field names a tool definition ID.", + )); + } + if let Some(version) = &self.minimum_version { + crate::plugins::declarative::validate_minimum_version( + version, + &format!("{field}.minimumVersion"), + )?; + } + if self.default.is_some() { + return Err(invalid( + format!("{field}.default"), + "The host chooses the initial installation.", + )); + } + } _ => { return Err(invalid( format!("{field}.type"), - "A field type is text, enum, boolean, multiSelect, or runtime.", + "A field type is text, enum, boolean, multiSelect, runtime, or tool.", )) } } Ok(()) } + + /// Runtime fields belong to `projectGenerators` 1 and tool fields to 2, + /// so one generator never mixes the old runtime path with tool + /// definitions. The old path is removed once no plugin uses version 1. + fn check_kind_for_version(&self, field: &str, version: u32) -> Validation<()> { + if self.is(FIELD_TOOL) && version < TOOL_FIELDS_VERSION { + return Err(invalid( + format!("{field}.type"), + "A tool field needs requires.projectGenerators 2.", + )); + } + if self.is(FIELD_RUNTIME) && version >= TOOL_FIELDS_VERSION { + return Err(invalid( + format!("{field}.type"), + "projectGenerators 2 selects installations with a tool field, not a runtime field.", + )); + } + Ok(()) + } } impl GeneratorStep { @@ -837,7 +914,9 @@ fn check_conditions( let comparable = |value: &Value| match target.kind.as_str() { FIELD_ENUM => value.as_str().is_some_and(|text| target.offers(text)), FIELD_BOOLEAN => value.is_boolean(), - FIELD_TEXT | FIELD_RUNTIME => value.is_string(), + // A runtime or tool field compares as its home, so + // `notEquals ""` asks whether an installation was chosen. + FIELD_TEXT | FIELD_RUNTIME | FIELD_TOOL => value.is_string(), _ => false, }; if !comparisons.into_iter().all(comparable) { @@ -894,15 +973,15 @@ fn check_variables( if !variable_is_known(variable, |id| fields.get(id).copied()) { return Err(invalid( field, - "The template reads a field that is not declared earlier, or a runtime version of a field that is not a runtime.", + "The template reads a field that is not declared earlier, a runtime version of a field that is not a runtime, or the executable of a field that is not a tool.", )); } } Ok(()) } -/// Whether a variable names a field `lookup` knows, with a runtime suffix -/// only on runtime fields. +/// Whether a variable names a field `lookup` knows, with a version suffix +/// only on runtime fields and `.executable` only on tool fields. pub(crate) fn variable_is_known<'a>( variable: &Variable, lookup: impl Fn(&str) -> Option<&'a GeneratorField>, @@ -913,6 +992,7 @@ pub(crate) fn variable_is_known<'a>( Source::RuntimeVersion(id) | Source::RuntimeMajor(id) => { lookup(id).is_some_and(|field| field.is(FIELD_RUNTIME)) } + Source::ToolExecutable(id) => lookup(id).is_some_and(|field| field.is(FIELD_TOOL)), } } diff --git a/rust/lithe-core/src/project_generators/mod.rs b/rust/lithe-core/src/project_generators/mod.rs index 98e1167547..e1facf5bce 100644 --- a/rust/lithe-core/src/project_generators/mod.rs +++ b/rust/lithe-core/src/project_generators/mod.rs @@ -12,6 +12,12 @@ //! processes in a staging directory, and move the finished project into //! place. //! +//! A version 2 generator picks tool installations with `tool` fields. The +//! host lists the installations of the tool a field names (through the same +//! tool definitions and discovery the settings use); Core chooses among them +//! (the newest that meets `minimumVersion`), reports the choice in the form, +//! and puts the pins the host saves after creation in the plan. +//! //! Core reads template files from the plugin package and extracts archives //! into a staging directory the host created; it never writes anywhere else. //! diff --git a/rust/lithe-core/src/project_generators/plan.rs b/rust/lithe-core/src/project_generators/plan.rs index 4864204210..6bf6f57f74 100644 --- a/rust/lithe-core/src/project_generators/plan.rs +++ b/rust/lithe-core/src/project_generators/plan.rs @@ -2,7 +2,7 @@ use super::form::{self, FormInput, FormState}; use super::manifest::{ - variable_is_known, GeneratorStep, ProjectGeneratorDeclaration, FIELD_RUNTIME, + variable_is_known, GeneratorStep, ProjectGeneratorDeclaration, FIELD_RUNTIME, FIELD_TOOL, }; use super::template::{self, FieldValue, Values, Variable}; use crate::plugins::declarative::{is_safe_relative_path, PluginRejection, Validation}; @@ -162,10 +162,35 @@ pub(crate) fn plan( "steps": steps, "openFiles": open_files, "runtimes": runtimes, + "toolPins": tool_pins(generator, &state), "usesNetwork": uses_network, })) } +/// The tool selections the host saves once the project exists, so the +/// project builds and runs with the installations its creator picked. A +/// hidden field or one without an installation leaves nothing to save. +fn tool_pins(generator: &ProjectGeneratorDeclaration, state: &FormState) -> Vec { + generator + .fields + .iter() + .filter(|field| field.is(FIELD_TOOL)) + .filter_map(|field| { + let pin_as = field.pin_as?; + match state.values.get(&field.id) { + Some(FieldValue::Tool { home, executable }) if !home.is_empty() => Some(json!({ + "field": field.id, + "tool": field.tool, + "pinAs": pin_as, + "home": home, + "executable": executable, + })), + _ => None, + } + }) + .collect() +} + fn reject_invalid_form(state: &FormState) -> Validation<()> { if let Some(problem) = state.project_name_problem { return Err(PluginRejection::at( diff --git a/rust/lithe-core/src/project_generators/template.rs b/rust/lithe-core/src/project_generators/template.rs index ec7edc14bc..1be3789b8e 100644 --- a/rust/lithe-core/src/project_generators/template.rs +++ b/rust/lithe-core/src/project_generators/template.rs @@ -15,12 +15,15 @@ pub(crate) enum Source { /// The project name the user typed. ProjectName, /// A field's value: text, the chosen option, `true`/`false`, a - /// comma-separated list, or a runtime's home directory. + /// comma-separated list, or the home directory of a runtime or tool. Field(String), /// The full version of a runtime field's selection. RuntimeVersion(String), /// The major version of a runtime field's selection, such as `21`. RuntimeMajor(String), + /// The executable of a tool field's selection, the file the tool + /// definition resolved inside the home. + ToolExecutable(String), } /// A transformation applied after the value is read, in order. @@ -54,6 +57,7 @@ impl Variable { None => Source::Field(rest.to_string()), Some((id, "version")) => Source::RuntimeVersion(id.to_string()), Some((id, "major")) => Source::RuntimeMajor(id.to_string()), + Some((id, "executable")) => Source::ToolExecutable(id.to_string()), Some(_) => return None, } }; @@ -73,7 +77,10 @@ impl Variable { pub(crate) fn field(&self) -> Option<&str> { match &self.source { Source::ProjectName => None, - Source::Field(id) | Source::RuntimeVersion(id) | Source::RuntimeMajor(id) => Some(id), + Source::Field(id) + | Source::RuntimeVersion(id) + | Source::RuntimeMajor(id) + | Source::ToolExecutable(id) => Some(id), } } } @@ -89,7 +96,15 @@ pub(crate) enum FieldValue { Text(String), Flag(bool), List(Vec), - Runtime { home: String, version: String }, + Runtime { + home: String, + version: String, + }, + /// The installation a tool field selected. + Tool { + home: String, + executable: String, + }, } impl FieldValue { @@ -98,7 +113,7 @@ impl FieldValue { Self::Text(text) => text.clone(), Self::Flag(flag) => flag.to_string(), Self::List(items) => items.join(","), - Self::Runtime { home, .. } => home.clone(), + Self::Runtime { home, .. } | Self::Tool { home, .. } => home.clone(), } } } @@ -126,6 +141,10 @@ impl Values<'_> { Some(FieldValue::Runtime { version, .. }) => major_version(version), _ => String::new(), }, + Source::ToolExecutable(id) => match self.fields.get(id) { + Some(FieldValue::Tool { executable, .. }) => executable.clone(), + _ => String::new(), + }, }; variable .modifiers @@ -246,11 +265,38 @@ mod tests { Variable::parse("field.jdk.major").map(|variable| variable.source), Some(Source::RuntimeMajor("jdk".into())) ); + assert_eq!( + Variable::parse("field.python.executable").map(|variable| variable.source), + Some(Source::ToolExecutable("python".into())) + ); assert!(Variable::parse("field.jdk.home").is_none()); assert!(Variable::parse("project.path").is_none()); assert!(Variable::parse("field.name|upper").is_none()); } + #[test] + fn tool_fields_read_as_home_and_executable() { + let fields = values(&[( + "python", + FieldValue::Tool { + home: "/opt/python".into(), + executable: "/opt/python/bin/python3".into(), + }, + )]); + let values = Values { + project_name: "demo", + fields: &fields, + }; + let expand = |text: &str| values.expand(&parse(text).unwrap()); + assert_eq!(expand("${field.python}"), "/opt/python"); + assert_eq!( + expand("${field.python.executable} -m venv ."), + "/opt/python/bin/python3 -m venv ." + ); + // A field that holds no tool reads as empty, never as another value. + assert_eq!(expand("${field.missing.executable}"), ""); + } + #[test] fn package_modifier_makes_a_valid_package() { assert_eq!(package_name("com.Example.my-app"), "com.example.myapp"); diff --git a/rust/lithe-core/src/tests/build_systems.rs b/rust/lithe-core/src/tests/build_systems.rs index 28169c8aeb..a536d64134 100644 --- a/rust/lithe-core/src/tests/build_systems.rs +++ b/rust/lithe-core/src/tests/build_systems.rs @@ -30,9 +30,12 @@ macro_rules! contract_file { const CARGO_FIXTURE: &str = contract_file!("fixtures/build-systems/build-static-file.plugin.json"); const GO_FIXTURE: &str = contract_file!("fixtures/build-systems/build-command-stdout.plugin.json"); -const GRADLE_FIXTURE: &str = contract_file!("fixtures/build-systems/build-command-output-file.plugin.json"); -const MAVEN_FIXTURE: &str = contract_file!("fixtures/build-systems/build-host-integration.plugin.json"); -const GRADLE_PROVIDER_FIXTURE: &str = contract_file!("fixtures/build-providers/build-provider.plugin.json"); +const GRADLE_FIXTURE: &str = + contract_file!("fixtures/build-systems/build-command-output-file.plugin.json"); +const MAVEN_FIXTURE: &str = + contract_file!("fixtures/build-systems/build-host-integration.plugin.json"); +const GRADLE_PROVIDER_FIXTURE: &str = + contract_file!("fixtures/build-providers/build-provider.plugin.json"); const MIX_PROVIDER_FIXTURE: &str = contract_file!("fixtures/build-providers/build-provider-language-server.plugin.json"); const BUILD_PLANS: &str = contract_file!("fixtures/build-systems/build-plans.json"); @@ -49,7 +52,9 @@ fn fixture(name: &str) -> Value { "build-provider-reports.plugin.json" => { contract_file!("fixtures/build-providers/build-provider-reports.plugin.json") } - "build-toolchains.plugin.json" => contract_file!("fixtures/build-systems/build-toolchains.plugin.json"), + "build-toolchains.plugin.json" => { + contract_file!("fixtures/build-systems/build-toolchains.plugin.json") + } "build-command-stdout.plugin.json" => GO_FIXTURE, "build-command-output-file.plugin.json" => GRADLE_FIXTURE, "build-host-integration.plugin.json" => MAVEN_FIXTURE, @@ -478,8 +483,10 @@ fn provider_test_coverage_is_bounded_canonical_and_does_not_change_legacy_result fn canonical_repeated_cases_sum_only_complete_safe_durations() { let workspace = TemporaryWorkspace::new("canonical-case-duration"); let manifest = fixture("build-provider-reports.plugin.json"); - let fixture: Value = - serde_json::from_str(contract_file!("fixtures/build-providers/build-test-durations.json")).unwrap(); + let fixture: Value = serde_json::from_str(contract_file!( + "fixtures/build-providers/build-test-durations.json" + )) + .unwrap(); for case in fixture["cases"].as_array().unwrap() { let left = &case["values"][0]; let right = &case["values"][1]; @@ -1959,8 +1966,10 @@ fn task_categories_and_option_placements_are_display_only() { /// 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-systems/build-tool-path.plugin.json")).unwrap(); + let manifest: Value = serde_json::from_str(contract_file!( + "fixtures/build-systems/build-tool-path.plugin.json" + )) + .unwrap(); let validated = validate(manifest.clone()); assert_eq!(validated["status"], "ok", "{validated}"); let invoke = |settings: Value| { @@ -2027,8 +2036,10 @@ fn explicit_build_tool_path_requires_supported_global_file_setting() { #[test] fn executable_candidates_order_and_constraints_are_shared_and_deterministic() { - let base: Value = - serde_json::from_str(contract_file!("fixtures/build-systems/build-tool-path.plugin.json")).unwrap(); + let base: Value = serde_json::from_str(contract_file!( + "fixtures/build-systems/build-tool-path.plugin.json" + )) + .unwrap(); let invoke = |candidates: Value| { let mut manifest = base.clone(); manifest["buildSystems"][0]["invocation"]["executables"] = candidates; @@ -2080,8 +2091,10 @@ fn executable_candidates_order_and_constraints_are_shared_and_deterministic() { #[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-systems/build-tool-path.plugin.json")).unwrap(); + let mut manifest: Value = serde_json::from_str(contract_file!( + "fixtures/build-systems/build-tool-path.plugin.json" + )) + .unwrap(); manifest["requires"] .as_object_mut() .unwrap() @@ -2116,8 +2129,10 @@ fn published_build_v2_paths_adapt_to_candidates_and_preserve_invalid_values() { #[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-systems/build-tool-path.plugin.json")).unwrap(); + let mut manifest: Value = serde_json::from_str(contract_file!( + "fixtures/build-systems/build-tool-path.plugin.json" + )) + .unwrap(); let old_host = validate_command( json!({"manifest":manifest,"hostCapabilities":{"buildSystems":[1,2],"settings":[1,2]}}), ) @@ -2283,10 +2298,14 @@ fn preparation_tasks_are_declared_versioned_and_unambiguous() { /// no test selector is interpolated into a host command. #[test] fn provider_tests_preserve_filters_and_report_ownership() { - let manifest: Value = - serde_json::from_str(contract_file!("fixtures/build-providers/build-provider-tests.plugin.json")).unwrap(); - let fixture: Value = - serde_json::from_str(contract_file!("fixtures/build-providers/build-test-protocol.json")).unwrap(); + let manifest: Value = serde_json::from_str(contract_file!( + "fixtures/build-providers/build-provider-tests.plugin.json" + )) + .unwrap(); + let fixture: Value = serde_json::from_str(contract_file!( + "fixtures/build-providers/build-test-protocol.json" + )) + .unwrap(); assert_eq!(validate(manifest.clone())["status"], "ok"); let plan = build_test_plan_command( json!({"manifest": manifest, "buildSystemID":"gradle", "request":fixture["request"]}), @@ -2339,8 +2358,10 @@ fn provider_tests_preserve_filters_and_report_ownership() { /// every test or fall back to host-owned business logic. #[test] fn provider_tests_require_version_three_and_an_owned_process() { - let manifest: Value = - serde_json::from_str(contract_file!("fixtures/build-providers/build-provider-tests.plugin.json")).unwrap(); + let manifest: Value = serde_json::from_str(contract_file!( + "fixtures/build-providers/build-provider-tests.plugin.json" + )) + .unwrap(); for version in [1, 2] { let mut old = manifest.clone(); old["requires"]["buildProviders"] = json!(version); @@ -2383,10 +2404,14 @@ fn provider_tests_require_version_three_and_an_owned_process() { /// identity and rejecting malformed lists instead of silently hiding tests. #[test] fn provider_test_discovery_is_bounded_deterministic_and_runnable() { - let manifest: Value = - serde_json::from_str(contract_file!("fixtures/build-providers/build-provider-tests.plugin.json")).unwrap(); - let fixture: Value = - serde_json::from_str(contract_file!("fixtures/build-providers/build-test-discovery.json")).unwrap(); + let manifest: Value = serde_json::from_str(contract_file!( + "fixtures/build-providers/build-provider-tests.plugin.json" + )) + .unwrap(); + let fixture: Value = serde_json::from_str(contract_file!( + "fixtures/build-providers/build-test-discovery.json" + )) + .unwrap(); let plan = build_test_discovery_plan_command(json!({"manifest":manifest,"buildSystemID":"gradle", "request":{"root":".","refresh":true}})) @@ -2449,8 +2474,10 @@ fn provider_run_preparation_selects_targets_and_validates_launch_data() { "fixtures/build-providers/build-provider-run-preparation.plugin.json" )) .unwrap(); - let fixture: Value = - serde_json::from_str(contract_file!("fixtures/build-providers/build-run-preparation.json")).unwrap(); + let fixture: Value = serde_json::from_str(contract_file!( + "fixtures/build-providers/build-run-preparation.json" + )) + .unwrap(); assert_eq!(validate(manifest.clone())["status"], "ok"); let plan = |request: Value| { build_prepare_run_plan_command( @@ -2557,10 +2584,14 @@ fn provider_debug_preparation_validates_native_target_and_same_plugin_adapter() "fixtures/build-providers/build-provider-debug-preparation.plugin.json" )) .unwrap(); - let fixture: Value = - serde_json::from_str(contract_file!("fixtures/build-providers/build-debug-preparation.json")).unwrap(); - let run: Value = - serde_json::from_str(contract_file!("fixtures/build-providers/build-run-preparation.json")).unwrap(); + let fixture: Value = serde_json::from_str(contract_file!( + "fixtures/build-providers/build-debug-preparation.json" + )) + .unwrap(); + let run: Value = serde_json::from_str(contract_file!( + "fixtures/build-providers/build-run-preparation.json" + )) + .unwrap(); assert_eq!(validate(manifest.clone())["status"], "ok"); let planned = build_prepare_debug_plan_command( json!({"manifest":manifest,"buildSystemID":"gradle","request":run["request"]}), @@ -2669,10 +2700,14 @@ fn provider_debug_target_bindings_keep_exact_arguments_and_require_bounded_facts #[test] fn model_inputs_project_public_facts_and_preserve_configuration_identity() { use crate::build::build_resolve_model_inputs_command; - let producer: Value = - serde_json::from_str(contract_file!("fixtures/build-systems/build-model-producer.plugin.json")).unwrap(); - let consumer: Value = - serde_json::from_str(contract_file!("fixtures/build-systems/build-model-consumer.plugin.json")).unwrap(); + let producer: Value = serde_json::from_str(contract_file!( + "fixtures/build-systems/build-model-producer.plugin.json" + )) + .unwrap(); + let consumer: Value = serde_json::from_str(contract_file!( + "fixtures/build-systems/build-model-consumer.plugin.json" + )) + .unwrap(); assert_eq!( validate(producer.clone())["status"], "ok", @@ -2746,10 +2781,14 @@ fn model_inputs_project_public_facts_and_preserve_configuration_identity() { #[test] fn model_inputs_reject_invalid_manifests_models_and_snapshot_smuggling() { use crate::build::build_resolve_model_inputs_command; - let producer: Value = - serde_json::from_str(contract_file!("fixtures/build-systems/build-model-producer.plugin.json")).unwrap(); - let consumer: Value = - serde_json::from_str(contract_file!("fixtures/build-systems/build-model-consumer.plugin.json")).unwrap(); + let producer: Value = serde_json::from_str(contract_file!( + "fixtures/build-systems/build-model-producer.plugin.json" + )) + .unwrap(); + let consumer: Value = serde_json::from_str(contract_file!( + "fixtures/build-systems/build-model-consumer.plugin.json" + )) + .unwrap(); for change in ["oldWire", "noCapability", "reexport", "unknownOption"] { let mut manifest = consumer.clone(); match change { @@ -2843,15 +2882,18 @@ fn provider_run_options_preserve_argv_and_validate_declared_execution_and_cwd() "fixtures/build-providers/build-provider-run-options.plugin.json" )) .unwrap(); - let fixture: Value = - serde_json::from_str(contract_file!("fixtures/build-providers/build-run-options.json")).unwrap(); + let fixture: Value = serde_json::from_str(contract_file!( + "fixtures/build-providers/build-run-options.json" + )) + .unwrap(); let workspace = TemporaryWorkspace::new("provider-run-options"); workspace.write("app/Main.java", "class Main {}"); workspace.write("tools/readme.txt", "working directory"); - let mut request: Value = - serde_json::from_str::(contract_file!("fixtures/build-providers/build-run-preparation.json")) - .unwrap()["request"] - .clone(); + let mut request: Value = serde_json::from_str::(contract_file!( + "fixtures/build-providers/build-run-preparation.json" + )) + .unwrap()["request"] + .clone(); request["root"] = json!("app"); request["run"] = json!("service"); request["workspaceRoot"] = json!(workspace.root()); @@ -2938,10 +2980,11 @@ fn provider_connection_debug_options_require_version_eleven_and_a_declared_regis let workspace = TemporaryWorkspace::new("provider-connection-debug"); workspace.write("app/Main.java", "class Main {}"); workspace.write("tools/readme.txt", "cwd"); - let mut request: Value = - serde_json::from_str::(contract_file!("fixtures/build-providers/build-run-preparation.json")) - .unwrap()["request"] - .clone(); + let mut request: Value = serde_json::from_str::(contract_file!( + "fixtures/build-providers/build-run-preparation.json" + )) + .unwrap()["request"] + .clone(); request["root"] = json!("app"); request["run"] = json!("service"); request["workspaceRoot"] = json!(workspace.root()); @@ -3007,8 +3050,10 @@ fn provider_run_options_refuse_explicit_cwd_symlinks_outside_workspace() { "fixtures/build-providers/build-provider-run-options.plugin.json" )) .unwrap(); - let mut fixture: Value = - serde_json::from_str(contract_file!("fixtures/build-providers/build-run-options.json")).unwrap(); + let mut fixture: Value = serde_json::from_str(contract_file!( + "fixtures/build-providers/build-run-options.json" + )) + .unwrap(); fixture["launchOptions"]["workingDirectory"] = json!("escape"); fixture["result"]["workingDirectory"] = json!("escape"); let result = build_decode_run_preparation_command(json!({"manifest":manifest,"buildSystemID":"gradle", diff --git a/rust/lithe-core/src/tests/declarative_plugins.rs b/rust/lithe-core/src/tests/declarative_plugins.rs index 77a715b5bb..23f7eb6952 100644 --- a/rust/lithe-core/src/tests/declarative_plugins.rs +++ b/rust/lithe-core/src/tests/declarative_plugins.rs @@ -19,27 +19,41 @@ const PHP_FIXTURE: &str = contract_file!("fixtures/languages/language-packaged-s const HELLO_FIXTURE: &str = contract_file!("fixtures/manifest/hello-lithe.plugin.json"); const WEB_PANEL_FIXTURE: &str = contract_file!("fixtures/ui/web-panel.plugin.json"); const GO_FIXTURE: &str = contract_file!("fixtures/languages/language-system-command.plugin.json"); -const JAVA_FIXTURE: &str = contract_file!("fixtures/languages/language-host-integration.plugin.json"); +const JAVA_FIXTURE: &str = + contract_file!("fixtures/languages/language-host-integration.plugin.json"); const NODE_RUNTIME_FIXTURE: &str = contract_file!("fixtures/languages/node-runtime.plugin.json"); -const PUBLISHER_BUNDLED_FIXTURE: &str = contract_file!("fixtures/package-trust/publisher-bundled.plugin.json"); -const CARGO_BUILD_FIXTURE: &str = contract_file!("fixtures/build-systems/build-static-file.plugin.json"); -const GO_BUILD_FIXTURE: &str = contract_file!("fixtures/build-systems/build-command-stdout.plugin.json"); -const GRADLE_BUILD_FIXTURE: &str = contract_file!("fixtures/build-systems/build-command-output-file.plugin.json"); -const MAVEN_BUILD_FIXTURE: &str = contract_file!("fixtures/build-systems/build-host-integration.plugin.json"); -const GRADLE_PROVIDER_FIXTURE: &str = contract_file!("fixtures/build-providers/build-provider.plugin.json"); +const PUBLISHER_BUNDLED_FIXTURE: &str = + contract_file!("fixtures/package-trust/publisher-bundled.plugin.json"); +const CARGO_BUILD_FIXTURE: &str = + contract_file!("fixtures/build-systems/build-static-file.plugin.json"); +const GO_BUILD_FIXTURE: &str = + contract_file!("fixtures/build-systems/build-command-stdout.plugin.json"); +const GRADLE_BUILD_FIXTURE: &str = + contract_file!("fixtures/build-systems/build-command-output-file.plugin.json"); +const MAVEN_BUILD_FIXTURE: &str = + contract_file!("fixtures/build-systems/build-host-integration.plugin.json"); +const GRADLE_PROVIDER_FIXTURE: &str = + contract_file!("fixtures/build-providers/build-provider.plugin.json"); const MIX_PROVIDER_FIXTURE: &str = contract_file!("fixtures/build-providers/build-provider-language-server.plugin.json"); const SETTINGS_V2_FIXTURE: &str = contract_file!("fixtures/settings/settings-v2.plugin.json"); const BACKEND_FIXTURE: &str = contract_file!("fixtures/backend/backend.plugin.json"); -const WORKSPACE_BACKEND_FIXTURE: &str = contract_file!("fixtures/backend/backend-workspace.plugin.json"); -const DEBUG_ADAPTER_FIXTURE: &str = contract_file!("fixtures/debug-adapters/debug-adapter.plugin.json"); +const WORKSPACE_BACKEND_FIXTURE: &str = + contract_file!("fixtures/backend/backend-workspace.plugin.json"); +const DEBUG_ADAPTER_FIXTURE: &str = + contract_file!("fixtures/debug-adapters/debug-adapter.plugin.json"); const DEBUG_ADAPTER_LANGUAGE_SERVER_FIXTURE: &str = contract_file!("fixtures/debug-adapters/debug-adapter-language-server.plugin.json"); const DEBUG_ADAPTER_REMOTE_ATTACH_FIXTURE: &str = contract_file!("fixtures/debug-adapters/debug-adapter-remote-attach.plugin.json"); -const DEBUG_ADAPTER_REJECTIONS: &str = contract_file!("fixtures/debug-adapters/debug-adapter-rejections.json"); +const DEBUG_ADAPTER_REJECTIONS: &str = + contract_file!("fixtures/debug-adapters/debug-adapter-rejections.json"); const EDITOR_ACTIONS_FIXTURE: &str = contract_file!("fixtures/editor/editor-actions.plugin.json"); const GRAMMAR_FIXTURE: &str = contract_file!("fixtures/languages/language-grammar.plugin.json"); +const PROJECT_GENERATOR_FIXTURE: &str = + contract_file!("fixtures/project-generators/project-generator.plugin.json"); +const PROJECT_GENERATOR_TOOL_FIXTURE: &str = + contract_file!("fixtures/project-generators/project-generator-tool-field.plugin.json"); const INVALID_MANIFESTS: &str = contract_file!("fixtures/manifest/invalid-manifests.json"); const COMMAND_PLANS: &str = contract_file!("fixtures/languages/command-plans.json"); const RUNTIME_VERSIONS: &str = contract_file!("fixtures/languages/runtime-versions.json"); @@ -69,6 +83,8 @@ fn fixture(name: &str) -> Value { "debug-adapter-language-server.plugin.json" => DEBUG_ADAPTER_LANGUAGE_SERVER_FIXTURE, "debug-adapter-remote-attach.plugin.json" => DEBUG_ADAPTER_REMOTE_ATTACH_FIXTURE, "language-grammar.plugin.json" => GRAMMAR_FIXTURE, + "project-generator.plugin.json" => PROJECT_GENERATOR_FIXTURE, + "project-generator-tool-field.plugin.json" => PROJECT_GENERATOR_TOOL_FIXTURE, other => panic!("unknown fixture {other}"), }; serde_json::from_str(source).expect("fixture manifest is JSON") @@ -94,6 +110,7 @@ fn contract_fixtures_and_official_plugins_validate() { DEBUG_ADAPTER_REMOTE_ATTACH_FIXTURE, GRAMMAR_FIXTURE, EDITOR_ACTIONS_FIXTURE, + PROJECT_GENERATOR_TOOL_FIXTURE, OFFICIAL_JAVA, ] { let outcome = validate(serde_json::from_str(source).unwrap()); @@ -2096,8 +2113,10 @@ fn debug_start_arguments_refuse_stale_registrations_and_missing_target_facts() { /// only their own methods rather than an entire growing service namespace. #[test] fn pure_backend_commands_require_ui_4_and_grant_methods_individually() { - let manifest: Value = - serde_json::from_str(contract_file!("fixtures/backend/command-backend.plugin.json")).unwrap(); + let manifest: Value = serde_json::from_str(contract_file!( + "fixtures/backend/command-backend.plugin.json" + )) + .unwrap(); assert_eq!(validate(manifest.clone())["status"], "ok"); let mut old = manifest.clone(); old["requires"]["ui"] = json!(3); @@ -2117,8 +2136,10 @@ fn pure_backend_commands_require_ui_4_and_grant_methods_individually() { #[test] fn terminal_permission_grants_only_owned_terminal_methods_and_requires_backend_two() { - let manifest: Value = - serde_json::from_str(contract_file!("fixtures/backend/terminal-backend.plugin.json")).unwrap(); + let manifest: Value = serde_json::from_str(contract_file!( + "fixtures/backend/terminal-backend.plugin.json" + )) + .unwrap(); assert_eq!(validate(manifest.clone())["status"], "ok"); let plan = language_server_launch_plan_command(json!({ "manifest": manifest, "backend": true, diff --git a/rust/lithe-core/src/tests/marketplace_plugins.rs b/rust/lithe-core/src/tests/marketplace_plugins.rs index 05cd5a1dbc..b92ec4e983 100644 --- a/rust/lithe-core/src/tests/marketplace_plugins.rs +++ b/rust/lithe-core/src/tests/marketplace_plugins.rs @@ -20,7 +20,8 @@ const SOURCE_URLS: &str = contract_file!("fixtures/marketplace/source-urls.json" const INSTALL_REVIEWS: &str = contract_file!("fixtures/marketplace/install-reviews.json"); const PHP_FIXTURE: &str = contract_file!("fixtures/languages/language-packaged-server.plugin.json"); const GO_FIXTURE: &str = contract_file!("fixtures/languages/language-system-command.plugin.json"); -const MIX_FIXTURE: &str = contract_file!("fixtures/build-providers/build-provider-language-server.plugin.json"); +const MIX_FIXTURE: &str = + contract_file!("fixtures/build-providers/build-provider-language-server.plugin.json"); const GRADLE_FIXTURE: &str = contract_file!("fixtures/build-providers/build-provider.plugin.json"); const CARGO_FIXTURE: &str = contract_file!("fixtures/build-systems/build-static-file.plugin.json"); const OFFICIAL_INDEX: &str = include_str!(concat!( diff --git a/rust/lithe-core/src/tests/plugin_editor_resources.rs b/rust/lithe-core/src/tests/plugin_editor_resources.rs index 45d84a9504..5e1e1c2c20 100644 --- a/rust/lithe-core/src/tests/plugin_editor_resources.rs +++ b/rust/lithe-core/src/tests/plugin_editor_resources.rs @@ -195,7 +195,9 @@ fn configuration_only_contribution_decodes_without_grammar() { .unwrap() .remove("grammar"); // A backend is a complete tool plugin, with no language-server ownership. - let tool = manifest(contract_file!("fixtures/backend/backend-workspace.plugin.json")); + let tool = manifest(contract_file!( + "fixtures/backend/backend-workspace.plugin.json" + )); plugin["backend"] = tool["backend"].clone(); plugin["requires"]["backend"] = json!(2); plugin["requires"]["serverProcess"] = json!(1); diff --git a/rust/lithe-core/src/tests/project_generators.rs b/rust/lithe-core/src/tests/project_generators.rs index 2efd03b5b9..fb59f1eed2 100644 --- a/rust/lithe-core/src/tests/project_generators.rs +++ b/rust/lithe-core/src/tests/project_generators.rs @@ -15,6 +15,14 @@ const PLANS: &str = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), "/../../shared/contracts/plugins/v1/fixtures/project-generators/project-plans.json" )); +const TOOL_FIELD_FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/contracts/plugins/v1/fixtures/project-generators/project-generator-tool-field.plugin.json" +)); +const TOOL_DEFINITION_FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/contracts/plugins/v1/fixtures/toolchains/tool-definition.plugin.json" +)); const MAVEN_BUILD_FIXTURE: &str = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), "/../../shared/contracts/plugins/v1/fixtures/build-systems/build-host-integration.plugin.json" @@ -64,16 +72,28 @@ fn official_package_path() -> PathBuf { fn host() -> Value { json!({"languages": [1], "languageServers": [1, 2], "serverProcess": [1], "settings": [1, 2], - "buildSystems": [1], "projectGenerators": [1], "debugAdapters": [1], "packageTrust": [1], "toolDefinitions": [1], "executableResolution": [1, 2]}) + "buildSystems": [1], "projectGenerators": [1, 2], "debugAdapters": [1], "packageTrust": [1], "toolDefinitions": [1], "executableResolution": [1, 2]}) +} + +/// A case names its manifest fixture in `manifest`; the default is the +/// version 1 generator fixture. +fn case_manifest(case: &Value) -> Value { + match case["manifest"].as_str() { + Some(file) => json( + &std::fs::read_to_string(fixtures_path().join(file)) + .unwrap_or_else(|error| panic!("{file}: {error}")), + ), + None => json(FIXTURE), + } } #[test] fn golden_project_plans_match() { let plans = json(PLANS); assert_eq!(plans["schemaVersion"], 1); - let manifest = json(FIXTURE); for case in plans["cases"].as_array().unwrap() { let name = case["name"].as_str().unwrap(); + let manifest = case_manifest(case); let actual = match case["operation"].as_str().unwrap() { "generatorForm" => project_generator_form_command(json!({ "manifest": manifest, @@ -149,6 +169,116 @@ fn catalog_skips_plugins_the_host_cannot_run() { ); } +/// The catalog tells the host which plugins declare the tool a field selects, +/// so a field with no installation can point at the plugin to enable. A tool +/// nobody declares has no provider, which is how an unknown ID shows up. +#[test] +fn catalog_names_the_plugins_that_define_each_tool() { + // Read the names from the definition fixture, so the test follows it and + // the host keeps no tool name of its own. + let definition = json(TOOL_DEFINITION_FIXTURE); + let definer = definition["id"].as_str().unwrap().to_string(); + let tool = definition["toolDefinitions"][0]["id"] + .as_str() + .unwrap() + .to_string(); + let mut generators = json(TOOL_FIELD_FIXTURE); + // Point the first field at the tool the definition fixture declares. + generators["projectGenerators"][0]["fields"][0]["tool"] = json!(tool); + let result = project_generator_catalog_command(json!({ + "plugins": [ + {"manifest": generators, "enabled": true}, + {"manifest": json(TOOL_DEFINITION_FIXTURE), "enabled": false}, + ], + "hostCapabilities": host(), + })) + .unwrap(); + assert_eq!(result["status"], "ok", "{result}"); + assert_eq!(result["catalog"]["issues"], json!([]), "{result}"); + let entries = result["catalog"]["generators"].as_array().unwrap(); + assert_eq!( + entries[0]["tools"], + json!([{ + "fieldID": "python", "tool": tool, + "providers": [{ + "pluginID": definer, + "pluginName": "Tool definition fixture", + "enabled": false + }] + }]) + ); + assert_eq!( + entries[1]["tools"], + json!([{"fieldID": "node", "tool": "node", "providers": []}]) + ); + // A third-party definition is addressed as `:`; that spelling + // names the same declaration, but another plugin's prefix does not. + for (tool, provider_count) in [ + (format!("{definer}:{tool}"), 1), + (format!("dev.lithe.fixture.other:{tool}"), 0), + ] { + let mut generators = json(TOOL_FIELD_FIXTURE); + generators["projectGenerators"][0]["fields"][0]["tool"] = json!(tool); + let qualified = project_generator_catalog_command(json!({ + "plugins": [ + {"manifest": generators, "enabled": true}, + {"manifest": json(TOOL_DEFINITION_FIXTURE), "enabled": true}, + ], + "hostCapabilities": host(), + })) + .unwrap(); + let providers = &qualified["catalog"]["generators"][0]["tools"][0]["providers"]; + assert_eq!( + providers.as_array().unwrap().len(), + provider_count, + "{tool}" + ); + } + // Version 1 generators have no tool fields. + let version_1 = project_generator_catalog_command(json!({ + "plugins": [{"manifest": json(FIXTURE), "enabled": true}], + "hostCapabilities": host(), + })) + .unwrap(); + assert_eq!(version_1["catalog"]["generators"][0]["tools"], json!([])); +} + +#[test] +fn catalog_skips_tool_fields_on_hosts_without_projectgenerators_2() { + let result = project_generator_catalog_command(json!({ + "plugins": [{"manifest": json(TOOL_FIELD_FIXTURE), "enabled": true}], + "hostCapabilities": {"projectGenerators": [1]}, + })) + .unwrap(); + assert_eq!(result["catalog"]["generators"], json!([])); + assert_eq!( + result["catalog"]["issues"][0]["error"]["field"], + "requires.projectGenerators" + ); +} + +/// A form value naming an installation the host did not list never reaches +/// the plan: the plan is built from the newest listed installation instead. +#[test] +fn a_plan_never_uses_an_installation_the_host_did_not_list() { + let plan = project_generation_plan_command(json!({ + "manifest": json(TOOL_FIELD_FIXTURE), + "generatorID": "python-app", + "packagePath": fixtures_path(), + "form": { + "projectName": "demo", + "values": {"python": "/tmp/planted"}, + "tools": {"python": [ + {"home": "/opt/python-3.12", "executable": "/opt/python-3.12/bin/python3", "version": "3.12.4"} + ]}, + }, + })) + .unwrap(); + assert_eq!(plan["status"], "ok", "{plan}"); + assert_eq!(plan["plan"]["toolPins"][0]["home"], "/opt/python-3.12"); + assert!(!plan.to_string().contains("/tmp/planted"), "{plan}"); +} + #[test] fn official_java_templates_validate_and_plan() { let manifest = json(OFFICIAL_JAVA); diff --git a/scripts/architecture/verify-shared-contracts.sh b/scripts/architecture/verify-shared-contracts.sh index 5cadb0fa79..68bb2d84f9 100755 --- a/scripts/architecture/verify-shared-contracts.sh +++ b/scripts/architecture/verify-shared-contracts.sh @@ -214,6 +214,11 @@ done abort "plan case #{item.fetch("name")} uses an undocumented code" unless documented.include?(expected.fetch("code")) end end + # A project plan case runs against the version 1 generator fixture unless it names another. + JSON.parse(File.read(File.join(fixtures, "project-generators", "project-plans.json"))).fetch("cases").each do |item| + next unless item.key?("manifest") + abort "project plan case #{item.fetch("name")} uses an unknown manifest" unless fixture_names.include?(item.fetch("manifest")) + end build_operations = %w[ invocationPlan checkToolVersion resolveOptions modelPlan taskPlan testPlan dependencyPlan decodeModel decodeTestResults decodeDiagnostics decodeDependencies syncFingerprint diff --git a/shared/contracts/plugins/v1/README.md b/shared/contracts/plugins/v1/README.md index 54f53f48c6..0a3c32a6ee 100644 --- a/shared/contracts/plugins/v1/README.md +++ b/shared/contracts/plugins/v1/README.md @@ -30,7 +30,7 @@ This directory is the cross-platform source of truth: | `fixtures/debug-adapters/` | Debug adapters (standalone, served by a language server, remote attach) and `debug-adapter-rejections.json` | | `fixtures/vcs/` | A version control provider | | `fixtures/keymap-import/` | Two independent shortcut import providers sharing one packaged backend | -| `fixtures/project-generators/` | Project generators: template files (`templates/`), a scaffolding command, and an archive download; `project-plans.json` holds golden `project.*` form evaluations, generation plans, and name checks | +| `fixtures/project-generators/` | Project generators: template files (`templates/`), a scaffolding command, and an archive download; `project-generator-tool-field.plugin.json` with `templates/tool-field/` picks tool installations with `tool` fields (`projectGenerators` 2); `project-plans.json` holds golden `project.*` form evaluations, generation plans, and name checks, and a case's optional `manifest` names the fixture it runs against (the version 1 generator by default) | | `fixtures/toolchains/` | Production-shape fixtures for tool definitions and a packaged toolchain provider; they require `toolDefinitions` and `toolchainProviders`, which only macOS advertises. `proposal/` holds the examples and cases of the toolchains draft | | `fixtures/package-trust/` | `publisher-bundled.plugin.json` and `signature-vectors.json`: public cross-platform vectors for the package publisher signature: the RFC 8032 test key, a fixed package with non-ASCII file names, its canonical payload, RFC 8032 deterministic signature, and content digest. Both platforms must build the same canonical payload and digest and accept the recorded signature; signers that add randomness, such as CryptoKit, check the signature by verification instead of byte comparison | | `fixtures/marketplace/` | Marketplace index, availability, source URL, and install review golden cases | @@ -673,7 +673,7 @@ hosts. Level 3 versions each section on its own. A level 3 manifest lists in | `buildModelInputs` | Optional public model sharing between enabled workspace plugins | `1` | | `vcsProviders` | `vcsProviders[]` | `1` | | `debugAdapters` | [`debugAdapters[]`](#api-level-4-debug-adapters); v2 adds target arguments/environment | `1`, `2` | -| `projectGenerators` | [`projectGenerators[]`](#project-generators): entries of the New Project window | `1` | +| `projectGenerators` | [`projectGenerators[]`](#project-generators): entries of the New Project window; v2 adds the [`tool` field](#tool-fields) | `1`, `2` | | `backend` | [`backend`](#plugin-backend): the plugin's own long-lived process | `1`, `2` (adds [start without a page and workspace contexts](#backend-2-workspace-contexts)) | | `toolDefinitions` | [Tool identities, layouts, discovery and four-layer candidates](toolchains/README.md) | `1` (Core; macOS settings pages, Build and run tools) | | `toolchainProviders` | [Provider declarations and permission-bound answer validation](toolchains/README.md) | `1` (Core; macOS settings pages, Build and run tools) | @@ -2030,8 +2030,8 @@ process starts and what it learns about workspaces. ## Project generators -`projectGenerators` (requires `projectGenerators` 1 and the `project.generate` -permission) adds entries to the host's New Project window. The host owns the +`projectGenerators` (requires `projectGenerators` 1 or 2 and the +`project.generate` permission) adds entries to the host's New Project window. The host owns the window: the entry list and its search, the project name and location, the "create Git repository" option, the empty project, and the Create button. A generator declares only its own fields, the languages and build systems the @@ -2074,7 +2074,8 @@ generator ID. | `enum` | One option | `options` (1–16 `{value, title}`), `default` (the first option otherwise), `presentation` (`segmented` or `menu`), `maximumVersion` | | `boolean` | `true` or `false` | `default` | | `multiSelect` | A list of options, kept in declaration order | `options` (1–128, each with an optional `group` heading), `default` | -| `runtime` | A host-discovered installation | `runtime` (`java`), `minimumVersion`, `optional`. The host chooses the initial value. | +| `runtime` | A host-discovered installation (`projectGenerators` 1 only) | `runtime` (`java`), `minimumVersion`, `optional`. The host chooses the initial value. | +| `tool` | An installation of a [tool definition](toolchains/README.md), by home directory (`projectGenerators` 2 only) | `tool` (a tool definition ID), `minimumVersion`, `pinAs`, `optional`. See [Tool fields](#tool-fields). | `maximumVersion` is a template over earlier fields naming the highest version the value may be, compared numerically by dotted components. A target Java @@ -2093,21 +2094,61 @@ directory name: not empty, at most 128 characters, no `/`, `\`, `:`, or control characters, not `.` or `..`, no leading `-`, and no surrounding spaces. +### Tool fields + +A `tool` field (requires `projectGenerators` 2) lets the user pick one +installation of any tool that a plugin declares in +[`toolDefinitions`](toolchains/README.md), such as a Python interpreter, a Node.js +runtime, or a JDK. The generator names no host feature and no language: the +host lists installations through its generic tool discovery and the user's +tool settings, exactly as the tool's own settings page does. + +```json +{"id": "sdk", "type": "tool", "title": "Python", "tool": "python", + "minimumVersion": "3.11", "pinAs": "project"} +``` + +| Property | Meaning | +| --- | --- | +| `tool` | Required. A tool definition ID: the short ID of a verified official definition, otherwise `:`. There is no closed list; a tool nobody declares has no installations. | +| `minimumVersion` | Optional, one to three dotted numbers. An installation below it is not selectable. | +| `pinAs` | Optional. `project` saves the selection as the created project's own tool choice (the same per-directory pin the tool's settings page edits), so the project builds and runs with the installation picked here. Without it the selection only feeds the steps. | +| `optional` | Optional. Without a usable installation an optional field stays empty and leaves no pin; otherwise the form reports `required`. An installation below `minimumVersion` is not usable. | + +A tool field has no `default`; the host chooses the initial value. Core picks, +among the installations the host listed, the user's choice if it is still +listed and otherwise the newest version (the host's first among equal +versions). Versions compare as releases, so trailing zeros do not count: +`3.11`, `3.11.0`, and `3.11.0.0` are equal. An installation below +`minimumVersion` is marked in the field's `installations`, so the host can +show it disabled. When the selection is below `minimumVersion` the form +reports `toolTooOld`, except that an optional field the user has not set +stays empty rather than picking an installation that is too old. + +A generator with `projectGenerators` 2 must not declare a `runtime` field, and +a generator with `projectGenerators` 1 must not declare a `tool` field +(`plugin.manifest.invalid`). Version 1 generators keep working unchanged. +A host that lacks `projectGenerators` 2 refuses the whole manifest with +`plugin.api.unsupported` (`requires.projectGenerators`). + ### Conditions `when` and `visibleWhen` take one condition or a list that must all hold: `{"field": "", "equals" | "notEquals": }` or `{"field": "", "oneOf": [, …]}`. Values are option values for `enum`, `true`/`false` -for `boolean`, and strings for `text` and `runtime` (`notEquals: ""` tests -that an optional field is filled). A condition on a hidden field is false. +for `boolean`, and strings for `text`, `runtime`, and `tool` (`notEquals: ""` +tests that an optional field is filled; a tool field compares as its home +directory). A condition on a hidden field is false. ### Templates Strings in the manifest use `${…}`: `${project.name}`, `${field.}` (a -text or option value, `true`/`false`, a comma-separated list, or a runtime's -home directory), `${field..version}`, and `${field..major}` (`21` for -`21.0.4`, `8` for `1.8.0_412`). Modifiers follow `|` and apply in order: -`path` turns dots into `/`, `package` makes a valid package name +text or option value, `true`/`false`, a comma-separated list, or the home +directory of a runtime or tool), `${field..version}` and +`${field..major}` (runtime fields only; `21` for `21.0.4`, `8` for +`1.8.0_412`), and `${field..executable}` (tool fields only: the path of +the tool's executable in the chosen installation). Modifiers follow `|` and +apply in order: `path` turns dots into `/`, `package` makes a valid package name (`com.Example.my-app` → `com.example.myapp`), and `lower` lowercases. Template files use `{{ … }}` with the same variables, because POMs, Gradle @@ -2137,26 +2178,43 @@ file, and 8 MiB in total; archives to 20,000 entries and 512 MiB extracted. | Command | Payload | Result | | --- | --- | --- | -| `project.generatorCatalog` | `{plugins: [{manifest, enabled}], hostCapabilities?}` | `catalog: {generators: [{pluginID, pluginName, pluginEnabled, generator, requirements: [{languageID or buildSystemID, providers: [{pluginID, pluginName, enabled}]}], usesNetwork}], issues: [{pluginID, error}]}` | -| `project.generatorForm` | `{manifest, generatorID, form}` | `form: {projectName: {problem}, fields: [{id, visible, value, problem?}], activeRequirements, valid}` | -| `project.generationPlan` | `{manifest, generatorID, packagePath, form}` | `plan: {projectName, steps, openFiles, runtimes: [{field, runtime, home, version}], usesNetwork}` | +| `project.generatorCatalog` | `{plugins: [{manifest, enabled}], hostCapabilities?}` | `catalog: {generators: [{pluginID, pluginName, pluginEnabled, generator, requirements: [{languageID or buildSystemID, providers: [{pluginID, pluginName, enabled}]}], tools: [{fieldID, tool, providers: [{pluginID, pluginName, enabled}]}], usesNetwork}], issues: [{pluginID, error}]}` | +| `project.generatorForm` | `{manifest, generatorID, form}` | `form: {projectName: {problem}, fields: [{id, visible, value, problem?, installations?}], activeRequirements, valid}` | +| `project.generationPlan` | `{manifest, generatorID, packagePath, form}` | `plan: {projectName, steps, openFiles, runtimes: [{field, runtime, home, version}], toolPins: [{field, tool, pinAs, home, executable}], usesNetwork}` | | `project.checkName` | `{projectName}` | `name: {problem}` | | `project.extractArchive` | `{archivePath, destination}` | `extracted: {files}` | -`form` is `{projectName, values, edited, runtimes}`. `values` maps field IDs -to values; a text value counts only when its ID is in `edited`, so derived +`form` is `{projectName, values, edited, runtimes, tools}`. `values` maps field +IDs to values; a text value counts only when its ID is in `edited`, so derived defaults keep following the fields they read. `runtimes` maps runtime field -IDs to `{home, version}` of the user's selection. A field `problem` is one of -`required`, `tooLong`, `invalidFormat`, `unsupportedValue`, `runtimeTooOld`, -and `versionTooHigh`; hosts localize by code. Plan steps are `{kind: +IDs to `{home, version}` of the user's selection. `tools` maps tool field IDs +to the installations the host found for the field's tool, in the order the +host prefers them, as `[{home, executable, version}]`; the field's `values` +entry is the `home` of the user's choice. A tool field in the result carries +`installations: [{home, executable, version, meetsMinimum}]`, so the host +draws its picker from Core's answer, and its `value` is the installation Core +selected. A field `problem` is one of `required`, `tooLong`, `invalidFormat`, +`unsupportedValue`, `runtimeTooOld`, `toolTooOld`, and `versionTooHigh`; hosts +localize by code. Plan steps are `{kind: "writeFiles", files: [{path, contents, executable}]}`, `{kind: "command", command, arguments, environment, collect, mayUseNetwork, timeoutSeconds}`, and -`{kind: "archive", url, host, format: "zip"}`. +`{kind: "archive", url, host, format: "zip"}`. `toolPins` lists, for each +visible tool field with `pinAs` and an installation, the tool and the chosen +`home` and `executable` that the host saves as the project's pin. + +The catalog entry's `tools` lists, for each tool field, the plugins that +declare the field's tool and whether they are enabled, in the same way as +`requirements`: the host can offer to enable one, or open the marketplace, +when the field has no installation. Core only names declarations; whether a +definition is trusted, and whether the tool is installed, is decided by the +host's tool registry and discovery. After the steps, the host moves the staging directory to its final location -(refusing an existing one), may record a `java` runtime as the project's JDK, -creates the Git repository when asked, and opens the project. A failure or -cancellation removes the staging directory and leaves nothing behind +(refusing an existing one), may record a `java` runtime as the project's JDK +(version 1 runtime fields only), saves the `toolPins` as the project's tool +choices through the same store the tool settings page edits, creates the Git +repository when asked, and opens the project. A failure or cancellation +removes the staging directory and leaves nothing behind (`plugin.project.generationFailed`). ## Plugin functions diff --git a/shared/contracts/plugins/v1/fixtures/manifest/invalid-manifests.json b/shared/contracts/plugins/v1/fixtures/manifest/invalid-manifests.json index 5ca1ec5364..65ce2362f5 100644 --- a/shared/contracts/plugins/v1/fixtures/manifest/invalid-manifests.json +++ b/shared/contracts/plugins/v1/fixtures/manifest/invalid-manifests.json @@ -1054,6 +1054,15 @@ {"name": "languages 2 is used by a grammar or configuration", "base": "language-grammar.plugin.json", "edits": [{"op": "remove", "path": "/languages/0/grammar"}, {"op": "remove", "path": "/languages/0/configuration"}], "expectedCode": "plugin.manifest.invalid"}, {"name": "a grammar names a known engine", "base": "language-grammar.plugin.json", "edits": [{"op": "set", "path": "/languages/0/grammar/kind", "value": "textmate"}], "expectedCode": "plugin.manifest.invalid"}, {"name": "a grammar file stays in the package", "base": "language-grammar.plugin.json", "edits": [{"op": "set", "path": "/languages/0/grammar/path", "value": "../http.monarch.json"}], "expectedCode": "plugin.manifest.invalid"}, - {"name": "a comment delimiter is one short line", "base": "language-grammar.plugin.json", "edits": [{"op": "set", "path": "/languages/0/configuration/lineComment", "value": "#\n"}], "expectedCode": "plugin.manifest.invalid"} + {"name": "a comment delimiter is one short line", "base": "language-grammar.plugin.json", "edits": [{"op": "set", "path": "/languages/0/configuration/lineComment", "value": "#\n"}], "expectedCode": "plugin.manifest.invalid"}, + {"name": "a tool field needs projectGenerators 2", "base": "project-generator.plugin.json", "edits": [{"op": "set", "path": "/projectGenerators/0/fields/1", "value": {"id": "sdk", "type": "tool", "title": "SDK", "tool": "python"}}], "expectedCode": "plugin.manifest.invalid"}, + {"name": "projectGenerators 2 has no runtime field", "base": "project-generator.plugin.json", "edits": [{"op": "set", "path": "/requires/projectGenerators", "value": 2}], "expectedCode": "plugin.manifest.invalid"}, + {"name": "a tool field names a tool definition ID", "base": "project-generator-tool-field.plugin.json", "edits": [{"op": "set", "path": "/projectGenerators/0/fields/0/tool", "value": "Python Interpreter"}], "expectedCode": "plugin.manifest.invalid"}, + {"name": "a tool field names a tool", "base": "project-generator-tool-field.plugin.json", "edits": [{"op": "remove", "path": "/projectGenerators/0/fields/0/tool"}], "expectedCode": "plugin.manifest.invalid"}, + {"name": "a tool field minimum version is dotted numbers", "base": "project-generator-tool-field.plugin.json", "edits": [{"op": "set", "path": "/projectGenerators/0/fields/0/minimumVersion", "value": "latest"}], "expectedCode": "plugin.manifest.invalid"}, + {"name": "a tool field is pinned to a known scope", "base": "project-generator-tool-field.plugin.json", "edits": [{"op": "set", "path": "/projectGenerators/0/fields/0/pinAs", "value": "workspace"}], "expectedCode": "plugin.manifest.invalid"}, + {"name": "the host chooses a tool field's initial installation", "base": "project-generator-tool-field.plugin.json", "edits": [{"op": "set", "path": "/projectGenerators/0/fields/0/default", "value": "/usr/bin"}], "expectedCode": "plugin.manifest.invalid"}, + {"name": "only a tool field has an executable", "base": "project-generator-tool-field.plugin.json", "edits": [{"op": "set", "path": "/projectGenerators/0/steps/1/arguments/2", "value": "${field.venv.executable}"}], "expectedCode": "plugin.manifest.invalid"}, + {"name": "a tool property needs a tool field", "base": "project-generator-tool-field.plugin.json", "edits": [{"op": "set", "path": "/projectGenerators/0/fields/1/tool", "value": "python"}], "expectedCode": "plugin.manifest.invalid"} ] } diff --git a/shared/contracts/plugins/v1/fixtures/project-generators/project-generator-tool-field.plugin.json b/shared/contracts/plugins/v1/fixtures/project-generators/project-generator-tool-field.plugin.json new file mode 100644 index 0000000000..4994323ff1 --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/project-generators/project-generator-tool-field.plugin.json @@ -0,0 +1,45 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.fixture.project-generator-tool-field", + "displayName": "Project Generator Tool Field Fixture", + "version": "1.0.0", + "pluginAPIVersion": 3, + "requires": {"projectGenerators": 2}, + "vendor": {"id": "org.example", "displayName": "Example", "signatureRequirement": "publisherPackage"}, + "permissions": ["project.generate"], + "activationEvents": [], + "projectGenerators": [ + { + "id": "python-app", + "displayName": "Python App", + "section": "project", + "fields": [ + {"id": "python", "type": "tool", "title": "Python", "tool": "python", + "minimumVersion": "3.11", "pinAs": "project"}, + {"id": "venv", "type": "boolean", "title": "Create a virtual environment", "default": true} + ], + "steps": [ + {"kind": "files", "source": "templates/tool-field"}, + {"kind": "command", "command": "uv", "mayUseNetwork": false, + "arguments": ["venv", "--python", "${field.python.executable}"], + "environment": {"PYTHON_HOME": "${field.python}"}, + "when": {"field": "venv", "equals": true}} + ] + }, + { + "id": "node-app", + "displayName": "Node App", + "section": "generator", + "fields": [ + {"id": "node", "type": "tool", "title": "Node.js", "tool": "node", + "minimumVersion": "18.0.0", "pinAs": "project", "optional": true} + ], + "steps": [ + {"kind": "command", "command": "npm", "mayUseNetwork": true, + "arguments": ["init", "--yes"], + "environment": {"NODE": "${field.node.executable}"}, + "when": {"field": "node", "notEquals": ""}} + ] + } + ] +} diff --git a/shared/contracts/plugins/v1/fixtures/project-generators/project-plans.json b/shared/contracts/plugins/v1/fixtures/project-generators/project-plans.json index f35b509279..67324d9eba 100644 --- a/shared/contracts/plugins/v1/fixtures/project-generators/project-plans.json +++ b/shared/contracts/plugins/v1/fixtures/project-generators/project-plans.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "description": "Golden answers of the project.* commands for project-generator.plugin.json. generationPlan cases read templates with fixtures/project-generators as the package path.", + "description": "Golden answers of the project.* commands for project-generator.plugin.json, or for the fixture a case names in manifest (a file name in fixtures/project-generators). generationPlan cases read templates with fixtures/project-generators as the package path.", "cases": [ { "name": "form defaults", @@ -201,6 +201,7 @@ "version": "21.0.4" } ], + "toolPins": [], "usesNetwork": false } } @@ -277,6 +278,7 @@ "version": "21.0.4" } ], + "toolPins": [], "usesNetwork": true } } @@ -308,6 +310,7 @@ ], "openFiles": [], "runtimes": [], + "toolPins": [], "usesNetwork": true } } @@ -334,6 +337,369 @@ } } }, + { + "name": "tool form selects the newest installation", + "manifest": "project-generator-tool-field.plugin.json", + "operation": "generatorForm", + "generatorID": "python-app", + "form": { + "projectName": "demo", + "tools": { + "python": [ + {"home": "/opt/python-3.10", "executable": "/opt/python-3.10/bin/python3", "version": "3.10.14"}, + {"home": "/opt/python-3.12", "executable": "/opt/python-3.12/bin/python3", "version": "3.12.4"} + ] + } + }, + "expected": { + "status": "ok", + "form": { + "projectName": {"problem": null}, + "fields": [ + { + "id": "python", + "visible": true, + "value": "/opt/python-3.12", + "installations": [ + {"home": "/opt/python-3.10", "executable": "/opt/python-3.10/bin/python3", "version": "3.10.14", "meetsMinimum": false}, + {"home": "/opt/python-3.12", "executable": "/opt/python-3.12/bin/python3", "version": "3.12.4", "meetsMinimum": true} + ] + }, + {"id": "venv", "visible": true, "value": true} + ], + "activeRequirements": [], + "valid": true + } + } + }, + { + "name": "tool form keeps the chosen installation", + "manifest": "project-generator-tool-field.plugin.json", + "operation": "generatorForm", + "generatorID": "python-app", + "form": { + "projectName": "demo", + "values": {"python": "/opt/python-3.11"}, + "tools": { + "python": [ + {"home": "/opt/python-3.11", "executable": "/opt/python-3.11/bin/python3", "version": "3.11.9"}, + {"home": "/opt/python-3.13", "executable": "/opt/python-3.13/bin/python3", "version": "3.13.1"} + ] + } + }, + "expected": { + "status": "ok", + "form": { + "projectName": {"problem": null}, + "fields": [ + { + "id": "python", + "visible": true, + "value": "/opt/python-3.11", + "installations": [ + {"home": "/opt/python-3.11", "executable": "/opt/python-3.11/bin/python3", "version": "3.11.9", "meetsMinimum": true}, + {"home": "/opt/python-3.13", "executable": "/opt/python-3.13/bin/python3", "version": "3.13.1", "meetsMinimum": true} + ] + }, + {"id": "venv", "visible": true, "value": true} + ], + "activeRequirements": [], + "valid": true + } + } + }, + { + "name": "tool form reports an installation below the minimum", + "manifest": "project-generator-tool-field.plugin.json", + "operation": "generatorForm", + "generatorID": "python-app", + "form": { + "projectName": "demo", + "tools": { + "python": [ + {"home": "/opt/python-3.10", "executable": "/opt/python-3.10/bin/python3", "version": "3.10.14"} + ] + } + }, + "expected": { + "status": "ok", + "form": { + "projectName": {"problem": null}, + "fields": [ + { + "id": "python", + "visible": true, + "value": "/opt/python-3.10", + "problem": "toolTooOld", + "installations": [ + {"home": "/opt/python-3.10", "executable": "/opt/python-3.10/bin/python3", "version": "3.10.14", "meetsMinimum": false} + ] + }, + {"id": "venv", "visible": true, "value": true} + ], + "activeRequirements": [], + "valid": false + } + } + }, + { + "name": "tool form without an installation is required", + "manifest": "project-generator-tool-field.plugin.json", + "operation": "generatorForm", + "generatorID": "python-app", + "form": {"projectName": "demo"}, + "expected": { + "status": "ok", + "form": { + "projectName": {"problem": null}, + "fields": [ + {"id": "python", "visible": true, "value": "", "problem": "required"}, + {"id": "venv", "visible": true, "value": true} + ], + "activeRequirements": [], + "valid": false + } + } + }, + { + "name": "plan expands the tool and pins it", + "manifest": "project-generator-tool-field.plugin.json", + "operation": "generationPlan", + "generatorID": "python-app", + "form": { + "projectName": "demo", + "tools": { + "python": [ + {"home": "/opt/python-3.12", "executable": "/opt/python-3.12/bin/python3", "version": "3.12.4"} + ] + } + }, + "expected": { + "status": "ok", + "plan": { + "projectName": "demo", + "steps": [ + { + "kind": "writeFiles", + "files": [ + { + "path": "README.md", + "contents": "# demo\n\nInterpreter: /opt/python-3.12/bin/python3\nInstallation: /opt/python-3.12\n", + "executable": false + } + ] + }, + { + "kind": "command", + "command": "uv", + "arguments": ["venv", "--python", "/opt/python-3.12/bin/python3"], + "environment": {"PYTHON_HOME": "/opt/python-3.12"}, + "collect": null, + "mayUseNetwork": false, + "timeoutSeconds": 600 + } + ], + "openFiles": [], + "runtimes": [], + "toolPins": [ + { + "field": "python", + "tool": "python", + "pinAs": "project", + "home": "/opt/python-3.12", + "executable": "/opt/python-3.12/bin/python3" + } + ], + "usesNetwork": false + } + } + }, + { + "name": "plan refuses a tool below the minimum", + "manifest": "project-generator-tool-field.plugin.json", + "operation": "generationPlan", + "generatorID": "python-app", + "form": { + "projectName": "demo", + "tools": { + "python": [ + {"home": "/opt/python-3.10", "executable": "/opt/python-3.10/bin/python3", "version": "3.10.14"} + ] + } + }, + "expected": { + "status": "rejected", + "error": { + "code": "plugin.project.invalidInput", + "message": "The value of python is not accepted (toolTooOld).", + "field": "values.python" + } + } + }, + { + "name": "plan leaves an optional tool without an installation unpinned", + "manifest": "project-generator-tool-field.plugin.json", + "operation": "generationPlan", + "generatorID": "node-app", + "form": {"projectName": "demo"}, + "expected": { + "status": "ok", + "plan": { + "projectName": "demo", + "steps": [], + "openFiles": [], + "runtimes": [], + "toolPins": [], + "usesNetwork": false + } + } + }, + { + "name": "plan pins an optional tool that has an installation", + "manifest": "project-generator-tool-field.plugin.json", + "operation": "generationPlan", + "generatorID": "node-app", + "form": { + "projectName": "demo", + "tools": { + "node": [ + {"home": "/opt/node-22", "executable": "/opt/node-22/bin/node", "version": "22.11.0"} + ] + } + }, + "expected": { + "status": "ok", + "plan": { + "projectName": "demo", + "steps": [ + { + "kind": "command", + "command": "npm", + "arguments": ["init", "--yes"], + "environment": {"NODE": "/opt/node-22/bin/node"}, + "collect": null, + "mayUseNetwork": true, + "timeoutSeconds": 600 + } + ], + "openFiles": [], + "runtimes": [], + "toolPins": [ + { + "field": "node", + "tool": "node", + "pinAs": "project", + "home": "/opt/node-22", + "executable": "/opt/node-22/bin/node" + } + ], + "usesNetwork": true + } + } + }, + { + "name": "tool form treats a release with fewer trailing zeros as the same release", + "manifest": "project-generator-tool-field.plugin.json", + "operation": "generatorForm", + "generatorID": "node-app", + "form": { + "projectName": "demo", + "tools": { + "node": [ + {"home": "/opt/node-18", "executable": "/opt/node-18/bin/node", "version": "18"}, + {"home": "/opt/node-18.0", "executable": "/opt/node-18.0/bin/node", "version": "18.0.0"} + ] + } + }, + "expected": { + "status": "ok", + "form": { + "projectName": {"problem": null}, + "fields": [ + { + "id": "node", + "visible": true, + "value": "/opt/node-18", + "installations": [ + {"home": "/opt/node-18", "executable": "/opt/node-18/bin/node", "version": "18", "meetsMinimum": true}, + {"home": "/opt/node-18.0", "executable": "/opt/node-18.0/bin/node", "version": "18.0.0", "meetsMinimum": true} + ] + } + ], + "activeRequirements": [], + "valid": true + } + } + }, + { + "name": "tool form leaves an optional tool with only old installations empty", + "manifest": "project-generator-tool-field.plugin.json", + "operation": "generatorForm", + "generatorID": "node-app", + "form": { + "projectName": "demo", + "tools": { + "node": [ + {"home": "/opt/node-16", "executable": "/opt/node-16/bin/node", "version": "16.20.2"} + ] + } + }, + "expected": { + "status": "ok", + "form": { + "projectName": {"problem": null}, + "fields": [ + { + "id": "node", + "visible": true, + "value": "", + "installations": [ + {"home": "/opt/node-16", "executable": "/opt/node-16/bin/node", "version": "16.20.2", "meetsMinimum": false} + ] + } + ], + "activeRequirements": [], + "valid": true + } + } + }, + { + "name": "plan leaves an optional tool with only old installations unpinned", + "manifest": "project-generator-tool-field.plugin.json", + "operation": "generationPlan", + "generatorID": "node-app", + "form": { + "projectName": "demo", + "tools": { + "node": [ + {"home": "/opt/node-16", "executable": "/opt/node-16/bin/node", "version": "16.20.2"} + ] + } + }, + "expected": { + "status": "ok", + "plan": {"projectName": "demo", "steps": [], "openFiles": [], "runtimes": [], "toolPins": [], "usesNetwork": false} + } + }, + { + "name": "plan refuses an old optional tool the user chose", + "manifest": "project-generator-tool-field.plugin.json", + "operation": "generationPlan", + "generatorID": "node-app", + "form": { + "projectName": "demo", + "values": {"node": "/opt/node-16"}, + "tools": { + "node": [ + {"home": "/opt/node-16", "executable": "/opt/node-16/bin/node", "version": "16.20.2"} + ] + } + }, + "expected": { + "status": "rejected", + "error": {"code": "plugin.project.invalidInput", "message": "The value of node is not accepted (toolTooOld).", "field": "values.node"} + } + }, { "name": "name accepted", "operation": "checkName", diff --git a/shared/contracts/plugins/v1/fixtures/project-generators/templates/tool-field/README.md b/shared/contracts/plugins/v1/fixtures/project-generators/templates/tool-field/README.md new file mode 100644 index 0000000000..9bbb191cac --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/project-generators/templates/tool-field/README.md @@ -0,0 +1,4 @@ +# {{ project.name }} + +Interpreter: {{ field.python.executable }} +Installation: {{ field.python }} diff --git a/shared/contracts/plugins/v1/plugin-manifest.schema.json b/shared/contracts/plugins/v1/plugin-manifest.schema.json index a418847ddc..3122ad9262 100644 --- a/shared/contracts/plugins/v1/plugin-manifest.schema.json +++ b/shared/contracts/plugins/v1/plugin-manifest.schema.json @@ -46,7 +46,7 @@ "languageServers": { "description": "Required exactly when languageServers is non-empty. Version 2 adds languageServers[].workspaceConfiguration.", "enum": [1, 2] }, "languages": { "description": "Version 2 adds languages[].grammar and languages[].configuration and is required exactly when one of them is used.", "enum": [1, 2] }, "packageTrust": { "description": "Required exactly when vendor.bundledSignatureRequirement is present.", "enum": [1] }, - "projectGenerators": { "description": "Entries of the New Project window: fields, requirements, and steps.", "enum": [1] }, + "projectGenerators": { "description": "Entries of the New Project window: fields, requirements, and steps. Version 2 adds the tool field and retires the runtime field.", "enum": [1, 2] }, "serverProcess": { "description": "The launch fields of language servers, of build providers that start their own process, of VCS providers, of debug adapters, and of the plugin backend. Version 2 adds runSelectedRuntime and is required exactly when a debug adapter uses it (pluginAPIVersion 4).", "enum": [1, 2] }, "settings": { "description": "Required exactly when settings or settingActions is non-empty. Version 1 is the API level 2 section: integer, enum, and boolean options. Version 2 adds scope, effect, page, section, the multiSelect, text, file, and directory types, and settingActions.", "enum": [1, 2] }, "toolDefinitions": { "const": 1 }, @@ -847,12 +847,12 @@ "required": ["id", "type", "title"], "properties": { "id": { "type": "string", "pattern": "^[a-z][A-Za-z0-9]*$" }, - "type": { "enum": ["text", "enum", "boolean", "multiSelect", "runtime"] }, + "type": { "enum": ["text", "enum", "boolean", "multiSelect", "runtime", "tool"], "description": "runtime needs requires.projectGenerators 1; tool needs requires.projectGenerators 2." }, "title": { "type": "string", "minLength": 1, "maxLength": 120 }, "description": { "type": "string", "minLength": 1, "maxLength": 480 }, "advanced": { "type": "boolean" }, "visibleWhen": { "$ref": "#/$defs/generatorConditions" }, - "default": { "description": "text: a template over ${project.name} and earlier fields; enum: an option; boolean; multiSelect: options. Not allowed for runtime." }, + "default": { "description": "text: a template over ${project.name} and earlier fields; enum: an option; boolean; multiSelect: options. Not allowed for runtime and tool." }, "options": { "type": "array", "items": { @@ -872,7 +872,14 @@ "placeholder": { "type": "string", "minLength": 1, "maxLength": 120 }, "optional": { "type": "boolean" }, "runtime": { "enum": ["java"] }, - "minimumVersion": { "type": "string" }, + "tool": { + "description": "tool: a tool definition ID, the short ID of a verified official definition or :.", + "type": "string", + "maxLength": 192, + "pattern": "^([a-z0-9]+([.-][a-z0-9]+)*:)?[a-z][a-z0-9-]{0,63}$" + }, + "pinAs": { "description": "tool: project saves the selection as the created project's own tool choice.", "enum": ["project"] }, + "minimumVersion": { "type": "string", "description": "runtime and tool: one to three dotted numbers." }, "maximumVersion": { "type": "string", "description": "text and enum: the highest version the value may name, a template over earlier fields such as ${field.jdk.major}." } } }, diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index a4e023a69a..869f006d6d 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -5032,6 +5032,13 @@ answers. Results use the `plugins.declarative.*` envelope: `catalog`, `form`, `plan`, `name`, and `extracted`. Commands that take a manifest revalidate it and answer `plugin.project.unavailable` for an undeclared generator. +A `projectGenerators` 2 generator picks tool installations with `tool` fields. +The host lists the installations of each field's tool and sends them as the +form's `tools`; Core chooses among them, checks `minimumVersion`, expands +`${field..executable}`, and returns `toolPins` in the plan. Core lists no +installations itself, so the commands touch neither the file system nor a +process for tools. + `project.generationPlan` reads template files below `packagePath` (bounded, no symbolic links). `project.extractArchive` is the only command that writes: it extracts into an existing staging directory the host created and never diff --git a/shared/i18n/locales/en-US.json b/shared/i18n/locales/en-US.json index 9ad89c5bf7..2d62b92f02 100644 --- a/shared/i18n/locales/en-US.json +++ b/shared/i18n/locales/en-US.json @@ -4047,6 +4047,7 @@ "native.couldNotLoadSavedChanges": "Could not load saved changes", "native.couldNotLoadServiceDependencies": "Could not load service dependencies", "native.couldNotLoadTheChangedFiles": "Could not load the changed files.", + "native.couldNotLookForInstalledTools": "Could not look for installed tools: {arg1}", "native.couldNotMoveTheEditorTabItsContentCouldNotBeSynchronized": "Could not move the editor tab. Its content could not be synchronized.", "native.couldNotOpenHTMLInBrowser": "Could not open HTML in browser", "native.couldNotRead": "Could not read {arg1}: {arg2}", @@ -4937,6 +4938,7 @@ "native.logMessage": "Log message", "native.logs": "Logs", "native.lookingForGit": "Looking for Git…", + "native.lookingForInstalledTools": "Looking for installed tools…", "native.low": "Low", "native.lowEffortAndASmallOutputLimitAreRecommendedForFastCommitMessageGeneration": "Low effort and a small output limit are recommended for fast commit-message generation.", "native.lowRecommended": "Low (recommended)", @@ -5169,6 +5171,7 @@ "native.noGitRepositoryIsOpen": "No Git repository is open", "native.noHistoryRecordedYet": "No history recorded yet", "native.noImplementationsFound": "No implementations found", + "native.noInstallationOfThisToolWasFound": "No installation of this tool was found.", "native.noJDKFoundSetJAVAHOMEInstallAJDKOrChooseAJDKDirectory": "No JDK found. Set JAVA_HOME, install a JDK, or choose a JDK directory.", "native.noJDKIsConfiguredForThisProject": "No JDK is configured for this project.", "native.noJVMPerformanceSamplesReceivedFor10Seconds": "No JVM performance samples received for 10 seconds.", @@ -5288,6 +5291,7 @@ "native.official": "Official", "native.olderCommitsAreNotListed": "Older commits are not listed.", "native.olderCommitsAreOutsideTheLoadedHistory": "Older commits are outside the loaded history", + "native.olderThanRequired": "{arg1} (older than required)", "native.on": "On", "native.oneArgumentPerLineArgumentsKeepSpacesWithoutQuoting": "One argument per line. Arguments keep spaces without quoting.", "native.oneFile": "One file: {arg1}", @@ -6309,6 +6313,7 @@ "native.thePrimaryWorktreeCannotBeLocked": "The primary worktree cannot be locked.", "native.thePrimaryWorktreeCannotBeRemoved": "The primary worktree cannot be removed.", "native.theProjectModelIsNotLoaded": "The project model is not loaded.", + "native.theProjectWasCreatedButTheToolYouChoseCouldNotBeSavedForIt": "The project was created, but the tool you chose could not be saved for it: {arg1}", "native.thePublishedUpdateManifestIsInvalidAndCannotBeTrustedOpenTheReleasePageAndDowna6815c0bf9": "The published update manifest is invalid and cannot be trusted. Open the Release page and download the update manually.", "native.thePullRequestWillReturnToTheOpenListAndCanReceiveNewReviews": "The pull request will return to the open list and can receive new reviews.", "native.theRebaseWasAbortedInspectTheBranchAndWorkingFilesBeforeContinuingYourWork": "The rebase was aborted. Inspect the branch and working files before continuing your work.", @@ -6380,6 +6385,7 @@ "native.thisFileIsOutsideTheCurrentProject": "This file is outside the current project.", "native.thisFileWasDeletedOutsideLitheYourEditorContentIsPreserved": "This file was deleted outside Lithe. Your editor content is preserved.", "native.thisFolderAlreadyBelongsToAGitRepository": "This folder already belongs to a Git repository", + "native.thisInstallationIsOlderThanTheGeneratorRequires": "This installation is older than the generator requires.", "native.thisMac": "This Mac", "native.thisMacOnly": "This Mac only", "native.thisPage": "This page", diff --git a/shared/i18n/locales/zh-CN.json b/shared/i18n/locales/zh-CN.json index 730338323f..d6a40d02c5 100644 --- a/shared/i18n/locales/zh-CN.json +++ b/shared/i18n/locales/zh-CN.json @@ -4047,6 +4047,7 @@ "native.couldNotLoadSavedChanges": "无法加载已保存的更改", "native.couldNotLoadServiceDependencies": "无法加载服务依赖", "native.couldNotLoadTheChangedFiles": "无法加载更改的文件。", + "native.couldNotLookForInstalledTools": "无法查找已安装的工具:{arg1}", "native.couldNotMoveTheEditorTabItsContentCouldNotBeSynchronized": "无法移动编辑标签,未能同步其内容。", "native.couldNotOpenHTMLInBrowser": "无法在浏览器中打开 HTML", "native.couldNotRead": "无法读取 {arg1}:{arg2}", @@ -4937,6 +4938,7 @@ "native.logMessage": "日志消息", "native.logs": "日志", "native.lookingForGit": "正在查找 Git…", + "native.lookingForInstalledTools": "正在查找已安装的工具…", "native.low": "低", "native.lowEffortAndASmallOutputLimitAreRecommendedForFastCommitMessageGeneration": "为了快速生成提交信息,建议使用低推理强度和较小的输出限制。", "native.lowRecommended": "低(推荐)", @@ -5169,6 +5171,7 @@ "native.noGitRepositoryIsOpen": "未打开 Git 仓库", "native.noHistoryRecordedYet": "还没有历史记录", "native.noImplementationsFound": "没有找到实现", + "native.noInstallationOfThisToolWasFound": "未找到此工具的安装。", "native.noJDKFoundSetJAVAHOMEInstallAJDKOrChooseAJDKDirectory": "未找到 JDK。请设置 JAVA_HOME、安装 JDK,或选择 JDK 目录。", "native.noJDKIsConfiguredForThisProject": "此项目没有配置 JDK。", "native.noJVMPerformanceSamplesReceivedFor10Seconds": "10 秒内未收到 JVM 性能采样。", @@ -5288,6 +5291,7 @@ "native.official": "官方", "native.olderCommitsAreNotListed": "更早的提交未列出。", "native.olderCommitsAreOutsideTheLoadedHistory": "更早的提交不在当前加载的历史范围内", + "native.olderThanRequired": "{arg1}(低于要求的版本)", "native.on": "开启", "native.oneArgumentPerLineArgumentsKeepSpacesWithoutQuoting": "每行一个参数。参数中的空格无需额外引号。", "native.oneFile": "单个文件:{arg1}", @@ -6309,6 +6313,7 @@ "native.thePrimaryWorktreeCannotBeLocked": "主工作区不能锁定。", "native.thePrimaryWorktreeCannotBeRemoved": "主工作区不能删除。", "native.theProjectModelIsNotLoaded": "项目模型尚未加载。", + "native.theProjectWasCreatedButTheToolYouChoseCouldNotBeSavedForIt": "项目已创建,但无法为它保存你选择的工具:{arg1}", "native.thePublishedUpdateManifestIsInvalidAndCannotBeTrustedOpenTheReleasePageAndDowna6815c0bf9": "已发布的更新清单无效,无法信任。请打开 Release 页面手动下载更新。", "native.thePullRequestWillReturnToTheOpenListAndCanReceiveNewReviews": "该拉取请求将回到打开列表,并可继续接收审查。", "native.theRebaseWasAbortedInspectTheBranchAndWorkingFilesBeforeContinuingYourWork": "变基已中止。继续工作前请检查分支和工作文件。", @@ -6380,6 +6385,7 @@ "native.thisFileIsOutsideTheCurrentProject": "此文件不在当前项目中。", "native.thisFileWasDeletedOutsideLitheYourEditorContentIsPreserved": "该文件已被外部删除,编辑器内容已保留。", "native.thisFolderAlreadyBelongsToAGitRepository": "此目录已属于一个 Git 仓库。", + "native.thisInstallationIsOlderThanTheGeneratorRequires": "此安装版本低于模板要求。", "native.thisMac": "本机", "native.thisMacOnly": "仅本机", "native.thisPage": "此页面", diff --git a/shared/i18n/messages.json b/shared/i18n/messages.json index 6260def5f7..ed65c9bde1 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -33343,6 +33343,22 @@ "arguments": [] } }, + { + "id": "native.couldNotLookForInstalledTools", + "description": "Native UI: Could not look for installed tools: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Could not look for installed tools: %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, { "id": "native.couldNotMoveTheEditorTabItsContentCouldNotBeSynchronized", "description": "Native UI: Could not move the editor tab. Its content could not be synchronized.", @@ -44291,6 +44307,17 @@ "arguments": [] } }, + { + "id": "native.lookingForInstalledTools", + "description": "Native UI: Looking for installed tools…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Looking for installed tools…", + "arguments": [] + } + }, { "id": "native.low", "description": "Native UI: Low", @@ -46891,6 +46918,17 @@ "arguments": [] } }, + { + "id": "native.noInstallationOfThisToolWasFound", + "description": "Native UI: No installation of this tool was found.", + "platforms": [ + "macos" + ], + "macos": { + "key": "No installation of this tool was found.", + "arguments": [] + } + }, { "id": "native.noJDKFoundSetJAVAHOMEInstallAJDKOrChooseAJDKDirectory", "description": "Native UI: No JDK found. Set JAVA_HOME, install a JDK, or choose a JDK directory.", @@ -48224,6 +48262,22 @@ "arguments": [] } }, + { + "id": "native.olderThanRequired", + "description": "Native UI: %@ (older than required)", + "platforms": [ + "macos" + ], + "macos": { + "key": "%@ (older than required)", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, { "id": "native.on", "description": "Native UI: On", @@ -59821,6 +59875,22 @@ "arguments": [] } }, + { + "id": "native.theProjectWasCreatedButTheToolYouChoseCouldNotBeSavedForIt", + "description": "Native UI: The project was created, but the tool you chose could not be saved for it: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "The project was created, but the tool you chose could not be saved for it: %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, { "id": "native.thePublishedUpdateManifestIsInvalidAndCannotBeTrustedOpenTheReleasePageAndDowna6815c0bf9", "description": "Native UI: The published update manifest is invalid and cannot be trusted. Open the Release page and download the update manually.", @@ -60596,6 +60666,17 @@ "arguments": [] } }, + { + "id": "native.thisInstallationIsOlderThanTheGeneratorRequires", + "description": "Native UI: This installation is older than the generator requires.", + "platforms": [ + "macos" + ], + "macos": { + "key": "This installation is older than the generator requires.", + "arguments": [] + } + }, { "id": "native.thisMac", "description": "Native UI: This Mac", diff --git a/shared/platform-feature-matrix/features/plugin-project-generators.json b/shared/platform-feature-matrix/features/plugin-project-generators.json index 07faab8d85..5610667eea 100644 --- a/shared/platform-feature-matrix/features/plugin-project-generators.json +++ b/shared/platform-feature-matrix/features/plugin-project-generators.json @@ -2,18 +2,22 @@ "id": "plugin-project-generators", "area": "工作区", "group": "项目", - "capability": "新建项目窗口按 IDEA 的布局列出“新建项目”和“生成器”两组条目:空项目由宿主提供,其余条目由插件通过 projectGenerators 1 注入(随 Lithe 内置的 Java Support 直接提供 Java、Maven Archetype、Spring Boot)。名称、位置、创建 Git 仓库由宿主绘制;字段、派生默认值、显示条件和生成计划由 Rust Core 计算;先在目标目录旁的临时目录生成,完成后整体移动到位,失败或取消不留残留。条目需要的语言或构建系统插件未启用时提示一键启用,未安装时跳转插件市场搜索;所选 JDK 写入新项目的运行时设置,模板指定的文件随项目一起打开", + "capability": "新建项目窗口按 IDEA 的布局列出“新建项目”和“生成器”两组条目:空项目由宿主提供,其余条目由插件通过 projectGenerators 1 注入(随 Lithe 内置的 Java Support 直接提供 Java、Maven Archetype、Spring Boot)。名称、位置、创建 Git 仓库由宿主绘制;字段、派生默认值、显示条件和生成计划由 Rust Core 计算;先在目标目录旁的临时目录生成,完成后整体移动到位,失败或取消不留残留。条目需要的语言或构建系统插件未启用时提示一键启用,未安装时跳转插件市场搜索;所选 JDK 写入新项目的运行时设置,模板指定的文件随项目一起打开。projectGenerators 2 增加通用的工具字段(type:tool):生成器只写工具定义 ID 和最低版本,宿主通过通用工具解析列出已安装的候选,所选工具写入新项目的工具 pin(.lithe/run/local.json),步骤可引用 ${field.} 与 ${field..executable};找不到工具时提示启用或到插件市场查找,低于最低版本的安装不可创建。只含生成器或工具定义的插件安装、更新、卸载后免重启,新建项目窗口随之刷新条目", "macos": { "evidence": [ "rust/lithe-core/src/project_generators/", "shared/contracts/plugins/v1/fixtures/project-generators/project-generator.plugin.json", "shared/contracts/plugins/v1/fixtures/project-generators/project-plans.json", + "shared/contracts/plugins/v1/fixtures/project-generators/project-generator-tool-field.plugin.json", "Plugins/mac/Official/JavaSupport/plugin.json", "macos/Sources/Lithe/Views/Workspace/NewProjectView.swift", "macos/Sources/Lithe/Application/Features/NewProjectFeatureModel.swift", "macos/Sources/Lithe/Services/ProjectGeneration/ProjectGenerationService.swift", "macos/Sources/Lithe/Platform/MacOS/ProjectGeneration/MacProjectGenerationPlatform.swift", + "macos/Sources/Lithe/Platform/MacOS/ProjectGeneration/MacProjectToolInstallations.swift", + "macos/Sources/Lithe/Platform/MacOS/ProjectGeneration/MacProjectToolPins.swift", "macos/Tests/LitheTests/ProjectGenerationTests.swift", + "macos/Tests/LitheTests/ProjectToolAdaptersTests.swift", "rust/lithe-core/src/project_generators/archive.rs" ], "implementationStatus": "implemented", @@ -27,6 +31,6 @@ "verificationStatus": "not-applicable" }, "owner": "Plugin Platform", - "verification": "macOS:从欢迎页和项目切换器点“新建项目”,确认左栏有“新建项目”(Java、空项目)和“生成器”(Maven Archetype、Spring Boot)两组且可搜索。Java 选 Maven、默认 JDK 创建,确认生成 pom.xml(maven.compiler.release 等于所选 JDK 主版本)、.gitignore 和 src/main/java/org/example/Main.java,项目打开后 Main.java 已打开、Build 工具窗口识别 Maven 项目。勾选“创建 Git 仓库”时项目根目录有 .git。Spring Boot 选几个依赖创建,确认从 start.spring.io 下载并解压、mvnw 可执行;把服务器 URL 改成不可达地址时报错且目标目录下没有残留的 .*.lithe-creating-* 临时目录。Maven Archetype 在已安装 mvn 时生成 quickstart 项目,未安装时提示找不到 mvn。停用 Java Support 后,三个条目变灰、创建按钮不可用,只出现一条“启用 Java Support”,点击后条目恢复可用;未安装 Maven 插件时出现“在插件市场中查找”并跳到市场搜索 maven。目标目录已存在时拒绝创建且不改动原目录。Windows:宿主不声明 projectGenerators,需要它的插件在市场中隐藏。", + "verification": "macOS:从欢迎页和项目切换器点“新建项目”,确认左栏有“新建项目”(Java、空项目)和“生成器”(Maven Archetype、Spring Boot)两组且可搜索。Java 选 Maven、默认 JDK 创建,确认生成 pom.xml(maven.compiler.release 等于所选 JDK 主版本)、.gitignore 和 src/main/java/org/example/Main.java,项目打开后 Main.java 已打开、Build 工具窗口识别 Maven 项目。勾选“创建 Git 仓库”时项目根目录有 .git。Spring Boot 选几个依赖创建,确认从 start.spring.io 下载并解压、mvnw 可执行;把服务器 URL 改成不可达地址时报错且目标目录下没有残留的 .*.lithe-creating-* 临时目录。Maven Archetype 在已安装 mvn 时生成 quickstart 项目,未安装时提示找不到 mvn。停用 Java Support 后,三个条目变灰、创建按钮不可用,只出现一条“启用 Java Support”,点击后条目恢复可用;未安装 Maven 插件时出现“在插件市场中查找”并跳到市场搜索 maven。目标目录已存在时拒绝创建且不改动原目录。工具字段:用 shared/contracts/plugins/v1/fixtures/project-generators/project-generator-tool-field.plugin.json 和一个定义了同名工具的插件做本地开发安装,新建项目里该生成器的工具下拉列出本机已发现的安装(含版本),选低于最低版本的安装时创建按钮不可用并提示,选满足版本的安装创建后新项目的 .lithe/run/local.json 里有该工具的 pin,设置里的工具链页面显示同一个固定值;没有任何插件定义该工具时出现“在插件市场中查找”,插件已安装未启用时出现“启用”。在新建项目窗口保持打开时安装或卸载只含生成器的插件,条目立即出现或消失且无需重启。Windows:宿主不声明 projectGenerators,需要它的插件在市场中隐藏。", "notes": "#1478 拆分(Core):生成器归档解压先预检全部条目(条目数、展开字节、目录深度 64 层、路径总字节 16 MiB),只经过真实目录(不跟随符号链接,Windows 还拒绝 reparse point),按 32 KiB 分块复制并在块间检查取消;违反约定报 plugin.project.archiveInvalid,取消或超时作为 Core 错误返回且不再写入。Windows 额外拒绝设备名、ADS 和非法字符。macOS 的 project.extractArchive 没有取消作用域,只受预检和上限影响。" }