diff --git a/.agents/notes/implemented/architecture/2026-09-18-java-project-build-and-launch-boundary.md b/.agents/notes/implemented/architecture/2026-09-18-java-project-build-and-launch-boundary.md index 173514938d..c6f29b537e 100644 --- a/.agents/notes/implemented/architecture/2026-09-18-java-project-build-and-launch-boundary.md +++ b/.agents/notes/implemented/architecture/2026-09-18-java-project-build-and-launch-boundary.md @@ -101,6 +101,42 @@ JDT 直启流程。继承插件的库模块、只有测试入口的模块和入 原问题换成一次不确定的错误运行。JDT 对模块化项目返回的 `module/name.Type` 会转换为 JVM 需要的 `-m module/name.Type`。 +### 阶段 D 的 Provider 资源准备 + +macOS 已把资源步骤切入 Maven Provider 的公开项目任务:先保存文档、等待当前 +Build 模型,再按模型中属于目标工程的源码角色选择主资源或主加测试资源任务。 +其他工程发布的更深内容目录不能覆盖目标工程的源码角色。工作区通过符号链接 +打开而 JDT 返回规范路径时,文件归属判断比较解析链接后的路径;工作区激活身份 +仍比较原工作区及代次,不能把另一个打开操作当成当前操作。 + +Provider 完成后才请求 JDT 构建和运行路径,并把当前准备回执传给 Core,避免 +Core 再规划一次资源进程。失败、取消、配置 ABA 或工作区替换均撤销回执。 +Windows 现有消费者尚未切换,因此未带回执的共享启动计划继续保留原资源步骤。 +这次切换没有新增资源目录、可复用缓存或发行包写入。 + +真实集成 `real_provider_resources_survive_jdt_build_and_reach_the_project_jvm` 通过 +同一个生效 Profile 串起 Maven Provider、打包的 JDT LS 和真实项目 JVM。 +场景使用嵌套 reactor、依赖模块及非标准主/测试源码和过滤资源根;在 JDT 导入后 +修改资源,再分别启动主源码与测试源码入口,断言实际输出包含新资源和依赖模块值。 +全部会话关闭后比较 Maven 插件包与 JDT 安装目录的 SHA-256 清单。 +它证明这个明确输入组合可以协作,不能代替完整 UI、每个 Run 覆盖、正式发行组合 +或 Windows 实机验收。后续增量已经把单次 Run 的 Profile 接进宿主:资源准备先用同一份已校验选项和工具 +重新读取 Provider 模型,再选择源码角色及资源任务,并返回由 `build.languageImport` +产生的原样导入输入。临时模型、Profile 和工具都不写回 Build 默认值,不发布成默认 +模型。JDT 冷启动直接使用这些输入;已运行会话原地更新,不能接收 Maven 输入的 +旧会话按现有生命周期重启。下次默认入口发现重新应用当前 Build 输入,避免把 +上次 Run 的 Profile 留成影子设置。 + +入口发现本身也等待 Core 的配置屏障(Maven Profile 任务和 JDT 项目配置任务), +而不是先问旧模型、再仅让构建等待。等待有 Java 构建期限和 128 项队列上限; +排队中的取消不写协议请求,超时、停用和关闭都终结对应等待。宿主在共享启动准备操作前后及用户处理构建错误后核对导入代次;导入变化会立即 +取消这个操作,配置改回原值也不能恢复旧准备。 +异步入口发现和 Java 命令经已有 Core 取消路径撤销单次操作,不停止共享语言服务。 +真实 JDT 场景从没有入口的另一组源码根启动,再热切换到本次 Profile,并要求首次 +查询直接得到新源码根入口,不能靠重试空列表隐藏配置竞态。 + +这仍不代表运行配置生成、框架启动规划、正式发行包或 Windows Provider 已迁移。 + ### 两端共用启动准备操作 Mac 和 Windows 调用同一个 `java.prepareLaunch` 操作,由 Core 串起入口选择、 @@ -299,6 +335,21 @@ Maven JDK、显式覆盖及资源步骤;#1133 的 Maven 原生资源步骤保 ## 验证 +2026-10-30 补充验证:Core/集成逐项计时 1398 项全部通过,真实 Profile 热切换→JDT→JVM 场景 14.249 秒;生产 macOS BuildSystemService 与真实 Maven Provider 验证临时 Profile 改变源码角色,宿主确实选择测试资源任务,返回同一导入值,默认模型/选项不变、进程释放且插件包 SHA-256 不变,2.763 秒通过。 + +- Provider 与真实 JDT/JVM 的新增集成显式设置 `LITHE_JDTLS_SMOKE_MAVEN_PACKAGE` + 为开发包或正式包根目录,并同时设置既有 `LITHE_JDTLS_SMOKE_ROOT`、 + `LITHE_JDTLS_SMOKE_JAVA`、`LITHE_JDTLS_SMOKE_PROJECT_JDK` 与 + `LITHE_JDTLS_SMOKE_MAVEN`。不设置包路径时普通单元车道不启动外部工具; + 明确设置后缺少工具或包必须失败,不能降级跳过。 + 通过 Rust 计时入口运行,真实工具场景沿用专属的 600 秒总预算: + `node .agents/skills/write-stable-tests/scripts/run-rust-tests-with-timing.mjs --manifest rust/lithe-core/Cargo.toml --test-budget lsp::interface::engine::real_jdt_tests::=600000 --report .artifacts/test-stability/stage-d-real-jdt-core.json`。 + 本机 1395 项通过,其中新增集成 35.983 秒,另两项真实 JDT 为 14.897/11.520 秒。 + 最后把临时目录所有权提前到首个 fixture 写入前;该场景再次逐项计时通过,含进程清理为 27.751 秒。 +- 原生工程归属、链接路径及取消回归: + `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'BuildSystemServiceTests|RunWorkflowCoordinatorTests|RunEntryPointTests'`。 + 本机 130 项计时通过,新增链接路径 3 毫秒,当前工程角色/撤销场景 15 毫秒。 + - 资源处理参数与独立子项目兼容:`cargo test --manifest-path rust/Cargo.toml -p lithe-core --lib tests::run_configuration::`。 - macOS 服务会话消费前置步骤的顺序、失败阻止 JVM、Stop 取消和超时期限: `macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift` 中的 diff --git a/.agents/notes/implemented/architecture/2026-09-21-java-entrypoints-owned-by-jdt.md b/.agents/notes/implemented/architecture/2026-09-21-java-entrypoints-owned-by-jdt.md index 1603de902d..f538c6d4c0 100644 --- a/.agents/notes/implemented/architecture/2026-09-21-java-entrypoints-owned-by-jdt.md +++ b/.agents/notes/implemented/architecture/2026-09-21-java-entrypoints-owned-by-jdt.md @@ -98,6 +98,61 @@ Windows 会先同步当前编辑文档再请求该操作。两端都不再解析 比对。输入指纹不再对全部 Java 源码做内容哈希(见 `.agents/notes/implemented/bug-fix/2026-09-22-run-panel-fingerprint-loading.md`)。 +### 阶段 D 的运行生成归属 + +macOS 在生成配置前等待当前 Build 模型与导入,随后把模型的项目路径、Maven +reactor 根及该项目的主/测试源码根交给 Core。Core 只校验、选取上游已经发布的 +归属,不再沿 Java 源码祖先寻找 POM,也不按 `src/test` 推断已提供模型的源码集。 +同一工作区有两个独立 reactor、都含 `app` 和同名 Main 类时,用工作区内完整 +模块路径区分配置;同一个 reactor 里原有配置 ID 保持稳定,避免丢失覆盖。原来已被合并成单个 ID 的多 reactor 记录无法确定归属时,旧覆盖由 +现有 orphan 诊断保留,不能猜测性地挂到其中一个新配置。 + +`projectOwnership: []` 是明确的空模型,不能偷偷用 POM 补齐;不传该字段才保留 +现有 Windows 消费者的兼容路径。快照未准备好、失效或截断时宿主拒绝生成, +工作区或 Build 代次变化后拒绝旧 JDT 答复。只转交声明 Java 导入的 Provider 模型, +其他构建插件不能凭共享目录占用 Java 项目。Maven 后端现在从生效的 +`MavenProject.getBuildPlugins()` 识别框架,包含当前 Profile 和父 POM 继承; +仅放在插件管理(pluginManagement)中的插件不算已经应用。模型通过公开属性 +`run.modelVersion` / `run.provider` 发布结果,宿主只投影项目名和服务标识。 +Core 收到当前快照后关闭旧 POM 框架检测,只生成与既有配置 ID 一致的服务。 +旧插件缺少模型版本时明确要求更新,不能退回宿主解析;后端缓存指纹包含扩展 JAR +字节,更新扩展也会使旧快照失效。Java 编译版本要求也改由 Maven 的执行计划计算器 +(`LifecycleExecutionPlanCalculator`)及参数解析器提供:只计算到 `test-compile`, +不执行目标;在克隆的会话里检查所有实际 compiler execution 的 release,或 +source/target,包含 Profile、继承、测试专用版本和自定义执行。不可解析时记录 +诊断并保持未知,不能再按 POM 属性名猜测。Core 只校验标准化版本并取当前工程的 +最高要求;本机 `.java-version` 不得降低已知编译下限。 + +编译计划接口在 Maven 内部包中,3.6.3 已提供相同签名;扩展仍按 3.6.3 编译, +升级时必须运行最低版本真实集成。已否决宿主正则和独立解析 compiler XML:它们 +不能重现 execution 合并和参数默认值。模型使用原有 Maven 本地仓库解析描述符, +不新增缓存目录;状态和下载物不进入插件包,安装文件清单验证保持有效。 +Wrapper 的存在和发行版本也由 Maven 后端读取选定 reactor 的原生文件后发布。 +Java `Properties` 负责转义和注释语义,分发 URL 仅提取可识别发行文件名;自定义 +文件名保持未知。读取有 1 MiB 硬上限,不执行脚本,不导出可能含凭据的完整 URL。 +脚本与 properties 都参与后端指纹和宿主同步观察;脚本替换/删除必须撤销缓存。 +宿主把脚本投影成工作区相对路径;两个独立 reactor 不共用全局 Wrapper,实际 +Run 仍由平台按其 cwd 和工具选择解析。Windows `.cmd` 也参与生成指纹。 +这些模型事实继续使用现有仓库、状态与包生命周期,不新增资源目录;Core 收到 +快照时不读取 Wrapper 文件补齐。旧 Maven 启动规划仍待迁移,不能据此声称整个生成器已移出 Core。 + +生成文档还记录模型摘要(`generator.providerFingerprint`)。文件没有改变不代表 +Profile 或插件仍然相同,所以检查与启动必须比较当前工程投影,不能只看文件指纹。 +项目和源码根排序不影响摘要,工程名、归属、角色、框架及工具要求变化都会使它 +过期;老文档缺少摘要时提示重新生成。摘要不含环境值或配置秘密。 + +macOS 在模型存储提交后发事件,Run 列表异步检查;工作区、生成或模型变化会撤销 +旧答复;连续通知只保留一个在途检查和一次待处理读取,不累积后台文件检查。模型未加载、截断或失效时显示等待提示。真正启动前还要同步确认当前模型, +覆盖主 Run、服务单项/批量、Agent 共用入口和 Java Debug 准备,不能把屏幕上的 +诊断当作授权。切回原 Profile 可以重新匹配同一份生成文档,但不能让旧异步任务 +借 ABA 变化发布结果;操作代次与语义摘要解决的是不同问题。 + +正确例子:Maven 模型把 `custom-checks` 声明为测试源码,即按测试源码生成配置。 +不要因为路径里没有 `test` 就改成主源码,也不要遇到损坏的 POM 就覆盖上游提供的 +已验证工程归属。共享 fixture 在损坏 POM 下同时验证两条 reactor、同名类、自定义 +源码角色和空模型;Swift 使用真实 Core bridge 验证同一份 fixture。该 fixture 只证明 Core 不解析 POM; +生产宿主仍须在 POM 变化后撤销旧模型,不能借此复用失效快照。 + ### Spring 服务也必须有已确认的入口 Maven 的 Spring Boot 插件可以由父模块继承;继承到插件的库模块不一定包含 @@ -112,8 +167,9 @@ JDT 已确认、被分类为 Spring Boot 的非测试入口,Core 才保留服 模块位置从已确认入口的源码集合推导,不依赖编辑器此次上传的文件列表是否完整。 JDT 尚未完成发现时,已有确认入口可沿用上次生成结果;没有上次结果就不虚构 -服务。明确返回空入口后移除自动服务。用户主动保存的旧 Maven-goal 配置仍保留 -兼容启动路径;这条规则只收紧自动发现。生成器 revision 同步增加,使旧缓存 +服务。明确返回空入口后移除自动服务。用户主动保存的 Maven-goal 配置仍保留 +原身份与参数;阶段 D 的 macOS 入口重新交给 Provider 准备,Windows 暂时保留 +原共享调用。这条规则只收紧自动发现。生成器 revision 同步增加,使旧缓存 不能长期保留误识别的库模块。已有真实服务的 ID 不变,用户覆盖配置仍可关联。 ### 不要这样做 @@ -151,6 +207,43 @@ JDT 尚未完成发现时,已有确认入口可沿用上次生成结果;没 ## 验证 +- 生成身份增量:1399 项普通 Core 计时通过,源码根排序与归属变化的最终定向 + 复验也通过;289 项 Swift(含迟到检查的输入/工作区/重置三种顺序、100 次通知 + 合并为一次后续读取)通过。新异步用例均为 1 毫秒,生产 Swift bridge fixture + 42 毫秒,关闭时先移除订阅再停止 Provider,后续 4 项生命周期定向复验通过。 + 当前源码 SDK E2E 6 项、完整 Rust crates、Core/共享/服务/Windows 边界及最终 + C ABI/24 项 Swift bridge 通过。首轮应用早期 AskPass 辅助进程未在 5 秒内连接, + 串行复验在相同期限下 4.359 秒通过,没有延长超时。4 项真实 macOS Provider 集成通过;默认 Profile A→B→A 在 POM + 不变时使已存生成过期/恢复,7.496 秒。另显式执行 3 项真实 JDT,含 Cargo + 进程的计时 26.984/13.763/10.486 秒,验证过滤资源和运行路径仍到达实际 JVM, + 退出后安装包清单不变。旧失败计时保留作诊断,后续 complete/native 报告是通过依据。 +- Wrapper 增量:1399 项含真实 JDT 的 Core/集成、286 项 Swift、4 项生产 + Provider 和完整 Rust/24 项 bridge 通过。共享 Core fixture 21 毫秒验证双根不 + 共用 Wrapper、单根 Windows 脚本及 `.cmd` 内容变化影响指纹;Swift fixture + 33 毫秒。真实 Core→Maven 19 项、缓存 17 项、受控原生 16 项、常规 Maven + 16 项通过;Wrapper 原生 properties、脚本缓存撤销和超限文件拒绝 3.739 秒。 + 后端指纹改为有单文件 64 MiB 上限的流式哈希,避免新增脚本输入触发整文件分配。 + 最终包再次执行 3 项真实 JDT,含 Cargo 启动的计时为 16.199/8.056/6.940 秒; + 退出、安装目录文件清单和进程清理均通过。HTML 由现有有界进程/报告入口生成, + 每项命令 600 秒期限,目标测试本身继续使用原有 JDT 操作期限。 +- 编译要求增量:1399 项含真实 JDT 的 Core/集成计时、286 项 Swift、4 项生产 + Provider 集成和完整 Rust/24 项 bridge 通过。真实 Core→Maven 18 项通过, + 编译计划场景 2.964 秒,覆盖 Profile/testRelease、自定义 execution、模块覆盖、 + Maven 将缺失表达式视为未设置、非法语言版本保持未知,以及损坏 POM 不改变 + 已提供要求。Core fixture 19 毫秒,生产 Swift bridge 21 毫秒。 + Provider→JDT→JVM 场景 17.038 秒;普通入口与链接工作区分别 10.750/6.433 秒。 +- 框架识别增量:1399 项 Core 计时、286 项 Swift、4 项生产 macOS Provider 集成、 + C ABI/24 项 bridge 及完整 Rust crates 通过。共享 fixture 经 Core 22 毫秒和 + 真实 Swift bridge 32 毫秒验证;缺少后端元数据的模型拒绝生成,2 毫秒。 + 真实 Core→Maven 17 项通过,其中 Profile/继承、仅插件管理及损坏 POM 下的框架 + 配置生成 2.127 秒;退出后包文件清单不变。该轮普通 Core 计时没有启用真实 JDT, + 真实 JDT 的证据仍是下述专用集成记录;不能把早退用例当成新的 JDT 运行证据。 +- 2026-10-30 的归属生成增量通过 1399 项 Core/集成计时与 285 项 Swift 回归。 + 新共享 fixture 的 Core 用例 17 毫秒;Swift 使用生产存储和真实 Core bridge,验证 + 生成文档的 reactor、模块、同名配置 ID 与源码角色。Windows Cargo 检查与同一 + preview 基线保持相同的两项 macOS 平台错误,无新增 Rust 调用类型错误,不代表 + Windows 实机通过。当前源码快照的 SDK E2E 6 项通过。 + - Rust 单元与契约测试覆盖入口点和测试项的版本化规范化、错误结果、Windows 路径、 Maven 多模块同名类,以及旧生成结果在 JDT 未就绪时的保留。 - 真实 JDT 测试使用 JDK 25 项目验证 `static void main()`、实例 main、紧凑源文件, @@ -208,3 +301,24 @@ JDT 尚未完成发现时,已有确认入口可沿用上次生成结果;没 - 相关笔记: `.agents/notes/implemented/feature/2026-09-20-run-configuration-discovery-quality.md`、 `.agents/notes/implemented/architecture/2026-09-18-java-project-build-and-launch-boundary.md` + +## 运行生成的通用构建项目引用 + +阶段 D 后续把归属投影中的 `mavenReactorPath` 改为完整构建项目身份:插件、构建声明、 +实例根和 Provider 项目 ID。Core 只校验身份、路径包含与源码角色,不据此解析 Maven; +主类和相对模块来自已有 Java 入口事实,写在 `extensions.java`,生成结果没有 Maven +扩展或 Maven 工具绑定。源码归属指纹包含这份完整身份,改名、换包、目标撤回或模型 +未准备不能复用旧结果。 + +原生 Run 持有这份引用,Java 准备向当前启用的同一构建模型取导入输入。四个身份任一 +不符、模型过期或重开到另一个工作区都拒绝;不按显示名称、祖先 POM 或工作目录补猜。 +用户修改 cwd 不改变归属,团队/本机层也不能伪造生成项的构建身份。旧 Windows 调用者 +尚未传入归属时继续走已有兼容路径;它的 Maven 业务须随真实消费者切换删除,不能把 +本次通用引用当成整个 Java/Windows 迁移完成。 + +JDT 暂时没有源码结果时,Provider 已知的 Java 编译版本仍生成 JDK 要求;否则删除 +Maven reactor 字段后,首次导入可能误把当前工程当成不需要 Java。缺失版本仍是未知, +不能据此重新解析 POM 或从框架名称猜测。 + +验证使用共享损坏 POM fixture 的 Rust、Swift 生产桥接与 Windows 映射,并检查精确 +导入查询的错误插件、构建声明、根和项目,以及模型撤销后的拒绝。 diff --git a/.agents/notes/implemented/architecture/2026-09-21-maven-settings-reach-the-language-server.md b/.agents/notes/implemented/architecture/2026-09-21-maven-settings-reach-the-language-server.md index 34ca94b756..b54ec28dfa 100644 --- a/.agents/notes/implemented/architecture/2026-09-21-maven-settings-reach-the-language-server.md +++ b/.agents/notes/implemented/architecture/2026-09-21-maven-settings-reach-the-language-server.md @@ -14,6 +14,11 @@ Lithe 以前只把 `settings.xml` 这一个字段转给 JDT LS(Eclipse 的 Jav 会读的设置。** 开发者以后新增任何影响依赖解析的 Maven 配置项,都要同时回答 “它怎么到达语言服务”,否则命令行和语言服务会再次分叉。 +macOS 已删除旧 Maven 服务和配置副本的生产读写。Build Provider 的实例配置、canonical 工具链及 +受保护的原生配置流程提供唯一输入;Java 导入、Run 和 Debug 不再读取旧服务的 launch context。 +Debug 在异步准备前等待当前 Build 输入,输入代次变化(包括改走又改回)会废弃准备结果。 +旧配置字段只由一次性迁移 adapter 读取,目标读回后才清理;下文较早的服务接线说明用于解释历史问题。 + ## 问题 三个独立缺陷叠在一起,表现为同一个现象。 @@ -189,26 +194,48 @@ Windows 保存项目环境在默认值和 Maven 本机配置写入后即视为 随后的运行配置刷新可能要等进行中的识别,因此单独返回并在结束后报告失败,不能让 保存本身等待识别。 -macOS 的项目 JDK、Maven 与 Maven JDK 以 `.lithe/run/local.json` 为唯一真源, -UserDefaults 中的项目运行时设置只是它的镜像,供 Maven 进程和环境检查使用。 -`runConfig.inspect` 返回的本机 `toolchain` 在未生成配置时也会读取;运行功能读完 -项目文档后,镜像以它为准覆盖这三个字段。本机层尚未保存过默认值(`toolchain` -为空)时保留现有镜像,下一次保存再写入本机层;文档无法解析时两边都不改。错误 -做法是「镜像为空才补」:旧版服务编辑器只写本机层,镜像里的旧值会在关闭设置时 -反向覆盖运行实际使用的 JDK。 - -Core 的服务编辑器保存每次都会重写本机层 `toolchain`,因此编辑器必须传有效默认值: -本机层已保存的默认值,未保存过时用镜像(`RunService.saveEditorChanges` 以 -`savedProjectToolchain ?? 镜像` 选取)。错误做法是传解析结果 `projectToolchain`: -本机层没有默认值时它是空值,写入后「从未保存」变成「显式自动」,镜像随之被清空, -运行改用自动检测的 JDK。 - -macOS 的项目设置表单必须在运行功能读完当前项目文档后再填入初始值,关闭设置时的 -自动保存也只在 `ProjectLoadState.hasLoadedDocuments(for:)` 成立时写入。运行功能 -加载中持有的是空工具链;此时保存会把用户已保存的项目 JDK 覆盖成空值。 -表单在填入初始值之前保持禁用,关闭设置时也只在准备完成后才保存,与 Windows 加载期间 -禁用表单一致:准备期间的修改无法写入本机层,读完文档后以本机层为准会把它静默还原; -尚未合并 Maven 配置的表单值也不能写回 Maven 本机设置。 +macOS 的项目默认 JDK、Maven 与 Maven JDK 直接经 `runConfig.inspect` +读取 `.lithe/run/local.json` 的 `toolchain`。`MacProjectRuntimeSettingsStore` +负责平台文件和旧偏好的迁移,运行时服务在选择工具前只读刷新;不再持久化 +UserDefaults 副本,也不再用 `adoptProjectToolchain` 在两个副本间同步。 +子项目工具路径也已迁到同一 `toolchain.pathOverrides`,按规范化工作区相对根保存。 +旧偏好中的 `moduleOverrides` 只有在目标尚未保存这部分选择时才迁入;已有映射(包括 +显式清空)优先。重复路径、未知字段或越界根会保留旧数据并明确报错,避免迁移时丢失内容。 +Maven 自己的旧本机配置仍有待阶段 D 迁移的每个 reactor 覆盖,不能把本次项目默认值 +迁移当成 Maven 全配置迁移完成。 + +项目设置的子项目清单已改为消费 Build Provider 的项目路径、名称和父子关系; +未加载模型时,仅使用已安装语言插件声明的项目标记提供目录选择,不推断 Maven 模块。 +同一目录保持稳定的路径标识,已加载模型覆盖其范围内的文件标记;未声明的文件名不会 +被当作工程。这里不再读取旧 `MavenProject`,也没有 `pom.xml`/`package.json` 专属扫描。 +项目设置对 Maven 路径和原生配置的其余旧消费仍须在阶段 D 中继续切换。 + +已实现一次性 Maven 配置数据转移器,但尚未接入生产切换入口。它仅能在旧配置读写方 +撤下后启用:共享选择进入 Build 团队默认值,未选中的自定义候选也保留;每个已知 +reactor 的文件/仓库覆盖与工具路径进入各自的规范位置,已有目标选择优先。 +暂未出现的 reactor 的旧本机文件保留,待该根再次出现时迁移。所有目标读回后才按原 +字节移除源文件;部分发布的来源指纹保留在平台偏好中,以便失败重试。若源文件在 +部分发布后被外部修改,则阻止自动删除,不能把迁移写出的旧值误当成新的用户选择。 +`LegacyMavenConfigurationMigrationTests` 验证转移、已有显式自动、空选择、自定义候选、 +暂缺根、部分失败重试、外部修改以及损坏/未知版本/越界链接保留。测试通过不代表 +MavenService、Run/Test/Debug 和 Agent 的剩余消费者已完成切换。 + +工作区首次激活时,只有本机层没有 `toolchain`、旧偏好里又有明确选择,才通过 +Core 的现有工具链分支生成目标文档。已有选择(包括显式自动)优先;读取或写入 +失败保留源。目标重新读取并核对后才清除旧偏好中的项目默认字段,其他旧字段 +保留到其领域迁移。设置页打开只读取,不触发迁移或生成空文档。 + +Core 的服务编辑器保存会重写本机层 `toolchain`,因此 `RunService.saveEditorChanges` +在保存前重新 inspect 当前本机选择。没有保存过选择时使用自动选择,不能从其他表单 +或过时的 `savedProjectToolchain` 缓存补回旧值。项目创建时明确选定的 JDK 也写入同一 +本机层;写入失败向创建流程传播,不能静默记到另一处。 + +macOS 项目设置在填入初始值之前保持禁用。用户输入只更新草稿,点保存才写入 +明确标注的 `.lithe/run/local.json`;取消、失焦和离页不写。保存使用读取时的 +内容标识和平台条件替换,外部文件或剩余偏好已变时保留草稿并报告冲突,成功后 +再读回。运行过程即使刷新了工具选择,也不能替换打开中的表单草稿基线。 +Maven settings/仓库覆盖暂时继续调用旧 Maven 持有方,原生 XML 编辑与完整配置 +所有权迁移仍待阶段 D 后续工作,不据此声称 #818 或 #1493 全部完成。 不要在设置中复制一套运行参数解析或另存一份配置。正确做法是设置页复用运行 功能模型及原有编辑表单;错误做法是在运行面板和设置各保留一个独立草稿入口。 @@ -405,6 +432,15 @@ profile 变化、失败或超时期间变更补跑、握手前无设置变化的 `LanguageIntelligenceModuleTests`、`ExecutionModuleTests` 覆盖原地更新、回退重启和 设置实时同步。 +## 阶段 D 删除后的验证 + +`stage-d-retire-maven-project-load.{json,html,junit.xml}` 的 315 项原生回归通过,覆盖 +Provider 模型消失后撤销 Run 投影、官方根被动检测后的受保护迁移、配置变更的 ABA 代次、 +旧数据/未知字段/显式自动保护,以及真实 Maven Agent 编译、输出与包清单检查。 +已删除旧服务的扫描/重载测试,保留并复用 `BuildSystemServiceTests` 中对应的 Provider +合并等待、模型/配置失效、取消及工作区隔离场景;Run 原生进程与加载并发测试继续执行。 +这些测试不代替完整 Java/JDT UI、正式插件包或 Windows 原生验收。 + ## 验证 mvnd 的 Core 定向验证应包含整个 `tests::languages::`:除 mvnd 根目录、`bin`、 @@ -476,8 +512,32 @@ macOS 使用 `MacRuntimeDiscoveryTests`、`MacRuntimeProbeCacheTests` 和 运行配置刷新完成前返回、刷新失败与 Maven 写入失败的报告。macOS 运行 `./scripts/macos/test/test-macos.sh`, `RunConfigurationIntegrationTests` 覆盖未生成配置时保存、Git 忽略规则逐条补齐且 重复保存不改动文件、不支持版本的识别拦截,只有读完当前项目文档后才允许保存 -项目默认值,未生成配置时读取本机默认值,以及服务编辑器保存写入有效默认值;`MavenRuntimeTests` 覆盖镜像以本机层 -为准且不改动其他 Maven 设置。 +项目默认值,未生成配置时读取本机默认值,以及服务编辑器保存写入有效默认值;`ProjectRuntimePersistenceTests` 覆盖旧偏好迁移、显式自动优先、损坏文件保留、 +失败保留源、非 Data 偏好拒绝读取且不迁移、不回退自动选择,草稿/取消/条件保存/读回与外部编辑冲突;`MavenRuntimeTests` 覆盖 +直接刷新当前项目选择及配置读取失败时拒绝启动。完整原生 UI 与 Maven 配置迁移仍待验收。 + +### Java 导入停用旧设置回退(2026-10-29) + +macOS 的 Java 启动和热更新现在只读取当前 Build 插件发布的导入配置;删除 +AppModel 对 `MavenService.launchContext` 的补值,以及语言会话管理器中独立的旧 +Maven 配置回调。Run 的 Maven 准备也改从 Build 服务读取同一代输入,按保存的 +reactor 根精确选择,不使用工作区默认根替代另一个独立工程。检测尚未完成或失败 +时明确拒绝,不能因为新结果为空而把旧服务的 +Profiles、settings 或仓库重新带回 JDT。没有启用 Build 插件时不注入 Lithe 的 +构建覆盖,JDT 仍按上游默认导入。 + +插件确认移除 Maven 输入时,原有会话收到空配置以清除旧值;一次读取失败不能 +被当作移除,必须保留会话的输入所有权,以便恢复后完成清除。测试覆盖首次启动、 +热更新、失败后恢复、插件移除及同路径重新启动。这里没有把 Java/JDT 领域逻辑 +迁入插件后端;Run 的工程模型、配置生成和 Maven 启动规划仍待迁移,旧 Maven 的 +Agent 与设置消费者也仍在。后续切换仍要 +完成一次性数据迁移,当前未激活迁移器,不能删除用户的旧配置。 + +迁移器还覆盖早期运行偏好中的 settings/仓库字段。它只移除这两个字段,保留 +JDK 和未知偏好;非空值成为本机 Build 默认值,空值继续表示没有覆盖。原有 +团队/逐根选择优先,之后检测到的 reactor 继承同一默认值。此来源独立于 Maven +文件事务:先验证其目标,再清理对应偏好;私有收据记录原始字节身份,部分写入 +后源被外部修改时拒绝自动重试清理。没有检测到 reactor 时保留此来源。 ## 适用范围 @@ -494,7 +554,7 @@ macOS 使用 `MacRuntimeDiscoveryTests`、`MacRuntimeProbeCacheTests` 和 - Windows 前端:`windows/tauri/src/features/maven/services/resolve-maven-toolchain.ts`、 `windows/tauri/src/features/maven/stores/maven.store.ts` - Windows 宿主:`windows/tauri/src-tauri/src/run.rs`(`maven_resolve_installation`) -- macOS:`macos/Sources/LitheExecutionModule/Services/MavenService.swift` +- macOS:`macos/Sources/LitheExecutionModule/Services/BuildSystemService.swift` - 项目环境与运行配置设置入口: - Rust Core:`rust/lithe-core/src/execution/configuration.rs`(`runConfig.inspect`、 `runConfig.updateOptions` 工具链分支) @@ -505,7 +565,7 @@ macOS 使用 `MacRuntimeDiscoveryTests`、`MacRuntimeProbeCacheTests` 和 `macos/Sources/LitheExecutionModule/Services/RunService.swift`(`saveProjectToolchain`)、 `macos/Sources/Lithe/Platform/MacOS/RunConfiguration/MacRunConfigurationStore.swift`、 `macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift`(项目设置填充与保存)、 - `macos/Sources/Lithe/Services/Java/ProjectRuntimeService.swift`(`adoptProjectToolchain`、 + `macos/Sources/Lithe/Services/Java/ProjectRuntimeService.swift`(`reloadSettings`、 `chooseJavaHome` 等解析链)、 `macos/Sources/Lithe/Views/App/EffectiveRuntimeLabel.swift` - Windows:`windows/tauri/src/features/settings/components/project-environment-settings.tsx`、 diff --git a/.agents/notes/implemented/architecture/2026-09-22-editor-run-markers-and-test-outcomes.md b/.agents/notes/implemented/architecture/2026-09-22-editor-run-markers-and-test-outcomes.md index 3446e365b7..8f51c61c17 100644 --- a/.agents/notes/implemented/architecture/2026-09-22-editor-run-markers-and-test-outcomes.md +++ b/.agents/notes/implemented/architecture/2026-09-22-editor-run-markers-and-test-outcomes.md @@ -7,7 +7,7 @@ Issue #790 要求像 IDEA 一样在 Java 编辑器行号旁显示运行按钮:`main` 方法旁一个 ▶, 测试类旁一个双三角,测试方法旁一个 ▶,跑过的测试显示通过(绿勾)或失败(红色)。 决定是:**哪些声明能运行由 JDT 回答,图标和状态怎么算由 Rust Core 统一计算, -逐方法的结果从 Maven 写出的 XML 报告里读取**。两端只负责画图标、弹菜单和调用 +逐方法的结果来自实际测试运行的报告**。两端只负责画图标、弹菜单和调用 已有的运行、测试、调试流程。开发者以后改标记规则时只改 Core 的 `java.runMarkers`,不要在 Windows 或 macOS 里各写一份。 @@ -36,25 +36,41 @@ Issue #790 要求像 IDEA 一样在 Java 编辑器行号旁显示运行按钮: - JDT 只给出 `main` 方法名的位置,不给类体范围,所以 `main` 只在方法那一行画图标, 不在类名那一行画;光标规则用“这一行的 `main`,否则文件里第一个 `main`”兜底。 -### 状态来自 XML 报告 - -- `maven.testResults` 增加可选的 `reports` 参数:模块目录(或测试文件路径,由 Core - 找最近的 `pom.xml`)、所选类名、运行开始时间。Core 读取 Surefire/Failsafe - 写出的 `TEST-<类名>.xml`,按方法汇总为 `testCases`。 -- **只读本次运行写的报告**:修改时间早于运行开始时间的报告属于上一次运行,一律忽略。 - 这取代了原来“XML 报告由平台自己读”的约定,因为两端都需要,而解析和汇总是确定性 - 规则,属于 Core。 -- 方法耗时同样来自 XML 的 `testcase time`,通过可选 `durationMillis` 传给平台和通用构建结果;重复/参数化调用累加真实耗时。任一次缺失、负值、非有限值、无法表示或总和溢出时省略整个方法耗时,零值保留。界面只按完整“类名#方法名”取值,不把控制台整次摘要复制给每个方法,也不按短方法名跨类回填。 -- 参数化测试的 `name(Type)[1]`、`name[2]` 合并到同一个方法,最严重的结果为准; - 以显示名开头、无法对应方法的记录丢弃。报告的数量、大小和方法数都有上限。 -- 平台保存“每个方法最近一次结果”:整类运行替换该类(含内部类)的结果;单方法运行 - 只替换所选类中的所选方法,保留同类其他方法及内部类的历史结果。因此重跑一个通过 - 的方法不会清除另一个方法的失败,也不会让类图标错误地变绿。 -- Windows 启动指定测试类前,重新向 JDT 查询该文件并验证类归属。同文件内多个顶层类 - 和内部类都使用 JDT 返回的类名;目标已消失时拒绝启动,不回退到文件名推导出的另一类。 -- Windows 的发现结果与投影结果绑定到同一文档代次。编辑、切换文件或语言服务状态变化 - 后立即禁用旧标记;延迟响应及失败响应不能恢复旧位置。Monaco 内容变化时同步清除 - 点击目标和打开的菜单,避免装饰已移动而点击仍按旧行号查找。 +### 状态来自当前操作的后端报告 + +macOS 的 Maven 测试已切换到 Build Provider:JDT 仍提供类和方法标识,Maven 插件 +通过 Apache Surefire parser 读取实际配置目录中的本次报告。宿主不再调用 +`maven.testResults`、扫描固定 XML 目录或根据测试源码寻找 POM。Windows 还在使用 +旧共享命令;删除它们前须先迁移当前 Windows 消费者,这不代表 macOS 保留回退。 + +报告按工程/类/方法保留状态、参数化调用次数和耗时;相同方法的重复调用取最严重 +状态,仅在所有调用都有安全的毫秒数时显示总耗时。缺失、负值、非有限值或无法安全 +表示的时间不能伪造为零。macOS 结果树和编辑器标记按当前 Provider 模型的工程身份 +隔离同名类,重跑仅替换本次返回的规范方法结果,不解释一个不透明测试标识来清空 +其他方法的失败。 + +macOS 服务、结果树和 Java 行标记边界直接消费通用报告 `BuildTestResults`,不再转换成 +`MavenTestResults`。多份报告保留并相加规范结果的 `summary`,不在 Swift 重新解释统计口径; +`invocations` 独立保留,重复调用的聚合仍由后端和 Core 完成。套件和用例名称完整保留,括号及参数后缀不由 +宿主解析;耗时只读报告的 `durationMillis`,不再读取 Surefire 控制台摘要。 +现有 JDT 适配器提供独立的报告名称关联,执行标识仍原样转发;通用 UI 仅按完整字段 +匹配,工程、套件和用例分开作键,名称中的 `#`、冒号或括号不会合并不同结果。 +精确重跑引用单独按报告用例关联;清空当前结果时清空引用和来源根,历史行标记保留。 +历史按语言提供者隔离,Node 等提供者的同名用例不能覆盖 Java 的编辑器状态。 +结果树合并精确的语义关联与全部报告节点;只有部分源码匹配时,也不能丢弃其余 +报告用例。来源不明确或关联重复的节点保留为报告节点,不据名称猜测 Debug 源码。 +Java 行标记操作接受通用 `suite`/`case`,为现有 Windows 调用保留旧字段别名。 + +整类重跑按后端明确返回的覆盖范围清除旧方法;单方法重跑保留兄弟方法和其他工程。 +没有覆盖范围时只替换报告中的精确用例,不能从选择器猜测执行范围。 + +报告节点保留插件、实例、当前工程 ID 与 suite/case;没有源码关联仍可重新请求原 +后端运行,但 Debug 必须有 JDT 提供的源码关联。保存、模型/配置变化、工作区切换和 +取消保留同一操作身份;旧输出、PID 或报告不能覆盖新运行。协议、资源边界和 +Surefire 精确选择证据见 [原生执行决策](2026-10-26-build-provider-task-execution.md)。 + +Windows 启动指定测试类前继续重新向 JDT 查询文件并验证类归属;编辑、切换文件或 +语言服务状态变化立即撤销旧标记。不能从类名或文件名推导另一个启动目标。 ### 投影在 Core @@ -68,7 +84,7 @@ Issue #790 要求像 IDEA 一样在 Java 编辑器行号旁显示运行按钮: macOS 顶栏保存完整的 `LanguageTestScope`(工作区、文件、类/方法)与 provider ID;测试面板切换范围后覆盖旧目标,顶栏 Run/Debug 按同一 scope 分派,不能把工作区或文件重建成上一次单方法目标。名称由 scope 生成,文件或单方法调试准备完成后采用 JDT 解析出的启动名称并保留原 scope,避免顶栏 Pause/Resume 的名称关联丢失;用户已切换目标时迟到结果不能覆盖新选择。顶栏 Run 在有测试目标时优先走测试分派,即使底层普通配置还在运行也不能重启它,运行图标和背景也按当前目标呈现。选择普通运行配置时清除测试目标。是否支持该范围的调试仍由既有测试调试准备流程判断。顶栏 Debug 在已有运行或暂停会话时仍先比较当前测试目标;目标改变就重新准备所选测试,只有同一目标才重启旧会话。 -Tests 结果树只展示当前运行的 `results`,且仅投影到 `activePlan.providerID` 对应分组;状态和耗时也不能跨提供者使用。关闭或清空当前结果后恢复完整发现列表,供编辑器标记使用的 `testOutcomes` 历史缓存继续保留,但不得用它恢复结果树。Tests 结果树的渲染、选中查询和默认选中共用节点集合,报告合成出的类/方法必须能按完整标识重跑;已有 JDT 节点继续保留源码关联。报告本身不能证明源码归属,缺少关联时禁用 Debug,不能从类名猜路径后启动另一个测试。 +Tests 结果树只展示当前运行的 `results`,且仅投影到 `activePlan.providerID` 对应分组;状态和耗时也不能跨提供者使用。关闭或清空当前结果后恢复完整发现列表,供编辑器标记使用的 `testOutcomesByProviderID` 历史缓存继续保留,但不得用它恢复结果树。Tests 结果树的渲染、选中查询和默认选中共用节点集合,报告合成出的类/方法必须能按完整标识重跑;已有 JDT 节点继续保留源码关联。报告本身不能证明源码归属,缺少关联时禁用 Debug,不能从类名猜路径后启动另一个测试。 | | Windows(React/Monaco) | macOS(WKWebView 中的共享 Monaco) | | --- | --- | --- | @@ -108,20 +124,20 @@ intellij-community 的 `runConfigurations/testState` 图标(Apache 2.0)。 - 收益:两端标记规则相同;Java 新写法只需升级 JDT;测试结果精确到方法,包括 参数化测试和内部类。 -- 代价:每次停止输入后多一次 `resolveMainMethod` 请求;Core 需要读取模块下的报告目录; - POM 里用无法解析的属性配置 `reportsDirectory` 时读不到报告,只显示普通 ▶。 -- 跑完测试后如果报告被修改时间更早的文件覆盖(例如时钟回拨),状态不会更新, - 这是按时间识别“本次报告”的已知限制。 +- 代价:每次停止输入后多一次 `resolveMainMethod` 请求;Maven 插件需要捕获上游实际配置的本次报告; + 报告读取失败会明确终结当前操作,不用旧文件补成成功。 +- macOS 当前 Maven 路径按实际 Mojo 的操作前后内容变化捕获,不再只按修改时间判断; + Windows 的旧 Core 报告读取器仍保留时间戳限制,需在 Provider 宿主迁移时删除。 ## 验证 - `./scripts/rust-core/verify-rust-core.sh`:`java_main_methods`、`java_run_markers`、 `maven_test_reports` 单元测试,`shared/fixtures/java/run-markers-v1.json` 兼容性 - fixture,以及可选的真实 JDT 冒烟测试(设置 `LITHE_JDTLS_SMOKE_ROOT` 时断言 Java 25 + fixture(同时覆盖旧字段与通用报告字段),以及可选的真实 JDT 冒烟测试(设置 `LITHE_JDTLS_SMOKE_ROOT` 时断言 Java 25 实例 `main` 有标记、`private main` 没有)。 - `./scripts/architecture/verify-shared-contracts.sh` - `./scripts/windows/verify/verify-windows-boundaries.sh` -- `./scripts/macos/test/test-macos.sh`:`MavenTestOutcomeTests` 与测试服务报告请求断言。 +- `./scripts/macos/test/test-macos.sh`:`ExecutionModuleTests`、`BuildSystemServiceTests`、`LanguageTestPresentationTests`、`LanguageIntelligenceModuleTests` 与真实 Maven Provider 回归。 ## 适用范围 @@ -133,3 +149,4 @@ intellij-community 的 `runConfigurations/testState` 图标(Apache 2.0)。 - `frontend/editor/src/run-markers.ts` - `macos/Sources/Lithe/Models/AppModel/AppModel+JavaRunMarkers.swift` - `macos/Sources/LitheExecutionModule/Services/LanguageTestService.swift` +- `shared/fixtures/build-provider/test-results-v1.json` diff --git a/.agents/notes/implemented/architecture/2026-09-26-maven-dependency-tree-output-file.md b/.agents/notes/implemented/architecture/2026-09-26-maven-dependency-tree-output-file.md index afb202efef..35ecc88eb6 100644 --- a/.agents/notes/implemented/architecture/2026-09-26-maven-dependency-tree-output-file.md +++ b/.agents/notes/implemented/architecture/2026-09-26-maven-dependency-tree-output-file.md @@ -4,15 +4,15 @@ ## 先说结论 -Maven 工具窗口的依赖树以前是从 Maven 进程的整段控制台输出里解析出来的。 -依赖树和普通日志共用 500,000 字符的上限,大型项目(例如 6,000 个依赖节点, -约 700 KB 文本)会报 `Maven dependency output exceeded the supported limit.`, -依赖根本打不开(#890)。 +Windows 现存 Maven 依赖树继续通过独立输出文件读取,避免控制台日志挤掉合法依赖数据。 +macOS 在 #1495 阶段 D 已改用工作区 Maven Provider,删除旧 Maven 专属依赖进程、 +临时输出 adapter 和 Swift 文本树命令桥。通用 Build 树读取插件的上游依赖结果; +Java 依赖导航使用 JDT 返回的实际类路径与虚拟文档,不再根据 Maven 坐标拼接 JAR 路径。 -现在依赖树由 `maven-dependency-plugin` 通过 `-DoutputFile` 写进一个平台临时 -文件,Rust Core 从文件读取并解析。控制台输出只当日志,不再参与数据解析。 -开发者以后要记住:**依赖数据走文件,日志走控制台,两条通道不能再合并**; -临时文件由平台创建和删除,Core 只负责校验和解析。 +下面的文本树协议和 Windows 文件生命周期仍是当前契约;只有 Windows 消费者也切换 +后才能移除共享 Core 解析器。macOS 的通用 Provider 生命周期以 +[构建系统插件契约](2026-10-17-build-system-plugin-contract.md) 和 +[原生 Provider 执行](2026-10-26-build-provider-task-execution.md) 为准。 ## 问题 @@ -62,10 +62,7 @@ Maven 工具窗口的依赖树以前是从 Maven 进程的整段控制台输出 `maven-dependency-trees/` 下,以会话 ID 命名;应用是单实例,启动时清空 整个目录。前端 `maven.store.ts` 的所有结束路径(读完、取消、超时、失败、 配置失效、被新请求取代)都经过 `releaseDependencySession`。 -- **macOS**:端口 `MavenDependencyOutputStoring`(`LitheCoreContracts`)由 - `MacMavenDependencyOutputStore` 实现。允许多个 Lithe 同时运行,所以每个 - 进程使用以 PID 命名的子目录,创建时只删除进程已退出的目录。`MavenService` - 的结束路径统一经过 `releaseDependencyOutputFile`。 +- **macOS**:该专属端口、adapter 和服务结束路径已删除。Provider 使用自己的工作区状态目录与上游扩展,宿主通用服务负责取消、过期结果和会话释放,不再分配旧树文本文件。 - 两端都在 Core 读完之后才删除文件;一次读取开始后,新的请求或取消不会删掉 正在被读取的文件。 @@ -135,10 +132,12 @@ m2e 内嵌的 Maven,而不是用户配置的 Maven 或 mvnd,违反“依赖 - Windows 前端:在 `windows/tauri` 运行 `bun test --preload ./src/test-utils/vite-assets.ts src/features/maven`,覆盖 每条结束路径都删除临时文件、读完才删除、删除失败只记日志。 -- macOS:`./scripts/macos/test/test-macos.sh`,其中 `ExecutionModuleTests` 覆盖服务生命周期, - `MavenRuntimeTests` 验证 fixture 解码与 PID 目录清理。普通 Swift 单测不链接 - Core,`MavenDependencyTreeBridgeTests` 在 `./scripts/rust-core/verify-rust-core.sh` 的 - Core 链接测试中通过真实 Core 规划并读取树文件。 +- macOS:`BuildSystemServiceTests` 验证 Provider 依赖结果、失败、取消和过期输出; + `RustBuildSystemOperationsTests` 验证公共 `build.*` 计划和解码; + `LanguageIntelligenceModuleTests` 与 `DependencyProviderTests` 验证语言后端解析根和虚拟文档。 + `MacNativeBuildProviderIntegrationTests` 可用显式开发包、工具路径与真实多模块工程复验。 + 旧 PID 目录清理、Swift Maven 文本树 DTO 与文本树命令桥的测试随被替代实现删除; + Windows/Core 文本树 fixture 继续在其拥有者的测试中验证。 ## 适用范围 @@ -148,5 +147,4 @@ m2e 内嵌的 Maven,而不是用户配置的 Maven 或 mvnd,违反“依赖 - `shared/fixtures/maven/dependency-tree-v2.json` - `windows/tauri/src-tauri/src/maven.rs` - `windows/tauri/src/features/maven/` -- `macos/Sources/LitheExecutionModule/Services/MavenService.swift` -- `macos/Sources/Lithe/Platform/MacOS/Persistence/MacMavenDependencyOutputStore.swift` +- `macos/Sources/LitheExecutionModule/Services/BuildSystemService.swift` diff --git a/.agents/notes/implemented/architecture/2026-09-30-ide-capabilities-and-local-mcp.md b/.agents/notes/implemented/architecture/2026-09-30-ide-capabilities-and-local-mcp.md index 82c9fa5a3a..9f90d1a4ef 100644 --- a/.agents/notes/implemented/architecture/2026-09-30-ide-capabilities-and-local-mcp.md +++ b/.agents/notes/implemented/architecture/2026-09-30-ide-capabilities-and-local-mcp.md @@ -56,8 +56,31 @@ Lithe 对外开放项目环境、Maven、运行配置和执行输出,复用界 用默认值构造的结果满足新协议。旧版、新版直接发现及兼容初始化分别使用独立 helper 进程验证,不能只在旧版会话中追加一个新版本请求。 -正确示例:MCP 的 Maven execute 调用现有 Maven 功能的 runGoals/runCustomGoal, -沿用项目的工具链与 Profile;不要在辅助程序里自行执行 `mvn` 并读取另一套配置。 +macOS 的 Agent 构建已改为公共 Build 入口:先查询插件检测的实例、声明和模型 ID, +再用 `lithe_build_execute` 调用同一 `BuildSystemService` 与工作区 Provider。参数保留 +独立数组,选择器从当前模型再次核对;模型过期时不能执行旧项目/目标/模型任务。 +Maven 的目标语义、参数解释和执行由 Maven 插件负责,宿主不再保留该 Agent 路径的 +`runCustomGoal`、命令拼接、专属进程和任务状态机。配置、项目加载、依赖与 Java +协作仍待阶段 D 的后续切片替换,本次删除不代表整个阶段完成。 + +Build 输出槽持有最近一次操作的快照,任务结束后仍保留 ID、状态、退出码和输出。 +同控制台的模型/依赖日志不属于该任务,不进入 Agent 输出。新任务替换或同路径重开 +会撤销旧 ID;停止只取消仍匹配的运行身份。快照只在内存里,不新增文件或镜像配置。 +两端从共享清单选择自己实际接通的工具,声明随原生授权写入私有连接文件。 +macOS 只暴露通用 Build 工具,删除旧 Maven 查询、配置、重载与执行接线;环境工具只编辑 +JDK/Maven 工具链,Profile、跳过测试和原生 XML 继续使用插件配置流程。 +环境查询和修改直接读取 canonical 工具配置,不激活执行模块,也不为配置 JDK 加载旧 +Maven/Run 工程。设置页已有未保存草稿或正在保存时返回忙碌,不重置草稿或代用户提交其他编辑。 +Windows 仍有旧 Maven 消费者,暂时保留四个 Maven 工具。Build 查询、重载和任务执行 +已接工作区现有 Provider controller,发现清单包含这三项;尚未接入持久化的通用选项 +修改仍不暴露。原生插件能力准入继续关闭,API 接通不代表允许安装要求未实现能力的包。 +声明只能减少共享清单里的工具和可选参数,不能定义新类型或扩大权限。 +辅助程序据此发现工具,broker 在请求入队前执行相同校验,原生插件调用也使用同一声明。 +因此旧客户端直接调用已删除工具也会明确失败,而不会触发模块初始化或旧业务路径。 + +正确示例:Agent 查询 `goal` 声明后把 `["clean", "verify"]` 作为参数数组交给 Maven +Provider,UI 与 Agent 使用同一份 Build 生效配置。不要在辅助程序里自行执行 `mvn`, +不要用旧操作 ID 停止一个已经替换的运行。 连接信息存入平台应用数据目录,锁文件按项目排他持有。目录由平台 adapter 决定,关闭连接时删除凭据文件,崩溃后的陈旧文件在重新授权时覆盖。辅助程序 @@ -86,6 +109,9 @@ Lithe 对外开放项目环境、Maven、运行配置和执行输出,复用界 8. **使用 `Date.now()` 并仅拒绝负差值**:系统回拨后差值仍可能为正且小于预算, 放行实际已过期的请求。Windows 适配器提供单调计时,其他平台不可直接假设其 WebView 的休眠计时行为相同。 +9. **共享清单直接等同于每个平台的发现清单**:会向客户端宣称尚未接通的能力, + 或为了另一平台仍有消费者而让已迁移平台继续暴露旧入口。改为原生宿主选择已知工具; + 不允许平台另写一套 schema,以免参数类型和权限规则逐渐分叉。 ## 后果 @@ -98,6 +124,33 @@ Rust 命令可以直接替代当前窗口或工作区的状态所有权。 沙盒隔离能力。配置文件跨存储写入沿用现有部分失败语义,调用者应检查返回错误 并重新读取实际值;API 不承诺安装工具链或静默处理 Java 构建失败确认。 +通用 `lithe_build_configure` 与原生 Build 的实例选项编辑共用 Core 校验和同一存储。 +Agent 先 inspect 取得选项 ID、类型、当前值与候选,再提交一个值或 null 重置;非法值在 +写入前拒绝,并读回确认。它不会编辑原生 XML,也不创建未声明的偏好。清空多选保留 +已有实例清空标记和兄弟实例选择,配置变化撤销旧模型、语言导入与原生配置草稿。 +Windows 的通用选项修改尚未接入同等写入工作流,因此不暴露 `lithe_build_configure`; +旧 Maven 专属配置入口的剩余消费者继续列在阶段 D 待删清单,不能因新 API 可用而宣称整个迁移完成。 + +### Windows Agent 借用现有 Build 工作区 + +Agent 的查询先执行只读目录检测,返回已准入清单、Core 校验的选项和当前模型身份, +不会为了 inspect 探测工具或启动 Provider。重载和执行经同一 controller 发布到 Build +输出面板,工作区拥有后端及清理;MCP 连接不另建会话,也不复制日志历史。调用前后的 +授权与工作区身份核对防止迟到检测启动任务,Core 选项读取期间模型被撤销也拒绝发布。 + +任务必须来自当前声明及完整模型。项目、目标、模型任务逐项核对归属与模板,模型任务 +不能同时携带目标;领域层启动时再次校验当前工具、来源和模型证据,Core 最终校验 +任务作用域及选择器。参数保持数组,不解释 Maven goal 或从名称推导目标。 + +例如查询得到项目 A 的目标后,可使用其精确 ID 执行声明的目标任务;不要把同名目标 +和项目 B 拼在一起,也不要把模型任务交给另一个声明模板。原生未准入的包不会出现在 +检测结果,不使用旧 Maven store 补出一个可执行实例。 + +状态和输出使用领域层有界内存记录(最近 50 个完成操作),列表只读取摘要,实际输出 +按需交给现有 Core 游标分页。停止精确 UUID;旧记录不能停止替换任务。撤销 MCP 只 +阻止新请求,已经接受的工作仍属 IDE;项目关闭和插件撤销继续取消、排空并清理。这里 +没有新运行时目录、下载或可复用资源,Build 配置写入和旧 Maven 消费者迁移仍待完成。 + ## 验证 - `cargo test --manifest-path rust/Cargo.toml -p lithe-ide-host` 验证授权、路径、 @@ -122,8 +175,21 @@ Rust 命令可以直接替代当前窗口或工作区的状态所有权。 - Worker 宿主测试验证未声明权限时不能发现或调用项目、显式工作区不被当前焦点 替换以及应用层拒绝原样返回。Maven 的 API 保存等待真实写入结果;失败进程的 输出读取不能被 Windows Git 兼容层误判为 IPC 失败。 +- 阶段 D 的 Agent 构建切片在 macOS 的固定 Swift 6.3.3 下通过 159 项/6 组回归; + 新增的 Provider/Agent 成功与失败两个参数用例合计 108 毫秒。显式开发包经真实 + Agent 调用、生产 macOS Provider 会话与 Maven 多模块工程完成模型加载和模块编译, + 完成后查询输出并关闭后端、核对插件包文件清单,耗时 4.787 秒。开发包来自 Maven + 插件 `a72ee3e8ee2d32412281c746ea7cc2d76a3cda6a` 与显式 SDK JAR,不冒充发行包。 + 本地逐测试报告为 `.artifacts/test-stability/stage-d-agent.{json,html,junit.xml}`、 + `stage-d-agent-real-maven.{json,html,junit.xml}`;IDE broker 6 项和 Windows 前端逻辑 + 在 macOS 上的 Bun 9 项计时通过。后者不是 Windows 实机证据。 - 双端原生打包运行的验证状态以功能矩阵为准。Linux 的协议与前端测试不能 证明 macOS Swift 编译、Windows 安装与原生运行通过。 +- 阶段 D 删除 macOS 剩余 Maven Agent 接线后,`stage-d-agent-environment-canonical` + 的 25 项原生计时通过:损坏 POM 时不激活执行模块即可保存工具链,已有草稿返回忙碌, + 损坏配置保留原字节;旧 Maven 工具和 XML 参数在激活前拒绝。真实 Maven 模块编译约 + 2.57 秒。三种真实 stdio 会话、14 项 IDE Host 与 23 项前端测试通过;前端测试运行在 + macOS Bun,不算 Windows 原生证据。完整 `./scripts/rust-core/verify-rust-core.sh` 用时约 111 秒通过。 ## 适用范围 diff --git a/.agents/notes/implemented/architecture/2026-10-17-build-system-plugin-contract.md b/.agents/notes/implemented/architecture/2026-10-17-build-system-plugin-contract.md index e230417011..3e3532d057 100644 --- a/.agents/notes/implemented/architecture/2026-10-17-build-system-plugin-contract.md +++ b/.agents/notes/implemented/architecture/2026-10-17-build-system-plugin-contract.md @@ -20,6 +20,27 @@ Rust Core 负责校验声明、识别工程、展开每一条命令行,并把 当前增加 `buildProviders: 2/3/4` 与 `litheBuild` 协议 2/3/4:原生 Build 任务、测试发现/执行和应用启动准备通过类型化请求进入同一个工作区 Provider,不要求网页,不使用插件私有函数。测试报告复用原有解析器,应用准备交给既有 Run 进程所有者。本文关于版本 1 仅有模型/依赖的说明仍适用于版本 1;具体版本准入、进度、取消与删除条件见[Build Provider 原生任务执行](2026-10-26-build-provider-task-execution.md)。这不代表命令模型运行配置、服务编排或 Java/Maven 业务迁移已经完成。 +### 多个构建根各自保存选项(2026-10-28) + +同一工作区可能同时包含两个独立工程。它们即使使用同一个构建插件,也可能选择 +不同的 Profiles、配置文件或仓库目录,因此不能把不同实例的路径和开关压成一个共享值。 +插件用 `buildSystems: 3` 为需要隔离的选项声明实例作用域,宿主以检测到的相对根目录 +保存和读取;Core 在生成静态运行入口前就收到同一份实例值。模型、依赖、任务、同步指纹 +与语言导入继续消费这份输入,宿主不从工具名字推测作用域。 + +例如 A 的配置文件是 `a.xml`、B 是 `b.xml`,编辑或重置 A 只改变 A。显式空数组、 +空文本和 `false` 仍是用户值;实例作用域不再使用共享多选的空选择标记。未声明作用域 +的现有插件保留工作区选项和既有多选行为。不要把实例值覆盖到共享选项,也不要以工作区 +值作为实例的隐藏回退。已有选项改变作用域时,宿主先为各根复制旧值、保留已有实例覆盖和显式空标记, +读回目标成功后才删除共享源并重新检测。失败阻止发布默认配置,保留源以便重试;暂未检测到 +但已有标记的根也保存其原值。本能力本身 +不宣称旧 Maven 配置和其所有消费者已经迁移。 + +新增状态只在平台偏好存储中按工作区、构建系统和根目录隔离,不写入发行包或插件包, +不跨工作树复用。`PluginBuildOptionPersistenceTests` 使用真实 Core、生产存储和服务, +验证兄弟编辑、空值、重置、重开、任务参数与语言导入;Core fixture 验证静态运行入口 +也使用对应根的参数,并拒绝不支持实例作用域的宿主。 + ## 问题 在这次改动之前,构建工具的知识分散在三处: @@ -226,9 +247,11 @@ macOS 宿主接受级别 3,并在 `PluginManifest.hostCapabilities` 里声明 - **视图状态不能跨模型沿用。** 模型、实例列表或工作区变化后清除所选任务、未提交的参数弹窗和选项输入弹窗,避免运行旧模型中的请求。实例选择消失后回到首个有效实例,自定义输入还要校验打开时的实例身份,防止旧弹窗改到新实例。任务分组节点按每级标题的 UTF-8 长度编码身份;标题即使含有分隔符,也不能和另一条分组路径共用折叠状态。 - **已删除。** `MavenView`、`MavenBuildOutputView`、`MavenDependencyLocalization`、`BuildSystemsView`,`execution.maven`、`execution.maven.output`、`execution.build` 贡献,`toggle-maven` 命令和对应的 `ToolWindow` 常量。旧布局里的这些 ID 和其他未知贡献一样被忽略;`ToolWindow.restoredID` 改为显式映射旧名字,不再依赖 `allCases` 的顺序。 -还留着的 Maven 宿主代码:`MavenService`(以及 `MavenFeatureModel`)仍负责未声明插件导入配置时的兼容设置、模块运行/调试的启动上下文,以及 `lithe_maven_*` Agent 工具。Agent 执行的 Maven 目标不再有专门的输出窗口,Agent 通过 `lithe_operation_output` 读取输出。`MavenResolutionProblems`(JDT 报告的 `pom.xml` 问题)仍供编辑器诊断使用,但不再显示在构建窗口里。通用构建窗口读取的依赖也尚未成为 Java 依赖侧栏的补充来源。 - -macOS 已接入插件选项到 JDT 的通用桥(第 12 节):检测、选项修改和模型加载完成后,Core 归一化每个实例的生效选项为 `mavenContext` / `javaGradle`,Swift 只负责等待、转发和过期保护,并通过唯一的发布回调更新同一工作区的 Java 会话。停用插件的清除资格归属实际带过 Maven 输入的活动会话,不用工作区内“曾发布过”的全局标记。运行/调试与 Agent 工具仍使用原来的 Maven 业务,因此还不能删除 `MavenService`。 +阶段 D 已删除 macOS 的 `MavenService`、`MavenFeatureModel`、项目加载中的 POM 分支及旧配置存储。 +Run 的工程路径、源码根和选项候选直接读取当前 Provider 模型;模型失效后这些投影立即消失, +不再用旧 MavenProject 填补。环境设置页只编辑 canonical 工具链,Maven 原生 XML 与调用选项在插件设置中维护。 +旧数据经平台迁移 adapter 校验并读回后才清理,迁移改变配置时重新检测后再发布语言导入输入。 +`MavenResolutionProblems` 仍展示 JDT 诊断;Run 的旧启动规划、精确测试和 Windows 当前共享命令消费者仍须继续迁移。 ### 12. 语言服务器导入:`languageImport` @@ -401,3 +424,79 @@ BuildSystemService 验证上述组合:前置 Runtime 缺失或版本不足时 Runtime 的显式选择均阻止模型和依赖 Provider 请求;全部设置为空时仍发送无 tool 的请求。 `MacBuildToolResolverTests.customRuntimeProbeAndExecutionUseTheSameSiblingHelper` 用临时可执行脚本验证带空格目录里的 Runtime 在探测和执行时都能找到同目录 helper。 + + +### 构建与运行复用本机工具链选择(2026-10-28) + +`executableResolution: 2` 的工具链候选引用已有 Run 注册表。Core 只传递工具链 ID 与 +规范化实例根,平台从同一份 local.json 读取项目和子项目选择,不在 Build 中再建 Maven +路径偏好。显式选择不可用或前置 Runtime 失败时停止请求,不能把无 tool 的请求交给 +Provider 自行找另一套 Maven。被动导入经只读发现入口,不运行 Wrapper 或版本探测。 + +Maven 构建所用 JDK 与后端进程自身的 JDK 是两项独立选择。宿主把解析得到的少量运行时 +环境与 Maven 路径一起传给 Core;Core 合并插件选项环境后使用既有 Provider 参数。 +模型、依赖、任务、测试和原生配置使用同一输入。不把整个宿主环境经 RPC 转发,否则会 +携带无关凭据。工具链解析到工作区内的程序或文件别名时,仍按项目代码限制显式读取。 + + +### 共享 Build 偏好与本机覆盖 + +旧 Maven 的共享 Profiles 和跳过测试不能迁入本机 UserDefaults 后丢掉团队范围。 +通用 Build 偏好使用 `.lithe/build/options.json`:工作区选项、所有实例的默认值和 +逐根覆盖都有明确位置,值仍由 Core 和插件声明校验。它是 Lithe 的调用偏好,不能 +冒充 Maven 原生 settings。非敏感原生文件仍按来源读取与确认编辑。 + +本机选项只保存相对共享值的差异,重置本机覆盖重新继承共享值。检测与重开不写共享 +文件,也不把共享默认值复制到本机偏好。本机偏好只有缺失键才表示未配置;键存在但 +不是预期的 Data 时同样报损坏,保留原值,不能当作空选项发布默认配置。读取损坏、不可访问或不支持的文件时停止 +发布默认构建/语言导入配置,保留原始内容并报告错误;写入检查原字节并读回核对。 +旧运行偏好中的 Maven 文件/仓库覆盖属于本机作用域,迁入 `.InstanceDefaults`, +供之后出现的实例继承,不写入团队文档。已有团队和逐根选择优先于迁移值, +逐根编辑及重置仍由同一选项合并链处理。两个实例从默认值继承后,编辑 A 不改变 B;Core 的静态运行入口也消费同样的合并值。 +共享文件变化会撤销原生配置草稿和模型快照,不能继续使用之前的配置指纹。 + +未选中的用户自定义值单独保留为候选,仅在插件允许自定义值时显示,不作为已选值或 +上游模型事实。这样旧 Profiles 迁移不会只保住当前选择,也不会因候选变化触发构建。 + +团队文档的存储语法由 Core 的 `build.projectOptions` 校验。macOS 和 Windows 的文件 +adapter 只负责有界读取、可写位置及发布,不再分别维护字段、类型和根路径规则。 +校验保留未启用插件的值、自定义候选及显式空选择;损坏文档报错,不能按空文档继续。 +共享 `validation.json` 同时驱动 Core 和真实 macOS bridge,Windows 文件测试复用 +同一团队默认值 fixture。它只校验存储结构,选项的类型、作用域和默认值继续由插件声明决定。 + +`build.detect` 同时返回 Core 按作用域和实例根合并的已保存选项与被拒绝的键,平台直接 +使用这份选择,不在前端再实现一遍继承链。例如 A 根显式保存空 Profiles,不能在 UI +发现列表为空时自行补回团队默认值。检测只读,不把归一化结果写回共享文档。 + +### Windows 结果与文件失效的基础设施 + +构建输出和结构化结果分开持有。Core 解码后的诊断、测试汇总和用例可以导航源码, +宿主不解析 Maven 输出或重新聚合报告;测试发现保留工程/目标身份,同名用例不能 +跨模块互换。报告和发现结果分别最多保留四份快照,大列表沿用共享虚拟列表;输出 +通知不能重复复制报告。取消、配置变化和插件撤销后不发布迟到的解码结果。 + +例如工程 A 和 B 都有 `ExampleTest.fails`,点击 A 的发现项必须把 A 的规范工程 ID +交给 Core,不能按类名找第一个匹配工程。源码编辑立即撤销发现清单,不能继续使用 +之前的发现项。Core 返回的 `syncFingerprint.watch` 决定模型输入;命中后立即撤销 +模型、依赖和报告,再合并只读检测,不能由前端解析 POM 来推断模块。 + +首次准备遇到源码事件也会取消,即使尚未取得子工程列表。加载前后核对根输入指纹, +发布完整模型的 watch 清单;执行前重算完整指纹,漏掉原生事件时也不能发送旧计划。 +通知先交给全部已有工作区所有者,再进行当前文件树的异步目录查询,隐藏面板不会 +丢失失效事件。代价是保守撤销正在准备的模型,需要用户再次明确加载,不能自动 +启动项目代码。团队文档变化立即撤销旧目录,合并后的检测只读。 + +原生来源的版本检查只返回一致性与观察路径,复用窗口文档 watcher 观察原路径及 +链接目标;缺失父目录观察现存祖先,不创建用户目录。只读版本检查端口与原生写入 +端口独立,模型加载不需要取得保存权限,也不把 XML 或凭据交给 WebView。 + +已保存 Provider 引用现在可以通过公共 Core 计划交给现有 Windows Run 进程管理, +不要求旧 Maven store 提供启动模型,也不先加载默认模型。临时运行模型沿用 Core +的选项校验、reactor 工具选择和该运行配置明确保存的路径覆盖;自己的原生来源观察 +在交接后排空,不停止工作区的共享后端。 +已有框架/模块配置只有在原生能力表准入后才切换;当前表仍关闭。具体所有权与取消规则见 +[任务执行决策](2026-10-26-build-provider-task-execution.md)。以上仍是 Windows 准入 +关闭期间的基础设施。完整 Run/Debug、Java、Agent、旧偏好迁移和实机验收尚未完成, +不能据此前端测试开放全部 Build 能力。 +`build-provider-workspace.test.ts`、`build-file-inputs.test.ts`、 +`build-workspace-controller.test.ts` 和 `build-pane.test.tsx` 覆盖上述状态与交互。 diff --git a/.agents/notes/implemented/architecture/2026-10-21-plugin-settings-surfaces.md b/.agents/notes/implemented/architecture/2026-10-21-plugin-settings-surfaces.md index e330be5d8b..34e2c28e18 100644 --- a/.agents/notes/implemented/architecture/2026-10-21-plugin-settings-surfaces.md +++ b/.agents/notes/implemented/architecture/2026-10-21-plugin-settings-surfaces.md @@ -28,10 +28,10 @@ macOS 设置窗口现在可以由插件“注册”页面:插件在 `plugin.js `PluginSettingsRegistry`(`macos/Sources/Lithe/Application/Features/PluginSettingsRegistry.swift`)从插件快照推导出侧栏页面: - 已启用插件为某语言提供语言服务器,或把设置/按钮放到 `{"languageID": …}`,就出现“语言与框架 › <语言>”页。 -- 设置/按钮放到 `{"buildSystemID": …}`,就出现“构建、执行、部署 › <构建系统>”页。 +- 设置/按钮放到 `{"buildSystemID": …}`,或构建系统声明公共原生配置投影,就出现“构建、执行、部署 › <构建系统>”页。 - 没有 `page` 的设置只留在插件详情的“设置”标签页。 -页面由 `ContributedSettingsPageView` 统一绘制:语言页先显示通用的语言服务状态卡(状态、按项目开关、重启),然后按插件列出它放到这一页的设置和按钮。控件由 `PluginSettingsSection` 根据设置类型选择,按 `section` 分组。 +页面由 `ContributedSettingsPageView` 统一绘制:语言页先显示通用的语言服务状态卡(状态、按项目开关、重启),然后按插件列出它放到这一页的设置和按钮。控件由 `PluginSettingsSection` 根据设置类型选择,按 `section` 分组。原生配置由公共 `buildProviders: 7` 声明,使用同页的通用来源/草稿/保存控件,不进入插件偏好双层存储;来源、冲突保护和上游回读见 [真实配置编辑](2026-10-25-settings-native-configuration-editing.md)。 正确做法:Elixir 插件想在 Elixir 页加“重建 PLT”按钮,就在清单里声明 `settingActions`,指向自己的插件函数。 diff --git a/.agents/notes/implemented/architecture/2026-10-25-settings-native-configuration-editing.md b/.agents/notes/implemented/architecture/2026-10-25-settings-native-configuration-editing.md index c16176ee09..10c0a488ad 100644 --- a/.agents/notes/implemented/architecture/2026-10-25-settings-native-configuration-editing.md +++ b/.agents/notes/implemented/architecture/2026-10-25-settings-native-configuration-editing.md @@ -35,14 +35,101 @@ 发行包、安装目录和无写入权限的配置保持只读。需要改变继承值时,明确让用户选择受支持的可写覆盖层,不自动提权,也不绕过发行包只读规则。真正属于 Lithe 的偏好仍可写入应用存储,但须标明作用范围,不能伪装成工具原生配置。 +### 项目和子项目的本机工具选择 + +项目 JDK、Maven 和 Maven JDK 的本机选择,以及各子项目的路径覆盖,都保存在 +已有的 `.lithe/run/local.json`。子项目路径覆盖按工作区相对根标识;编辑 A 的 JDK +不改变 B,也不替换项目默认值。页面只从这份文档回显,保存后再次读取;旧偏好里的 +`moduleOverrides`(按子项目保存的工具路径)不能在打开设置页时重新覆盖文档。 + +激活工作区时才允许一次性迁移。已有项目默认值继续优先;旧版文档尚未保存子项目 +选择时补迁旧子项目值,已经保存(包括显式清空)的子项目选择不被旧值复活。目标读回 +成功后才删除旧偏好字段,非法相对根和写入失败保留源数据。Run 编辑器保存配置时保留 +子项目映射,不将这些本机绝对路径写入团队配置或发行包。 + +这只完成项目设置偏好的这一部分迁移;旧 MavenService 按 reactor 保存的工具路径、 +Profiles/settings/仓库和其生产消费者仍须在阶段 D 的完整纵向切换中迁移并删除。 + +### Build Provider 的原生来源编辑 + +构建插件以公共 `buildProviders: 7` 注册原生配置读取,仍复用已有工作区 Provider 会话。 +宿主从清单推导通用构建设置页,不为 Maven 增加专用页面。自动读取遇到项目 Wrapper 或无法确定工具来源时先停止,只有用户明确点击“使用项目工具读取”后才运行;自动发现失败不能悄悄交给 Provider 运行 Wrapper 或触发冷下载。来源、可编辑原值、合并生效值、 +字段 ID 和非敏感投影来自插件,格式解释和编辑留在提供者;宿主只负责草稿、明确保存目标、生命周期、平台保护写入及回读。 +这条链路已接入 macOS,但不表示 #1495 阶段 D 的全部旧 Maven 消费者已经删除。 + +Windows 已补公共来源读取、原生文件两阶段事务和插件设置页:草稿由工作区持有, +隐藏设置页不写入或丢失草稿;明确保存显示目标 URI 和作用域,成功后只显示上游回读。 +继承值预填但不生成覆盖,取消不写文件,一个来源的保存保留其他来源的草稿。 +失败重载或保存后无法回读会保留草稿并使基线过期,必须成功重载后才能再次保存。 +项目工具仍需显式读取,文件选择器的迟到回复也要核对读取基线,不能覆盖重载后的值。 +这些是准备中的通用入口;Build 产品准入仍关闭,旧数据迁移和 Windows 原生验收未完成, +不能据此删除旧 Maven 设置路径。 + +Maven 插件观察所选 Maven 的官方 `EventSpy`(构建事件观察接口),在 +`SettingsBuildingRequest` 开始时记录实际来源与字节哈希,从 `SettingsBuildingResult` +取得原始合并事实,再在 reactor(多模块工程)读取前导出非敏感配置。最终命令行覆盖值 +来自执行请求。不能将 `MavenSession.getSettings()` 当成覆盖前的设置:当前 Maven 的 +`SettingsAdapter` 会直接回显已包含 CLI 覆盖的执行请求。 +上游依据是 [Maven 3.9.16 的配置处理入口](https://github.com/apache/maven/blob/maven-3.9.16/maven-embedder/src/main/java/org/apache/maven/cli/configuration/SettingsXmlConfigurationProcessor.java)。 +捕获来源、读取结束或源路径/哈希不一致时明确失败;不在 Lithe 维护另一套优先级和 Profiles 合并。 + +完整原生文档不经过 Provider RPC、网页、模型共享或可观察界面状态。协议 7 的字段 +只携带不敏感的原值、生效值和提供者自己认识的字段 ID,不再声明 XML 根或元素路径; +Core 不再包含 XML 编辑器。Maven 插件使用 JDK 的安全 DOM 读写器处理自己的 XML, +拒绝 DTD、限制深度并重新解析生成的文档后才输出。直接用 Maven 的整份 Settings +序列化器会丢失注释及未建模内容,所以不用于受保护编辑;有效值仍由 Maven 读取, +不在 DOM 层重建合并或优先级。 +Node/Java SDK 的示例使用 JSON 读写器处理配置;新增工具格式不需要给宿主加枚举。 + +宿主在已有工作区 `outputs` 下创建私有暂存文件,保留原文件字节和来源身份,然后把 +字段编辑和输出路径交给提供者。提供者核实来源后只写暂存文件,返回来源 ID、SHA-256 +和是否更改的回执;原始和替换文档均不返回。宿主确认仍是当前操作、校验暂存哈希后, +使用已有条件原子替换保留权限和扩展属性;提供者负责保留格式支持的注释、无关内容和 +秘密。app bundle 和插件安装目录仍只读,多个来源的可写别名不得绕过这条边界。 + +暂存沿用已有隔离状态生命周期,不跨工作树复用。成功、失败、取消或窗口/插件撤销都 +释放暂存。提供者必须无 CREATE 打开已有文件,因此取消后的迟到请求不能重建秘密 +文件;已经打开的句柄可以结束写入,但不能发布原生来源。Windows 的文件清理由阻塞 +工作线程处理,不在 WebView 线程或关闭窗口的锁内读写磁盘。 + +草稿以原值为基线,继承值直接填入控件但不产生覆盖;用户显式启用覆盖后才进入编辑集。 +保存前重新确认工作区、包、设置与单调递增的选项修订号,选项 A→B→A 也会撤销旧保存。 +文件本身另以原字节进行冲突检查。保存后撤销构建、测试和语言导入事实,再调用同一 +Provider 读取实际生效值及已发布的源哈希;回读失败保留草稿并要求重载,不能把草稿当成结果。 + +探针目录和实际来源哈希缓存只写平台的工作区状态,调用结束清理临时输出,不跨工作树复用。 +配置探针还观察 Maven 官方发布的完整 `MavenExecutionRequest`,在上游已填入默认值、 +尚未创建仓库会话时记录生效结果。仅把这次探针的仓库管理器改到操作拥有的私有临时目录, +随后在工程导入前退出;不能因打开设置页或保存回读而创建真实用户仓库。早先仅在 +`afterSessionStart` 导出已经太晚,Maven 会先创建所选目录。两种路径的非敏感字段仍由 +同一官方请求提供,不重算优先级。依据是 [Maven 3.9.16 的 CLI 执行顺序](https://github.com/apache/maven/blob/maven-3.9.16/maven-embedder/src/main/java/org/apache/maven/cli/MavenCli.java)。 + +Maven 模型缓存版本 2 保存上游实际选中的来源,即使来源由带空格路径的 `.mvn/maven.config` +指定,外部编辑或删除也会使缓存失效;不以宿主猜测的默认路径代替上游事实。 + ## 考虑过的备选方案 - 只提示默认路径、要求用户重新填写:省去读取逻辑,但无法证明显示值与工具一致,也增加误操作,因此不作为非敏感配置的默认交互。 - 所有字段每次按键或失焦立即保存:省掉一次确认,却容易破坏共享配置、保存未完成输入或复活重置前草稿;原生文件编辑不采用。 - 把整份配置复制到应用存储再接管:制造第二份真源和升级兼容成本。优先复用上游解析与编辑能力;确需隔离运行时配置时,沿用已有专门契约并清楚标注范围,不冒充原文件编辑。 +- 用私有插件函数读写整份原生文件:使宿主猜测领域参数,也把凭据送入 RPC;采用公共只读配置投影,原始字节只在平台 adapter 与 Core 纯编辑操作间流动。 +- 重新运行 SettingsBuilder 后与会话 Settings 比较:会话已经包含命令行覆盖,合法覆盖会被误判为文件变化;改为观察 Maven 自己的原始构建事件并校验原字节身份。 ## 后果 +Windows 的 Build 来源版本通过原生只读检查核对,结果只含一致性与观察路径,原始 +XML 不进入 WebView。外部用户/安装来源和符号链接目标加入现有窗口文档 watcher, +与编辑器文档共享注册;它们不能替换编辑器租约或另建文件监控器。若 `.m2` 等父 +目录尚不存在,观察最近存在的祖先,收到目录创建事件后再更新注册;打开设置和 +首次模型读取均不能为了观察而创建用户目录。 + +构建模型读取时同时获取已声明的来源投影,发布模型和开始执行前重新核对版本, +不要求用户先打开设置。版本不符或观察失败使模型/配置基线过期;链接换目标即使 +字节相同也重载观察。保存期间将自身来源通知与外部失效区分,提交后重读并重新 +核对;保存失败也要核对旧租约,不能留下仍被视为当前值的旧基线。关闭工作区和 +插件撤销要排空迟到的原生检查,再确认清理完成。这是来源观察基础设施,不能 +代替 Windows 实机或整个 Maven 迁移验收。 + 用户能在设置页完成读取、编辑、确认和核对结果,不必猜测应用是否真的影响工具。实现需要维护来源、有效值和草稿的区别,并处理并发修改与读取失败;这些复杂性由已有配置适配器和功能模型承担,不下放给视图。现有页面逐项修复,不能仅更新本文就将功能矩阵标为已验证。 ## 验证 @@ -51,6 +138,7 @@ - 补充敏感字段不泄露、外部修改冲突、文件缺失或无权限、写入失败保留草稿,以及恢复默认后立即失焦、切页或关闭不写回旧值。测试使用临时目录或隔离存储,不读取或修改开发者真实配置。 - 按目标平台执行原生交互测试和真实工作流;单元测试通过不能替代整页验收。记录未完成的设置页迁移,不把政策当运行时证据。 - 运行 `./scripts/agent-notes/verify-agent-notes.sh`;有产品实现改动时执行对应测试、`./scripts/architecture/verify-service-boundaries.sh`、`./scripts/macos/verify/verify-runtime-bundle-immutability.sh` 和功能矩阵检查。 +- `BuildNativeConfigurationTests` 覆盖继承/取消、通用 JSON 暂存哈希与权限/扩展属性保留、外部写入、缺失文件、只读根和符号链接目标变化;`BuildSystemServiceTests` 覆盖选项 ABA、旧工作区回复、取消后迟到编辑回执、暂存清理及回读失败。`MacNativeBuildProviderIntegrationTests` 的显式 stage D 场景使用真实 Maven、SDK、Core、Provider 会话和平台文件 adapter;其通过情况仍以实际计时报告为准。 ## 适用范围 diff --git a/.agents/notes/implemented/architecture/2026-10-26-build-provider-task-execution.md b/.agents/notes/implemented/architecture/2026-10-26-build-provider-task-execution.md index 4a7a127421..aa5c862e68 100644 --- a/.agents/notes/implemented/architecture/2026-10-26-build-provider-task-execution.md +++ b/.agents/notes/implemented/architecture/2026-10-26-build-provider-task-execution.md @@ -4,7 +4,7 @@ ## 先说结论 -构建插件现在可以在自己的工作区 Provider(提供构建能力的后端进程)里执行任务、发现和运行测试,而不是把每个操作都展开为宿主命令。原生 Build 窗口复用现有工作区会话、输出、诊断、取消和进程清理,测试报告复用现有解析器,不要求插件增加网页或调用私有函数。这是 [#1495](https://github.com/1lck/Lithe-IDEA/issues/1495) 阶段 C 的构建、测试与应用启动准备基础能力;Maven/Java 业务迁移、多服务编排,及 Java/JDT 调试准备不在本决策已实现的范围内。插件自有原生程序的调试准备已经接到既有调试适配器注册表。 +构建插件在自己的工作区 Provider(提供构建能力的后端进程)里执行任务、准备应用和运行测试,宿主负责原生界面、操作身份与进程清理。Maven 的构建、配置、模型和精确测试已逐项切换到这条路径,并删除了对应的 macOS 旧服务;自动运行发现已改为通用声明/目标选择器,旧运行配置与工具链、部分启动规划和 Windows 消费者仍待迁移。本文记录阶段 C 的基础能力及阶段 D 已实现的增量,不代表 Maven/Java 全部迁移或正式发行已经完成。 ## 问题 @@ -62,6 +62,20 @@ Core 用 JSON-RPC 请求 ID 作为后端看到的 `operationID`,进度按仍 契约/协议变更合并前,用 `lithe-plugin-docs` 的 SDK 端到端工具验证当前源码;合并后按[契约发布规则](2026-10-23-plugin-contract-publishing.md)同步副本。已有 SDK 模型/依赖示例须继续协商版本 1 并正常工作;SDK 的执行处理器和 Maven 插件接入在独立仓库分别推进;本地源码验证、正式发版与旧生产路径删除必须分别记录,不互相代替。 +### 展示层消费已导入的模型事实 + +编辑器标签的测试源角色与搜索结果的模块名从通用 Build 模型读取,不再访问旧 +`MavenProject`。只使用已加载且未过期的模型,按最深的目录边界匹配;同路径结果 +按稳定顺序选择。比如 `app-copy` 不是 `app` 的子目录,虚拟文档也不能误套磁盘源码根。 +没有匹配的导入角色时,Java 编辑器仍可消费现有 JDT 返回的测试语义;这部分 Java +后端迁移属于阶段 E,不在这里另扫注解或文件名。 + +依赖导航继续使用语言 Provider/JDT 的真实类路径与虚拟文档;Build 树使用 Maven +Provider 已有的依赖模型。旧 MavenService 依赖进程、文本树临时 adapter 和把 Maven +坐标拼成 JAR 路径的 fallback 已删除。Windows 仍使用的共享文本树命令保留到其 +消费者迁移,不让 macOS 删除破坏另一平台,也不借此恢复 macOS 旧业务入口。 +本次没有新增下载、缓存、生成目录或持久状态;删除的临时文件路径不再创建。 + ### 正确做法与反例 正确做法:插件将构建任务声明为 Provider 来源,用户在原生窗口点击任务,宿主发送类型化执行请求;取消后,由同一个会话停止请求,旧输出不能写回新运行。 @@ -93,6 +107,73 @@ Build 树的应用点击在第一次异步等待之前就登记现有运行槽 原生点击和重启先等待工作区文档保存,保存失败或工作区代次改变便停止,不发送准备请求。准备失败、用户停止、插件停用和工作区切换各自终结对应身份;旧准备完成不能启动进程,旧终止事件不能结束替换准备。应用仍使用单个主运行槽,不宣称已经提供通用 Services 或多应用并行编排。 +### Windows 已保存入口交给现有 Run 进程管理 + +Windows 的已保存 Provider 引用保留公共选择器。框架/模块的已有 Run 配置在原生 +宿主准入后也使用公共 Core 输入,不改变服务 ID。准入状态只读取原生宿主的能力表; +当前仍关闭,因此旧框架入口继续原路线。宿主不拼 Maven 目标、类路径或 shell 命令。 + +启动先保存文档并只读核对当前目录及选项;即使文件通知漏掉,也不能继续使用旧团队 +选择。未变化的目录保留对象身份,避免并发服务互相取消。每次运行直接加载自己的 +模型,不先加载默认模型;默认 Maven/JDK 不可用时,明确选择有效工具的单项 Run +仍可准备。取消只排空自己的模型读取,不等待或取消另一服务的模型。 + +每次运行持有自己的临时模型和原生来源租约。单次 Profiles、明确空选择和 false +由 Core 校验。Maven/JDK 先沿用 reactor 的本机选择,再应用该运行配置明确保存的 +工具路径;空路径继承 reactor,不把全局默认误当成单项覆盖。Core 复用原有生成、 +团队和本机配置合并,平台只发现并探测最终路径,不新增工具偏好或第二份合并策略。 +临时模型不覆盖默认模型、设置草稿或持久配置。同步指纹包含选项,所以默认输入只与 +默认指纹比较,临时模型只与自己的指纹比较,不能把正常 Profile 变化误报为文件冲突。 + +临时模型借用工作区拥有的 Provider 后端会话。它关闭时只排空自己的读取和文件观察, +不能停止共享后端,否则同一工作区的 Build 或其他服务会被一起终止。原生启动确认后 +立即释放临时模型;释放失败则停止刚启动的执行并保留所有者,等待明确重试。 + +准备从第一次异步等待前登记到现有插件进程所有者。原生启动尚未确认时收到 Stop, +所有者等待确认后按同一执行 ID 清理;清理失败保留所有者供明确重试,不能把失败 +当成已停止,同一 Stop 内不会悄悄重试失败清理。关闭工作区或停用插件复用这份 +所有者,不另建后台运行服务。Native exit 比启动确认更早到达时,通用事件回调也 +不能提前删除这个所有者。准备输出以有界文本和单调偏移交给 Run,进度不复制模型 +或报告;清理成功后释放保存了临时模型的回调,避免服务运行期间继续持有准备数据。 + +例如用户在准备期间把参数从 `old` 改成 `new`,即使目标 ID 相同、文件通知漏掉, +启动前也必须重新读取 Core 的完整选项投影并拒绝旧计划。工具选择、工程指纹和 +原生来源版本同样在交接前重查。原生输出/退出事件携带执行 ID,旧进程的迟到退出 +不能结束同一会话的新执行,停止旧执行也不能停止替换进程。 + +这里使用 Core 已公开的语言导入投影选择 Maven/JDK,不让 Run 依赖旧 Maven 模型。 +代价是需要保存并再次检查准备身份,每次运行也需要一次独立的上游模型读取,取消时 +还须排空迟到的原生调用。完整 Debug/语言导入屏障、Java/Agent、旧偏好 +迁移及 Windows 实机验收仍待完成,不能据此开放 Build 准入。 + +### Windows 目标树显式保存并运行 + +目标树只展示当前工程和插件声明匹配的 Provider 选择器,服务、任务和应用的分类沿用 +声明。用户确认独立的参数数组后保存到既有 Run local.json,再由当前 Run 所有者准备; +不保存上次的命令或类路径。大列表复用虚拟列表,任务与目标共用参数输入控件。 + +保存阶段由工作区持有可取消的任务,确认对话框提供 Stop。即使原生保存忽略取消后 +才返回,也不能继续启动;进入 Provider 准备后 Stop 按确切执行 ID 清理。关闭工作区 +先撤销保存及准备,再排空所有请求。Run 面板首次打开等待这次目标交接,不能用 +页面自己的重载取消同一点击;其他项目仍可立即使它过期。 + +原生适配器复用 Core 的稳定 ID/文档合并与已有文档模块的字节冲突检测、权限保留和 +原子替换。Run 源文档只在原生侧持有,写入前重查路径,拒绝安装目录及工作区外别名, +成功后读回;既有本机忽略规则也通过受保护文档写入。外部编辑和损坏来源失败保留原文。 +保存触发的文件通知会撤销原模型,所以保存后重新检测并加载私有模型;仅在保存前 +检查旧 UI 模型租约,不能把自己的保存事件误当成目标撤回。后续模型和参数由正常 +运行路径重新验证。这条路径没有增加资源缓存或偏好副本,能力准入仍关闭。 + +### 已保存应用保留原始参数通道 + +协议 4 的应用引用保存的是交给 Provider 的原始参数数组。两端重新准备这种引用时, +继续使用 `userArguments`,不能只因当前宿主支持协议 10 就把它改成 `launchOptions`, +否则原来能运行的旧 SDK 会正确拒绝新版字段。共享的保存引用 fixture 同时约束两端。 + +只有原引用的参数保持不变,且没有 JVM 参数、环境、工作目录、入口或 Debug 覆盖时, +才可以沿用这条旧通道。实际选择新版运行选项仍须走协议 10/11,并在旧后端明确失败; +不能为了兼容删掉用户选择。service/task 始终使用声明的执行类型和类型化选项。 + ### SDK 使用同一组类型化入口 配套 Node/Java SDK 源码在独立的 `lithe-plugin-docs` 仓库实现任务、测试、发现与运行准备处理器,不复制进主仓库。SDK 选择宿主明确提供的最高共同协议版本;版本 1 不能调用执行、版本 2 不能调用测试、版本 3 不能调用运行准备,即使后端注册了对应处理器也一样。清单能力准入与进程协商是两层独立检查,SDK 不用私有函数代替领域方法。 @@ -169,9 +250,178 @@ Java SDK 并更新插件锁定值。开发验证通过显式 SDK JAR 构建独 不跨工作树复用。 真实 Core C ABI、插件 Java 进程和 Maven 三模块工程已经一起验证模型读取、模块编译、 -精确单方法测试、报告解析及公开模型投影;没有启动完整 macOS UI。旧 MavenService、 -Agent 专属入口、项目设置、运行配置和 Java/JDT 协同仍未迁移,因此只算阶段 D 的一个 -执行切片,不能宣布阶段 C 的全部官方插件同步或阶段 D 完成。 +精确单方法测试、报告解析及公开模型投影;没有启动完整 macOS UI。随后增量已经删除 +macOS 旧 MavenService、Agent 专属执行和配置表单;运行配置生成、其余启动规划、 +Windows 与正式发行仍未完成,因此不能宣布阶段 D 完成。 + +### 后端随执行返回测试报告(2026-10-28) + +`buildProviders: 8` 增加随当前操作返回的规范化报告。测试声明选择 Provider 解码后, +Core 请求明确要求报告,旧协商版本不能忽略这个要求;宿主不再按该工具的目录猜测 +报告来源。普通构建任务执行了测试时也可以返回报告,非零退出码仍保留失败用例。 +Core 只验证数量、字节、路径和字段,按工程/类/方法聚合;Swift 接收解码后的同一 +运行结果,取消或配置失效后不得发布。这样两个模块中的同名测试不会合成一项。 + +Maven 后端使用 Maven 的 `MojoExecutionListener` 取得已配置 Surefire/Failsafe 实例 +的报告目录,目录插值和命令行优先级仍由 Maven 处理;成功与失败都收集本次新报告。 +Maven 3.9 的 checked test failure 不经过该监听器的失败回调,所以补用公开的 +`ExecutionEvent.MojoFailed` 结束同一份捕获,不通过时间猜测报告是否完成。 +Apache Surefire Report Parser 3.5.2 负责报告语义;读取同一份有界字节前禁用 DTD 和 +外部实体,拒绝非普通文件、越界目录与超限报告。操作结束删除私有快照,保留工程原始 +报告,插件包只读。干净打包验证 Sisu 组件注册,避免依赖上一次编译留下的索引。 + +考虑过继续由 Core 根据固定 glob 读取 Maven 报告,但自定义目录和插件实际生效配置 +会漂移;也没有再写一套 JUnit 状态解释器。代价是 Maven 插件增加 Apache 报告解析 +依赖与有界的操作快照,许可证随包保留。验证包含真实模块编译、精确单方法、未知 +方法拒绝、自定义带空格目录中的失败用例、坏 XML/外部实体拒绝、取消与包字节不变。 +Java/Node SDK 的真实 Core 场景与计时报告通过;Maven 3.9.16 和最低支持的 3.6.3 +均通过报告及原生配置流程。[固定宿主和 SDK 的 Java 8 CI](https://github.com/Lithe-IDEA/lithe-plugin-maven/actions/runs/40792694290) +在当前 Maven 与 3.6.3 各完成 72 项检查,包含 Surefire/Failsafe、原生配置、取消 +和包字节不变;这份新提交证据独立于阶段 C 的旧结果。正式 SDK/插件发行、Java 运行后端和 Windows 消费者迁移仍未完成;macOS 的旧 +MavenService 及 Maven 测试报告 adapter 已随后删除。 + +### Java 语义测试标识进入通用执行(2026-10-29) + +协议 9 允许测试声明公开的 `selectorKinds`(选择器命名空间),再接收原样的标识列表。 +比如 Java/JDT 返回的 `java.test.fullName` 可包含二进制类名和方法;宿主只转交,不按 +源码文件名猜类名、不解释方法签名。Core、Node SDK 和 Java SDK 在旧协商版本下都 +拒绝这些字段,防止一个不认识筛选条件的后端运行全部测试。 + +macOS 的 Maven 测试经同一个 Build 服务读取当前模型、保存文档、等待语言导入, +再由 Maven 后端选择 Surefire 测试并返回本次报告。多 reactor 顺序执行、配置 ABA、 +保存期间被新操作替换、工作区切换、取消和报告解码都保留操作身份。原测试窗口仍管理 +当前选择、输出、重跑和编辑器历史;宿主的 Maven CLI 规划和报告读取已删除, +`RustJavaMavenOperations` 的 Maven 方法及旧 Maven 端口已删除。Gradle 和 Java 全领域 +后端迁移仍在后续范围内,不能把这段测试接入写成阶段 D 全部完成。 + +报告重跑保存当前插件、实例根、工程 ID 与报告提供的 suite/case;每次重新检查当前 +模型,不缓存上次命令或猜一个源码路径。没有源码关联的报告仍可按原工程重跑,但 +不能 Debug。结果、耗时和编辑器历史保留工程身份,避免两个模块的同名方法互相覆盖。 +性能采样的根 PID 来自宿主实际持有的 Provider 会话,后端不能提交一个任意 PID; +复用原来的子进程识别、JFR 和清理 adapter,没有另建进程管理器。 + +Surefire 3.5.2 的公开 `ResolvedTest` 会为普通类模式补 `**/`,仅拼全限定名仍可能匹配 +另一个包的后缀。因此插件使用其公开 `%regex[class#method]` 接口,引用完整类文件路径, +方法只允许自身的参数化调用后缀。真实回归放一个同路径后缀且执行即失败的类,证明 +错误包没有被运行;参数化调用由 Maven 报告 adapter 统一方法身份,通用 Core 仅聚合 +同项目/suite/case 的次数、最严重状态和完整、可安全相加的毫秒数。 + +后端显式返回已完成的报告覆盖范围。整类选择清除该工程同类的旧方法,单方法选择 +保留兄弟方法;宿主不解析 JDT 标识来猜范围。没有覆盖范围的旧报告只替换返回的 +精确用例。未选择具体类时,上游仍可能从配置继承筛选,所以不能声称整类已运行。 +Maven 的 `-am` 仍构建依赖工程,但监听器在上游完成配置后通过公开 `setSkipTests` +跳过未选工程的测试 Mojo;所选工程的类型化精确筛选在原始附加参数之后生效,避免 +同名依赖测试被执行,或被 `-Dtest=*` 扩大。 + +没有新增运行时资源目录、下载依赖或跨工作树缓存;后端仍打包进已有 JAR,瞬时报告 +和测试工作区沿用原来的状态目录与清理路径。已安装插件仍只读。 + +### Java 启动资源准备复用公开任务(2026-10-29) + +Java 启动前的 Maven 资源过滤与复制归 Maven 插件。语言工作流通过现有 Provider +任务请求清单声明的 `run` 用途;任务 ID 和适用源码角色由插件声明,详见下文 +“准备任务由插件声明用途和源码角色”。不增加私有函数或另一种后端进程。当前 +Build 模型最深工程边界和源码角色决定工程及资源任务;后端继续由 Maven 解释 +Profile、过滤表达式、自定义目录和 reactor。 + +Run 从原始点击起复用现有可取消工作流任务,覆盖激活、保存、资源处理和 JDT 等待; +Stop 在准备期间可用,新 Run 先撤销旧任务,迟到返回不能清除替换任务。 +原生工作流等待保存、当前导入和模型加载,等待资源任务成功后才请求 JDT 构建和 +运行路径。单次 Run 的选项与工具选择只作为这次请求的覆盖,不写回 Build 默认值; +配置 ABA、取消、替换和工作区切换撤销结果。当前模型仍表示持久配置,不能因为 +某次使用了不同 Profile 就把它伪装成模型已切换。Java 目标携带当前准备完成的宿主 +回执,Core 才省略旧资源 pre-launch 规划;现存 Windows 调用未携带回执,兼容路径 +继续保留,待其 Provider 消费者接替后删除。 + +正确做法:Maven 接到所选模块的准备任务,执行上游资源目标并返回状态,JDT 再解析 +运行路径。不要在 AppModel 拼 `resources:resources`、重新读取 POM、把准备失败当 +成功,或复用另一代配置的准备回执。这里没有新资源目录或下载;生成资源仍位于上游 +工作区输出,插件包只读,不影响签名和增量更新源基线。 + +### 框架调试保留 Maven 运行方式 + +Review 修正(2026-10-31):`runtime: jvm` 不是通用宿主契约,已改为 +`adapterID`、`configurationType` 和 `{kind: connection, host: 127.0.0.1, port}`。 +插件运行声明只发布配置类型;宿主复用现有 Registry,显式适配器选择优先,缺失或 +歧义时拒绝启动。准备、端口等待和 attach 都持有注册代次和包身份,停用、替换或 +代次变化撤销并清理所属进程,不能恢复到内置 Java attach。 + +Java Support 在自己的 `debugAdapters` 声明 JDT 托管适配器,引用同包的 Java +语言服务器;Maven 只引用公开类型,不能借 `tcpLanguageServer` 调用别的包。 +语言服务器会话也核对所属插件。现有 Core 的 `debug.adapters.startArguments` +负责 connection 到 DAP 参数的绑定;运行时特定的 JDWP/Mojo 参数仍由 Maven +后端解释。没有新增下载或运行时目录,插件安装目录与 app bundle 保持只读。 +普通 Java 的旧调试迁移仍属于阶段 E,不因为此入口已改造就宣称全量完成。 + + +框架的开发目标可能维护热更新、生成代码和运行时初始化,不能在 Debug 点击后改成 +随便一个 Java 主类。协议 11 在已有运行选项里接收宿主从 Registry 选择的适配器身份、公开配置类型和本机 connection 目标;Maven +后端通过当前生效插件的 Mojo(Maven 目标执行单元)描述符配置暂停和回环地址。 +Spring Boot 合并现有 JVM 参数并拒绝重复 JDWP;Quarkus、Micronaut 使用各自插件的 +受管调试参数,并校验参数名称和类型。缺少兼容参数时明确失败,不静默忽略。 +描述符查找同时识别 Maven 的属性表达式和默认值表达式:Quarkus 3.15.5 的 +`jvm.args` 使用默认值绑定,只检查属性表达式会错误拒绝真实插件。读取已有 JVM +参数时继续使用 Maven 自己的参数求值器,先展开配置值或默认值,再检查重复代理; +不要把 `${jvm.args}` 当作最终参数,也不要在宿主复制 Maven 的属性优先级。 +用于框架调试的同一份临时模型和选项还通过已有语言导入契约交给 JDT,并等待 +配置屏障后再启动框架;不保存为默认 Profile,也不为了附加调试再做一次 JDT 构建。 + +宿主继续复用 Run 的进程、输出和工具选择,等本机端口就绪后交给已有 Java Debug +Adapter(调试适配器)连接,不在 Core 或 Swift 拼 Maven 属性。等待有 120 秒期限, +Stop、配置代次、工作区和插件包身份都能使准备失效;进程结束和调试会话结束互相 +回收对方,只影响同一操作。非当前会话的失败也必须清理它拥有的进程。 +准备和进程输出使用同一个 Run 主输出区,停止后保留日志,不切到没有日志的模块会话。 +端口探测使用 bind,不先连接 JDWP,以免探测改变暂停状态;就绪前还要在后台核对 +监听者属于当前运行 PID 或其后代,不把抢占同一端口的其他进程当成目标。远程工作区尚无这条 +路径的端口转发,因此在启动前明确拒绝,不能把远端端口误当成本机 JVM。 + +例如框架服务应执行 Provider 返回的 Maven reactor 目标后再附加调试;不要把 +Quarkus 服务配置换成编辑器当前 Java 文件。明确带 Java 源文件的 Spring 主类仍可 +复用既有 JDT 主类构建与启动,这属于 Java 路径;任意 Maven 模块目标没有确定的 JVM +目标,不再宣称可直接 Debug。 + +没有新增运行时目录或缓存。Maven 模型、扩展及插件包保持只读,编译输出仍属于 +用户工作区;用户配置仍写既有 canonical Run 文档。此增量不替代完整原生 UI、 +全部框架版本和 Windows 验收,也没有完成阶段 E 的 Java 后端迁移。 + +配套 Maven 仓库的 `scripts/test-framework-debug.mjs` 用 Quarkus 3.15.5 和 +Micronaut Maven Plugin 4.7.2 的真实开发目标验证了启动前暂停、标准 JDI 连接与恢复、 +原有 JVM 参数、应用参数、环境和未安装的 reactor 兄弟依赖;停止后包字节不变。 +这组集成使用 Java 21 和 Maven 3.9.16,CI 在 Java 17 的现代 Maven 车道执行; +Provider 的 Java 8 / Maven 3.6.3 兼容车道保持独立。Java 8 的 JDI 辅助程序在编译和 +运行时都从所选 JDK 加载 `tools.jar`,不能借用 Maven 的另一个 `JAVA_HOME`。 +后续本地夹具把 Micronaut 换成真实应用:固定 Platform 4.7.6(Core/注解处理器 +4.7.14),由编译期依赖注入创建服务及 HTTP 控制器,恢复 JVM 后通过回环请求验证 +Netty 服务确实调用了未安装的 reactor 兄弟依赖。真实 Quarkus/Micronaut 四步集成在 +Java 21 / Maven 3.9.16 通过,应用异常及 HTTP 请求分别有显式失败和期限,包字节不变。 +此增量不混作此前固定组合 CI 中普通 Micronaut main 的证据,也不替代宿主断点 UI、 +正式发行包和 Windows 原生验收,因此矩阵仍保持待验收。 + +### 准备任务由插件声明用途和源码角色 + +评审拒绝宿主通过 `java-prepare-run-main`/`java-prepare-run-test` 固定 ID 调用插件。 +现在 `buildSystems: 4` 的任务可声明通用 `preparation`:用途 `purpose` 与适用的模型 +源码角色 `sourceRoles`。例如插件可以把任意 ID 的任务声明为 `run` 用途、适用于 +`source` 和 `generatedSource`;另一个任务处理测试源码。Java 工作流只提出 `run` +用途,宿主从本次模型最深的工程和源码根查找唯一匹配,不按工具名或文件名选择。 + +Core 拒绝旧能力版本、空角色、重叠绑定、未知角色和需要提示参数的自动任务。 +用途/角色绑定属于执行行为,进入现有安装审查的程序摘要;不能把它当成显示元数据, +在不重新审查的情况下改变任务被自动调用的时机。 +缺少模型源码归属或匹配不唯一时宿主明确失败,不回退到主资源任务。已有执行协议、 +选项/工具临时输入、取消和配置代次继续复用;协议 8~10 不因此重新编号。 +Maven 插件的任务 ID 可以保留,但只属于插件自身实现,宿主和契约不依赖其拼写。 + +代价是新增一项清单能力版本;旧宿主在准入时拒绝,而不是忽略声明后错误启动。 +共享 fixture `build-provider-preparation.plugin.json` 覆盖非 Maven 任务;Core 和 Swift +分别验证准入、无歧义选取、缺失失败及配置/工作区变化取消。Windows Build 产品 +准入仍关闭,不能把契约支持等同于 Windows 启动工作流已实现。 + +Debug 在缺少精确源码路径时,也必须按当前通用 Build 归属定位:模块路径相对于 +`build.root`,不能相对于整个工作区或继续仅读取旧 Maven reactor 字段。通用归属 +优先于残留旧字段;当前实例的模块没有源码时返回不可用,不从其他实例找一个 +同名类替代。回归使用两个不同的非 Maven 提供者身份,覆盖嵌套实例、工作区根 +实例和源码缺失;这里只校正原生源码选择,不增加新的领域解析或公共字段。 ## 考虑过的备选方案 @@ -189,6 +439,15 @@ Agent 专属入口、项目设置、运行配置和 Java/JDT 协同仍未迁移 ## 验证 +2026-10-29 协议 9 增量的本机验证:350 项 macOS 原生计时、1368 项 Core 及其集成 +逐项计时均通过;完整 `scripts/rust-core/verify-rust-core.sh` 包含 Swift bridge 与 Git/AskPass +检查通过。当前源码快照的 SDK 端到端 6 项通过,Node 65 项、Java 47 项和 TypeScript +调用方检查通过。Maven 3.9.16/JDK 26 的受控后端和真实 Core reactor 各 15 项通过, +最慢真实场景为跨模块精确测试约 4.16 秒;生产 macOS Provider 场景约 4.26 秒。 +协议 9 的[固定提交 Java 8 CI](https://github.com/Lithe-IDEA/lithe-plugin-maven/actions/runs/40824700153) +使用宿主 `7f2dfdafc`、SDK `0d00334` 和 Maven 插件 `7a03fae`,当前及 3.6.3 Maven +均通过;SDK 的 Node 20/Java 8 检查也通过。后续资源准备增量的本机原生回归 307 项(Swift 汇总含参数组为 309)通过,真实 Maven 的受控后端和 Core reactor 各 16 项通过;资源过滤约 14.5 秒(冷资源插件解析),原生取消 gate 为毫秒级。合并 preview `35b84dad4` 后再通过 344 项计时与 Core/host 检查;完整 UI/JDT 和 Windows 实机尚未完成。 + 共享 fixture 覆盖版本准入、类型化请求/回复、路径与大小限制,以及本地 Run 选择器。Core 回归使用独立计时进程验证旧协议拒绝新方法、进度映射、取消、注入时间的超时和晚到结果;保存回归验证不生成空模型、重复保存保留 ID、其他命名空间的元数据和工具链不丢失、缺省参数归一化、非法进程字段明确拒绝。 macOS 使用固定的 Swift 6.3.3 工具链和原生计时 harness。相关服务回归控制准备开始、解码暂停、停止或工作区切换的顺序;原生存储用真实 Core 和隔离临时目录验证保存后读回。Run 的对外操作身份从准备到应用退出保持不变,底层准备/进程各自按身份取消;Agent 启动返回同一主运行记录,授权仍沿用既有入口。插件停用、进程替换和工作区切换不能留下旧输出、订阅或可操作的旧主运行记录。 @@ -337,3 +596,185 @@ node scripts/sdk-e2e.mjs --source /path/to/Lithe-IDEA --working-tree --build-wor - `macos/Sources/Lithe/Platform/MacOS/Plugins/MacLivePluginRegistry.swift` - `macos/Sources/Lithe/Platform/MacOS/Debug/MacBuildDebugLaunchResolver.swift` - `shared/fixtures/debug/adapter-selection-v1.json` + + +### 运行选项的公开边界(2026-10-30,协议 10) + +框架和模块的启动规划不能把服务伪装成应用,也不能在宿主拼接一条 Maven shell +命令。沿用协议 4 的准备方法,协议 10 允许显式 service/task 声明和已解析的用户 +选项;准备结束后进程仍属于既有 Run 宿主,取消、输出与 Stop 不转移给新的后台 +服务系统。参数始终保留数组边界,包括空参数、空格、Unicode 和反斜杠。构建 +Profiles、工具选择和原生配置仍来自现有链路,不再维护运行专用的影子配置。 + +安装准入与会话协商分别检查新版本,旧后端必须在发送前拒绝选项,两种 SDK 在 +进入 handler 前再次校验。不能让旧协议忽略新增字段后启动一个与用户选择不同 +的进程。显式 cwd 只允许当前工作区内的规范目录,并要求解码时使用同一选项; +没有覆盖时保留实例根限制,不能借新字段放宽旧应用或原生 Debug 的边界。 + +Maven 插件现已发布服务和模块任务目标。原生 Run/Start All 的每个服务独立读取 +有效选项、获取当前模型并准备启动,不占用 Build 的单任务槽;准备结束后仍由 +原有 Run 会话拥有进程。主 Run 保留操作身份,停止、重置、参数或配置 ABA +会拒绝迟到结果,已停止进程的回调不能覆盖同名新会话。插件停用或更新在移除 +包文件前停止有关准备和进程;更新后的旧启动注册不可继续使用,重启后才重新 +读取新包。Build 树也按声明发送执行类型,模块任务先收集 argv。 + +保存的 Provider 引用可保留 service/task 分类,重开后仍参与原有 Start All 和单项 +服务入口;缺省的历史 application 引用保持原有含义。再次启动按当前模型重查 +完整选择器,声明改变执行类型时拒绝启动。保存只更新原引用,保留稳定 ID、参数 +和无关扩展,不存一份可执行路径或类路径。Agent 在服务准备时取得的操作 ID +贯穿启动、输出查询和 Stop,进程创建不会替换该身份。 + +例如所选 app 依赖同 reactor 的 core,但 core 尚未安装到本机仓库,插件使用 +Maven 的执行计划和 Mojo 执行器先构建依赖,再只在 app 执行框架目标。不要 +直接运行 `-am spring-boot:run`,它会把目标也应用于父工程和依赖工程。框架参数 +按该版本实际 Mojo 描述符写入,无法精确保留的参数明确拒绝;不建立平行类路径 +或插件执行模型。原生运行配置仍保留旧稳定 ID 和用户覆盖,领域参数由后端读取。 + +macOS 无调用者的旧 Maven scan/launch/diagnostics/report 桥和旧 MavenProject/ +MavenModule 类型已删除;Java/JDT 专属功能留在阶段 E。Windows 仍使用共享旧 +Maven 命令,因此其 Core 实现尚不能删除,阶段 D 也尚未达到整体退出条件。 +没有新增运行时资源或共享缓存;已安装包继续只读。正式 mirror 同步仍须等待 +preview 合并,开发端到端用例通过显式参数测试工作树,不伪造发行版本。 + + +本增量验证:Swift 6.3.3 的 345 项回归(344 条计时记录)、Core 1403 条计时、 +四项生产 macOS Provider 场景、20 项真实 Core→Maven、Node 24/Java 48 项 SDK +单测及六项 SDK E2E 通过。新增已保存任务准备和 Profile 场景为 11.567 秒,保持 +默认模型、默认选项、插件包哈希及准备/进程清理边界。原生与共享边界、契约、 +Agent Notes、bundle 只读和测试稳定性检查通过;对应报告路径见剩余迁移计划。 + + +整合 `preview@f3ee7114f` 后,自动识别结果及模型摘要跟随 Core 的内存检测层, +应用缓存只为后续会话保留 JDT 上次入口。macOS 的 Provider 归属输入与 +`cacheDirectory` 同时交给生成器;不恢复生成文件或工具链需求文件的工作区写入。 +已有用户 Run 文档继续原子保存/读回,缓存不能成为第二份生效配置;冷启动自动 +识别保留配置代次、取消和 Build 模型校验。参见 +[自动识别的运行配置只存在内存里](2026-10-29-run-configuration-detection-in-memory.md)。 + + +服务所有者保存完整插件包身份,停用/更新时与当前启用包逐项比较,不能只按 +buildSystemID 停止:两个插件可以声明同名工具,却拥有不同的运行进程。准备前 +固定该身份,停止或包更新同时取消对应准备及进程;同名工具的其他插件继续运行。 +原生集成按 Profile/模型代次与保存任务分开计时,不把多个完整流程合成一个测试, +也不放宽既有 15 秒单项预算。 + + +与内存检测层整合后的验证:346 项 Swift、1414 项 Core、五项生产 macOS Provider +场景通过;另显式执行三项真实 JDT/JVM,包哈希与进程清理断言通过。SDK 六项 +E2E、Core→Maven 20 项、资源复用 30 项及 i18n 35 项通过。保留原有超时,不放宽 +运行预算;新原生流程单独计时,报告位于 `stage-d-preview-*`。 + +### PR #1558 review 修正验证(2026-11-01) + +v7 的格式无关编辑和通用报告/准备任务回归在本地通过 Core 1436 项、macOS +246 项;后续 v11 注册表与通用保存引用增量通过 macOS 192 项、Windows 前端 +62 项及类型检查/生产构建,Node SDK 构建协议 26 项和 Java SDK 49 项通过。 +Node/Java 的真实 SDK E2E 共 6 项通过;计数有重叠,不相加为全新覆盖数。 + +v11 Core 全量计时 1436 项中 1435 项先通过;唯一失败是项目生成器测试把正式 +Java Support 裁成语言提供者时遗漏删除新 DAP 声明,却移除了调试权限。已修正 +测试裁剪并增加独立准入断言,该项定向回归 30ms 通过。 +`stage-d-review-connection-core-final.json` 合并每个精确用例的最新结果,保留原始 +全量报告和定向日志供核对,不代表又执行了一遍全量。 + +真实 Maven 流程 20 步、Quarkus/Micronaut 暂停/JDI/恢复/HTTP 和清理 4 步通过; +最新开发包的 macOS 生产存储写回、回读及外部冲突测试通过(7.354 秒)。包清单 +不变。v7 的 XML 格式回归归 Maven 编辑器,JSON 格式回归归 SDK;宿主不保留 +XML 编辑器。原生 Windows/完整断点 UI/正式发行组合仍未验收;#1581 尚未合并, +工具定义消费和 Core Maven 专用运行配置边界继续列为未完成,三个 PR 保持 Draft。 + +## 固定工具定义基础的运行前解析 + +#1495 按用户授权固定 #1581 的 `b87c7f6` 预集成,仍保持 Draft。新 Build 候选可以引用 +工具定义的命名空间 ID,Maven 插件不申请官方短 ID;Java 短 ID 由官方 Java Support +定义。宿主不再注册 `project-maven-jdk`,Java 资源准备入口也不再按 Maven 工具名选择。 +原有 Java 引擎仍使用的 `project-maven` 只随其真实消费者迁移删除。 + +设置页的检测值只是展示,运行前必须重新解析。原生 adapter 读取现有目录 pin,复用 +定义扫描和 Provider 查询,收集文件与版本证据后调用 Core;校验失败、上下文撤销、 +插件停用或异步期间输入变化均不得启动。每个工具最多查询八个提供方,单次查询和版本 +探测沿用有界期限。已校验的提供方不可用回复保留给 Core,允许按既定规则检查低层; +安全、身份与取消失败不能当成“未安装”。项目 Wrapper 只在显式执行时允许,不做被动探测。 + +解析结果直接作为操作的返回值交给同步进程启动,不保留跨 Run 的共享许可槽。目录 pin +只读,不创建新缓存、下载物或包内资源;既有本机输入由工作区所有,探测输出由当前操作 +所有。安装包清单保持不变,因此不影响代码签名与 Sparkle 源基线。用户旧自定义工具 +输入仍须通过隔离迁移器无损转移,不能用此新入口绕开迁移失败。 + +对应验证为工具解析、Run 环境和 Build 服务的计时回归,以及显式真实 Java/Maven 的 +生产 resolver 集成;Windows、旧数据和正式发行仍按剩余计划验收。 + + +### 同目录下不同 Run 的工具选择 + +Provider 运行项的编辑器直接编辑通用 `runtimeArguments` 数组和不透明入口;每一行 +都是一个参数,不经过旧 JVM 参数文本解析器。两端使用共享多行输入控件保留参数内 +的换行,长数组交给惰性/虚拟列表,只在显式保存时进入既有 Run 存储。编辑这些字段 +必须保留同一对象中的按次选项和工具选择;打开旧配置不会自动创建覆盖。 + +macOS 的 Reset 会为已经生效的按 Run 工具发出显式 `null`,而不是省略命名空间; +否则底层“省略即保留”的兼容语义会让界面显示重置、磁盘却继续使用旧选择。不要 +把 Provider 项的运行时参数继续写到旧 `java.vmArguments`,因为通用准备不会读取它。 +这只是 Run 草稿的呈现与保存,不新增工具配置来源、公共 API 或下载缓存。 +共享 argv fixture、Windows 的真实对话框保存动作,以及 macOS 的原生存储回读/重置 +覆盖该边界。团队层提供工具、本机只覆盖其中一项时,Reset 必须从已解析的编辑 +草稿取得全部有效工具 ID;回读及重开验证全部清空,同时团队文件字节保持不变。 +参数编辑按钮复用已打包的 IDEA 图标;组件测试不代替完整产品界面验收。 + +目录 pin 表示整个目录的默认工具,不能承载两个服务各自的选择。Run 的 +`extensions.build-run.tools` 因此只保存本次消费者明确选择的安装目录或可执行文件; +宿主在使用前通过同一个工具定义解析器重新校验,不恢复 Maven 工具名分支。 +团队/本机层按工具 ID 合并,`null` 清除继承的 Run 选择,随后使用正常目录策略。 +空对象保留已有条目,避免只修改启动参数时意外丢失工具选择。 + +例如同一目录的服务 A 可以选择安装目录甲,服务 B 继承目录 pin 乙;启动 A +不会重写 B 的 pin。不要把旧 Maven JDK 与应用 JDK 的继承关系直接冻结成当前 +`java` 目录 pin:后续默认值变化将失去原有联动。一次性转换器须先证明完整继承 +关系可以表达,再移除旧字段;不能因为新解析入口已存在就静默丢弃无法转换的值。 + +明确选择的可执行文件可以是用户自己的启动器,宿主不从父目录猜测安装目录或 +`JAVA_HOME`。工作区内的词法路径和符号链接目标都须取得当前操作授权后才可探测; +无效路径和旧版本均直接失败。Windows 为当前工具证据生成只在宿主内使用的身份摘要, +用于撤销旧模型;摘要不包含继承环境秘密,也不是以后启动的许可证。 +新数据仍由 Run 文档所有,不产生共享缓存或下载资源,不改写安装包或插件包。 + +验证覆盖 Core 的合并/清空和路径拒绝、两端按 Run 的重新解析、独立编辑副本及 +Windows 原生证据失效;完整旧默认值迁移和发行准入仍未完成。Windows 已接操作内 +Provider 查询,其生命周期与未开放范围见 [Windows 宿主决策](2026-10-22-windows-declarative-plugin-host.md)。 + +一次性旧 Maven 转换器现在可把逐 Run 的 `mavenExecutablePath` 和 +`mavenJavaHomePath` 转为对应定义的显式选择;本机空字符串转成单项 `null`, +保留团队字段与本机清空的区别。偏好来源继续保留,重跑不再次覆盖新文档。 +项目默认、目录覆盖和应用 JDK 的联动尚未有完整目标模型时,转换器在第一次写入前 +拒绝整个计划,避免原值虽然留在文件中、实际启动却已忽略它的隐性丢失。 + +工具定义可以声明更复杂的启动模板,但当前 Build 工具上下文只携带程序路径和 +Build 自己的参数。两端消费者因此只接受 `${executable}` 后接 `${arguments}` 的 +直接模板;含额外 flag 或 home 参数的定义在直接执行前明确拒绝。运行时安装依赖 +只消费已校验版本、路径和声明导出,不实例化该工具的 entry 启动模板;例如 Maven +消费 JDK 安装信息时,不需要执行 JDK 定义的 JAR 模板。两种用途显式区分,安装依赖 +不得冒充直接可执行绑定。不要省略声明参数后继续执行,也不要为单个工具在宿主拼接 +模板;支持新直接模板须先补齐完整消费者契约。 + + +## 使用方工具默认值保持引用关系 + +Build invocation 携带当前插件/构建系统的完整消费者身份。两端运行前 adapter 读取现有 +Run 本机文档中的通用偏好,交 Core 选择惰性的固定输入,再沿原有定义、版本、文件事实 +与进程授权链验证;不从设置页的检测结果获得启动许可。工作区默认的继承保存为引用, +改变项目默认后下一次运行自然读取新值,不批量改写各服务或把当前值冻结成目录 pin。 + +例如,一个构建消费者明确跟随工作区工具,子目录可以明确返回该消费者的工作区选择, +不被父目录另一份消费者覆盖截走;按 Run 的明确路径继续优先。不要在宿主判断 Maven +或 Java 名字后补 home,或在每个 UI 留一份“继承的当前路径”;那会产生静态副本并让 +设置与执行漂移。休眠 ID 只保留数据,不能注册工具或伪造已安装贡献。 + +偏好、目录 pin 和插件来源在异步探测后重新核对;错误、取消或源变化都拒绝结果。 +无效的明确路径不会回落到默认。旧 Run 编辑器省略通用偏好时原样保留它,显式空 v1 +文档才重置;读写校验拒绝循环引用和越界目录。状态只属于现有 `.lithe/run/local.json`, +不建立第二份文件或可复用缓存,资源清单拒绝跨 worktree 复制。 + +该增量接通的是 Core 规则与两端运行前消费者。通用设置写入入口、全局默认,以及 +旧 Project/Maven 默认的一次性无损转换尚未实现,旧来源不能因此删除。验证使用同一 +共享 fixture、Core 文档保存回读和原生重新选择/探测期来源变化场景;完整迁移与 GUI +验收仍按阶段 D 账本记录。 diff --git a/.agents/notes/implemented/architecture/2026-10-29-run-configuration-detection-in-memory.md b/.agents/notes/implemented/architecture/2026-10-29-run-configuration-detection-in-memory.md index 18ef64f4dc..2351458305 100644 --- a/.agents/notes/implemented/architecture/2026-10-29-run-configuration-detection-in-memory.md +++ b/.agents/notes/implemented/architecture/2026-10-29-run-configuration-detection-in-memory.md @@ -9,7 +9,8 @@ 和 `.lithe/.gitignore`;没写这些文件之前,Run 和 Debug 都不可用,要先点“识别项目”。 现在识别结果(检测层,即 Lithe 自动找到的运行入口和工具链需求)只保存在 Rust Core 的进程内存里,并把上次的结果缓存到应用自己的缓存目录。打开项目时,宿主发现还没识别就直接识别, -工程里只剩用户自己保存的配置。 +工程里只剩用户自己保存的配置。已有用户 Run 文档的一次性格式转换独立处理, +不会恢复任何检测文件的工作区写入。 ## 问题 @@ -62,10 +63,80 @@ JDT(Java 语言服务)冷启动要几十秒。宿主在 `runConfig.generate` ### 默认配置由 Core 选 `.lithe/project.json` 仍是用户可以编辑的团队文档,里面的 `defaultRunConfiguration` 依旧优先。 -没有写默认值时,`resolve` 按“框架服务(Spring Boot、Quarkus、Micronaut)优先,否则第一个不是 -Current File 的检测入口”选出默认项。以前两端各写了一份同样的规则,并在第一次生成时把结果写进 +没有写默认值时,`resolve` 优先选择声明为 service 的检测入口,否则选择第一个不是 +Current File 的检测入口;不维护框架提供者名单。以前两端各写了一份同样的规则,并在第一次生成时把结果写进 `project.json`。 +### 插件运行目标与 Java 入口分别提供 + +macOS 只把当前启用插件的公开运行声明与最新模型中的匹配目标交给生成器。 +例如插件声明 `serve` 适用于 `endpoint` 标签,宿主选中模型里的对应目标,Core +只保存完整选择器和上游名称;Core 不把某个目标 ID 翻译成框架、工具或调试器。 +没有 Java 导入的插件也能生成运行配置,Java 源码归属继续由独立输入负责。 + +选择器决定稳定 ID,名称和执行类型变化只更新内容,不丢失参数、环境与目录覆盖。 +模型指纹覆盖完整快照,顺序变化不算过期;目标撤回时移除自动配置,并保留用户层 +以提示孤立覆盖。不要从旧缓存重新补回插件已经撤回的目标,也不要为了生成服务 +在 Core 中维护框架名单。macOS 在模型通知和生成后重新比较快照;实际准备仍验证 +当前插件、声明和模型,显示的新鲜度提示不能替代启动许可。 + +用户的运行时参数、入口和按次 Build 选项保存在独立的 `extensions.build-run`。 +参数不先拼成字符串,空参数、空格与 Unicode 都保留。Core 只检查通用大小和字段, +准备时由当前声明校验选项,不能在 Core 里解释 Profile 或框架名称。省略该段保留 +旧调用者的选择;显式空值表示清空,不把 `false` 和空数组当作“没有覆盖”。 +原生存储与偏好复制保持该快照,重存同一选择器保留目录覆盖;Reset 发送空快照。 +准备使用 Core 按当前声明规范化后的值,多选项排序不算无效;被丢弃或未知的选择 +仍然拒绝。不要以原始数组顺序与规范顺序不同为由拒绝整个运行。 +这些数据复用现有 Run 文档和 v10 请求,没有新增下载或缓存。两端编辑器以可逆文本回显 argv, +Core 保留显式空引号参数;共用 argument-text fixture 约束空格、引号、反斜杠与 Unicode。 +Windows 的选项快照在映射、编辑和保存时独立复制,旧调用者省略时继续保留。 + +这段复用既有检测层和缓存,没有新增资源目录,不写入应用包。Windows 仍待接入 +Provider 宿主;旧 Maven 配置格式及工具链迁移不因新增通用发现就视为完成。 + +### 保存的选择器保留已有配置 ID + +配置 ID 是用户文档中的稳定身份,选择器是插件当前目标的身份;两者不能混为一谈。 +完整的保存记录可以沿用旧 ID,Core 不再要求它必须以 `user:` 开头。没有完整记录的 +孤立覆盖仍然提示孤立,所有实际启动仍重新验证启用插件、声明与目标,ID 不授予权限。 +团队层和本机层先合并成有效选择器,再隐藏检测层中相同目标的重复项;用户明确保存的 +多个配置仍然保留。这样迁移可以保留默认项、选择、层级覆盖和操作身份,而无需改写 ID。 + +例如,旧服务 `quarkus.maven:nested/app` 转成完整通用选择器后,仍使用原 ID, +自动生成的同目标条目不再重复出现。不要只换一个新 ID 再删除旧记录,那会把用户 +默认项和本机覆盖变成孤立记录。Core 也不按这个历史前缀识别框架,它只比较选择器。 + +`resolve` 可接收内存中的团队/本机文档,宿主在发布两个文件前先验证完整合并结果。 +这是通用的事务预检,不写文件、不授予操作权限;实际发布仍检查快照和当前工作区。 +正常 Provider 启动只接受完整选择器,两端删除按旧 Maven 名称进入该路径的分支,Core 不再从 +旧 Maven 扩展组装 Provider 启动输入。尚未替换的 Java/Windows 旧引擎不是这条新路径的后备实现。 + +### 旧 Maven Run 和偏好的有界转换 + +临时的 `MacLegacyMavenRunConfigurationMigration` 只从生成入口调用,不参与正常运行 +准备。它将已有 Run v1/v2 中可确认的框架服务和模块目标转为通用选择器,保存原 ID、 +参数数组、入口、环境、目录与按次选项;Java 源码入口继续留给阶段 E。 +必须在当前模型里找到唯一的同工程/同目标,并核对旧提供者与当前目标名称一致。 +重名、框架变化、未知字段或尚未接通通用工具定义的工具路径仍保留源数据并失败,不能靠猜测继续转换。 +v1 文档在发布时整体转换字段形状,无关 Java 条目保留原身份;Java 的运行语义不在这里重建。 +空目标列表保留为空,由 Maven 自己执行 defaultGoal 或报告无目标;不生成一个宿主默认目标。 +旧模块里不受支持的运行时/入口选择也原样保存,由当前提供者明确拒绝,不静默丢弃。 + +宿主先构造两层文档,用 Core 预检,然后比较所有输入(包括未改写的一层)和偏好状态, +才使用带源版本检查的原子替换。写入、回读、取消或冲突失败时,只回滚自身仍未被改动 +的输出;一个文件无法回滚,也继续尝试其他文件,不覆盖用户或另一进程的新数据。 +生成任务的租约(可撤销的写入资格)在取消、工作区切换、模型变化或后续生成时失效。 +这只约束用户文档与原生状态发布,不把检测缓存当作持久配置。 + +创建本机文档前,读取旧偏好命名空间并一并转移当时可确认的 Java 配置,避免旧导入器因 local.json 已存在而跳过它们。 +显式本机字段高于旧偏好;现有偏好字节、键清单和迁移标记都加入发布前后的来源检查。 +没有枚举能力的存储不能在缺少 local.json 时声称完成偏好迁移。通用文档发布后拥有当前值,旧偏好字节只作兼容窗口中的回滚记录,不再作正常运行的后备来源。 + +转换保留已有文件权限;偏好首次产生的本机文件权限为 0600。取消时条件删除自己的新文件, +只用 rmdir 清理本次创建的空目录,外部新文件不会被删除。不创建迁移缓存或收据,不写应用包和已安装 +插件。没有旧记录的只读工程仍可识别。该适配器与唯一入口按 D-MIGRATION-REMOVAL +在稳定 N+2 前删除;通用 ID、预检和层合并规则继续服务正常保存配置。 + ### 识别前也能选 JDK `ProjectRuntimeService` 在 JDT 启动前就要按项目最低 Java 版本选 JDK。检测层还不存在时, @@ -102,12 +173,12 @@ Current File 的检测入口”选出默认项。以前两端各写了一份同 ### 没有检测层时 `resolve` 按空检测层处理 -可以少一次“未识别”的判断。但团队层里覆盖检测入口的条目会全部变成“孤立覆盖”, +对只有稀疏覆盖的文档,这可以少一次“未识别”的判断。但团队层里覆盖检测入口的条目会全部变成“孤立覆盖”, 用户会看到一批误导性的诊断。保留 `missing` 并让宿主自动识别更直接。 ## 后果 -- 收益:打开项目不再写用户工程;Run 和 Debug 不再需要先手动识别;两端删除了重复的写文件、 +- 收益:常规检测不再写用户工程;已有用户文档的临时格式转换单独做冲突保护。Run 和 Debug 不再需要先手动识别;两端删除了重复的写文件、 默认项挑选和“重新生成损坏文件”入口。 - 代价:每个新进程打开工程都要重新识别一次(只读扫描,不启动进程)。Java 入口在 JDT 回答前 依赖缓存;缓存也没有时,Java 入口要等 JDT 就绪后才出现。 @@ -122,6 +193,7 @@ Current File 的检测入口”选出默认项。以前两端各写了一份同 - `cargo test -p lithe-core --lib detectors` - `cd windows/tauri && bun test src/features/run` - `./scripts/macos/test/test-macos.sh --filter 'RunConfigurationIntegrationTests|MavenRuntimeTests|RunEntryPointTests'` +- `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'MacLegacyMavenRunConfigurationMigrationTests|ExecutionModuleTests|MacNativeBuildProviderIntegrationTests'` - `./scripts/macos/verify/verify-runtime-bundle-immutability.sh` ## 适用范围 @@ -131,3 +203,4 @@ Current File 的检测入口”选出默认项。以前两端各写了一份同 - `windows/tauri/src/features/run/` - `macos/Sources/Lithe/Platform/MacOS/RunConfiguration/MacRunConfigurationStore.swift` - `macos/Sources/LitheExecutionModule/Services/RunService.swift` +- `macos/Sources/Lithe/Platform/MacOS/Persistence/MacLegacyMavenRunConfigurationMigration.swift` diff --git a/.agents/notes/implemented/feature/2026-10-15-macos-settings-hierarchy.md b/.agents/notes/implemented/feature/2026-10-15-macos-settings-hierarchy.md index 44a3ba4d2d..db7974cfc7 100644 --- a/.agents/notes/implemented/feature/2026-10-15-macos-settings-hierarchy.md +++ b/.agents/notes/implemented/feature/2026-10-15-macos-settings-hierarchy.md @@ -26,7 +26,7 @@ macOS 设置侧栏现在只有七个一级入口:外观与行为、插件、 插件如果带来了新的设置,默认放进“插件”页;只有它新增的是上表中某个分组本来就该有的能力(例如新的版本控制系统)时,才放进那个分组。 -“运行环境”和“环境来源”是构建分组的工具链通用框架页,位于旧项目页和运行配置前;工具名称、提供方及使用方来自贡献快照,不是给某个插件开的专用设置页。提供方自己的配置继续从插件页注入。当前页面只读,旧项目 JDK/Maven 页面保留到实际消费者迁移后再删除,完整进度见[工具链提案](../../proposed/architecture/2026-10-31-plugin-injected-toolchains.md)。 +“运行环境”和“环境来源”是构建分组的工具链通用框架页,位于旧项目页和运行配置前;工具名称、提供方及使用方来自贡献快照,不是给某个插件开的专用设置页。提供方自己的配置继续从插件页注入。发现结果保持只读;运行环境页为已经接通的 Build 使用方提供明确保存的本机工具偏好,并列出只有偏好而没有版本文件的目录。旧项目 JDK/Maven 页面仍服务尚未迁移的消费者,完整进度见[工具链提案](../../proposed/architecture/2026-10-31-plugin-injected-toolchains.md)。 “根据 Git 忽略规则自动排除目录”放在“版本控制 › Git”的程序与凭据区域之后, 不再提供独立的“项目索引”页面。它是当前工作区的 Lithe 偏好,始终沿用原来的 diff --git a/.agents/notes/proposed/architecture/2026-10-17-build-system-provider-api.md b/.agents/notes/proposed/architecture/2026-10-17-build-system-provider-api.md index 3b981cebb8..4bf92ba3d2 100644 --- a/.agents/notes/proposed/architecture/2026-10-17-build-system-provider-api.md +++ b/.agents/notes/proposed/architecture/2026-10-17-build-system-provider-api.md @@ -344,8 +344,8 @@ Core 契约部分的验收已在已实现的 Note 里完成。剩下的工作按 - `rust/lithe-core/src/runtime/dispatcher.rs` - `shared/contracts/rust-core-api.md` - `macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeDiscovery.swift` -- `macos/Sources/Lithe/Platform/MacOS/Persistence/MacMavenConfigurationStore.swift` -- `macos/Sources/LitheExecutionModule/Services/MavenService.swift` +- `macos/Sources/Lithe/Platform/MacOS/Persistence/MacLegacyMavenConfigurationMigration.swift` +- `macos/Sources/LitheExecutionModule/Services/BuildSystemService.swift` - `macos/Sources/LitheExecutionModule/Services/StandardLanguageTestProvider.swift` - `macos/Sources/LitheCoreContracts/Execution/MavenContracts.swift` - `macos/Sources/Lithe/Views/Run/BuildSystemToolWindowView.swift` diff --git a/.agents/notes/proposed/architecture/2026-10-31-plugin-injected-toolchains.md b/.agents/notes/proposed/architecture/2026-10-31-plugin-injected-toolchains.md index b2e10f7b4c..b8c256761e 100644 --- a/.agents/notes/proposed/architecture/2026-10-31-plugin-injected-toolchains.md +++ b/.agents/notes/proposed/architecture/2026-10-31-plugin-injected-toolchains.md @@ -4,7 +4,7 @@ ## 先说结论 -从 Dock 启动时找不到版本管理器安装的运行时、项目要求的版本未生效,不能靠宿主逐个补目录和版本文件解析器解决。本方案把工具定义、安装事实提供方和使用方分开:插件说明工具是什么,版本管理器回答当前事实,宿主只提供统一选择、查询和环境展示框架;Java、Maven 等定义全部由插件贡献,不保留宿主内置定义表或兼容别名。当前已实现 Core 基础切片及 macOS 只读设置页;macOS 设置页已按声明式路线真实扫描本机安装、运行定义的版本探测并交 Core 选择,并通过插件后端查询提供方、监听其输入后整份重查;每行哪一层生效由 Core `toolchains.resolve` 用页面展示的同一份事实决定,Swift 不自己排优先级。保存固定值、选择版本管理器等写入操作尚未实现,Windows 查询和运行时消费者也未接入。完整方案仍在本提案中跟踪,不能把页面出现解释成产品已能使用 mise 或已经完成工具迁移。 +从 Dock 启动时找不到版本管理器安装的运行时、项目要求的版本未生效,不能靠宿主逐个补目录和版本文件解析器解决。本方案把工具定义、安装事实提供方和使用方分开:插件说明工具是什么,版本管理器回答当前事实,宿主只提供统一选择、查询和环境展示框架;Java、Maven 等定义全部由插件贡献,不保留宿主内置定义表或兼容别名。当前已实现 Core 基础切片及 macOS 只读设置页;macOS 设置页已按声明式路线真实扫描本机安装、运行定义的版本探测并交 Core 选择,并通过插件后端查询提供方、监听其输入后整份重查;每行哪一层生效由 Core `toolchains.resolve` 用页面展示的同一份事实决定,Swift 不自己排优先级。macOS 已补已接通 Build 使用方的本机偏好编辑,目录 pin 保存、选择版本管理器等写入操作尚未实现;阶段 D 已补两端 Build 运行前消费者、Windows 操作内查询及通用偏好基础,完整 Windows 产品准入和旧默认转换仍未接通。完整方案仍在本提案中跟踪,不能把页面出现解释成产品已能使用 mise 或已经完成工具迁移。 ## 问题 @@ -191,6 +191,18 @@ macOS 的声明式路线由 `MacToolchainDiscovery` 执行,宿主不认识任 - `Plugins/mac/` - `Plugins/win/` +## 阶段 D 的固定提交预集成 + +#1495 在用户授权下先固定本提案的 `b87c7f6` 做集成验证,暂不改变合并与发行顺序。 +Build 和 Provider Run 新增独立的运行前解析,复用这里的扫描、查询和 Core 规则;不读取 +设置页的检测结果来授权进程。用户目录 pin 只读,解析后重新核对原生路径、安装与来源 +文件身份、工作区上下文及插件清单;Wrapper 只在显式执行时允许,不作被动版本探测。 + +Maven 社区插件使用 `:maven` 定义,Java Support 的短 ID 仍要求独立官方来源 +证明。`executableResolution: 2` 同时供工具定义和已有 Build toolchain 候选使用,不能把 +后者误判成“没有定义就未使用能力”,也不能放开旧候选的未知字段。Windows 和旧 Java +消费者的切换尚未完成,原有选择链只可随真实消费者迁移逐项删除。 + ## macOS 查询客户端的取舍 - 复用 `MacPluginBackendSessions` 的单插件单进程,不另起进程:查询与页面调用共用同一后端和设置重启规则。协商结果在 Core 会话状态里,未协商的提供方由 Core 拒绝,不写到进程。 @@ -208,3 +220,71 @@ macOS 的声明式路线由 `MacToolchainDiscovery` 执行,宿主不认识任 - **每行读哪个目录。** 有固定值的行读固定值所在目录的结果;其余行读最近一个有自己版本文件的目录(没有就是根目录)。 - **已知限制。** 一是 `PATH` 上的命令永远不会被 Core 选中,因为页面不传 `commandHomes`;Core 跳过所有层时,页面把这条命令当作“版本未验证”的兜底显示,后续见 [#1641](https://github.com/1lck/Lithe-IDEA/issues/1641)。二是 `mvnw`、`gradlew` 这类项目文件候选不参与解析,因为 `discoveryPlan` 不提供它们的路径,也没有项目代码授权;要支持它就得改插件契约,不在本轮做,后续见 [#1640](https://github.com/1lck/Lithe-IDEA/issues/1640)。 - **冲突要用户选,现在只能看。** Core 遇到同层版本管理器不一致时返回 `chooseProvider`,遇到同版本的多个安装时返回 `chooseInstallation`,并且明确不允许靠排序替用户决定。页面现在只显示冲突原因,没有选择按钮,因为写入还没实现。计划中,用户的选择存成本机 `local.json` 里的固定值:固定值格式已在契约里,优先级最高,还会被 `.lithe/.gitignore` 排除。`providerChoices` 的存放位置契约里没有定义,单独保存它需要先改契约。选择入口、固定值的添加与切换以及 Lithe 全局默认的写入,后续见 [#1639](https://github.com/1lck/Lithe-IDEA/issues/1639)。 + + +### 阶段 D 的使用方默认值与符号继承(实现中) + +旧配置中“跟随项目 JDK”是会随项目默认变化的关系,不能迁移成今天探测到的 home 或 +目录 pin。旧路径覆盖的空字段还可能跳过父目录的显式覆盖,直接读取项目默认;只合并 +所有祖先的非空字段会改变结果。新的通用偏好因此保存 workspace/consumer 引用,由 +Core 在每次操作读取当前默认;按 Run 的明确选择仍优先,原生端随后复用现有工具解析。 +不在 Core 引入 Maven/Java 名称或版本管理器格式,不改变项目文件/全局/自动层的选择。 + +Core 开发切片提供有界、无 I/O 的消费偏好解析与共享 fixture。消费者是当前插件贡献的 +完整身份,文件中休眠身份只保留数据,不注册工具,也不授予执行权限。引用不能指定任意工具或作用域,也不能指回自身,因此无需运行时循环回退。 +消费者还可用 tool 引用跟随请求目录的通用工具:它保留最初请求目录,沿通用工具自己的 +目录规则解析。通用工具不能反向引用消费者,因此这不是任意引用图,也不会形成循环。 +这与 workspace 引用不同:前者跟随当前目录的应用工具选择,后者明确跳过目录覆盖。 +目录消费者先继承同消费者的工作区值、该值再引用 tool 时,不能把请求目录改成根目录。 +正常使用方不能通过复制旧 ProjectRuntimeService 输出取得“默认”;必须读取同一份 +原生来源并在探测后重查原始版本,取消、错误、过期和插件撤销不能伪装成自动选择。 + +本切片已保存在现有 Run 本机文档,两端 Build 运行前 native adapter 已按当前消费者身份读取并重查;macOS 设置页已补显式偏好编辑,Windows 设置页、全局默认与一次性旧默认转换仍未接入。 +这些接线完成前不能清除旧来源或宣称默认迁移结束。旧 Java 端口把空路径解释为继续 +读取根默认,而通用消费者引用可以跳过该默认并返回无明确选择;不能将两者都投影为 +空字符串,否则运行时会重新使用已经跳过的默认。一次性转换须与现有 Java 消费者的 +明确选择状态一起切换,不能仅删除迁移器中的拒绝检查。旧无效 Maven JDK 的回落语义 +若不能无损表达,保留来源并报告,不把它变成新的隐式 fallback。验收须实际更改项目 +默认、确认继承者随之更新且显式覆盖不变,并覆盖取消/冲突/重开及包文件清单不变。 + + +### Build 使用方偏好的编辑边界 + +macOS 运行环境页只为 Core invocation 真实引用该工具的当前 Build 贡献展示编辑入口。 +使用方名字来自已启用插件,完整身份为插件 ID/构建系统 ID;不把尚未迁移的语言服务 +列成可配置消费者,也不按 Maven、Java 或命令名称推断关系。停用插件后列表撤回, +休眠偏好仍作为惰性数据保留,不能借此注册工具或取得运行权限。 + +草稿、继承值和已验证安装分开。页面显示 Core 解析出的“下次构建输入”,明确说明它 +仍需运行时校验;不能把固定路径回显成已探测安装。继承引用由 Core 编辑命令保存, +不在 Swift 展开成当前路径。比如消费者保存 workspace 引用后,改工作区默认会影响 +下次运行;不要把它保存成今天读到的安装目录。 + +打开、切页、未修改保存及空重置不写磁盘。明确 Save/Reset 复用 Run 本机文档的条件 +原子写入,只替换 preferences,保留无关字段与现有文件元数据;Core 编辑只接收捕获 +文档,不读取文件、不触发 v1 Run 的领域迁移,未知版本不作整数截断。首次写入按现有流程 +保护本机文件的 Git 忽略。取消重新读取,保存失败保留草稿。草稿跨重开仍携带最初 +文件版本,不能因后台读取较新文件而悄悄覆盖外部修改。关闭或撤销编辑器取消当前 +操作并等待原生回执,晚到结果不能发布到另一个工作区。 + +只有通用偏好的 Run 文档不证明旧默认已经迁移。旧 Project 设置仍须继续读取自己的 +来源,并在成功读回目标后才清理旧值;两者共享文档但没有完成彼此的迁移条件。 +目录列表也读取同一文档的作用域,仅有消费者偏好的目录及其父目录仍可进入编辑; +不能只依赖版本文件或 pin,否则保存后重开的目录会消失。读取有大小和层级上限, +目录元数据交给同一 Core 校验,不将 Run 参数或环境送到设置快照。加载期间来源变化 +只重试一次,持续变化显示可刷新的失败;不将它当作普通取消留下空白页面。 +Run 本机文件纳入既有输入观察,即使首次尚不存在。后台重载会更新未编辑表单, +有草稿时保留最初基线并报告冲突;正在保存时不重新进入编辑器,以免自身文件通知 +等待同一保存或撤销成功回执。比如外部改了默认 JDK,干净表单应显示新输入; +不要先把新版本设为旧草稿的基线,再让旧草稿覆盖它。 +读写入口复用逐目录不跟随链接校验,尤其检查最终文件不存在时已存在的父目录,避免 +Foundation 的整路径规范化漏过外部别名。目标是用户工作区的本机 Run 文件,不写 app、 +已安装插件、代码签名资源或共享缓存,因此不会改变 Sparkle 的发行基线。 + +验证用 `ToolchainPreferencesTests`、`ToolchainSettingsLoaderTests`、Core 偏好/Run JSON +回归及 `scripts/macos/verify/verify-runtime-bundle-immutability.sh`。深浅主题的原生按钮 +验证仍是组件级证据,不能替代完整 Maven 插件、Windows 和正式发行组合验收。 +显式集成场景 `consumerDefaultChangesReachTheRealMavenProcessWhileExplicitRunsStayFixed` +通过生产保存和解析入口切换两个已选 JDK,再启动真实 Maven 检查实际 Java home, +同时确认按 Run 明确选择不随默认改变、插件目录清单不变;外部工具依赖只在明确启用 +的集成测试使用,不进入普通单元测试。 diff --git a/Plugins/mac/Official/JavaSupport/plugin.json b/Plugins/mac/Official/JavaSupport/plugin.json index 0b605df64a..7bcd18bc0f 100644 --- a/Plugins/mac/Official/JavaSupport/plugin.json +++ b/Plugins/mac/Official/JavaSupport/plugin.json @@ -3,16 +3,158 @@ "id": "dev.lithe.plugin.java-support", "displayName": "Java Support", "version": "1.1.0", - "pluginAPIVersion": 3, - "requires": {"languages": 1, "languageServers": 1, "serverProcess": 1, "settings": 2, "projectGenerators": 1, "packageTrust": 1}, + "pluginAPIVersion": 4, + "requires": {"debugAdapters": 1, "languages": 1, "languageServers": 1, "serverProcess": 1, "settings": 2, "projectGenerators": 1, "packageTrust": 1, "toolDefinitions": 1, "executableResolution": 2}, "vendor": { "id": "dev.lithe", "displayName": "Lithe", "signatureRequirement": "publisherPackage", "bundledSignatureRequirement": "sameTeamAsHost" }, - "permissions": ["languageServer.launch", "project.generate"], - "activationEvents": ["onLanguage:java"], + "permissions": ["languageServer.launch", "project.generate", "debug.launch", "debug.attach"], + "activationEvents": ["onLanguage:java", "onDebug:java"], + "toolDefinitions": [ + { + "id": "java", + "displayName": "JDK", + "icon": { + "host": "plugin" + }, + "executableLayouts": [ + { + "path": "bin/java", + "platforms": [ + "macos", + "linux" + ] + }, + { + "path": "bin/java.exe", + "platforms": [ + "windows" + ] + } + ], + "executables": [ + { + "id": "pinned", + "kind": "setting", + "settingID": "executable", + "layer": "project" + }, + { + "id": "project-request", + "kind": "toolchain", + "scope": "project" + }, + { + "id": "default", + "kind": "setting", + "settingID": "executable", + "layer": "global" + }, + { + "id": "global-request", + "kind": "toolchain", + "scope": "global" + }, + { + "id": "home", + "kind": "environment", + "variable": "JAVA_HOME", + "path": "bin/java", + "platforms": [ + "macos", + "linux" + ] + }, + { + "id": "home-windows", + "kind": "environment", + "variable": "JAVA_HOME", + "path": "bin/java.exe", + "platforms": [ + "windows" + ] + }, + { + "id": "shell", + "kind": "command", + "command": "java" + }, + { + "id": "detected", + "kind": "toolchain", + "scope": "installed" + } + ], + "version": { + "arguments": [ + "-version" + ], + "pattern": "version \\\"(?:1\\.)?(\\d+(?:\\.\\d+)*)" + }, + "homeLayouts": [ + { + "match": "*.jdk", + "home": "Contents/Home", + "platforms": [ + "macos" + ] + }, + { + "match": "*", + "home": "." + } + ], + "discovery": [ + { + "root": { + "kind": "absolute", + "path": "/Library/Java/JavaVirtualMachines" + }, + "pattern": "*.jdk", + "platforms": [ + "macos" + ] + }, + { + "root": { + "kind": "home" + }, + "pattern": "Library/Java/JavaVirtualMachines/*.jdk", + "platforms": [ + "macos" + ] + } + ], + "homeVariable": "JAVA_HOME", + "commands": [ + "java" + ], + "wrappers": [], + "exports": [ + { + "name": "JAVA_HOME", + "value": "${home}" + }, + { + "name": "LITHE_JAVA_PATH", + "value": "${executable}" + } + ], + "pathPrepend": [ + "${home}/bin" + ], + "launch": [ + "${executable}", + "${runtimeArguments}", + "-jar", + "${entry}", + "${arguments}" + ] + } + ], "languages": [ { "id": "java", @@ -21,6 +163,23 @@ "projectFileNames": ["pom.xml", "build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts"] } ], + "debugAdapters": [ + { + "id": "java", + "displayName": "Java Debugger", + "adapterID": "java", + "configurationTypes": ["java"], + "requests": ["attach"], + "selectors": [{"targetKinds": ["connection"], "languageIDs": ["java"]}], + "launch": { + "transport": "tcpLanguageServer", + "endpoint": {"languageID": "java", "command": "vscode.java.startDebugSession"} + }, + "configurations": { + "attach": {"targetBindings": {"connection": {"host": "hostName", "port": "port"}}} + } + } + ], "settings": [ { "id": "heapMaxMB", diff --git a/docs/ci-builds.md b/docs/ci-builds.md index ec62b04493..c993692795 100644 --- a/docs/ci-builds.md +++ b/docs/ci-builds.md @@ -375,6 +375,31 @@ Swift 单元和插件 CI 通道通过 Swatinem/rust-cache 复用裁剪后的 Rus ### 独立工作树的本地编译 +通用工具使用方偏好保存在工作区现有 `.lithe/run/local.json` 的 `toolchain.preferences`, +生命周期随该工作区本机配置,显式重置或移除后结束;没有增加并行缓存。目录/消费者引用 +每次运行重新解析,运行前 native adapter 读取文档并在探测后核对原始内容,不写应用或 +插件资源,不影响代码签名/Sparkle delta。它没有可靠构建 identity stamp,资源清单 +`consumer-tool-preferences` 和复用脚本在所有校验/复制阶段拒绝共享,包括相邻原子替换文件。 +macOS Build 偏好编辑复用同一文档,只在明确 Save/Reset 后条件原子替换并保留元数据; +打开、取消和空重置不创建资源,外部冲突保留原始草稿。工作区关闭等待原生保存回执。 +设置目录快照与文件观察只持有内存,读取同一文档并在加载结束核对版本;仅偏好目录 +也可重开。持续修改最多重试一次,关闭释放观察,不新增下载、持久缓存或可复用资源。 + + +Windows 注册表 stdio 调试器状态位于 +`//plugin-state//debug-adapters///`。 +由当前插件/适配器/工作区拥有;停止会话关闭进程,卸载插件清理其状态。语言服务器托管适配器 +继续使用原语言服务器状态,关闭 Debug 不删除或停止共享服务器。两类可变状态均没有构建 +identity stamp,不在任何复制阶段跨 worktree 复用;清单 `official-plugin-packages` 和复用脚本 +保持拒绝路由。入口/资源只读,写入只经平台 app-data owner,不改变签名或 Sparkle delta。 + + +Windows Build 本机选项位于平台 app-data 的 `build-options/.json`, +由应用用户配置拥有,保留到用户显式重置/移除配置;团队层仍是工作区的 +`.lithe/build/options.json`。该文档、显式空选择标记及文档 adapter 的相邻临时替换均是 +可变状态,没有版本/平台/架构/工具链构建 identity stamp,归入 `build-project-preferences` +拒绝路由。任何校验或复制阶段均不得跨 worktree 复用,不从安装目录推导写入目标; +本机保存检查两层原始版本并回读,不改变插件包、代码签名或 Sparkle delta 基线。 编辑器快捷键导入插件的源码和锁文件随 Git 获取;构建时从锁定的 SDK Git 提交及 Cargo registry 获取依赖,按当前 rustc、平台和目标架构在本工作树的 `.artifacts/editor-keymap-provider-target/` 重新编译。此目录登记为 @@ -793,9 +818,53 @@ Inter 4.1 的 18 个静态 OTF(内部版本 4.001)及许可、JetBrains Mono identity stamp,任何复制阶段都禁止共享。资源清单 `jdt-maven-settings` 显式排除, 复用脚本直接拒绝该资源,不进入下载或生成物校验路由。 +Build Provider 原生配置探针的 `configuration-*/configuration.json`、模型读取时的 +`run-*/configuration.json` 及 Maven v2 模型缓存中的实际来源 URI/哈希,均属于当前 +插件/构建系统/工作区的可变状态。只读配置探针在 Maven 的完整执行请求发布后,记录上游生效事实,再把探针自己的仓库管理器改到 +`configuration-*/repository`,随后在读取工程前退出,不能因此创建实际选择的用户仓库目录。 +探针与模型读取的临时目录在调用结束后删除;模型缓存 +由 Provider 管理,并在原生来源变化时失效。源哈希不是构建 identity stamp,不能跨工作树 +复制。宿主发布原生配置时在目标旁准备的 `..lithe-` 可能含凭据, +只归该条件替换操作所有;成功后清理,无法安全恢复时明确报告保留路径。资源清单 +`build-native-configuration-state` 和复用脚本的排除路由拒绝这些目录与临时副本,不进入 +任何下载验证或复制阶段。它们不写 app/plugin 安装资源,不影响签名与 Sparkle delta。原生配置控件的隔离截图保存在 +`.artifacts/test-stability/native-configuration-ui/*.png`,身份取决于源码、夹具、主题和视口, +同样由排除路由拒绝跨工作树复制,按测试报告保留策略清理。 + +原生配置编辑也复用各平台已有的工作区 `outputs/.json` 生命周期:宿主创建私有 +暂存文件,提供者用自己的格式读写器准备内容,只返回来源/哈希回执;宿主校验后以 +冲突保护原子替换原配置。此文件可能含秘密,绝不进入 RPC、日志、UI 或复用复制阶段。 +提供者禁止 CREATE,取消后不能重新生成已经删除的文件。成功、失败、工作区/窗口关闭 +和插件撤销均清理该文件;没有版本/平台/架构/工具链可复用身份,不允许跨工作树共享。 + +Windows 原生配置写回复用文档模块的 `.lithe-document--.tmp` 相邻临时文件, +只在明确提交时创建,保留原文件元数据并在成功/失败后删除。准备阶段的原始文档留在 +原生宿主内存中,按窗口与操作 ID 持有;提供者只写已有输出文件,回执通过哈希检查才可提交。最多 8 项、每项 2 MiB 输入及替换文本,60 秒后 +禁止提交;取消、窗口关闭或插件变更时释放。内存令牌和临时文件均没有版本/架构/工具链 +identity stamp,不允许任何阶段复用;沿用 `build-native-configuration-state` 的拒绝路由。 +它们不会写入应用或插件安装目录,因而不改变代码签名及 Sparkle delta 基线。 + 可下载的官方插件包是声明式插件包(只含 `plugin.json` 和它声明的资源,不含宿主加载的代码,与架构无关;自带运行时的 Java Support 见下文),在 `.build///OfficialPlugins` 中独立组装,`--triple` 只决定输出目录和签名工具的构建布局;组装后通过 `LitheOfficialPluginVerifier` 验证声明、打包的可执行文件和“Mach-O 原生代码只出现在清单声明的运行时目录内”;发布配置还要求 repository secret `LITHE_PLUGIN_PACKAGE_PRIVATE_KEY`,由 `LithePluginPackageSigner` 对完整包生成 `lithe-plugin-signature.json`,客户端按宿主内置官方插件策略和 publisher 公钥验证后才允许安装。该 secret 的值是与客户端内置公钥匹配的 base64 编码 32 字节 Ed25519 私钥,可用 `gh secret set LITHE_PLUGIN_PACKAGE_PRIVATE_KEY --repo 1lck/Lithe-IDEA < /secure/path/lithe-plugin-package-private-key.base64` 配置;私钥文件和 shell 历史都不得进入仓库或日志。没有该 key 时,普通 debug 全量构建会跳过 publisher 签名包,指定其中一个包 ID 的构建会失败,不会留下缺少签名文档的目录。本地调试插件时可以显式传入 `--development-unsigned`(仅 debug 配置可用),生成不含签名文档的开发包,只能由 debug 构建的 Lithe 从磁盘安装;这类开发包同样不跨工作树复用。无可靠 identity stamp,不跨工作树复制。插件安装后打包的语言服务器位于 `/Lithe/Plugins//versions//language-server`,由插件版本目录拥有,运行时只读,重装、回滚和卸载随插件一起处理,不是工作树构建缓存。Go Support 和 PHP Support 已迁移为 Lithe Store 社区插件(`Lithe-IDEA/lithe-plugin-go`、`Lithe-IDEA/lithe-plugin-php`),不再由本仓库构建。PHPUnit 测试夹具的 `shared/fixtures/phpunit-project/vendor` 也由当前工作树独立安装。以上项目在资源清单 `excludedResources` 中明确排除,复用脚本会拒绝显式复制请求。 +Maven 模型与依赖快照继续位于现有 +`/Lithe/PluginState//build-systems//workspaces//`。 +Windows Build Provider 的对应位置为 +`//plugin-state//build-systems///`, +由原生存储 adapter 分配,和语言服务器状态分开。会话拥有其缓存、日志和临时输出, +模型失效可重建,插件卸载随所属状态清理;工作区摘要只是归属键,不是构建身份。 +它属于具体插件、构建系统和工作区的可变上游状态,没有版本/平台/架构/工具链 identity stamp, +复用清单 `official-plugin-packages` 和脚本的排除路由拒绝任何复制阶段共享。 +Windows 工具查询后端使用同一平台 adapter 下独立的 +`//plugin-state//toolchain-queries///`。 +一个 Build 准备操作可以复用同插件的后端;并发操作使用独占创建的不同目录,结束前停止 +全部进程并删除自身状态。失败保留仍受进程使用的目录,插件卸载清理遗留状态。它没有版本/平台/架构/工具链构建印记,任何阶段均不得跨工作树 +复制。查询参数、文件证据和执行许可只存在于当前操作的内存中;安装包和插件资源只读。 +#1495 阶段 D 已删除 macOS 旧 Maven 依赖树临时文件 adapter;不再创建该专属目录, +也不能把旧树文本当作新的 Provider 缓存或从其坐标推导 JAR 路径。 +Provider 状态经平台 adapter 写 Application Support;插件安装目录和发行 bundle 只读, +不改变代码签名或 Sparkle delta 的源基线。 + ### Java Support 独立下载安装与迁移 Java Support 当前仍随应用发行,独立下载的构建配置已登记,但 @@ -907,3 +976,25 @@ CI 在 Windows frontend lane 构建并上传独立包,同时运行包、Worker 原生 UI 截图位于 `.artifacts/test-stability/hooks-ui/`,绑定本次源码、主题、 语言和窗口尺寸,不作为可复用资源。运行 `node --test scripts/resources/test-reuse-worktree-resources.mjs` 验证 `agent-hook-library-state` 被复用入口拒绝。 + + +共享 Build 调用偏好 `.lithe/build/options.json` 是用户维护的工作区文件,本机差异位于 +平台偏好的 `BuildSystems.Options.*` / `.Instances` / `.InstanceDefaults`。 +`.InstanceDefaults` 保留此工作区的本机默认值,供当前与之后出现的 reactor 继承, +不把本机 settings/仓库路径写入团队文档;逐根保存的值优先于这些默认值。这些内容没有构建产物 identity stamp, +不能作为资源从其他工作树复制、链接或发布;共享值随普通源文件流转,本机路径不转移。 +资源清单 `build-project-preferences` 与复用脚本直接拒绝它们。只有明确编辑或带原字节 +检查的一次性激活迁移可以写共享文件,普通检测和设置页读取不创建文件;不写 app 或插件 +安装目录,不改变签名与 Sparkle 增量基线。 + +Maven 配置切换使用本机偏好的 `BuildSystems.Migrations.Maven.` 及 `.Runtime` 保存待迁移 +源文件的指纹。它只服务当前工作区的一次性恢复:目标全部读回、旧源按原字节移除后清理, +部分写入后旧源被外部修改则停止自动删除。这个标记不保存原生 settings 文档,不可跨 +worktree 复用;由 `build-project-preferences` 排除项覆盖,不得作为下载缓存增加校验路由。 + +Maven Provider 随每次任务收集的报告快照在宿主提供的 `workspaceStateDir/execution-*`, +没有状态目录时在系统临时目录 `lithe-maven-execution-*`。已配置的 Surefire/Failsafe +实例提供报告位置,私有目录只保留本次新报告、索引和字节哈希;操作返回、失败或取消 +完成后删除,崩溃残留沿用插件工作区状态清理。它不具备构建缓存 identity stamp, +`maven-operation-report-snapshots` 排除项与脚本拒绝跨 worktree 复制、硬链接和符号链接。 +原始报告仍是上游写入工作区的输出,不可用于替代另一次运行;插件安装包始终只读。 diff --git a/docs/development/issue-1495-plugin-migration-remaining-plan.md b/docs/development/issue-1495-plugin-migration-remaining-plan.md index da3da386b1..abf38f50f0 100644 --- a/docs/development/issue-1495-plugin-migration-remaining-plan.md +++ b/docs/development/issue-1495-plugin-migration-remaining-plan.md @@ -5,17 +5,732 @@ ## 当前交付边界 - 阶段 A 清单 [PR #1513](https://github.com/1lck/Lithe-IDEA/pull/1513) 与独立分发 [PR #1483](https://github.com/1lck/Lithe-IDEA/pull/1483) 已合并,保持原范围,不重复认领。 -- 宿主/Core 的原生构建、测试发现/执行、运行/原生调试准备、保存的运行配置和只读模型共享在 [PR #1517](https://github.com/1lck/Lithe-IDEA/pull/1517)。配套实现为已合并的 [SDK PR #4](https://github.com/Lithe-IDEA/lithe-plugin-docs/pull/4) 和 [Maven Draft PR #7](https://github.com/Lithe-IDEA/lithe-plugin-maven/pull/7);SDK 源码合并不代表新版已经正式发布。 +- 已合并的宿主/Core 原生构建、测试发现/执行、运行/原生调试准备、保存的运行配置和只读模型共享见 [PR #1517](https://github.com/1lck/Lithe-IDEA/pull/1517)。配套实现为已合并的 [SDK PR #4](https://github.com/Lithe-IDEA/lithe-plugin-docs/pull/4) 和 [Maven Draft PR #7](https://github.com/Lithe-IDEA/lithe-plugin-maven/pull/7);SDK 源码合并不代表新版已经正式发布。 - Maven 插件已完成本地原生执行切片:生命周期、插件目标、自定义目标和测试由工作区后端执行;模型仍来自现有 Maven 扩展。它尚未替换宿主全部 Maven 消费者。 - 这些是开发源码和本地验证;正式 SDK/插件发行、完整 macOS UI、Java/JDT 和 Windows 实机验收不能据此勾选完成。#1495 保持开放。 +## PR #1558 评审修正 + +[2026-10-30 的 review](https://github.com/1lck/Lithe-IDEA/pull/1558#pullrequestreview-5715250513) +改变了下文已接通代码的验收条件。历史通过数只证明当时的实现,不能用来认定被驳回的 +架构可以合入。三个 PR 继续保持 Draft;协议 8~10 保留,协议 7 已改为提供者编辑,协议 11 已改为注册表连接目标;完整迁移仍有下列待办。 + +| 修正项 | 当前状态与退出条件 | +| --- | --- | +| 通用测试报告 | macOS 改为直接消费 `BuildTestResults`;移除三种 Maven 报告类型和转换。上游摘要、调用次数、耗时、来源位置与不透明重跑引用保持原义;JDT 发现适配器明确关联报告名称,通用 UI 不解析用例名称。Windows 的现有行标记字段保留兼容别名。验证记录见本次计时报告,不替代整机验收。 | +| 原生配置 v7 | 已移除 XML 绑定和 Core XML 编辑器,新增提供者编辑计划及不含文档的回执。两端暂存/校验/原子发布接线已更新;Node/Java JSON E2E、真实 Maven 编辑/回读、127 项 macOS、71 项 Windows 前端和类型检查通过。后续整合 preview 修复了旧 watcher 基线,Windows CI 41410144912 的 Project/host 全部通过;完整产品与正式发行组合仍待验收。 | +| 服务调试 v11 | 已删除本入口的 `runtime: jvm` 与内置 Java attach;协议传递适配器身份、公开配置类型和 `connection` 目标,宿主从当前 Registry 选择并验证配置,跨准备/监听/连接检查包与注册代次。Java Support 自己声明同包 JDT 托管适配器,Maven 仅声明配置类型;真实断点 UI 与发行组合仍待验收。 | +| 工具选择 | #1581 已实际合并,本分支从已授权的 b87 预集成继续适配最终版本及最新 preview,仍保持 Draft、不合并发布。macOS Build/Provider Run 已接 `toolDefinitions` / `toolchainProviders` 与四层选择;删除新增的 Maven JDK 注册和 Java 准备按 Maven 名字选工具的分支。按 Run 的明确工具选择现已接入两端 adapter,并保留两层继承和显式清空;旧 Maven 明确路径可一次性转移。Windows 已补来源证明和操作内 Provider 查询;项目/全局默认与应用 JDK 联动、Windows 完整产品准入和现有 Java 消费者仍待完成。 | +| Maven 运行配置与准备任务 | 新 Provider 路径已改为不透明通用选择器:Core 不解释 Maven profile/reactor/framework,提供者拥有语义。准备任务已改为 `buildSystems: 4` 清单的通用用途和适用源码角色,宿主不约定固定任务 ID。自动运行配置已由声明/目标生成 `build.provider` 选择器,独立于 Java 导入,删除 `mavenRunProvider` 字段与 Core 框架名单;快照指纹、目标撤回和用户通用覆盖已接线。旧 Run v2 中可确认的服务/显式任务已保留 ID 转为通用引用并通过两层预检及冲突保护;后续已补 v1、可确认偏好、隐式模块及明确的按 Run 工具转换,当前归属投影已移除 reactor/Wrapper/版本字段;项目/全局默认的动态继承、Windows 旧生产路径与旧 Java 扩展兼容消费者仍待处理。 | +| SDK 与版本 | 当前分支已整合公开 main 的函数协议 3、源码版本 0.2.0 与已合并 f64d262c 契约副本,Node 20/Java 8 固定组合 CI 已通过;Build 7~11 增量仍在 Draft、未正式发布。重写 v7 为提供者暂存编辑,保留 8~10 版本及处理器,v11 改为注册表连接目标,正式发行组合仍待验收。只在宿主合并后同步契约副本,正式 SDK 锁定值不变。 | + +### 整合最新 preview 与工具默认迁移的切片边界(2026-11-04) + +本分支两次合并 `preview`(`5705732d0`,随后 `4a027b10e`),共 26 个冲突文件。`MavenService` +与 `MacMavenDependencyOutputStore` 保持删除,preview 给它们接的日志 v2 语义没有替代路径; +生成文件由 `shared/i18n/` 合并后重新生成;功能矩阵、契约文档、资源复用清单与 Windows CI +工作流保留两侧条目;Swift 冲突同时保留 preview 的结构化日志与本分支的 Build Provider/工具链 +接线。为满足 preview 新增的日志用法门禁,Agent 能力的 Build 选项编码失败改为记录 +`app.ide.request_failed`,Run 本机文件缺失与工具解析转 terminalFailure 两处按封闭例外标注。 + +合并暴露了本分支最后几批提交遗留的两处陈旧测试夹具,不是合并引入:Run 文档改用保留 +元数据的替换写入(cc6fc7d8f)后,旧配置迁移测试的存储替身没有拦截该方法;JavaSupport 清单 +新增 `debugAdapters`/`toolDefinitions` 后,项目生成测试拆出的模板/语言变体分别缺 requires 与 +debug 权限,Core 目录把两者都拒绝。两处都已按新结构修正夹具,生产代码不变。 + +验证:Core 1767 项全量通过;macOS 定向 674 项(Build/Provider/工具链/运行配置/插件设置/ +调试适配器/日志/IDE 能力)在修正夹具后通过,整批并行时出现的 `DeadlineExceeded` 在串行重跑 +261 项与 62 项时不再复现,属于负载争用;契约、服务/Windows 边界、Notes、bundle 只读、i18n、 +功能矩阵、资源复用、Core 注释与日志事件 14 项静态检查通过。Windows 实机与正式发行仍未验收。 + +合并前的 `f0561be5d` 上已经失败的 `RunEntryPointTests` 5 个“延后的 Run 在快照到达后重发”用例 +(`runWithExistingConfigurationLaunchesOnlyAfterTheSnapshotArrives` 等)已修复。实际链路:重发的 Run 对 +`javaMain` 配置调用 `prepareCurrentProviderGeneration`;该套件没有启用任何构建插件, +`buildSystemsFeatureIfActive` 为空,守卫直接抛出 `CancellationError`,Run 被当作取消静默结束,测试在自身 +约 31 秒的等待后失败。按代码路径,产品中没有可用构建插件时 `javaMain` 运行也会无提示地不启动(未在真机复现)。修复与 +`javaLanguageImportConfiguration` 的规则一致:没有可用构建插件时没有 Provider 模型需要刷新,跳过刷新, +继续由 `validateProviderGeneration` 校验(无构建项目归属时直接放行);有可用构建插件但功能未激活仍按取消处理。 + +Windows 部分随后从本 PR 拆出,本 PR 只保留 macOS 与 Core:`windows/` 下全部改动、Windows CI +工作流、Windows 测试稳定性脚本、Windows 声明式插件宿主笔记、功能矩阵各能力的 Windows 条目和 +只给 Windows 用的新增 i18n 词条已还原到 preview 基线,并在单独的 Windows PR 中原样恢复。 +新增能力的 Windows 条目记为未实现,证据只引用已存在的 Core、共享契约或宿主入口。还原后的 +Windows crate 仍在新 Core 上做编译检查;共享契约、fixture、资源复用清单与 `docs/ci-builds.md` +同时描述两端运行状态,留在本 PR。 + +工具默认迁移的退出条件经核对不能单独在阶段 D 完成:旧 `toolchain.java.homePath` 等字段的 +空值同时表示“继承根默认”和“自动”,由 Run、Debug 与 JDT 启动(`ProjectRuntimeService`、 +`RunToolchainProviders`、`MacJdtLaunchResources`)动态求值;展开成 pin 会冻结继承,改写为 +`toolchain.preferences` 而 Java 消费端仍读旧字段则形成本计划禁止的双轨。可行切片是 +“Core 通用层 automatic 表达 + 非破坏式转换器 + Build/Run 消费端改读 preferences”为一片, +Java/JDT/Debug 消费端与应用级全局默认存储随任务三(阶段 E)一起切换;在此之前旧字段必须 +保留,`D-MIGRATION-REMOVAL` 顺延。本轮不处理 Windows 旧 Maven 消费者,共享 `maven.*` 命令 +因此继续保留。 + +### 最新固定组合与 Windows Registry Debug 增量(2026-11-02) + +宿主 `f51968cf4c3485607ddd278e86baed82a3a69ec4` 的 +[Windows CI 41435122086](https://github.com/1lck/Lithe-IDEA/actions/runs/41435122086) +全通过,覆盖前端、原生 Rust 与真实 JDT。下载计时产物确认 Project 104 项通过/1 项跳过、 +宿主 394 项通过/3 项跳过、Core 1587 项通过,真实 JDT lane 另有 1587 项通过。 +这是 Windows Build 本机选项/Agent configure 的固定源码证据,不能替代插件完整准入和 GUI。 + +SDK `3596a4d14bab2f4e19d187a136c8515bfd51eb88` 对该宿主的 +[显式 CI 41435126121](https://github.com/Lithe-IDEA/lithe-plugin-docs/actions/runs/41435126121) +全通过;Maven `19b6b0103e9bc8c569a3987a09ccd57b69cef966` 使用同一 SDK/宿主的 +[CI 41435128254](https://github.com/Lithe-IDEA/lithe-plugin-maven/actions/runs/41435128254) +通过两种 Maven/Java 8、真实原生配置、13 步 TypeScript 工作流和现代框架 JDI 场景。 +公开 SDK 契约镜像固定已合并的 `f64d262c48f1ff5ad1778b301138596493a09ad5`,77 文件校验通过; +正式锁定和发行元数据未改。三个 PR 保持 Draft。 + +后续 Windows 增量已经接入:按配置类型与可选完整身份选择唯一当前适配器、原生包/设置/ +代次重查、Core 连接绑定、类型化 Provider Run、Build 目标“保存并调试”、stdio 与同包 +语言服务器端口,以及精确 Run/适配器联合清理。缺失/重复、旧 Java 会话、启动输入改变、 +不支持传输均拒绝;取消只撤销本次请求并排空原生确认,不停止共享服务器。 +本机受影响的 Run/Build/LSP/界面 555 项、DAP/生命周期 14 项、稳定错误映射 2 项全通过, +包含显式确认/取消、注册身份、请求取消确认、Run 退出观察和迟到回执清理。资源复用 33 项、 +i18n 35 项及 TypeScript/生产 bundle 和架构门禁通过。host 的生产/测试编译检查仍只有 +macOS 上既有的两项 Windows API 错误,没有新增错误;不算原生 Windows 通过。 +报告使用 `stage-d-registry-debug-*` 前缀;`b810a0abe348b7fd1c4ce43c1a513c9df9822b0c` +的 [Windows CI 41440877663](https://github.com/1lck/Lithe-IDEA/actions/runs/41440877663) 已全部通过: +host 398/3 跳过、Project 104/1 跳过、Core/真实 JDT 各 1587 项;Run 366、Build 132、 +Debug service 14、LSP 86、错误映射 2 项均通过。同一宿主的 +[SDK 41440952515](https://github.com/Lithe-IDEA/lithe-plugin-docs/actions/runs/41440952515) 与 +[Maven 41440957155](https://github.com/Lithe-IDEA/lithe-plugin-maven/actions/runs/41440957155) +也已全部通过。这是 Debug 接线检查点;后续工具偏好增量不能借用它作为原生结果。 +首次将相互隔离的 mock 套件放进一个 Bun 进程出现 Channel 导出失败,按现有 CI 隔离方式 +复验通过;首轮 DOM portal 夹具未挂载,改用已有共享 portal 的局部 spy 后确认/取消均通过。 +保留失败报告,不放宽断言、超时或生产行为。 + +仍未完成的退出条件:通用项目/全局工具默认和旧默认的动态继承迁移;官方 Windows +适配器/Build 完整准入与旧 Maven/Java/Agent 生产消费者切换;正式 SDK/插件发行;两端 +完整 GUI 与发行组合验收。连接调试接线缩小了 Windows 缺口,没有完成阶段 D。 + +### 偏好目录重开、实时刷新与真实进程验证(2026-11-03,实现中) + +macOS 目录列表现包含只有通用/消费者偏好的目录及其父目录,重置后撤回。读取沿用 +Run 本机文档,限制大小和路径层级,拒绝父目录/最终文件的外部链接。文件纳入既有 +输入观察,加载途中变更只重试一次,持续变更显示可刷新错误。干净表单更新为最新 +输入;冲突保留草稿原始基线;自身保存通知不会等待或撤销正在进行的保存。 + +`stage-d-preference-scopes-complete-final` 的 80 项逐项计时全部通过,新增目录/重载回归 +包含确定性事件门和本地期限。真实集成 `stage-d-real-tool-defaults-macos-final` 的 +两项方法通过(四种保存/Wrapper 组合与一次默认切换,分别 6.165 秒、4.825 秒): +经生产存储保存选择,再让生产解析器启动真实 Maven;切换两个明确选定的 JDK 后 +Maven 输出确认新 home,显式 Run 仍保持原选择,Maven/Java 插件清单不变。 +这些是本机组件和真实进程证据,尚未转换旧默认数据或完成完整 GUI/Windows 准入。 +额外的 `missingPreferenceLeafBehindAParentAliasIsRejectedBeforeToolWork` 覆盖两级父目录、 +存在/悬空链接共四种情况,当前运行前链路均拒绝且零探测,最终逐项计时 8ms。 +旧默认转换仍需与 Java 消费端一起切换:空路径目前会重新使用根默认,不能承载 +通用引用明确跳过该默认的结果。Windows 旧 Agent 入口也须等完整 Build 准入后删除。 + +### 当前目录工具继承(2026-11-03,实现中) + +为保持“构建运行时跟随当前目录应用工具”的动态关系,Core 增加消费者专用 tool +引用,复用通用工具原有目录查找;经 workspace consumer 间接引用时仍保留最初目录。 +通用工具不能反向引用消费者,不引入任意循环、工具名称分支或缓存路径。macOS 编辑器 +已支持保存/重开该引用;共享 schema/fixture 与两端运行前解析回归同步增加。它为旧 +默认转换补齐表达能力,尚未清理旧默认来源,不能据此标记一次性迁移完成。 +Core 全量 1636 项、原生 Project 108 项、macOS 67 项与后补的 8 项编辑器回归均通过; +报告前缀 `stage-d-directory-tool-reference-*`。此前 cc6fc7d8f 的 Windows CI +41550723054 已全通过;本增量 96f10837e 的 [Windows CI 41558993167](https://github.com/1lck/Lithe-IDEA/actions/runs/41558993167) 也已全部通过。 + +### macOS Build 偏好显式编辑(2026-11-02,实现中) + +设置页从当前启用插件的 Core invocation 列出真实 Build 使用方,可编辑通用目录默认 +或特定消费者的安装目录、可执行文件与 workspace/consumer 符号继承。Core 的精确 +作用域编辑和 Run 文档替换只改 preferences,打开/取消/无变化重置不建文件;原生保存 +检查草稿最初版本,保留文件元数据并回读。草稿重开不能悄悄吸收外部版本,关闭取消 +保存并排除晚到发布。只有偏好的文档不阻断旧 Project 默认迁移。 + +新增回归捕获并修复了最终文件缺失时父目录别名被整路径规范化漏过的问题,现复用 +逐目录不跟随链接校验。Core 1635 项逐项计时全通过,4 项新增编辑/JSON 回归为 4–5ms; +macOS 扩展 240 项相关回归通过,包含深浅主题原生 Save/Reset 按钮、使用方撤回及 +工作区关闭回执等待;新增按钮 133ms,新增存储回归为 1–5ms。随后继续补严格捕获 +文档编辑:v1 Run 项原样保留,不触发旧格式迁移;超大未知版本号拒绝;来源读失败 +仍保留当前草稿;Core 1635 项与直接受影响的 macOS 171 项再次通过。最终补充 +保留已有空容器的无修改 Reset 后,当前源码 10 项偏好/Run JSON 定向计时全通过。报告前缀 +`stage-d-preference-editor-*`。这些是本机偏好与组件证据;Windows 编辑、全局默认、 +旧默认动态迁移、完整产品与正式包仍保留为退出条件。 + +### 通用工具偏好与两端消费(2026-11-02,实现中) + +Core 新增无 I/O 的 `toolchains.resolveConsumerPreferences`:按 Run 明确值、当前消费者、 +目录与工作区选择输入,工作区/消费者继承保持引用而非复制路径;引用不能形成循环。 +共享 schema/fixture 覆盖最近目录、跳过父消费者覆盖、默认变化、明确覆盖和严格拒绝。 +现有 `.lithe/run/local.json` 的 `toolchain.preferences` 是唯一持有方,旧编辑器省略字段 +时原样保留,显式空 v1 文档重置;读取、编辑都校验,不新建配置副本。 + +macOS Build invocation 与 Windows native Build 使用当前插件/构建系统身份消费它。 +原生端读取当前文档、交同一 Core 选择惰性固定输入,再复用已安装定义、目录 pin、版本 +与本机文件证据;探测后核对来源字节,变更、取消、无效路径及越出工作区的来源拒绝。 +没有增加 Maven/Java 名字分支,也没有改变公开 Build invocation JSON。资源清单新增 +现有 Run 偏好的拒绝项;不写安装包、签名资源或跨 worktree 共享缓存。 + +该批先接通存储协议与两端运行前读取;后续 macOS 显式编辑见下。全局默认及旧 +Project/Maven 默认的一次性无损转换尚未接通,不能把这项基础标成工具迁移全部完成。 +本批 Core 1631 项全量通过,消费者 ID 按实际插件清单语法校验后的 8 项定向计时也通过 +(6–12ms);原生 Project 107 项全通过,新默认变化/探测期失效/无效文档为 27/11/7ms。 +macOS 166 项回归通过(默认重读/来源变化/越界输入为 56/14/2ms);随后补充 Run 文档 +2MiB 上限与大型无关元数据后,最终 macOS 186 项扩展回归全部通过,报告为 +`stage-d-tool-preferences-macos-complete`。host 生产/测试检查仍只有 +macOS 上既有的两处 Windows API 错误。随后 aa8a4a7e 的 Windows CI 41466245542 +全部通过:host 398/3 跳过、Project 107/1 跳过、Core 与真实 JDT 各 1595;SDK +41466252742 与 Maven 41466259510 的固定组合也通过。 + +验证报告前缀 `stage-d-tool-preferences-*`;首次 Core golden 因多加公开 consumerID 字段 +失败,移除该字段并让平台从当前已安装贡献提供身份;首次 Swift 夹具每次重新编码清单 +触发来源字节变化,改为持有同一清单快照。后续版本检查同时接受 JSON 整数的编码类型, +保留无效版本拒绝;所有失败报告保留,不放宽生产条件或等待预算。 + +### 通用运行配置生成(2026-11-01) + +macOS 自动配置改为当前公开运行声明与目标的通用选择器,Core 删除 `mavenRunProvider` +及服务默认项的框架名单。Java 导入独立提供源码归属;没有语言导入的插件也能生成运行项。 +名称与执行类型变化保留选择器 ID;重排保持生成顺序,撤回目标不从缓存复活,用户层保持原样。 +参数、环境和目录覆盖在准备前重新读回;旧 application 选择器保留协议 4 的不透明参数含义。 +宿主提前限制声明与目标组合的数量,避免在主线程展开超限清单。 + +本轮 Core 1437 项全量通过;最后的生成顺序和显式目录覆盖补充了三项定向计时。 +macOS 280 项通过,其中真实 Maven 模型→生产 Run bridge→通用配置→Profile 失效/恢复 +为 11.215 秒,包含插件包清单及会话释放检查。报告分别为 +`.artifacts/test-stability/stage-d-generic-run-generation-core-final.html`、 +`stage-d-generic-run-ordering.html` 和 `stage-d-generic-run-generation-macos-final.html`。 +Windows Rust 消费者检查与既有基线一致,仍有两处 macOS 无法编译的 Windows API;未运行 Windows 产品。 + +本段没有迁移旧框架 ID 与领域选项,也没有删除归属中的 reactor、Wrapper 或版本字段; +这些仍属于上表待办,不把新的自动配置当成阶段 D 整体完成。 + +### 旧 Run 数据迁移所需的通用选项(2026-11-01) + +已补充 `extensions.build-run` 保存精确运行时参数、入口和按次 Build 选项,并通过 +现有 v10 `launchOptions` / 生效选项转交 Provider;不再为这些新数据增加 Maven 字段。 +团队与本机层允许显式清空,旧调用者省略时保留原值;原生存储、偏好和同选择器重存 +保留它们与工作目录。大小、空值和未知字段在调用 Provider 前拒绝。 + +本轮 Core 1439 项全量和 macOS 284 项回归通过。新增共享契约用例为 15/25 毫秒, +生产桥接读写为 9 毫秒;真实保存任务使用未排序的两个 Profile,按当前声明规范化后 +运行(5.904 秒),Build 默认选项和模型保持不变。另覆盖原生 Reset、原生配置存储、 +插件包清单与会话释放;计时报告为 +`.artifacts/test-stability/stage-d-portable-choices-core-final.html` 和 +`stage-d-portable-choices-macos-normalized.html`。Core 生成的写入文档通过 Run v2 Schema; +显式 null 被拒绝,运行时参数与不透明 Build 选项读回一致。Windows Cargo 检查仍与 +基线相同,仅有两处 macOS 不支持的 Windows API 错误,未执行 Windows 产品。 + +这是旧数据迁移的存储前提,尚未把旧框架 ID、领域选项或工具覆盖自动转换为新格式。 +转换必须保持层级、稳定身份与冲突保护,并在回读后清理旧输入;Core 现存领域解析 +和归属字段继续保留在待删除账本,不能把前提完成当成迁移完成。 + +### 旧 Run v2 配置转换(2026-11-01) + +已接入有界的临时转换器:保留已有 ID,把可唯一映射到当前模型的框架服务和显式 +Maven 目标任务转为 `build.provider`,保留两层覆盖、argv、入口、环境、目录与元数据。 +旧提供者必须与当前目标名称一致,框架已改变或存在歧义时拒绝。Core 只按有效选择器 +消除自动项重复,不识别历史框架前缀;默认项和本机选择因此无需改写 ID。 + +两层文档先通过只读预检,再检查全部输入、偏好状态和生成租约,以保持权限的 CAS +发布并回读。取消或冲突时有界回滚,不覆盖外部修改;只读安装目录和越界链接拒绝写入。 +该转换不接管 Java 源码入口,不清理原有检测缓存,也不新增运行时目录。 + +本轮 Core 1440 项全量通过;macOS 受影响的 9 组共有 293 项,其中 292 项通过并记录计时, +1 项既有外部工具集成因未设置其独立环境开关而跳过。真实 Maven 转换任务经生产存储、 +当前模型及准备链为 4.282 秒;覆盖配置 ID/按次 Profile/环境保留、默认模型不变和插件包 +清单/会话清理。旧来源拒绝包含 10 组数据,取消/冲突覆盖 9 个写入边界,生成撤销覆盖 +5 种上下文变化。最终回归所有单项均在原 15 秒门槛内;既有双 reactor 用例曾有一次 +17.066 秒,单独复查及最终整组分别为 10.780/10.416 秒,失败记录保留。 +计时报告为 `.artifacts/test-stability/stage-d-legacy-runs-summary.html`。 +Core C ABI、共享契约、服务/Windows 边界、安装包只读、i18n、Agent Notes 与功能矩阵 +检查通过;Windows Cargo 仍与既有两处平台 API 错误一致,未运行 Windows 产品或完整 UI。 + +尚未覆盖的来源继续保留原输入并明确失败:Run v1、未完成转移的旧偏好、服务的自定义 +工具路径,以及需要解释旧应用/隐式目标语义的模块配置。工具相关转换必须等待 #1581, +不能在这里再建 Maven/JDK 选择链。整体迁移和 Core Maven 字段删除仍未完成。 + +### 旧输入和正常 Provider 路径收尾(2026-11-01) + +已补齐 Run v1/v2 混合层的格式转换、可确认活动偏好的转移和隐式模块目标保存。 +创建 local.json 前枚举旧偏好命名空间,同时转移可确认的 Java 选项,避免触发旧导入器 +“已有本机文件就跳过”的边界。显式本机字段优先,来源字节、键清单与标记在发布前后 +复核;新文件使用 0600,取消只条件删除自身输出并清理空目录。已转移偏好只作兼容窗口 +的回滚记录,不再参与正常解析;窗口关闭时与迁移器一起移除。 + +Core 的 providerLaunchInputs 已删除 Maven 扩展解释,只接收完整通用选择器。 +macOS 删除按旧框架/模块名选择工具的 Provider 路径;Windows 删除未准入的同名入口, +完整保存引用保持原 ID。既有 Java/Windows 旧引擎的最终删除仍按当前消费者处理, +不能将其接回新 Provider 路径。Windows 数字端口旧调用不能静默变成普通运行; +实际 Registry 调试接线仍需独立验收。 + +空模块目标原样交给提供者,由 Maven 自身处理 defaultGoal 或无目标错误;保存的 +运行时/入口选择也保留,由提供者明确验证。两端编辑器使用同一 argv fixture, +保留空参数、空格、引号、反斜杠和 Unicode;Windows 映射与保存携带通用选项快照。 + +当前 Core 1442 项通过;macOS 297 条回归计时加 1 项活动偏好变化回归通过,另有 +1 项条件跳过;真实 Core→Maven 20 步通过,包含默认目标实际执行与插件包清单不变。 +Windows Run/Build 回归和生产 bundle/typecheck 已通过,最终计数随本轮汇总报告登记。 + +用户已允许不等待 #1581 正式合并,先固定 b87c7f60dd80f851695d7052b5287a5c93c7fa46 +进行独立工作树的集成验证;这调整 review 的等待顺序,不授权合并或发布。 +下一步接 toolDefinitions/toolchainProviders,删除宿主专属工具注册和对应旧元数据。 +Windows 实机验证使用现有 Windows CI,避免在无 Parallels 的本机声称原生通过。 + +### 固定工具链基础的预集成(2026-11-01) + +用户已授权先固定 #1581 的 `b87c7f60dd80f851695d7052b5287a5c93c7fa46`,在独立工作树 +合入验证;宿主合入检查点为 `c4c74fb8a`。这不改变三个 PR 保持 Draft、暂不合并和发布 +的限制。该依赖包含的设置页仍只读,不能把页面的探测结果直接当成启动许可。 + +本轮增加单次操作的原生解析:复用定义扫描和 Provider 查询,重新读取目录 pin、原生 +文件事实与版本证据,交给相同的 Core 四层解析器。构建声明的最低版本参与选择,固定 +路径失效时不回落;工作区 Wrapper 只在明确执行时准许,不在打开工程时探测。工具包、 +应用资源和插件资源均不写入。Maven 插件使用自己的命名空间工具 ID,Java 短 ID 只由 +具有独立官方来源证明的 Java Support 定义,不把社区 ID 伪装成官方贡献。 + +已删除阶段 D 新增的 `project-maven-jdk` 注册和 Java 准备入口的 Maven 工具名分支; +原有 `project-maven` 仍由 Java 旧消费者使用,不能在它们完成切换前删除。归属投影中 +新增的 `mavenMinimumVersion`、`mavenWrapper` 以及 Core Wrapper properties 解析已移除; +Java 导入 reactor 字段暂留,精确删除仍需连同当前 Java/Windows 消费者完成。 + +Windows 前端在该固定集成上已完成 typecheck、生产构建和 456 项 Run/Build 回归, +这仍是 macOS 主机验证。原生 Windows 使用现有 CI;当前尚未触发整机作业,也不宣称 +Windows 的工具定义解析器、Registry 调试、旧 Maven 入口替换或阶段 D 已完成。旧工具 +路径的无损转移、原生产品完整验收、正式依赖发布仍在待办中。 + +本轮固定组合的最终回归:Core 1,573 项、macOS 318 项与真实工具解析 1 项、Windows +前端 456 项通过;真实 Core→Maven 20 步、Windows TypeScript→Core→Maven 11 步通过。 +真实工具解析使用目录 pin 选择指定 Maven/JDK,耗时 1.116 秒,并比较 Maven 包和 Java +Support 目录清单不变。运行准备直接返回操作持有的解析值,不留跨 Run 的可消费缓存槽。 +Windows Cargo 在 macOS 的两处平台 API 失败与既有基线一致,不能算原生 Windows 通过。 +报告为 `.artifacts/test-stability/stage-d-defined-core-final.html`、 +`stage-d-defined-tools-macos-final-v2.html`、`stage-d-real-defined-tools-v2.html` 与 +`stage-d-pin-windows.html`;Maven 仓库报告位于其 `stage-d-defined-tools-native-v2/` 和 +`stage-d-defined-windows/`。首次检查的命名空间与规范路径断言失败记录保留,不放宽门槛。 +提供方不可用补充回归通过 34 项(与前述 macOS 回归重叠),另有 1 项条件跳过;SDK 的 +Node/Java/Rust 和 Build/配置/报告/运行选项端到端共 6 项在当前固定宿主源码组合通过。 +汇总报告 `stage-d-pinned-integration-summary.html` 已在浏览器检查,无失败或硬预算超限。 + +### 通用构建项目身份与原生 Windows 首轮结果 + +当前继续删除归属投影的最后一个 Maven 字段:`mavenReactorPath` 替换为完整的 +`build={pluginID,buildSystemID,root,projectID}`,用于引用当前模型,而不是改名保存 Maven +语义。生成 Java 配置只包含 Java 主类/模块/源码角色和此引用,不带 Maven 扩展或工具 +绑定;Core 不再需要 Maven 根来解释这份新输入。普通 Java 的旧引擎与未提供新归属的 +Windows 调用者暂留兼容路径,仍按真实消费者逐项删除。 + +首轮现有 [Windows CI 41307483413](https://github.com/1lck/Lithe-IDEA/actions/runs/41307483413) +验证 `138a8aa97`:前端通过;Rust 与真实 JDT lane 都被同一 v7 配置测试阻断,原因是 +Unix 的 `/fixture/mvn` 在 Windows 不是本机绝对路径。修正测试使用各平台有效的虚构 +绝对路径,保留相对路径拒绝,不修改生产路径校验。尚未重跑前不把该轮记为通过; +后续修改先批量完成本地验证再触发一次现有 CI,避免连续 push 取消 runner。 + +通用身份不可被团队/本机文档改写,整个 Java 扩展被置空也不能撤销当前模型的归属; +Java 源码尚未返回时,已知的 Provider 编译版本仍生成对应 JDK 要求,不再依赖 Maven 根 +来推断工程类型。精确导入查询拒绝四个身份任一不符,以及选项变化后尚未恢复的模型。 + +真实开发包验收补出 macOS 安装准入遗漏:运行前解析器已实现,但能力表未声明 +`toolDefinitions:1` 和 `toolchainProviders:1`,导致生产验证器拒绝 Maven 包。现在能力表 +与实现同步,增加经过生产验证器的声明/提供者回归,不放宽 Core 准入;Windows 未实现的 +能力继续关闭。Build 解析保留定义导出的全部 PATH 目录,并去掉空目录项。 + +本批本地验证:Core 1,573 项全量、macOS 326 项逐项计时通过(另有 1 项独立开关的 +集成测试跳过)、Windows Run/Build 457 项及类型检查和生产构建通过;真实 Core→Maven +20 步和 Windows TypeScript→Core→Maven 11 步通过,核对包文件清单与后端清理。 +报告为 `stage-d-generic-owner-core-final.html`、`stage-d-generic-owner-macos-final.html`、 +`stage-d-generic-owner-macos-neighbors.html` 和 `stage-d-generic-owner-windows.html`;真实 +Maven 仓库报告分别在 `stage-d-generic-owner-native-v2/` 和 `stage-d-generic-owner-windows/`。 +首次生产准入失败、旧工具名断言失败及空源码版本要求失败均保留日志,未放宽断言或预算。 +Windows Cargo 在 macOS 检查仍只有两处原生 API 基线错误,不代表原生 Windows 通过。 + +### Windows 原生工具解析与最低运行时验证(2026-11-01) + +Windows 的 Build 解析开始读取已准入插件的工具定义,目录 pin、声明扫描、Shell 变量和 +命令仍交相同 Core 规则校验。原生 adapter 不包含 Maven/Java 名字分支,不由安装目录 +或 manifest 自称官方来源。固定值不依赖低层查询;没有固定值而需要 Provider 时因查询 +后端尚未实现而明确拒绝。只有声明的导出、PATH 和明确输入进入 RPC,宿主秘密不转发。 +这些基础代码不开放 Build 能力准入,Registry 调试与旧消费者迁移仍在待办。 + +移除 Windows 未准入 Build 路径新增的 `project-maven-jdk` 别名与按该名字选择 JDK 的分支。 +共享工具消费 fixture 改用两个命名空间工具及显式版本声明,验证不依赖 Maven/Java 注册名。 + +同时修复显式 Wrapper 的版本证据缺口:需要最低版本时,先由 Core 确认项目启动器,再 +有界探测并以原最低版本重新解析。macOS 真实 Maven 委托 Wrapper 与目录 pin 两种情况 +通过;被动调用不执行 Wrapper,版本过旧仍拒绝。原生测试覆盖第二种工具、固定值失效、 +同版本冲突、目录继承、环境隔离和探测期间输入变化;macOS 上四个既有 watcher 超时保留 +原断言,真实 Windows 结果以现有 CI 为准。 + +固定宿主 `3d1656ed4` 的 [Windows CI 41312095369](https://github.com/1lck/Lithe-IDEA/actions/runs/41312095369) +前端通过,Rust/JDT 再被 v7 暂存输出的 Unix 路径 fixture 阻断。本轮已把该文件的暂存目录、 +输出、源 URI 和拒绝变体,以及 Provider 环境回归的工具路径一并改为原生路径;保留相对 +路径、越界、同源和缺失 state 的拒绝,不放宽生产安全检查。后续集中重跑,不宣称该轮通过。 + +当前宿主与 Maven `5f701c9`、SDK `23e455a` 的 [Java 8 开发验证 41312168557](https://github.com/Lithe-IDEA/lithe-plugin-maven/actions/runs/41312168557) +已通过 runner Maven 和 Maven 3.6.3,两组都包含真实 Core/Maven 工作流;runner 另覆盖 +Windows TypeScript 工作流及 Java 17 框架调试场景。SDK 仅调整显式宿主 CI 的覆盖开关, +`0ce8335` 对同一宿主的 [验证 41312873834](https://github.com/Lithe-IDEA/lithe-plugin-docs/actions/runs/41312873834) +通过 Node 20、Java 8、Rust 和当前配置/报告/运行选项 E2E;整体仍因公开契约镜像相对 +preview 的漂移检查失败。按既定顺序等待宿主合入后同步,不修改镜像或正式锁定值来掩盖漂移。 + +本批本地最终结果:Core 1,573 项全量通过;macOS 30 项逐项计时通过,另有 1 项外部工具 +开关跳过,真实 Maven 目录 pin/Wrapper 均覆盖;Windows 前端 465 项、类型检查、生产构建 +通过;当前源码上的 SDK E2E 6 项通过。Project crate 的 68 项中 64 项通过(含新增 9 项 +定义解析回归),4 个既有 watcher 在 macOS 超时;保留失败日志和门槛。Windows host 的 +生产与测试编译检查仅有既有两项 Windows API 错误。计时报告分别为 +`stage-d-generic-tools-core-final.html`、`stage-d-generic-tools-macos-final.html`、 +`stage-d-windows-defined-tools-bun.html` 和 `stage-d-windows-defined-tools-rust-final.html`。 + +### 按 Run 的独立工具选择与迁移(2026-11-02) + +目录 pin 无法保存同一目录两个 Run 的不同选择。新增通用 `extensions.build-run.tools` +以工具定义 ID 保存明确的安装目录或可执行文件;两端在准备时使用同一 Core 解析器重新 +校验,团队/本机按 ID 合并,`null` 清除单项 Run 选择,空对象保留已有条目。失效明确值 +不回落,工作区启动器取得当前操作授权前不参与探测,不从启动器父目录猜测 JAVA_HOME。 +数据随现有 Run 文档持有,目录 pin、安装包和插件包保持不变。 + +临时转换器已能迁移逐 Run 的明确 Maven 可执行文件和 Maven JDK,保留本机清空、 +稳定 ID、多个同目录 Run 及偏好来源;重复执行不覆盖新格式。旧项目默认、目录覆盖、 +应用 JDK 联动尚无完整目标模型,转换前明确拒绝并保留源文件,不能把当前继承结果 +冻结成 java pin。完整默认值迁移仍是退出条件。 + +本地验证:Core 1,577 项全量通过;macOS 254 条受影响回归与独立的 14 条迁移回归 +通过;Windows 前端 465 项、类型检查与生产构建通过;Node/Java/Rust SDK 6 项真实 +端到端通过。原生项目库 70 项中 66 项通过,仍保留在 macOS 已复现的四项 watcher +失败。Windows host 生产/测试编译检查仍仅有两处既有 macOS 平台 API 错误。 +报告为 `stage-d-consumer-fixed-core-final.html`、`stage-d-consumer-fixed-macos.html`、 +`stage-d-fixed-tools-migration.html`、`stage-d-consumer-fixed-windows-frontend.html` 和 +`stage-d-consumer-fixed-native-rust.html`,不复用旧提交的通过数。 + +宿主 `7b3fe8039` 的 [Windows CI 41315520152](https://github.com/1lck/Lithe-IDEA/actions/runs/41315520152) +中共享 Core、前端及真实 JDT 已通过;项目库因 source check 路径期望值带 Windows +verbatim 前缀而失败。本批只修正该夹具通过文件 URI 的路径拼写,生产校验不变;完整 +Windows 仍须在本批提交上通过,不能把这一轮记为成功。 + +### Windows 发布者证据与原生路径修正(2026-11-02) + +Windows 已复用既有 Ed25519 发布包格式实现原生只读验签,算法交给现有依赖 ring; +导入准备/确认、暂存激活、扫描和启动都复核存在的签名。扫描记录完整内容摘要,签名 +移除或文件改变即撤销;工具定义的官方来源只来自独立验证结果。安装记录与包名仍不 +证明官方身份,无签名旧路线不获得该证据。共享测试向量同时通过 Swift CryptoKit 和 +Rust ring,含 Unicode 文件名、完整字节、篡改/增删、身份/钥匙/算法、重复字段及预算。 + +本地 Swift 签名 7 项、Windows 前端 466 项及类型检查/生产构建通过,i18n 35 项通过。 +原生项目库最终 79 项中 75 项通过,四项既有 macOS watcher 超时不变;新增签名及 +路径回归全部通过。Windows host 测试编译在 macOS 仍仅两个既有平台 API 错误。 +报告为 `stage-d-publisher-vector-macos-final.html`、`stage-d-publisher-windows-frontend.html` 和 +`stage-d-publisher-and-native-paths-nfc-final.html`;宿主特有扫描/安装回归须由 Windows CI 执行。 + +上一提交 `cef257782` 的 [Windows CI 41373760510](https://github.com/1lck/Lithe-IDEA/actions/runs/41373760510) +中共享 Core 和真实 JDT 通过;前端因新增测试漏必填 name 而失败,现已修正并实际确认 +本地类型检查成功。原生项目库又暴露暂存身份比较错误:普通 drive/UNC 路径与等价的 +verbatim 路径被当成不同文件。现在只统一这两种规范拼写,仍拒绝设备路径、DOS 保留名、 +尾点/尾空格及流路径,链接重定向和安装根只读约束不变;需要重新跑完整 Windows 组合。 + +CI 脚本在系统 Bash 3.2 下保留既有 21 项门禁失败和大小写语法不兼容记录;用临时 +Bash 5.2.37 按 Linux CI 语义复验,113 项门禁、分类器及 19 项复用规划全部通过。 +临时源码和可执行文件已清理,不加入产品或资源复用清单。 + +本增量不开放 packageTrust/Build/Debug 产品能力,也不包含 Windows 调试 Registry、 +Provider 查询和旧 Maven 生产消费者替换;完整准入和阶段 D 仍未完成。没有新运行时 +下载、缓存或可复用工件目录,验签全程只读,不影响安装包哈希、代码签名或更新源基线。 + +### Windows 工具 Provider 查询与真实 ID 兼容(2026-11-02) + +宿主 `2a8bcddf8` 的 [Windows CI 41377299138](https://github.com/1lck/Lithe-IDEA/actions/runs/41377299138) +已全部通过:共享 Core、原生项目/host、前端和真实 JDT。它验证前一批签名与路径修复, +不作为本批新增查询的通过证据。 + +本批把 Windows 工具查询接到单次 Build 准备:复用 Core 的后端协商、协议、期限与进程树, +同一操作内复用插件进程,返回前全部停止。可写状态经平台 app-data adapter 放到独占的 +plugin/operation/workspace 目录,先停止进程再删除状态,不写安装资源、不跨工作树复制。 +原生端收集回复引用的文件事实,交 Core 校验后再探测版本;探测期间来源改变即撤销。 +固定选择不查询低层,只有已校验的不可用回复可继续检查低层,安全及身份错误直接失败。 + +真实 Core 后端使用字符串 JSON-RPC ID,原有查询校验只接受整数,导致正确回复也被拒绝。 +Core、macOS 与 Windows 现在接受有界字符串和正整数,并严格匹配回复的类型和值; +共享 fixture 覆盖混合类型、空值、控制字符和大小上限。Build 工具上下文尚不能传递 +完整启动模板时,两端在探测前拒绝带额外参数的模板,避免丢掉插件声明的 flag。 + +本地 Core 1,578 项、macOS 工具解析/Build resolver 27 条计时记录及 SDK 6 项真实 E2E +通过;Project crate 88 条记录中 84 项通过,四个既有 macOS watcher 超时不变,真实 +Core/Node 会话测试已显式启用并覆盖协商、复用、错误、无协商拒绝、静默超时、初始化超时、意外退出 +与撤销清理。资源复用 31 项通过。报告为 `stage-d-provider-wire-core.html`、 +`stage-d-provider-wire-macos.html` 和 `stage-d-provider-final-native.html`。Windows host +在 macOS 的编译检查仍仅有两处既有 Windows API 错误;本批原生结果须等待新 CI。 +Windows 计时入口收集同包全部失败而不改变期限,仍拒绝失败且不重试已执行的测试。 + +当前不开放工具、Build 或 Debug 的产品能力准入;非空未实现 hostServices 明确拒绝。 +共享后台服务、工作区关闭主动取消、完整默认值迁移、旧 Java/Windows 消费者、调试 +Registry、正式包与完整 UI 验收仍在待办。三个 PR 保持 Draft,不合并、不发布。 + +### 整合工具链最终合入版本与最新 preview(2026-11-02) + +#1581 已于 2026-11-01 合并,最终 PR head 为 `4d30a64`,合并提交 `ef6e70c3c`; +本分支已整合最新 `preview@50b4ebe0f`,不再停在先前授权的固定 b87 基础。 +保留最终工具链设置页的同目录查询轮次、原生事实共享、Core 生效层/冲突判断和 +提供方超时/不可用原因,并适配阶段 D 的运行前解析、明确工具选择和字符串 RPC ID。 +上游新增 JDK 测试改用本分支已有的隔离 settingsStore,未恢复已淘汰的偏好存储。 + +此组合的本地 macOS 回归 228 项、原生 Project crate 94 项、Windows 前端 469 项 +通过,类型检查和生产构建通过。上游 watcher 修复使本分支以前在 macOS 的四项超时 +全部通过;它们不再是当前组合的本地失败。契约、边界、bundle 只读、Notes、矩阵、 +资源复用、i18n 与计时工具检查通过。Swift 第一次重编译在测试开始前达到十分钟期限, +随后编译暴露新增测试的旧 helper 引用;失败记录保留,修正后在原期限内通过。 + +Core 两轮均执行 1,621 项且所有断言通过,但逐进程计时门禁分别有一项及两项超限, +不能把整组报告称为全绿。三项超限测试以同一二进制、相同十五秒期限定向复验全部 +通过;原报告和预算保留,不增加例外。报告前缀 `stage-d-latest-preview-*`,最终原生 +报告为 `native`、`macos-final` 与 `windows-frontend`,Core 保留 `core`、`core-serial` +和 `core-focused`,不混算成一轮通过。 + +SDK PR #5 整合已合并 main 后为 `5703d37`,公开契约保持 main 已同步的实际宿主 +`589116e30`,不提前写入 #1558 的契约。其 [CI 41383313427](https://github.com/Lithe-IDEA/lithe-plugin-docs/actions/runs/41383313427) +已全部通过:镜像、Node 20、Java 8、Rust 1.87 与固定公开 Core E2E,之前的镜像漂移 +已解决。本地阶段 D 工作区源码的六项 SDK E2E、仓库工具 15 项、Node 69 项及类型检查 +通过。SDK/Maven 正式版本和锁定值不变;#1558 实际合入后再同步其增量并正式验证发行。 + +新宿主组合须单独通过现有 Windows CI,前一提交 `2a8bcddf8` 的通过记录仅作其自身证据。 +工具默认值无损迁移、Windows 产品准入/主动取消/Registry/旧消费者和正式包验收仍待完成。 +所有相关 PR 保持 Draft;本地整合提交不等于合并外部 PR 或发布。 + +### Windows 工具准备取消与窗口归属(2026-11-02) + +工具解析从单纯取消前端等待改为撤销原生所有者:先登记窗口令牌,再领取并派发工作, +同一 AbortSignal 贯穿模型准备、私有 Run scope 与启动前验证。取消先于派发时拒绝领取; +窗口销毁撤销自己的全部登记,其他窗口不受影响。扫描、查询和最终发布都检查当前性; +已启动的版本探测仍由原五秒期限和进程树清理持有,不放宽超时。内存登记有六十四项 +上限和未领取三十秒有效期,活跃租约不被容量清理驱逐;没有新增磁盘缓存或运行时资源。 + +本地原生 Project crate 99 项、Windows Build/Run 与工具准备 473 项通过,类型检查通过。 +新增真实 Core/Node 取消测试在请求已经发出后撤销并确认 shutdown,另覆盖取消早于派发、 +双窗口隔离、原生探测中撤销、迟到结果与清理失败。计时报告前缀 `stage-d-tool-cancel-*`。 +Windows host 在 macOS 的编译仍仅有两处既有 Windows API 错误;CI 门禁/复用 136 项 +及分类器在临时 Bash 5.2.37 下通过,临时源码与可执行文件已清理。本批尚待自己的原生 CI。 + +此前整合源码的真实 Core→Maven 20 步与当前 Windows TypeScript→Core→Maven 11 步通过, +包括实际框架启动、报告和插件包字节不变; +SDK 六项 E2E 与公开镜像 CI 保持各自固定提交的证据,不作为新取消路径的产品验收。 +完整默认值迁移、Windows 产品准入/Registry/旧消费者、正式包和完整 UI 仍未完成。 + +### Windows Provider Run 使用本次工具定义绑定(2026-11-02) + +删除 Provider Run 通过 Maven languageImport 和旧 `mavenExecutablePath` / +`mavenJavaHomePath` 补齐启动程序的路径,也不再传入应用的旧 runtime 路径表。 +原生工具上下文发布当前定义 ID 到已校验程序的绑定,前端只把绑定路径交给通用进程 +解析器;没有绑定就失败,同一 ID 在不同根出现不同路径也拒绝,不按工具名或迭代 +顺序选择。旧 Java/Windows 消费者仍使用的全局旧工具 API 按各自迁移继续处理。 + +共享 `native-tool-context.json` 由 Rust 的真实 Serialize 和 TypeScript Run 编排共同 +消费,固定 camelCase、选中环境和两个不同工具的路径映射。现有 camelCase 声明本身 +无需修改;新回归补的是此前手写 mock 未覆盖的原生传输边界。按 Run 的选项、argv、 +操作身份、来源失效和清理仍沿用现有工作流,没有新增资源或持久化许可。 + +本地前端 476 项、类型检查、生产构建、共享契约及 bundle 只读检查通过。新增 Rust +序列化/路径冲突回归已通过 macOS 编译检查(仍有两处既有 Windows API 错误),实际 +运行留给下一批现有 Windows CI;不冒充已经运行宿主二进制测试。报告前缀为 +`stage-d-tool-bindings-*`。此增量和窗口取消合并成下一次 push,等待当前 CI 结束后再 +触发完整原生验证,避免取消尚未完成的 runner。 + +### 当前原生 CI 通过与公开契约更新(2026-11-02) + +`cb060460f` 的 [Windows CI 41398771918](https://github.com/1lck/Lithe-IDEA/actions/runs/41398771918) +已全部通过:前端、共享 Core、原生 Project/host 和真实 JDT。这是整合 #1581 最终实现 +及 preview@50b4ebe0f 后的独立原生证据;不覆盖随后 `3c950a34c` 的取消和 `06ad4d58f` +的工具绑定增量,两者须由下一次 CI 验收。 + +本地继续合入已发布到 preview 的 `04b1fb848`(#1646 设置 v2 Schema、#1650 SDK/函数 +协议文档),保留本分支未合并的 Build 7~11 修正。JavaScript/Python 设置 Schema +四项与共享契约检查通过;新合入内容不改变 Core 执行代码。三个 SDK 现有协议 3 实现 +来自公开 main,不能在整合时退回协议 1。SDK PR 正在整合相同公开提交,镜像由 main +既有同步引入,不手改未合并契约副本。 + +固定 SDK `5703d37` / 宿主 `cb060460f` 的 [CI 41400817947](https://github.com/Lithe-IDEA/lithe-plugin-docs/actions/runs/41400817947) +中 Node、Java、Rust 和显式宿主六项 E2E 全部通过;整体仍因 preview 在验证期间新增 +README/Schema 而漂移失败。记录保留,更新 main 后重新核对,不关闭漂移门禁。 +Maven 的 [固定组合 Java 8 CI 41400819604](https://github.com/Lithe-IDEA/lithe-plugin-maven/actions/runs/41400819604) +继续按其实际结果登记,不以本地 JDK 21 代替。 + +### 当前取消、工具绑定与三仓库 CI 验收(2026-11-02) + +宿主 `e3fea58db` 的 [Windows CI 41401417746](https://github.com/1lck/Lithe-IDEA/actions/runs/41401417746) +全部通过,实际覆盖窗口取消和本次工具绑定:Project 100 项通过/1 条件跳过,host 394 项 +通过/3 跳过;原生序列化回归也已运行。共享 Core、前端和真实 JDT 同时通过。这轮不 +覆盖下面安装依赖用途修正,后续修正必须有自己的 Windows 结果。 + +SDK `192efd4` 已整合公开 main 的函数协议 3、0.2.0 源码版本及实际合并的 +`preview@04b1fb848` 契约镜像,保留未发布的 Build 7~11。其 [CI 41401704342](https://github.com/Lithe-IDEA/lithe-plugin-docs/actions/runs/41401704342) +和固定宿主 `e3fea58db` 的 [CI 41402002857](https://github.com/Lithe-IDEA/lithe-plugin-docs/actions/runs/41402002857) +全部通过,包含 Node 20、Java 8、Rust 1.87 及六项真实 E2E;本地为 Node 81、Java 61、 +Rust 37 加 1 文档用例。没有将本分支契约提前复制到公开镜像。 + +Maven `9193bc8` 仅将开发 CI 的 SDK JAR 选择改为 clean 构建后唯一普通工件,拒绝 +缺失、歧义和符号链接,适配 SDK 已合并的 0.2.0 源码版本;不改正式锁定或后端业务。 +固定上述 SDK/宿主的 [Java 8 CI 41402006715](https://github.com/Lithe-IDEA/lithe-plugin-maven/actions/runs/41402006715) +在 runner Maven 与 Maven 3.6.3 全部通过,runner 还包含真实 Core、Windows TypeScript +及 Java 17 框架调试。此前 [41400819604](https://github.com/Lithe-IDEA/lithe-plugin-maven/actions/runs/41400819604) +也已通过。新的实际 SDK 0.2.0 开发包在本机通过 Core→Maven 20 步和 Windows +TypeScript→Core→Maven 11 步,包字节不变且后端已清理。三个 PR 继续 Draft。 + +### 工具安装依赖与直接启动用途修正(2026-11-02) + +真实 macOS SDK 0.2.0 开发包流程发现直接模板检查误拒绝运行时依赖:JDK 定义可以 +声明启动 entry JAR,而 Maven 只消费其安装目录、版本和环境。两端现在显式区分用途, +安装依赖不实例化 entry 模板,主程序直接执行继续拒绝当前无法完整传递的模板。 +Windows 不把非直接模板的依赖发布为 `resolvedExecutables`,避免间接丢弃声明参数。 +版本下限、输入身份、项目代码授权和取消仍按原规则校验,没有增加工具名分支或资源目录。 + +Project crate 100 项逐项计时通过,新增通用工具验证安装依赖只执行版本探测并消费 +声明导出;原有直接模板拒绝仍通过。macOS 50 项行为断言通过,但双 reactor 用例两轮 +分别为 15.267/16.172 秒,超出原 15 秒门槛,失败报告保留。保存原生来源和切换来源改为 +独立工作流测试,各自保留两个 reactor、回读、隔离和进程清理断言。保存使用生产方法 +返回的 Provider 回读,不额外重复 RPC;未修改根按已知夹具内容和实际来源/生效值核对。 +最终两项分别为 9.968/5.379 秒,在原门槛内通过;之前拆分过程中的超时报告也保留, +不把多轮计时拼成一轮全绿。报告为 `stage-d-installation-use-macos`、 +`stage-d-installation-use-macos-workflows`、`stage-d-installation-use-reactor-bounded` 和 +`stage-d-installation-use-native` 的 HTML/JSON/JUnit。安装包清单和后端清理均通过。 +Windows host 生产/测试编译在 macOS 仍仅两处已有 Windows API 错误,不算原生执行通过。 + +修正提交 `dc041fb9e` 的 [Windows CI 41406039323](https://github.com/1lck/Lithe-IDEA/actions/runs/41406039323) +已全部通过:Project 101 项通过/1 跳过、host 394 项通过/3 跳过,包含安装依赖、直接 +模板拒绝和绑定过滤;共享 Core、前端与真实 JDT 同时通过。报告已下载到本工作树的 +`.artifacts/test-stability/ci-41406039323/`,不把后续 Agent 接线算入这轮。 + +### Windows Agent 的通用 Build 消费者(2026-11-02) + +接通已实现的 Build inspect/reload/execute:复用现有工作区 controller、领域模型、 +原生工具准备及 Provider 所有者。只读检测不启动工具,未准入包不进入清单;Agent +不从旧 Maven store 补实例。工作区、插件和 MCP 授权失效后拒绝迟到调用;已接受的 +操作继续由 IDE 持有,工作区关闭等待取消和清理。 + +任务选择同时核对声明、项目、目标和模型模板,领域层从当前模型二次选取目标,再交 +Core 校验,不按目标显示名或 Maven goal 猜测。argv 保持数组;操作摘要、输出和停止 +使用同一有界记录及精确 UUID,不复制日志历史或创建第二个后端。Windows 暂不暴露 +尚未实现存储工作流的通用 Build configure,旧 Maven/Java 配置及消费者仍须迁移。 +原生插件能力表继续关闭,不能把 API 接通当成完整 Maven 包准入。 + +本机前端最终 457 项逐项计时通过,涵盖 Agent、Build 与 Run 的受影响范围;Rust +broker 14 项通过。TypeScript 和生产 bundle 通过,真实 MCP stdio 的三个协议会话 +均验证 Windows 平台清单,计时为 1.028 秒/34 毫秒/24 毫秒;配置写入不暴露,调用 +仍经过 broker 权限。报告为 `stage-d-agent-build-frontend-final`、 +`stage-d-agent-build-ide-host` 和 `stage-d-agent-build-mcp-stdio` 的 HTML/JSON/JUnit。 +共享契约、服务/Windows 边界、bundle 只读、Notes、矩阵与测试稳定性检查通过。 +此批没有改 Core Build 协议或下载/状态目录, +没有代替完整 Windows 产品工作流或阶段 D 的其他退出条件。 + +该批提交 `8a46260619aef72584e451cfe630a47b49c5e2e4` 的 +[Windows CI 41410144912](https://github.com/1lck/Lithe-IDEA/actions/runs/41410144912) +已全部通过:Project 101 项通过/1 跳过、host 394 项通过/3 跳过、broker 14 项通过, +共享 Core 与真实 JDT 各 1585 项通过;Build、Run、MCP 生命周期等前端车道通过。 +原始计时产物在 `.artifacts/test-stability/ci-41410144912/`,不包含下述后续 UI 修改。 + +Maven `19b6b0103e9bc8c569a3987a09ccd57b69cef966` 的 +[CI 41411645747](https://github.com/Lithe-IDEA/lithe-plugin-maven/actions/runs/41411645747) +固定上述宿主与 SDK `192efd469e9e5fb94ad328a1c7b76ba1cc3252aa`,两条 Java 8/Maven +兼容车道通过。真实 Windows TypeScript→Core→Provider 集成现为 13 步,新增 Agent +inspect/reload/execute 复用同一模型、任务记录及后端;还验证 Java 17 调试规划和插件 +包文件不变。这里的 Windows TypeScript 集成运行于 Linux CI,不等于 Windows 产品 +实机验收,也不产生正式 SDK 锁定值。 + +### Provider Run 参数编辑与原生保存(2026-11-02) + +两端 Run 编辑器直接编辑通用运行时参数数组与入口,避免 Windows 仍显示实际准备 +不会消费的旧 JVM 文本字段。每行保留一个参数及其空白、引号、空值和换行;增加、 +删除或修改入口保留按次 Build 选项与工具选择。较长列表只挂载视口行;开页不新增 +覆盖,保存沿用原对话框及原生存储。macOS Reset 为有效的按 Run 工具发出显式清空, +避免底层兼容合并重新保留它们。 + +本机 macOS 122 项原生集成、Windows 受影响前端 464 项计时通过;新增对话框测试 +从实际保存按钮取得完整 Provider 选项,原生桥接用共享 argv fixture 验证保存、回读 +和重置。TypeScript、生产 bundle、35 项 i18n 测试和生成一致性通过;报告为 +`stage-d-provider-editor-macos-final`、`stage-d-provider-editor-frontend-final`。 +这批没有新增公共 API、资源下载或缓存,不改写工具原生配置;完整 GUI 验收、旧默认 +JDK 联动、Windows 原生 Debug/产品准入和旧消费者替换仍是待办。 + +### 通用 Build 归属下的 Debug 源码定位(2026-11-02) + +macOS Debug 的源码兜底定位现在优先使用 `buildProject.root`,把模块相对路径拼到 +当前 Provider 实例。旧实现只看 Maven reactor 字段,通用嵌套实例会错误定位到工作区 +根的同名模块;找不到源码时还可能选择其他实例的同名主类。现在当前归属缺少源码就 +返回不可用,残留旧字段不能覆盖通用归属。精确源码路径和 Current File 的原有行为 +保持,Windows 已有 Java 启动要求精确源码,不复用这条 macOS 定位器。 + +本机原生集成 123 项逐项计时通过,新增回归覆盖两种非 Maven 提供者身份、嵌套实例、 +工作区根实例和源码缺失。报告为 `stage-d-provider-debug-root-macos`;服务边界、 +测试稳定性、Notes、矩阵和 bundle 只读检查通过。没有改公共字段、Core 或 Windows +实现,不把这项源码选择回归当成完整 Registry/DAP 或 GUI 验收。 + +### 原生编辑器重置的两层继承复验(2026-11-02) + +补充了团队层提供两个工具、本机只改其中一个的原生存储场景:先从 Core 解析实际 +编辑草稿,再通过同一个 Reset 保存;准备输入没有残留工具,重开仍是显式清空, +团队配置文件字节保持不变。参数按钮同时复用现有已打包 IDEA 图标,没有新增资源。 +当前 macOS 124 项计时通过,报告为 `stage-d-provider-run-inherited-reset-final`。 +新增用例首轮因误写 Swift 枚举名而在编译阶段失败、未执行测试,修正后全部通过; +失败日志与零用例报告保留,没有调整预算或断言。 + +### 本轮 Windows 原生 CI 结束(2026-11-02) + +[Windows CI 41414153616](https://github.com/1lck/Lithe-IDEA/actions/runs/41414153616) +固定 `3a46b1b83ff5af707e26aca82e696a1cd63f306f`,五项作业全部通过。Run 前端 +364 项包含新增四项编辑/保存回归,共享 Core 与真实 JDT 各 1585 项通过,原生 +Project/host 和 broker 车道通过。原始报告下载在 +`.artifacts/test-stability/ci-41414153616/`;后续提交只改 macOS、矩阵和文档, +Windows/Core/共享契约与该 CI 提交相同,macOS 另由上述 124 项报告验证。 +这仍不代替 Windows 产品包、完整 GUI 或正式 SDK/插件发行验收,阶段 D 不关闭。 + +### Windows Build 选项保存与 Agent 配置(2026-11-02) + +已经接通此前缺失的本机 Build 选项流程:Core 统一检查团队/本机合并和声明式编辑, +Windows 平台在 app-data 保存独立工作区覆盖,使用已有文档 adapter 的条件原子替换。 +Build 面板与 Agent configure 共用 controller;开页只读、隐藏保留草稿,显式保存、 +取消和恢复继承有独立动作。实例覆盖、兄弟多选保留、明确空选择、无关系统/根/记忆 +选项保留、两层冲突及关闭/插件撤销均有回归;团队原文件不被本机编辑改写。 + +新增两个纯 Core 操作和共享 fixture,没有新增插件 RPC 或 Maven 语义;Windows +`lithe_build_configure` 清单、权限校验和实际调用已同步。产品能力表继续关闭,旧 +Maven/Java 消费者尚未完成切换。资源复用清单和拒绝路由登记本机可变状态;它不进入 +app/plugin 安装目录,也不跨 worktree 共享。 + +当前本机 Core 全量 1623 项、原生 Project 104 项、受影响前端 520 项计时通过; +TypeScript 与生产 bundle 通过,Windows host 检查仍仅两项既有 macOS Windows API +错误。报告为 `stage-d-build-options-core-full`、`stage-d-build-local-native` 和 +`stage-d-build-options-frontend-final-v2`;原生存储最终报告为 `stage-d-build-local-native-final`,Core 最终报告为 `stage-d-build-options-core-final`(包含继承状态重置不创建本机文件)。后续原生 Windows CI 结果单独登记, +尚不声称完整产品验收。同步整合最新 preview `8b1d66693` 的公开终端协议与实现; +SDK 分支整合其 main 原生终端增量,公开镜像仍来自已合并提交,未发布新版本。 + +本批已补完 14 项 broker 和三个真实 MCP stdio 会话,Windows 清单包含 configure, +缺少配置权限仍拒绝;计时为 795/9/10 毫秒,报告前缀 `stage-d-build-options-broker-final` +及 `stage-d-build-options-mcp-stdio`。首轮仍断言旧清单不支持 configure,已同步 fixture +和测试并保留失败记录。最新 preview 合并后的 macOS 原生回归 130 项通过,报告 +`stage-d-build-options-macos-merged`;共享/服务/Windows 边界、bundle 只读、Notes、矩阵、 +i18n、测试稳定性和资源拒绝复用回归均通过。 + +SDK `3596a4d` 整合公开 main 的三种 SDK 终端 API,公开镜像固定已合并的 `f64d262c48f1ff5ad1778b301138596493a09ad5`,77 文件校验通过;未手改镜像、未发行。 +当前本地 Node/工具脚本 97 项、Java 62 项、Rust 38 项及一个文档用例通过,真实 +Node/Java 原生 Build/Run/配置/报告与三种 SDK 函数/宿主服务共六项 E2E 通过。 +这些开发验证不替代正式发行地址与锁定值,也不把完整产品验收标成完成。 + +### D-MIGRATION-REMOVAL:旧 Maven 数据迁移器清理 + +这是 #1495 下独立跟踪的删除项,状态为未完成。`MacLegacyMavenConfigurationMigration` +只允许由 `MacBuildOptionsStore` 的一次性切换入口调用。新增的 +`MacLegacyMavenRunConfigurationMigration` 只允许由 `MacRunConfigurationStore.generateCurrent` +调用,负责已有 Run v1/v2 与可确认旧偏好的格式转换;不得把它用于正常配置读取、工具选择或运行准备。 +旧输入必须在目标写入并回读后才清理;完整转换及所有旧来源的删除仍未完成。 + +删除窗口固定为两个稳定版本:把首个包含迁移能力的稳定版记作 N,迁移器只保留在 +N 和紧随其后的 N+1,**N+2 发布前删除**两种迁移器、各自唯一调用入口、旧格式测试和迁移 +回执。N 尚未发布,所以当前不编造实际版本号;N 发布时在本项登记对应版本和 +N+2 目标版本。兼容窗口关闭须在该稳定版发布说明交代,跳过窗口的旧格式用户使用 +明确的一次性导入指引,不把迁移器重新接回正常运行路径。 + +删除前覆盖跨版本升级、目标冲突、取消及失败后重试,并确认全仓无生产调用。 +本项完成前,不把临时迁移器计入永久通用 API。 + ## 任务一:完成宿主、契约、SDK 与 Maven 插件的合并发布闭环 **目标:** 用户能从正常插件渠道安装一组明确兼容、校验通过的最新版本,不依赖本地 SDK JAR。 **范围与顺序:** -1. 完成 #1517 的评审和本地/CI 验证,合入 preview;确认原生 Provider 与只读模型共享的最终契约。 +1. #1517 已于 2026-10-29 核实合入 preview(`659739011`);以合并提交核对最终契约,阶段 D 增量仍需各自验证。 2. 在 `lithe-plugin-docs` 用合并后的 preview commit 运行 `node scripts/contract.mjs sync --revision `;不手改镜像或伪造锁定值。 3. 已合并的 Node/Java SDK 实现按最终契约重新验证,在锁定 Core 与当前 preview 上运行真实端到端场景;补齐 Node 20、Java 8 运行证据后,按其发布流程发布实际的新版本。 4. Maven 插件将 `sdk.lock.json` 和构建依赖更新到实际发布的版本、URL、SHA-256;用正式 `package.mjs` 验证,开发构建覆盖不能进入发行流程。 @@ -32,7 +747,7 @@ | 配置快照与跨插件协作 | 协议 6 的只读模型输入公开选定事实,配置/源码/模型/插件/工作区变化撤销旧输入;两个真实 SDK 后端经过 Core 交换公开投影。不能据此声称 Java 已消费 Maven 全部配置。 | | 最低 SDK 运行时 | 历史[显式宿主 CI](https://github.com/Lithe-IDEA/lithe-plugin-docs/actions/runs/40531099687) 的 Node 20、Java 8 原生端到端作业通过,宿主固定为 `58ca81cf66c5c6e281c7df73ac67adb3526174f3`。该轮契约漂移检查失败;SDK #4 后续已合并且 main CI 通过,镜像仍需在 #1517 合并后按最终宿主提交同步。 | | Maven 最低运行时 | [固定 SDK 提交的开发验证](https://github.com/Lithe-IDEA/lithe-plugin-maven/actions/runs/40533306865) 已通过 Java 8 后端及当前/3.6.3 Maven,每组 55 项计时检查(含 3 项打包);不能代替旧 SDK 锁定下仍失败的正式打包检查。 | -| 正式合并、契约同步与发行 | SDK #4 已合并;宿主 #1517 保持 Ready,Maven #7 因正式 SDK 锁定依赖继续保持 Draft。最终契约同步与新版发行尚未完成。必须依次合并宿主、用最终 preview commit 同步契约、发布 SDK,再更新 Maven 的真实版本/URL/SHA-256 和完成正式包验收;不能先填预期校验值。 | +| 正式合并、契约同步与发行 | SDK #4 已合并;宿主 #1517 已于 2026-10-29 核实合并(preview `659739011`),Maven #7 因正式 SDK 锁定依赖继续保持 Draft。最终契约同步与新版发行尚未完成。使用最终 preview commit 同步契约、发布 SDK,再更新 Maven 的真实版本/URL/SHA-256 和完成正式包验收;不能先填预期校验值。 | 阶段 C 不能仅凭接口存在或本地开发包通过就勾选完成。下面的任务二至四不阻塞通用接口继续评审,但不会代替上表中尚未完成的验收与发行条件。 @@ -51,6 +766,33 @@ **退出条件:** 多模块工程从打开、配置读写、同步、构建,到 UI/Agent 触发相关操作使用一致配置;重开、失败、取消、配置 ABA 与工作区切换可验收;旧 macOS Maven 服务、配置副本和 fallback 已删除。共享命令的现有 Windows 调用也完成切换,迁移证据和删除清单可逐项对照。 +### 阶段 D 的实施记录(尚未完成) + +- 工作分支 `codex/issue-1495-maven-migration` 从 `upstream/preview@0beb43c936f61e68b983533805d6534a01fdf8b4` 建立,整合 #1517 的 `9f702e15deeeed2e9889d396fab50c706ee48c0a` 作为阶段 C 源码依赖;随后整合已合并 #1517 的最新 preview `f0957b7f0`,保留其后续修正。 +- 首个完整流程为 Agent 查询插件 Build 实例/任务 → Provider 执行 → 输出/退出查询 → 按 ID 停止。新入口复用 Build 服务;同步删除旧 Maven 任务执行 API、进程和状态机。macOS 旧配置与项目加载服务随后已删除;剩余启动规划和平台/发行验收仍阻止阶段 D 完成;精确测试已切入同一 Provider,并通过当前本机组合验证。 +- SDK #4 已于 2026-10-28 合并;阶段 D 配套增量为 [SDK Draft #5](https://github.com/Lithe-IDEA/lithe-plugin-docs/pull/5) 与叠在 #7 上的 [Maven Draft #8](https://github.com/Lithe-IDEA/lithe-plugin-maven/pull/8)。它们尚未发行,不修改任务一的正式发布门槛。 + +当前删除/迁移账本(只记录本分支新增证据,不复用阶段 C 的通过数): + +| 流程 | 本分支状态 | 仍须处理 | +| --- | --- | --- | +| Agent 构建 → 输出/状态 → 停止 | 已实现并验证;`MavenService` 任务进程/API/状态机已删除;原生回归 159 项/6 组通过,真实 Agent + Maven 模块编译 4.787 秒通过 | Draft PR;不代替整阶段配置/加载/平台验收 | +| Maven 配置和项目运行设置 | 项目默认和按根工具路径已切 Run local.json;Build 选项按实例保留团队默认及本机差异;协议 7 原生 XML 读取、确认保存、冲突保护和回读已经接通通用插件设置页及真实 Maven。旧设置表单 XML 读取与配置存储已删除;迁移器已接官方插件被动检测,验证后清理源并重新检测,315 项原生回归通过 | 正式包与完整 UI 验收 | +| 项目加载、标签/搜索模块事实、运行配置 | 已删除 MavenService/FeatureModel 和 POM 专属加载;Run 工程路径、源码根、候选来自当前 Provider 模型,无 MavenProject 注入 | Java 入口生成的模块/reactor/源码角色已改为当前 Provider 归属,损坏 POM 共享 fixture、独立 reactor 同名类及真实 Swift bridge 通过;框架识别已改用 Maven 生效插件模型(含 Profile/继承),保留既有服务 ID;编译要求已改用 Maven 实际 execution/参数模型;Wrapper 元数据也已切入 Provider;协议 10 已接框架/模块 Provider 准备及原生 Run/Start All;Java 入口继续复用 JDT。剩余 Core/Windows 旧规划和框架 Debug 边界仍须核对 | +| 依赖树与依赖导航 | 已删除旧 macOS 依赖进程、Swift 文本树桥、临时 adapter 与坐标路径 fallback;Build 用 Provider,导航用 JDT;245 项受影响原生回归通过,真实 Agent 构建及 Provider 编译/精确测试 2 项通过 | 完整 UI/JDT 实机流程、Windows 现存共享命令仍待迁移 | +| Java 导入、运行/测试/调试准备 | Java 导入和 Run Maven 上下文已停用旧服务回退,按 reactor 根读取同一代 Build 输入;315 项受影响原生测试通过;Debug 已等待 Build 输入并检查 ABA 代次;旧 Maven 工程模型已从生产加载删除 | Java 主程序/Debug 的资源准备已接公开 Provider 项目任务(主资源/主加测试资源),Core 仅给未携带当前回执的旧消费者保留资源规划;协议 10 已接框架/模块运行准备、保存引用、Agent 操作身份和 Stop;框架 Debug 的旧 Core 路径仍须核对。协议 9 的 JDT 原样标识、报告精确重跑及工程隔离通过 350 项 macOS 回归、1368 项 Core/集成计时和 Maven 实测;删除宿主 Maven CLI/报告 adapter,测试 Debug 准备补配置 ABA;完整 Java/JDT 后端迁移留在阶段 E | +| Maven 执行中的测试报告 | 协议 8 随操作返回规范报告,Maven 后端用实际配置的 Surefire/Failsafe Mojo 目录和 Apache parser;删除插件内旧固定目录测试选择报告读取器;真实 Core/macOS/Maven 和两个 SDK 通过,Maven 3.6.3 兼容通过 | Java 精确测试宿主路径已切换并删除旧 adapter;Windows 旧共享命令仍未迁移;[固定提交 Java 8 CI](https://github.com/Lithe-IDEA/lithe-plugin-maven/actions/runs/40792694290) 在两种 Maven 各 72 项通过;正式包验证仍依赖 SDK 发行 | +| 共享 Maven 命令 | 未删除 | Windows 当前仍直接使用 scanner/launch/dependency/report 命令;接替当前消费者后才能删 Core 命令/fixture | +| 发行包和双平台验收 | 未完成 | 最新正式 SDK/插件组合、macOS 完整 UI、Windows 实机、安装目录字节不变与进程清理 | + +本轮新增验证(2026-10-29):两个真实 SDK 后端及旧兼容场景、Node 65/Java 47 单测、TypeScript 编译通过;Maven 3.9.16/JDK 26 的受控后端和真实 Core reactor 各 15 项通过,涵盖 Unicode、参数化、精确包名、依赖模块跳过、失败报告与包字节不变。协议 9 的[固定 SDK/Core Java 8 CI](https://github.com/Lithe-IDEA/lithe-plugin-maven/actions/runs/40824700153) 已通过当前和 3.6.3 Maven;资源准备增量的[固定提交 Java 8 CI](https://github.com/Lithe-IDEA/lithe-plugin-maven/actions/runs/40859505864) 也已通过当前和 3.6.3 Maven,包括真实 Core;正式 SDK 锁定与发行门槛仍未完成。 + +已核对 #818 和 #1493:安装工具/JDK 的项目选择仍是合法的 Lithe 覆盖; +不能用“原生配置”名义改写系统环境或安装目录。原生 Maven XML 的读取与受保护编辑 +归插件后端,UI 必须分别表达原生来源值、最终生效值和 Lithe 项目覆盖;不能在打开 +页面时把发现的默认值写成覆盖。旧 UserDefaults、旧 Maven 本机文件的清理只能在 +目标实际写入并读回验证后进行。 + ## 任务三:完成 Java/JDT 领域后端及 Run/Test/Debug/框架迁移 **依赖:** 任务二的 Maven 配置所有权;复用 [#1210](https://github.com/1lck/Lithe-IDEA/issues/1210) 的 DebugAdapter Registry 与当前通用 LSP/DAP,不另建调试器或项目引擎。 @@ -83,3 +825,248 @@ ## 执行纪律 默认顺序为任务一→任务二→任务三→任务四。每项按完整用户流程组织里程碑与 PR,报告新增行为、删除范围、实测证据和未验证平台;PR 默认 Draft,满足本地与依赖门禁后再 ready。不要提前发布版本、放松校验或把待实施计划写成已完成。 + +资源准备增量(2026-10-29):真实 Maven 受控后端及 Core reactor 各 16 项通过,涵盖自定义资源根、过滤、Profile、主/测试资源区分、拒绝附加目标。合并 preview `35b84dad4` 后原生计时 344 项通过;Run 原始点击到保存、Build 和 JDT 等待由现有协调器持有,新请求替换、Stop 与工作区重置都能撤销准备。Windows `cargo check --bin lithe-windows` 与 preview 基线同为 `project_window_registry.rs` 的两项 macOS 平台错误,未出现新增 Rust 类型调用错误;不代表 Windows 实机通过。 + +真实 Java 协作增量(2026-10-29):新增显式工具集成,将 Maven Provider 资源任务、JDT 项目构建/运行路径和真实 JVM 串联;嵌套 reactor、依赖模块、非标准主/测试源码根以及导入后修改的过滤资源均验证真实输出,关闭会话后比较插件包与 JDT 安装目录 SHA-256。宿主源码角色选择限制在当前工程,并统一比较链接工作区与 JDT 的规范路径。新增后 Core/集成 1395 项、Swift 130 项计时通过;新增真实链路 35.983 秒,链接路径与角色/撤销回归分别为 3/15 毫秒。此处直接提供同一份 Profile 导入输入,尚不能证明每个 Run 专属 Profile 都已同步进 JDT;运行配置生成、框架/模块规划、Windows Provider 消费者及正式发行仍是阶段 D 的剩余项。 + +Run Profile/JDT 一致性增量(2026-10-30):生产宿主在单次资源准备操作内按临时选项/工具读取 Provider 模型,使用同一模型的源码角色选择资源任务,并返回原样 languageImport;Run/Debug 将这份输入用于 JDT 冷启动/热更新,默认发现恢复当前 Build 输入。配置变化/ABA/会话替换撤销旧发现、构建、运行路径和用户错误决策。Core 的入口发现等待 Profile 和项目配置屏障,队列、期限、取消和关闭均有界;异步语义命令取消只影响本操作。真实 JDT 从无入口源码根热切换后首次查询得到两个入口、实际 JVM 读取新资源通过;315 项 Swift 计时通过(参数组汇总 318)。这项输入传递已接通,但整个 Run 生成与框架/模块规划、Windows 消费者和正式发行门槛仍未完成。 + +2026-10-30 补充验证:Core/集成逐项计时 1398 项全部通过,真实 Profile 热切换→JDT→JVM 场景 14.249 秒;生产 macOS BuildSystemService 与真实 Maven Provider 验证临时 Profile 改变源码角色,宿主确实选择测试资源任务,返回同一导入值,默认模型/选项不变、进程释放且插件包 SHA-256 不变,2.763 秒通过。 + +运行生成归属增量(2026-10-30):macOS 等待当前 Build 模型/导入,将规范项目路径、reactor 根和源码角色投影给 Core。Java 归属不再由祖先 POM、旧 modulePaths 或目录名称补齐;两个独立 reactor 都有 app/Main 时保留两项配置,单 reactor 的旧 ID 保持。明确空模型不会恢复旧推断,过期/未准备/截断模型不能用于生成。Windows 省略新输入保持兼容。共享损坏 POM fixture 覆盖同名类、源码角色、空模型和非法边界。Maven 框架检测、部分工具链推断和旧启动规划仍待迁移,阶段 D 未完成。 + +归属生成最新验证:1399 项 Core/集成、285 项 Swift 计时通过,新的 Core 共享 fixture 用例 17 毫秒;当前工作树源码的 SDK E2E 6 项通过。Windows Cargo 检查与 preview 基线同为两项 macOS 平台错误,无新增公开类型调用错误;不代表 Windows 原生通过。 + +生成归属补充验收:生产 macOS Provider 集成 4 项全部通过,临时 Profile 的当前模型/归属/导入一致、双 reactor 隔离、真实配置读回和构建测试均覆盖;最慢 5.252 秒。全 Rust crates 与真实 bridge 检查通过。该记录不替代完整 UI 和 Windows 原生验收。 + +框架识别补充验收(2026-10-30):Maven 生效模型发布框架选择和模型版本;宿主拒绝 +旧插件元数据,Core 关闭 POM 框架检测,保留同 reactor 既有 ID、重名模块和独立 +reactor。1399 项普通 Core 计时、286 项 Swift、4 项生产 Provider 集成及 17 项 +真实 Core→Maven 集成通过,Profile/继承/仅插件管理与损坏 POM 场景 2.127 秒。 +这轮未重跑真实 JDT;既有专用集成记录保留。旧启动规划、工具链推断及发行/双平台 +完整验收仍未完成。 + +编译要求补充验收(2026-10-30):Provider 通过 Maven 自己的编译执行计划与参数 +解析器发布 Java 版本下限,覆盖 Profile/继承、测试版本、自定义 execution、缺失 +表达式和非法值;Core 停用此路径的 POM 正则,本机版本偏好不能降低已知下限。 +1399 项含真实 JDT 的 Core/集成、286 项 Swift、4 项生产 Provider、18 项真实 +Core→Maven 和 Maven 常规集成通过,包清单不变。上一轮固定组合 CI 暴露旧模型 +全等断言漏列新增字段,已更新断言并在本地完整常规集成通过;修正后的 [最低运行时 CI](https://github.com/Lithe-IDEA/lithe-plugin-maven/actions/runs/40983512456) 已在 Java 8、当前 Maven 和 3.6.3 通过(Core b70929c、SDK 19fbda5、Maven 072960c)。Wrapper 来源也已迁入后端;旧启动规划、Windows 及正式发行仍待完成。 + +生成结果的模型身份已接通:持久层记录当前工程投影摘要,列表及启动比较当前 +Provider 输入;文件指纹、语义摘要与异步操作代次分别处理文件变化、配置变化和 +ABA 迟到结果。289 项 Swift、4 项生产 macOS Provider 与 1399 项普通 Core +计时通过,另显式执行 3 项真实 JDT。POM 未变的默认 Profile A→B→A 使生成 +过期/恢复,7.496 秒;后台检查串行且只留一次最新待办,100 次通知不会积压。 +主 Run、服务单项/批量、Agent 共用入口和 Debug 准备拒绝过期事实,视图复用既有 +警告与重新识别动作。旧框架/模块启动规划、Windows Provider/旧消费者和正式 +发行/完整平台验收仍未完成。 + +Wrapper 补充验收(2026-10-30):原生 properties/脚本元数据、脚本/配置缓存撤销、 +宿主同步观察、单根相对路径与独立 reactor 隔离均接通。指纹对脚本和其他文件使用 +有单文件 64 MiB 上限的流式哈希。1399 项含真实 JDT 的 Core、286 项 Swift、 +4 项生产 Provider、19 项真实 Core→Maven、17 项缓存、16 项受控原生与 16 项 +常规 Maven 检查通过;最终开发包再次通过 3 项真实 JDT(16.199/8.056/6.940 秒, +含 Cargo 启动),关闭后包/JDT 清单不变且无进程残留。 + +Wrapper 固定提交组合的 [Java 8 / 两种 Maven CI](https://github.com/Lithe-IDEA/lithe-plugin-maven/actions/runs/40990047639) 已通过(Core 25ef13d、SDK afcc133、Maven 00208cd);后续生成身份增量不沿用此提交证据。 + +生成身份收尾:当前 SDK 源码 6 项 E2E、完整 Rust crates、Core/契约边界和最终 +24 项 Swift bridge 通过。关闭图时先撤销订阅,避免停止 Provider 的通知排入新 +检查;后续 4 项生命周期复验通过。首次早期 AskPass 触发 5 秒连接期限,串行 +复验在相同期限内 4.359 秒通过;未放宽超时。 + + +运行选项和框架启动增量(2026-10-30):协议 10 传递执行类型、独立参数数组、环境、 +入口和显式 cwd。Maven 后端用真实生效模型校验目标,使用同一 reactor 的 Maven +执行计划/执行器编译依赖并只启动所选模块,不再让宿主生成框架目标 argv。 +原生 Run/Start All 的服务准备彼此独立,Stop、重置和晚到进程回调按操作身份隔离; +Build 树按服务/任务声明发送类型化选项。插件停用/包更新同时撤销准备和运行进程。 +macOS 旧 scan/launch/diagnostics/report Swift 桥和旧工程模型类型已删除。 +Windows 共享 Maven 命令、Core 旧规划及完整 UI/正式发行验收仍未完成,不能关闭阶段 D。 +真实 Core→Maven 的 20 项集成再次通过,包括未安装兄弟模块、Spring Boot 精确 argv、 +工作目录和插件包清单不变;当前增量的原生和 SDK 验证结果以本次报告为准。 + + +本增量收尾验证(2026-10-30):Swift 6.3.3 下 345 项测试通过(344 条计时记录), +其中包含 Provider 服务的 Agent 启动→准备→输出→Stop 身份回归;Core 1403 条逐项 +计时通过。生产 macOS Provider 四项显式场景通过,新增 Profile/保存任务准备场景 +11.567 秒,另一次真实构建/测试为 6.753 秒;Node SDK 24 项、Java SDK 48 项和 +SDK 真实进程 E2E 六项通过。Core→Maven 20 项通过,包括实际 Spring Boot 启动和 +插件包字节不变。报告分别在 `.artifacts/test-stability/stage-d-framework-final-swift.html`、 +`stage-d-framework-final-core.html`、`stage-d-framework-native-swift.html` 和 +`stage-d-framework-native-workflow.html`;它们属于当前开发组合,不是正式发行证明。 +保存后的 service/task 分类和参数通过原子写回/读回,重新启动仍按当前插件声明 +与模型校验;没有为迁移增加包内可写状态或可跨工作树共享的缓存。 + + +整合最新 preview `f3ee7114f`:保留 #1580 的进程内检测层、缓存和自动识别行为, +Provider 模型摘要、工具要求和类型化准备改从同一检测层读取。合并前固定提交组合 +Core `1e66735` / SDK `af13e3c` / Maven `b426f42` 的 +[Java 8 + 当前 Maven/3.6.3 CI](https://github.com/Lithe-IDEA/lithe-plugin-maven/actions/runs/41017667643) +已通过;合并后的验证另行记录,不能复用这项通过数。 + + +最新基线整合验证:346 项 Swift 回归通过(345 条计时记录),Core 两轮各 1414 条 +计时通过,其中一轮显式开启三项真实 JDT/JVM(43.535/14.328/10.980 秒)。五项 +生产 macOS Provider 场景均通过并低于原有单项预算;Profile 和已保存任务各自拥有 +独立模型加载/清理,不能依赖另一场景先行准备。两个同名构建系统插件的进程按完整 +包身份撤销,保留未停用插件的会话。SDK E2E 六项、真实 Core→Maven 20 项、资源复用 +30 项、i18n 35 项以及 C ABI/链接/Git 25 项 Swift bridge 通过。Windows Cargo 检查 +仍只出现 preview 基线的 project_window_registry.rs 两项 macOS 平台 API 错误, +没有新类型/签名调用错误;这不代表 Windows 原生验收。 +报告前缀为 `.artifacts/test-stability/stage-d-preview-*`,保留完整 HTML/JUnit。 + + +团队文档和 Windows 原生窗口接线(2026-10-31):`build.projectOptions` 统一存储语法校验, +macOS/Windows 复用共享 fixture;`build.detect` 直接返回按作用域与实例根合并的保存选项, +保留空选择。Core 1,436 项、macOS 144 项计时通过。Windows 工作区所有者已接入项目 +关闭/替换及插件停用确认,目录刷新不丢失历史目标,失败清理保留所有者以供重试。通用 +Build 窗口复用现有侧栏/底部输出、模型任务虚拟列表;受控点击覆盖加载、执行、Stop、 +隐藏后恢复和迟到输出隔离。原生项目库 49 项中 48 项通过,唯一 POM watcher 超时 +已在改动前基线复现,新增团队文档与原生配置测试均通过;不能声称该 crate 全绿。 +报告前缀为 `.artifacts/test-stability/stage-d-build-document-*` 与 `stage-d-windows-build-*`。 +Windows 配置 UI/迁移、声明任务/依赖/报告呈现、文件观察、Run/Java/Agent 与实机验收 +仍未完成;不开放 Build 能力,不删除仍有消费者的旧 Maven 命令,不据此关闭阶段 D。 + +此增量最终本地复验:Windows 前端 111 项及隔离插件生命周期 6 项全部通过, +`bun run build` 和 TypeScript 检查通过;Rust crates、C ABI、链接符号、Git/Swift +bridge 25 项通过。原生团队文档 4 项分别为 10/10/6/6 毫秒,完整项目库仍保留 +同一项基线 watcher 超时。Windows Cargo 仅有既有 `std::os::windows` 与 +`same_file::Handle::as_raw_handle` 两项 macOS API 错误;未进行 Windows 原生验收。 + + +Windows 原生配置设置接线(2026-10-31):插件设置详情根据已准入声明显示真实来源, +工作区持有草稿,隐藏/重开不丢修改。继承值预填、显式覆盖、取消、逐来源保存及 +保存后回读已接通;保存一个来源不丢另一个来源草稿,失败重载/回读后禁止用旧基线 +继续保存,外部冲突保留草稿。默认项目工具不运行,明确读取才继续;迟到文件选择 +不能覆盖重载后的状态。Windows CI 增加对应有界 Build 回归,仍不开放产品能力。 +旧数据迁移、其他 UI/Run/Java/Agent 和 Windows 实机/正式发行仍是剩余项。 + +本地计时:受影响前端 120 项、与新增 CI Build 车道完全相同的 59 项、隔离插件 +生命周期 6 项通过,含 9 项原生配置编辑器/控制器回归;类型检查与前端构建通过。 +共享词条 9681 个、i18n 35 项和相关边界通过。`ci-workflow-gates.test.mjs` 在本机 +系统 Bash 3.2 下有 21 项失败,隔离的 b43202184 改动前同样复现 21 项;最小 +`[[ false ]]`/`-e` 组合也不退出,与新 Build 测试登记无关,未改动门禁。 +报告为 `.artifacts/test-stability/stage-d-windows-native-settings-*`。 + +使用临时构建的 Bash 5.2.37 按 Linux CI 语义复验:113 项门禁、分类器全部用例 +及 19 项复用规划测试通过。临时工具链和源码均已清理,产品未新增下载或缓存目录。 +原先系统 Bash 3.2 的失败只记录为环境诊断,最终等价检查以上述现代 Bash 结果为准。 + + +Windows 任务与依赖呈现后续增量(2026-10-31):通用窗口按工作区/模块显示公共声明 +和模型任务,按分类排列,显式参数确认保留 argv 项边界,取消不执行。依赖只展示 +上游/Core 节点并虚拟化,不从坐标推导本机路径;最多四份内存快照,模型/配置失效 +和取消后丢弃迟到结果。设置保存导致的模型撤销现在有独立低频通知,旧任务立即消失, +输出进度不会重绘模型树或重复检查配置草稿。目标任务、报告/测试呈现、文件观察、 +Run/Java/Agent、旧数据迁移及完整 Windows/正式发行仍待完成;产品准入继续关闭。 + +任务/依赖增量最终计时:受影响前端 127 项、CI Build 同范围 66 项通过;生产前端 +构建、TypeScript、i18n 35 项/9697 词条、共享/服务/Windows 边界及 bundle 只读检查 +通过。新增回归验证模块/工作区选择、明确参数确认、分类顺序、依赖展开、四份缓存 +预算和模型失效即时呈现;没有新资源目录或持久状态。报告前缀为 +`.artifacts/test-stability/stage-d-windows-task-presentation-*`。 + +Windows 报告与文件失效后续增量(2026-10-31):使用 Core 的规范诊断、测试汇总、 +用例和发现选择器,展示失败详情并导航源码;保留精确工程/目标身份,不按测试名称 +猜测模块。报告和发现各保留最近四份快照,大报告虚拟化,进度通知不会复制旧报告。 +取消中的解码和普通完成后的迟到进度均不能重新发布结果。 + +模型加载前后检查根输入,保存 Core 的完整 watch 清单;每次执行前重新核对完整 +指纹。源码事件清除发现清单并取消首次模型/发现准备,命中 watch 撤销模型/依赖/ +报告。团队文档立即撤销旧目录并合并只读检测;路由覆盖全部已有工作区,不依赖 +当前文件树的异步目录查询。没有新增持久状态、资源目录或后台进程。 + +该增量仍未完成外部原生配置文件观察、目标 Run/Debug、Java/Agent、旧数据迁移与 +真实 Windows/正式发行验收;产品准入继续关闭,阶段 D 不关闭。 + +本地 149 项受影响前端、CI Build 同范围 81 项通过,含万级报告虚拟化、输出不复制 +报告、精确用例/源码跳转、源码与 watch 失效、首次准备取消、漏事件指纹检查和 +旧检测拒绝发布。TypeScript、生产 bundle、i18n 35 项/9711 词条、共享/服务/Windows +边界、172 Notes、222 矩阵、测试稳定性和 bundle 只读检查通过。该批未改 Rust/Swift, +未重算之前的原生通过数。报告前缀为 `stage-d-build-reports-watch-*`。 +新增行的 Windows 稳定性门禁通过;另行全量扫描报告四个未改文件的 22 项旧 sleep/ +timer,逐文件与本批前 HEAD 对照完全一致,未改动或放宽这些既有规则。 + +Windows 外部原生来源观察增量(2026-10-31):模型读取同时获取已声明的来源投影, +发布和执行前核对原生版本;只返回一致性和路径,不向 WebView 传 XML 或凭据。 +来源和符号链接目标复用窗口文档 watcher,保持编辑器租约;缺失父目录观察现存 +祖先,目录创建后更新观察,不创建 `.m2` 或用户文件。通知合并、迟到原生读取、 +失效与保存/回读分别持有;插件撤销等待来源检查清理完成。 + +本地受影响前端 175 项通过;原生项目 53 项中 49 项通过,新增来源检查三项及真实 +缺失父目录观察一项全部通过(4–39ms)。同一环境用本批前 `bfcd4690d` 的隔离源码 +复验 49 项,45 项通过,完全相同的四项 Git/root/POM watcher 五秒超时;保留失败 +证据,未修改或放宽这些旧用例。临时源码已清理。macOS 上的 Windows host check +仍仅两个既有 Windows API 错误,没有新增类型错误。报告前缀 +`stage-d-build-native-watch-*`。Run/Debug、Java/Agent、旧数据迁移、正式发行与真实 +Windows 验收仍待完成,能力准入继续关闭,阶段 D 不关闭。 + +该批 CI Build 同范围 91 项、TypeScript 与生产 bundle、共享/服务/Windows 边界、 +172 Notes、222 矩阵、9711 词条一致性、测试稳定性及 bundle 只读门禁通过。 +来源监听与检查只持有内存租约,关闭/插件撤销后排空,临时比较源码已删除;没有 +新增运行时资源目录、持久状态或跨 worktree 共享项。 + +Windows 已保存 Provider Run 增量(2026-10-31):Core 的公共 Run 选项、精确工程/ +目标选择器和准备结果交给既有 Native Run;所选 Maven/JDK 来自公共语言导入与 +工具上下文,不再让这条路径依赖旧 Maven store。默认模型由并发服务共同等待, +各自取消;准备输出直接追加现有 Run 面板。启动前重查保存选项、工具、工程指纹 +和原生来源版本,插件/工作区所有者等待迟到启动确认并按执行 ID 清理。原生输出/ +退出携带执行 ID,旧事件不能结束替换会话,清理失败保留所有者供重试。 + +真实 Windows TypeScript→Core→Maven Provider 的集成补上只读来源检查,九项均 +通过,新增模块 Run 规划 137ms;默认模型/选项和开发包文件清单保持不变,后端已 +清理。报告在 `.artifacts/test-stability/stage-d-windows-real-run/`;测试运行于 macOS, +不代表 Windows 原生启动验收。该批当时仅覆盖已保存引用的默认模型;后续临时模型增量见下。目标树 Run/Debug、 +旧配置/Java/Agent 迁移与正式发行仍未完成,能力准入继续关闭。 + +本批 Windows Build/共享进程所有者 100 项、完整 Run 335 项计时通过,覆盖共享模型 +取消、原生启动迟到、失败清理重试、参数编辑后拒绝旧计划和会话替换事件隔离。 +TypeScript、生产 bundle、共享/服务/Windows 边界、172 Notes、222 矩阵、测试稳定性 +和 bundle 只读检查通过。报告为 `stage-d-build-run-final`、`stage-d-windows-run-final` +及 `stage-d-build-run-*`;Windows host check 仍只含两项既有 macOS 平台 API 错误。 + +Windows 临时运行模型增量(2026-10-31):已保存引用和准入后的已有框架/模块配置 +都从 Core 的当前输入准备,保持已有服务 ID。每个 Run 使用自己的有效模型、Profiles +和 reactor 工具选择,再应用运行配置明确保存的 Maven/JDK 路径。Core 复用原有配置 +合并,空路径继承 reactor;不把全局默认误当成单项覆盖。单项模型直接加载,不再 +依赖默认模型及默认工具可用,原生来源观察独立释放;后端会话继续由工作区拥有,关闭 +一个临时模型不会停止其他服务。启动前只读重新检测,未变化目录保留身份,漏文件 +通知也能发现新的团队选项。Native exit 早于启动确认时仍等待清理,失败保留所有者; +同一次 Stop 不自动重试失败清理。原生能力表保持关闭,旧框架生产入口继续原路线。 + +共享保存引用 fixture 增加旧应用参数通道约束,macOS/Windows 都保留协议 4 的 +原始参数;实际新版 JVM/环境/cwd/入口/Debug 选项仍由 Core 拒绝不兼容后端。 +验证报告以 `stage-d-private-run-final`、`stage-d-private-run-core-final`、 +`stage-d-windows-private-run-final/` 和 `stage-d-private-run-swift-regressions` 为准; +受影响前端 427 项、Core/集成 1437 项、Swift 292 项及最终桥接 19 项通过; +真实 Core→Maven 11 项通过,临时 Profiles 1.669 秒, +配置选择另一 Maven 入口并继续使用共享后端 1.921 秒,默认模型/选项和包字节不变。 +每项运行的工具覆盖、空值继承、团队合并及来源只读由新增共享 fixture 验证。 +未进行原生 Windows 验收。 + +目标树 Debug、同一临时输入的 JDT/资源准备、Agent、旧偏好迁移和其余生产消费者 +替换仍未完成;正式 SDK/插件发行及完整两端验收仍是退出条件,阶段 D 不关闭。 + + +Windows 目标树增量(2026-10-31):当前工程/声明匹配的应用、服务和任务提供参数确认、 +显式保存并运行,复用 Run 会话及精确执行 ID 清理。保存阶段可 Stop 或随工作区关闭 +撤销,原生调用迟到不进入启动;首次打开 Run 面板不会用重载取消同一点击。原生 +沿用 Core 稳定引用及现有文档 CAS/权限/原子替换,保护安装路径及别名,保存后读回。 +保存自己的通知会撤销旧模型,后续改用重新检测的私有模型,不绕过正常来源/工具校验。 +目标列表虚拟化,与已有任务共用参数输入。没有新增资源下载/缓存或偏好副本。 + +当前前端 458 项通过(含精确参数、确认/取消、保存期 Stop/关闭、冷 Run 面板重载、 +来源失效和一万目标的有界挂载);原生适配器回归及 TypeScript/生产 bundle 和门禁 +结果以 `stage-d-targets-*` 报告为准。原生项目 58 项中 54 项通过,新增五项保存/ +外部冲突/路径保护全部通过(4–20ms);仍是四项此前已隔离复现的 watcher 超时。 +Windows host check 仅两个既有 macOS 平台 API 错误,未放宽旧测试。Windows 原生调试、JDT/资源准备、Agent 和旧生产 +消费者替换仍未完成,能力准入关闭;正式发行与完整平台验收仍是阶段 D 退出条件。 + +固定 Core `efa33a5901c1f96b354f5acb3f967c05395fc73a`、SDK `4886c90`、Maven `5124e2c` +的 [CI 41178631885](https://github.com/Lithe-IDEA/lithe-plugin-maven/actions/runs/41178631885) +已通过 Java 8/两种 Maven及 11 步 Windows TypeScript 工作区、现代框架调试和包不变 +检查。它覆盖私有 Run 工具选择,尚不覆盖后续目标树保存增量;不代替正式 SDK lock。 +首次短 SHA 触发仅在输入格式门禁失败,修正为完整 SHA 后重跑,没有放宽门禁。 diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index 67a9d14b60..0cfc9538a2 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -169,6 +169,7 @@ "A Java project with no build tool, Maven, or Gradle." = "A Java project with no build tool, Maven, or Gradle."; "A Spring Boot project from Spring Initializr." = "A Spring Boot project from Spring Initializr."; "A basic project with a free structure." = "A basic project with a free structure."; +"A build operation is already active" = "A build operation is already active"; "A custom Agent can follow either your Codex or your Claude configuration." = "A custom Agent can follow either your Codex or your Claude configuration."; "A detailed message with a clear subject and body" = "A detailed message with a clear subject and body"; "A host name, an IP address, or a Host alias from ~/.ssh/config. Settings from that file, such as ProxyJump and IdentityFile, still apply." = "A host name, an IP address, or a Host alias from ~/.ssh/config. Settings from that file, such as ProxyJump and IdentityFile, still apply."; @@ -181,6 +182,7 @@ "A rebase cannot start with any uncommitted changes, including these unrelated to '%@':" = "A rebase cannot start with any uncommitted changes, including these unrelated to '%@':"; "A removed Hook cannot be saved." = "A removed Hook cannot be saved."; "A running local JVM and its JDK jstat tool are required." = "A running local JVM and its JDK jstat tool are required."; +"A saved input is revalidated by the existing toolchain resolver when the consumer runs. It is not a verified current installation." = "A saved input is revalidated by the existing toolchain resolver when the consumer runs. It is not a verified current installation."; "A secure connection to %@ could not be established. Check TLS inspection, proxy, VPN, or system certificate settings." = "A secure connection to %@ could not be established. Check TLS inspection, proxy, VPN, or system certificate settings."; "A secure connection to GitHub could not be established. Check TLS inspection, proxy, VPN, or system certificate settings." = "A secure connection to GitHub could not be established. Check TLS inspection, proxy, VPN, or system certificate settings."; "A secure connection to the MCP Registry could not be established. Check the proxy or retry directly." = "A secure connection to the MCP Registry could not be established. Check the proxy or retry directly."; @@ -248,6 +250,7 @@ "Add a memory usage indicator to the status bar" = "Add a memory usage indicator to the status bar"; "Add an AI provider in AI Providers first." = "Add an AI provider in AI Providers first."; "Add another account" = "Add another account"; +"Add argument" = "Add argument"; "Add at least one absolute allowed folder path after the Filesystem package argument." = "Add at least one absolute allowed folder path after the Filesystem package argument."; "Add at least one absolute folder path after the package argument, one per line. Filesystem can read and write only in the folders you allow. Requires Node.js 24 or later." = "Add at least one absolute folder path after the package argument, one per line. Filesystem can read and write only in the folders you allow. Requires Node.js 24 or later."; "Add clickable actions beside document text" = "Add clickable actions beside document text"; @@ -315,6 +318,7 @@ "Agents run on the Node.js installed on this Mac. Install or update Node.js yourself, then check again." = "Agents run on the Node.js installed on this Mac. Install or update Node.js yourself, then check again."; "All" = "All"; "All Branches" = "All Branches"; +"All Build consumers" = "All Build consumers"; "All References" = "All References"; "All Repositories" = "All Repositories"; "All Users" = "All Users"; @@ -453,6 +457,7 @@ "Automatic" = "Automatic"; "Automatic discovery" = "Automatic discovery"; "Automatic query ×%lld, identical results" = "Automatic query ×%lld, identical results"; +"Automatic selection" = "Automatic selection"; "Automatic tries your SSH agent and keys first, then asks for a password. Lithe asks in a dialog whenever the server needs an answer, such as a password, a passphrase, or a verification code." = "Automatic tries your SSH agent and keys first, then asks for a password. Lithe asks in a dialog whenever the server needs an answer, such as a password, a passphrase, or a verification code."; "Automatic uses a project mvnw first, then detected Maven, then mvnd." = "Automatic uses a project mvnw first, then detected Maven, then mvnd."; "Automatically accept all file edits" = "Automatically approve file edits; other actions follow permission rules."; @@ -522,9 +527,15 @@ "Build System" = "Build System"; "Build files changed." = "Build files changed."; "Build files changed. Reload the model to pick up the changes." = "Build files changed. Reload the model to pick up the changes."; +"Build launch did not start" = "Build launch did not start"; +"Build option migration could not be verified. The previous configuration has been retained. Reload the build instance to retry." = "Build option migration could not be verified. The previous configuration has been retained. Reload the build instance to retry."; +"Build options changed on disk. Reload before saving; your changes have been kept." = "Build options changed on disk. Reload before saving; your changes have been kept."; "Build output" = "Build output"; +"Build plugins are unavailable" = "Build plugins are unavailable"; "Build provider" = "Build provider"; +"Build provider model changed. Regenerate Run configurations before running." = "Build provider model changed. Regenerate Run configurations before running."; "Build system" = "Build system"; +"Build tool defaults" = "Build tool defaults"; "Build, Execution, Deployment" = "Build, Execution, Deployment"; "Build-system language import is still preparing. Retry when project loading completes." = "Build-system language import is still preparing. Retry when project loading completes."; "Build-system language import preparation timed out. Retry project loading." = "Build-system language import preparation timed out. Retry project loading."; @@ -665,7 +676,9 @@ "Choose a color theme and whether Lithe follows the system appearance." = "Choose a color theme and whether Lithe follows the system appearance."; "Choose a commit format" = "Choose a commit format"; "Choose a database connection and then a table from the sidebar." = "Choose a database connection and then a table from the sidebar."; +"Choose a declared build task" = "Choose a declared build task"; "Choose a default web browser in System Settings, then retry." = "Choose a default web browser in System Settings, then retry."; +"Choose a detected build instance" = "Choose a detected build instance"; "Choose a directory inside the project" = "Choose a directory inside the project"; "Choose a language-server executable." = "Choose a language-server executable."; "Choose a local or custom provider in the Agent panel settings." = "Choose a local or custom provider in Settings > AI > Agents."; @@ -952,6 +965,7 @@ "Connection timeout" = "Connection timeout"; "Connections: %lld" = "Connections: %lld"; "Console" = "Console"; +"Consumer" = "Consumer"; "Consumers" = "Consumers"; "Content" = "Content"; "Content SHA-256" = "Content SHA-256"; @@ -1090,6 +1104,7 @@ "Could not start the Java test result listener: %@" = "Could not start the Java test result listener: %@"; "Could not start the MCP command. Check its executable and runtime." = "Could not start the MCP command. Check its executable and runtime."; "Could not verify the commit outcome. Review before retrying." = "Could not verify the commit outcome. Review before retrying."; +"Could not verify the saved project environment settings. The previous preferences have been kept when possible." = "Could not verify the saved project environment settings. The previous preferences have been kept when possible."; "Could not write %@." = "Could not write %@."; "Could not write %@. The previous contents were kept in %@." = "Could not write %@. The previous contents were kept in %@."; "Could not write Java launch argument file at %@: %@" = "Could not write Java launch argument file at %@: %@"; @@ -1198,6 +1213,7 @@ "Default options" = "Default options"; "Default push behavior" = "Default push behavior"; "Default shell" = "Default shell"; +"Defaults" = "Defaults"; "Defaults for ordinary Fetch in every project. A repository's own Git configuration above still wins where it is set." = "Defaults for ordinary Fetch in every project. A repository's own Git configuration above still wins where it is set."; "Definition" = "Definition"; "Definition not found" = "Definition not found"; @@ -1366,6 +1382,7 @@ "Each built-in preset includes a preview of the generated message." = "Each built-in preset includes a preview of the generated message."; "Each repository has its own commit. Completed steps are kept if another repository fails." = "Each repository has its own commit. Completed steps are kept if another repository fails."; "Each row explains the setting, the value Git is actually using, and where that value comes from." = "Each row explains the setting, the value Git is actually using, and where that value comes from."; +"Each row is one argument. Spaces, quotes and empty values are preserved." = "Each row is one argument. Spaces, quotes and empty values are preserved."; "Each submodule is pushed before its parent." = "Each submodule is pushed before its parent."; "Each tool uses the first source that has a value: a fixed version, then project version files, then global defaults, then automatic discovery. Installed versions are checked by running each tool's own version command. Enabled version managers report what version files ask for." = "Each tool uses the first source that has a value: a fixed version, then project version files, then global defaults, then automatic discovery. Installed versions are checked by running each tool's own version command. Enabled version managers report what version files ask for."; "Earlier" = "Earlier"; @@ -1389,6 +1406,7 @@ "Edit Shortcuts" = "Edit Shortcuts"; "Edit Watch" = "Edit Watch"; "Edit configuration text" = "Edit configuration text"; +"Edit defaults" = "Edit defaults"; "Edit exception breakpoint" = "Edit exception breakpoint"; "Edit files and stage the changes you want to include. Amend and Continue updates this commit; Continue keeps the existing commit." = "Edit files and stage the changes you want to include. Amend and Continue updates this commit; Continue keeps the existing commit."; "Edit message" = "Edit message"; @@ -1492,6 +1510,7 @@ "Enter the new symbol name." = "Enter the new symbol name."; "Enter the next function call" = "Enter the next function call"; "Enter the one-time code" = "Enter the one-time code"; +"Entry point" = "Entry point"; "Environment" = "Environment"; "Environment variable and header names must be unique." = "Environment variable and header names must be unique."; "Environment variables" = "Environment variables"; @@ -1518,6 +1537,7 @@ "Excluded from indexing by a parent directory" = "Excluded from indexing by a parent directory"; "Exclusion list" = "Exclusion list"; "Executable" = "Executable"; +"Executable file" = "Executable file"; "Execute Maven Goal" = "Execute Maven Goal"; "Execute Maven goal" = "Execute Maven goal"; "Execute the next source line" = "Execute the next source line"; @@ -1687,6 +1707,7 @@ "Found automatically" = "Found automatically"; "Frames are not available" = "Frames are not available"; "Frames rendered per second" = "Frames rendered per second"; +"Framework service debugging is not available in remote workspaces yet." = "Framework service debugging is not available in remote workspaces yet."; "Free space" = "Free space"; "From %@" = "From %@"; "Frontend and other non-Java roots keep the workspace editor and terminal settings. Java and Maven SDKs only apply to the backends listed on the left." = "Frontend and other non-Java roots keep the workspace editor and terminal settings. Java and Maven SDKs only apply to the backends listed on the left."; @@ -1958,6 +1979,7 @@ "Install the selected channel" = "Install the selected channel"; "Install the tool on the remote host, add it to the login PATH, and refresh the available runtimes." = "Install the tool on the remote host, add it to the login PATH, and refresh the available runtimes."; "Installation" = "Installation"; +"Installation directory" = "Installation directory"; "Installation: %@" = "Installation: %@"; "Installations found in the locations this tool's plugin declares. Each version comes from the tool's own version command, and the highest one is used. Installations inside the project are never run." = "Installations found in the locations this tool's plugin declares. Each version comes from the tool's own version command, and the highest one is used. Installations inside the project are never run."; "Installations of %@ could not be checked." = "Installations of %@ could not be checked."; @@ -2143,6 +2165,7 @@ "Linked this conversation to a pull request" = "Linked this conversation to a pull request"; "Linked worktree" = "Linked worktree"; "Listed in" = "Listed in"; +"Lists the project root and directories with version files, fixed versions or saved tool preferences, together with their parents." = "Lists the project root and directories with version files, fixed versions or saved tool preferences, together with their parents."; "Lists the project root and each directory with its own version file or fixed version. A fixed version also applies to the directories below it." = "Lists the project root and each directory with its own version file or fixed version. A fixed version also applies to the directories below it."; "Lithe" = "Lithe"; "Lithe Agent" = "Lithe Agent"; @@ -2447,6 +2470,12 @@ "Name and email are written to this repository's Git configuration and override the global values. Existing commits do not change." = "Name and email are written to this repository's Git configuration and override the global values. Existing commits do not change."; "Name for this filter" = "Name for this filter"; "Name:" = "Name:"; +"Native configuration" = "Native configuration"; +"Native configuration changed on disk. Reload before saving; your draft has been kept." = "Native configuration changed on disk. Reload before saving; your draft has been kept."; +"Native configuration changed. Reload before saving; your draft has been kept." = "Native configuration changed. Reload before saving; your draft has been kept."; +"Native configuration is busy. Wait for the current operation to finish." = "Native configuration is busy. Wait for the current operation to finish."; +"Native configuration is unavailable." = "Native configuration is unavailable."; +"Native configuration was saved, but its current effective values could not be verified. Reload before editing again." = "Native configuration was saved, but its current effective values could not be verified. Reload before editing again."; "Native installer" = "Native installer"; "Native instruction file" = "Native instruction file"; "Navigate" = "Navigate"; @@ -2492,6 +2521,7 @@ "New terminal session" = "New terminal session"; "New terminal with shell" = "New terminal with shell"; "New window" = "New window"; +"Next Build input" = "Next Build input"; "Next File" = "Next File"; "Next Match" = "Next Match"; "Next console expression" = "Next console expression"; @@ -2608,6 +2638,7 @@ "No matching skills." = "No matching skills."; "No matching users" = "No matching users"; "No models available" = "No models available"; +"No native configuration has been read." = "No native configuration has been read."; "No navigation results" = "No navigation results"; "No new commits. Pushing creates the remote branch." = "No new commits. Pushing creates the remote branch."; "No notifications" = "No notifications"; @@ -2812,6 +2843,7 @@ "Open a project to configure its run configurations." = "Open a project to configure its run configurations."; "Open a project to let connected agents inspect and control it." = "Open a project to let connected agents inspect and control it."; "Open a project to manage its skills." = "Open a project to manage its skills."; +"Open a project to read its native configuration." = "Open a project to read its native configuration."; "Open a project to see how much space its history uses." = "Open a project to see how much space its history uses."; "Open a project to see its language service." = "Open a project to see its language service."; "Open a project to use Git" = "Open a project to use Git"; @@ -3043,6 +3075,8 @@ "Project default" = "Project default"; "Project defaults (This Mac)" = "Project defaults (This Mac)"; "Project environment" = "Project environment"; +"Project environment settings are being saved. Wait before saving again." = "Project environment settings are being saved. Wait before saving again."; +"Project environment settings changed on disk. Reload before saving; your draft has been kept." = "Project environment settings changed on disk. Reload before saving; your draft has been kept."; "Project files" = "Project files"; "Project identification complete" = "Project identification complete"; "Project identification did not produce a run configuration." = "Project identification did not produce a run configuration."; @@ -3148,6 +3182,7 @@ "Read again" = "Read again"; "Read declared shortcut files from the editor you select for import" = "Read declared shortcut files from the editor you select for import"; "Read result" = "Read result"; +"Read using project tool" = "Read using project tool"; "Read, write and search files only in the directories you allow. Requires Node.js 24 or later." = "Read, write and search files only in the directories you allow. Requires Node.js 24 or later."; "Read-only" = "Read-only"; "Read-only file preview" = "Read-only file preview"; @@ -3155,6 +3190,7 @@ "Reading Git configuration…" = "Reading Git configuration…"; "Reading changes and recent commits." = "Reading changes and recent commits."; "Reading changes…" = "Reading changes…"; +"Reading configuration with this project tool requires an explicit action." = "Reading configuration with this project tool requires an explicit action."; "Reading memory…" = "Reading memory…"; "Reading project…" = "Reading project…"; "Reading registered checkouts." = "Reading registered checkouts."; @@ -3241,6 +3277,7 @@ "Reload Range" = "Reload Range"; "Reload from disk" = "Reload from disk"; "Reload the Core preview and reset the step plan" = "Reload the Core preview and reset the step plan"; +"Reload the build model before selecting a project, target or model task" = "Reload the build model before selecting a project, target or model task"; "Reload the project model" = "Reload the project model"; "Reload this Agent before synchronizing instructions." = "Reload this Agent before synchronizing instructions."; "Reload tool settings" = "Reload tool settings"; @@ -3513,6 +3550,7 @@ "Runtime" = "Runtime"; "Runtime Environment" = "Runtime Environment"; "Runtime Environment — Isolated Preview" = "Runtime Environment — Isolated Preview"; +"Runtime arguments" = "Runtime arguments"; "Runtime discovery failed" = "Runtime discovery failed"; "Runtime used by the language server (installed on this Mac)" = "Runtime used by the language server (installed on this Mac)"; "Rust Core cache-retention planning is unavailable." = "Rust Core cache-retention planning is unavailable."; @@ -3570,6 +3608,7 @@ "Saved in .lithe/run/configurations.json for the whole team. Local JDK paths are never shared." = "Saved in .lithe/run/configurations.json for the whole team. Local JDK paths are never shared."; "Saved in .lithe/run/local.json and excluded from Git." = "Saved in .lithe/run/local.json and excluded from Git."; "Saved in Keychain" = "Saved in Keychain"; +"Saved input" = "Saved input"; "Saved only for this project on this computer in .lithe/run/local.json and excluded from Git." = "Saved only for this project on this computer in .lithe/run/local.json and excluded from Git."; "Saved to %@" = "Saved to %@"; "Saved, not verified" = "Saved, not verified"; @@ -3872,6 +3911,7 @@ "Some installation versions were not checked in time." = "Some installation versions were not checked in time."; "Some project folders could not be read. Existing run configurations were retained. Restore access, then regenerate configurations." = "Some project folders could not be read. Existing run configurations were retained. Restore access, then regenerate configurations."; "Some repositories could not be refreshed. Displayed changes may be out of date." = "Some repositories could not be refreshed. Displayed changes may be out of date."; +"Some saved run configurations cannot be migrated yet. Keep the existing files and retry after updating Lithe and its build plugin." = "Some saved run configurations cannot be migrated yet. Keep the existing files and retry after updating Lithe and its build plugin."; "Some selected diffs could not be read. Refresh Git changes and retry." = "Some selected diffs could not be read. Refresh Git changes and retry."; "Some selected files belong to another version control root. Refresh and select them again." = "Some selected files belong to another version control root. Refresh and select them again."; "Some settings have unsupported values and were skipped." = "Some settings have unsupported values and were skipped."; @@ -4172,10 +4212,14 @@ "The branch could not be published" = "The branch could not be published"; "The branch now contains the completed plan. Its original history remains available through the recovery reference." = "The branch now contains the completed plan. Its original history remains available through the recovery reference."; "The breakdown needs Model call statistics (on by default). After turning it on, reconnect and send a message." = "The breakdown needs Model call statistics (on by default). After turning it on, reconnect and send a message."; +"The build option is no longer available. Reload the build instance." = "The build option is no longer available. Reload the build instance."; +"The build option value is invalid. Inspect its current kind and choices." = "The build option value is invalid. Inspect its current kind and choices."; +"The build options could not be saved or verified. Reload before retrying." = "The build options could not be saved or verified. Reload before retrying."; "The build provider failed to start." = "The build provider failed to start."; "The build provider returned an invalid launch plan." = "The build provider returned an invalid launch plan."; "The build provider stopped before it was ready." = "The build provider stopped before it was ready."; "The build provider's debug program or adapter is unavailable. Build it again and check that the plugin is enabled." = "The build provider's debug program or adapter is unavailable. Build it again and check that the plugin is enabled."; +"The build selection is no longer available" = "The build selection is no longer available"; "The build tool exited with code %lld." = "The build tool exited with code %lld."; "The build tool output is too large or not UTF-8." = "The build tool output is too large or not UTF-8."; "The build tool timed out." = "The build tool timed out."; @@ -4249,6 +4293,7 @@ "The file or its hard links changed while saving. Your edits are still unsaved. Check the file and save again." = "The file or its hard links changed while saving. Your edits are still unsaved. Check the file and save again."; "The file system that holds the home directory." = "The file system that holds the home directory."; "The file will be created when you save." = "The file will be created when you save."; +"The framework did not open its JVM debug port before the deadline. Check the Run output." = "The framework did not open its JVM debug port before the deadline. Check the Run output."; "The function call was cancelled." = "The function call was cancelled."; "The generated message will replace the text currently in the editor." = "The generated message will replace the text currently in the editor."; "The generated title or description would replace text you already entered." = "The generated title or description would replace text you already entered."; @@ -4284,6 +4329,7 @@ "The name of the artifact the build produces." = "The name of the artifact the build produces."; "The native Hook configuration must be valid JSON. Existing contents were kept." = "The native Hook configuration must be valid JSON. Existing contents were kept."; "The native Hook event must contain an array. Existing contents were kept." = "The native Hook event must contain an array. Existing contents were kept."; +"The native configuration must be a writable file smaller than 2 MB." = "The native configuration must be a writable file smaller than 2 MB."; "The native hooks field must be a JSON object. Existing contents were kept." = "The native hooks field must be a JSON object. Existing contents were kept."; "The native panel state exceeds 64 KiB." = "The native panel state exceeds 64 KiB."; "The official Lithe plugin repository cannot be removed. Disable it instead." = "The official Lithe plugin repository cannot be removed. Disable it instead."; @@ -4374,6 +4420,8 @@ "The resource names a root this window does not offer." = "The resource names a root this window does not offer."; "The resource path must be relative, use \"/\" separators, and not contain \".\" or \"..\"." = "The resource path must be relative, use \"/\" separators, and not contain \".\" or \"..\"."; "The response stopped at the model's token limit." = "The response stopped at the model's token limit."; +"The saved build option could not be verified. Reload before editing again." = "The saved build option could not be verified. Reload before editing again."; +"The saved build options are invalid. The original configuration has been kept." = "The saved build options are invalid. The original configuration has been kept."; "The saved provider run configuration is unavailable." = "The saved provider run configuration is unavailable."; "The saved toolchain settings are invalid." = "The saved toolchain settings are invalid."; "The selected AI provider has an invalid API URL or model." = "The selected AI provider has an invalid API URL or model."; @@ -4394,6 +4442,7 @@ "The selected language-server path is not executable: %@" = "The selected language-server path is not executable: %@"; "The selected table has no columns to display." = "The selected table has no columns to display."; "The selected test identifier is invalid." = "The selected test identifier is invalid."; +"The selected toolchain is unavailable." = "The selected toolchain is unavailable."; "The selected versions match" = "The selected versions match"; "The server asked again. Check the previous answer." = "The server asked again. Check the previous answer."; "The server rejected the credentials. Check the user name and the authentication method." = "The server rejected the credentials. Check the user name and the authentication method."; @@ -4459,6 +4508,7 @@ "These choices apply to this Fetch only. Git configuration and credentials remain unchanged." = "These choices apply to this Fetch only. Git configuration and credentials remain unchanged."; "These defaults apply only to this project on this computer. Run configurations inherit them unless overridden. Use the Project save scope in a run configuration to share settings with your team." = "These defaults apply only to this project on this computer. Run configurations inherit them unless overridden. Use the Project save scope in a run configuration to share settings with your team."; "These files will be moved to the macOS Trash." = "These files will be moved to the macOS Trash."; +"These machine-local choices apply to Build providers and their Run configurations on the next operation. Saving does not probe or execute a tool. Inherited choices follow later default changes." = "These machine-local choices apply to Build providers and their Run configurations on the next operation. Saving does not probe or execute a tool. Inherited choices follow later default changes."; "These overrides affect only this run configuration. Clear a path to inherit the project environment." = "These overrides affect only this run configuration. Clear a path to inherit the project environment."; "These processes left their terminal or tool to keep running in the background, like daemons and dev servers. Checked items end when Lithe quits; unchecked items keep running." = "These processes left their terminal or tool to keep running in the background, like daemons and dev servers. Checked items end when Lithe quits; unchecked items keep running."; "These programs were started in Lithe before it closed unexpectedly. Checked items are ended now; unchecked items keep running and are not tracked anymore." = "These programs were started in Lithe before it closed unexpectedly. Checked items are ended now; unchecked items keep running and are not tracked anymore."; @@ -4515,6 +4565,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 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."; "This permanently deletes uncommitted and untracked files in '%@'. The branch itself is kept." = "This permanently deletes uncommitted and untracked files in '%@'. The branch itself is kept."; @@ -4530,6 +4581,7 @@ "This plugin sent messages too quickly." = "This plugin sent messages too quickly."; "This preview contains changes that have not been officially released and may be unstable." = "This preview contains changes that have not been officially released and may be unstable."; "This project" = "This project"; +"This project has no matching build-system instance." = "This project has no matching build-system instance."; "This project is not a Git repository" = "This project is not a Git repository"; "This project window is no longer available." = "This project window is no longer available."; "This rebase session is no longer active in the current repository." = "This rebase session is no longer active in the current repository."; @@ -4606,6 +4658,7 @@ "Toolbar Actions" = "Toolbar Actions"; "Toolchain Sources" = "Toolchain Sources"; "Toolchain settings are not available for remote workspaces yet." = "Toolchain settings are not available for remote workspaces yet."; +"Toolchain settings changed while loading. Refresh and try again." = "Toolchain settings changed while loading. Refresh and try again."; "Tools" = "Tools"; "Tools sent" = "Tools sent"; "Total" = "Total"; @@ -4732,6 +4785,7 @@ "Update source" = "Update source"; "Update sources must use https://. http:// is only allowed for this Mac (localhost)." = "Update sources must use https://. http:// is only allowed for this Mac (localhost)."; "Update status" = "Update status"; +"Update the Maven Build plugin before generating Run configurations." = "Update the Maven Build plugin before generating Run configurations."; "Update this CLI using its original installer, then check again." = "Update this CLI using its original installer, then check again."; "Update to" = "Update to"; "Update to %@" = "Update to %@"; @@ -4759,6 +4813,7 @@ "Use AI worktree directory (/private/tmp)" = "Use AI worktree directory (/private/tmp)"; "Use AutoBuild" = "Use AutoBuild"; "Use AutoBuild Rolling?" = "Use AutoBuild Rolling?"; +"Use Build execute to start another build" = "Use Build execute to start another build"; "Use Default Messages" = "Use Default Messages"; "Use Git configuration" = "Use Git configuration"; "Use Git from PATH" = "Use Git from PATH"; @@ -4771,11 +4826,14 @@ "Use a valid file or directory name" = "Use a valid file or directory name"; "Use an Agent in your project" = "Use an Agent in your project"; "Use automatic value" = "Use automatic value"; +"Use consumer default" = "Use consumer default"; "Use credential helper" = "Use credential helper"; "Use detected JDK" = "Use detected JDK"; +"Use directory tool default" = "Use directory tool default"; "Use fast processing" = "Use fast processing"; "Use form" = "Use form"; "Use full HTTP path for credentials" = "Use full HTTP path for credentials"; +"Use inherited choice" = "Use inherited choice"; "Use letters, digits, dots, dashes or underscores. The name must start with a letter or digit." = "Use letters, digits, dots, dashes or underscores. The name must start with a letter or digit."; "Use local configuration or add your own provider." = "Use local configuration or add your own provider."; "Use mvnw or detected Maven" = "Use mvnw or detected Maven"; @@ -4794,6 +4852,7 @@ "Use the standard response speed" = "Use the standard response speed"; "Use this when a repository or Git default needs a one-off adjustment." = "Use this when a repository or Git default needs a one-off adjustment."; "Use up to 64 lowercase letters, digits and single hyphens for the skill name." = "Use up to 64 lowercase letters, digits and single hyphens for the skill name."; +"Use workspace default" = "Use workspace default"; "Use your local ChatGPT account. No API key or URL is needed." = "Use your local ChatGPT account. No API key or URL is needed."; "Used" = "Used"; "Used %@" = "Used %@"; @@ -4991,6 +5050,7 @@ "aiCommit.outputTokenFieldInfo" = "Lithe uses this field for commit requests. Choose max_tokens for older gateways."; "avg" = "avg"; "basic Latin letters" = "basic Latin letters"; +"build.window.addArgument" = "Add argument"; "characters used by the interface and your files" = "characters used by the interface and your files"; "chars" = "chars"; "checkout" = "checkout"; @@ -5126,6 +5186,14 @@ "git.unifiedView" = "Unified view"; "history.clearFailed" = "Could not clear this project's local history. Check storage permissions and try again."; "https://github.com/owner/repository" = "https://github.com/owner/repository"; +"ide.build.busy" = "A build operation is already active"; +"ide.build.chooseInstance" = "Choose a detected build instance"; +"ide.build.chooseTask" = "Choose a declared build task"; +"ide.build.launchFailed" = "Build launch did not start"; +"ide.build.reloadSelection" = "Reload the build model before selecting a project, target or model task"; +"ide.build.restart" = "Use Build execute to start another build"; +"ide.build.selectionExpired" = "The build selection is no longer available"; +"ide.build.unavailable" = "Build plugins are unavailable"; "indexing.autoGit" = "Automatically exclude Git-ignored directories"; "indexing.cancelExclusion" = "Cancel Exclusion"; "indexing.configureFailed" = "Could not apply project indexing exclusions. Try reopening the project."; @@ -5294,6 +5362,9 @@ "mac.workbench.unhide" = "Unhide"; "macOS could not prepare the update disk image. Download the release manually and try again." = "macOS could not prepare the update disk image. Download the release manually and try again."; "macOS could not prepare the update disk image. Open the Release page and install it manually." = "macOS could not prepare the update disk image. Open the Release page and install it manually."; +"macos.buildProvider.jvmDebugPortTimeout" = "The framework did not open its JVM debug port before the deadline. Check the Run output."; +"macos.buildProvider.jvmDebugRemoteUnavailable" = "Framework service debugging is not available in remote workspaces yet."; +"macos.native.savedRunMigrationNeedsUpdate" = "Some saved run configurations cannot be migrated yet. Keep the existing files and retry after updating Lithe and its build plugin."; "main Java" = "main Java"; "main resources" = "main resources"; "markdown.plantumlRenderFailed" = "PlantUML could not render this diagram. Showing its source instead."; @@ -5653,6 +5724,12 @@ "native.buildFilesChanged" = "%1$@ build files changed"; "native.buildFilesChanged.variant2" = "Build files changed."; "native.buildFilesChangedReloadTheModelToPickUpTheChanges" = "Build files changed. Reload the model to pick up the changes."; +"native.buildOptionInvalid" = "The build option value is invalid. Inspect its current kind and choices."; +"native.buildOptionMigrationCouldNotBeVerified" = "Build option migration could not be verified. The previous configuration has been retained. Reload the build instance to retry."; +"native.buildOptionReadBackFailed" = "The saved build option could not be verified. Reload before editing again."; +"native.buildOptionUnavailable" = "The build option is no longer available. Reload the build instance."; +"native.buildOptionsConflict" = "Build options changed on disk. Reload before saving; your changes have been kept."; +"native.buildOptionsSaveFailed" = "The build options could not be saved or verified. Reload before retrying."; "native.buildOutput" = "Build Output"; "native.buildOutput.variant2" = "Build output"; "native.buildOutputs" = "Build Outputs"; @@ -6165,6 +6242,7 @@ "native.couldNotSaveTheConnections" = "Could not save the connections: %1$@"; "native.couldNotStartTheJavaTestResultListener" = "Could not start the Java test result listener: %1$@"; "native.couldNotStartTheMcpCommandCheckItsExecutableAndRuntime" = "Could not start the MCP command. Check its executable and runtime."; +"native.couldNotVerifySavedProjectEnvironment" = "Could not verify the saved project environment settings. The previous preferences have been kept when possible."; "native.couldNotVerifyTheCommitOutcomeReviewBeforeRetrying" = "Could not verify the commit outcome. Review before retrying."; "native.couldNotWrite" = "Could not write %1$@."; "native.couldNotWriteJavaLaunchArgumentFileAt" = "Could not write Java launch argument file at %1$@: %2$@"; @@ -7606,6 +7684,19 @@ "native.name.variant2" = "Name:"; "native.nameAndEmailAreWrittenToTheGlobalGitConfigurationExistingCommitsDoNotChange" = "Name and email are written to the global Git configuration. Existing commits do not change."; "native.nameAndEmailAreWrittenToThisRepositorySGitConfigurationAndOverrideTheGlobalVal4aca07ef08" = "Name and email are written to this repository's Git configuration and override the global values. Existing commits do not change."; +"native.nativeConfigurationBusy" = "Native configuration is busy. Wait for the current operation to finish."; +"native.nativeConfigurationDiskConflict" = "Native configuration changed on disk. Reload before saving; your draft has been kept."; +"native.nativeConfigurationExplicitRead" = "Reading configuration with this project tool requires an explicit action."; +"native.nativeConfigurationFileRequired" = "The native configuration must be a writable file smaller than 2 MB."; +"native.nativeConfigurationNoInstance" = "This project has no matching build-system instance."; +"native.nativeConfigurationNotRead" = "No native configuration has been read."; +"native.nativeConfigurationOpenProject" = "Open a project to read its native configuration."; +"native.nativeConfigurationReadBackFailed" = "Native configuration was saved, but its current effective values could not be verified. Reload before editing again."; +"native.nativeConfigurationReadUsingProjectTool" = "Read using project tool"; +"native.nativeConfigurationSourceReadOnly" = "This native configuration source is read-only. Installed applications and plugin packages cannot be edited."; +"native.nativeConfigurationStale" = "Native configuration changed. Reload before saving; your draft has been kept."; +"native.nativeConfigurationTitle" = "Native configuration"; +"native.nativeConfigurationUnavailable" = "Native configuration is unavailable."; "native.nativeInstaller" = "Native installer"; "native.nativeInstructionFile" = "Native instruction file"; "native.navigate" = "Navigate"; @@ -8141,6 +8232,8 @@ "native.projectDefault" = "Project default"; "native.projectDefaultsThisMac" = "Project defaults (This Mac)"; "native.projectEnvironment" = "Project environment"; +"native.projectEnvironmentSaveInProgress" = "Project environment settings are being saved. Wait before saving again."; +"native.projectEnvironmentSettingsChangedOnDisk" = "Project environment settings changed on disk. Reload before saving; your draft has been kept."; "native.projectErrors" = "Project Errors"; "native.projectFiles" = "Project files"; "native.projectIdentificationComplete" = "Project identification complete"; @@ -8620,6 +8713,7 @@ "native.saveTitle" = "Save title"; "native.saved" = "Saved"; "native.savedBreakpointsCouldNotBeRead" = "Saved breakpoints could not be read."; +"native.savedBuildOptionsInvalid" = "The saved build options are invalid. The original configuration has been kept."; "native.savedDebuggerSteppingFiltersCouldNotBeRead" = "Saved debugger stepping filters could not be read."; "native.savedInKeychain" = "Saved in Keychain"; "native.savedInLitheRunConfigurationsJsonForTheWholeTeamLocalJDKPathsAreNeverShared" = "Saved in .lithe/run/configurations.json for the whole team. Local JDK paths are never shared."; @@ -8718,6 +8812,7 @@ "native.selectedDirectory" = "Selected directory"; "native.selectedFromDiskThePublisherAndRepositoryHaveNotBeenVerified" = "Selected from disk. The publisher and repository have not been verified."; "native.selectedRuntime" = "Selected runtime"; +"native.selectedToolchainUnavailable" = "The selected toolchain is unavailable."; "native.selectedVersion" = "selected version"; "native.selfUpdateIsOnlyAvailableWhenLitheIsRunningFromAPackagedLitheApp" = "Self-update is only available when Lithe is running from a packaged Lithe.app."; "native.send" = "Send"; @@ -8932,6 +9027,7 @@ "native.staged.variant2" = "Staged"; "native.stagedChanges" = "Staged changes"; "native.stagedFilesChangedBeforeGenerationFinished" = "Staged files changed before generation finished"; +"native.staleProviderRunGeneration" = "Build provider model changed. Regenerate Run configurations before running."; "native.staleRecord" = "Stale record"; "native.standard" = "Standard"; "native.startADebugSessionToRunDebuggerCommands" = "Start a debug session to run debugger commands"; @@ -9524,6 +9620,24 @@ "native.toolWindow" = "Tool Window"; "native.toolWindows" = "Tool Windows"; "native.toolbarActions" = "Toolbar Actions"; +"native.toolchainPreferences.allBuildConsumers" = "All Build consumers"; +"native.toolchainPreferences.automaticSelection" = "Automatic selection"; +"native.toolchainPreferences.buildToolDefaults" = "Build tool defaults"; +"native.toolchainPreferences.consumer" = "Consumer"; +"native.toolchainPreferences.defaultsBadge" = "Defaults"; +"native.toolchainPreferences.editDefaults" = "Edit defaults"; +"native.toolchainPreferences.executableFile" = "Executable file"; +"native.toolchainPreferences.installationDirectory" = "Installation directory"; +"native.toolchainPreferences.localBuildPreferencesInfo" = "These machine-local choices apply to Build providers and their Run configurations on the next operation. Saving does not probe or execute a tool. Inherited choices follow later default changes."; +"native.toolchainPreferences.nextBuildInput" = "Next Build input"; +"native.toolchainPreferences.preferenceDirectoriesInfo" = "Lists the project root and directories with version files, fixed versions or saved tool preferences, together with their parents."; +"native.toolchainPreferences.savedInput" = "Saved input"; +"native.toolchainPreferences.savedInputInfo" = "A saved input is revalidated by the existing toolchain resolver when the consumer runs. It is not a verified current installation."; +"native.toolchainPreferences.sourceChangedDuringLoad" = "Toolchain settings changed while loading. Refresh and try again."; +"native.toolchainPreferences.useConsumerDefault" = "Use consumer default"; +"native.toolchainPreferences.useDirectoryToolDefault" = "Use directory tool default"; +"native.toolchainPreferences.useInheritedChoice" = "Use inherited choice"; +"native.toolchainPreferences.useWorkspaceDefault" = "Use workspace default"; "native.toolchainSettings.answeredIn" = "Answered in %1$lld ms"; "native.toolchainSettings.automaticDiscovery" = "Automatic discovery"; "native.toolchainSettings.automaticStepInfo" = "Installations found in the locations this tool's plugin declares. Each version comes from the tool's own version command, and the highest one is used. Installations inside the project are never run."; @@ -9725,6 +9839,7 @@ "native.updateCurrentBranch" = "Update Current Branch"; "native.updateLitheToUseThisConfigurationVersion" = "Update Lithe to use this configuration version."; "native.updateLog" = "Update log"; +"native.updateMavenBeforeRunGeneration" = "Update the Maven Build plugin before generating Run configurations."; "native.updateNow" = "Update Now"; "native.updateParentRepositoryReferences" = "Update parent repository references"; "native.updateProject" = "Update Project"; @@ -9999,6 +10114,9 @@ "run configuration" = "run configuration"; "run.incompleteProjectScanMessage" = "Some project folders could not be read. Existing run configurations were retained. Restore access, then regenerate configurations."; "run.incompleteProjectScanTitle" = "Project scan is incomplete"; +"run.provider.entrypoint" = "Entry point"; +"run.provider.runtimeArguments" = "Runtime arguments"; +"run.provider.runtimeArgumentsHint" = "Each row is one argument. Spaces, quotes and empty values are preserved."; "running for %@" = "running for %@"; "scope managed from %@" = "scope managed from %@"; "scope not updated to %@" = "scope not updated to %@"; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 0330bd2662..7c8c6f7db3 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -169,6 +169,7 @@ "A Java project with no build tool, Maven, or Gradle." = "不使用构建工具,或使用 Maven、Gradle 的 Java 项目。"; "A Spring Boot project from Spring Initializr." = "由 Spring Initializr 生成的 Spring Boot 项目。"; "A basic project with a free structure." = "具有自由结构的基本项目。"; +"A build operation is already active" = "已有构建操作正在运行"; "A custom Agent can follow either your Codex or your Claude configuration." = "自定义 Agent 可以跟随你本机的 Codex 或 Claude 配置。"; "A detailed message with a clear subject and body" = "带有清晰标题和正文的详细说明"; "A host name, an IP address, or a Host alias from ~/.ssh/config. Settings from that file, such as ProxyJump and IdentityFile, still apply." = "主机名、IP 地址,或 ~/.ssh/config 中的 Host 别名。该文件里的 ProxyJump、IdentityFile 等设置仍然生效。"; @@ -181,6 +182,7 @@ "A rebase cannot start with any uncommitted changes, including these unrelated to '%@':" = "变基无法在存在任何未提交更改时开始,包括下列与“%@”无关的更改:"; "A removed Hook cannot be saved." = "无法保存已移除的 Hook。"; "A running local JVM and its JDK jstat tool are required." = "需要运行中的本地 JVM 及其 JDK jstat 工具。"; +"A saved input is revalidated by the existing toolchain resolver when the consumer runs. It is not a verified current installation." = "使用方运行时会由现有工具链解析器重新校验此输入。它不代表已验证的当前安装。"; "A secure connection to %@ could not be established. Check TLS inspection, proxy, VPN, or system certificate settings." = "无法与 %@ 建立安全连接。请检查 TLS 检查、代理、VPN 或系统证书设置。"; "A secure connection to GitHub could not be established. Check TLS inspection, proxy, VPN, or system certificate settings." = "无法与 GitHub 建立安全连接。请检查 TLS 检查、代理、VPN 或系统证书设置。"; "A secure connection to the MCP Registry could not be established. Check the proxy or retry directly." = "无法与 MCP Registry 建立安全连接。请检查代理,或使用直连重试。"; @@ -248,6 +250,7 @@ "Add a memory usage indicator to the status bar" = "在状态栏添加内存占用指示器"; "Add an AI provider in AI Providers first." = "请先在“AI 服务商”中添加服务商。"; "Add another account" = "添加另一个账号"; +"Add argument" = "添加参数"; "Add at least one absolute allowed folder path after the Filesystem package argument." = "请在 Filesystem 包参数后至少添加一个允许访问的文件夹绝对路径。"; "Add at least one absolute folder path after the package argument, one per line. Filesystem can read and write only in the folders you allow. Requires Node.js 24 or later." = "在包参数后至少添加一个文件夹的绝对路径,每行一个。Filesystem 只可读写你允许的文件夹。需要 Node.js 24 或更高版本。"; "Add clickable actions beside document text" = "在文档文本旁添加可点击的操作"; @@ -315,6 +318,7 @@ "Agents run on the Node.js installed on this Mac. Install or update Node.js yourself, then check again." = "Agent 运行在这台 Mac 上已安装的 Node.js 之上。请自行安装或升级 Node.js,然后重新检测。"; "All" = "全部"; "All Branches" = "全部分支"; +"All Build consumers" = "所有构建使用方"; "All References" = "所有引用"; "All Repositories" = "全部仓库"; "All Users" = "所有用户"; @@ -453,6 +457,7 @@ "Automatic" = "自动"; "Automatic discovery" = "自动检测"; "Automatic query ×%lld, identical results" = "自动查询 %lld 次,结果相同"; +"Automatic selection" = "自动选择"; "Automatic tries your SSH agent and keys first, then asks for a password. Lithe asks in a dialog whenever the server needs an answer, such as a password, a passphrase, or a verification code." = "“自动”会先尝试 SSH Agent 和密钥,再询问密码。服务器需要回答时(例如密码、私钥口令或验证码),Lithe 会弹窗询问。"; "Automatic uses a project mvnw first, then detected Maven, then mvnd." = "自动模式会优先使用项目 mvnw,其次使用检测到的 Maven,最后使用 mvnd。"; "Automatically accept all file edits" = "自动批准文件编辑,其他操作仍按权限规则处理。"; @@ -522,9 +527,15 @@ "Build System" = "构建系统"; "Build files changed." = "构建文件已更改。"; "Build files changed. Reload the model to pick up the changes." = "构建文件已更改,请重新加载模型以应用更改。"; +"Build launch did not start" = "构建未能启动"; +"Build option migration could not be verified. The previous configuration has been retained. Reload the build instance to retry." = "无法验证构建选项迁移结果,已保留原配置。请重新加载构建实例以重试。"; +"Build options changed on disk. Reload before saving; your changes have been kept." = "构建选项已在磁盘上变更,请重新加载后保存;你的更改已保留。"; "Build output" = "构建输出"; +"Build plugins are unavailable" = "构建插件不可用"; "Build provider" = "构建 Provider"; +"Build provider model changed. Regenerate Run configurations before running." = "构建 Provider 模型已更改。请重新生成运行配置后再运行。"; "Build system" = "构建系统"; +"Build tool defaults" = "构建工具默认值"; "Build, Execution, Deployment" = "构建、执行、部署"; "Build-system language import is still preparing. Retry when project loading completes." = "构建系统的语言导入仍在准备中。请在项目加载完成后重试。"; "Build-system language import preparation timed out. Retry project loading." = "构建系统的语言导入准备超时。请重新加载项目。"; @@ -665,7 +676,9 @@ "Choose a color theme and whether Lithe follows the system appearance." = "选择配色主题,并设置是否跟随系统外观。"; "Choose a commit format" = "选择提交格式"; "Choose a database connection and then a table from the sidebar." = "请先选择数据库连接,再从侧边栏选择一个表。"; +"Choose a declared build task" = "请选择插件声明的构建任务"; "Choose a default web browser in System Settings, then retry." = "请在系统设置中选择默认网页浏览器,然后重试。"; +"Choose a detected build instance" = "请选择已检测到的构建实例"; "Choose a directory inside the project" = "请选择项目内的目录"; "Choose a language-server executable." = "请选择语言服务器可执行文件。"; "Choose a local or custom provider in the Agent panel settings." = "请在设置窗口的 Agent 页面中选择本机配置或自定义供应商。"; @@ -952,6 +965,7 @@ "Connection timeout" = "连接超时"; "Connections: %lld" = "连接:%lld"; "Console" = "控制台"; +"Consumer" = "使用方"; "Consumers" = "使用方"; "Content" = "内容"; "Content SHA-256" = "内容 SHA-256"; @@ -1090,6 +1104,7 @@ "Could not start the Java test result listener: %@" = "无法启动 Java 测试结果监听器:%@"; "Could not start the MCP command. Check its executable and runtime." = "无法启动 MCP 命令,请检查程序及所需运行时。"; "Could not verify the commit outcome. Review before retrying." = "无法确认提交结果,请检查后再重试。"; +"Could not verify the saved project environment settings. The previous preferences have been kept when possible." = "无法核对已保存的项目环境设置。已尽可能保留原偏好。"; "Could not write %@." = "无法写入 %@。"; "Could not write %@. The previous contents were kept in %@." = "无法写入 %@。之前的内容已保留在 %@。"; "Could not write Java launch argument file at %@: %@" = "无法在 %@ 写入 Java 启动参数文件:%@"; @@ -1198,6 +1213,7 @@ "Default options" = "默认参数"; "Default push behavior" = "默认 Push 行为"; "Default shell" = "默认 Shell"; +"Defaults" = "默认值"; "Defaults for ordinary Fetch in every project. A repository's own Git configuration above still wins where it is set." = "所有项目普通获取的默认值。仓库自身的 Git 配置一旦设置,仍以其为准。"; "Definition" = "定义"; "Definition not found" = "没有找到定义"; @@ -1366,6 +1382,7 @@ "Each built-in preset includes a preview of the generated message." = "三个内置预设都会显示生成信息示例。"; "Each repository has its own commit. Completed steps are kept if another repository fails." = "每个仓库会分别提交。某个仓库失败时,已完成的步骤仍会保留。"; "Each row explains the setting, the value Git is actually using, and where that value comes from." = "每一行都会说明设置用途、Git 实际采用的值,以及这个值来自哪里。"; +"Each row is one argument. Spaces, quotes and empty values are preserved." = "每行是一个独立参数,空格、引号和空值会原样保留。"; "Each submodule is pushed before its parent." = "先推送子模块,再推送父仓库。"; "Each tool uses the first source that has a value: a fixed version, then project version files, then global defaults, then automatic discovery. Installed versions are checked by running each tool's own version command. Enabled version managers report what version files ask for." = "每个工具采用第一个有值的来源,依次为固定版本、项目版本文件、全局默认、自动检测。已安装的版本通过运行该工具自己的版本命令来检查。已启用的版本管理器会报告版本文件所要求的版本。"; "Earlier" = "更早"; @@ -1389,6 +1406,7 @@ "Edit Shortcuts" = "编辑快捷键"; "Edit Watch" = "编辑监视"; "Edit configuration text" = "编辑配置文本"; +"Edit defaults" = "编辑默认值"; "Edit exception breakpoint" = "编辑异常断点"; "Edit files and stage the changes you want to include. Amend and Continue updates this commit; Continue keeps the existing commit." = "修改文件并暂存需要包含的更改。“修订并继续”会更新当前提交;“继续”会保留现有提交。"; "Edit message" = "编辑消息"; @@ -1492,6 +1510,7 @@ "Enter the new symbol name." = "输入新的符号名称。"; "Enter the next function call" = "进入下一个函数调用"; "Enter the one-time code" = "输入一次性代码"; +"Entry point" = "入口"; "Environment" = "环境变量"; "Environment variable and header names must be unique." = "环境变量名和 Header 名不能重复。"; "Environment variables" = "环境变量"; @@ -1518,6 +1537,7 @@ "Excluded from indexing by a parent directory" = "继承父目录的索引排除"; "Exclusion list" = "排除列表"; "Executable" = "可执行文件"; +"Executable file" = "可执行文件"; "Execute Maven Goal" = "执行 Maven 目标"; "Execute Maven goal" = "执行 Maven 目标"; "Execute the next source line" = "执行下一行源代码"; @@ -1687,6 +1707,7 @@ "Found automatically" = "已自动找到"; "Frames are not available" = "当前无可用栈帧"; "Frames rendered per second" = "每秒渲染帧数"; +"Framework service debugging is not available in remote workspaces yet." = "远程工作区暂不支持框架服务调试。"; "Free space" = "剩余空间"; "From %@" = "来自 %@"; "Frontend and other non-Java roots keep the workspace editor and terminal settings. Java and Maven SDKs only apply to the backends listed on the left." = "前端和其他非 Java 根目录沿用工作区的编辑器和终端设置。Java 与 Maven SDK 只作用于左侧列出的后端项目。"; @@ -1958,6 +1979,7 @@ "Install the selected channel" = "安装所选渠道版本"; "Install the tool on the remote host, add it to the login PATH, and refresh the available runtimes." = "请在远程主机上安装工具并加入登录环境的 PATH,然后刷新可用运行环境。"; "Installation" = "安装来源"; +"Installation directory" = "安装目录"; "Installation: %@" = "安装来源:%@"; "Installations found in the locations this tool's plugin declares. Each version comes from the tool's own version command, and the highest one is used. Installations inside the project are never run." = "在插件为该工具声明的位置中找到的安装。每个版本来自该工具自己的版本命令,使用其中最高的版本。项目内的安装永远不会运行。"; "Installations of %@ could not be checked." = "无法检查 %@ 的安装。"; @@ -2143,6 +2165,7 @@ "Linked this conversation to a pull request" = "已将此对话关联到拉取请求"; "Linked worktree" = "关联工作树"; "Listed in" = "所在插件仓库"; +"Lists the project root and directories with version files, fixed versions or saved tool preferences, together with their parents." = "列出项目根目录、具有版本文件、固定版本或已保存工具偏好的目录,以及它们的父目录。"; "Lists the project root and each directory with its own version file or fixed version. A fixed version also applies to the directories below it." = "列出项目根目录,以及自带版本文件或固定版本的目录。固定版本也作用于其下的子目录。"; "Lithe" = "Lithe"; "Lithe Agent" = "Lithe Agent"; @@ -2447,6 +2470,12 @@ "Name and email are written to this repository's Git configuration and override the global values. Existing commits do not change." = "姓名和邮箱写入当前仓库的 Git 配置并覆盖全局值,已有提交不会改变。"; "Name for this filter" = "给这个过滤起个名字"; "Name:" = "名称:"; +"Native configuration" = "原生配置"; +"Native configuration changed on disk. Reload before saving; your draft has been kept." = "磁盘上的原生配置已更改。请重新加载后再保存;已保留你的草稿。"; +"Native configuration changed. Reload before saving; your draft has been kept." = "原生配置已更改。请重新加载后再保存;已保留你的草稿。"; +"Native configuration is busy. Wait for the current operation to finish." = "原生配置正在处理中。请等待当前操作完成。"; +"Native configuration is unavailable." = "原生配置不可用。"; +"Native configuration was saved, but its current effective values could not be verified. Reload before editing again." = "原生配置已保存,但无法核实当前生效值。请重新加载后再编辑。"; "Native installer" = "原生安装器"; "Native instruction file" = "原生指令文件"; "Navigate" = "导航"; @@ -2492,6 +2521,7 @@ "New terminal session" = "新建终端会话"; "New terminal with shell" = "使用 Shell 新建终端"; "New window" = "新窗口"; +"Next Build input" = "下次构建输入"; "Next File" = "下一个文件"; "Next Match" = "下一个匹配项"; "Next console expression" = "下一条表达式"; @@ -2608,6 +2638,7 @@ "No matching skills." = "没有匹配的技能。"; "No matching users" = "没有匹配的用户"; "No models available" = "暂无可用模型"; +"No native configuration has been read." = "尚未读取原生配置。"; "No navigation results" = "没有导航结果"; "No new commits. Pushing creates the remote branch." = "没有新的提交。推送会创建远程分支。"; "No notifications" = "暂无通知"; @@ -2812,6 +2843,7 @@ "Open a project to configure its run configurations." = "打开项目后即可编辑运行配置。"; "Open a project to let connected agents inspect and control it." = "打开项目后,已连接的 Agent 才能检查和控制它。"; "Open a project to manage its skills." = "请先打开项目,再管理项目技能。"; +"Open a project to read its native configuration." = "打开项目以读取其原生配置。"; "Open a project to see how much space its history uses." = "打开项目后可查看其历史占用的空间。"; "Open a project to see its language service." = "打开项目后可查看其语言服务。"; "Open a project to use Git" = "请打开项目以使用 Git"; @@ -3043,6 +3075,8 @@ "Project default" = "项目默认值"; "Project defaults (This Mac)" = "项目默认运行环境(本机)"; "Project environment" = "项目环境"; +"Project environment settings are being saved. Wait before saving again." = "正在保存项目环境设置,请等待完成后再保存。"; +"Project environment settings changed on disk. Reload before saving; your draft has been kept." = "项目环境设置已被外部修改。请重新加载后再保存;你的草稿已保留。"; "Project files" = "项目文件"; "Project identification complete" = "项目识别完成"; "Project identification did not produce a run configuration." = "项目识别没有生成运行配置。"; @@ -3148,6 +3182,7 @@ "Read again" = "重新读取"; "Read declared shortcut files from the editor you select for import" = "读取所选编辑器中声明的快捷键配置文件以供导入"; "Read result" = "读取结果"; +"Read using project tool" = "使用项目工具读取"; "Read, write and search files only in the directories you allow. Requires Node.js 24 or later." = "读写、搜索你允许的目录中的文件。需要 Node.js 24 或更高版本。"; "Read-only" = "只读"; "Read-only file preview" = "只读文件预览"; @@ -3155,6 +3190,7 @@ "Reading Git configuration…" = "正在读取 Git 配置…"; "Reading changes and recent commits." = "正在读取文件更改和最近提交。"; "Reading changes…" = "正在读取更改…"; +"Reading configuration with this project tool requires an explicit action." = "读取配置需要显式运行此项目工具。"; "Reading memory…" = "正在读取内存…"; "Reading project…" = "正在读取项目…"; "Reading registered checkouts." = "正在读取 Git 中注册的检出目录。"; @@ -3241,6 +3277,7 @@ "Reload Range" = "重新加载范围"; "Reload from disk" = "从磁盘重新加载"; "Reload the Core preview and reset the step plan" = "重新加载预览并重置操作计划"; +"Reload the build model before selecting a project, target or model task" = "选择项目、目标或模型任务前,请重新加载构建模型"; "Reload the project model" = "重新加载项目模型"; "Reload this Agent before synchronizing instructions." = "请重新加载此 Agent 的指令后再同步。"; "Reload tool settings" = "重新加载工具设置"; @@ -3513,6 +3550,7 @@ "Runtime" = "运行时长"; "Runtime Environment" = "运行环境"; "Runtime Environment — Isolated Preview" = "运行环境 — 隔离预览"; +"Runtime arguments" = "运行时参数"; "Runtime discovery failed" = "运行环境探测失败"; "Runtime used by the language server (installed on this Mac)" = "语言服务器使用的运行时(本机已安装)"; "Rust Core cache-retention planning is unavailable." = "Rust Core 的缓存保留规划不可用。"; @@ -3570,6 +3608,7 @@ "Saved in .lithe/run/configurations.json for the whole team. Local JDK paths are never shared." = "保存在 .lithe/run/configurations.json,供整个团队使用。本机 JDK 路径不会共享。"; "Saved in .lithe/run/local.json and excluded from Git." = "保存在 .lithe/run/local.json,并已从 Git 中排除。"; "Saved in Keychain" = "已保存在钥匙串中"; +"Saved input" = "已保存的输入"; "Saved only for this project on this computer in .lithe/run/local.json and excluded from Git." = "仅为这台电脑上的此项目保存在 .lithe/run/local.json,并已从 Git 中排除。"; "Saved to %@" = "已保存到 %@"; "Saved, not verified" = "已保存,未验证"; @@ -3872,6 +3911,7 @@ "Some installation versions were not checked in time." = "部分安装的版本未能及时检查。"; "Some project folders could not be read. Existing run configurations were retained. Restore access, then regenerate configurations." = "部分项目目录无法读取,已有运行配置已保留。请恢复访问权限后重新识别运行配置。"; "Some repositories could not be refreshed. Displayed changes may be out of date." = "部分仓库无法刷新,显示的更改可能已过期。"; +"Some saved run configurations cannot be migrated yet. Keep the existing files and retry after updating Lithe and its build plugin." = "部分已保存的运行配置暂时无法迁移。请保留现有文件,在更新 Lithe 及其构建插件后重试。"; "Some selected diffs could not be read. Refresh Git changes and retry." = "部分勾选文件的差异读取失败,请刷新 Git 更改后重试。"; "Some selected files belong to another version control root. Refresh and select them again." = "部分所选文件属于其他版本控制根目录。请刷新后重新选择。"; "Some settings have unsupported values and were skipped." = "部分设置值不受支持,已跳过。"; @@ -4172,10 +4212,14 @@ "The branch could not be published" = "无法发布该分支"; "The branch now contains the completed plan. Its original history remains available through the recovery reference." = "操作计划已执行完成。仍可通过恢复引用查看分支的原始历史。"; "The breakdown needs Model call statistics (on by default). After turning it on, reconnect and send a message." = "分项需要开启“模型调用统计”(默认开启)。开启后请重新连接并发送消息。"; +"The build option is no longer available. Reload the build instance." = "此构建选项已不可用。请重新加载构建实例。"; +"The build option value is invalid. Inspect its current kind and choices." = "构建选项值无效。请查看其当前类型和可选值。"; +"The build options could not be saved or verified. Reload before retrying." = "无法保存或验证构建选项,请重新加载后重试。"; "The build provider failed to start." = "构建 Provider 启动失败。"; "The build provider returned an invalid launch plan." = "构建提供方返回了无效的启动计划。"; "The build provider stopped before it was ready." = "构建 Provider 在就绪前已停止。"; "The build provider's debug program or adapter is unavailable. Build it again and check that the plugin is enabled." = "构建插件的调试程序或适配器不可用。请重新构建,并检查插件是否已启用。"; +"The build selection is no longer available" = "所选构建项已不可用"; "The build tool exited with code %lld." = "构建工具已退出,退出码 %lld。"; "The build tool output is too large or not UTF-8." = "构建工具的输出过大或不是 UTF-8 编码。"; "The build tool timed out." = "构建工具超时。"; @@ -4249,6 +4293,7 @@ "The file or its hard links changed while saving. Your edits are still unsaved. Check the file and save again." = "保存期间文件或硬链接发生了变化。编辑内容尚未保存,请检查文件后重新保存。"; "The file system that holds the home directory." = "主目录所在的文件系统。"; "The file will be created when you save." = "保存时将创建此文件。"; +"The framework did not open its JVM debug port before the deadline. Check the Run output." = "框架未在期限内开放 JVM 调试端口,请查看运行输出。"; "The function call was cancelled." = "函数调用已取消。"; "The generated message will replace the text currently in the editor." = "生成的提交信息将替换编辑器中的当前文本。"; "The generated title or description would replace text you already entered." = "生成的标题或描述可能会替换你已经输入的内容。"; @@ -4284,6 +4329,7 @@ "The name of the artifact the build produces." = "构建产物的名称。"; "The native Hook configuration must be valid JSON. Existing contents were kept." = "原生 Hook 配置必须是有效 JSON,已有内容已保留。"; "The native Hook event must contain an array. Existing contents were kept." = "原生 Hook 事件必须是数组,已有内容已保留。"; +"The native configuration must be a writable file smaller than 2 MB." = "原生配置必须是小于 2 MB 的可写文件。"; "The native hooks field must be a JSON object. Existing contents were kept." = "原生 hooks 字段必须是 JSON 对象,已有内容已保留。"; "The native panel state exceeds 64 KiB." = "原生面板状态超过 64 KiB。"; "The official Lithe plugin repository cannot be removed. Disable it instead." = "官方 Lithe 插件仓库无法移除,请改为停用。"; @@ -4374,6 +4420,8 @@ "The resource names a root this window does not offer." = "资源指定的根目录不在此窗口提供的范围内。"; "The resource path must be relative, use \"/\" separators, and not contain \".\" or \"..\"." = "资源路径必须是相对路径,使用“/”分隔,且不能包含“.”或“..”。"; "The response stopped at the model's token limit." = "回复已达到模型的 token 上限,已停止。"; +"The saved build option could not be verified. Reload before editing again." = "无法核实已保存的构建选项。请重新加载后再编辑。"; +"The saved build options are invalid. The original configuration has been kept." = "已保存的构建选项无效,原配置已保留。"; "The saved provider run configuration is unavailable." = "已保存的提供方运行配置不可用。"; "The saved toolchain settings are invalid." = "已保存的工具链设置无效。"; "The selected AI provider has an invalid API URL or model." = "所选 AI 服务商的 API 地址或模型无效。"; @@ -4394,6 +4442,7 @@ "The selected language-server path is not executable: %@" = "所选语言服务器路径不可执行:%@"; "The selected table has no columns to display." = "所选表没有可显示的列。"; "The selected test identifier is invalid." = "所选测试标识符无效。"; +"The selected toolchain is unavailable." = "所选工具链不可用。"; "The selected versions match" = "所选版本相同"; "The server asked again. Check the previous answer." = "服务器再次提问,请检查上一次的回答。"; "The server rejected the credentials. Check the user name and the authentication method." = "服务器拒绝了凭据,请检查用户名和认证方式。"; @@ -4459,6 +4508,7 @@ "These choices apply to this Fetch only. Git configuration and credentials remain unchanged." = "这些选项仅用于本次获取。Git 配置和凭据保持不变。"; "These defaults apply only to this project on this computer. Run configurations inherit them unless overridden. Use the Project save scope in a run configuration to share settings with your team." = "这些默认设置仅作用于这台电脑上的此项目。运行配置默认继承,单独设置时可覆盖。需要与团队共享的设置,请在运行配置中选择“项目”保存范围。"; "These files will be moved to the macOS Trash." = "这些文件将移到 macOS 废纸篓。"; +"These machine-local choices apply to Build providers and their Run configurations on the next operation. Saving does not probe or execute a tool. Inherited choices follow later default changes." = "这些本机选择会在构建提供者及其运行配置的下一次操作中生效。保存不会探测或执行工具。继承项会随默认值的后续变化更新。"; "These overrides affect only this run configuration. Clear a path to inherit the project environment." = "以下覆盖值仅影响当前运行配置。清空路径可恢复继承项目环境。"; "These processes left their terminal or tool to keep running in the background, like daemons and dev servers. Checked items end when Lithe quits; unchecked items keep running." = "这些进程已脱离所在的终端或工具,在后台独立运行,例如守护进程和开发服务器。勾选的项目会在 Lithe 退出时结束;未勾选的项目继续运行。"; "These programs were started in Lithe before it closed unexpectedly. Checked items are ended now; unchecked items keep running and are not tracked anymore." = "这些程序是 Lithe 意外关闭前在 Lithe 中启动的。勾选的项目会立即结束;未勾选的项目继续运行,Lithe 之后不再管理它们。"; @@ -4515,6 +4565,7 @@ "This folder cannot be read." = "无法读取此文件夹。"; "This folder is empty" = "此文件夹为空"; "This host cannot receive a compatible plugin package. Update the remote server." = "此主机无法接收兼容的插件包,请更新远程服务。"; +"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." = "此面板要求工作区中恰好有一个匹配的插件实例。"; "This permanently deletes uncommitted and untracked files in '%@'. The branch itself is kept." = "这会永久删除“%@”中的未提交文件和未跟踪文件,但会保留对应分支。"; @@ -4530,6 +4581,7 @@ "This plugin sent messages too quickly." = "此插件发送消息过快。"; "This preview contains changes that have not been officially released and may be unstable." = "此预览版包含尚未正式发布的改动,可能不稳定。"; "This project" = "此项目"; +"This project has no matching build-system instance." = "此项目没有匹配的构建系统实例。"; "This project is not a Git repository" = "当前项目不是 Git 仓库"; "This project window is no longer available." = "此项目窗口已不可用。"; "This rebase session is no longer active in the current repository." = "此变基会话在当前仓库中已失效。"; @@ -4606,6 +4658,7 @@ "Toolbar Actions" = "工具栏操作"; "Toolchain Sources" = "环境来源"; "Toolchain settings are not available for remote workspaces yet." = "远程工作区暂不支持工具链设置。"; +"Toolchain settings changed while loading. Refresh and try again." = "加载期间工具链设置发生变化。请刷新后重试。"; "Tools" = "工具"; "Tools sent" = "发送的工具"; "Total" = "总计"; @@ -4732,6 +4785,7 @@ "Update source" = "更新源"; "Update sources must use https://. http:// is only allowed for this Mac (localhost)." = "更新源必须使用 https://;http:// 只允许用于本机(localhost)。"; "Update status" = "更新状态"; +"Update the Maven Build plugin before generating Run configurations." = "请先更新 Maven Build 插件,再生成运行配置。"; "Update this CLI using its original installer, then check again." = "请使用原安装器更新此 CLI,然后重新检测。"; "Update to" = "可更新至"; "Update to %@" = "更新到 %@"; @@ -4759,6 +4813,7 @@ "Use AI worktree directory (/private/tmp)" = "使用 AI 工作树目录(/private/tmp)"; "Use AutoBuild" = "使用 AutoBuild"; "Use AutoBuild Rolling?" = "使用 AutoBuild 滚动版?"; +"Use Build execute to start another build" = "请使用构建执行工具开始新的构建"; "Use Default Messages" = "使用默认消息"; "Use Git configuration" = "使用 Git 配置"; "Use Git from PATH" = "使用 PATH 中的 Git"; @@ -4771,11 +4826,14 @@ "Use a valid file or directory name" = "请输入有效的文件或目录名称"; "Use an Agent in your project" = "在项目中使用 Agent"; "Use automatic value" = "使用自动值"; +"Use consumer default" = "使用方的工作区默认值"; "Use credential helper" = "使用凭据助手"; "Use detected JDK" = "使用检测到的 JDK"; +"Use directory tool default" = "使用当前目录的工具默认值"; "Use fast processing" = "使用快速处理模式"; "Use form" = "使用表单"; "Use full HTTP path for credentials" = "凭据使用完整 HTTP 路径"; +"Use inherited choice" = "沿用继承选择"; "Use letters, digits, dots, dashes or underscores. The name must start with a letter or digit." = "只能使用字母、数字、点、短横线或下划线,且必须以字母或数字开头。"; "Use local configuration or add your own provider." = "使用本机配置或添加自定义供应商。"; "Use mvnw or detected Maven" = "使用 mvnw 或检测到的 Maven"; @@ -4794,6 +4852,7 @@ "Use the standard response speed" = "使用标准响应速度"; "Use this when a repository or Git default needs a one-off adjustment." = "仅在需要临时调整仓库或 Git 默认值时使用。"; "Use up to 64 lowercase letters, digits and single hyphens for the skill name." = "技能名称最多 64 个字符,请使用小写字母、数字和单个连字符。"; +"Use workspace default" = "使用工作区默认值"; "Use your local ChatGPT account. No API key or URL is needed." = "使用本机已登录的 ChatGPT 账号,无需 API Key 或 URL。"; "Used" = "已用"; "Used %@" = "已使用 %@"; @@ -4991,6 +5050,7 @@ "aiCommit.outputTokenFieldInfo" = "Lithe 使用此字段发送提交信息请求。旧网关请选择 max_tokens。"; "avg" = "平均"; "basic Latin letters" = "基本拉丁字母"; +"build.window.addArgument" = "添加参数"; "characters used by the interface and your files" = "界面和文件用到的字符"; "chars" = "字符"; "checkout" = "检出"; @@ -5126,6 +5186,14 @@ "git.unifiedView" = "统一视图"; "history.clearFailed" = "无法清空此项目的本地历史。请检查存储目录权限后重试。"; "https://github.com/owner/repository" = "https://github.com/owner/repository"; +"ide.build.busy" = "已有构建操作正在运行"; +"ide.build.chooseInstance" = "请选择已检测到的构建实例"; +"ide.build.chooseTask" = "请选择插件声明的构建任务"; +"ide.build.launchFailed" = "构建未能启动"; +"ide.build.reloadSelection" = "选择项目、目标或模型任务前,请重新加载构建模型"; +"ide.build.restart" = "请使用构建执行工具开始新的构建"; +"ide.build.selectionExpired" = "所选构建项已不可用"; +"ide.build.unavailable" = "构建插件不可用"; "indexing.autoGit" = "根据 Git 忽略规则自动排除目录"; "indexing.cancelExclusion" = "取消排除"; "indexing.configureFailed" = "无法应用项目索引排除策略,请重新打开项目后重试。"; @@ -5294,6 +5362,9 @@ "mac.workbench.unhide" = "取消隐藏"; "macOS could not prepare the update disk image. Download the release manually and try again." = "macOS 无法准备更新磁盘映像,请手动下载版本后重试。"; "macOS could not prepare the update disk image. Open the Release page and install it manually." = "macOS 无法准备更新磁盘映像。请打开 Release 页面手动安装。"; +"macos.buildProvider.jvmDebugPortTimeout" = "框架未在期限内开放 JVM 调试端口,请查看运行输出。"; +"macos.buildProvider.jvmDebugRemoteUnavailable" = "远程工作区暂不支持框架服务调试。"; +"macos.native.savedRunMigrationNeedsUpdate" = "部分已保存的运行配置暂时无法迁移。请保留现有文件,在更新 Lithe 及其构建插件后重试。"; "main Java" = "主代码"; "main resources" = "主资源"; "markdown.plantumlRenderFailed" = "PlantUML 无法渲染此图表,已显示源码。"; @@ -5653,6 +5724,12 @@ "native.buildFilesChanged" = "%1$@ 构建文件已更改"; "native.buildFilesChanged.variant2" = "构建文件已更改。"; "native.buildFilesChangedReloadTheModelToPickUpTheChanges" = "构建文件已更改,请重新加载模型以应用更改。"; +"native.buildOptionInvalid" = "构建选项值无效。请查看其当前类型和可选值。"; +"native.buildOptionMigrationCouldNotBeVerified" = "无法验证构建选项迁移结果,已保留原配置。请重新加载构建实例以重试。"; +"native.buildOptionReadBackFailed" = "无法核实已保存的构建选项。请重新加载后再编辑。"; +"native.buildOptionUnavailable" = "此构建选项已不可用。请重新加载构建实例。"; +"native.buildOptionsConflict" = "构建选项已在磁盘上变更,请重新加载后保存;你的更改已保留。"; +"native.buildOptionsSaveFailed" = "无法保存或验证构建选项,请重新加载后重试。"; "native.buildOutput" = "构建输出"; "native.buildOutput.variant2" = "构建输出"; "native.buildOutputs" = "构建产物"; @@ -6165,6 +6242,7 @@ "native.couldNotSaveTheConnections" = "无法保存连接:%1$@"; "native.couldNotStartTheJavaTestResultListener" = "无法启动 Java 测试结果监听器:%1$@"; "native.couldNotStartTheMcpCommandCheckItsExecutableAndRuntime" = "无法启动 MCP 命令,请检查程序及所需运行时。"; +"native.couldNotVerifySavedProjectEnvironment" = "无法核对已保存的项目环境设置。已尽可能保留原偏好。"; "native.couldNotVerifyTheCommitOutcomeReviewBeforeRetrying" = "无法确认提交结果,请检查后再重试。"; "native.couldNotWrite" = "无法写入 %1$@。"; "native.couldNotWriteJavaLaunchArgumentFileAt" = "无法在 %1$@ 写入 Java 启动参数文件:%2$@"; @@ -7606,6 +7684,19 @@ "native.name.variant2" = "名称:"; "native.nameAndEmailAreWrittenToTheGlobalGitConfigurationExistingCommitsDoNotChange" = "姓名和邮箱写入全局 Git 配置,已有提交不会改变。"; "native.nameAndEmailAreWrittenToThisRepositorySGitConfigurationAndOverrideTheGlobalVal4aca07ef08" = "姓名和邮箱写入当前仓库的 Git 配置并覆盖全局值,已有提交不会改变。"; +"native.nativeConfigurationBusy" = "原生配置正在处理中。请等待当前操作完成。"; +"native.nativeConfigurationDiskConflict" = "磁盘上的原生配置已更改。请重新加载后再保存;已保留你的草稿。"; +"native.nativeConfigurationExplicitRead" = "读取配置需要显式运行此项目工具。"; +"native.nativeConfigurationFileRequired" = "原生配置必须是小于 2 MB 的可写文件。"; +"native.nativeConfigurationNoInstance" = "此项目没有匹配的构建系统实例。"; +"native.nativeConfigurationNotRead" = "尚未读取原生配置。"; +"native.nativeConfigurationOpenProject" = "打开项目以读取其原生配置。"; +"native.nativeConfigurationReadBackFailed" = "原生配置已保存,但无法核实当前生效值。请重新加载后再编辑。"; +"native.nativeConfigurationReadUsingProjectTool" = "使用项目工具读取"; +"native.nativeConfigurationSourceReadOnly" = "此原生配置来源为只读。不能编辑已安装的应用和插件包。"; +"native.nativeConfigurationStale" = "原生配置已更改。请重新加载后再保存;已保留你的草稿。"; +"native.nativeConfigurationTitle" = "原生配置"; +"native.nativeConfigurationUnavailable" = "原生配置不可用。"; "native.nativeInstaller" = "原生安装器"; "native.nativeInstructionFile" = "原生指令文件"; "native.navigate" = "导航"; @@ -8141,6 +8232,8 @@ "native.projectDefault" = "项目默认值"; "native.projectDefaultsThisMac" = "项目默认运行环境(本机)"; "native.projectEnvironment" = "项目环境"; +"native.projectEnvironmentSaveInProgress" = "正在保存项目环境设置,请等待完成后再保存。"; +"native.projectEnvironmentSettingsChangedOnDisk" = "项目环境设置已被外部修改。请重新加载后再保存;你的草稿已保留。"; "native.projectErrors" = "项目错误"; "native.projectFiles" = "项目文件"; "native.projectIdentificationComplete" = "项目识别完成"; @@ -8620,6 +8713,7 @@ "native.saveTitle" = "保存标题"; "native.saved" = "已保存"; "native.savedBreakpointsCouldNotBeRead" = "无法读取已保存的断点。"; +"native.savedBuildOptionsInvalid" = "已保存的构建选项无效,原配置已保留。"; "native.savedDebuggerSteppingFiltersCouldNotBeRead" = "无法读取已保存的调试器步进过滤器。"; "native.savedInKeychain" = "已保存在钥匙串中"; "native.savedInLitheRunConfigurationsJsonForTheWholeTeamLocalJDKPathsAreNeverShared" = "保存在 .lithe/run/configurations.json,供整个团队使用。本机 JDK 路径不会共享。"; @@ -8718,6 +8812,7 @@ "native.selectedDirectory" = "当前选择的目录"; "native.selectedFromDiskThePublisherAndRepositoryHaveNotBeenVerified" = "从本地磁盘选择,尚未验证发布者与源码仓库。"; "native.selectedRuntime" = "已选择"; +"native.selectedToolchainUnavailable" = "所选工具链不可用。"; "native.selectedVersion" = "已选版本"; "native.selfUpdateIsOnlyAvailableWhenLitheIsRunningFromAPackagedLitheApp" = "只有从打包后的 Lithe.app 运行时才支持自动覆盖更新。"; "native.send" = "发送"; @@ -8932,6 +9027,7 @@ "native.staged.variant2" = "已暂存"; "native.stagedChanges" = "已暂存更改"; "native.stagedFilesChangedBeforeGenerationFinished" = "生成完成前,已暂存的文件发生了变化"; +"native.staleProviderRunGeneration" = "构建 Provider 模型已更改。请重新生成运行配置后再运行。"; "native.staleRecord" = "陈旧记录"; "native.standard" = "标准"; "native.startADebugSessionToRunDebuggerCommands" = "启动调试会话后即可运行调试器命令"; @@ -9524,6 +9620,24 @@ "native.toolWindow" = "工具窗口"; "native.toolWindows" = "工具窗口"; "native.toolbarActions" = "工具栏操作"; +"native.toolchainPreferences.allBuildConsumers" = "所有构建使用方"; +"native.toolchainPreferences.automaticSelection" = "自动选择"; +"native.toolchainPreferences.buildToolDefaults" = "构建工具默认值"; +"native.toolchainPreferences.consumer" = "使用方"; +"native.toolchainPreferences.defaultsBadge" = "默认值"; +"native.toolchainPreferences.editDefaults" = "编辑默认值"; +"native.toolchainPreferences.executableFile" = "可执行文件"; +"native.toolchainPreferences.installationDirectory" = "安装目录"; +"native.toolchainPreferences.localBuildPreferencesInfo" = "这些本机选择会在构建提供者及其运行配置的下一次操作中生效。保存不会探测或执行工具。继承项会随默认值的后续变化更新。"; +"native.toolchainPreferences.nextBuildInput" = "下次构建输入"; +"native.toolchainPreferences.preferenceDirectoriesInfo" = "列出项目根目录、具有版本文件、固定版本或已保存工具偏好的目录,以及它们的父目录。"; +"native.toolchainPreferences.savedInput" = "已保存的输入"; +"native.toolchainPreferences.savedInputInfo" = "使用方运行时会由现有工具链解析器重新校验此输入。它不代表已验证的当前安装。"; +"native.toolchainPreferences.sourceChangedDuringLoad" = "加载期间工具链设置发生变化。请刷新后重试。"; +"native.toolchainPreferences.useConsumerDefault" = "使用方的工作区默认值"; +"native.toolchainPreferences.useDirectoryToolDefault" = "使用当前目录的工具默认值"; +"native.toolchainPreferences.useInheritedChoice" = "沿用继承选择"; +"native.toolchainPreferences.useWorkspaceDefault" = "使用工作区默认值"; "native.toolchainSettings.answeredIn" = "%1$lld 毫秒内已回答"; "native.toolchainSettings.automaticDiscovery" = "自动检测"; "native.toolchainSettings.automaticStepInfo" = "在插件为该工具声明的位置中找到的安装。每个版本来自该工具自己的版本命令,使用其中最高的版本。项目内的安装永远不会运行。"; @@ -9725,6 +9839,7 @@ "native.updateCurrentBranch" = "更新当前分支"; "native.updateLitheToUseThisConfigurationVersion" = "请更新 Lithe 以使用此版本的配置。"; "native.updateLog" = "更新日志"; +"native.updateMavenBeforeRunGeneration" = "请先更新 Maven Build 插件,再生成运行配置。"; "native.updateNow" = "立即更新"; "native.updateParentRepositoryReferences" = "更新父仓库引用"; "native.updateProject" = "更新项目"; @@ -9999,6 +10114,9 @@ "run configuration" = "运行配置"; "run.incompleteProjectScanMessage" = "部分项目目录无法读取,已有运行配置已保留。请恢复访问权限后重新识别运行配置。"; "run.incompleteProjectScanTitle" = "项目扫描不完整"; +"run.provider.entrypoint" = "入口"; +"run.provider.runtimeArguments" = "运行时参数"; +"run.provider.runtimeArgumentsHint" = "每行是一个独立参数,空格、引号和空值会原样保留。"; "running for %@" = "已运行 %@"; "scope managed from %@" = "依赖管理前 scope 为 %@"; "scope not updated to %@" = "未提升为 %@ scope"; diff --git a/macos/Sources/Lithe/Application/Composition/AppModelFeatureGraph.swift b/macos/Sources/Lithe/Application/Composition/AppModelFeatureGraph.swift index 5f0636a725..71614b53b6 100644 --- a/macos/Sources/Lithe/Application/Composition/AppModelFeatureGraph.swift +++ b/macos/Sources/Lithe/Application/Composition/AppModelFeatureGraph.swift @@ -95,7 +95,7 @@ final class AppModelFeatureGraph { runtime = RuntimeSettingsFeatureModel(service: services.projectRuntimeService) toolchainSettings = ToolchainSettingsFeatureModel( loader: services.toolchainSettingsLoader, loginShell: services.loginShellEnvironmentInspector, - inputWatcher: services.toolchainInputWatcher + inputWatcher: services.toolchainInputWatcher, preferences: services.toolchainPreferences ) languageTooling = LanguageToolingFeatureModel( catalogSource: services.languageProviderCatalogSource, diff --git a/macos/Sources/Lithe/Application/Composition/AppServices.swift b/macos/Sources/Lithe/Application/Composition/AppServices.swift index 5cfe567caf..785dfeaa5f 100644 --- a/macos/Sources/Lithe/Application/Composition/AppServices.swift +++ b/macos/Sources/Lithe/Application/Composition/AppServices.swift @@ -46,6 +46,7 @@ final class AppServices { var projectGeneratorSources: (@MainActor () -> [ProjectGeneratorPluginSource])? /// Read-only plugin toolchain projection; absent until platform composition. var toolchainSettingsLoader: (any ToolchainSettingsLoading)? + var toolchainPreferences: (any ToolchainPreferencesStoring)? var toolchainInputWatcher: (any ToolchainInputWatching)? /// Existing workspace tree projection; settings must not start a second filesystem scan. var toolchainSettingsDirectories: (@MainActor () -> [String])? diff --git a/macos/Sources/Lithe/Application/Composition/ExecutionModuleCoordinator.swift b/macos/Sources/Lithe/Application/Composition/ExecutionModuleCoordinator.swift index b0689e0d47..6fc3cd0a6c 100644 --- a/macos/Sources/Lithe/Application/Composition/ExecutionModuleCoordinator.swift +++ b/macos/Sources/Lithe/Application/Composition/ExecutionModuleCoordinator.swift @@ -60,7 +60,6 @@ final class ExecutionModuleCoordinator { func activateAccess() async -> ExecutionFeatureAccess? { guard let capability = await activate() else { return nil } return ExecutionFeatureAccess( - mavenFeature: capability.mavenFeature, runFeature: capability.runFeature, tests: capability.testService, projectDevelopment: capability.projectDevelopment, @@ -69,19 +68,16 @@ final class ExecutionModuleCoordinator { } func resetFeatures( - maven: MavenFeatureModel?, run: RunFeatureModel?, tests: LanguageTestService?, buildSystems: BuildSystemsFeatureModel? ) { - maven?.reset() run?.reset() tests?.reset() buildSystems?.reset(workspaceURL: nil) } - func stopFeatures(maven: MavenFeatureModel?, run: RunFeatureModel?) { - maven?.stop() + func stopFeatures(run: RunFeatureModel?) { run?.stop() } diff --git a/macos/Sources/Lithe/Application/Features/BuildNativeConfigurationDraft.swift b/macos/Sources/Lithe/Application/Features/BuildNativeConfigurationDraft.swift new file mode 100644 index 0000000000..e983dddf07 --- /dev/null +++ b/macos/Sources/Lithe/Application/Features/BuildNativeConfigurationDraft.swift @@ -0,0 +1,49 @@ +import Foundation +import LitheCoreContracts +import LitheModuleAPI + +/// A source's editable raw values. Inherited effective values are populated in +/// controls but remain absent from edits until the user enables an override. +struct BuildNativeConfigurationDraft: Equatable { + let source: BuildNativeConfiguration.Source + private(set) var values: [String: ToolingJSONValue] + private(set) var overrides: Set + + init(source: BuildNativeConfiguration.Source) { + self.source = source + values = Dictionary(uniqueKeysWithValues: source.fields.map { + ($0.id, $0.value == .null ? $0.effectiveValue : $0.value) + }) + overrides = Set(source.fields.filter { $0.value != .null }.map(\.id)) + } + + var edits: BuildNativeConfigurationEdits { + var changed: [String: ToolingJSONValue] = [:] + for field in source.fields { + let next = overrides.contains(field.id) ? (values[field.id] ?? .null) : .null + if next != field.value { changed[field.id] = next } + } + return BuildNativeConfigurationEdits(sourceID: source.id, revision: source.revision, values: changed) + } + var isDirty: Bool { !edits.values.isEmpty } + + mutating func setOverride(_ enabled: Bool, fieldID: String) { + if enabled { overrides.insert(fieldID) } else { overrides.remove(fieldID) } + } + mutating func setValue(_ value: ToolingJSONValue, fieldID: String) { + guard source.fields.contains(where: { $0.id == fieldID }) else { return } + values[fieldID] = value + } + func text(_ fieldID: String) -> String { + switch values[fieldID] { + case .string(let value): value + case .array(let items): items.compactMap { if case .string(let value) = $0 { value } else { nil } }.joined(separator: "\n") + default: "" + } + } + mutating func setText(_ text: String, field: BuildNativeConfiguration.Field) { + if field.kind == .stringList { + setValue(.array(text.split(whereSeparator: \.isNewline).map { .string(String($0)) }), fieldID: field.id) + } else { setValue(.string(text), fieldID: field.id) } + } +} diff --git a/macos/Sources/Lithe/Application/Features/ExecutionFeatureAliases.swift b/macos/Sources/Lithe/Application/Features/ExecutionFeatureAliases.swift index 948d2c8ff5..6b3ba5cc37 100644 --- a/macos/Sources/Lithe/Application/Features/ExecutionFeatureAliases.swift +++ b/macos/Sources/Lithe/Application/Features/ExecutionFeatureAliases.swift @@ -1,6 +1,5 @@ import LitheExecutionModule -typealias MavenFeatureModel = LitheExecutionModule.MavenFeatureModel typealias RunFeatureModel = LitheExecutionModule.RunFeatureModel typealias ProjectDevelopmentFeatureModel = LitheExecutionModule.ProjectDevelopmentFeatureModel typealias RunConfigurationGenerationIntent = LitheExecutionModule.RunConfigurationGenerationIntent diff --git a/macos/Sources/Lithe/Application/Features/IdeCapabilitiesFeatureModel.swift b/macos/Sources/Lithe/Application/Features/IdeCapabilitiesFeatureModel.swift index 6255be7d51..3c1d29c81d 100644 --- a/macos/Sources/Lithe/Application/Features/IdeCapabilitiesFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/IdeCapabilitiesFeatureModel.swift @@ -3,10 +3,23 @@ import Combine import Foundation import LitheCoreContracts import LitheExecutionModule +import LitheModuleAPI /// Workspace-bound application entry points shared by MCP and host API consumers. @MainActor final class IdeCapabilitiesFeatureModel: ObservableObject { + /// Only workflows implemented by this product are advertised. Schema types + /// and permissions remain in the shared catalog; this list can only narrow it. + static let toolExposure: [[String: Any]] = [ + "lithe_project_inspect", "lithe_environment_inspect", "lithe_environment_configure", + "lithe_run_list", "lithe_run_start", "lithe_run_restart", "lithe_operations_list", + "lithe_operation_output", "lithe_operation_stop", "lithe_build_inspect", + "lithe_build_reload", "lithe_build_configure", "lithe_build_execute" + ].map { name in + name == "lithe_environment_configure" + ? ["name": name, "arguments": ["javaHomePath", "mavenExecutablePath", "mavenJavaHomePath"]] + : ["name": name] + } private let logSink: (any LogSink)? private weak var model: (any IdeCapabilityActions)? @Published private(set) var isEnabled = false @@ -34,7 +47,7 @@ final class IdeCapabilitiesFeatureModel: ObservableObject { return } do { - let result = try transport.open(workspace: identity.url, permissions: permissions) + let result = try transport.open(workspace: identity.url, permissions: permissions, toolExposure: Self.toolExposure) guard let id = result["hostID"] as? String, let config = result["configuration"] else { throw failure("Invalid MCP connection response") } hostID = id workspace = identity @@ -124,7 +137,7 @@ final class IdeCapabilitiesFeatureModel: ObservableObject { func call(_ name: String, arguments: [String: Any]) async -> [String: Any] { guard isEnabled, let grant = hostID, let model, let workspace, model.isCurrentWorkspace(workspace), let transport else { return failed("WORKSPACE_CLOSED", "The authorized project is no longer open") } do { - let validation = try transport.control("validate", arguments: ["name": name, "arguments": arguments, "permissions": permissions]) + let validation = try transport.control("validate", arguments: ["name": name, "arguments": arguments, "permissions": permissions, "toolExposure": Self.toolExposure]) guard validation["ok"] as? Bool == true else { return validation } let mutates = validation["mutation"] as? Bool == true if mutates && mutationInFlight { return failed("BUSY", "Another configuration or launch request is in progress") } @@ -144,21 +157,16 @@ final class IdeCapabilitiesFeatureModel: ObservableObject { private func execute(_ name: String, _ args: [String: Any], model: any IdeCapabilityActions, identity: WorkspaceIdentity, grant: String) async throws -> [String: Any] { if name == "lithe_project_inspect" { return ["workspaceID": model.id.uuidString, "name": model.projectName, "permissions": permissions] } - guard let execution = await model.activateExecutionModule() else { return failed("NOT_AVAILABLE", "The execution module is disabled") } - try check(model, identity, grant: grant) - let run = execution.runFeature - let maven = execution.mavenFeature - if ["lithe_maven_inspect", "lithe_run_list", "lithe_maven_execute"].contains(name), !run.projectLoadState.hasLoadedDocuments(for: identity.url) { - _ = await model.ensureRunProjectReady(run, for: identity) - try check(model, identity, grant: grant) - } - switch name { - case "lithe_environment_inspect", "lithe_environment_configure": - await model.prepareProjectRuntimeSettings() + if name == "lithe_environment_inspect" || name == "lithe_environment_configure" { + await model.runtimeFeature.ensureRuntimesDiscovered() try check(model, identity, grant: grant) + guard !model.runtimeFeature.hasChanges, !model.runtimeFeature.isSaving else { + return failed("BUSY", "Save or cancel the open environment settings draft before using this capability") + } + guard model.runtimeFeature.reloadToolchainSettings() else { + throw failure(model.runtimeFeature.settingsError ?? L10n.string("Could not verify the saved project environment settings. The previous preferences have been kept when possible.")) + } if name == "lithe_environment_configure" { - guard run.projectLoadState.hasLoadedDocuments(for: identity.url), !run.isLoadingProject else { return failed("NOT_READY", "Project settings are still loading") } - if maven.project == nil && (args["settingsPath"] != nil || args["localRepositoryPath"] != nil) { return failed("NOT_READY", "No Maven project is loaded") } model.runtimeFeature.updateSettings { settings in if let value = args["javaHomePath"] as? String { settings.javaHomePath = value } if let value = args["mavenJavaHomePath"] as? String { settings.mavenJavaHomePath = value } @@ -166,63 +174,88 @@ final class IdeCapabilitiesFeatureModel: ObservableObject { settings.mavenHomeSelection = value.isEmpty ? .automatic : value == "mvnw" ? .wrapper : .custom settings.mavenHomePath = value } - if let value = args["settingsPath"] as? String { settings.mavenSettingsPath = value } - if let value = args["localRepositoryPath"] as? String { settings.mavenLocalRepositoryPath = value } } - model.persistProjectRuntimeSettings() - if let error = run.configurationSaveError { throw failure(error) } - if maven.project != nil, let error = await maven.saveConfiguration() { throw failure(error) } + guard model.runtimeFeature.saveSettings() else { + throw failure(model.runtimeFeature.settingsError ?? L10n.string("Could not verify the saved project environment settings. The previous preferences have been kept when possible.")) + } + model.runtimeFeature.finishSaving() try check(model, identity, grant: grant) } return environment(model, root: identity.url) - case "lithe_maven_inspect": - let project: [String: Any]? = maven.project.map { - ["artifactId": $0.artifactID, "modules": $0.modules.map(moduleJSON), "profiles": $0.profiles.map { ["id": $0.id] }] + } + guard let execution = await model.activateExecutionModule() else { return failed("NOT_AVAILABLE", "The execution module is disabled") } + try check(model, identity, grant: grant) + let run = execution.runFeature + if name == "lithe_run_list", !run.projectLoadState.hasLoadedDocuments(for: identity.url) { + _ = await model.ensureRunProjectReady(run, for: identity) + try check(model, identity, grant: grant) + } + switch name { + case "lithe_build_inspect": + guard let build = execution.buildSystems else { return failed("NOT_AVAILABLE", L10n.string("Build plugins are unavailable")) } + return ["detecting": build.isDetecting, "issues": build.issues, + "instances": build.entries.map(buildEntryJSON)] + case "lithe_build_reload": + guard let build = execution.buildSystems, let entry = build.entry(args["instanceID"] as? String ?? "") else { + return failed("NOT_FOUND", L10n.string("Choose a detected build instance")) } - return [ - "project": project as Any? ?? NSNull(), - "profiles": maven.selectedProfiles.sorted(), "skipTests": maven.skipTests, - "reloadRequired": maven.isReloadRequired, - "diagnostic": (maven.reloadError ?? maven.configurationSaveError ?? maven.javaConfigurationError) as Any? ?? NSNull() - ] - case "lithe_maven_configure": - guard maven.project != nil else { return failed("NOT_READY", "Load a Maven project first") } - if let profiles = args["profiles"] as? [String] { - for profile in profiles { _ = maven.addCustomProfile(profile) } - maven.setSelectedProfiles(Set(profiles)) + build.loadModel(entry.id, reason: .refresh) + return ["instanceID": entry.id, "reloadRequested": true] + case "lithe_build_configure": + guard let build = execution.buildSystems, let entry = build.entry(args["instanceID"] as? String ?? "") else { + return failed("NOT_FOUND", L10n.string("Choose a detected build instance")) } - if let skip = args["skipTests"] as? Bool { maven.setSkipTests(skip) } - if let error = await maven.saveConfiguration() { throw failure(error) } - try check(model, identity, grant: grant) - return ["saved": true, "profiles": maven.selectedProfiles.sorted(), "skipTests": maven.skipTests, "reloadRequired": maven.isReloadRequired] - case "lithe_maven_reload": - await model.reloadMavenProject(rescan: true) - try check(model, identity, grant: grant) - if let error = maven.reloadError { throw failure(error) } - return ["outcome": maven.project == nil ? "noProject" : "completed"] - case "lithe_maven_execute": - guard let project = maven.project else { return failed("NOT_READY", "Load a Maven project first") } - guard !maven.isRunning, !maven.isReloading else { return failed("BUSY", "A Maven operation is already active") } - let path = args["modulePath"] as? String ?? "." - let module = findModule(path, in: project.modules) - guard path == "." || module != nil else { return failed("NOT_FOUND", "Unknown Maven module") } - let goals = args["goals"] as? [String] ?? [] - maven.runCustomGoal(goals.joined(separator: " "), module: module) - guard let operationID = maven.outputOperationID else { return failed("LAUNCH_FAILED", "Maven launch did not start") } - return ["operationID": operationID, "state": mavenState(maven)] + let value = try JSONDecoder().decode(ToolingJSONValue.self, + from: JSONSerialization.data(withJSONObject: args["value"] ?? NSNull(), options: [.fragmentsAllowed])) + let option = try build.configureOption(entry.id, optionID: args["optionID"] as? String ?? "", value: value == .null ? nil : value) + return ["saved": true, "instanceID": entry.id, "option": try buildOptionJSON(option), + "reloadRequired": build.entry(entry.id)?.isReloadRequired == true] + case "lithe_build_execute": + guard let build = execution.buildSystems, let entry = build.entry(args["instanceID"] as? String ?? "") else { + return failed("NOT_FOUND", L10n.string("Choose a detected build instance")) + } + guard !build.isRunning else { return failed("BUSY", L10n.string("A build operation is already active")) } + let taskID = args["taskID"] as? String ?? "" + guard let declaration = entry.declaration.tasks.first(where: { $0.id == taskID }) else { + return failed("NOT_FOUND", L10n.string("Choose a declared build task")) + } + let projectID = args["projectID"] as? String + let targetID = args["targetID"] as? String + let modelTaskID = args["modelTaskID"] as? String + if projectID != nil || targetID != nil || modelTaskID != nil { + guard entry.modelState == .loaded, !entry.isReloadRequired else { + return failed("NOT_READY", L10n.string("Reload the build model before selecting a project, target or model task")) + } + } + let project = entry.model?.projects.first { $0.id == projectID } + let target = entry.model?.targets.first { $0.id == targetID && $0.projectID == projectID } + let task = entry.model?.tasks.first { $0.id == modelTaskID && $0.projectID == projectID && $0.template == taskID } + guard (projectID == nil || project != nil), (targetID == nil || target != nil), + declaration.fromModel == (modelTaskID != nil), (modelTaskID == nil || task != nil) else { + return failed("NOT_FOUND", L10n.string("The build selection is no longer available")) + } + let request = BuildTaskRequest(task: taskID, project: project.map(BuildSelection.init(project:)), + target: target.map(BuildSelection.init(target:)), modelTask: task.map(BuildSelection.init(task:)), + userArguments: args["userArguments"] as? [String] ?? []) + guard let id = build.runTask(entry.id, request: request, title: task?.displayName ?? declaration.displayName) else { + return failed("LAUNCH_FAILED", L10n.string("Build launch did not start")) + } + return ["operationID": "build:" + id.uuidString, "state": "running"] case "lithe_run_list": return ["configurations": run.configurations.filter { $0.kind != .currentFile }.map { ["id": $0.id, "name": $0.name, "provider": $0.kind.providerID] }, "status": runStatus(run.configurationStatus)] case "lithe_run_start": return try await start(args["configurationID"] as? String ?? "", model: model, identity: identity, run: run, grant: grant) case "lithe_operations_list": - return ["operations": operations(run: run, maven: maven).map { $0.filter { $0.key != "output" && $0.key != "sessionID" } }] + return ["operations": operations(run: run, build: execution.buildSystems).map { $0.filter { $0.key != "output" && $0.key != "sessionID" } }] case "lithe_operation_output", "lithe_operation_stop", "lithe_run_restart": let id = args["operationID"] as? String ?? "" - guard let operation = operations(run: run, maven: maven).first(where: { $0["operationID"] as? String == id }) else { return failed("OPERATION_EXPIRED", "This execution was replaced or removed from IDE history") } + guard let operation = operations(run: run, build: execution.buildSystems).first(where: { $0["operationID"] as? String == id }) else { return failed("OPERATION_EXPIRED", "This execution was replaced or removed from IDE history") } if name == "lithe_operation_output" { return operation } - if id.hasPrefix("maven:") { - guard name != "lithe_run_restart" else { return failed("INVALID_ARGUMENTS", "Use Maven execute to start another build") } - maven.stop() + if id.hasPrefix("build:") { + guard name != "lithe_run_restart" else { return failed("INVALID_ARGUMENTS", L10n.string("Use Build execute to start another build")) } + if let retained = execution.buildSystems?.operation { + execution.buildSystems?.cancelRun(retained.id) + } } else if let primaryID = run.primaryExecutionID, id == "run:" + primaryID { if name == "lithe_run_restart", run.lastConfiguration?.kind == .currentFile { return failed("INVALID_ARGUMENTS", "Restart requires a saved project configuration") @@ -247,7 +280,7 @@ final class IdeCapabilitiesFeatureModel: ObservableObject { let previous = run.moduleSessions.first { $0.configurationID == id }?.executionID await model.startAPIConfiguration(configuration) try check(model, identity, grant: grant) - if configuration.buildProvider != nil { + if configuration.buildProvider != nil && (configuration.buildProvider?.execution ?? .application) == .application { guard let operationID = run.primaryExecutionID, operationID != previousPrimary, run.lastConfiguration?.id == id else { return failed("LAUNCH_FAILED", "Launch did not start. Check the IDE project readiness and any pending launch decision.") @@ -268,7 +301,7 @@ final class IdeCapabilitiesFeatureModel: ObservableObject { return ["operationID": "run:" + session.executionID] } - private func operations(run: RunFeatureModel, maven: MavenFeatureModel) -> [[String: Any]] { + private func operations(run: RunFeatureModel, build: BuildSystemsFeatureModel?) -> [[String: Any]] { var values: [[String: Any]] = run.moduleSessions.map { session in ["operationID": "run:" + session.executionID, "kind": "run", "configurationID": session.configurationID, "sessionID": session.id, "title": session.title, "state": session.isRunning ? "running" : session.exitCode == nil ? "cancelled" : session.exitCode == 0 ? "completed" : "failed", "exitCode": session.exitCode as Any? ?? NSNull(), "output": session.output] } @@ -281,29 +314,50 @@ final class IdeCapabilitiesFeatureModel: ObservableObject { "exitCode": run.lastExitCode as Any? ?? NSNull(), "output": run.output ]) } - if let id = maven.outputOperationID { - values.append(["operationID": id, "kind": "maven", "configurationID": "", "sessionID": id, "title": maven.runningTitle ?? "Maven", "state": mavenState(maven), "exitCode": maven.lastExitCode as Any? ?? NSNull(), "output": maven.output]) + if let operation = build?.operation { + let id = "build:" + operation.id.uuidString + values.append(["operationID": id, "kind": "build", "configurationID": "", "sessionID": id, + "title": operation.title, "state": buildState(operation.state), + "exitCode": operation.exitCode as Any? ?? NSNull(), "output": operation.output]) } return values.sorted { ($0["operationID"] as? String ?? "") < ($1["operationID"] as? String ?? "") } } - private func mavenState(_ maven: MavenFeatureModel) -> String { - switch maven.taskState { + private func buildState(_ state: BuildRunState) -> String { + switch state { case .running: "running" - case .stopping: "stopping" + case .succeeded: "completed" case .failed: "failed" - case .cancelled: "cancelled" - case .idle: maven.lastExitCode == 0 ? "completed" : "cancelled" + case .cancelled, .idle: "cancelled" } } - private func moduleJSON(_ module: MavenModule) -> [String: Any] { - ["relativePath": module.relativePath, "artifactId": module.artifactID, "modules": module.modules.map(moduleJSON)] + private func buildEntryJSON(_ entry: BuildSystemEntry) -> [String: Any] { + ["instanceID": entry.id, "pluginID": entry.source.pluginID, "buildSystemID": entry.instance.buildSystemID, + "root": entry.instance.root, "name": entry.instance.displayName, "reloadRequired": entry.isReloadRequired, + "modelState": entry.modelState == .loaded ? "ready" : entry.modelState == .loading ? "loading" : "unavailable", + "tasks": entry.declaration.tasks.map { ["id": $0.id, "name": $0.displayName, "fromModel": $0.fromModel, + "scopes": $0.scopes.map(\.rawValue), "requiresUserArguments": $0.requiresUserArguments] as [String: Any] }, + "projects": entry.model?.projects.map { ["id": $0.id, "name": $0.name, "path": $0.path] } ?? [], + "targets": entry.model?.targets.map { ["id": $0.id, "projectID": $0.projectID, "name": $0.name] } ?? [], + "modelTasks": entry.model?.tasks.map { ["id": $0.id, "projectID": $0.projectID, "taskID": $0.template, + "name": $0.displayName ?? $0.name] } ?? [], + "options": entry.options.compactMap { option -> [String: Any]? in + do { return try buildOptionJSON(option) } catch { + // An option the Agent cannot see is lost data: record it, then keep the rest of the entry. + logSink?.emit(AppIdeRequestFailed(error: error, stage: "build-option-encoding")); return nil + } + }, + "nativeConfiguration": entry.declaration.nativeConfiguration] } - private func findModule(_ path: String, in modules: [MavenModule]) -> MavenModule? { - for module in modules { if module.relativePath == path { return module }; if let found = findModule(path, in: module.modules) { return found } } - return nil + + private func buildOptionJSON(_ option: BuildResolvedOption) throws -> [String: Any] { + ["id": option.id, "name": option.displayName, "kind": option.kind?.rawValue as Any? ?? NSNull(), + "value": try JSONSerialization.jsonObject(with: JSONEncoder().encode(option.value), options: [.fragmentsAllowed]), + "source": option.source, "scope": option.scope.rawValue, "allowCustom": option.allowCustom, + "choices": option.choices.map { ["value": $0.value, "name": $0.title ?? $0.value] }] } + private func runStatus(_ status: ProjectRunConfigurationStatus) -> String { switch status { case .missing: "missing"; case .ready: "ready"; case .invalid: "invalid" } } @@ -311,7 +365,7 @@ final class IdeCapabilitiesFeatureModel: ObservableObject { private func environment(_ model: any IdeCapabilityActions, root: URL) -> [String: Any] { let feature = model.runtimeFeature let settings = feature.settings - return ["scope": "project-local", "settings": ["javaHomePath": settings.javaHomePath, "mavenExecutablePath": settings.mavenExecutableOverride, "mavenJavaHomePath": settings.mavenJavaHomePath, "settingsPath": settings.mavenSettingsPath, "localRepositoryPath": settings.mavenLocalRepositoryPath], "discovered": ["java": feature.javaRuntimes.map { ["homePath": $0.homePath, "version": $0.version, "vendor": $0.vendor] }, "maven": feature.mavenRuntimes.map { ["executablePath": $0.executablePath, "version": $0.version] }], "effective": ["java": choice(feature.javaChoice(overridePath: nil)), "mavenJava": choice(feature.mavenJavaChoice(overridePath: nil)), "maven": choice(feature.mavenChoice(at: root, overridePath: nil))]] + return ["scope": "project-local", "settings": ["javaHomePath": settings.javaHomePath, "mavenExecutablePath": settings.mavenExecutableOverride, "mavenJavaHomePath": settings.mavenJavaHomePath], "discovered": ["java": feature.javaRuntimes.map { ["homePath": $0.homePath, "version": $0.version, "vendor": $0.vendor] }, "maven": feature.mavenRuntimes.map { ["executablePath": $0.executablePath, "version": $0.version] }], "effective": ["java": choice(feature.javaChoice(overridePath: nil)), "mavenJava": choice(feature.mavenJavaChoice(overridePath: nil)), "maven": choice(feature.mavenChoice(at: root, overridePath: nil))]] } private func choiceSource(_ source: RuntimeChoiceSource) -> String { switch source { diff --git a/macos/Sources/Lithe/Application/Features/IdeCapabilityActions.swift b/macos/Sources/Lithe/Application/Features/IdeCapabilityActions.swift index e542bae110..db2eb556a2 100644 --- a/macos/Sources/Lithe/Application/Features/IdeCapabilityActions.swift +++ b/macos/Sources/Lithe/Application/Features/IdeCapabilityActions.swift @@ -13,9 +13,6 @@ protocol IdeCapabilityActions: AnyObject { func isCurrentWorkspace(_ identity: WorkspaceIdentity) -> Bool func activateExecutionModule() async -> ExecutionFeatureAccess? func ensureRunProjectReady(_ run: RunFeatureModel, for identity: WorkspaceIdentity) async -> RunProjectReadiness - func prepareProjectRuntimeSettings() async - func persistProjectRuntimeSettings() - func reloadMavenProject(rescan: Bool) async func startAPIConfiguration(_ configuration: RunConfiguration) async func registerIdeCapabilities(_ feature: IdeCapabilitiesFeatureModel) func unregisterIdeCapabilities() diff --git a/macos/Sources/Lithe/Application/Features/JavaTestDebugWorkflowCoordinator.swift b/macos/Sources/Lithe/Application/Features/JavaTestDebugWorkflowCoordinator.swift index fa43011341..ed855cf06e 100644 --- a/macos/Sources/Lithe/Application/Features/JavaTestDebugWorkflowCoordinator.swift +++ b/macos/Sources/Lithe/Application/Features/JavaTestDebugWorkflowCoordinator.swift @@ -33,7 +33,7 @@ final class JavaTestDebugWorkflowCoordinator { guard let self, let state else { return } defer { state.finishDebugLaunch(operationID, stopResultServer: false) } guard state.isCurrentDebugLaunch(operationID) else { return } - guard await prepareDirtyDocument() else { return } + guard await prepareDirtyDocument(), state.isCurrentDebugLaunch(operationID) else { return } do { let prepared = try await prepareLaunch() diff --git a/macos/Sources/Lithe/Application/Features/JavaTestWorkflowState.swift b/macos/Sources/Lithe/Application/Features/JavaTestWorkflowState.swift index 4f3afec25d..d55430b80a 100644 --- a/macos/Sources/Lithe/Application/Features/JavaTestWorkflowState.swift +++ b/macos/Sources/Lithe/Application/Features/JavaTestWorkflowState.swift @@ -42,7 +42,7 @@ final class JavaTestWorkflowState { testIdentifier: nil ) ) - case .testCase(let identifier, let url): + case .testCase(let identifier, let url, _): guard let url else { return .rejected("The selected Java test has no source file") } @@ -118,6 +118,27 @@ final class JavaTestWorkflowState { } } + var runPreparationTask: Task? + private var runPreparationID: UUID? + + func beginRunPreparation() -> UUID { + cancelRunPreparation() + let id = UUID() + runPreparationID = id + return id + } + func isCurrentRunPreparation(_ id: UUID) -> Bool { runPreparationID == id && !Task.isCancelled } + func finishRunPreparation(_ id: UUID) { + guard runPreparationID == id else { return } + runPreparationID = nil + runPreparationTask = nil + } + func cancelRunPreparation() { + runPreparationID = nil + runPreparationTask?.cancel() + runPreparationTask = nil + } + var resultServer: (any JavaTestResultServing)? var debugLaunchTask: Task? var debugLaunchOperationID: UUID? diff --git a/macos/Sources/Lithe/Application/Features/ModuleFeatureAccess.swift b/macos/Sources/Lithe/Application/Features/ModuleFeatureAccess.swift index dca7afbed5..12f80fbaea 100644 --- a/macos/Sources/Lithe/Application/Features/ModuleFeatureAccess.swift +++ b/macos/Sources/Lithe/Application/Features/ModuleFeatureAccess.swift @@ -10,7 +10,6 @@ struct DebugFeatureAccess { /// The execution features exposed to application workflows after activation. @MainActor struct ExecutionFeatureAccess { - let mavenFeature: MavenFeatureModel let runFeature: RunFeatureModel let tests: LanguageTestService let projectDevelopment: ProjectDevelopmentFeatureModel diff --git a/macos/Sources/Lithe/Application/Features/PluginManagement.swift b/macos/Sources/Lithe/Application/Features/PluginManagement.swift index a83e9b7152..a75db699c2 100644 --- a/macos/Sources/Lithe/Application/Features/PluginManagement.swift +++ b/macos/Sources/Lithe/Application/Features/PluginManagement.swift @@ -71,6 +71,7 @@ struct PluginSettingsContributions: Equatable, Sendable { struct BuildSystem: Equatable, Sendable { let id: String let displayName: String + var nativeConfiguration = false } var languages: [DeclarativePluginLanguage] = [] diff --git a/macos/Sources/Lithe/Application/Features/PluginSettingsRegistry.swift b/macos/Sources/Lithe/Application/Features/PluginSettingsRegistry.swift index 6a5b2b3569..71f5f1e55c 100644 --- a/macos/Sources/Lithe/Application/Features/PluginSettingsRegistry.swift +++ b/macos/Sources/Lithe/Application/Features/PluginSettingsRegistry.swift @@ -24,6 +24,7 @@ struct ContributedSettingsPage: Identifiable, Equatable, Sendable { let actions: [DeclarativePluginSettingAction] /// The plugin runs a language server for this page's language. let providesLanguageServer: Bool + var nativeBuildSystemIDs: [String] = [] var id: PluginID { pluginID } } @@ -84,6 +85,7 @@ enum PluginSettingsRegistry { var placements: [ContributedSettingsPage.Placement] = declared.languageServerIDs.map { .language($0) } placements += plugin.settings.compactMap { $0.declaration.page.map(placement) } placements += declared.actions.compactMap { $0.page.map(placement) } + placements += declared.buildSystems.filter(\.nativeConfiguration).map { .buildSystem($0.id) } var seen = Set() for page in placements where seen.insert(page).inserted { contributions[page, default: []].append(ContributedSettingsPage.Contribution( @@ -91,7 +93,10 @@ enum PluginSettingsRegistry { pluginName: plugin.manifest.displayName, settings: plugin.settings.filter { $0.declaration.page.map(placement) == page }, actions: declared.actions.filter { $0.page.map(placement) == page }, - providesLanguageServer: page.languageID.map(declared.languageServerIDs.contains) ?? false + providesLanguageServer: page.languageID.map(declared.languageServerIDs.contains) ?? false, + nativeBuildSystemIDs: declared.buildSystems.filter { + $0.nativeConfiguration && page == .buildSystem($0.id) + }.map(\.id) )) } } diff --git a/macos/Sources/Lithe/Application/Features/RunWorkflowCoordinator.swift b/macos/Sources/Lithe/Application/Features/RunWorkflowCoordinator.swift index bd119d67e8..f6a924afdb 100644 --- a/macos/Sources/Lithe/Application/Features/RunWorkflowCoordinator.swift +++ b/macos/Sources/Lithe/Application/Features/RunWorkflowCoordinator.swift @@ -68,6 +68,16 @@ final class RunWorkflowCoordinator { private var moduleOperation: Task? private var moduleOperationID: UUID? var isModuleOperationStarting: Bool { moduleOperationID != nil } + private(set) var isRunActionStarting = false + + /// Run owns activation/save/build/JDT waits from the original click. A new + /// action revokes the old task before any of its ignored waits can return. + func startRunAction(_ operation: @escaping @MainActor () async -> Void) { + cancelModuleOperation() + startModuleOperation(operation) + isRunActionStarting = true + onPendingActionChange() + } func startModuleOperation(_ operation: @escaping @MainActor () async -> Void) { guard moduleOperationID == nil else { return } @@ -79,6 +89,7 @@ final class RunWorkflowCoordinator { guard let self, moduleOperationID == id else { return } moduleOperation = nil moduleOperationID = nil + isRunActionStarting = false onPendingActionChange() } } @@ -88,6 +99,7 @@ final class RunWorkflowCoordinator { moduleOperation?.cancel() moduleOperation = nil moduleOperationID = nil + isRunActionStarting = false onPendingActionChange() } diff --git a/macos/Sources/Lithe/Application/Features/RuntimeSettingsFeatureModel.swift b/macos/Sources/Lithe/Application/Features/RuntimeSettingsFeatureModel.swift index a9d3bbaf03..c4720147f2 100644 --- a/macos/Sources/Lithe/Application/Features/RuntimeSettingsFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/RuntimeSettingsFeatureModel.swift @@ -1,12 +1,18 @@ import Combine import Foundation import LitheCoreContracts +import LitheModuleAPI /// UI-facing projection for project runtime settings and discovery. @MainActor final class RuntimeSettingsFeatureModel: ObservableObject { private let service: ProjectRuntimeService private var observation: AnyCancellable? + private var baseline = ProjectRuntimeSettings() + private var draftSnapshot: ProjectRuntimeSettingsSnapshot? + @Published private(set) var settingsError: String? + @Published private(set) var isSaving = false + var hasChanges: Bool { settings != baseline } @Published private(set) var javaRuntimes: [JavaRuntimeCandidate] @Published private(set) var mavenRuntimes: [MavenRuntimeCandidate] @@ -28,24 +34,30 @@ final class RuntimeSettingsFeatureModel: ObservableObject { self.mavenRuntimes = self.service.mavenRuntimes self.javaEnvironmentReport = self.service.javaEnvironmentReport self.isDiscovering = self.service.isDiscovering - self.settings = self.service.settings + self.settingsError = self.service.settingsError } } - func openProject(at url: URL) { service.openProject(at: url) } + func openProject(at url: URL) { + service.openProject(at: url) + settings = service.settings + baseline = settings + draftSnapshot = nil + isSaving = false + settingsError = service.settingsError + } func closeProject() { service.closeProject() subprojects = [] + settings = ProjectRuntimeSettings() + baseline = settings + draftSnapshot = nil + isSaving = false + settingsError = nil } func refreshAvailableRuntimes() async { await service.refreshAvailableRuntimes() } func ensureRuntimesDiscovered() async { await service.ensureRuntimesDiscovered() } func activeJavaRuntime() -> JavaRuntimeCandidate? { service.activeJavaRuntime() } - func activeMavenRuntime(for project: MavenProject) -> MavenRuntimeCandidate? { - service.activeMavenRuntime(for: project) - } - func mavenExecutable(for project: MavenProject) -> URL? { - service.mavenExecutable(for: project) - } func executableCandidates(_ command: String) -> [RuntimeToolCandidate] { service.executableCandidates(command) } @@ -81,51 +93,90 @@ final class RuntimeSettingsFeatureModel: ObservableObject { service.hasDiscoveredRuntimes ? service.javaRuntimes : nil } - func adoptProjectToolchain(_ toolchain: ProjectToolchainSelection) { - service.adoptProjectToolchain(toolchain) - settings = service.settings - } - func prepare( workspaceName: String, workspaceURL: URL?, files: [URL], - mavenProject: MavenProject?, - toolchain: ProjectToolchainSelection?, - mavenSettingsPath: String?, - mavenLocalRepositoryPath: String?, - mavenExecutablePath: String?, - mavenJavaHomePath: String? + buildSources: [ProjectRuntimeInventory.BuildSource] = [], + languageSupports: [LanguageSupportDeclaration] = [] ) { + guard !isSaving else { return } guard let workspaceURL else { subprojects = [] return } - service.mergeImportedSettings( - toolchain: toolchain, - mavenSettingsPath: mavenSettingsPath, - mavenLocalRepositoryPath: mavenLocalRepositoryPath, - mavenExecutablePath: mavenExecutablePath, - mavenJavaHomePath: mavenJavaHomePath - ) + service.reloadSettings() subprojects = ProjectRuntimeInventory.subprojects( workspaceName: workspaceName, workspaceURL: workspaceURL, files: files, - mavenProject: mavenProject + buildSources: buildSources, + languageSupports: languageSupports ) settings = service.settings + baseline = settings + draftSnapshot = service.settingsSnapshot + settingsError = service.settingsError + } + + /// Refreshes canonical tool selections without loading a build model or + /// replacing an open settings draft. Provider-owned configuration has a separate editor. + func reloadToolchainSettings() -> Bool { + guard !isSaving, !hasChanges else { return false } + guard service.reloadSettings() else { + settingsError = service.settingsError + return false + } + let current = service.settings + settings = current + baseline = current + draftSnapshot = service.settingsSnapshot + settingsError = service.settingsError + return draftSnapshot != nil + } + + @discardableResult + func saveSettings() -> Bool { + guard !isSaving else { + settingsError = L10n.string("Project environment settings are being saved. Wait before saving again.") + return false + } + isSaving = true + guard let draftSnapshot, service.updateSettings(settings, expecting: draftSnapshot) else { + settingsError = service.settingsError + isSaving = false + return false + } + let saved = service.settings + settings = saved + self.draftSnapshot = service.settingsSnapshot + return true + } + + func finishSaving(error: String? = nil) { + isSaving = false + settingsError = error + if error == nil { baseline = settings } + } + + func cancelChanges() { + guard !isSaving else { return } + guard service.reloadSettings() else { + settingsError = service.settingsError + return + } + let current = service.settings + settings = current + baseline = current + draftSnapshot = service.settingsSnapshot + settingsError = nil } func updateSettings(_ mutate: (inout ProjectRuntimeSettings) -> Void) { + guard !isSaving else { return } var next = settings mutate(&next) - service.updateSettings(next) - settings = service.settings - } - - var projectToolchainSelection: ProjectToolchainSelection { - settings.projectToolchainSelection() + settings = next } func javaRuntimeTitle(for path: String, automaticTitle: String) -> String { diff --git a/macos/Sources/Lithe/Application/Features/ToolchainPreferencesFeatureModel.swift b/macos/Sources/Lithe/Application/Features/ToolchainPreferencesFeatureModel.swift new file mode 100644 index 0000000000..8e6285380e --- /dev/null +++ b/macos/Sources/Lithe/Application/Features/ToolchainPreferencesFeatureModel.swift @@ -0,0 +1,135 @@ +import Combine +import Foundation +import LitheCoreContracts + +/// Edits remain workspace-owned drafts until an explicit Save. A draft keeps +/// its original native revision across reloads so external changes cannot +/// silently rebase a later write onto a newer document. +@MainActor +final class ToolchainPreferencesFeatureModel: ObservableObject { + enum Mode: String, CaseIterable, Sendable { case inherited, workspace, consumer, directoryTool, installation, executable } + struct Draft: Equatable { + let snapshot: ToolchainPreferencesSnapshot + var mode: Mode + var path: String + var originalMode: Mode + var originalPath: String + + var isChanged: Bool { mode != originalMode || path != originalPath } + var value: ToolingJSONValue? { + switch mode { + case .inherited: nil + case .workspace: .object(["inherit": .string("workspace")]) + case .consumer: .object(["inherit": .string("consumer")]) + case .directoryTool: .object(["inherit": .string("tool")]) + case .installation: .object(["home": .string(path)]) + case .executable: .object(["executable": .string(path)]) + } + } + } + + @Published private(set) var workspace: URL? + @Published private(set) var address: ToolchainPreferenceAddress? + @Published private(set) var draft: Draft? + @Published private(set) var reading: ToolchainPreferenceReading? + @Published private(set) var error: (any Error)? + @Published private(set) var isLoading = false + @Published private(set) var isSaving = false + private let store: (any ToolchainPreferencesStoring)? + private var drafts: [ToolchainPreferenceAddress: Draft] = [:] + private var generation = UUID() + private var savingTask: Task? + private var writeID: UUID? + + init(store: (any ToolchainPreferencesStoring)?) { self.store = store } + var isAvailable: Bool { store != nil } + + func open(workspace: URL, address: ToolchainPreferenceAddress) async { + let generation = UUID(); self.generation = generation + if let savingTask { _ = await savingTask.result } + guard self.generation == generation, !Task.isCancelled, let store else { return } + if self.workspace != workspace { drafts.removeAll(); draft = nil } + self.workspace = workspace; self.address = address + isLoading = true; error = nil + draft = drafts[address]; reading = nil + defer { if self.generation == generation { isLoading = false } } + do { + guard drafts[address] != nil || drafts.count < 64 else { throw ToolchainPreferenceError.invalid } + let latest = try await store.load(workspace: workspace) + try Task.checkCancellation() + guard self.generation == generation else { return } + let existing = drafts[address] + let baseline = existing?.snapshot ?? latest + reading = try store.inspect(address, snapshot: baseline) + guard let reading else { throw ToolchainPreferenceError.invalid } + draft = try existing ?? Self.draft(reading: reading, snapshot: baseline) + if let existing, existing.snapshot.revision != latest.revision { error = ToolchainPreferenceError.conflict } + } catch is CancellationError { } + catch { if self.generation == generation { self.error = error } } + } + + func edit(mode: Mode? = nil, path: String? = nil) { + guard !isLoading, !isSaving, var draft, let address else { return } + if let mode { draft.mode = mode } + if let path { draft.path = path } + self.draft = draft + if draft.isChanged { drafts[address] = draft } else { drafts.removeValue(forKey: address) } + } + + func cancel() async { + guard !isSaving, let workspace, let address else { return } + drafts.removeValue(forKey: address); draft = nil + await open(workspace: workspace, address: address) + } + + func save(reset: Bool = false) async { + guard let store, let address, let draft, !isSaving, !isLoading, reset || draft.isChanged else { return } + let generation = self.generation + let writeID = UUID(); self.writeID = writeID + isSaving = true; error = nil + let task = Task { try await store.save(reset ? nil : draft.value, at: address, expecting: draft.snapshot) } + savingTask = task + defer { if self.writeID == writeID { self.writeID = nil; savingTask = nil; isSaving = false } } + do { + let saved = try await withTaskCancellationHandler { try await task.value } onCancel: { task.cancel() } + guard self.generation == generation else { return } + let reading = try store.inspect(address, snapshot: saved) + self.reading = reading + self.draft = try Self.draft(reading: reading, snapshot: saved) + drafts.removeValue(forKey: address) + } catch { if self.generation == generation { self.error = error } } + } + + /// Hiding retains drafts. Closing a workspace revokes every in-flight read + /// and drops that workspace's inputs, never another workspace's editor. + func hide(_ address: ToolchainPreferenceAddress) { + guard self.address == address else { return } + savingTask?.cancel(); generation = UUID() + self.address = nil; draft = nil; reading = nil; error = nil; isLoading = false + } + + func close() { + savingTask?.cancel() + generation = UUID(); workspace = nil; address = nil; draft = nil; reading = nil + drafts.removeAll(); error = nil; isLoading = false + } + + /// Workspace shutdown waits for the native write owner to acknowledge + /// cancellation before allowing its platform resources to be released. + func drain() async { + if let savingTask { _ = await savingTask.result } + } + + private static func draft(reading: ToolchainPreferenceReading, snapshot: ToolchainPreferencesSnapshot) throws -> Draft { + var mode: Mode = .inherited, path = "" + if case .object(let stored) = reading.stored { + if case .string(let value) = stored["home"] { mode = .installation; path = value } + else if case .string(let value) = stored["executable"] { mode = .executable; path = value } + else if stored["inherit"] == .string("workspace") { mode = .workspace } + else if stored["inherit"] == .string("consumer") { mode = .consumer } + else if stored["inherit"] == .string("tool") { mode = .directoryTool } + else { throw ToolchainPreferenceError.invalid } + } else if reading.stored != nil { throw ToolchainPreferenceError.invalid } + return Draft(snapshot: snapshot, mode: mode, path: path, originalMode: mode, originalPath: path) + } +} diff --git a/macos/Sources/Lithe/Application/Features/ToolchainSettingsFeatureModel.swift b/macos/Sources/Lithe/Application/Features/ToolchainSettingsFeatureModel.swift index a09df471d2..778d337d25 100644 --- a/macos/Sources/Lithe/Application/Features/ToolchainSettingsFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/ToolchainSettingsFeatureModel.swift @@ -20,6 +20,7 @@ final class ToolchainSettingsFeatureModel: ObservableObject { @Published private(set) var isRereadingLoginShell = false private let loader: (any ToolchainSettingsLoading)? + let preferences: ToolchainPreferencesFeatureModel private let loginShellInspector: (any LoginShellEnvironmentInspecting)? private let inputWatcher: (any ToolchainInputWatching)? private let reloadDelay: Duration @@ -31,12 +32,14 @@ final class ToolchainSettingsFeatureModel: ObservableObject { loader: (any ToolchainSettingsLoading)? = nil, loginShell: (any LoginShellEnvironmentInspecting)? = nil, inputWatcher: (any ToolchainInputWatching)? = nil, - reloadDelay: Duration = .milliseconds(350) + reloadDelay: Duration = .milliseconds(350), + preferences: (any ToolchainPreferencesStoring)? = nil ) { self.loader = loader self.loginShellInspector = loginShell self.inputWatcher = inputWatcher self.reloadDelay = reloadDelay + self.preferences = ToolchainPreferencesFeatureModel(store: preferences) } var selectedDirectory: ToolchainSettingsSnapshot.Directory? { @@ -113,6 +116,7 @@ final class ToolchainSettingsFeatureModel: ObservableObject { let changedWorkspace = workspaceURL != workspace workspaceURL = workspace if changedWorkspace { + preferences.close() snapshot = nil selectedDirectoryID = "." expandedToolIDs = [] @@ -129,7 +133,20 @@ final class ToolchainSettingsFeatureModel: ObservableObject { throw SnapshotError.workspaceMismatch } snapshot = next + if let address = preferences.address, + !next.tools.contains(where: { $0.id == address.tool && $0.directoryID == address.directory + && (address.consumer == nil || $0.consumers.contains(where: { $0.id == address.consumer })) }) { + preferences.hide(address) + } observeInputs(of: next) + if let workspace, let address = preferences.address, !preferences.isSaving, !preferences.isLoading { + // Refresh a clean editor, or report a changed source against a + // dirty draft's original baseline. Never reenter an active save: + // its own file event must not revoke its acknowledgement. + await preferences.open(workspace: workspace, address: address) + guard generation == request else { return } + try Task.checkCancellation() + } if !next.directories.contains(where: { $0.id == selectedDirectoryID }) { selectedDirectoryID = next.directories.first?.id ?? "." } @@ -160,7 +177,13 @@ final class ToolchainSettingsFeatureModel: ObservableObject { } /// Revocation happens before any suspended load can return to this actor. + func closeAndDrain() async { + close() + await preferences.drain() + } + func close() { + preferences.close() generation = UUID() reloadTask?.cancel() reloadTask = nil diff --git a/macos/Sources/Lithe/Core/Ports/DefinedToolchains.swift b/macos/Sources/Lithe/Core/Ports/DefinedToolchains.swift new file mode 100644 index 0000000000..648c2d8a63 --- /dev/null +++ b/macos/Sources/Lithe/Core/Ports/DefinedToolchains.swift @@ -0,0 +1,53 @@ +import Foundation +import LitheCoreContracts +import LitheModuleAPI + +/// Native evidence accepted for one consumer operation, independent of settings UI. +struct ResolvedDefinedToolchain: Sendable { + let executable: URL + let environment: [String: String] + let projectCode: Bool + let version: String? +} + +/// Installation dependencies consume version and exports; they do not invoke +/// the definition's entry-launch recipe. Direct executable consumers do. +enum DefinedToolchainUse: Sendable, Equatable { case executable, installation } + +@MainActor +protocol DefinedToolchainResolving: AnyObject { + func resolve(tool: String, directory: String, workspace: URL, environment: [String: String], + minimumVersion: String?, projectCodeAllowed: Bool, + fixed: FixedToolchainSelection?, use: DefinedToolchainUse, consumerID: String?) async throws -> ResolvedDefinedToolchain? + func resolve(tool: String, directory: String, workspace: URL, environment: [String: String], + minimumVersion: String?, projectCodeAllowed: Bool) async throws -> ResolvedDefinedToolchain? + func resolve(tool: String, directory: String, workspace: URL, environment: [String: String], + minimumVersion: String?, projectCodeAllowed: Bool, + fixed: FixedToolchainSelection?) async throws -> ResolvedDefinedToolchain? + func resolve(tool: String, directory: String, workspace: URL, environment: [String: String], + minimumVersion: String?, projectCodeAllowed: Bool, + fixed: FixedToolchainSelection?, use: DefinedToolchainUse) async throws -> ResolvedDefinedToolchain? +} + +extension DefinedToolchainResolving { + func resolve(tool: String, directory: String, workspace: URL, environment: [String: String], + minimumVersion: String?, projectCodeAllowed: Bool, + fixed: FixedToolchainSelection?, use: DefinedToolchainUse, consumerID: String?) async throws -> ResolvedDefinedToolchain? { + try await resolve(tool: tool, directory: directory, workspace: workspace, environment: environment, + minimumVersion: minimumVersion, projectCodeAllowed: projectCodeAllowed, fixed: fixed, use: use) + } + func resolve(tool: String, directory: String, workspace: URL, environment: [String: String], + minimumVersion: String?, projectCodeAllowed: Bool, + fixed: FixedToolchainSelection?, use: DefinedToolchainUse) async throws -> ResolvedDefinedToolchain? { + try await resolve(tool: tool, directory: directory, workspace: workspace, environment: environment, + minimumVersion: minimumVersion, projectCodeAllowed: projectCodeAllowed, fixed: fixed) + } + + func resolve(tool: String, directory: String, workspace: URL, environment: [String: String], + minimumVersion: String?, projectCodeAllowed: Bool, + fixed: FixedToolchainSelection?) async throws -> ResolvedDefinedToolchain? { + guard fixed == nil else { throw BuildToolResolutionError(L10n.string("The selected toolchain is unavailable."), terminal: true) } + return try await resolve(tool: tool, directory: directory, workspace: workspace, environment: environment, + minimumVersion: minimumVersion, projectCodeAllowed: projectCodeAllowed) + } +} diff --git a/macos/Sources/Lithe/Core/Ports/FileStorage.swift b/macos/Sources/Lithe/Core/Ports/FileStorage.swift index eff7718129..6e985200dd 100644 --- a/macos/Sources/Lithe/Core/Ports/FileStorage.swift +++ b/macos/Sources/Lithe/Core/Ports/FileStorage.swift @@ -53,6 +53,9 @@ protocol FileStorage: Sendable { /// restores the previous bytes when another writer got there first; a /// failed rollback keeps them in a hidden temporary file next to `url`. func replaceData(_ data: Data, at url: URL, expecting expected: Data?) throws -> Bool + /// Conditional native-configuration publication preserving the existing + /// file's metadata, including permissions. Missing files are private. + func replaceDataPreservingMetadata(_ data: Data, at url: URL, expecting expected: Data?) throws -> Bool /// Removes `url` only while it still matches `expected`. Returns `false` /// and leaves the file in place when another writer changed it. func removeData(at url: URL, expecting expected: Data) throws -> Bool @@ -63,6 +66,9 @@ protocol FileStorage: Sendable { } extension FileStorage { + func replaceDataPreservingMetadata(_ data: Data, at url: URL, expecting expected: Data?) throws -> Bool { + throw CocoaError(.featureUnsupported) + } /// Every platform storage must provide a real compare-and-publish /// operation. A generic fallback would silently weaken the lost-update /// guarantee on a future adapter, so the port fails closed instead. diff --git a/macos/Sources/Lithe/Core/Ports/IdeHostTransport.swift b/macos/Sources/Lithe/Core/Ports/IdeHostTransport.swift index 888daa2948..97da3ea16b 100644 --- a/macos/Sources/Lithe/Core/Ports/IdeHostTransport.swift +++ b/macos/Sources/Lithe/Core/Ports/IdeHostTransport.swift @@ -3,6 +3,6 @@ import Foundation /// Local broker transport. The platform owns writable connection storage and helper discovery. @MainActor protocol IdeHostTransport { - func open(workspace: URL, permissions: [String: Bool]) throws -> [String: Any] + func open(workspace: URL, permissions: [String: Bool], toolExposure: [[String: Any]]) throws -> [String: Any] func control(_ action: String, arguments: [String: Any]) throws -> [String: Any] } diff --git a/macos/Sources/Lithe/Core/Ports/KeyValueStore.swift b/macos/Sources/Lithe/Core/Ports/KeyValueStore.swift index 3a6cd7bcee..adae7ead00 100644 --- a/macos/Sources/Lithe/Core/Ports/KeyValueStore.swift +++ b/macos/Sources/Lithe/Core/Ports/KeyValueStore.swift @@ -6,4 +6,10 @@ protocol KeyValueStore { func string(forKey key: String) -> String? func stringArray(forKey key: String) -> [String]? func set(_ value: Any?, forKey key: String) + /// Returns the complete namespace, or nil when this adapter cannot enumerate it. + func keys(withPrefix prefix: String) -> [String]? +} + +extension KeyValueStore { + func keys(withPrefix prefix: String) -> [String]? { nil } } diff --git a/macos/Sources/Lithe/Core/Ports/LanguageTesting.swift b/macos/Sources/Lithe/Core/Ports/LanguageTesting.swift index 90767d8814..6efb334062 100644 --- a/macos/Sources/Lithe/Core/Ports/LanguageTesting.swift +++ b/macos/Sources/Lithe/Core/Ports/LanguageTesting.swift @@ -6,6 +6,5 @@ typealias LanguageTestItem = LitheCoreContracts.LanguageTestItem typealias LanguageTestScope = LitheCoreContracts.LanguageTestScope typealias LanguageTestContext = LitheCoreContracts.LanguageTestContext typealias LanguageTestPlan = LitheCoreContracts.LanguageTestPlan -typealias LanguageTestResults = LitheCoreContracts.MavenTestResults -typealias LanguageTestFailureDetail = LitheCoreContracts.MavenTestFailureDetail +typealias LanguageTestResults = LitheCoreContracts.BuildTestResults typealias LanguageTestProvider = LitheCoreContracts.LanguageTestProvider diff --git a/macos/Sources/Lithe/Core/Ports/ProjectRuntimeSettingsStoring.swift b/macos/Sources/Lithe/Core/Ports/ProjectRuntimeSettingsStoring.swift new file mode 100644 index 0000000000..ea4a6570e8 --- /dev/null +++ b/macos/Sources/Lithe/Core/Ports/ProjectRuntimeSettingsStoring.swift @@ -0,0 +1,18 @@ +import Foundation + +/// A settings draft is tied to both the local run document and the remaining +/// Lithe preferences. The revision is opaque and must not contain file contents. +struct ProjectRuntimeSettingsSnapshot: Equatable { + let workspaceURL: URL + let settings: ProjectRuntimeSettings + let revision: String + var hasSavedToolchain = false +} + +@MainActor +protocol ProjectRuntimeSettingsStoring { + /// Migration is allowed only during workspace activation, never page display. + func load(at workspace: URL, migrateLegacy: Bool) throws -> ProjectRuntimeSettingsSnapshot + func save(_ settings: ProjectRuntimeSettings, at workspace: URL, + expecting snapshot: ProjectRuntimeSettingsSnapshot) throws -> ProjectRuntimeSettingsSnapshot +} diff --git a/macos/Sources/Lithe/Core/Ports/RunConfigurationOperations.swift b/macos/Sources/Lithe/Core/Ports/RunConfigurationOperations.swift index eb6f3fe7ab..92067ef5a5 100644 --- a/macos/Sources/Lithe/Core/Ports/RunConfigurationOperations.swift +++ b/macos/Sources/Lithe/Core/Ports/RunConfigurationOperations.swift @@ -18,3 +18,5 @@ typealias RunConfigurationDocumentMutating = LitheCoreContracts.RunConfiguration typealias ProjectToolchainSelection = LitheCoreContracts.ProjectToolchainSelection typealias ProjectToolchainCandidate = LitheCoreContracts.ProjectToolchainCandidate typealias RunConfigurationOperations = LitheCoreContracts.RunConfigurationOperations + +typealias RunProjectOwnership = LitheCoreContracts.RunProjectOwnership diff --git a/macos/Sources/Lithe/Core/Ports/RunToolchainProviders.swift b/macos/Sources/Lithe/Core/Ports/RunToolchainProviders.swift index 4db6be795d..f31c184d4c 100644 --- a/macos/Sources/Lithe/Core/Ports/RunToolchainProviders.swift +++ b/macos/Sources/Lithe/Core/Ports/RunToolchainProviders.swift @@ -18,12 +18,16 @@ protocol RunToolchainProvider: AnyObject { /// Owning language pack, when this provider is language-specific. A nil /// value is reserved for shared build tools such as Maven or Gradle. var languageProviderID: String? { get } + var discoverable: Bool { get } func resolve( projectURL: URL, options: RunOptions, runtimeService: ProjectRuntimeService ) throws -> ResolvedRunToolchain + /// Discovery only: no process, runtime probe, or project code may run. + func resolvePassive(projectURL: URL, options: RunOptions, runtimeService: ProjectRuntimeService) throws -> ResolvedRunToolchain? + func candidates( projectURL: URL, runtimeService: ProjectRuntimeService @@ -32,6 +36,8 @@ protocol RunToolchainProvider: AnyObject { extension RunToolchainProvider { var languageProviderID: String? { nil } + var discoverable: Bool { true } + func resolvePassive(projectURL: URL, options: RunOptions, runtimeService: ProjectRuntimeService) throws -> ResolvedRunToolchain? { nil } func candidates( projectURL: URL, @@ -71,6 +77,11 @@ final class RunToolchainRegistry { providersByIdentifier[identifier] != nil } + func resolvePassive(identifier: String, projectURL: URL, options: RunOptions, + runtimeService: ProjectRuntimeService) throws -> ResolvedRunToolchain? { + try providersByIdentifier[identifier]?.resolvePassive(projectURL: projectURL, options: options, runtimeService: runtimeService) + } + func resolve( identifier: String, projectURL: URL, @@ -108,6 +119,7 @@ final class RunToolchainRegistry { ) -> [DiscoveredRunToolchain] { var visited = Set() return providersByIdentifier.values.flatMap { provider -> [DiscoveredRunToolchain] in + guard provider.discoverable else { return [] } guard visited.insert(ObjectIdentifier(provider)).inserted else { return [] } guard !excludingTypes.contains(provider.toolchainType) else { return [] } return provider.identifiers.sorted().compactMap { identifier in @@ -220,6 +232,13 @@ private final class MavenRunToolchainProvider: RunToolchainProvider { let toolchainType = "maven" let languageProviderID: String? = "java" + func resolvePassive(projectURL: URL, options: RunOptions, runtimeService: ProjectRuntimeService) throws -> ResolvedRunToolchain? { + guard let executable = runtimeService.mavenExecutable(at: projectURL, overridePath: options.mavenExecutablePath) else { + throw RunExecutableResolutionError(message: L10n.string("No Maven executable was found. Edit this service configuration.")) + } + return ResolvedRunToolchain(executableURL: executable, environment: [:]) + } + func resolve( projectURL: URL, options: RunOptions, diff --git a/macos/Sources/Lithe/Core/Ports/ToolchainPreferences.swift b/macos/Sources/Lithe/Core/Ports/ToolchainPreferences.swift new file mode 100644 index 0000000000..c05cc0e0a2 --- /dev/null +++ b/macos/Sources/Lithe/Core/Ports/ToolchainPreferences.swift @@ -0,0 +1,41 @@ +import Foundation +import LitheCoreContracts +import LitheModuleAPI + +enum ToolchainPreferenceError: LocalizedError { + case invalid, conflict + var errorDescription: String? { + switch self { + case .invalid: L10n.string("The saved toolchain settings are invalid.") + case .conflict: L10n.string("Project environment settings changed on disk. Reload before saving; your draft has been kept.") + } + } +} + +/// Machine-local tool choices only. The revision never exposes Run documents, +/// environment variables or filesystem write authority to the settings model. +struct ToolchainPreferencesSnapshot: Equatable, Sendable { + let workspace: URL + let document: ToolingJSONValue + let revision: String +} + +struct ToolchainPreferenceAddress: Hashable, Sendable { + let directory: String + let consumer: String? + let tool: String +} + +struct ToolchainPreferenceReading: Equatable, Sendable { + let stored: ToolingJSONValue? + let fixed: FixedToolchainSelection? + let source: String +} + +@MainActor +protocol ToolchainPreferencesStoring: AnyObject { + func load(workspace: URL) async throws -> ToolchainPreferencesSnapshot + func inspect(_ address: ToolchainPreferenceAddress, snapshot: ToolchainPreferencesSnapshot) throws -> ToolchainPreferenceReading + func save(_ value: ToolingJSONValue?, at address: ToolchainPreferenceAddress, + expecting snapshot: ToolchainPreferencesSnapshot) async throws -> ToolchainPreferencesSnapshot +} diff --git a/macos/Sources/Lithe/Core/Ports/ToolchainSettings.swift b/macos/Sources/Lithe/Core/Ports/ToolchainSettings.swift index 3b30ae2cae..3a18b16743 100644 --- a/macos/Sources/Lithe/Core/Ports/ToolchainSettings.swift +++ b/macos/Sources/Lithe/Core/Ports/ToolchainSettings.swift @@ -16,6 +16,7 @@ struct ToolchainSettingsSnapshot: Equatable, Sendable { var depth: Int = 0 /// Version files in this directory itself; ancestors' files are not repeated. var versionFiles: [VersionFile] = [] + var hasToolPreferences = false } /// What a provider says one source asks for. `request` stays the manager's own syntax. @@ -118,6 +119,13 @@ struct ToolchainSettingsSnapshot: Equatable, Sendable { } struct Tool: Identifiable, Equatable, Sendable { + /// Current Build contributions whose Core invocation actually references + /// this tool. Other consumers remain hidden until their adapters migrate. + struct Consumer: Identifiable, Equatable, Sendable { + let id: String + let title: String + let pluginName: String + } let id: String var directoryID: String = "." let displayName: String @@ -135,6 +143,7 @@ struct ToolchainSettingsSnapshot: Equatable, Sendable { var detection: Detection? = nil /// `nil` when Core could not resolve this directory. var resolution: Resolution? = nil + var consumers: [Consumer] = [] var identity: String { "\(directoryID):\(id)" } } diff --git a/macos/Sources/Lithe/Core/Rust/RustBuildSystemOperations.swift b/macos/Sources/Lithe/Core/Rust/RustBuildSystemOperations.swift index d38fb4cde2..e22d791146 100644 --- a/macos/Sources/Lithe/Core/Rust/RustBuildSystemOperations.swift +++ b/macos/Sources/Lithe/Core/Rust/RustBuildSystemOperations.swift @@ -11,14 +11,18 @@ struct RustBuildSystemOperations: BuildSystemPlanning { func detect( workspaceURL: URL, sources: [BuildSystemPluginSource], - options: [String: [String: ToolingJSONValue]] + options: [String: [String: ToolingJSONValue]], + instanceOptions: [String: [String: [String: ToolingJSONValue]]] = [:], + instanceDefaults: [String: [String: ToolingJSONValue]] = [:] ) throws -> BuildDetection { let outcome: Outcome = try execute( "build.detect", payload: DetectPayload( workspaceRoot: workspaceURL.path, plugins: try sources.map { DetectPayload.Plugin(manifest: try Self.json($0.manifest)) }, - options: options + options: options, + instanceOptions: instanceOptions, + instanceDefaults: instanceDefaults ) ) return outcome.value.detection @@ -50,7 +54,11 @@ struct RustBuildSystemOperations: BuildSystemPlanning { target: target, request: InvocationRequest(root: root, platform: "macos", settings: settings) ) - return try outcome.value.invocation.invocation() + var invocation = try outcome.value.invocation.invocation() + guard let manifest = try JSONSerialization.jsonObject(with: target.manifest) as? [String: Any], + let pluginID = manifest["id"] as? String else { throw BuildToolResolutionError(L10n.string("The selected toolchain is unavailable."), terminal: true) } + invocation.toolConsumerID = pluginID + "/" + target.buildSystemID + return invocation } func checkToolVersion(_ target: BuildSystemTarget, runtime: String?, output: String) throws -> BuildVersionCheck { @@ -84,6 +92,50 @@ struct RustBuildSystemOperations: BuildSystemPlanning { return outcome.value.modelInputs } + func configurationPlan(_ target: BuildSystemTarget, context: BuildPlanContext, hints: BuildProviderHints) throws -> BuildProviderRequest { + let outcome: Outcome = try execute("build.configurationPlan", target: target, + request: OutputRequest(context: context, project: nil, outputFile: nil, hints: hints)) + guard case .provider(let request) = try outcome.value.plan.outputPlan() else { + throw PluginFunctionFailure(code: "plugin.build.unavailable", message: L10n.string("Native configuration is unavailable.")) + } + return request + } + + func decodeConfiguration(_ target: BuildSystemTarget, result: ToolingJSONValue) throws -> BuildNativeConfiguration { + struct Request: Encodable { let result: ToolingJSONValue } + struct Reply: Decodable { let configuration: BuildNativeConfiguration } + let outcome: Outcome = try execute("build.decodeConfiguration", target: target, request: Request(result: result)) + return outcome.value.configuration + } + + func configurationEditPlan(_ target: BuildSystemTarget, context: BuildPlanContext, hints: BuildProviderHints, + snapshot: BuildNativeConfiguration, edits: BuildNativeConfigurationEdits, outputFile: URL) throws -> BuildProviderRequest { + struct Request: Encodable { + let root: String; let options: [String: ToolingJSONValue]; let pluginDirectory: String? + let workspaceStateDirectory: String?; let modelInputs: ToolingJSONValue? + let snapshot: BuildNativeConfiguration; let edits: BuildNativeConfigurationEdits; let outputFile: String + let tool: OutputRequest.Tool? + } + let outcome: Outcome = try execute("build.configurationEditPlan", target: target, + request: Request(root: context.root, options: context.options, pluginDirectory: context.pluginDirectory?.path, + workspaceStateDirectory: context.workspaceStateDirectory?.path, modelInputs: context.modelInputs, + snapshot: snapshot, edits: edits, outputFile: outputFile.path, + tool: hints.toolExecutable.map { OutputRequest.Tool(executable: $0.path, environment: hints.toolEnvironment) })) + guard case .provider(let request) = try outcome.value.plan.outputPlan() else { + throw PluginFunctionFailure(code: "plugin.build.unavailable", message: L10n.string("Native configuration is unavailable.")) + } + return request + } + + func decodeConfigurationEdit(_ target: BuildSystemTarget, snapshot: BuildNativeConfiguration, + edits: BuildNativeConfigurationEdits, result: ToolingJSONValue) throws -> BuildNativeConfigurationEditReceipt { + struct Request: Encodable { let snapshot: BuildNativeConfiguration; let edits: BuildNativeConfigurationEdits; let result: ToolingJSONValue } + struct Reply: Decodable { let edit: BuildNativeConfigurationEditReceipt } + let outcome: Outcome = try execute("build.decodeConfigurationEdit", target: target, + request: Request(snapshot: snapshot, edits: edits, result: result)) + return outcome.value.edit + } + func modelPlan( _ target: BuildSystemTarget, context: BuildPlanContext, @@ -131,9 +183,10 @@ struct RustBuildSystemOperations: BuildSystemPlanning { } func prepareRunPlan(_ target: BuildSystemTarget, context: BuildPlanContext, run: String, - project: BuildSelection, selectedTarget: BuildSelection, userArguments: [String]) throws -> BuildProviderRequest { + project: BuildSelection, selectedTarget: BuildSelection, userArguments: [String], + workspaceURL: URL? = nil, launchOptions: BuildLaunchOptions? = nil) throws -> BuildProviderRequest { try preparationPlan(target, context: context, run: run, project: project, selectedTarget: selectedTarget, - userArguments: userArguments, command: "build.prepareRunPlan") + userArguments: userArguments, command: "build.prepareRunPlan", workspaceURL: workspaceURL, launchOptions: launchOptions) } func prepareDebugPlan(_ target: BuildSystemTarget, context: BuildPlanContext, run: String, @@ -143,7 +196,8 @@ struct RustBuildSystemOperations: BuildSystemPlanning { } private func preparationPlan(_ target: BuildSystemTarget, context: BuildPlanContext, run: String, - project: BuildSelection, selectedTarget: BuildSelection, userArguments: [String], command: String) throws -> BuildProviderRequest { + project: BuildSelection, selectedTarget: BuildSelection, userArguments: [String], command: String, + workspaceURL: URL? = nil, launchOptions: BuildLaunchOptions? = nil) throws -> BuildProviderRequest { struct Request: Encodable { let root: String let pluginDirectory: String? @@ -154,11 +208,13 @@ struct RustBuildSystemOperations: BuildSystemPlanning { let project: BuildSelection let target: BuildSelection let userArguments: [String] + let workspaceRoot: String? + let launchOptions: BuildLaunchOptions? } let outcome: Outcome = try execute(command, target: target, request: Request(root: context.root, pluginDirectory: context.pluginDirectory?.path, workspaceStateDirectory: context.workspaceStateDirectory?.path, modelInputs: context.modelInputs, options: context.options, run: run, - project: project, target: selectedTarget, userArguments: userArguments)) + project: project, target: selectedTarget, userArguments: userArguments, workspaceRoot: workspaceURL?.path, launchOptions: launchOptions)) guard case .provider(let request) = try outcome.value.plan.outputPlan() else { throw DeclarativePluginRejection(code: "plugin.build.invalidOutput", message: L10n.string("The build provider returned an invalid launch plan.")) } @@ -166,11 +222,11 @@ struct RustBuildSystemOperations: BuildSystemPlanning { } func decodeRunPreparation(_ target: BuildSystemTarget, workspaceURL: URL, root: String, - result: ToolingJSONValue) throws -> BuildRunPreparation { - struct Request: Encodable { let workspaceRoot: String; let root: String; let result: ToolingJSONValue } + result: ToolingJSONValue, launchOptions: BuildLaunchOptions? = nil) throws -> BuildRunPreparation { + struct Request: Encodable { let workspaceRoot: String; let root: String; let result: ToolingJSONValue; let launchOptions: BuildLaunchOptions? } struct Reply: Decodable { let launch: BuildRunPreparation } let outcome: Outcome = try execute("build.decodeRunPreparation", target: target, - request: Request(workspaceRoot: workspaceURL.path, root: root, result: result)) + request: Request(workspaceRoot: workspaceURL.path, root: root, result: result, launchOptions: launchOptions)) return outcome.value.launch } @@ -250,6 +306,7 @@ struct RustBuildSystemOperations: BuildSystemPlanning { project: request.project, projects: request.projects, output: request.output, + result: request.result, notBeforeMillis: request.notBefore.map { UInt64(max(0, $0.timeIntervalSince1970 * 1000)) }, suite: request.suite ) @@ -364,6 +421,8 @@ struct RustBuildSystemOperations: BuildSystemPlanning { let workspaceRoot: String let plugins: [Plugin] let options: [String: [String: ToolingJSONValue]] + let instanceOptions: [String: [String: [String: ToolingJSONValue]]] + let instanceDefaults: [String: [String: ToolingJSONValue]] } private struct BuildPayload: Encodable { @@ -414,6 +473,7 @@ struct RustBuildSystemOperations: BuildSystemPlanning { struct Tool: Encodable { let executable: String + let environment: [String: String] } init(context: BuildPlanContext, project: BuildSelection?, outputFile: URL?, hints: BuildProviderHints) { @@ -425,7 +485,7 @@ struct RustBuildSystemOperations: BuildSystemPlanning { self.project = project self.outputFile = outputFile?.path refresh = hints.reason == .refresh - tool = hints.toolExecutable.map { Tool(executable: $0.path) } + tool = hints.toolExecutable.map { Tool(executable: $0.path, environment: hints.toolEnvironment) } } } @@ -463,6 +523,8 @@ struct RustBuildSystemOperations: BuildSystemPlanning { let modelInputs: ToolingJSONValue? let project: BuildSelection? let target: BuildSelection? + let selectorKind: String? + let selectors: [String]? let suite: String? let `case`: String? let userArguments: [String] @@ -476,6 +538,8 @@ struct RustBuildSystemOperations: BuildSystemPlanning { workspaceStateDirectory = context.workspaceStateDirectory?.path project = request.project target = request.target + selectorKind = request.selectorKind + selectors = request.selectorKind == nil ? nil : request.selectors suite = request.suite `case` = request.testCase userArguments = request.userArguments @@ -498,6 +562,7 @@ struct RustBuildSystemOperations: BuildSystemPlanning { let project: String? let projects: [String] let output: String + let result: ToolingJSONValue? let notBeforeMillis: UInt64? let suite: String? } @@ -576,9 +641,12 @@ struct RustBuildSystemOperations: BuildSystemPlanning { let command: String? let runtime: String? let settingID: String? + let toolchainID: String? + let root: String? func executable() throws -> BuildInvocation.Executable { if kind == "setting", let settingID { return .setting(id: settingID, path: path) } + if kind == "toolchain", let toolchainID, let root { return .toolchain(id: toolchainID, root: root) } switch (kind, path, variable, command, runtime) { case ("projectFile", let path?, _, _, _): return .projectFile(path: path, requires: requires ?? []) @@ -639,9 +707,11 @@ struct RustBuildSystemOperations: BuildSystemPlanning { let reports: [String]? var results: BuildTestPlan.Results { - decoder == "hostIntegration" - ? .hostIntegration - : .reports(decoder: decoder, patterns: reports ?? []) + switch decoder { + case "provider": .provider + case "hostIntegration": .hostIntegration + default: .reports(decoder: decoder, patterns: reports ?? []) + } } } diff --git a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift index 1f13dce037..e0be2ce2b4 100644 --- a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift +++ b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift @@ -419,233 +419,10 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { let description: String } - struct MavenScanPayload: Decodable, Sendable { - struct SourceRoot: Decodable, Sendable { - let path: String - let kind: String - - func makeModel() -> MavenSourceRoot? { - guard let kind = MavenSourceRootKind(rawValue: kind) else { return nil } - return MavenSourceRoot(path: path, kind: kind) - } - } - - struct Profile: Decodable, Sendable { - let id: String - let isActiveByDefault: Bool - } - - /// The core serializes Maven coordinates as `groupId` / `artifactId`, - /// matching Maven itself. Swift spells the same properties with a - /// capital D, so the mapping has to be explicit -- without it decoding - /// fails and the whole scan silently returns nil. - struct Module: Decodable, Sendable { - let relativePath: String - let groupID: String? - let artifactID: String - let version: String? - let packaging: String - let sourceRoots: [SourceRoot]? - let modules: [Module] - - enum CodingKeys: String, CodingKey { - case relativePath - case groupID = "groupId" - case artifactID = "artifactId" - case version - case packaging - case sourceRoots - case modules - } - - func makeModel(rootURL: URL) -> MavenModule { - MavenModule( - relativePath: relativePath, - url: rootURL.appendingPathComponent(relativePath), - groupID: groupID, - artifactID: artifactID, - version: version, - packaging: packaging, - modules: modules.map { $0.makeModel(rootURL: rootURL) }, - sourceRoots: sourceRoots?.compactMap { $0.makeModel() } ?? [] - ) - } - } - - let relativePath: String - let groupID: String? - let artifactID: String - let version: String? - let packaging: String - let sourceRoots: [SourceRoot]? - let modules: [Module] - let profiles: [Profile] - let hasWrapper: Bool - - enum CodingKeys: String, CodingKey { - case groupID = "groupId" - case relativePath - case artifactID = "artifactId" - case version - case packaging - case sourceRoots - case modules - case profiles - case hasWrapper - } - - func makeProject(workspaceRootURL: URL) -> MavenProject { - let rootURL = relativePath == "." - ? workspaceRootURL - : workspaceRootURL.appending(path: relativePath, directoryHint: .isDirectory) - return MavenProject( - rootURL: rootURL, - pomURL: rootURL.appendingPathComponent("pom.xml"), - groupID: groupID, - artifactID: artifactID, - version: version, - packaging: packaging, - modules: modules.map { $0.makeModel(rootURL: rootURL) }, - profiles: profiles.map { - MavenProfile(id: $0.id, isActiveByDefault: $0.isActiveByDefault) - }, - hasWrapper: hasWrapper, - sourceRoots: sourceRoots?.compactMap { $0.makeModel() } ?? [] - ) - } - } - - struct MavenDiagnosticsPayload: Decodable, Sendable { - struct Issue: Decodable, Sendable { - let path: String - let line: Int - let column: Int? - let severity: String - let message: String - } - - let issues: [Issue] - } - - struct MavenTestResultsPayload: Decodable, Sendable { - struct Failure: Decodable, Sendable { - let name: String - let kind: String - let message: String? - let path: String? - let line: Int? - let column: Int? - } - - let testsRun: Int - let failures: Int - let errors: Int - let skipped: Int - let passed: Int - let success: Bool - let failureDetails: [Failure] - /// Absent from older Core builds; treated as no recorded outcomes. - let testCases: [MavenTestCaseOutcome]? - } - struct JavaRunMarkersPayload: Decodable, Sendable { let markers: [JavaRunMarker] } - struct MavenLaunchPlanPayload: Decodable, Sendable { - struct Executable: Decodable, Sendable { - let toolchain: String - } - - let version: Int - let executable: Executable - let arguments: [String] - let workingDirectory: String - let configurationFingerprint: String - - func makeModel() -> MavenLaunchPlan { - MavenLaunchPlan( - version: version, - toolchain: executable.toolchain, - arguments: arguments, - workingDirectory: workingDirectory, - configurationFingerprint: configurationFingerprint - ) - } - } - - struct MavenDependenciesPayload: Decodable, Sendable { - struct Dependency: Decodable, Sendable { - let modulePath: String - let groupID: String - let artifactID: String - let version: String - let type: String - let classifier: String? - let scope: String - let resolution: String - let selectedVersion: String? - let premanagedVersion: String? - let premanagedScope: String? - let originalScope: String? - let ignoredScope: String? - let children: [Dependency] - - enum CodingKeys: String, CodingKey { - case modulePath - case groupID = "groupId" - case artifactID = "artifactId" - case version - case type - case classifier - case scope - case resolution - case selectedVersion - case premanagedVersion - case premanagedScope - case originalScope - case ignoredScope - case children - } - - func makeModel() throws -> MavenDependency { - guard let resolution = MavenDependencyResolution(rawValue: resolution) else { - throw MavenOperationError( - code: "parse_failed", - message: "Maven dependency resolution state is invalid.", - details: resolution - ) - } - return MavenDependency( - modulePath: modulePath, - groupID: groupID, - artifactID: artifactID, - version: version, - type: type, - classifier: classifier, - scope: scope, - resolution: resolution, - selectedVersion: selectedVersion, - premanagedVersion: premanagedVersion, - premanagedScope: premanagedScope, - originalScope: originalScope, - ignoredScope: ignoredScope, - children: try children.map { try $0.makeModel() } - ) - } - } - - let modulePath: String - let dependencies: [Dependency] - - func makeModel() throws -> MavenDependencyTree { - MavenDependencyTree( - modulePath: modulePath, - dependencies: try dependencies.map { try $0.makeModel() } - ) - } - } - struct SpringIndexPayload: Decodable, Sendable { struct Property: Decodable, Sendable { let name: String @@ -748,9 +525,13 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { let java: Java? let maven: Maven? + var pathOverrides: [String: ProjectToolchainSelection.PathOverride]? = nil } struct Generator: Codable, Sendable { let fingerprint: String + let providerFingerprint: String? + let providerRunFingerprint: String? + let unreadablePaths: [String]? let inputs: [String: String]? } struct Configuration: Codable, Sendable { @@ -769,6 +550,9 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { let mavenJavaHomePath: String? let source: String? let sourceSet: String? + var module: String? = nil + var mainClass: String? = nil + var build: RunBuildProjectReference? = nil } struct Debug: Codable, Sendable { let adapter: String @@ -777,6 +561,11 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { let maven: Maven? let java: Java? var build: BuildProviderRunReference? = nil + var buildRun: BuildProviderRunOptions? = nil + private enum CodingKeys: String, CodingKey { + case maven, java, build + case buildRun = "build-run" + } } let id: String let name: String @@ -1741,25 +1530,6 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { let destinationPath: String } - private struct MavenScanRequest: Encodable { - let root: String - let paths: [String] - } - - private struct MavenLaunchPlanRequest: Encodable { - let root: String - let context: MavenLaunchContext - let module: String? - let goals: [String] - } - - private struct MavenDependencyPlanRequest: Encodable { - let root: String - let context: MavenLaunchContext - let module: String? - let outputFile: String - } - private struct MarkdownRenderRequest: Encodable { let source: String } @@ -2302,26 +2072,10 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { let version: String? } - private struct MavenDiagnosticsRequest: Encodable { - let root: String - let output: String - } - - private struct MavenTestResultsRequest: Encodable { - let root: String - let output: String - let reports: MavenTestReportRequest? - } - private struct JavaRunMarkersRequest: Encodable { let mainMethods: [JavaMainMethod] let testItems: [JavaTestItem] - let testCases: [MavenTestCaseOutcome] - } - - private struct MavenDependenciesRequest: Encodable { - let modulePath: String - let outputFile: String + let testCases: [BuildTestResults.TestCase] } private struct RunConfigurationInspectRequest: Encodable { @@ -2330,6 +2084,8 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { let checkFingerprint: Bool? /// JDT's current answer; omitted while the Java service is not ready. let javaEntrypoints: JavaEntrypoints? + let projectOwnership: [RunProjectOwnership]? + let providerRuns: [RunProviderConfiguration]? } private struct RunConfigurationGenerateRequest: Encodable { let root: String @@ -2338,13 +2094,18 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { /// Omitted while JDT has not answered; Core then keeps the previous /// generation's Java entries. let javaEntrypoints: JavaEntrypoints? + let projectOwnership: [RunProjectOwnership]? + let providerRuns: [RunProviderConfiguration]? /// Application cache root where Core keeps the last detection for the /// next session; the workspace is never written. let cacheDirectory: String? + } private struct RunConfigurationResolveRequest: Encodable { let root: String let toolchainCandidates: [ProjectToolchainCandidate] + let teamDocument: ToolingJSONValue? + let localDocument: ToolingJSONValue? } private struct RunConfigurationUpdateOptionsRequest: Encodable { let root: String @@ -2360,6 +2121,7 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { let mavenExecutablePath: String let mavenJavaHomePath: String let toolchain: ProjectToolchainSelection? + let providerRunOptions: BuildProviderRunOptions? } private struct RunConfigurationCreateUserRequest: Encodable { let root: String @@ -2375,6 +2137,7 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { let mainClass: String let classPaths: [String] let modulePaths: [String] + let buildPrepared: Bool } let root: String let configurationId: String @@ -2956,105 +2719,11 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { return response?.relocated == true } - func scanMaven(at rootURL: URL, paths: [String] = []) -> MavenScanPayload? { - do { return try scanMavenResult(at: rootURL, paths: paths).get() } - catch { - coreLogSink?.emit(AppCoreFallbackSelected(error: LitheModuleAPI.LogFailure(domain: "RustCore", code: error.code), stage: "maven-scan")) - return nil - } - } - - func scanMavenResult( - at rootURL: URL, - paths: [String] = [] - ) -> Result { - let result: Result, CoreCallError> = decodeEnvelope( - command: "maven.scan", - payload: MavenScanRequest( - root: rootURL.standardizedFileURL.path, - paths: paths - ) - ) - return result.map(\.data) - } - - func mavenLaunchPlan( - at rootURL: URL, - context: MavenLaunchContext, - module: String?, - goals: [String] - ) -> Result { - executeResult( - command: "maven.launchPlan", - payload: MavenLaunchPlanRequest( - root: rootURL.standardizedFileURL.path, - context: context, - module: module, - goals: goals - ) - ) - } - - func mavenDependencyPlan( - at rootURL: URL, - context: MavenLaunchContext, - module: String?, - outputFile: URL - ) -> Result { - executeResult( - command: "maven.dependencyPlan", - payload: MavenDependencyPlanRequest( - root: rootURL.standardizedFileURL.path, - context: context, - module: module, - outputFile: outputFile.standardizedFileURL.path - ) - ) - } - - func mavenDependencies( - modulePath: String, - outputFile: URL - ) -> Result { - executeResult( - command: "maven.dependencies", - payload: MavenDependenciesRequest( - modulePath: modulePath, - outputFile: outputFile.standardizedFileURL.path - ) - ) - } - - func mavenDiagnostics(at rootURL: URL, output: String) -> MavenDiagnosticsPayload? { - execute( - command: "maven.diagnostics", - payload: MavenDiagnosticsRequest( - root: rootURL.standardizedFileURL.path, - output: output - ) - ) - } - - func mavenTestResults( - at rootURL: URL, - output: String, - reports: MavenTestReportRequest? = nil - ) -> Result { - executeResult( - command: "maven.testResults", - payload: MavenTestResultsRequest( - root: rootURL.standardizedFileURL.path, - output: output, - reports: reports - ) - ) - } - /// Combines JDT main/test discovery and recorded outcomes into gutter markers. func javaRunMarkers( mainMethods: [JavaMainMethod], testItems: [JavaTestItem], - testCases: [MavenTestCaseOutcome] + testCases: [BuildTestResults.TestCase] ) -> Result { executeResult( command: "java.runMarkers", @@ -3096,14 +2765,17 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { func inspectRunConfiguration( at rootURL: URL, checkFingerprint: Bool? = nil, - javaEntrypoints: JavaEntrypoints? = nil + javaEntrypoints: JavaEntrypoints? = nil, + projectOwnership: [RunProjectOwnership]? = nil, + providerRuns: [RunProviderConfiguration]? = nil ) -> Result { executeResult( command: "runConfig.inspect", payload: RunConfigurationInspectRequest( root: rootURL.standardizedFileURL.path, checkFingerprint: checkFingerprint, - javaEntrypoints: javaEntrypoints + javaEntrypoints: javaEntrypoints, + projectOwnership: projectOwnership, providerRuns: providerRuns ) ) } @@ -3113,7 +2785,10 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { paths: [String], modulePaths: [String], javaEntrypoints: JavaEntrypoints? = nil, + projectOwnership: [RunProjectOwnership]? = nil, + providerRuns: [RunProviderConfiguration]? = nil, cacheDirectoryURL: URL? = nil + ) -> Result { executeResult( command: "runConfig.generate", @@ -3122,24 +2797,35 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { paths: paths, modulePaths: modulePaths, javaEntrypoints: javaEntrypoints, + projectOwnership: projectOwnership, providerRuns: providerRuns, cacheDirectory: cacheDirectoryURL?.path + ) ) } func resolveRunConfiguration( at rootURL: URL, - toolchainCandidates: [ProjectToolchainCandidate] + toolchainCandidates: [ProjectToolchainCandidate], + teamDocument: ToolingJSONValue? = nil, + localDocument: ToolingJSONValue? = nil ) -> Result { executeResult( command: "runConfig.resolve", payload: RunConfigurationResolveRequest( root: rootURL.standardizedFileURL.path, - toolchainCandidates: toolchainCandidates + toolchainCandidates: toolchainCandidates, + teamDocument: teamDocument, localDocument: localDocument ) ) } + func providerLaunchInputs(at rootURL: URL, configurationID: String, providerRoot: String) -> Result { + struct Request: Encodable { let root: String; let configurationId: String; let providerRoot: String } + return executeResult(command: "runConfig.providerLaunchInputs", payload: Request(root: rootURL.standardizedFileURL.path, + configurationId: configurationID, providerRoot: providerRoot)) + } + func createLaunchPlan( at rootURL: URL, configurationID: String, @@ -3160,7 +2846,8 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { LaunchPlanRequest.JavaLaunch( mainClass: $0.mainClass, classPaths: $0.classPaths, - modulePaths: $0.modulePaths + modulePaths: $0.modulePaths, + buildPrepared: $0.buildPrepared ) }, debugPort: debugPort, @@ -3191,7 +2878,8 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { javaHomePath: options.javaHomePath, mavenExecutablePath: options.mavenExecutablePath, mavenJavaHomePath: options.mavenJavaHomePath, - toolchain: toolchain + toolchain: toolchain, + providerRunOptions: options.providerRunOptions ) ) } @@ -3218,7 +2906,8 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { javaHomePath: options.javaHomePath, mavenExecutablePath: options.mavenExecutablePath, mavenJavaHomePath: options.mavenJavaHomePath, - toolchain: toolchain + toolchain: toolchain, + providerRunOptions: options.providerRunOptions ) ) } diff --git a/macos/Sources/Lithe/Core/Rust/RustDeclarativePluginOperations.swift b/macos/Sources/Lithe/Core/Rust/RustDeclarativePluginOperations.swift index 5dc6423b44..d39822c296 100644 --- a/macos/Sources/Lithe/Core/Rust/RustDeclarativePluginOperations.swift +++ b/macos/Sources/Lithe/Core/Rust/RustDeclarativePluginOperations.swift @@ -191,6 +191,7 @@ struct RustDeclarativePluginOperations: DeclarativePluginManifestValidating, Dec kind: request.targetKind, path: request.path, processID: request.processID, + host: request.host, port: request.port, workingDirectory: request.workingDirectory, executable: request.executable, arguments: request.arguments, environment: request.environment @@ -634,6 +635,8 @@ struct RustDeclarativePluginOperations: DeclarativePluginManifestValidating, Dec let kind: String let path: String? let processID: Int? + let host: String? + let port: UInt16? let workingDirectory: String? let executable: String? let arguments: [String]? diff --git a/macos/Sources/Lithe/Core/Rust/RustJavaMavenOperations.swift b/macos/Sources/Lithe/Core/Rust/RustJavaMavenOperations.swift index 3fc5908be1..a88cbc9ebc 100644 --- a/macos/Sources/Lithe/Core/Rust/RustJavaMavenOperations.swift +++ b/macos/Sources/Lithe/Core/Rust/RustJavaMavenOperations.swift @@ -1,26 +1,18 @@ import Foundation -protocol JavaMavenOperations: MavenProjectOperations, RunServerPortParsing, Sendable { +protocol JavaMavenOperations: RunServerPortParsing, Sendable { /// Core's shared projection of JDT answers and test outcomes into editor /// Run markers; `nil` when Core rejected the input. func javaRunMarkers( mainMethods: [JavaMainMethod], testItems: [JavaTestItem], - testCases: [MavenTestCaseOutcome] + testCases: [BuildTestResults.TestCase] ) -> [JavaRunMarker]? func javaWorkspacePolicy( at rootURL: URL, files: [URL], changedFiles: [URL] ) -> JavaWorkspacePolicyResult? - func scanMavenProject(at rootURL: URL, files: [URL]) throws -> MavenProject? - func mavenLaunchPlan( - at rootURL: URL, - context: MavenLaunchContext, - module: String?, - goals: [String] - ) throws -> MavenLaunchPlan - func mavenDiagnostics(output: String, projectRoot: URL) -> [MavenBuildIssue] func codeVision( at rootURL: URL, targetPath: String, @@ -54,21 +46,9 @@ extension JavaMavenOperations { func javaRunMarkers( mainMethods _: [JavaMainMethod], testItems _: [JavaTestItem], - testCases _: [MavenTestCaseOutcome] + testCases _: [BuildTestResults.TestCase] ) -> [JavaRunMarker]? { nil } - func mavenLaunchPlan( - at _: URL, - context _: MavenLaunchContext, - module _: String?, - goals _: [String] - ) throws -> MavenLaunchPlan { - throw MavenOperationError( - code: "not_supported", - message: "Maven launch planning is unavailable." - ) - } - func javaWorkspacePolicy( at _: URL, files _: [URL], @@ -169,134 +149,10 @@ struct RustJavaMavenOperations: JavaMavenOperations, Sendable { ) } - func scanMavenProject(at rootURL: URL, files: [URL]) throws -> MavenProject? { - let root = rootURL.standardizedFileURL - let rootComponents = root.pathComponents - let paths = files.compactMap { fileURL -> String? in - let file = fileURL.standardizedFileURL - guard file.lastPathComponent.lowercased() == "pom.xml", - file.pathComponents.starts(with: rootComponents) else { return nil } - return file.pathComponents - .dropFirst(rootComponents.count) - .joined(separator: "/") - } - return try core.scanMavenResult(at: root, paths: paths) - .mapError(MavenOperationError.init) - .get()? - .makeProject(workspaceRootURL: root) - } - - func mavenLaunchPlan( - at rootURL: URL, - context: MavenLaunchContext, - module: String?, - goals: [String] - ) throws -> MavenLaunchPlan { - try core.mavenLaunchPlan( - at: rootURL, - context: context, - module: module, - goals: goals - ) - .mapError(MavenOperationError.init) - .get() - .makeModel() - } - - func mavenDependencyPlan( - at rootURL: URL, - context: MavenLaunchContext, - module: String?, - outputFile: URL - ) throws -> MavenLaunchPlan { - try core.mavenDependencyPlan( - at: rootURL, - context: context, - module: module, - outputFile: outputFile - ) - .mapError(MavenOperationError.init) - .get() - .makeModel() - } - - func mavenDependencies(modulePath: String, outputFile: URL) throws -> MavenDependencyTree { - try core.mavenDependencies(modulePath: modulePath, outputFile: outputFile) - .mapError(MavenOperationError.init) - .get() - .makeModel() - } - - func mavenDiagnostics(output: String, projectRoot: URL) -> [MavenBuildIssue] { - guard let payload = core.mavenDiagnostics(at: projectRoot, output: output) else { return [] } - return payload.issues.map { issue in - let fileURL = issue.path.hasPrefix("/") - ? URL(fileURLWithPath: issue.path) - : projectRoot.appendingPathComponent(issue.path).standardizedFileURL - return MavenBuildIssue( - id: fileURL.path + ":" + String(issue.line) + ":" + String(issue.column ?? 0) + ":" + issue.message, - fileURL: fileURL, - line: issue.line, - column: issue.column, - severity: MavenIssueSeverity(rawValue: issue.severity) ?? .warning, - message: issue.message - ) - } - } - - func mavenTestResults(output: String, projectRoot: URL) -> MavenTestResults? { - mavenTestResults(output: output, projectRoot: projectRoot, reports: nil) - } - - func mavenTestResults( - output: String, - projectRoot: URL, - reports: MavenTestReportRequest? - ) -> MavenTestResults? { - guard let payload = try? core.mavenTestResults( - at: projectRoot, - output: output, - reports: reports - ).get() else { - return nil - } - let root = projectRoot.standardizedFileURL - let details = payload.failureDetails.map { detail in - let fileURL: URL? - if let path = detail.path, !path.isEmpty { - fileURL = path.hasPrefix("/") - ? URL(fileURLWithPath: path).standardizedFileURL - : root.appendingPathComponent(path).standardizedFileURL - } else { - fileURL = nil - } - return MavenTestFailureDetail( - id: [detail.kind, detail.name, detail.path ?? "", String(detail.line ?? 0)] - .joined(separator: ":"), - name: detail.name, - kind: detail.kind, - message: detail.message, - fileURL: fileURL, - line: detail.line, - column: detail.column - ) - } - return MavenTestResults( - testsRun: payload.testsRun, - failures: payload.failures, - errors: payload.errors, - skipped: payload.skipped, - passed: payload.passed, - success: payload.success, - failureDetails: details, - testCases: payload.testCases ?? [] - ) - } - func javaRunMarkers( mainMethods: [JavaMainMethod], testItems: [JavaTestItem], - testCases: [MavenTestCaseOutcome] + testCases: [BuildTestResults.TestCase] ) -> [JavaRunMarker]? { if mainMethods.isEmpty && testItems.isEmpty { return [] } return try? core.javaRunMarkers( @@ -342,15 +198,6 @@ struct RustJavaMavenOperations: JavaMavenOperations, Sendable { core.javaServerPort(content: content, fileExtension: fileExtension)?.port } - func workspaceMavenModules( - in project: MavenProject?, - relativeTo root: URL - ) -> [(path: String, module: MavenModule)] { - project?.allModules.compactMap { module in - workspaceRelativePath(for: module.url, root: root).map { ($0, module) } - } ?? [] - } - private func workspaceRelativePath(for url: URL, root: URL) -> String? { let path = url.standardizedFileURL.path let rootPath = root.standardizedFileURL.path diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+BuildProviderDebugging.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+BuildProviderDebugging.swift new file mode 100644 index 0000000000..3c47e9a368 --- /dev/null +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+BuildProviderDebugging.swift @@ -0,0 +1,123 @@ +import Combine +import Foundation +import LitheCoreContracts +import LitheDebugModule +import LitheExecutionModule +import LitheModuleAPI + +// Note: .agents/notes/implemented/architecture/2026-10-26-build-provider-task-execution.md +@MainActor +extension AppModel { + /// The provider prepares its service; a current registered adapter attaches + /// to the host-owned connection after the process is listening. + func startProviderServiceDebug(_ configuration: RunConfiguration, identity: WorkspaceIdentity, + run: RunFeatureModel) async { + var operation: String? + var attached = false + defer { if !attached, let operation, run.isProviderDebugActive(operation) { run.stopProviderDebug() } } + do { + guard services.remoteConnections?.workspaces.contains(identity.url) != true else { + throw RunConfigurationOperationFailure(message: L10n.string("Framework service debugging is not available in remote workspaces yet.")) + } + guard await saveAllDocuments() else { throw CancellationError() } + guard isCurrentWorkspace(identity), !Task.isCancelled, + let debug = genericDebugFeatureIfActive else { throw CancellationError() } + runWorkflowCoordinator.reapplySelectedConfiguration(run) + if configuration.buildProvider != nil { + try await prepareBuildLanguageImport() + guard let builds = buildSystemsFeatureIfActive else { throw CancellationError() } + try await builds.prepareRunGenerationModels(workspace: identity.url) + guard isCurrentWorkspace(identity), !Task.isCancelled else { throw CancellationError() } + } else { + try await prepareCurrentProviderGeneration(configuration: configuration, identity: identity) + } + var languageImportIsCurrent: @MainActor @Sendable () -> Bool = { true } + var connection: PreparedProviderConnectionDebug? + let revision = buildSystemsFeatureIfActive?.inputRevision + let current: @MainActor @Sendable () -> Bool = { [weak self, weak run] in + guard let self, let run else { return false } + return self.isCurrentWorkspace(identity) && !Task.isCancelled + && run.selectedConfiguration == configuration && self.buildSystemsFeatureIfActive?.inputRevision == revision + && languageImportIsCurrent() + && (connection?.isCurrent() ?? true) + } + guard current() else { throw CancellationError() } + let port = try debugPortAvailabilityChecker.unusedLoopbackPort() + let launched = try await run.prepareProviderDebug(configuration, debug: { configurationType in + let prepared = try self.debugLaunchConfigurationResolver.resolveProviderConnection( + configurationType: configurationType, adapterID: configuration.debugAdapter, port: port) + connection = prepared + return prepared.options + }, languageImport: { [weak self] imported in + guard let self, current(), case .maven(let context) = imported else { throw CancellationError() } + let sessions = try await self.languageSessionsForWorkspaceMaintenance(for: identity) + guard current() else { throw CancellationError() } + let defaults = try self.javaLanguageImportConfiguration(rootURL: identity.url) + try await sessions.applyJavaRunImport(rootURL: identity.url, + languageImport: .init(mavenContext: context, javaGradle: defaults.javaGradle)) + let revision = sessions.javaImportRevision + languageImportIsCurrent = { sessions.javaImportRevision == revision } + }, isCurrent: current) + operation = launched + showToolWindow(.run) + try await debugPortAvailabilityChecker.waitForLoopbackListener(port, processID: { run.providerDebugProcessID(launched) }) { + current() && run.isProviderDebugActive(launched) + } + guard current(), run.isProviderDebugActive(launched) else { throw CancellationError() } + guard let connection, connection.isCurrent() else { throw CancellationError() } + let attach = connection.configuration + let source = runWorkflowCoordinator.sourceURLForDebug(configuration: configuration, + activeDocument: activeDocument, projectFiles: projectFiles, workspaceURL: identity.url) ?? identity.url + guard debug.startAdditional(fileURL: source, rootURL: identity.url, configuration: attach), + let sessionID = debug.activeSessionID else { + throw DebugLaunchConfigurationResolutionError.providerDebugUnavailable + } + // Clear any previous association before stopping it: callbacks may + // arrive synchronously and must not stop the replacement process. + let previous = providerDebugSession + providerDebugSession = (launched, sessionID) + if let previous { debug.stopSession(previous.session) } + providerDebugRunObservation = run.providerDebugEnded.sink { [weak self, weak debug] ended in + guard let self, let owned = self.providerDebugSession, owned.operation == ended else { return } + self.providerDebugSession = nil + debug?.stopSession(owned.session) + } + attached = true + showDebugToolWindow() + } catch is CancellationError { } + catch { if isCurrentWorkspace(identity), !Task.isCancelled { showNotification(error.localizedDescription) } } + } + + func stopProviderDebugProcess(for session: DebugSessionID) { + guard let owned = providerDebugSession, owned.session == session else { return } + providerDebugSession = nil + if runFeatureIfActive?.isProviderDebugActive(owned.operation) == true { runFeatureIfActive?.stopProviderDebug() } + } + + func debugBuildProviderApplication(entryID: String, request: BuildRunRequest, title: String) { + runWorkflowCoordinator.startModuleOperation { [weak self] in + guard let self, let identity = currentWorkspaceIdentity, + await saveAllDocuments(), isCurrentWorkspace(identity), + await activateDebugModule() != nil, isCurrentWorkspace(identity), + !Task.isCancelled, let feature = buildSystemsFeatureIfActive else { return } + if feature.debugApplication(entryID, request: request, title: title, launch: { [weak self] source, preparation in + guard let self, self.isCurrentWorkspace(identity), !Task.isCancelled else { throw CancellationError() } + try self.launchPreparedProviderDebug(source: source, preparation: preparation, workspaceURL: identity.url, title: title) + }) != nil { showToolWindow(.buildOutput) } + } + } + + func launchPreparedProviderDebug(source: BuildSystemPluginSource, preparation: BuildDebugPreparation, + workspaceURL: URL, title: String) throws { + guard let feature = genericDebugFeatureIfActive else { throw CancellationError() } + let configuration = try debugLaunchConfigurationResolver.resolveProvider(source: source, preparation: preparation, + workspaceURL: workspaceURL, title: title) + guard feature.startAdditional(fileURL: workspaceURL.appendingPathComponent(preparation.program), + rootURL: workspaceURL, configuration: configuration) else { + throw NSError(domain: "BuildProviderDebug", code: 1, userInfo: [NSLocalizedDescriptionKey: feature.errorMessage + ?? L10n.string("The build provider's debug program or adapter is unavailable. Build it again and check that the plugin is enabled.")]) + } + showToolWindow(.debug) + } + +} diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+BuildSystems.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+BuildSystems.swift index 7a2a1b22d0..f7fb69f272 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+BuildSystems.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+BuildSystems.swift @@ -56,23 +56,17 @@ extension AppModel { func javaLanguageImportConfiguration(rootURL: URL) throws -> JavaLanguageImportConfiguration { guard workspaceURL?.standardizedFileURL == rootURL.standardizedFileURL else { throw CancellationError() } - let pluginImport: JavaLanguageImportConfiguration if hasAvailableBuildSystemPlugins { guard let feature = buildSystemsFeatureIfActive, feature.workspaceURL == rootURL.standardizedFileURL, let configuration = feature.languageImport else { throw RunConfigurationOperationFailure(message: L10n.string("Build-system language import is still preparing. Retry when project loading completes.")) } - pluginImport = configuration - } else { - pluginImport = JavaLanguageImportConfiguration() + return configuration } - // Legacy Maven remains the compatibility source only when no plugin - // supplied that import kind; Run/Debug/Agent keep their existing owner. - return JavaLanguageImportConfiguration( - mavenContext: pluginImport.mavenContext ?? mavenFeatureIfActive?.launchContext, - javaGradle: pluginImport.javaGradle - ) + // No enabled build plugin means no Lithe build override. In particular, + // retiring a plugin must not revive the old Maven service's settings. + return JavaLanguageImportConfiguration() } private func synchronizeBuildLanguageImport(at rootURL: URL) { diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+Debugging.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+Debugging.swift index 7aa896c939..0ba5d11c94 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+Debugging.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+Debugging.swift @@ -194,6 +194,10 @@ extension AppModel { } return } + if selectedConfiguration.usesProviderConnectionDebug { + await startProviderServiceDebug(selectedConfiguration, identity: identity, run: runFeature) + return + } if selectedConfiguration.buildProvider != nil { guard await saveAllDocuments(), isCurrentWorkspace(identity), !Task.isCancelled, let feature = buildSystemsFeatureIfActive else { return } @@ -216,6 +220,10 @@ extension AppModel { configurations: runFeature.configurations ) runFeature.select(configuration) + if configuration.usesProviderConnectionDebug { + await startProviderServiceDebug(configuration, identity: identity, run: runFeature) + return + } let sourceResolution = runWorkflowCoordinator.resolveDebugSource( configuration: configuration, @@ -256,33 +264,10 @@ extension AppModel { await startGenericDebuggingAfterActivation(fileURL: sourceURL, document: document) } - func debugBuildProviderApplication(entryID: String, request: BuildRunRequest, title: String) { - runWorkflowCoordinator.startModuleOperation { [weak self] in - guard let self, let identity = currentWorkspaceIdentity, - await saveAllDocuments(), isCurrentWorkspace(identity), - await activateDebugModule() != nil, isCurrentWorkspace(identity), - !Task.isCancelled, let feature = buildSystemsFeatureIfActive else { return } - if feature.debugApplication(entryID, request: request, title: title, launch: { [weak self] source, preparation in - guard let self, self.isCurrentWorkspace(identity), !Task.isCancelled else { throw CancellationError() } - try self.launchPreparedProviderDebug(source: source, preparation: preparation, workspaceURL: identity.url, title: title) - }) != nil { showToolWindow(.buildOutput) } - } - } - - private func launchPreparedProviderDebug(source: BuildSystemPluginSource, preparation: BuildDebugPreparation, - workspaceURL: URL, title: String) throws { - guard let feature = genericDebugFeatureIfActive else { throw CancellationError() } - let configuration = try debugLaunchConfigurationResolver.resolveProvider(source: source, preparation: preparation, - workspaceURL: workspaceURL, title: title) - guard feature.startAdditional(fileURL: workspaceURL.appendingPathComponent(preparation.program), - rootURL: workspaceURL, configuration: configuration) else { - throw NSError(domain: "BuildProviderDebug", code: 1, userInfo: [NSLocalizedDescriptionKey: feature.errorMessage - ?? L10n.string("The build provider's debug program or adapter is unavailable. Build it again and check that the plugin is enabled.")]) - } - showToolWindow(.debug) - } - func stopDebugging() { + if providerDebugSession == nil || providerDebugSession?.session == genericDebugFeatureIfActive?.activeSessionID { + runFeatureIfActive?.stopProviderDebug() + } buildSystemsFeatureIfActive?.cancelDebugPreparation() runWorkflowCoordinator.cancelModuleOperation() cancelJavaTestDebugLaunch() @@ -495,7 +480,6 @@ extension AppModel { } guard isCurrentWorkspace(identity), !Task.isCancelled else { return } let selectedConfiguration = runFeature.selectedConfiguration - let mavenContext = mavenFeatureIfActive?.launchContext // Debug is the second execution mode for the Run selection. Re-apply // the selection here so its project-scoped Java runtime override is // active even when the Run panel was never opened in this session. @@ -505,7 +489,14 @@ extension AppModel { } var javaUpdateTarget: JavaDebugLaunchTarget? let configuration: DebugLaunchConfiguration + let buildInputs: UInt64? + let javaImport: JavaLanguageImportConfiguration? do { + if let selectedConfiguration { try await prepareCurrentProviderGeneration(configuration: selectedConfiguration, identity: identity) } + if let selectedConfiguration, selectedConfiguration.usesJavaProjectPreparation { + javaImport = try await prepareJavaBuildResources(configuration: selectedConfiguration, fileURL: fileURL, identity: identity) + } else { try await prepareBuildLanguageImport(); javaImport = nil } + buildInputs = buildSystemsFeatureIfActive?.inputRevision configuration = try await featureGraph.debugLaunchPreparation.prepare( provider: provider, fileURL: fileURL, @@ -516,18 +507,26 @@ extension AppModel { resolveJavaTarget: { [weak self] in guard let self else { return nil } try await self.saveJavaWorkspaceForLaunch(identity: identity) + let operation = self.buildSystemsFeatureIfActive?.operation?.id let sessions = try await self.languageSessionsForWorkspaceMaintenance(for: identity) - let preparation = try await sessions.prepareJavaRunLaunchTarget( - fileURL: fileURL, - rootURL: workspaceURL, - mainClass: selectedConfiguration?.mainClass - ) + let preparation: JavaLaunchPreparation + if let javaImport { + preparation = try await sessions.prepareJavaRunLaunchTarget(fileURL: fileURL, rootURL: workspaceURL, languageImport: javaImport, mainClass: selectedConfiguration?.mainClass) + } else { + preparation = try await sessions.prepareJavaRunLaunchTarget(fileURL: fileURL, rootURL: workspaceURL, mainClass: selectedConfiguration?.mainClass) + } + let javaRevision = sessions.javaImportRevision let target = try await self.resolveJavaLaunchPreparation( preparation, identity: identity ) - javaUpdateTarget = target - return target + guard !Task.isCancelled, self.isCurrentWorkspace(identity), self.buildSystemsFeatureIfActive?.operation?.id == operation, + sessions.javaImportRevision == javaRevision else { throw CancellationError() } + let prepared = JavaDebugLaunchTarget(mainClass: target.mainClass, projectName: target.projectName, + modulePaths: target.modulePaths, classPaths: target.classPaths, + buildPrepared: selectedConfiguration?.usesJavaProjectPreparation == true) + javaUpdateTarget = prepared + return prepared } ) // log-exempt: 用户取消:调用方取消等待或替换旧任务时结束延迟,不代表操作失败 @@ -539,7 +538,7 @@ extension AppModel { return } guard isCurrentWorkspace(identity), !Task.isCancelled, - mavenFeatureIfActive?.launchContext == mavenContext, + buildSystemsFeatureIfActive?.inputRevision == buildInputs, runFeature.selectedConfiguration == selectedConfiguration else { return } guard featureGraph.debugLaunchPreparation.start( configuration: configuration, diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+ExecutionModules.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+ExecutionModules.swift index e6da26f880..18734d10be 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+ExecutionModules.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+ExecutionModules.swift @@ -6,10 +6,9 @@ import LitheModuleAPI @MainActor extension AppModel { - var mavenFeatureIfActive: MavenFeatureModel? { executionCapability?.mavenFeature } var runFeatureIfActive: RunFeatureModel? { executionCapability?.runFeature } - /// Run configurations and Maven goals still running in this project, for + /// Run configurations and plugin build tasks still running in this project, for /// the question before the project, its window or Lithe closes. Record IDs /// carry the session ID so several projects can be listed together. var runServiceExitCandidates: [RunServiceExitCandidate] { @@ -31,10 +30,10 @@ extension AppModel { ) } } - if let maven = mavenFeatureIfActive, maven.isRunning { + if let build = buildSystemsFeatureIfActive, build.isRunning, let operation = build.operation { candidates.append(RunServiceExitCandidate( - id: "maven:\(id.uuidString)", - title: maven.runningTitle ?? "Maven", + id: "build:\(id.uuidString):\(operation.id.uuidString)", + title: operation.title, projectName: project )) } @@ -50,27 +49,7 @@ extension AppModel { // hand back a run feature that teardown releases moments later, and the // deferred action waiting on it would never be resumed. guard let access = await executionModuleCoordinator.activateAccess() else { return nil } - access.mavenFeature.configureJavaConfigurationSync { [weak self] rootURL in - guard let self, self.workspaceURL?.standardizedFileURL == rootURL.standardizedFileURL, - let sessions = self.languageToolingSessionsIfActive else { return false } - // Plugin options own Java import when present; a legacy Maven - // settings write must not replace them, including during detection. - guard !self.isBuildLanguageImportPending else { return false } - switch sessions.updateJavaMavenConfiguration(rootURL: rootURL, reloadProjects: false) { - case .updated: return true - case .noSession: return false - case .failed(let failure): - throw NSError( - domain: "MavenJavaConfiguration", - code: 1, - userInfo: [NSLocalizedDescriptionKey: failure.userMessage] - ) - } - } - for ownership in services.pluginCatalog.languageSupports.values { - registerLanguageDependencySourceIfAvailable(support: ownership.declaration) - } - registerJavaDependencySourceIfAvailable() + loadBuildSystems(access) return access } @@ -158,6 +137,10 @@ extension AppModel { } feature.onSessionStopped = { [weak self] debugSessionID in self?.stopDebugTerminalProcesses(for: debugSessionID) + self?.stopProviderDebugProcess(for: debugSessionID) + } + feature.onSessionStateChanged = { [weak self] sessionID, state in + if state == .failed || state == .terminated { self?.stopProviderDebugProcess(for: sessionID) } } feature.onSessionRunInTerminalRequest = { [weak self] debugSessionID, request, completion in guard let self else { diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift index aad6e6241e..19118f1341 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift @@ -393,54 +393,27 @@ extension AppModel { func prepareProjectRuntimeSettings() async { await runtimeFeature.refreshAvailableRuntimes() - if let identity = currentWorkspaceIdentity, - let run = await activateExecutionModule()?.runFeature, - isCurrentWorkspace(identity) { - // Seed the form from the saved project toolchain, not the empty value a - // Run feature holds while it is still reading this project's documents. - _ = await ensureRunProjectReady(run, for: identity) - if isCurrentWorkspace(identity) { adoptSavedProjectToolchain(from: run, workspace: identity.url) } - } runtimeFeature.prepare( workspaceName: projectName, workspaceURL: workspaceURL, files: projectFiles, - mavenProject: mavenFeatureIfActive?.project, - toolchain: runFeatureIfActive?.projectToolchain, - mavenSettingsPath: mavenFeatureIfActive?.settingsPath, - mavenLocalRepositoryPath: mavenFeatureIfActive?.localRepositoryPath, - mavenExecutablePath: mavenFeatureIfActive?.mavenExecutablePath, - mavenJavaHomePath: mavenFeatureIfActive?.javaHomePath + buildSources: buildSystemsFeatureIfActive?.entries.map { entry in + ProjectRuntimeInventory.BuildSource(id: entry.id, root: entry.instance.root, + displayName: entry.instance.displayName, + usesJava: services.pluginCatalog.languageSupports.values.contains { ownership in + let support = ownership.declaration + return support.id == "java" && support.recognizesProject( + fileNames: entry.instance.markers.map { URL(fileURLWithPath: $0).lastPathComponent }) + }, + model: entry.modelState == .loaded && !entry.isReloadRequired ? entry.model : nil) + } ?? [], + languageSupports: services.pluginCatalog.languageSupports.values.map(\.declaration) + .filter { !languageToolingFeature.isDisabled($0.id) } ) } - /// `.lithe/run/local.json` owns the project JDK and Maven defaults; the runtime - /// settings only mirror them. A layer without saved defaults keeps the current - /// settings, which the next save writes into the layer. - func adoptSavedProjectToolchain(from run: RunFeatureModel, workspace: URL) { - guard !run.isLoadingProject, - run.projectLoadState.hasLoadedDocuments(for: workspace), - let saved = run.savedProjectToolchain else { return } - runtimeFeature.adoptProjectToolchain(saved) - } - - func persistProjectRuntimeSettings() { - let settings = runtimeFeature.settings - if let maven = mavenFeatureIfActive { - maven.updateLocalConfiguration( - settingsPath: settings.mavenSettingsPath, - localRepositoryPath: settings.mavenLocalRepositoryPath, - mavenExecutablePath: settings.mavenExecutableOverride, - javaHomePath: settings.mavenJavaHomePath - ) - } - // Closing Settings persists without an edit. Until the Run feature has read - // this project's documents, its toolchain is unknown, and saving would - // overwrite the project defaults with whatever the form was seeded from. - guard let workspaceURL, - let run = runFeatureIfActive, - !run.isLoadingProject, - run.projectLoadState.hasLoadedDocuments(for: workspaceURL) else { return } - _ = run.saveProjectToolchain(runtimeFeature.projectToolchainSelection) + func persistProjectRuntimeSettings() async { + guard currentWorkspaceIdentity != nil, runtimeFeature.saveSettings() else { return } + runtimeFeature.finishSaving() } } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+JavaBuildPreparation.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+JavaBuildPreparation.swift new file mode 100644 index 0000000000..0c18eb9ee7 --- /dev/null +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+JavaBuildPreparation.swift @@ -0,0 +1,82 @@ +import Foundation +import LitheCoreContracts +import LitheExecutionModule +import LitheModuleAPI + +/// Save/build/JDT ordering for Java Run and Debug; Maven owns resource semantics. +@MainActor +extension AppModel { + func prepareJavaRunLaunch( + for configuration: RunConfiguration, + identity: WorkspaceIdentity + ) async throws -> JavaDebugLaunchTarget? { + guard configuration.usesJavaProjectPreparation else { + try await prepareCurrentProviderGeneration(configuration: configuration, identity: identity) + return nil + } + try await saveJavaWorkspaceForLaunch(identity: identity) + guard let workspaceURL, + let sourceURL = runWorkflowCoordinator.sourceURLForDebug( + configuration: configuration, + activeDocument: activeDocument, + projectFiles: projectFiles, + workspaceURL: workspaceURL + ) else { + throw RunConfigurationOperationFailure( + message: L10n.string("The Java source for \(configuration.name) could not be resolved.") + ) + } + try await prepareCurrentProviderGeneration(configuration: configuration, identity: identity) + let languageImport = try await prepareJavaBuildResources(configuration: configuration, fileURL: sourceURL, identity: identity) + let revision = buildSystemsFeatureIfActive?.inputRevision + let operation = buildSystemsFeatureIfActive?.operation?.id + let sessions = try await languageSessionsForWorkspaceMaintenance(for: identity) + guard isCurrentWorkspace(identity), !Task.isCancelled else { throw CancellationError() } + let preparation = try await sessions.prepareJavaRunLaunchTarget(fileURL: sourceURL, rootURL: workspaceURL, languageImport: languageImport, mainClass: configuration.mainClass) + let javaRevision = sessions.javaImportRevision + let target = try await resolveJavaLaunchPreparation(preparation, identity: identity) + guard isCurrentWorkspace(identity), !Task.isCancelled, buildSystemsFeatureIfActive?.inputRevision == revision, + buildSystemsFeatureIfActive?.operation?.id == operation, sessions.javaImportRevision == javaRevision else { throw CancellationError() } + return JavaDebugLaunchTarget(mainClass: target.mainClass, projectName: target.projectName, + modulePaths: target.modulePaths, classPaths: target.classPaths, buildPrepared: true) + } + + func prepareCurrentProviderGeneration(configuration: RunConfiguration, identity: WorkspaceIdentity) async throws { + guard configuration.requiresProviderGeneration else { return } + try await prepareBuildLanguageImport() + guard let run = runFeatureIfActive else { throw CancellationError() } + // No enabled build plugin means no Provider model to refresh; validation + // then finds no build-project ownership and lets the launch continue. + if hasAvailableBuildSystemPlugins { + guard let builds = buildSystemsFeatureIfActive else { throw CancellationError() } + try await builds.prepareRunGenerationModels(workspace: identity.url) + guard isCurrentWorkspace(identity), !Task.isCancelled else { throw CancellationError() } + } + try run.validateProviderGeneration(for: configuration) + } + + /// The provider declares resource preparation; JDT retains launch semantics. + @discardableResult + func prepareJavaBuildResources(configuration: RunConfiguration, fileURL: URL, identity: WorkspaceIdentity) async throws -> JavaLanguageImportConfiguration { + guard configuration.usesJavaProjectPreparation else { return JavaLanguageImportConfiguration() } + guard isCurrentWorkspace(identity), await saveAllDocuments(), isCurrentWorkspace(identity), !Task.isCancelled else { throw CancellationError() } + try await prepareBuildLanguageImport() + guard let builds = buildSystemsFeatureIfActive, let run = runFeatureIfActive else { throw CancellationError() } + try await prepareCurrentProviderGeneration(configuration: configuration, identity: identity) + let options = run.options(for: configuration) + var overrides: [String: ToolingJSONValue] = [:] + if !options.activeProfiles.isEmpty { overrides["profiles"] = .array(options.activeProfiles.sorted().map(ToolingJSONValue.string)) } + let prepared = try await builds.prepareLanguageRun(fileURL: fileURL, workspace: identity.url, + purpose: "run", optionOverrides: overrides, + isCurrent: { [weak self] in self?.isCurrentWorkspace(identity) == true }) + guard let prepared else { + throw RunConfigurationOperationFailure(message: L10n.string("Load the current build model and enable its provider before running this configuration.")) + } + let current = try javaLanguageImportConfiguration(rootURL: identity.url) + switch prepared { + case .maven(let context): return JavaLanguageImportConfiguration(mavenContext: context, javaGradle: current.javaGradle) + case .gradle(let context): return JavaLanguageImportConfiguration(mavenContext: current.mavenContext, javaGradle: context) + } + } + +} diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+JavaEntrypoints.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+JavaEntrypoints.swift index f3faf42d2e..dc6d851246 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+JavaEntrypoints.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+JavaEntrypoints.swift @@ -18,9 +18,20 @@ extension AppModel { for identity: WorkspaceIdentity, automatic: Bool = false ) async { + do { + try await prepareBuildLanguageImport() + try await buildSystemsFeatureIfActive?.prepareRunGenerationModels(workspace: identity.url) + } catch { + guard isCurrentWorkspace(identity) else { return } + runFeature.reportGenerationProjectNotReady() + return + } + let buildRevision = buildSystemsFeatureIfActive?.inputRevision let (discovery, sessions) = await discoverJavaEntrypoints(for: identity) - guard isCurrentWorkspace(identity) else { return } + guard isCurrentWorkspace(identity), !Task.isCancelled, + buildSystemsFeatureIfActive?.inputRevision == buildRevision else { return } await runFeature.generateRunConfigurations(javaDiscovery: discovery, automatic: automatic) + if discovery == .pending, let sessions { refreshJavaEntrypointsWhenPrepared(for: identity, sessions: sessions, automatic: automatic) } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+JavaLaunchPreparation.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+JavaLaunchPreparation.swift index 19504be3a4..557f231cbd 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+JavaLaunchPreparation.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+JavaLaunchPreparation.swift @@ -23,34 +23,4 @@ extension AppModel { } } - func prepareJavaRunLaunch( - for configuration: RunConfiguration, - identity: WorkspaceIdentity - ) async throws -> JavaDebugLaunchTarget? { - guard configuration.usesJavaProjectPreparation else { return nil } - // JDT must compile the current workspace, including dirty dependencies - // outside the selected source. A failed save ends this launch attempt. - try await saveJavaWorkspaceForLaunch(identity: identity) - guard let workspaceURL, - let sourceURL = runWorkflowCoordinator.sourceURLForDebug( - configuration: configuration, - activeDocument: activeDocument, - projectFiles: projectFiles, - workspaceURL: workspaceURL - ) else { - throw RunConfigurationOperationFailure( - message: L10n.string("The Java source for \(configuration.name) could not be resolved.") - ) - } - let sessions = try await languageSessionsForWorkspaceMaintenance(for: identity) - guard isCurrentWorkspace(identity), !Task.isCancelled else { - throw CancellationError() - } - let preparation = try await sessions.prepareJavaRunLaunchTarget( - fileURL: sourceURL, - rootURL: workspaceURL, - mainClass: configuration.mainClass - ) - return try await resolveJavaLaunchPreparation(preparation, identity: identity) - } } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+JavaRunMarkers.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+JavaRunMarkers.swift index 5a27daf182..54d997abb9 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+JavaRunMarkers.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+JavaRunMarkers.swift @@ -39,10 +39,14 @@ extension AppModel { let items = await javaRunMarkerSource(sessions: sessions) { completion in try sessions.javaTestItems(fileURL: fileURL) { completion($0.map(\.items)) } } ?? [] + let project = buildSystemsFeatureIfActive?.projectPath(containing: document.url) + let outcomes = (languageTestServiceIfActive?.testOutcomes(for: "java") ?? []).filter { + $0.project == nil || $0.project == project + } return services.javaMavenOperations.javaRunMarkers( mainMethods: methods, testItems: items, - testCases: languageTestServiceIfActive?.testOutcomes ?? [] + testCases: outcomes ) ?? [] } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+LanguageServerRuntime.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+LanguageServerRuntime.swift index c6049bb24f..beeff5ecca 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+LanguageServerRuntime.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+LanguageServerRuntime.swift @@ -382,7 +382,6 @@ extension AppModel { handleBuildSystemFileChanges(changes) handleVcsProviderFileChanges(changes) toolchainSettingsFeature.workspaceFilesChanged(changes) - let maven = mavenFeatureIfActive let forwarded = changes.filter { change in if Self.isWorkspaceDependencyMetadata( change.fileURL, @@ -390,9 +389,6 @@ extension AppModel { ) { return false } - guard change.fileURL.lastPathComponent.lowercased() == "pom.xml" else { return true } - maven?.markPomChanged(change.fileURL) - // JDT LS also needs the change to refresh its resolved classpath snapshot. return true } javaLanguageServerPreparationCoordinator.notifyWorkspaceFileChanges( @@ -423,45 +419,6 @@ extension AppModel { return path == directory || path.hasPrefix(directory + "/") } - func reloadMavenProject(rescan: Bool) async { - guard let identity = currentWorkspaceIdentity, - let feature = mavenFeatureIfActive else { return } - let files = projectFiles - if feature.project == nil { - await feature.loadProject(at: identity.url, files: files) - return - } - await feature.reloadProject(files: files, rescan: rescan) { [weak self] in - guard let self, self.isCurrentWorkspace(identity) else { throw CancellationError() } - guard !self.languageToolingFeature.isDisabled("java") else { return } - guard self.services.javaMavenOperations.javaWorkspacePolicy( - at: identity.url, files: files, changedFiles: [] - )?.shouldStart == true else { - self.languageToolingSessionsIfActive?.stopLanguageServer(providerID: "java") - return - } - let preparation = await self.services.projectRuntimeService.prepareJavaLanguageServerRuntime() - try Task.checkCancellation() - guard self.isCurrentWorkspace(identity) else { throw CancellationError() } - guard !self.languageToolingFeature.isDisabled("java") else { return } - switch preparation { - case .ready: break - case .failed(let message): - throw NSError(domain: "MavenReload", code: 1, userInfo: [NSLocalizedDescriptionKey: message]) - case .unprepared: - throw NSError(domain: "MavenReload", code: 1, userInfo: [ - NSLocalizedDescriptionKey: self.settings.language.localizedString("Java runtime is not ready. Retry Maven reload.") - ]) - } - let sessions = try await self.languageSessionsForWorkspaceMaintenance(for: identity) - try Task.checkCancellation() - guard self.isCurrentWorkspace(identity) else { throw CancellationError() } - guard !self.languageToolingFeature.isDisabled("java") else { return } - self.cancelJavaLanguageServerPreparation() - try await sessions.reloadJavaWorkspace(rootURL: identity.url) - } - } - private func ownsJavaLanguageServerPreparation( workspaceURL: URL, operationID: UUID diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+LanguageTests.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+LanguageTests.swift index c4780cce11..0d2ee79c4e 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+LanguageTests.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+LanguageTests.swift @@ -78,11 +78,14 @@ extension AppModel { } func runTest(providerID: String, scope: LanguageTestScope) { - guard let workspaceURL else { return } + guard let workspaceURL, let identity = currentWorkspaceIdentity else { return } activeTestTarget = ActiveTestTarget(providerID: providerID, scope: scope) showToolWindow(.tests) - Task { [weak self] in - guard let self, let execution = await activateExecutionModule() else { return } + let preparationID = javaTestWorkflowState.beginRunPreparation() + javaTestWorkflowState.runPreparationTask = Task { [weak self] in + guard let self else { return } + defer { javaTestWorkflowState.finishRunPreparation(preparationID) } + guard let execution = await activateExecutionModule(), javaTestWorkflowState.isCurrentRunPreparation(preparationID) else { return } if let ownership = services.pluginCatalog.languageSupports[providerID], !(await languageIntelligenceModuleCoordinator.activateTestExtension( ownership.declaration, @@ -90,6 +93,50 @@ extension AppModel { )) { return } + guard isCurrentWorkspace(identity), javaTestWorkflowState.isCurrentRunPreparation(preparationID) else { return } + if providerID == "java" { + do { try await prepareBuildLanguageImport() } + catch { + if !error.isCancellation { showNotification(error.localizedDescription) } + return + } + guard isCurrentWorkspace(identity), javaTestWorkflowState.isCurrentRunPreparation(preparationID) else { return } + if let builds = execution.buildSystems, + builds.entries.contains(where: { $0.declaration.testSelectorKinds.contains("java.test.fullName") }) { + execution.tests.runUsingBackend(providerID: providerID, scope: scope, workspaceURL: workspaceURL, + label: L10n.string("Tests")) { [weak self, weak builds] output, process in + guard let self, let builds, isCurrentWorkspace(identity), !Task.isCancelled else { throw CancellationError() } + if let reference = scope.buildReference { + return try await builds.performLanguageTests(reference: reference, workspace: workspaceURL, title: L10n.string("Tests"), + isCurrent: { [weak self] in self?.isCurrentWorkspace(identity) == true }, beforePreparing: { + guard await self.saveAllDocuments(), self.isCurrentWorkspace(identity), !Task.isCancelled else { throw CancellationError() } + try await self.prepareBuildLanguageImport() + }, output: output, process: process) + } + let fileURL: URL? + let identifier: String? + switch scope { + case .workspace: fileURL = nil; identifier = nil + case .file(let file): fileURL = file; identifier = nil + case .testCase(let value, let file, _): + guard let file else { throw LanguageTestPlanError.unsupportedProvider("Java") } + fileURL = file; identifier = value + } + return try await builds.performLanguageTests(selectorKind: "java.test.fullName", fileURL: fileURL, + workspace: workspaceURL, title: L10n.string("Tests"), isCurrent: { [weak self] in + self?.isCurrentWorkspace(identity) == true + }, beforePreparing: { + guard await self.saveAllDocuments(), self.isCurrentWorkspace(identity), !Task.isCancelled else { throw CancellationError() } + try await self.prepareBuildLanguageImport() + }, prepareSelectors: { + guard let fileURL else { return [] } + let sessions = try await self.languageSessionsForWorkspaceMaintenance(for: identity) + return try await sessions.javaBuildTestSelectors(fileURL: fileURL, identifier: identifier, rootURL: workspaceURL) + }, output: output, process: process) + } + return + } + } _ = execution.tests.run( providerID: providerID, scope: scope, @@ -137,13 +184,20 @@ extension AppModel { if let selectedConfiguration = runFeature.selectedConfiguration { runFeature.select(selectedConfiguration) } + try await self.prepareBuildLanguageImport() + let revision = self.buildSystemsFeatureIfActive?.inputRevision let sessions = try await self.languageSessionsForWorkspaceMaintenance(for: identity) - return try await self.services.javaTestDebugLaunchService.prepare( + let prepared = try await self.services.javaTestDebugLaunchService.prepare( fileURL: request.fileURL, testIdentifier: request.testIdentifier, rootURL: workspaceURL, targetResolver: sessions ) + guard self.isCurrentWorkspace(identity), !Task.isCancelled, self.buildSystemsFeatureIfActive?.inputRevision == revision else { + prepared.stop() + throw CancellationError() + } + return prepared }, startDebug: { [weak self] prepared in guard let self, self.isCurrentWorkspace(identity), !Task.isCancelled, @@ -181,6 +235,7 @@ extension AppModel { } func stopTests() { + javaTestWorkflowState.cancelRunPreparation() executionModuleCoordinator.stopTests(languageTestServiceIfActive) } @@ -193,6 +248,7 @@ extension AppModel { } func cancelJavaTestWorkflows() { + javaTestWorkflowState.cancelRunPreparation() cancelLanguageTestDiscovery() cancelJavaTestDebugLaunch() } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+RunConfiguration.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+RunConfiguration.swift index 519b72569e..a37820dd48 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+RunConfiguration.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+RunConfiguration.swift @@ -8,6 +8,7 @@ import LitheModuleAPI extension AppModel { func runBuildProviderApplication(entryID: String, request: BuildRunRequest, title: String) { guard let identity = currentWorkspaceIdentity, let feature = buildSystemsFeatureIfActive else { return } + runWorkflowCoordinator.cancelModuleOperation() guard feature.runApplication(entryID, request: request, title: title, beforePreparing: { [weak self] in guard let self, !Task.isCancelled, isCurrentWorkspace(identity), await saveAllDocuments(), !Task.isCancelled, isCurrentWorkspace(identity) else { return false } @@ -86,7 +87,7 @@ extension AppModel { func generateRunConfigurations(automatic: Bool = false) async { guard let identity = currentWorkspaceIdentity else { return } guard let runFeature = await activateExecutionModule()?.runFeature else { return } - guard isCurrentWorkspace(identity) else { return } + guard isCurrentWorkspace(identity), !Task.isCancelled else { return } guard runFeature.recoveryAction != .upgradeApplication else { return } switch await ensureRunProjectReady(runFeature, for: identity) { case .ready: @@ -113,7 +114,7 @@ extension AppModel { ) return } - Task { [weak self] in await self?.runSelectedConfigurationAfterActivation() } + runWorkflowCoordinator.startRunAction { [weak self] in await self?.runSelectedConfigurationAfterActivation() } } /// Loads services for the workspace snapshot currently applied by the model. @@ -155,7 +156,7 @@ extension AppModel { ) loadVcsProviders(at: target) guard let execution = await activateExecutionModule() else { return } - guard isCurrentWorkspace(identity) else { return } + guard isCurrentWorkspace(identity), !Task.isCancelled else { return } loadBuildSystems(execution) execution.tests.discover(workspaceURL: target, files: files) await execution.projectDevelopment.loadProject( @@ -163,15 +164,15 @@ extension AppModel { files: files, snapshotID: snapshotID ) - guard isCurrentWorkspace(identity) else { return } + guard isCurrentWorkspace(identity), !Task.isCancelled else { return } // Detected entries live in Core's memory, not in the workspace, so a // project this process has not identified yet is identified now. if snapshotID != nil, execution.runFeature.configurationStatus == .missing || execution.runFeature.requiresConfigurationGeneration { await generateFromJavaEntrypoints(execution.runFeature, for: identity, automatic: true) - guard isCurrentWorkspace(identity) else { return } + guard isCurrentWorkspace(identity), !Task.isCancelled else { return } } - adoptSavedProjectToolchain(from: execution.runFeature, workspace: target) + checkJavaEntrypointFreshness(execution.runFeature, for: identity, files: files) guard resumesDeferredRunAction else { return } runWorkflowCoordinator.resumeDeferredAction( @@ -212,14 +213,14 @@ extension AppModel { } func deferRunAction(_ kind: PendingRunAction.Kind, for identity: WorkspaceIdentity) { - guard isCurrentWorkspace(identity) else { return } + guard isCurrentWorkspace(identity), !Task.isCancelled else { return } runWorkflowCoordinator.deferAction(kind, for: identity) } private func runSelectedConfigurationAfterActivation() async { guard let identity = currentWorkspaceIdentity else { return } guard let runFeature = await activateExecutionModule()?.runFeature else { return } - guard isCurrentWorkspace(identity) else { return } + guard isCurrentWorkspace(identity), !Task.isCancelled else { return } switch await ensureRunProjectReady(runFeature, for: identity) { case .ready: clearPendingRunAction(for: identity) @@ -250,7 +251,7 @@ extension AppModel { )) { return } - guard isCurrentWorkspace(identity) else { return } + guard isCurrentWorkspace(identity), !Task.isCancelled else { return } guard await runWorkflowCoordinator.saveDirtyCurrentFileIfNeeded( configuration: configuration, document: activeDocument, @@ -258,7 +259,7 @@ extension AppModel { ) else { return } - guard isCurrentWorkspace(identity) else { return } + guard isCurrentWorkspace(identity), !Task.isCancelled else { return } let javaLaunch: JavaDebugLaunchTarget? do { javaLaunch = try await prepareJavaRunLaunch( @@ -269,11 +270,11 @@ extension AppModel { } catch is CancellationError { return } catch { - guard isCurrentWorkspace(identity) else { return } + guard isCurrentWorkspace(identity), !Task.isCancelled else { return } showNotification(error.localizedDescription, logReference: notificationFailure(error, operation: "run_configuration")) return } - guard isCurrentWorkspace(identity) else { return } + guard isCurrentWorkspace(identity), !Task.isCancelled else { return } if configuration.kind != .currentFile { runFeature.startConfiguration(configuration, javaLaunch: javaLaunch) } else { @@ -289,11 +290,11 @@ extension AppModel { return } showToolWindow(.run) - Task { [weak self] in + runWorkflowCoordinator.startRunAction { [weak self] in guard let self else { return } guard let identity = currentWorkspaceIdentity else { return } guard let runFeature = await activateExecutionModule()?.runFeature else { return } - guard isCurrentWorkspace(identity) else { return } + guard isCurrentWorkspace(identity), !Task.isCancelled else { return } guard runFeature.lastConfiguration != nil else { return } switch await ensureRunProjectReady(runFeature, for: identity) { case .ready: @@ -315,7 +316,7 @@ extension AppModel { )) { return } - guard isCurrentWorkspace(identity) else { return } + guard isCurrentWorkspace(identity), !Task.isCancelled else { return } let document = documentFeature.editorDocuments.first { $0.url.standardizedFileURL == runFeature.lastRunFileURL?.standardizedFileURL } @@ -324,7 +325,7 @@ extension AppModel { document: document, saving: self ) else { return } - guard isCurrentWorkspace(identity) else { return } + guard isCurrentWorkspace(identity), !Task.isCancelled else { return } let javaLaunch: JavaDebugLaunchTarget? do { javaLaunch = try await prepareJavaRunLaunch( @@ -335,27 +336,27 @@ extension AppModel { } catch is CancellationError { return } catch { - guard isCurrentWorkspace(identity) else { return } + guard isCurrentWorkspace(identity), !Task.isCancelled else { return } showNotification(error.localizedDescription, logReference: notificationFailure(error, operation: "run_configuration")) return } - guard isCurrentWorkspace(identity) else { return } + guard isCurrentWorkspace(identity), !Task.isCancelled else { return } runFeature.restart(javaLaunch: javaLaunch) } } func startRunConfiguration(_ configuration: RunConfiguration) { - Task { [weak self] in + runWorkflowCoordinator.startRunAction { [weak self] in await self?.performStartRunConfiguration(configuration) } } func startSelectedServiceConfigurations(_ configurations: [RunConfiguration]) { guard !configurations.isEmpty else { return } - Task { [weak self] in + runWorkflowCoordinator.startRunAction { [weak self] in guard let self, let identity = currentWorkspaceIdentity else { return } guard let runFeature = await activateExecutionModule()?.runFeature else { return } - guard isCurrentWorkspace(identity) else { return } + guard isCurrentWorkspace(identity), !Task.isCancelled else { return } switch await ensureRunProjectReady(runFeature, for: identity) { case .ready: clearPendingRunAction(for: identity) @@ -375,7 +376,7 @@ extension AppModel { var preparedConfigurations: [(RunConfiguration, JavaDebugLaunchTarget?)] = [] do { for configuration in configurations { - guard isCurrentWorkspace(identity) else { return } + guard isCurrentWorkspace(identity), !Task.isCancelled else { return } let target = try await prepareJavaRunLaunch( for: configuration, identity: identity @@ -386,12 +387,12 @@ extension AppModel { } catch is CancellationError { return } catch { - guard isCurrentWorkspace(identity) else { return } + guard isCurrentWorkspace(identity), !Task.isCancelled else { return } showNotification(error.localizedDescription, logReference: notificationFailure(error, operation: "run_configuration")) return } for (configuration, javaLaunch) in preparedConfigurations { - guard isCurrentWorkspace(identity) else { return } + guard isCurrentWorkspace(identity), !Task.isCancelled else { return } runFeature.startConfiguration(configuration, javaLaunch: javaLaunch) } showToolWindow(.run) @@ -402,7 +403,7 @@ extension AppModel { func performStartRunConfiguration(_ configuration: RunConfiguration, allowDeferred: Bool = true) async { guard let identity = currentWorkspaceIdentity else { return } guard let runFeature = await activateExecutionModule()?.runFeature else { return } - guard isCurrentWorkspace(identity) else { return } + guard isCurrentWorkspace(identity), !Task.isCancelled else { return } switch await ensureRunProjectReady(runFeature, for: identity) { case .ready: clearPendingRunAction(for: identity) @@ -420,7 +421,7 @@ extension AppModel { currentFileURL: activeDocument?.url, runFeature: runFeature ) else { return } - guard isCurrentWorkspace(identity) else { return } + guard isCurrentWorkspace(identity), !Task.isCancelled else { return } let javaLaunch: JavaDebugLaunchTarget? do { javaLaunch = try await prepareJavaRunLaunch(for: configuration, identity: identity) @@ -428,17 +429,17 @@ extension AppModel { } catch is CancellationError { return } catch { - guard isCurrentWorkspace(identity) else { return } + guard isCurrentWorkspace(identity), !Task.isCancelled else { return } showNotification(error.localizedDescription, logReference: notificationFailure(error, operation: "run_configuration")) return } - guard isCurrentWorkspace(identity) else { return } + guard isCurrentWorkspace(identity), !Task.isCancelled else { return } runFeature.startConfiguration(configuration, javaLaunch: javaLaunch) showToolWindow(.run) } func startRunConfigurations(_ configurationIDs: [String]) { - Task { [weak self] in + runWorkflowCoordinator.startRunAction { [weak self] in await self?.performStartRunConfigurations(configurationIDs) } } @@ -447,7 +448,7 @@ extension AppModel { let ids = Set(configurationIDs).sorted() guard !ids.isEmpty, let identity = currentWorkspaceIdentity else { return } guard let runFeature = await activateExecutionModule()?.runFeature else { return } - guard isCurrentWorkspace(identity) else { return } + guard isCurrentWorkspace(identity), !Task.isCancelled else { return } switch await ensureRunProjectReady(runFeature, for: identity) { case .ready: clearPendingRunAction(for: identity) @@ -458,7 +459,7 @@ extension AppModel { return } for id in ids { - guard isCurrentWorkspace(identity) else { return } + guard isCurrentWorkspace(identity), !Task.isCancelled else { return } guard let configuration = runFeature.configurations.first(where: { $0.id == id }), !configuration.usesCurrentEditorFile, !runFeature.moduleSessions.contains(where: { $0.id == id && $0.isRunning }) @@ -468,7 +469,7 @@ extension AppModel { currentFileURL: nil, runFeature: runFeature ) - guard isCurrentWorkspace(identity) else { return } + guard isCurrentWorkspace(identity), !Task.isCancelled else { return } guard activated, let current = runFeature.configurations.first(where: { $0.id == id }), !runFeature.moduleSessions.contains(where: { $0.id == id && $0.isRunning }) @@ -478,13 +479,13 @@ extension AppModel { for: current, identity: identity ) - guard isCurrentWorkspace(identity) else { return } + guard isCurrentWorkspace(identity), !Task.isCancelled else { return } runFeature.startConfiguration(current, javaLaunch: javaLaunch) // log-exempt: 用户取消:调用方取消等待或替换旧任务时结束延迟,不代表操作失败 } catch is CancellationError { return } catch { - guard isCurrentWorkspace(identity) else { return } + guard isCurrentWorkspace(identity), !Task.isCancelled else { return } showNotification(error.localizedDescription, logReference: notificationFailure(error, operation: "run_configuration")) } } @@ -492,11 +493,11 @@ extension AppModel { } func runAllServiceConfigurations() { - Task { [weak self] in + runWorkflowCoordinator.startRunAction { [weak self] in guard let self else { return } guard let identity = currentWorkspaceIdentity else { return } guard let runFeature = await activateExecutionModule()?.runFeature else { return } - guard isCurrentWorkspace(identity) else { return } + guard isCurrentWorkspace(identity), !Task.isCancelled else { return } switch await ensureRunProjectReady(runFeature, for: identity) { case .ready: clearPendingRunAction(for: identity) @@ -513,7 +514,7 @@ extension AppModel { currentFileURL: nil, runFeature: runFeature ) else { return } - guard isCurrentWorkspace(identity) else { return } + guard isCurrentWorkspace(identity), !Task.isCancelled else { return } } var javaLaunches: [String: JavaDebugLaunchTarget] = [:] do { @@ -524,13 +525,13 @@ extension AppModel { ) { javaLaunches[configuration.id] = target } - guard isCurrentWorkspace(identity) else { return } + guard isCurrentWorkspace(identity), !Task.isCancelled else { return } } // log-exempt: 用户取消:调用方取消等待或替换旧任务时结束延迟,不代表操作失败 } catch is CancellationError { return } catch { - guard isCurrentWorkspace(identity) else { return } + guard isCurrentWorkspace(identity), !Task.isCancelled else { return } showNotification(error.localizedDescription, logReference: notificationFailure(error, operation: "run_configuration")) return } @@ -538,7 +539,11 @@ extension AppModel { } } + var isPreparingRun: Bool { runWorkflowCoordinator.isRunActionStarting } + var isPreparingExecution: Bool { runWorkflowCoordinator.isModuleOperationStarting } + func stopSelectedRun() { + runWorkflowCoordinator.cancelModuleOperation() if let feature = runFeatureIfActive, let configuration = feature.selectedConfiguration, configuration.kind != .currentFile, configuration.buildProvider == nil { @@ -548,7 +553,6 @@ extension AppModel { return } executionModuleCoordinator.stopFeatures( - maven: nil, run: runFeatureIfActive ) } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index 533145d0c0..4ef06dfd33 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -212,6 +212,8 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { var languageServerLogsFeature: LanguageServerLogsModel? { featureGraph.languageServerLogs } let debugLaunchConfigurationResolver: DebugLaunchConfigurationResolver let debugPortAvailabilityChecker: any DebugPortAvailabilityChecking + var providerDebugRunObservation: AnyCancellable? + var providerDebugSession: (operation: String, session: DebugSessionID)? var workspaceFeature: WorkspaceFeatureModel { featureGraph.workspace } var githubFeature: GitHubFeatureModel { featureGraph.github } var editorTabOrderFeature: EditorTabOrderFeatureModel { featureGraph.editorTabOrder } @@ -728,7 +730,7 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { func shutdownProjectSession() async { isProjectSessionClosed = true - toolchainSettingsFeature.close() + await toolchainSettingsFeature.closeAndDrain() commitFileDeletion.reset() // Native window close can retain this model through Settings without // closing its workspace. Revoke page requests before teardown can yield. @@ -787,7 +789,6 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { cancelJavaTestWorkflows() debugModuleCoordinator.stopFeature(genericDebugFeatureIfActive) executionModuleCoordinator.stopFeatures( - maven: mavenFeatureIfActive, run: runFeatureIfActive ) languageToolingSessionsIfActive?.stopLanguageServer(providerID: "java") @@ -979,7 +980,6 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { languageIntelligenceModuleCoordinator.resetWorkspaceState(languageToolingFeature) runtimeFeature.openProject(at: normalizedURL) executionModuleCoordinator.resetFeatures( - maven: mavenFeatureIfActive, run: runFeatureIfActive, tests: languageTestServiceIfActive, buildSystems: buildSystemsFeatureIfActive @@ -1054,7 +1054,6 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { runtimeFeature.closeProject() toolchainSettingsFeature.close() executionModuleCoordinator.resetFeatures( - maven: mavenFeatureIfActive, run: runFeatureIfActive, tests: languageTestServiceIfActive, buildSystems: buildSystemsFeatureIfActive diff --git a/macos/Sources/Lithe/Models/Editor/EditorDocument.swift b/macos/Sources/Lithe/Models/Editor/EditorDocument.swift index 8b22a6414e..5039046869 100644 --- a/macos/Sources/Lithe/Models/Editor/EditorDocument.swift +++ b/macos/Sources/Lithe/Models/Editor/EditorDocument.swift @@ -114,19 +114,10 @@ final class EditorDocument: ObservableObject, Identifiable, @unchecked Sendable @Published var hasRunnableJavaMain = false @Published var hasJavaTests = false /// Prefer imported source-root roles over test-name or annotation guesses. - static func isTestSourceFile(_ url: URL, project: MavenProject?, hasTests: Bool) -> Bool { + static func isTestSourceFile(_ url: URL, importedSourceRootKind: String?, hasTests: Bool) -> Bool { let semanticFallback = hasTests && url.pathExtension.lowercased() == "java" - guard let project else { return semanticFallback } - let roots = [(project.rootURL, project.sourceRoots)] + project.allModules.map { ($0.url, $0.sourceRoots) } - let matches = roots.flatMap { base, roots in - roots.compactMap { root -> (String, MavenSourceRootKind)? in - let path = (root.path.hasPrefix("/") ? URL(fileURLWithPath: root.path) - : base.appendingPathComponent(root.path, isDirectory: true)).standardizedFileURL.path - return url.standardizedFileURL.path.hasPrefix(path + "/") ? (path, root.kind) : nil - } - } - guard let kind = matches.max(by: { $0.0.count < $1.0.count })?.1 else { return semanticFallback } - return [.testJava, .testResources, .generatedTest].contains(kind) + guard let importedSourceRootKind else { return semanticFallback } + return ["testSource", "testResource", "generatedTestSource"].contains(importedSourceRootKind) } private(set) var externalDiskIdentity: String? diff --git a/macos/Sources/Lithe/Models/Java/ActiveTestTarget.swift b/macos/Sources/Lithe/Models/Java/ActiveTestTarget.swift index 634d7ab97d..0a681dd7d3 100644 --- a/macos/Sources/Lithe/Models/Java/ActiveTestTarget.swift +++ b/macos/Sources/Lithe/Models/Java/ActiveTestTarget.swift @@ -14,7 +14,7 @@ struct ActiveTestTarget: Equatable, Sendable { return switch scope { case .workspace: L10n.string("Workspace") case .file(let url): url.lastPathComponent - case .testCase(let identifier, _): Self.formattedName(for: identifier) + case .testCase(let identifier, _, _): Self.formattedName(for: identifier) } } diff --git a/macos/Sources/Lithe/Models/Java/MavenModels.swift b/macos/Sources/Lithe/Models/Java/MavenModels.swift index 8c84021ad6..1d3013169c 100644 --- a/macos/Sources/Lithe/Models/Java/MavenModels.swift +++ b/macos/Sources/Lithe/Models/Java/MavenModels.swift @@ -1,10 +1,6 @@ import Foundation import LitheCoreContracts -typealias MavenProject = LitheCoreContracts.MavenProject -typealias MavenModule = LitheCoreContracts.MavenModule -typealias MavenSourceRoot = LitheCoreContracts.MavenSourceRoot -typealias MavenSourceRootKind = LitheCoreContracts.MavenSourceRootKind typealias MavenProfile = LitheCoreContracts.MavenProfile typealias MavenLifecyclePhase = LitheCoreContracts.MavenLifecyclePhase typealias MavenIssueSeverity = LitheCoreContracts.MavenIssueSeverity diff --git a/macos/Sources/Lithe/Models/Runtime/ProjectRuntimeInventory.swift b/macos/Sources/Lithe/Models/Runtime/ProjectRuntimeInventory.swift index 064ccf81b6..88fc3a1a93 100644 --- a/macos/Sources/Lithe/Models/Runtime/ProjectRuntimeInventory.swift +++ b/macos/Sources/Lithe/Models/Runtime/ProjectRuntimeInventory.swift @@ -1,15 +1,16 @@ import Foundation import LitheCoreContracts +import LitheModuleAPI enum ProjectRuntimeSubprojectKind: String, Hashable, Sendable { case projectDefaults - case mavenReactor - case mavenModule + case buildRoot + case buildProject case other } -/// One row in Settings → Project, including workspace defaults and nested -/// Java, Maven, or frontend roots discovered in the same workspace. +/// A directory's local tool choices. Build providers own project hierarchy; +/// language declarations can identify directories before a model is loaded. struct ProjectRuntimeSubproject: Identifiable, Hashable, Sendable { let id: String let kind: ProjectRuntimeSubprojectKind @@ -24,99 +25,71 @@ struct ProjectRuntimeSubproject: Identifiable, Hashable, Sendable { } enum ProjectRuntimeInventory { + struct BuildSource { + let id: String + let root: String + let displayName: String + let usesJava: Bool + let model: BuildModel? + } static let projectDefaultsID = "project" static func subprojects( workspaceName: String, workspaceURL: URL, files: [URL], - mavenProject: MavenProject? + buildSources: [BuildSource], + languageSupports: [LanguageSupportDeclaration] ) -> [ProjectRuntimeSubproject] { let root = workspaceURL.standardizedFileURL - var result: [ProjectRuntimeSubproject] = [ - ProjectRuntimeSubproject( - id: projectDefaultsID, - kind: .projectDefaults, - title: workspaceName, - relativePath: "", - usesJava: true, - parentID: nil - ) - ] - - var knownJavaDirectories = Set() - if let mavenProject { - let reactorPath = relativePath(for: mavenProject.rootURL, root: root) ?? "" - let reactorID = mavenID(reactorPath) - result.append( - ProjectRuntimeSubproject( - id: reactorID, - kind: .mavenReactor, - title: mavenProject.displayName, - relativePath: normalizedDisplayPath(reactorPath), - usesJava: true, - parentID: projectDefaultsID - ) - ) - knownJavaDirectories.insert(normalizedDirectory(reactorPath)) - appendModules( - mavenProject.modules, - parentID: reactorID, - workspaceRoot: root, - into: &result, - knownJavaDirectories: &knownJavaDirectories - ) + var result = [ProjectRuntimeSubproject(id: projectDefaultsID, kind: .projectDefaults, + title: workspaceName, relativePath: "", usesJava: true, parentID: nil)] + let javaMarkers = Set(languageSupports.filter { $0.id == "java" }.flatMap(\.projectFileNames)) + var javaDirectories = Set(files.filter { javaMarkers.contains($0.lastPathComponent.lowercased()) } + .compactMap { relativePath(for: $0.deletingLastPathComponent(), root: root) }) + for source in buildSources where source.usesJava { + javaDirectories.insert(source.root) + javaDirectories.formUnion(source.model?.projects.map(\.path) ?? []) } - - for pomURL in pomFiles(in: files) { - let directory = pomURL.deletingLastPathComponent().standardizedFileURL - let relative = relativePath(for: directory, root: root) ?? "" - let directoryKey = normalizedDirectory(relative) - if knownJavaDirectories.contains(directoryKey) { continue } - if knownJavaDirectories.contains(where: { directoryKey.hasPrefix($0 + "/") }) { - continue + var known = Set(["."]) + var modeledRoots = Set() + for source in buildSources.sorted(by: { ($0.root, $0.id) < ($1.root, $1.id) }) { + guard validPath(source.root) else { continue } + let rootID = directoryID(source.root) + if known.insert(source.root).inserted { + let project = source.model?.projects.first { $0.path == source.root } + result.append(ProjectRuntimeSubproject(id: rootID, kind: .buildRoot, + title: project?.displayName ?? project?.name ?? source.displayName, + relativePath: source.root, usesJava: javaDirectories.contains(source.root), parentID: projectDefaultsID)) } - result.append( - ProjectRuntimeSubproject( - id: mavenID(relative), - kind: .mavenReactor, - title: directory.lastPathComponent, - relativePath: normalizedDisplayPath(relative), - usesJava: true, - parentID: projectDefaultsID - ) - ) - knownJavaDirectories.insert(directoryKey) - } - - for packageURL in packageManifests(in: files) { - let directory = packageURL.deletingLastPathComponent().standardizedFileURL - let relative = relativePath(for: directory, root: root) ?? "" - let directoryKey = normalizedDirectory(relative) - if directoryKey.split(separator: "/").contains("node_modules") { continue } - if knownJavaDirectories.contains(directoryKey) { continue } - if knownJavaDirectories.contains(where: { directoryKey.hasPrefix($0 + "/") }) { - continue + guard let model = source.model else { continue } + modeledRoots.insert(source.root) + let projects = Dictionary(model.projects.map { ($0.id, $0.path) }, uniquingKeysWith: { first, _ in first }) + for project in model.projects.sorted(by: { ($0.path, $0.id) < ($1.path, $1.id) }) { + guard validPath(project.path), known.insert(project.path).inserted else { continue } + let parent = project.parentID.flatMap { projects[$0] }.map(directoryID) ?? rootID + result.append(ProjectRuntimeSubproject(id: directoryID(project.path), kind: .buildProject, + title: project.displayName ?? project.name, relativePath: project.path, + usesJava: javaDirectories.contains(project.path), parentID: parent)) } - result.append( - ProjectRuntimeSubproject( - id: otherID(relative), - kind: .other, - title: directory.lastPathComponent, - relativePath: normalizedDisplayPath(relative), - usesJava: false, - parentID: projectDefaultsID - ) - ) } - + // Marker matching comes from installed declarations. It supplies only + // directory choices, never guesses modules or overrides an engine model. + for file in files.sorted(by: { $0.path < $1.path }) { + let languages = languageSupports.filter { $0.projectFileNames.contains(file.lastPathComponent.lowercased()) } + guard !languages.isEmpty, + let relative = relativePath(for: file.deletingLastPathComponent(), root: root), + validPath(relative), !known.contains(relative), + !modeledRoots.contains(where: { $0 == "." || relative.hasPrefix($0 + "/") }) else { continue } + known.insert(relative) + result.append(ProjectRuntimeSubproject(id: directoryID(relative), kind: .other, + title: file.deletingLastPathComponent().lastPathComponent, relativePath: relative, + usesJava: languages.contains { $0.id == "java" }, parentID: projectDefaultsID)) + } return result } - static func workspaceRelativePath( - modulePath: String?, - workingDirectory: String? - ) -> String { + static func workspaceRelativePath(modulePath: String?, workingDirectory: String?) -> String { let directory = ProjectRuntimeSettings.normalizedRelativePath(workingDirectory) let module = ProjectRuntimeSettings.normalizedRelativePath(modulePath) if directory.isEmpty { return module } @@ -125,76 +98,19 @@ enum ProjectRuntimeInventory { return directory + "/" + module } - private static func appendModules( - _ modules: [MavenModule], - parentID: String, - workspaceRoot: URL, - into result: inout [ProjectRuntimeSubproject], - knownJavaDirectories: inout Set - ) { - for module in modules { - let relative = relativePath(for: module.url, root: workspaceRoot) ?? module.relativePath - let id = moduleID(relative) - result.append( - ProjectRuntimeSubproject( - id: id, - kind: .mavenModule, - title: module.displayName, - relativePath: normalizedDisplayPath(relative), - usesJava: true, - parentID: parentID - ) - ) - knownJavaDirectories.insert(normalizedDirectory(relative)) - appendModules( - module.modules, - parentID: id, - workspaceRoot: workspaceRoot, - into: &result, - knownJavaDirectories: &knownJavaDirectories - ) - } - } - - private static func pomFiles(in files: [URL]) -> [URL] { - files - .filter { $0.lastPathComponent.lowercased() == "pom.xml" } - .sorted { $0.path.localizedStandardCompare($1.path) == .orderedAscending } - } - - private static func packageManifests(in files: [URL]) -> [URL] { - files - .filter { $0.lastPathComponent.lowercased() == "package.json" } - .sorted { $0.path.localizedStandardCompare($1.path) == .orderedAscending } - } - private static func relativePath(for url: URL, root: URL) -> String? { let path = url.standardizedFileURL.path let rootPath = root.standardizedFileURL.path - if path == rootPath { return "" } + if path == rootPath { return "." } let prefix = rootPath.hasSuffix("/") ? rootPath : rootPath + "/" - guard path.hasPrefix(prefix) else { return nil } - return String(path.dropFirst(prefix.count)) - } - - private static func normalizedDirectory(_ path: String) -> String { - ProjectRuntimeSettings.normalizedRelativePath(path) + return path.hasPrefix(prefix) ? String(path.dropFirst(prefix.count)) : nil } - private static func normalizedDisplayPath(_ path: String) -> String { - let normalized = ProjectRuntimeSettings.normalizedRelativePath(path) - return normalized.isEmpty ? "." : normalized + private static func validPath(_ path: String) -> Bool { + path == "." || !path.isEmpty && !path.contains("\\") && !path.contains(":") + && !path.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) + && path.split(separator: "/", omittingEmptySubsequences: false).allSatisfy { !$0.isEmpty && $0 != "." && $0 != ".." } } - private static func mavenID(_ path: String) -> String { - "maven:" + normalizedDisplayPath(path) - } - - private static func moduleID(_ path: String) -> String { - "module:" + normalizedDisplayPath(path) - } - - private static func otherID(_ path: String) -> String { - "other:" + normalizedDisplayPath(path) - } + private static func directoryID(_ path: String) -> String { path == "." ? projectDefaultsID : "path:" + path } } diff --git a/macos/Sources/Lithe/Models/Runtime/ProjectRuntimeModels.swift b/macos/Sources/Lithe/Models/Runtime/ProjectRuntimeModels.swift index d6e6a52f92..d191079886 100644 --- a/macos/Sources/Lithe/Models/Runtime/ProjectRuntimeModels.swift +++ b/macos/Sources/Lithe/Models/Runtime/ProjectRuntimeModels.swift @@ -47,14 +47,12 @@ struct ProjectRuntimeSettings: Codable, Hashable, Sendable { var mavenHomePath = "" /// Empty means use the project JDK, then the detected system JDK. var mavenJavaHomePath = "" - var mavenSettingsPath = "" - var mavenLocalRepositoryPath = "" /// Per-subproject JDK and Maven overrides for monorepos. var moduleOverrides: [ProjectModuleRuntimeOverride] = [] enum CodingKeys: String, CodingKey { case javaHomePath, mavenHomeSelection, mavenHomePath, mavenJavaHomePath - case mavenSettingsPath, mavenLocalRepositoryPath, moduleOverrides + case moduleOverrides } init( @@ -62,16 +60,12 @@ struct ProjectRuntimeSettings: Codable, Hashable, Sendable { mavenHomeSelection: MavenHomeSelection = .automatic, mavenHomePath: String = "", mavenJavaHomePath: String = "", - mavenSettingsPath: String = "", - mavenLocalRepositoryPath: String = "", moduleOverrides: [ProjectModuleRuntimeOverride] = [] ) { self.javaHomePath = javaHomePath self.mavenHomeSelection = mavenHomeSelection self.mavenHomePath = mavenHomePath self.mavenJavaHomePath = mavenJavaHomePath - self.mavenSettingsPath = mavenSettingsPath - self.mavenLocalRepositoryPath = mavenLocalRepositoryPath self.moduleOverrides = moduleOverrides } @@ -84,11 +78,6 @@ struct ProjectRuntimeSettings: Codable, Hashable, Sendable { ) ?? .automatic mavenHomePath = try container.decodeIfPresent(String.self, forKey: .mavenHomePath) ?? "" mavenJavaHomePath = try container.decodeIfPresent(String.self, forKey: .mavenJavaHomePath) ?? "" - mavenSettingsPath = try container.decodeIfPresent(String.self, forKey: .mavenSettingsPath) ?? "" - mavenLocalRepositoryPath = try container.decodeIfPresent( - String.self, - forKey: .mavenLocalRepositoryPath - ) ?? "" moduleOverrides = try container.decodeIfPresent( [ProjectModuleRuntimeOverride].self, forKey: .moduleOverrides @@ -181,10 +170,36 @@ struct ProjectRuntimeSettings: Codable, Hashable, Sendable { ProjectToolchainSelection( javaHomePath: javaHomePath.trimmingCharacters(in: .whitespacesAndNewlines), mavenExecutablePath: mavenExecutableOverride, - mavenJavaHomePath: mavenJavaHomePath.trimmingCharacters(in: .whitespacesAndNewlines) + mavenJavaHomePath: mavenJavaHomePath.trimmingCharacters(in: .whitespacesAndNewlines), + pathOverrides: Dictionary(moduleOverrides.filter(\.hasOverride).map { value in + (value.path, ProjectToolchainSelection.PathOverride( + javaHomePath: value.javaHomePath.trimmingCharacters(in: .whitespacesAndNewlines), + mavenExecutablePath: value.mavenExecutablePath.trimmingCharacters(in: .whitespacesAndNewlines), + mavenJavaHomePath: value.mavenJavaHomePath.trimmingCharacters(in: .whitespacesAndNewlines))) + }, uniquingKeysWith: { _, last in last }) ) } + mutating func applyProjectToolchain(_ selection: ProjectToolchainSelection) { + javaHomePath = selection.javaHomePath + mavenJavaHomePath = selection.mavenJavaHomePath + moduleOverrides = selection.pathOverrides.sorted { $0.key < $1.key }.map { path, value in + ProjectModuleRuntimeOverride(path: path, javaHomePath: value.javaHomePath, + mavenExecutablePath: value.mavenExecutablePath, mavenJavaHomePath: value.mavenJavaHomePath) + } + let maven = selection.mavenExecutablePath + if maven.isEmpty { + mavenHomeSelection = .automatic + mavenHomePath = "" + } else if maven == "mvnw" || maven == "./mvnw" || maven.hasSuffix("/mvnw") { + mavenHomeSelection = .wrapper + mavenHomePath = "" + } else { + mavenHomeSelection = .custom + mavenHomePath = maven + } + } + static func normalizedRelativePath(_ path: String?) -> String { guard let path else { return "" } let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/macos/Sources/Lithe/Platform/MacOS/Build/MacBuildNativeConfigurationStore.swift b/macos/Sources/Lithe/Platform/MacOS/Build/MacBuildNativeConfigurationStore.swift new file mode 100644 index 0000000000..067eaa4eed --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/Build/MacBuildNativeConfigurationStore.swift @@ -0,0 +1,110 @@ +import CryptoKit +import Foundation +import LitheCoreContracts +import LitheModuleAPI + +/// Generic native-file boundary. The provider owns source discovery and field +/// edits; this adapter owns private staging, filesystem +/// validation and conditional publication. No Maven precedence lives here. +struct MacBuildNativeConfigurationStore: BuildNativeConfigurationStoring { + let storage: any FileStorage + let planner: any BuildSystemPlanning + let immutableRoots: [URL] + private let maximumBytes = 2 * 1024 * 1024 + + func prepare(target: BuildSystemTarget, snapshot: BuildNativeConfiguration, + edits: BuildNativeConfigurationEdits, stateDirectory: URL) throws -> BuildNativeConfigurationStaging? { + guard let source = snapshot.sources.first(where: { $0.id == edits.sourceID }), + source.writable, source.scope != .installation, + let uri = URL(string: source.uri), uri.isFileURL, + uri.query == nil, uri.fragment == nil, uri.user == nil, uri.password == nil else { + throw NativeConfigurationFileError.readOnly + } + let url = try validatedTarget(uri) + guard !snapshot.sources.contains(where: { other in + guard other.id != source.id, let alias = URL(string: other.uri) else { return false } + return alias.standardizedFileURL.resolvingSymlinksInPath() == url + }) else { throw NativeConfigurationFileError.readOnly } + let existing: Data? + if storage.fileExists(at: url) { + guard let metadata = storage.metadata(for: url), metadata.isRegularFile, + metadata.isWritable, let count = metadata.byteCount, count <= maximumBytes else { + throw NativeConfigurationFileError.unreadable + } + let data = try storage.readData(from: url, options: []) + guard data.count <= maximumBytes else { throw NativeConfigurationFileError.unreadable } + existing = data + } else { existing = nil } + let revision = existing.map { "sha256:" + SHA256.hash(data: $0).map { String(format: "%02x", $0) }.joined() } ?? "missing" + guard revision == source.revision, revision == edits.revision else { throw NativeConfigurationFileError.conflict } + _ = try planner.decodeConfiguration(target, result: JSONDecoder().decode(ToolingJSONValue.self, from: JSONEncoder().encode(snapshot))) + guard edits.values.contains(where: { id, value in source.fields.first(where: { $0.id == id })?.value != value }) else { return nil } + let outputs = try validatedTarget(stateDirectory.appendingPathComponent("outputs", isDirectory: true)) + try storage.createDirectory(at: outputs, withIntermediateDirectories: true) + let output = outputs.appendingPathComponent(UUID().uuidString + ".json") + // Existing output-file lifecycle, private mode 0600. Providers must open + // without CREATE: cancellation can unlink it before a late write starts. + guard try storage.replaceDataPreservingMetadata(Data(), at: output, expecting: nil) else { + throw NativeConfigurationFileError.conflict + } + return BuildNativeConfigurationStaging(sourceID: source.id, sourceURL: uri, url: url, expected: existing, outputFile: output) + } + + func complete(_ staging: BuildNativeConfigurationStaging, receipt: BuildNativeConfigurationEditReceipt) throws -> BuildNativeConfigurationPreparedWrite? { + guard receipt.sourceID == staging.sourceID else { throw NativeConfigurationFileError.conflict } + guard receipt.changed else { return nil } + guard staging.outputFile.standardizedFileURL.resolvingSymlinksInPath() == staging.outputFile, + let metadata = storage.metadata(for: staging.outputFile), metadata.isRegularFile, + let count = metadata.byteCount, count <= maximumBytes else { throw NativeConfigurationFileError.unreadable } + let data = try storage.readData(from: staging.outputFile, options: []) + guard data.count <= maximumBytes, + receipt.revision == "sha256:" + SHA256.hash(data: data).map({ String(format: "%02x", $0) }).joined() else { + throw NativeConfigurationFileError.conflict + } + if data == staging.expected { return nil } + return BuildNativeConfigurationPreparedWrite(url: staging.url, sourceURL: staging.sourceURL, expected: staging.expected, + replacement: data, revision: receipt.revision) + } + + func discard(_ staging: BuildNativeConfigurationStaging) throws { + if storage.fileExists(at: staging.outputFile) { + guard storage.metadata(for: staging.outputFile)?.isDirectory != true else { throw NativeConfigurationFileError.unreadable } + try storage.removeItem(at: staging.outputFile) + } + } + + func commit(_ write: BuildNativeConfigurationPreparedWrite) throws { + let url = try validatedTarget(write.sourceURL) + guard url == write.url else { throw NativeConfigurationFileError.conflict } + if let metadata = storage.metadata(for: url), !metadata.isRegularFile || !metadata.isWritable { + throw NativeConfigurationFileError.readOnly + } + try storage.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + guard try storage.replaceDataPreservingMetadata(write.replacement, at: url, expecting: write.expected) else { + throw NativeConfigurationFileError.conflict + } + } + + private func validatedTarget(_ source: URL) throws -> URL { + let target = source.standardizedFileURL.resolvingSymlinksInPath() + guard target.isFileURL, !target.path.contains("\0"), !target.lastPathComponent.isEmpty else { + throw NativeConfigurationFileError.readOnly + } + for root in immutableRoots { + let path = root.standardizedFileURL.resolvingSymlinksInPath().path + if target.path == path || target.path.hasPrefix(path + "/") { throw NativeConfigurationFileError.readOnly } + } + return target + } +} + +private enum NativeConfigurationFileError: LocalizedError { + case conflict, readOnly, unreadable + var errorDescription: String? { + switch self { + case .conflict: L10n.string("Native configuration changed on disk. Reload before saving; your draft has been kept.") + case .readOnly: L10n.string("This native configuration source is read-only. Installed applications and plugin packages cannot be edited.") + case .unreadable: L10n.string("The native configuration must be a writable file smaller than 2 MB.") + } + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/Build/MacBuildOptionsStore.swift b/macos/Sources/Lithe/Platform/MacOS/Build/MacBuildOptionsStore.swift new file mode 100644 index 0000000000..cece3a2609 --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/Build/MacBuildOptionsStore.swift @@ -0,0 +1,208 @@ +import Foundation +import LitheCoreContracts +import LitheModuleAPI + +/// Stored option values. They follow the build-system ID, as `build.detect` +/// takes them, so a plugin update keeps the user's choices. +@MainActor +final class MacBuildOptionsStore: BuildOptionsStoring { + private static let keyPrefix = "BuildSystems.Options." + private let store: any KeyValueStore + private let storage: (any FileStorage)? + private let immutableRoots: [URL] + + init(store: any KeyValueStore, storage: (any FileStorage)? = nil, immutableRoots: [URL] = [Bundle.main.bundleURL]) { + self.store = store + self.storage = storage + self.immutableRoots = immutableRoots + } + + func migrateLegacyConfiguration(workspaceURL: URL, instances: [BuildSystemInstance]) throws -> Bool { + let roots = instances.filter { + $0.pluginID == "io.github.lithe-idea.maven-build" && $0.buildSystemID == "maven" + }.map(\.root) + guard !roots.isEmpty else { return false } + let files = storage ?? MacFileStorage() + return try MacLegacyMavenConfigurationMigration(storage: files, preferences: store, + runStore: MacRunConfigurationStore(core: RustCoreBridge(), storage: files, preferences: store), + options: self, immutableRoots: immutableRoots).migrate(workspace: workspaceURL, reactorRoots: roots) + } + + func options(workspaceURL: URL, buildSystemID: String) throws -> [String: ToolingJSONValue] { + let shared = try projectDocument(workspaceURL).document.systems[buildSystemID]?.options ?? [:] + return shared.merging(try read([String: ToolingJSONValue].self, key: Self.key(workspaceURL: workspaceURL, buildSystemID: buildSystemID)) ?? [:]) { _, local in local } + } + + func setOptions(_ options: [String: ToolingJSONValue], workspaceURL: URL, buildSystemID: String) throws { + let key = Self.key(workspaceURL: workspaceURL, buildSystemID: buildSystemID) + let shared = try projectDocument(workspaceURL).document.systems[buildSystemID]?.options ?? [:] + let local = options.filter { shared[$0.key] != $0.value } + try write(local.isEmpty ? nil : local, key: key) + } + + func instanceOptions(workspaceURL: URL, buildSystemID: String) throws -> [String: [String: ToolingJSONValue]] { + let key = Self.key(workspaceURL: workspaceURL, buildSystemID: buildSystemID) + ".Instances" + var shared = try projectDocument(workspaceURL).document.systems[buildSystemID]?.instances ?? [:] + for (root, values) in try read([String: [String: ToolingJSONValue]].self, key: key) ?? [:] { + shared[root, default: [:]].merge(values) { _, local in local } + } + return shared + } + + func setInstanceOptions(_ options: [String: [String: ToolingJSONValue]], workspaceURL: URL, buildSystemID: String) throws { + let key = Self.key(workspaceURL: workspaceURL, buildSystemID: buildSystemID) + ".Instances" + let shared = try projectDocument(workspaceURL).document.systems[buildSystemID] ?? BuildProjectOptionsDocument.System() + let defaults = try instanceDefaults(workspaceURL: workspaceURL, buildSystemID: buildSystemID) + let nonempty = options.mapValues { $0 }.reduce(into: [String: [String: ToolingJSONValue]]()) { result, item in + let inherited = defaults.merging(shared.instances[item.key] ?? [:]) { _, root in root } + let values = item.value.filter { inherited[$0.key] != $0.value } + if !values.isEmpty { result[item.key] = values } + } + try write(nonempty.isEmpty ? nil : nonempty, key: key) + } + + func instanceDefaults(workspaceURL: URL, buildSystemID: String) throws -> [String: ToolingJSONValue] { + let shared = try projectDocument(workspaceURL).document.systems[buildSystemID]?.instanceDefaults ?? [:] + let key = Self.key(workspaceURL: workspaceURL, buildSystemID: buildSystemID) + ".InstanceDefaults" + return shared.merging(try read([String: ToolingJSONValue].self, key: key) ?? [:]) { _, local in local } + } + + /// Machine-wide choices for this workspace remain local, including defaults + /// transferred from old runtime preferences before any reactor was selected. + func setInstanceDefaults(_ values: [String: ToolingJSONValue], workspaceURL: URL, buildSystemID: String) throws { + let shared = try projectDocument(workspaceURL).document.systems[buildSystemID]?.instanceDefaults ?? [:] + let local = values.filter { shared[$0.key] != $0.value } + try write(local.isEmpty ? nil : local, + key: Self.key(workspaceURL: workspaceURL, buildSystemID: buildSystemID) + ".InstanceDefaults") + } + + func savedChoices(workspaceURL: URL, buildSystemID: String) throws -> [String: [String]] { + try projectDocument(workspaceURL).document.systems[buildSystemID]?.savedChoices ?? [:] + } + + func projectDocument(_ workspace: URL) throws -> (document: BuildProjectOptionsDocument, bytes: Data?) { + guard let storage else { return (BuildProjectOptionsDocument(), nil) } + let url = try projectURL(workspace) + guard storage.fileExists(at: url) else { return (BuildProjectOptionsDocument(), nil) } + guard let metadata = storage.metadata(for: url), metadata.isRegularFile, + let size = metadata.byteCount, size <= 2 * 1024 * 1024 else { throw BuildOptionsStorageError.invalid } + let data = try storage.readData(from: url, options: []) + guard data.count <= 2 * 1024 * 1024 else { throw BuildOptionsStorageError.invalid } + let json = try JSONSerialization.jsonObject(with: data) + try BuildProjectOptionsDocument.validate(json) + return (try JSONDecoder().decode(BuildProjectOptionsDocument.self, from: data), data) + } + + func saveProjectDocument(_ document: BuildProjectOptionsDocument, workspace: URL, expecting expected: Data?) throws { + guard let storage else { throw BuildOptionsStorageError.write } + let url = try projectURL(workspace) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .prettyPrinted, .withoutEscapingSlashes] + let data = try encoder.encode(document) + try BuildProjectOptionsDocument.validate(JSONSerialization.jsonObject(with: data)) + guard data.count <= 2 * 1024 * 1024 else { throw BuildOptionsStorageError.invalid } + try storage.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + guard try storage.replaceDataPreservingMetadata(data, at: url, expecting: expected) else { throw BuildOptionsStorageError.conflict } + guard try projectDocument(workspace).document == document else { throw BuildOptionsStorageError.write } + } + + private func projectURL(_ workspace: URL) throws -> URL { + let root = workspace.resolvingSymlinksInPath().standardizedFileURL + let url = workspace.appendingPathComponent(".lithe/build/options.json") + let resolved = url.resolvingSymlinksInPath().standardizedFileURL + guard resolved.path.hasPrefix(root.path + "/"), !immutableRoots.contains(where: { + let path = $0.resolvingSymlinksInPath().standardizedFileURL.path + return resolved.path == path || resolved.path.hasPrefix(path + "/") + }) else { throw BuildOptionsStorageError.invalid } + return url + } + + func emptyMultiSelectOptions(workspaceURL: URL, buildSystemID: String) throws -> [String: Set] { + let key = Self.key(workspaceURL: workspaceURL, buildSystemID: buildSystemID) + ".EmptyMultiSelect" + let options = try read([String: [String]].self, key: key) ?? [:] + return options.mapValues { Set($0) } + } + + func setEmptyMultiSelectOptions(_ options: [String: Set], workspaceURL: URL, buildSystemID: String) throws { + let key = Self.key(workspaceURL: workspaceURL, buildSystemID: buildSystemID) + ".EmptyMultiSelect" + let nonempty = options.filter { !$0.value.isEmpty } + try write(nonempty.isEmpty ? nil : nonempty.mapValues { $0.sorted() }, key: key) + } + + private func read(_ type: Value.Type, key: String) throws -> Value? { + guard let data = store.data(forKey: key) else { + guard store.object(forKey: key) == nil else { throw BuildOptionsStorageError.invalid } + return nil + } + guard data.count <= 2 * 1024 * 1024 else { throw BuildOptionsStorageError.invalid } + do { return try JSONDecoder().decode(type, from: data) } + catch { throw BuildOptionsStorageError.invalid } + } + + private func write(_ value: Value?, key: String) throws { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let data = try value.map(encoder.encode) + if let data, data.count > 2 * 1024 * 1024 { throw BuildOptionsStorageError.invalid } + store.set(data, forKey: key) + guard store.data(forKey: key) == data else { throw BuildOptionsStorageError.write } + } + + private static func key(workspaceURL: URL, buildSystemID: String) -> String { + keyPrefix + buildSystemID + "." + MacPluginStateDirectories.workspaceDigest(workspaceURL) + } +} + +/// Team-owned Build preferences, independent of any tool's native configuration. +/// Local edits are sparse overrides in the existing platform preferences. +struct BuildProjectOptionsDocument: Codable, Equatable { + struct System: Codable, Equatable { + var options: [String: ToolingJSONValue] = [:] + var instanceDefaults: [String: ToolingJSONValue] = [:] + var instances: [String: [String: ToolingJSONValue]] = [:] + /// User-defined choices may remain useful while they are not selected. + var savedChoices: [String: [String]] = [:] + init() {} + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + options = try values.decodeIfPresent([String: ToolingJSONValue].self, forKey: .options) ?? [:] + instanceDefaults = try values.decodeIfPresent([String: ToolingJSONValue].self, forKey: .instanceDefaults) ?? [:] + instances = try values.decodeIfPresent([String: [String: ToolingJSONValue]].self, forKey: .instances) ?? [:] + savedChoices = try values.decodeIfPresent([String: [String]].self, forKey: .savedChoices) ?? [:] + } + private enum CodingKeys: String, CodingKey { case options, instanceDefaults, instances, savedChoices } + } + var version = 1 + var systems: [String: System] = [:] + + static func validate(_ value: Any) throws { + struct Payload: Encodable { let document: ToolingJSONValue } + struct Reply: Decodable { let status: String } + do { + let data = try JSONSerialization.data(withJSONObject: value, options: .fragmentsAllowed) + let document = try JSONDecoder().decode(ToolingJSONValue.self, from: data) + let result: Reply = try RustCoreBridge().executeResult( + command: "build.projectOptions", payload: Payload(document: document)).get() + guard result.status == "ok" else { throw BuildOptionsStorageError.invalid } + } catch { + throw BuildOptionsStorageError.invalid + } + } + + static func validRoot(_ root: String) -> Bool { + root == "." || !root.isEmpty && root.count <= 1024 && !root.contains("\\") && !root.contains(":") + && !root.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) + && root.split(separator: "/", omittingEmptySubsequences: false).allSatisfy { !$0.isEmpty && $0 != "." && $0 != ".." } + } +} + +enum BuildOptionsStorageError: LocalizedError { + case invalid, write, conflict + var errorDescription: String? { + switch self { + case .invalid: L10n.string("The saved build options are invalid. The original configuration has been kept.") + case .write: L10n.string("The build options could not be saved or verified. Reload before retrying.") + case .conflict: L10n.string("Build options changed on disk. Reload before saving; your changes have been kept.") + } + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/Build/MacBuildProviderSessions.swift b/macos/Sources/Lithe/Platform/MacOS/Build/MacBuildProviderSessions.swift index 1c2187f672..a2578cabe6 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Build/MacBuildProviderSessions.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Build/MacBuildProviderSessions.swift @@ -118,6 +118,10 @@ final class MacBuildProviderSessions: BuildProviderRequesting { } } + func processID(for target: BuildProviderTarget) -> Int32? { + running[target]?.session?.processIdentifier + } + func execute( _ request: BuildProviderRequest, on target: BuildProviderTarget, timeout: Duration, progress: @escaping @MainActor @Sendable (BuildProviderProgress) -> Void diff --git a/macos/Sources/Lithe/Platform/MacOS/Build/MacBuildToolResolver.swift b/macos/Sources/Lithe/Platform/MacOS/Build/MacBuildToolResolver.swift index c93abd3c41..30cb40bb8a 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Build/MacBuildToolResolver.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Build/MacBuildToolResolver.swift @@ -19,6 +19,8 @@ final class MacBuildToolResolver: BuildToolResolving { private let runtime: ProjectRuntimeService private let processRunner: any ProcessRunner private let fileStorage: any FileStorage + private let toolchains: RunToolchainRegistry + private let definedTools: (any DefinedToolchainResolving)? private let registry: RemoteWorkspaceRegistry? private let remoteFiles: (any RemoteBuildFileProbing)? private let remoteProcesses: (any RemoteProcessRunning)? @@ -27,8 +29,8 @@ final class MacBuildToolResolver: BuildToolResolving { private var probeOutputs: [String: String] = [:] init(runtime: ProjectRuntimeService, processRunner: any ProcessRunner, fileStorage: any FileStorage, - registry: RemoteWorkspaceRegistry? = nil, remoteFiles: (any RemoteBuildFileProbing)? = nil, - remoteProcesses: (any RemoteProcessRunning)? = nil, logSink: (any LogSink)? = nil) { + toolchains: RunToolchainRegistry? = nil, registry: RemoteWorkspaceRegistry? = nil, remoteFiles: (any RemoteBuildFileProbing)? = nil, + remoteProcesses: (any RemoteProcessRunning)? = nil, definedTools: (any DefinedToolchainResolving)? = nil, logSink: (any LogSink)? = nil) { self.logSink = logSink self.runtime = runtime self.processRunner = processRunner @@ -36,6 +38,8 @@ final class MacBuildToolResolver: BuildToolResolving { self.registry = registry self.remoteFiles = remoteFiles self.remoteProcesses = remoteProcesses + self.toolchains = toolchains ?? .standard() + self.definedTools = definedTools } func resolve( @@ -44,6 +48,12 @@ final class MacBuildToolResolver: BuildToolResolving { workspaceURL: URL, checkVersion: @escaping @Sendable (String?, String) throws -> BuildVersionCheck ) async throws -> ResolvedBuildTool { + let referenced = Set((invocation.executables + invocation.runtimeDependencies.flatMap(\.executables)).compactMap { candidate -> String? in + if case .toolchain(let id, _) = candidate { return id }; return nil + }) + guard Set(invocation.toolSelections.keys).isSubset(of: referenced) else { + throw BuildToolResolutionError(L10n.string("The selected toolchain is unavailable."), terminal: true) + } if registry?.contains(workspaceURL) == true { await runtime.ensureRuntimesDiscovered() try Task.checkCancellation() @@ -63,19 +73,22 @@ final class MacBuildToolResolver: BuildToolResolving { var problems: [String] = [] let resolver = try await preparedResolver(runtimeExecutables, candidates: invocation.executables, - workspaceURL: workspaceURL, environment: environment) + workspaceURL: workspaceURL, environment: environment, minimumVersion: invocation.minimumVersion, + selections: invocation.toolSelections, consumerID: invocation.toolConsumerID) for candidate in invocation.executables { switch resolver.resolve(candidate, workspaceURL: workspaceURL, environment: environment, environmentInputs: invocation.environmentInputs) { case .skip: continue case .terminalFailure(let message): throw BuildToolResolutionError(message, terminal: true) case .resolved(let choices): for choice in choices { - var selectedEnvironment = environment + var selectedEnvironment = environment.merging(choice.environment) { _, selected in selected } if choice.terminal { - selectedEnvironment["PATH"] = choice.url.deletingLastPathComponent().path + ":" + (environment["PATH"] ?? "") + let directory = choice.url.deletingLastPathComponent().path + let paths = (selectedEnvironment["PATH"] ?? "").split(separator: ":").map(String.init) + selectedEnvironment["PATH"] = ([directory] + paths.filter { $0 != directory }).joined(separator: ":") } if choice.executesProjectCode { - return ResolvedBuildTool(executableURL: choice.url, environment: selectedEnvironment, executesProjectCode: true, version: nil) + return resolved(choice.url, environment: selectedEnvironment, projectCode: true, version: nil) } do { if let tool = try await accept(choice.url, invocation: invocation, environment: selectedEnvironment, @@ -94,7 +107,7 @@ final class MacBuildToolResolver: BuildToolResolving { func languageImportExecutable(_ invocation: BuildInvocation, workspaceURL: URL) -> URL? { guard registry?.contains(workspaceURL) != true else { return nil } let environment = runtime.processEnvironment() - let resolver = candidateResolver([:]) + let resolver = candidateResolver([:], workspaceURL: workspaceURL, passive: true) for candidate in invocation.executables { // No runtime selection or version probes during passive import. switch resolver.resolve(candidate, workspaceURL: workspaceURL, environment: environment, environmentInputs: invocation.environmentInputs) { @@ -114,7 +127,7 @@ final class MacBuildToolResolver: BuildToolResolving { guard runtime.projectURL == workspaceURL.standardizedFileURL else { throw CancellationError() } let environment = runtime.processEnvironment() let resolver = try await preparedResolver([:], candidates: invocation.executables, - workspaceURL: workspaceURL, environment: environment) + workspaceURL: workspaceURL, environment: environment, consumerID: invocation.toolConsumerID) for candidate in invocation.executables { switch resolver.resolve(candidate, workspaceURL: workspaceURL, environment: environment, environmentInputs: invocation.environmentInputs) { @@ -128,9 +141,28 @@ final class MacBuildToolResolver: BuildToolResolving { private func preparedResolver( _ runtimes: [String: URL], candidates: [ExecutableCandidate], workspaceURL: URL, - environment: [String: String] + environment: [String: String], minimumVersion: String? = nil, + selections: [String: FixedToolchainSelection] = [:], use: DefinedToolchainUse = .executable, consumerID: String? = nil ) async throws -> ExecutableCandidateResolver { - guard let binding = registry?.resolve(workspaceURL)?.binding else { return candidateResolver(runtimes) } + var definitions: [String: ExecutableResolution] = [:] + for candidate in candidates { + if case .toolchain(let id, _) = candidate, selections[id] != nil, toolchains.contains(identifier: id) { + throw BuildToolResolutionError(L10n.string("The selected toolchain is unavailable."), terminal: true) + } + } + for candidate in candidates { + guard case .toolchain(let id, let root) = candidate, !toolchains.contains(identifier: id) else { continue } + guard registry?.contains(workspaceURL) != true, let definedTools, + let selected = try await definedTools.resolve(tool: id, directory: root, workspace: workspaceURL, + environment: environment, minimumVersion: minimumVersion, projectCodeAllowed: true, fixed: selections[id], use: use, consumerID: consumerID) else { + throw BuildToolResolutionError(L10n.string("The selected toolchain is unavailable."), terminal: true) + } + definitions[id + "\n" + root] = .resolved([.init(url: selected.executable, + executesProjectCode: selected.projectCode, terminal: true, environment: selected.environment)]) + } + guard let binding = registry?.resolve(workspaceURL)?.binding else { + return candidateResolver(runtimes, workspaceURL: workspaceURL, definitions: definitions) + } guard let remoteFiles else { throw CocoaError(.featureUnsupported) } var paths = Set() var commands: [String: [URL]] = [:] @@ -149,6 +181,7 @@ final class MacBuildToolResolver: BuildToolResolving { paths.formUnion(found) case .runtime(let id): if let url = runtimes[id] { paths.insert(url) } case .setting(_, let path): if let path, path.hasPrefix("/") { paths.insert(URL(fileURLWithPath: path)) } + case .toolchain: break // The workspace runtime's prepared snapshot owns these paths. } } let requested = paths.sorted { $0.path < $1.path }.map { url in @@ -172,13 +205,43 @@ final class MacBuildToolResolver: BuildToolResolving { } try Task.checkCancellation() return ExecutableCandidateResolver(fileExists: { files[$0] != nil }, - isExecutable: { files[$0]?.isExecutable == true }, commands: { commands[$0] ?? [] }, runtimes: { runtimes[$0] }) + isExecutable: { files[$0]?.isExecutable == true }, commands: { commands[$0] ?? [] }, runtimes: { runtimes[$0] }, + toolchains: candidateResolver(runtimes, workspaceURL: workspaceURL).toolchains) } - private func candidateResolver(_ runtimes: [String: URL]) -> ExecutableCandidateResolver { + private func candidateResolver(_ runtimes: [String: URL], workspaceURL: URL, passive: Bool = false, + definitions: [String: ExecutableResolution] = [:]) -> ExecutableCandidateResolver { ExecutableCandidateResolver(fileExists: { [fileStorage] in fileStorage.fileExists(at: $0) }, isExecutable: { [fileStorage] in fileStorage.metadata(for: $0)?.isDirectory == false && fileStorage.isExecutable(at: $0) }, - commands: { [self] in commandCandidates($0) }, runtimes: { runtimes[$0] }) + commands: { [self] in commandCandidates($0) }, runtimes: { runtimes[$0] }, + toolchains: { [self] id, root in + if let selected = definitions[id + "\n" + root] { return selected } + guard runtime.reloadSettings() else { return .terminalFailure(runtime.settingsError ?? "") } + let options = runtime.settings.overlay(onto: RunOptions(), workspaceRelativePath: root) + let project = root == "." ? workspaceURL : workspaceURL.appendingPathComponent(root) + do { + let tool: ResolvedRunToolchain + if passive { + guard let selected = try toolchains.resolvePassive(identifier: id, projectURL: project, options: options, runtimeService: runtime) else { + return .terminalFailure(L10n.string("The selected toolchain is unavailable.")) + } + tool = selected + } else { + tool = try toolchains.resolve(identifier: id, projectURL: project, options: options, runtimeService: runtime) + } + guard fileStorage.isExecutable(at: tool.executableURL), fileStorage.metadata(for: tool.executableURL)?.isDirectory == false else { + return .terminalFailure(L10n.string("The configured build tool is not executable: \(tool.executableURL.path)")) + } + // A registry selection can still be a repository wrapper. + // Preserve its trust boundary even through a file alias. + let roots = [workspaceURL.standardizedFileURL, workspaceURL.resolvingSymlinksInPath()] + let paths = [tool.executableURL.standardizedFileURL, tool.executableURL.resolvingSymlinksInPath()] + let projectCode = paths.contains { path in roots.contains { path.path == $0.path || path.path.hasPrefix($0.path + "/") } } + return .resolved([ResolvedExecutableCandidate(url: tool.executableURL, + executesProjectCode: projectCode, terminal: true, environment: tool.environment)]) + // log-exempt: 同一次失败已经记录过:错误原样转为 terminalFailure 抛给调用方,由 BuildSystemService 的准备边界记录 run.preparation.failed + } catch { return .terminalFailure(error.localizedDescription) } + }) } /// Keeps a candidate when the build tool declares no minimum version or @@ -192,7 +255,7 @@ final class MacBuildToolResolver: BuildToolResolving { problems: inout [String] ) async throws -> ResolvedBuildTool? { guard let arguments = invocation.versionArguments, let minimum = invocation.minimumVersion else { - return ResolvedBuildTool(executableURL: url, environment: environment, executesProjectCode: false, version: nil) + return resolved(url, environment: environment, projectCode: false, version: nil) } let output = try await probe(url, arguments: arguments, environment: environment, workspaceURL: workspaceURL) let check = try checkVersion(nil, output) @@ -200,7 +263,14 @@ final class MacBuildToolResolver: BuildToolResolving { problems.append(Self.tooOldMessage(url.path, found: check.version, minimum: minimum)) return nil } - return ResolvedBuildTool(executableURL: url, environment: environment, executesProjectCode: false, version: check.version) + return resolved(url, environment: environment, projectCode: false, version: check.version) + } + + private func resolved(_ url: URL, environment: [String: String], projectCode: Bool, version: String?) -> ResolvedBuildTool { + let base = runtime.processEnvironment() + let inputs = environment.filter { key, value in key == "JAVA_HOME" || key == "PATH" || base[key] != value } + return ResolvedBuildTool(executableURL: url, environment: environment, executesProjectCode: projectCode, + version: version, providerEnvironment: inputs) } /// Resolves one runtime and puts it first on `PATH`. `java`, `node`, and @@ -214,12 +284,13 @@ final class MacBuildToolResolver: BuildToolResolving { checkVersion: @escaping @Sendable (String?, String) throws -> BuildVersionCheck ) async throws -> URL { let name = dependency.displayName ?? dependency.id - let javaHome = dependency.hostManaged && dependency.id == "java" ? runtime.javaHomeURL() : nil - let sources: [ExecutableCandidate] = dependency.hostManaged + let javaHome = dependency.hostManaged && dependency.id == "java" && dependency.executables.isEmpty ? runtime.javaHomeURL() : nil + let sources: [ExecutableCandidate] = dependency.hostManaged && dependency.executables.isEmpty ? (dependency.id == "java" ? [.runtime("java")] : [.command(dependency.id)]) : dependency.executables let resolver = try await preparedResolver(javaHome.map { ["java": $0.appendingPathComponent("bin/java")] } ?? [:], - candidates: sources, workspaceURL: workspaceURL, environment: environment) + candidates: sources, workspaceURL: workspaceURL, environment: environment, minimumVersion: dependency.minimumVersion, + selections: invocation.toolSelections, use: .installation, consumerID: invocation.toolConsumerID) var problems: [String] = [] for source in sources { switch resolver.resolve(source, workspaceURL: workspaceURL, environment: environment, environmentInputs: invocation.environmentInputs) { @@ -230,10 +301,10 @@ final class MacBuildToolResolver: BuildToolResolving { // Probes and execution need the same PATH, including helpers // next to a selected runtime. Failed candidates must not // contribute their environment to the next choice. - var selectedEnvironment = environment + var selectedEnvironment = environment.merging(choice.environment) { _, selected in selected } if let javaHome { selectedEnvironment["JAVA_HOME"] = javaHome.path } let directory = choice.url.deletingLastPathComponent().path - let path = environment["PATH"] ?? "" + let path = selectedEnvironment["PATH"] ?? "" selectedEnvironment["PATH"] = path.isEmpty ? directory : directory + ":" + path if !choice.executesProjectCode, let minimum = dependency.minimumVersion, let arguments = dependency.versionArguments { @@ -274,7 +345,8 @@ final class MacBuildToolResolver: BuildToolResolving { return try captured.value() } let modified = fileStorage.metadata(for: url)?.modificationDate?.timeIntervalSince1970 ?? 0 - let key = ([url.path, String(modified)] + arguments).joined(separator: "\u{1F}") + let inputs = environment.sorted { $0.key < $1.key }.map { $0.key + "=" + $0.value } + let key = ([url.path, String(modified)] + arguments + inputs).joined(separator: "\0") if let output = probeOutputs[key] { return output } let runner = processRunner let request = ProcessRequest( @@ -324,60 +396,6 @@ private final class RemoteBuildProbeOutput: @unchecked Sendable { } } -/// Stored option values. They follow the build-system ID, as `build.detect` -/// takes them, so a plugin update keeps the user's choices. -@MainActor -final class MacBuildOptionsStore: BuildOptionsStoring { - private static let keyPrefix = "BuildSystems.Options." - private let store: any KeyValueStore - - init(store: any KeyValueStore) { - self.store = store - } - - func options(workspaceURL: URL, buildSystemID: String) -> [String: ToolingJSONValue] { - guard let data = store.data(forKey: Self.key(workspaceURL: workspaceURL, buildSystemID: buildSystemID)), - let options = try? JSONDecoder().decode([String: ToolingJSONValue].self, from: data) else { - return [:] - } - return options - } - - func setOptions(_ options: [String: ToolingJSONValue], workspaceURL: URL, buildSystemID: String) { - let key = Self.key(workspaceURL: workspaceURL, buildSystemID: buildSystemID) - guard !options.isEmpty else { - store.set(nil, forKey: key) - return - } - let encoder = JSONEncoder() - encoder.outputFormatting = [.sortedKeys] - store.set(try? encoder.encode(options), forKey: key) - } - - func emptyMultiSelectOptions(workspaceURL: URL, buildSystemID: String) -> [String: Set] { - let key = Self.key(workspaceURL: workspaceURL, buildSystemID: buildSystemID) + ".EmptyMultiSelect" - guard let data = store.data(forKey: key), - let options = try? JSONDecoder().decode([String: [String]].self, from: data) else { return [:] } - return options.mapValues { Set($0) } - } - - func setEmptyMultiSelectOptions(_ options: [String: Set], workspaceURL: URL, buildSystemID: String) { - let key = Self.key(workspaceURL: workspaceURL, buildSystemID: buildSystemID) + ".EmptyMultiSelect" - let nonempty = options.filter { !$0.value.isEmpty } - guard !nonempty.isEmpty else { - store.set(nil, forKey: key) - return - } - let encoder = JSONEncoder() - encoder.outputFormatting = [.sortedKeys] - store.set(try? encoder.encode(nonempty.mapValues { $0.sorted() }), forKey: key) - } - - private static func key(workspaceURL: URL, buildSystemID: String) -> String { - keyPrefix + buildSystemID + "." + MacPluginStateDirectories.workspaceDigest(workspaceURL) - } -} - /// `${workspaceStateDir}` and `${outputFile}` of build-system runs, under the /// plugin's state directory in Application Support. The app bundle and the /// installed plugin package stay read-only; the directory goes away with diff --git a/macos/Sources/Lithe/Platform/MacOS/Debug/MacBuildDebugLaunchResolver.swift b/macos/Sources/Lithe/Platform/MacOS/Debug/MacBuildDebugLaunchResolver.swift index d8faaea4da..9ff5cc6021 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Debug/MacBuildDebugLaunchResolver.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Debug/MacBuildDebugLaunchResolver.swift @@ -41,3 +41,37 @@ struct MacBuildDebugLaunchResolver { return DebugLaunchConfiguration(name: title, request: .launch, arguments: arguments, adapterProviderID: identity) } } + +/// Connection attach uses the installed adapter registry, including adapters +/// hosted by their owning language server. It never falls back to a language ID. +@MainActor +struct MacBuildConnectionDebugResolver { + let planner: any DeclarativeLanguageServerLaunchPlanning + let entries: @MainActor () -> [String: PluginDebugAdapterEntry] + let generation: @MainActor () -> Int + + func prepare(configurationType: String, preferredAdapterID: String?, port: UInt16) throws -> PreparedProviderConnectionDebug { + let available = entries().filter { identity, entry in + entry.adapter.requests.contains(.attach) + && entry.adapter.configurationTypes.contains(configurationType) + && (preferredAdapterID == nil || preferredAdapterID == identity) + } + guard port > 0, available.count == 1, let (identity, entry) = available.first else { + throw DebugLaunchConfigurationResolutionError.providerDebugUnavailable + } + let selectedGeneration = generation() + let arguments = try planner.debugStartArguments( + manifest: entry.plugin.descriptor.normalizedManifest, adapterID: entry.adapter.id, + request: DeclarativeDebugStartRequest(request: .attach, targetKind: "connection", host: "127.0.0.1", port: port, + registrationGeneration: selectedGeneration, currentRegistrationGeneration: selectedGeneration)) + return PreparedProviderConnectionDebug( + options: .init(adapterID: identity, configurationType: configurationType, port: port), + configuration: DebugLaunchConfiguration(name: configurationType, request: .attach, arguments: arguments, adapterProviderID: identity), + isCurrent: { + guard generation() == selectedGeneration, let current = entries()[identity] else { return false } + return current.plugin.packageURL.standardizedFileURL == entry.plugin.packageURL.standardizedFileURL + && current.plugin.descriptor.normalizedManifest == entry.plugin.descriptor.normalizedManifest + && current.adapter == entry.adapter + }) + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/Debug/MacDebugPortAvailabilityChecker.swift b/macos/Sources/Lithe/Platform/MacOS/Debug/MacDebugPortAvailabilityChecker.swift index f1ac435cc2..131ee814d4 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Debug/MacDebugPortAvailabilityChecker.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Debug/MacDebugPortAvailabilityChecker.swift @@ -1,9 +1,52 @@ import Darwin import Foundation +import LitheModuleAPI /// Probes loopback TCP ports without creating a long-lived listener. @MainActor final class MacDebugPortAvailabilityChecker: DebugPortAvailabilityChecking { + func unusedLoopbackPort() throws -> UInt16 { + let descriptor = socket(AF_INET, SOCK_STREAM, 0) + guard descriptor >= 0 else { throw DebugLaunchConfigurationResolutionError.providerDebugUnavailable } + defer { _ = close(descriptor) } + var address = sockaddr_in() + address.sin_len = UInt8(MemoryLayout.stride) + address.sin_family = sa_family_t(AF_INET) + address.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1")) + let bound = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + bind(descriptor, $0, socklen_t(MemoryLayout.stride)) == 0 + } + } + var length = socklen_t(MemoryLayout.stride) + let located = withUnsafeMutablePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { getsockname(descriptor, $0, &length) == 0 } + } + guard bound, located, address.sin_port != 0 else { throw DebugLaunchConfigurationResolutionError.providerDebugUnavailable } + return UInt16(bigEndian: address.sin_port) + } + + /// A bind probe does not connect to or resume a suspended JDWP VM. The + /// operation guard also ends the wait when its owning process exits. + func waitForLoopbackListener(_ port: UInt16, processID: @escaping @MainActor () -> Int32?, isCurrent: @escaping @MainActor () -> Bool) async throws { + let clock = ContinuousClock(), deadline = ContinuousClock.now.advanced(by: .seconds(120)) + while clock.now < deadline { + try Task.checkCancellation() + guard isCurrent() else { throw CancellationError() } + if !isPortAvailable(Int(port)), let pid = processID(), pid > 0 { + let owned = await Task.detached(priority: .utility) { + let snapshot = MacProcessTable.snapshot() + let processes = [pid] + MacProcessTable.descendants(of: pid, in: snapshot).map(\.processID) + return processes.contains { MacProcessTable.listeningTCPPorts(processID: $0).contains(Int(port)) } + }.value + if owned { try Task.checkCancellation(); guard isCurrent() else { throw CancellationError() }; return } + } + guard isCurrent() else { throw CancellationError() } + try await clock.sleep(for: .milliseconds(100)) + } + throw RunConfigurationOperationFailure(message: L10n.string("The framework did not open its JVM debug port before the deadline. Check the Run output.")) + } + func isPortAvailable(_ port: Int) -> Bool { guard (1...65_535).contains(port) else { return false } let descriptor = socket(AF_INET, SOCK_STREAM, 0) diff --git a/macos/Sources/Lithe/Platform/MacOS/FileSystem/MacFileStorage.swift b/macos/Sources/Lithe/Platform/MacOS/FileSystem/MacFileStorage.swift index 9a9a0dc8b2..d6a5ca49b6 100644 --- a/macos/Sources/Lithe/Platform/MacOS/FileSystem/MacFileStorage.swift +++ b/macos/Sources/Lithe/Platform/MacOS/FileSystem/MacFileStorage.swift @@ -110,13 +110,40 @@ struct MacFileStorage: FileStorage { /// can be compared with the planned snapshot. A plain "compare, then /// rename" still overwrites a save another program made in between. func replaceData(_ data: Data, at url: URL, expecting expected: Data?) throws -> Bool { + try replaceData(data, at: url, expecting: expected, preservingMetadata: false) + } + + func replaceDataPreservingMetadata(_ data: Data, at url: URL, expecting expected: Data?) throws -> Bool { + try replaceData(data, at: url, expecting: expected, preservingMetadata: true) + } + + private func replaceData(_ data: Data, at url: URL, expecting expected: Data?, preservingMetadata: Bool) throws -> Bool { let temporary = url.deletingLastPathComponent().appendingPathComponent( ".\(url.lastPathComponent).lithe-\(UUID().uuidString)" ) #if DEBUG if unsupportedExchangeForTesting { throw CocoaError(.featureUnsupported) } #endif - try prepareTemporaryFile(data, at: temporary) + if preservingMetadata, let expected { + let attributes = try FileManager.default.attributesOfItem(atPath: url.path) + guard attributes[.type] as? FileAttributeType == .typeRegular, + (attributes[.size] as? NSNumber)?.intValue == expected.count else { return false } + do { + // Copy first so ACLs, extended attributes and permissions stay + // with the native document; truncating the private copy keeps + // its metadata. The exchange below still checks displaced bytes. + try FileManager.default.copyItem(at: url, to: temporary) + try data.write(to: temporary, options: []) + } catch { + if FileManager.default.fileExists(atPath: temporary.path) { + do { try FileManager.default.removeItem(at: temporary) } + catch { throw FileStorageRetainedCopyError(destinationPath: url.path, retainedPath: temporary.path) } + } + throw error + } + } else { + try prepareTemporaryFile(data, at: temporary) + } #if DEBUG afterPreparingTemporaryForTesting?() #endif diff --git a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index 5280eaa2d1..30072b954d 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -141,12 +141,6 @@ final class MacServiceContainer { }) let workspaceLanguageServerPreferences = MacWorkspaceLanguageServerPreferencesStore(store: store) let directoryMarkStore = WorkspaceDirectoryMarkStore(store: store) - let runConfigurationStore = MacRunConfigurationStore( - core: rustCore, - storage: fileStorage, - preferences: store - ) - self.runConfigurationStore = runConfigurationStore let debugBreakpointStore = MacDebugBreakpointStore(store: store) let debugSteppingFilterStore = MacDebugSteppingFilterStore(store: store) // Remote workspaces open under an empty placeholder root; both file @@ -207,6 +201,13 @@ final class MacServiceContainer { validator: declarativePluginOperations, credentials: pluginCredentials ) + let runConfigurationStore = MacRunConfigurationStore( + core: rustCore, + storage: fileStorage, + preferences: store, + immutableRoots: pluginPackageStore.runtimeReadOnlyDirectories + ) + self.runConfigurationStore = runConfigurationStore let pluginSettings = MacPluginSettingsStore(store: store) let pluginLaunchResolver = MacPluginLanguageServerLaunchResolver( planner: declarativePluginOperations, @@ -383,7 +384,7 @@ final class MacServiceContainer { } let runtimeService = ProjectRuntimeService( runtimeLocator: MacRuntimeLocator(), - store: store, + settingsStore: MacProjectRuntimeSettingsStore(core: rustCore, storage: fileStorage, preferences: store), toolDiscovery: MacRuntimeToolDiscovery(), javaSelector: rustCore, workspaceRuntime: { root in @@ -416,8 +417,9 @@ final class MacServiceContainer { // can be connected; nothing starts until a model or dependency load. let remotePluginLaunches = MacRemotePluginLaunchPreparer(registry: remoteWorkspaces, packages: remotePluginPackages, host: remoteOperations, planner: declarativePluginOperations, resolver: pluginLaunchResolver, core: rustCore) + let definedToolchains = MacDefinedToolchainResolver() let providerTools = MacBuildToolResolver(runtime: runtimeService, processRunner: processRunner, fileStorage: fileStorage, - registry: remoteWorkspaces, remoteFiles: remoteOperations, remoteProcesses: remoteOperations, logSink: logSink) + registry: remoteWorkspaces, remoteFiles: remoteOperations, remoteProcesses: remoteOperations, definedTools: definedToolchains, logSink: logSink) let buildProviderSessions = MacBuildProviderSessions( launchResolver: pluginLaunchResolver, tools: providerTools, @@ -480,7 +482,7 @@ final class MacServiceContainer { fileStorage: fileStorage, platformUI: platformUI)) let sessions = MacPluginBackendSessions( launchResolver: pluginLaunchResolver, - tools: MacBuildToolResolver(runtime: runtimeService, processRunner: processRunner, fileStorage: fileStorage, logSink: logSink), + tools: MacBuildToolResolver(runtime: runtimeService, processRunner: processRunner, fileStorage: fileStorage, definedTools: definedToolchains, logSink: logSink), versionChecker: declarativePluginOperations, runtime: runtimeService, core: rustCore, @@ -646,7 +648,8 @@ final class MacServiceContainer { runtimeService: runtimeService, toolchainRegistry: runToolchainRegistry, metadataResolver: ProcessRunToolchainMetadataResolver(processRunner: processRunner), - environmentResolver: rustCore + environmentResolver: rustCore, + definedTools: definedToolchains ) let runService = RunService( logSink: logSink, @@ -676,9 +679,11 @@ final class MacServiceContainer { fileStorage: fileStorage, registry: remoteWorkspaces, remoteFiles: remoteOperations, - remoteProcesses: remoteOperations, logSink: logSink + remoteProcesses: remoteOperations, + definedTools: definedToolchains, logSink: logSink ), - optionsStore: MacBuildOptionsStore(store: store), + optionsStore: MacBuildOptionsStore(store: store, storage: fileStorage, + immutableRoots: [Bundle.main.bundleURL] + pluginPackageStore.runtimeReadOnlyDirectories), stateLocator: MacRemoteBuildStateLocator(local: MacBuildSystemStateLocator( directories: MacPluginStateDirectories(fileStorage: fileStorage), fileStorage: fileStorage, logSink: logSink @@ -689,6 +694,11 @@ final class MacServiceContainer { registry: remoteWorkspaces, host: remoteOperations), providers: buildProviderSessions, runLauncher: runService, + nativeConfigurationFiles: MacBuildNativeConfigurationStore( + storage: fileStorage, planner: RustBuildSystemOperations(core: rustCore), + immutableRoots: [Bundle.main.bundleURL] + pluginPackageStore.runtimeReadOnlyDirectories + + pluginStartup.registeredPlugins.map(\.packageURL) + ), settings: { pluginSettings.values(forPlugin: $0) }, sources: { // Enablement is read on every detection, so turning a @@ -709,58 +719,22 @@ final class MacServiceContainer { } ) let graph = ExecutionFeatureGraph( - maven: MavenService( - logSink: logSink, - runtimeService: runtimeService, - process: MacStreamingProcess(processRegistry: processRegistry, moduleID: .execution, logSink: logSink), - dependencyProcess: MacStreamingProcess(processRegistry: processRegistry, moduleID: .execution, logSink: logSink), - mavenOperations: javaMavenOperations, - configurationStore: MacMavenConfigurationStore(storage: fileStorage), - dependencyOutputs: MacMavenDependencyOutputStore(logSink: logSink) - ), run: runService, tests: LanguageTestService( catalog: languagePackRegistry.catalog, registry: languagePackRegistry.testProviders, executableResolver: executableResolver, processFactory: { MacStreamingProcess(processRegistry: processRegistry, moduleID: .execution, logSink: logSink) }, - extensionRequiredLanguageIDs: testExtensionLanguageIDs, - resultParser: { output, rootURL, reports in - javaMavenOperations.mavenTestResults( - output: output, - projectRoot: rootURL, - reports: reports - ) - } + extensionRequiredLanguageIDs: testExtensionLanguageIDs ), buildSystems: buildSystems ) - let mavenFeature = graph.mavenFeature - graph.run.configureLanguageDependencyProvider { [weak mavenFeature] languageID, workspace, serviceID in + graph.run.configureLanguageDependencyProvider { languageID, workspace, serviceID in let capabilityID: ModuleCapabilityID = languageID == "java" ? .languageIntelligence : .languageServerExtension(languageID) guard let provider = moduleRuntime.capability(capabilityID) as? any LanguageDependencyProviding else { return nil } - let snapshot = provider.dependencySnapshot(workspaceURL: workspace, serviceID: serviceID) - guard languageID == "java", snapshot?.dependencyRoots.isEmpty != false, - let mavenFeature, - let project = mavenFeature.project, - project.rootURL.standardizedFileURL == workspace.standardizedFileURL, - !mavenFeature.isProjectReloadRequired else { return snapshot } - let modules = ["."] + project.allModules.map(\.relativePath) - let roots = modules.flatMap { - mavenFeature.resolvedDependencyArtifactPaths( - modulePath: $0, - defaultRepositoryURL: mavenRepositoryURL - ) - }.filter { fileStorage.fileExists(at: $0) } - guard !roots.isEmpty else { return snapshot } - return LanguageDependencySnapshot( - sourceRoots: snapshot?.sourceRoots ?? [], - binaryRoots: snapshot?.binaryRoots ?? [], - dependencyRoots: Array(Set(roots)).sorted { $0.path < $1.path }, - virtualDocuments: snapshot?.virtualDocuments ?? [] - ) + return provider.dependencySnapshot(workspaceURL: workspace, serviceID: serviceID) } return graph }) @@ -1019,6 +993,8 @@ final class MacServiceContainer { pluginWebPageLifecycle.enable(id.rawValue) }, beforePackageReplacement: { id in + (moduleRuntime.capability(.executionWorkspace) as? ExecutionModuleCapability)? + .buildSystemsFeature?.pluginPackageWillChange(id.rawValue) pluginWebPageLifecycle.packageWillChange(id.rawValue) pluginBackends.packageWillChange(id.rawValue) // The running provider still executes the old files; the @@ -1096,6 +1072,11 @@ final class MacServiceContainer { languageProviderCatalogSnapshot: languageProviderCatalogSnapshot, debugLaunchConfigurationResolver: DebugLaunchConfigurationResolver( javaTestLaunchResolver: rustCore, + providerConnection: { configurationType, adapterID, port in + try MacBuildConnectionDebugResolver(planner: declarativePluginOperations, + entries: qualifiedDebugAdapters, generation: { liveSnapshotBox.snapshot.generation }) + .prepare(configurationType: configurationType, preferredAdapterID: adapterID, port: port) + }, providerLaunch: { source, preparation, workspaceURL, title in try MacBuildDebugLaunchResolver(planner: declarativePluginOperations, entry: { qualifiedDebugAdapters()[$0] }, generation: { liveSnapshotBox.snapshot.generation }) @@ -1326,61 +1307,66 @@ final class MacServiceContainer { } }, recordJavaHome: { root, home in - await runtimeService.presetJavaHome(home, for: root) + try await runtimeService.presetJavaHome(home, for: root) } ) let installedPlugins = pluginStartup.registeredPlugins services.loginShellEnvironmentInspector = MacLoginShellEnvironment.shared - services.toolchainSettingsLoader = MacToolchainSettingsLoader( - sources: { [pluginManager] in - let enabled = Set(pluginManager.snapshots.filter(\.isEnabled).map(\.id)) - return installedPlugins.map { plugin in - ToolchainSettingsPluginSource( - pluginID: plugin.descriptor.id.rawValue, - name: plugin.manifest.displayName, - manifest: plugin.descriptor.normalizedManifest, - isEnabled: enabled.contains(plugin.descriptor.id), - // 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) - ) + let toolchainSources: @MainActor @Sendable () -> [ToolchainSettingsPluginSource] = { [pluginManager] in + let enabled = Set(pluginManager.snapshots.filter(\.isEnabled).map(\.id)) + return installedPlugins.map { plugin in + ToolchainSettingsPluginSource( + pluginID: plugin.descriptor.id.rawValue, + name: plugin.manifest.displayName, + manifest: plugin.descriptor.normalizedManifest, + isEnabled: enabled.contains(plugin.descriptor.id), + // 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) + ) + } + } + let rootIDs = PluginWorkspaceRootIDs( + secret: MacPluginRootIDSecret.load(from: MacPluginRootIDSecret.fileURL(fileStorage: fileStorage), logSink: logSink)) + let toolchainDiscovery = MacToolchainDiscovery( + logSink: logSink, + queryProvider: { pluginID, providerID, params, timeout in + do { + return try await pluginBackends.queryToolchainProvider( + providerID, params: params, ofPlugin: pluginID, timeout: timeout) + } catch let failure as LanguageServerRequestFailure + where failure.runtimeError.code == "requestTimeout" { + throw MacToolchainDiscovery.QueryTimeout() } }, - directories: { [weak services] _ in services?.toolchainSettingsDirectories?() ?? [] }, - isRemoteWorkspace: { remoteWorkspaces.contains($0) }, - discover: { [pluginBackends, fileStorage] request in - let rootIDs = PluginWorkspaceRootIDs( - secret: MacPluginRootIDSecret.load(from: MacPluginRootIDSecret.fileURL(fileStorage: fileStorage), logSink: logSink)) - return await MacToolchainDiscovery( - logSink: logSink, - queryProvider: { pluginID, providerID, params, timeout in - do { - return try await pluginBackends.queryToolchainProvider( - providerID, params: params, ofPlugin: pluginID, timeout: timeout) - } catch let failure as LanguageServerRequestFailure - where failure.runtimeError.code == "requestTimeout" { - // Core's deadline for the request, not a backend that could not start. - throw MacToolchainDiscovery.QueryTimeout() - } - }, - workspaceContext: { workspace, pluginID in - await MainActor.run { - let path = workspace.standardizedFileURL.path - for context in PluginWorkspaceContexts.shared.contexts { - guard case .local = context.authority, - let folder = context.folders.first(where: { $0.localURL.standardizedFileURL.path == path }) - else { continue } - return MacToolchainDiscovery.WorkspaceContext( - contextID: context.contextID, generation: context.generation, - rootID: rootIDs.rootID(of: folder.location, pluginID: pluginID)) - } - return nil - } + workspaceContext: { workspace, pluginID in + await MainActor.run { + let path = workspace.standardizedFileURL.path + for context in PluginWorkspaceContexts.shared.contexts { + guard case .local = context.authority, + let folder = context.folders.first(where: { $0.localURL.standardizedFileURL.path == path }) + else { continue } + return MacToolchainDiscovery.WorkspaceContext( + contextID: context.contextID, generation: context.generation, + rootID: rootIDs.rootID(of: folder.location, pluginID: pluginID)) } - ).discover(request) + return nil + } } ) + definedToolchains.sources = toolchainSources + definedToolchains.discovery = toolchainDiscovery + services.toolchainSettingsLoader = MacToolchainSettingsLoader( + sources: toolchainSources, + directories: { [weak services] _ in services?.toolchainSettingsDirectories?() ?? [] }, + isRemoteWorkspace: { remoteWorkspaces.contains($0) }, + 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. diff --git a/macos/Sources/Lithe/Platform/MacOS/MonacoWorkbenchEditor.swift b/macos/Sources/Lithe/Platform/MacOS/MonacoWorkbenchEditor.swift index 14774ff9e6..64f33b71c9 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MonacoWorkbenchEditor.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MonacoWorkbenchEditor.swift @@ -729,7 +729,8 @@ final class MonacoWorkbenchSession: NSObject, ObservableObject, WKNavigationDele let identity = service.map(ObjectIdentifier.init) guard identity != testServiceIdentity else { return } testServiceIdentity = identity - testOutcomeSubscription = service?.$testOutcomes + testOutcomeSubscription = service?.$testOutcomesByProviderID + .map { $0["java"] ?? [] } .removeDuplicates() .dropFirst() .receive(on: DispatchQueue.main) diff --git a/macos/Sources/Lithe/Platform/MacOS/Persistence/MacLegacyMavenConfigurationMigration.swift b/macos/Sources/Lithe/Platform/MacOS/Persistence/MacLegacyMavenConfigurationMigration.swift new file mode 100644 index 0000000000..8937de5456 --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/Persistence/MacLegacyMavenConfigurationMigration.swift @@ -0,0 +1,270 @@ +import CoreFoundation +import CryptoKit +import Foundation +import LitheCoreContracts + +/// One-time data transfer at the Maven consumer cutover. These private formats +/// describe saved versions only; the adapter does not interpret Maven settings. +/// Temporary: retain for the first two stable versions with this migration, then +/// remove before the third. Tracker: D-MIGRATION-REMOVAL in +/// docs/development/issue-1495-plugin-migration-remaining-plan.md. +@MainActor +struct MacLegacyMavenConfigurationMigration { + let storage: any FileStorage + let preferences: any KeyValueStore + let runStore: MacRunConfigurationStore + let options: MacBuildOptionsStore + let immutableRoots: [URL] + + nonisolated static func storageIdentity(workspacePath: String, reactorPath: String) -> String { + workspacePath + "\0" + reactorPath + } + + /// Call only after the old configuration readers and writers are detached. + /// Every source is retained until all destinations have been read back. + @discardableResult + func migrate(workspace: URL, reactorRoots: [String]) throws -> Bool { + let roots = Array(Set(reactorRoots)).sorted() + guard roots.count <= 256, roots.allSatisfy(BuildProjectOptionsDocument.validRoot) else { throw BuildOptionsStorageError.invalid } + let workspace = workspace.standardizedFileURL + let portableURL = workspace.appendingPathComponent(".lithe/maven/config.json") + let portable = try source(Portable.self, at: portableURL, within: workspace, + fields: ["version", "selectedProfiles", "customProfiles", "skipTests"]) + let localDirectory = storage.applicationSupportDirectory().appendingPathComponent("Lithe/Maven") + let local: [(root: String, source: Source)] = try roots.compactMap { root in + let identity = Self.storageIdentity(workspacePath: workspace.path, reactorPath: root) + let digest = SHA256.hash(data: Data(identity.utf8)).map { String(format: "%02x", $0) }.joined() + let url = localDirectory.appendingPathComponent(digest + ".json") + return try source(Local.self, at: url, within: localDirectory, + fields: ["version", "settingsPath", "localRepositoryPath", "mavenExecutablePath", "javaHomePath"]) + .map { (root, $0) } + } + let receiptKey = "BuildSystems.Migrations.Maven." + MacPluginStateDirectories.workspaceDigest(workspace) + let receiptBytes = preferences.data(forKey: receiptKey) + if receiptBytes == nil, preferences.object(forKey: receiptKey) != nil { throw BuildOptionsStorageError.invalid } + var receipt = try readReceipt(receiptBytes) + receipt = receipt.filter { storage.fileExists(at: URL(fileURLWithPath: $0.key)) } + guard portable != nil || !local.isEmpty else { + try writeReceipt(receipt, key: receiptKey, expecting: receiptBytes) + return try migrateRuntimePreferences(workspace: workspace, roots: roots) + } + let original = try options.projectDocument(workspace) + var document = original.document + var shared = document.systems["maven"] ?? BuildProjectOptionsDocument.System() + if let portable { + try portable.value.validate() + if shared.instanceDefaults["profiles"] == nil { + shared.instanceDefaults["profiles"] = .array(portable.value.selectedProfiles.map(ToolingJSONValue.string)) + } + if shared.instanceDefaults["skipTests"] == nil { shared.instanceDefaults["skipTests"] = .bool(portable.value.skipTests) } + shared.savedChoices["profiles"] = Array(Set((shared.savedChoices["profiles"] ?? []) + portable.value.customProfiles)).sorted() + } + document.systems["maven"] = shared + let existingDefaults = try options.instanceDefaults(workspaceURL: workspace, buildSystemID: "maven") + let existingWorkspaceOptions = try options.options(workspaceURL: workspace, buildSystemID: "maven") + let previousOptions = try options.instanceOptions(workspaceURL: workspace, buildSystemID: "maven") + var instanceOptions = previousOptions + let runURL = workspace.appendingPathComponent(".lithe/run/local.json") + let needsTools = local.contains { $0.source.value.mavenExecutablePath != nil || $0.source.value.javaHomePath != nil } + let runBytes = needsTools ? try readOptional(runURL, within: workspace) : nil + let inspection = needsTools ? runStore.inspect(at: workspace, checkFingerprint: false, javaEntrypoints: nil) : nil + if let inspection, case .invalid = inspection.status { throw BuildOptionsStorageError.invalid } + if needsTools, try readOptional(runURL, within: workspace) != runBytes { throw BuildOptionsStorageError.conflict } + let originalToolchain = inspection?.projectToolchain ?? ProjectToolchainSelection() + var toolchain = originalToolchain + for (root, source) in local { + try source.value.validate() + var values = instanceOptions[root] ?? [:] + if values["settingsFile"] == nil, existingDefaults["settingsFile"] == nil, existingWorkspaceOptions["settingsFile"] == nil, let value = source.value.settingsPath, shared.instanceDefaults["settingsFile"] != .string(value) { values["settingsFile"] = .string(value) } + if values["localRepository"] == nil, existingDefaults["localRepository"] == nil, existingWorkspaceOptions["localRepository"] == nil, let value = source.value.localRepositoryPath, shared.instanceDefaults["localRepository"] != .string(value) { values["localRepository"] = .string(value) } + if !values.isEmpty { instanceOptions[root] = values } + // Reactor-local paths have higher priority than project defaults. + // An existing root entry, even explicitly automatic, wins intact. + if root == "." { + if inspection?.projectToolchain == nil { + toolchain.mavenExecutablePath = source.value.mavenExecutablePath ?? "" + toolchain.mavenJavaHomePath = source.value.javaHomePath ?? "" + } + } else if toolchain.pathOverrides[root] == nil, + source.value.mavenExecutablePath != nil || source.value.javaHomePath != nil { + toolchain.pathOverrides[root] = .init(mavenExecutablePath: source.value.mavenExecutablePath ?? "", + mavenJavaHomePath: source.value.javaHomePath ?? "") + } + } + let sources = (portable.map { [($0.url, $0.bytes)] } ?? []) + local.map { ($0.source.url, $0.source.bytes) } + for (url, bytes) in sources { + let fingerprint = SHA256.hash(data: bytes).map { String(format: "%02x", $0) }.joined() + if let earlier = receipt[url.path], earlier != fingerprint { throw BuildOptionsStorageError.conflict } + receipt[url.path] = fingerprint + } + try verifySources(sources) + try writeReceipt(receipt, key: receiptKey, expecting: receiptBytes) + let pendingReceipt = preferences.data(forKey: receiptKey) + // Failures may leave verified destination values. A retry preserves + // those values and completes remaining transfers; it never clears them. + if document != original.document { try options.saveProjectDocument(document, workspace: workspace, expecting: original.bytes) } + guard try options.instanceOptions(workspaceURL: workspace, buildSystemID: "maven") == previousOptions else { + throw BuildOptionsStorageError.conflict + } + if instanceOptions != previousOptions { + try options.setInstanceOptions(instanceOptions, workspaceURL: workspace, buildSystemID: "maven") + } + if toolchain != originalToolchain { + try runStore.saveProjectToolchain(toolchain, at: workspace, expecting: runBytes) + } + guard try options.projectDocument(workspace).document == document, + try options.instanceOptions(workspaceURL: workspace, buildSystemID: "maven") == instanceOptions else { + throw BuildOptionsStorageError.write + } + if needsTools { + let saved = runStore.inspect(at: workspace, checkFingerprint: false, javaEntrypoints: nil) + if case .invalid = saved.status { throw BuildOptionsStorageError.write } + guard (saved.projectToolchain ?? ProjectToolchainSelection()) == toolchain else { throw BuildOptionsStorageError.write } + } + try verifySources(sources) + guard preferences.data(forKey: receiptKey) == pendingReceipt else { throw BuildOptionsStorageError.conflict } + for (url, bytes) in sources { + guard try storage.removeData(at: url, expecting: bytes) else { throw BuildOptionsStorageError.conflict } + } + for (url, _) in sources { receipt[url.path] = nil } + try writeReceipt(receipt, key: receiptKey, expecting: pendingReceipt) + _ = try migrateRuntimePreferences(workspace: workspace, roots: roots) + return true + } + + /// This is a separate saved source from the reactor files. Only its two + /// retired Maven fields are removed after the private default layer verifies. + private func migrateRuntimePreferences(workspace: URL, roots: [String]) throws -> Bool { + guard !roots.isEmpty else { return false } + let key = MacProjectRuntimeSettingsStore.preferencesKey(workspace) + guard let bytes = preferences.data(forKey: key) else { + guard preferences.object(forKey: key) == nil else { throw BuildOptionsStorageError.invalid } + return false + } + guard bytes.count <= 2 * 1024 * 1024, + var document = try JSONSerialization.jsonObject(with: bytes) as? [String: Any] else { + throw BuildOptionsStorageError.invalid + } + let fields = ["mavenSettingsPath": "settingsFile", "mavenLocalRepositoryPath": "localRepository"] + let receiptKey = "BuildSystems.Migrations.Maven." + MacPluginStateDirectories.workspaceDigest(workspace) + ".Runtime" + guard fields.keys.contains(where: { document[$0] != nil }) else { + // A verified cleanup can survive a failure removing its receipt. + // There is no remaining old Maven field to migrate or overwrite. + if preferences.object(forKey: receiptKey) != nil { preferences.set(nil, forKey: receiptKey) } + return false + } + var incoming: [String: ToolingJSONValue] = [:] + for (field, option) in fields { + guard let raw = document[field] else { continue } + guard let value = raw as? String, value.count <= 32768, !value.contains("\0") else { + throw BuildOptionsStorageError.invalid + } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { incoming[option] = .string(trimmed) } + } + let fingerprint = SHA256.hash(data: bytes).map { String(format: "%02x", $0) }.joined() + if let previous = preferences.object(forKey: receiptKey) { + guard previous as? String == fingerprint else { throw BuildOptionsStorageError.conflict } + } + var defaults = try options.instanceDefaults(workspaceURL: workspace, buildSystemID: "maven") + for (id, value) in incoming where defaults[id] == nil { defaults[id] = value } + guard preferences.data(forKey: key) == bytes else { throw BuildOptionsStorageError.conflict } + preferences.set(fingerprint, forKey: receiptKey) + guard preferences.string(forKey: receiptKey) == fingerprint else { throw BuildOptionsStorageError.write } + try options.setInstanceDefaults(defaults, workspaceURL: workspace, buildSystemID: "maven") + guard try options.instanceDefaults(workspaceURL: workspace, buildSystemID: "maven") == defaults else { + throw BuildOptionsStorageError.write + } + guard preferences.data(forKey: key) == bytes, + preferences.string(forKey: receiptKey) == fingerprint else { throw BuildOptionsStorageError.conflict } + for field in fields.keys { document.removeValue(forKey: field) } + let cleaned = try JSONSerialization.data(withJSONObject: document, options: [.sortedKeys]) + guard cleaned.count <= 2 * 1024 * 1024 else { throw BuildOptionsStorageError.invalid } + preferences.set(cleaned, forKey: key) + guard preferences.data(forKey: key) == cleaned else { throw BuildOptionsStorageError.write } + preferences.set(nil, forKey: receiptKey) + guard preferences.object(forKey: receiptKey) == nil else { throw BuildOptionsStorageError.write } + return true + } + + private func readReceipt(_ bytes: Data?) throws -> [String: String] { + guard let bytes else { return [:] } + guard bytes.count <= 2 * 1024 * 1024, + let value = try JSONSerialization.jsonObject(with: bytes) as? [String: String], value.count <= 512, + value.allSatisfy({ $0.key.hasPrefix("/") && $0.key.count <= 32768 && !$0.key.contains("\0") && $0.value.count == 64 && $0.value.allSatisfy(\.isHexDigit) }) else { + throw BuildOptionsStorageError.invalid + } + return value + } + + private func writeReceipt(_ value: [String: String], key: String, expecting expected: Data?) throws { + guard preferences.data(forKey: key) == expected else { throw BuildOptionsStorageError.conflict } + let data = value.isEmpty ? nil : try JSONSerialization.data(withJSONObject: value, options: [.sortedKeys]) + if data == expected { return } + preferences.set(data, forKey: key) + guard preferences.data(forKey: key) == data else { throw BuildOptionsStorageError.write } + } + + private struct Source { + let url: URL + let bytes: Data + let value: Value + } + + private func source(_ type: Value.Type, at url: URL, within root: URL, + fields: Set) throws -> Source? { + guard let bytes = try readOptional(url, within: root) else { return nil } + guard let json = try JSONSerialization.jsonObject(with: bytes) as? [String: Any], + Set(json.keys).isSubset(of: fields), let version = json["version"] as? NSNumber, + CFGetTypeID(version) != CFBooleanGetTypeID(), version == 1 else { throw BuildOptionsStorageError.invalid } + return Source(url: url, bytes: bytes, value: try JSONDecoder().decode(type, from: bytes)) + } + + private func readOptional(_ url: URL, within root: URL) throws -> Data? { + let target = url.resolvingSymlinksInPath().standardizedFileURL.path + let root = root.resolvingSymlinksInPath().standardizedFileURL.path + guard target.hasPrefix(root + "/"), !immutableRoots.contains(where: { + let path = $0.resolvingSymlinksInPath().standardizedFileURL.path + return target == path || target.hasPrefix(path + "/") + }) else { throw BuildOptionsStorageError.invalid } + guard storage.fileExists(at: url) else { return nil } + guard let metadata = storage.metadata(for: url), metadata.isRegularFile, + let count = metadata.byteCount, count <= 2 * 1024 * 1024 else { throw BuildOptionsStorageError.invalid } + let bytes = try storage.readData(from: url, options: []) + guard bytes.count <= 2 * 1024 * 1024 else { throw BuildOptionsStorageError.invalid } + return bytes + } + + private func verifySources(_ sources: [(URL, Data)]) throws { + for (url, bytes) in sources { + guard let metadata = storage.metadata(for: url), metadata.isRegularFile, + metadata.byteCount == bytes.count, + try storage.readData(from: url, options: []) == bytes else { throw BuildOptionsStorageError.conflict } + } + } + + private struct Portable: Decodable { + let version: Int + let selectedProfiles: [String] + let customProfiles: [String] + let skipTests: Bool + func validate() throws { + guard selectedProfiles.count <= 256, customProfiles.count <= 256, + (selectedProfiles + customProfiles).allSatisfy({ !$0.isEmpty && $0.count <= 1024 && !$0.contains("\0") }) else { + throw BuildOptionsStorageError.invalid + } + } + } + + private struct Local: Decodable { + let version: Int + let settingsPath: String? + let localRepositoryPath: String? + let mavenExecutablePath: String? + let javaHomePath: String? + func validate() throws { + guard [settingsPath, localRepositoryPath, mavenExecutablePath, javaHomePath].compactMap({ $0 }) + .allSatisfy({ $0.count <= 32768 && !$0.contains("\0") }) else { throw BuildOptionsStorageError.invalid } + } + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/Persistence/MacLegacyMavenRunConfigurationMigration.swift b/macos/Sources/Lithe/Platform/MacOS/Persistence/MacLegacyMavenRunConfigurationMigration.swift new file mode 100644 index 0000000000..d59ced0d02 --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/Persistence/MacLegacyMavenRunConfigurationMigration.swift @@ -0,0 +1,532 @@ +import CoreFoundation +import Darwin +import Foundation +import LitheCoreContracts +import LitheModuleAPI + +/// Temporary format adapter for saved Run v1/v2 Maven entries, never a launch path. +/// Remove with D-MIGRATION-REMOVAL before stable N+2; see the #1495 remaining plan. +/// Installed packages stay read-only; preferences may create a private workspace local document. +struct MacLegacyMavenRunConfigurationMigration { + struct Write { + let url: URL + let original: Data? + let replacement: Data + } + struct Plan { + let writes: [Write] + var teamDocument: Data? = nil + var localDocument: Data? = nil + var preferenceMarker: (key: String, state: Int)? = nil + var absentPreferenceKeys: [String] = [] + var preferenceSources: [String: Data] = [:] + var preferenceNamespace: (prefix: String, keys: [String])? = nil + var sources: [(url: URL, bytes: Data?)] = [] + } + let storage: any FileStorage + let preferences: any KeyValueStore + let immutableRoots: [URL] + + private static let frameworks = ["spring-boot.maven", "quarkus.maven", "micronaut.maven"] + private static let mavenFields: Set = ["module", "reactorPath", "mainClass", "jvmArguments", "programArguments", "profiles", "skipTests"] + private static let limit = 2 * 1024 * 1024 + + func prepare(workspace: URL, projects: [RunProjectOwnership], runs: [RunProviderConfiguration], + models: [BuildModel] = [], generatedDocument: Data? = nil, lease: RunConfigurationGenerationLease) throws -> Plan { + try lease.check() + let candidates = runs.filter { $0.reference.pluginID == "io.github.lithe-idea.maven-build" && $0.reference.buildSystemID == "maven" } + guard !candidates.isEmpty else { return Plan(writes: []) } + var sources = try ["configurations.json", "local.json"].map { name -> (URL, Data?, [String: Any]) in + let url = workspace.appendingPathComponent(".lithe/run/" + name) + let data = try read(url, workspace: workspace) + let document: [String: Any] + if let data { + guard let parsed = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw unsupported() } + document = try normalizedDocument(parsed) + } else { document = ["version": 2, "configurations": []] } + return (url, data, document) + } + let aliases = legacyAliases(projects: projects, runs: candidates) + let projectKey = workspace.standardizedFileURL.path.replacingOccurrences(of: "/", with: "_") + let markerKey = "lithe.run-configuration-migrated.\(projectKey)" + let marker = try preferenceMarker(markerKey) + let storedIDs = sources.flatMap { ($0.2["configurations"] as? [[String: Any]] ?? []).compactMap { $0["id"] as? String } } + let prefix = "lithe.java-run-options.\(projectKey)." + let listedKeys = marker == 2 ? nil : preferences.keys(withPrefix: prefix) + let preferenceKeys = marker == 2 ? [] : Set(aliases.keys).union(storedIDs).map { prefix + $0 } + (listedKeys ?? []) + guard preferenceKeys.count <= 10_000 else { throw unsupported() } + var preferenceSources: [String: Data] = [:], absentKeys: [String] = [] + var preferenceByteCount = 0 + for key in Set(preferenceKeys).sorted() { + guard let value = preferences.object(forKey: key) else { absentKeys.append(key); continue } + guard let bytes = value as? Data, bytes.count <= Self.limit else { throw unsupported() } + preferenceByteCount += bytes.count + guard preferenceByteCount <= Self.limit else { throw unsupported() } + preferenceSources[key] = bytes + } + let imported = try importPreferences(preferenceSources, prefix: prefix, sources: sources, aliases: aliases, + models: models, generatedDocument: generatedDocument, workspace: workspace, canEnumerate: listedKeys != nil) + sources[1].2 = imported.document + if imported.changed, sources[1].1 == nil { + let runtimeKey = "lithe.project-runtime.\(projectKey)" + if let raw = preferences.object(forKey: runtimeKey) { + guard let bytes = raw as? Data, bytes.count <= Self.limit else { throw unsupported() } + let settings = try JSONDecoder().decode(ProjectRuntimeSettings.self, from: bytes) + guard settings.javaHomePath.isEmpty, settings.mavenJavaHomePath.isEmpty, + settings.mavenHomeSelection == .automatic else { throw unsupported() } + preferenceSources[runtimeKey] = bytes + } else { absentKeys.append(runtimeKey) } + } + var cumulative: [String: [String: Any]] = [:] + var writes: [Write] = [] + for (index, source) in sources.enumerated() { + let (url, bytes, original) = source + try lease.check() + guard let items = original["configurations"] as? [[String: Any]], items.count <= 10_000 else { throw unsupported() } + var document = original, transformed: [[String: Any]] = [], changed = index == 1 && imported.changed + var seen = Set() + for item in items { + guard let id = item["id"] as? String, seen.insert(id).inserted else { throw unsupported() } + let previous = cumulative[id] + let effective = try merge(previous ?? [:], item) + cumulative[id] = effective + let provider = effective["provider"] as? String ?? effective["type"] as? String + if provider == nil && Self.frameworks.contains(where: { id.hasPrefix($0 + ":") }) && aliases[id] == nil { throw unsupported() } + if provider == "build.provider", ((effective["extensions"] as? [String: Any])?["build"] as? [String: Any])?["pluginID"] as? String == "io.github.lithe-idea.maven-build", + ((item["extensions"] as? [String: Any])?["maven"]) != nil { throw unsupported() } + let legacy = provider == "maven.module" || Self.frameworks.contains(provider ?? "") || (provider == nil && aliases[id] != nil) + guard legacy else { transformed.append(item); continue } + let ext = effective["extensions"] as? [String: Any] ?? [:] + if let java = ext["java"] as? [String: Any], let source = java["source"] as? String, source.lowercased().hasSuffix(".java") { + transformed.append(item); continue // JDT-owned Java entries remain stage E. + } + guard effective["command"] == nil else { throw unsupported() } + guard ext["maven"] == nil || ext["maven"] is [String: Any] else { throw unsupported() } + let maven = ext["maven"] as? [String: Any] ?? [:] + guard Set(maven.keys).isSubset(of: Self.mavenFields) else { throw unsupported() } + for field in ["module", "reactorPath", "mainClass"] { + if let value = maven[field], !(value is NSNull), !(value is String) { throw unsupported() } + } + try validateLegacyTools(effective, extensions: ext) + // Linked project defaults need their own consumer model. Do not + // silently discard them merely because this Run can be renamed. + try rejectUnmigratedDefaults(sources[1].2) + for runtimeKey in ["lithe.project-runtime.", "lithe.project-runtime-settings."].map({ $0 + projectKey }) { + if let raw = preferences.object(forKey: runtimeKey) { + guard let bytes = raw as? Data, bytes.count <= Self.limit else { throw unsupported() } + let settings = try JSONDecoder().decode(ProjectRuntimeSettings.self, from: bytes) + guard settings.javaHomePath.isEmpty, settings.mavenJavaHomePath.isEmpty, + settings.mavenHomeSelection == .automatic, settings.moduleOverrides.isEmpty else { throw unsupported() } + preferenceSources[runtimeKey] = bytes + } else { absentKeys.append(runtimeKey) } + } + let execution: BuildExecution = provider == "maven.module" ? .task : .service + var matches = aliases[id] ?? candidates.filter { $0.reference.execution == execution } + matches = matches.filter { candidate in + if let root = maven["reactorPath"] as? String, root != candidate.reference.root { return false } + if let module = maven["module"] as? String { + let path = module == "." ? candidate.reference.root : candidate.reference.root == "." ? module : candidate.reference.root + "/" + module + if path != candidate.projectPath { return false } + } + return candidate.reference.execution == execution + } + guard matches.count == 1, let target = matches.first else { throw unsupported() } + let expectedProvider = provider ?? Self.frameworks.first { id.hasPrefix($0 + ":") } + let modelTargets = models.flatMap { model -> [BuildModel.Target] in + guard model.projects.contains(where: { $0.id == target.reference.projectID && $0.path == target.projectPath }) else { return [] } + return model.targets.filter { $0.projectID == target.reference.projectID && $0.id == target.reference.targetID } + } + guard modelTargets.count == 1, modelTargets.first?.name == expectedProvider else { throw unsupported() } + var result = item + result["provider"] = "build.provider" + result["name"] = effective["name"] as? String ?? target.name + result["execution"] = execution.rawValue + if result["cwd"] == nil && previous == nil { result["cwd"] = target.reference.root } + result.removeValue(forKey: "toolchains"); result.removeValue(forKey: "debug") + let arguments = try strings(maven["programArguments"]) + let effectiveArguments = execution == .task ? try strings(effective["args"]) + arguments : arguments + // Keep even empty goals or unsupported runtime choices as data. + // The provider owns their meaning and validation, never this adapter. + result["args"] = effectiveArguments + var resultExtensions = item["extensions"] as? [String: Any] ?? [:] + guard resultExtensions["build"] == nil, resultExtensions["build-run"] == nil else { throw unsupported() } + resultExtensions.removeValue(forKey: "maven"); resultExtensions.removeValue(forKey: "java") + resultExtensions["build"] = try JSONSerialization.jsonObject(with: JSONEncoder().encode(target.reference)) + var choices: [String: Any] = [:], options: [String: Any] = [:] + if let own = (item["extensions"] as? [String: Any])?["maven"] as? [String: Any] { + if own.keys.contains("jvmArguments") { choices["runtimeArguments"] = try strings(own["jvmArguments"]) } + if own.keys.contains("mainClass") { + guard own["mainClass"] == nil || own["mainClass"] is NSNull || own["mainClass"] is String else { throw unsupported() } + choices["entrypoint"] = own["mainClass"] as? String ?? "" + } + } + let profiles = try strings(maven["profiles"]) + if !profiles.isEmpty { options["profiles"] = profiles } + if let skip = maven["skipTests"], !(skip is NSNull) { + guard let bool = skip as? NSNumber, CFGetTypeID(bool) == CFBooleanGetTypeID() else { throw unsupported() } + options["skipTests"] = bool + } + if let ownJava = (item["extensions"] as? [String: Any])?["java"] as? [String: String] { + var tools: [String: Any] = [:] + for (field, id, key) in [("mavenExecutablePath", "io.github.lithe-idea.maven-build:maven", "executable"), + ("mavenJavaHomePath", "java", "home")] { + if let value = ownJava[field] { + tools[id] = value.isEmpty ? NSNull() : [key: value] + } + } + if !tools.isEmpty { choices["tools"] = tools } + } + choices["options"] = options + resultExtensions["build-run"] = choices + result["extensions"] = resultExtensions + transformed.append(result); changed = true + } + if changed { + try validate(url, workspace: workspace) + document["configurations"] = transformed + let replacement = try JSONSerialization.data(withJSONObject: document, options: [.sortedKeys, .prettyPrinted, .withoutEscapingSlashes]) + guard replacement.count <= Self.limit else { throw unsupported() } + writes.append(Write(url: url, original: bytes, replacement: replacement)) + } + } + var plan = Plan(writes: writes) + if !writes.isEmpty { + plan.sources = sources.map { (url: $0.0, bytes: $0.1) } + plan.preferenceMarker = (markerKey, marker) + plan.absentPreferenceKeys = absentKeys + plan.preferenceSources = preferenceSources + plan.preferenceNamespace = listedKeys.map { (prefix, $0.sorted()) } + plan.teamDocument = try writes.first { $0.url == sources[0].0 }?.replacement ?? sources[0].1 + ?? JSONSerialization.data(withJSONObject: sources[0].2) + plan.localDocument = try writes.first { $0.url == sources[1].0 }?.replacement ?? sources[1].1 + ?? JSONSerialization.data(withJSONObject: sources[1].2) + } + return plan + } + + /// All plans are prepared before the first write; every publication and rollback uses CAS. + func apply(_ plan: Plan, workspace: URL, lease: RunConfigurationGenerationLease) throws { + try lease.check() + try verifyPreferences(plan) + try verifySources(plan, completed: [], workspace: workspace) + var completed: [Write] = [] + var createdDirectories: [URL] = [] + do { + for write in plan.writes { + try lease.check() + try verifyPreferences(plan) + try verifySources(plan, completed: completed, workspace: workspace) + try validate(write.url, workspace: workspace) + if write.original == nil { + for path in [".lithe", ".lithe/run"] { + let directory = workspace.appendingPathComponent(path) + try validate(directory, workspace: workspace) + if mkdir(directory.path, 0o700) == 0 { createdDirectories.append(directory) } + else if errno != EEXIST { throw CocoaError(.fileWriteNoPermission) } + guard storage.metadata(for: directory)?.isDirectory == true else { throw unsupported() } + } + try lease.check() + try verifySources(plan, completed: completed, workspace: workspace) + } + guard try storage.replaceDataPreservingMetadata(write.replacement, at: write.url, expecting: write.original) else { throw conflict() } + completed.append(write) + MacFileWriteEventSuppression.markWritten(write.url) + } + try lease.check() + try verifyPreferences(plan) + try verifySources(plan, completed: completed, workspace: workspace) + } catch { + var rollbackFailed = false + for write in completed.reversed() { + // Another writer's bytes always win; keep trying the other files + // even when one destination can no longer be rolled back. + do { + try validate(write.url, workspace: workspace) + let restored = try write.original.map { try storage.replaceDataPreservingMetadata($0, at: write.url, expecting: write.replacement) } + ?? storage.removeData(at: write.url, expecting: write.replacement) + if restored { + MacFileWriteEventSuppression.markWritten(write.url) + } else { rollbackFailed = true } + } catch { rollbackFailed = true } + } + for directory in createdDirectories.reversed() { + // rmdir removes only an empty directory; another writer's files survive. + if (try? validate(directory, workspace: workspace)) != nil { _ = rmdir(directory.path) } + } + if rollbackFailed { throw conflict() } + throw error + } + } + + private func verifySources(_ plan: Plan, completed: [Write], workspace: URL) throws { + for source in plan.sources { + let expected = completed.first(where: { $0.url == source.url })?.replacement ?? source.bytes + guard try read(source.url, workspace: workspace) == expected else { throw conflict() } + } + } + + private func preferenceMarker(_ key: String) throws -> Int { + guard let raw = preferences.object(forKey: key) else { return 0 } + guard let value = raw as? NSNumber, CFGetTypeID(value) == CFBooleanGetTypeID() else { throw unsupported() } + return value.boolValue ? 2 : 1 + } + private func verifyPreferences(_ plan: Plan) throws { + if let marker = plan.preferenceMarker { + guard try preferenceMarker(marker.key) == marker.state, + plan.absentPreferenceKeys.allSatisfy({ preferences.object(forKey: $0) == nil }), + plan.preferenceSources.allSatisfy({ preferences.data(forKey: $0.key) == $0.value }) else { throw conflict() } + if let namespace = plan.preferenceNamespace { + guard preferences.keys(withPrefix: namespace.prefix)?.sorted() == namespace.keys else { throw conflict() } + } + } + } + + /// Import all observable active preferences before creating local.json; the + /// old importer uses that file's existence as its cutover boundary, including + /// for Java entries. Existing local fields always win over retired values. + private func importPreferences(_ values: [String: Data], prefix: String, + sources: [(URL, Data?, [String: Any])], aliases: [String: [RunProviderConfiguration]], + models: [BuildModel], generatedDocument: Data?, workspace: URL, + canEnumerate: Bool) throws -> (document: [String: Any], changed: Bool) { + var records: [String: [String: Any]] = [:] + for source in sources { + for item in source.2["configurations"] as? [[String: Any]] ?? [] { + guard let id = item["id"] as? String else { throw unsupported() } + records[id] = try merge(records[id] ?? [:], item) + } + } + let legacyRecord = records.values.contains { item in + let provider = item["provider"] as? String + return provider == "maven.module" || Self.frameworks.contains(provider ?? "") + || (provider == nil && aliases[item["id"] as? String ?? ""] != nil) + } + let hasLegacyPreference = values.keys.contains { key in + let id = String(key.dropFirst(prefix.count)) + return aliases[id] != nil && records[id]?["provider"] as? String != "build.provider" + } + guard legacyRecord || hasLegacyPreference, !values.isEmpty else { return (sources[1].2, false) } + if sources[1].1 == nil && !canEnumerate { throw unsupported() } + var generated: [String: [String: Any]] = [:] + if let generatedDocument { + guard let parsed = try JSONSerialization.jsonObject(with: generatedDocument) as? [String: Any], + let entries = parsed["configurations"] as? [[String: Any]] else { throw unsupported() } + for item in entries { if let id = item["id"] as? String { generated[id] = item } } + } + var document = sources[1].2 + var local = document["configurations"] as? [[String: Any]] ?? [] + var changed = false + for key in values.keys.sorted() { + let id = String(key.dropFirst(prefix.count)) + guard records[id]?["provider"] as? String != "build.provider" else { continue } + var record = records[id] ?? generated[id] ?? [:] + if record["provider"] == nil, let choices = aliases[id] { + guard choices.count == 1, let target = choices.first else { throw unsupported() } + let targets = models.flatMap { $0.targets }.filter { $0.projectID == target.reference.projectID && $0.id == target.reference.targetID } + guard targets.count == 1, let provider = targets.first?.name, Self.frameworks.contains(provider) else { throw unsupported() } + let root = target.reference.root + let module = target.projectPath == root ? "." : root == "." ? target.projectPath : String(target.projectPath.dropFirst(root.count + 1)) + record = ["id": id, "name": target.name, "provider": provider, "execution": "service", "cwd": root, + "extensions": ["maven": ["reactorPath": root, "module": module]]] + } + guard let provider = record["provider"] as? String else { throw unsupported() } + let isMaven = provider == "maven.module" || Self.frameworks.contains(provider) + let options = try legacyPreference(values[key]!) + if isMaven, !options.javaHomePath.isEmpty { throw unsupported() } + var patch: [String: Any] = ["id": id, "env": options.environment] + if !options.workingDirectoryPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + let raw = options.workingDirectoryPath.trimmingCharacters(in: .whitespacesAndNewlines) + let directory = (raw.hasPrefix("/") ? URL(fileURLWithPath: raw) : workspace.appendingPathComponent(raw)).resolvingSymlinksInPath().standardizedFileURL + let root = workspace.resolvingSymlinksInPath().standardizedFileURL.path + guard storage.metadata(for: directory)?.isDirectory == true, + directory.path == root || directory.path.hasPrefix(root + "/") else { throw unsupported() } + patch["cwd"] = directory.path == root ? "." : String(directory.path.dropFirst(root.count + 1)) + } + var java: [String: String] = [:] + for (field, value) in [("homePath", options.javaHomePath), ("mavenExecutablePath", options.mavenExecutablePath), ("mavenJavaHomePath", options.mavenJavaHomePath)] where !value.isEmpty { java[field] = value } + var extensions: [String: Any] = java.isEmpty ? [:] : ["java": java] + if isMaven || ["java.main", "java.current-file"].contains(provider) { + var maven: [String: Any] = ["jvmArguments": legacyArguments(options.vmArguments), "programArguments": legacyArguments(options.programArguments), + "profiles": options.activeProfiles.sorted()] + if let skip = options.mavenSkipTests { maven["skipTests"] = skip } + extensions["maven"] = maven + } else { patch["args"] = legacyArguments(options.programArguments) } + if !extensions.isEmpty { patch["extensions"] = extensions } + // A preference-only Maven record needs its current, proven identity. + if isMaven, records[id] == nil { patch = try merge(record, patch) } + if let index = local.firstIndex(where: { $0["id"] as? String == id }) { + local[index] = try merge(patch, local[index]) + } else { local.append(patch) } + changed = true + } + document["configurations"] = local + return (document, changed) + } + + private func legacyPreference(_ bytes: Data) throws -> RunOptions { + guard let value = try JSONSerialization.jsonObject(with: bytes) as? [String: Any], + Set(value.keys).isSubset(of: ["workingDirectoryPath", "arguments", "programArguments", "environment", "java", "javaHomePath", "vmArguments", "activeProfiles"]) else { throw unsupported() } + if let java = value["java"], !(java is NSNull) { + guard let object = java as? [String: Any], Set(object.keys).isSubset(of: ["homePath", "mavenExecutablePath", "mavenJavaHomePath", "vmArguments", "activeMavenProfiles", "skipTests"]) else { throw unsupported() } + } + return try JSONDecoder().decode(RunOptions.self, from: bytes) + } + + /// The retired preference writer used this text grammar, including dropping + /// empty quoted tokens. It performs no expansion or shell execution. + private func legacyArguments(_ text: String) -> [String] { + var result: [String] = [], current = "", quote: Character?, escaped = false + for character in text { + if escaped { current.append(character); escaped = false } + else if character == "\\" && quote != "'" { escaped = true } + else if character == "'" || character == "\"" { + if quote == character { quote = nil } + else if quote == nil { quote = character } + else { current.append(character) } + } else if character.isWhitespace && quote == nil { + if !current.isEmpty { result.append(current); current = "" } + } else { current.append(character) } + } + if escaped { current.append("\\") } + if !current.isEmpty { result.append(current) } + return result + } + + private func legacyAliases(projects: [RunProjectOwnership], runs: [RunProviderConfiguration]) -> [String: [RunProviderConfiguration]] { + var result: [String: [RunProviderConfiguration]] = [:] + for run in runs where run.reference.execution == .service { + guard let owner = projects.first(where: { $0.path == run.projectPath }), let name = owner.name else { continue } + let root = run.reference.root + guard root == "." || run.projectPath == root || run.projectPath.hasPrefix(root + "/") else { continue } + let module = run.projectPath == root ? "." : root == "." ? run.projectPath : String(run.projectPath.dropFirst(root.count + 1)) + // Retired generators used the label or module path. Keep both + // spellings, and reject any collision instead of guessing a root. + for label in Set([name, module]) { + for framework in Self.frameworks { + let id = framework + ":" + (root == "." ? "" : root + "/") + label + result[id, default: []].append(run) + } + } + } + return result + } + + private func validateLegacyTools(_ item: [String: Any], extensions: [String: Any]) throws { + if let debug = item["debug"], !(debug is NSNull) { + guard let values = debug as? [String: String], values == ["adapter": "jdwp"] else { throw unsupported() } + } + if let tools = item["toolchains"] { + guard let values = tools as? [String: String], values.allSatisfy({ key, value in + (key == "java" && value == "project-jdk") || (key == "maven" && value == "project-maven") + }) else { throw unsupported() } + } + if let java = extensions["java"] { + guard let values = java as? [String: String], Set(values.keys).isSubset(of: ["homePath", "mavenExecutablePath", "mavenJavaHomePath"]), + values["homePath", default: ""].isEmpty else { throw unsupported() } + // Validate the inert choice through its typed decoder. Core's full + // preflight validates the lexical absolute path before publication. + for (field, key) in [("mavenExecutablePath", "executable"), ("mavenJavaHomePath", "home")] { + if let value = values[field], !value.isEmpty { + guard value.utf8.count <= 4096, !value.contains(where: \.isNewline) else { throw unsupported() } + _ = try JSONDecoder().decode(FixedToolchainSelection.self, + from: JSONSerialization.data(withJSONObject: [key: value])) + } + } + } + } + + private func rejectUnmigratedDefaults(_ document: [String: Any]) throws { + guard let raw = document["toolchain"] else { return } + guard let tools = raw as? [String: Any] else { throw unsupported() } + for (namespace, fields) in [("java", ["homePath"]), ("maven", ["executablePath", "javaHomePath"])] { + if let raw = tools[namespace] { + guard let values = raw as? [String: String], fields.allSatisfy({ values[$0, default: ""].isEmpty }) else { throw unsupported() } + } + } + if let raw = tools["pathOverrides"] { + guard let overrides = raw as? [String: [String: String]], + overrides.values.allSatisfy({ $0.values.allSatisfy(\.isEmpty) }) else { throw unsupported() } + } + } + + private func merge(_ base: [String: Any], _ patch: [String: Any]) throws -> [String: Any] { + var result = base.merging(patch) { _, value in value } + if let raw = patch["extensions"] { + guard let incoming = raw as? [String: Any], base["extensions"] == nil || base["extensions"] is [String: Any] else { throw unsupported() } + var extensions = base["extensions"] as? [String: Any] ?? [:] + for (namespace, value) in incoming { + if let old = extensions[namespace] as? [String: Any], let values = value as? [String: Any] { + extensions[namespace] = old.merging(values) { _, next in next } + } else { extensions[namespace] = value } + } + result["extensions"] = extensions + } + return result + } + + private func strings(_ value: Any?) throws -> [String] { + guard let value, !(value is NSNull) else { return [] } + guard let strings = value as? [String], strings.count <= 1024, strings.allSatisfy({ !$0.contains("\0") && $0.utf8.count <= 65536 }) else { throw unsupported() } + return strings + } + + /// Retired v1 fields are a private saved format. Convert the whole document + /// when one Maven record changes, so unrelated Java records remain readable. + private func normalizedDocument(_ original: [String: Any]) throws -> [String: Any] { + guard let version = original["version"] as? NSNumber, CFGetTypeID(version) != CFBooleanGetTypeID(), + version == 1 || version == 2 else { throw unsupported() } + guard version == 1 else { return original } + guard let items = original["configurations"] as? [[String: Any]], items.count <= 10_000 else { throw unsupported() } + var document = original + document["version"] = 2 + document["configurations"] = try items.map { item in + guard item["provider"] == nil, item["cwd"] == nil else { throw unsupported() } + var result = item + var extensions = item["extensions"] as? [String: Any] ?? [:] + guard item["extensions"] == nil || item["extensions"] is [String: Any], + extensions["maven"] == nil else { throw unsupported() } + var maven: [String: Any] = [:] + for (old, current) in [("module", "module"), ("mainClass", "mainClass"), ("jvmArguments", "jvmArguments"), + ("programArguments", "programArguments"), ("mavenProfiles", "profiles")] { + if let value = result.removeValue(forKey: old), !(value is NSNull), !((value as? [Any])?.isEmpty == true) { + maven[current] = value + } + } + if !maven.isEmpty { extensions["maven"] = maven } + if !extensions.isEmpty { result["extensions"] = extensions } + if let cwd = result.removeValue(forKey: "workingDirectory") { result["cwd"] = cwd } + if let raw = result.removeValue(forKey: "type") { + guard let provider = raw as? String, !provider.isEmpty else { throw unsupported() } + result["provider"] = provider + result["execution"] = provider == "java.current-file" || provider == "java.main" ? "application" + : Self.frameworks.contains(provider) ? "service" : "task" + result["confidence"] = "native" + if result["debug"] == nil { result["debug"] = ["adapter": "jdwp"] } + } + return result + } + return document + } + + private func validate(_ url: URL, workspace: URL, writable: Bool = true) throws { + let path = url.resolvingSymlinksInPath().standardizedFileURL.path + let root = workspace.resolvingSymlinksInPath().standardizedFileURL.path + guard path.hasPrefix(root + "/"), !writable || !immutableRoots.contains(where: { + let immutable = $0.resolvingSymlinksInPath().standardizedFileURL.path + return path == immutable || path.hasPrefix(immutable + "/") + }) else { throw unsupported() } + } + private func read(_ url: URL, workspace: URL) throws -> Data? { + try validate(url, workspace: workspace, writable: false) + guard storage.fileExists(at: url) else { return nil } + guard let metadata = storage.metadata(for: url), metadata.isRegularFile, let size = metadata.byteCount, size <= Self.limit else { throw unsupported() } + let data = try storage.readData(from: url, options: []) + guard data.count <= Self.limit else { throw unsupported() } + return data + } + private func unsupported() -> any Error { + MacRunConfigurationStoreError.invalidConfiguration(L10n.string("Some saved run configurations cannot be migrated yet. Keep the existing files and retry after updating Lithe and its build plugin.")) + } + private func conflict() -> any Error { + MacRunConfigurationStoreError.invalidConfiguration(L10n.string("Run configuration changed while saving. Reload and try again.")) + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/Persistence/MacMavenConfigurationStore.swift b/macos/Sources/Lithe/Platform/MacOS/Persistence/MacMavenConfigurationStore.swift deleted file mode 100644 index 2d31546100..0000000000 --- a/macos/Sources/Lithe/Platform/MacOS/Persistence/MacMavenConfigurationStore.swift +++ /dev/null @@ -1,120 +0,0 @@ -import LitheModuleAPI -import CryptoKit -import Foundation -import LitheCoreContracts - -struct MacMavenConfigurationStore: MavenConfigurationStoring, Sendable { - private let storage: any FileStorage - - init(storage: any FileStorage) { - self.storage = storage - } - - func loadMavenConfiguration( - workspaceURL: URL, - reactorPath: String - ) throws -> MavenStoredConfiguration { - let portable = try decodeIfPresent( - MavenPortableConfiguration.self, - at: portableConfigurationURL(workspaceURL: workspaceURL) - ) - let local = try decodeIfPresent( - MavenLocalConfiguration.self, - at: localConfigurationURL(workspaceURL: workspaceURL, reactorPath: reactorPath) - ) - guard portable?.version == nil || portable?.version == MavenPortableConfiguration.currentVersion, - local?.version == nil || local?.version == MavenLocalConfiguration.currentVersion else { - throw MacMavenConfigurationStoreError.unsupportedVersion - } - return MavenStoredConfiguration(portable: portable, local: local) - } - - func saveMavenConfiguration( - _ configuration: MavenStoredConfiguration, - workspaceURL: URL, - reactorPath: String - ) throws { - try write( - configuration.portable, - to: portableConfigurationURL(workspaceURL: workspaceURL) - ) - try write( - configuration.local, - to: localConfigurationURL(workspaceURL: workspaceURL, reactorPath: reactorPath) - ) - } - - private func portableConfigurationURL(workspaceURL: URL) -> URL { - workspaceURL.standardizedFileURL - .appendingPathComponent(".lithe", isDirectory: true) - .appendingPathComponent("maven", isDirectory: true) - .appendingPathComponent("config.json") - } - - private func localConfigurationURL(workspaceURL: URL, reactorPath: String) -> URL { - let identity = Self.storageIdentity( - workspacePath: workspaceURL.standardizedFileURL.path, - reactorPath: reactorPath - ) - let digest = SHA256.hash(data: Data(identity.utf8)) - .map { String(format: "%02x", $0) } - .joined() - return storage.applicationSupportDirectory() - .appendingPathComponent("Lithe", isDirectory: true) - .appendingPathComponent("Maven", isDirectory: true) - .appendingPathComponent(digest + ".json") - } - - static func storageIdentity(workspacePath: String, reactorPath: String) -> String { - workspacePath + "\0" + reactorPath - } - - private func decodeIfPresent( - _ type: Value.Type, - at url: URL - ) throws -> Value? { - guard storage.fileExists(at: url) else { return nil } - do { - return try JSONDecoder().decode(type, from: storage.readData(from: url, options: [])) - } catch { - throw MacMavenConfigurationStoreError.invalidConfiguration(url.lastPathComponent) - } - } - - private func write(_ value: Value?, to url: URL) throws { - guard let value else { - if storage.fileExists(at: url) { - try storage.removeItem(at: url) - } - return - } - do { - try storage.createDirectory( - at: url.deletingLastPathComponent(), - withIntermediateDirectories: true - ) - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] - try storage.writeData(encoder.encode(value), to: url, options: .atomic) - } catch { - throw MacMavenConfigurationStoreError.writeFailed(error.localizedDescription) - } - } -} - -private enum MacMavenConfigurationStoreError: LocalizedError { - case invalidConfiguration(String) - case unsupportedVersion - case writeFailed(String) - - var errorDescription: String? { - switch self { - case .invalidConfiguration(let name): - L10n.string("The Maven configuration in \(name) is invalid.") - case .unsupportedVersion: - L10n.string("The Maven configuration was created by an unsupported version of Lithe.") - case .writeFailed(let details): - L10n.string("Unable to save Maven configuration: \(details)") - } - } -} diff --git a/macos/Sources/Lithe/Platform/MacOS/Persistence/MacMavenDependencyOutputStore.swift b/macos/Sources/Lithe/Platform/MacOS/Persistence/MacMavenDependencyOutputStore.swift deleted file mode 100644 index 1fba64cbdd..0000000000 --- a/macos/Sources/Lithe/Platform/MacOS/Persistence/MacMavenDependencyOutputStore.swift +++ /dev/null @@ -1,101 +0,0 @@ -import LitheModuleAPI -import Darwin -import Foundation -import LitheCoreContracts - -/// Scratch files the Maven dependency plugin writes one module's tree to. -/// -/// Several Lithe builds can run at the same time, so each process uses its own -/// directory named after its process identifier and, when created, removes only -/// the directories of processes that have exited. Dependency operations remove -/// their own files; that cleanup covers a crash, or a tree Maven finished -/// writing after its operation was stopped. -/// -/// Note: the data-source decision is recorded in .agents/notes/implemented/architecture/2026-09-26-maven-dependency-tree-output-file.md -struct MacMavenDependencyOutputStore: MavenDependencyOutputStoring { - private let logSink: (any LogSink)? - private let directoryURL: URL - - init( - logSink: (any LogSink)? = nil, - rootURL: URL = FileManager.default.temporaryDirectory - .appendingPathComponent("Lithe", isDirectory: true) - .appendingPathComponent("maven-dependency-trees", isDirectory: true), - processIdentifier: pid_t = ProcessInfo.processInfo.processIdentifier, - isProcessRunning: (pid_t) -> Bool = MacMavenDependencyOutputStore.isProcessRunning - ) { - self.logSink = logSink - directoryURL = rootURL.appendingPathComponent(String(processIdentifier), isDirectory: true) - Self.removeAbandonedDirectories( - in: rootURL, - keeping: processIdentifier, - isProcessRunning: isProcessRunning, logSink: logSink - ) - } - - func makeDependencyOutputFile(operationID: String) throws -> URL { - // Operation IDs become file names; accept only UUID-style identifiers. - guard !operationID.isEmpty, - operationID.count <= 128, - operationID.unicodeScalars.allSatisfy({ - $0.isASCII && (CharacterSet.alphanumerics.contains($0) || $0 == "-") - }) else { - throw MavenOperationError( - code: "invalid_request", - message: "The Maven dependency operation is invalid." - ) - } - try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true) - let fileURL = directoryURL.appendingPathComponent(operationID + ".txt", isDirectory: false) - // Core must only ever read what this operation's Maven wrote. - try removeIfPresent(fileURL) - return fileURL - } - - func removeDependencyOutputFile(_ fileURL: URL) { - do { - try removeIfPresent(fileURL) - } catch { - logSink?.emit(RunBuildScratchCleanupFailed(error: error, stage: "operation")) - } - } - - private func removeIfPresent(_ fileURL: URL) throws { - do { - try FileManager.default.removeItem(at: fileURL) - // log-exempt: 预期的缺失:探测的可选文件或目录不存在,按未发现处理 - } catch CocoaError.fileNoSuchFile { - return - } - } - - private static func removeAbandonedDirectories( - in rootURL: URL, - keeping processIdentifier: pid_t, - isProcessRunning: (pid_t) -> Bool, logSink: (any LogSink)? - ) { - guard let entries = try? FileManager.default.contentsOfDirectory( - at: rootURL, - includingPropertiesForKeys: nil - ) else { - // A missing root means no earlier process created scratch files. - return - } - for entry in entries { - guard let owner = pid_t(entry.lastPathComponent), - owner != processIdentifier, - !isProcessRunning(owner) else { continue } - do { - try FileManager.default.removeItem(at: entry) - } catch { - logSink?.emit(RunBuildScratchCleanupFailed(error: error, stage: "abandoned")) - } - } - } - - static func isProcessRunning(_ processIdentifier: pid_t) -> Bool { - // Signal 0 checks for existence only. EPERM means the process exists - // but belongs to another user, which still owns its directory. - Darwin.kill(processIdentifier, 0) == 0 || errno == EPERM - } -} diff --git a/macos/Sources/Lithe/Platform/MacOS/Persistence/MacProjectRuntimeSettingsStore.swift b/macos/Sources/Lithe/Platform/MacOS/Persistence/MacProjectRuntimeSettingsStore.swift new file mode 100644 index 0000000000..13106cbe80 --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/Persistence/MacProjectRuntimeSettingsStore.swift @@ -0,0 +1,133 @@ +import CryptoKit +import Foundation +import LitheCoreContracts +import LitheModuleAPI + +/// Project-wide tool selections live only in the Core-owned local run document. +/// Other legacy preferences remain in their existing store until their owning +/// domain migration can transfer them without losing user data. +struct MacProjectRuntimeSettingsStore: ProjectRuntimeSettingsStoring { + let core: RustCoreBridge + let storage: any FileStorage + let preferences: any KeyValueStore + + private static let toolKeys = ["javaHomePath", "mavenHomeSelection", "mavenHomePath", "mavenJavaHomePath", "moduleOverrides"] + + func load(at workspace: URL, migrateLegacy: Bool) throws -> ProjectRuntimeSettingsSnapshot { + let root = workspace.standardizedFileURL + let key = Self.preferencesKey(root) + let preferenceData = preferences.data(forKey: key) + guard preferenceData != nil || preferences.object(forKey: key) == nil else { + throw ProjectRuntimeSettingsStoreError.readBack + } + let legacy = try preferenceData.map { try JSONDecoder().decode(ProjectRuntimeSettings.self, from: $0) } + ?? ProjectRuntimeSettings() + let before = try localData(root) + let inspection = try inspect(root) + let inspected = inspection.projectToolchain + guard try localData(root) == before else { throw ProjectRuntimeSettingsStoreError.conflict } + if migrateLegacy, let preferenceData, + var document = try JSONSerialization.jsonObject(with: preferenceData) as? [String: Any], + Self.toolKeys.contains(where: { document[$0] != nil }) { + let paths = legacy.moduleOverrides.map(\.path) + let known = Set(["path", "javaHomePath", "mavenExecutablePath", "mavenJavaHomePath"]) + let rawOverrides = document["moduleOverrides"] as? [[String: Any]] ?? [] + guard Set(paths).count == paths.count, + rawOverrides.allSatisfy({ Set($0.keys).isSubset(of: known) }) else { + throw ProjectRuntimeSettingsStoreError.readBack + } + let previous = legacy.projectToolchainSelection() + var selection = inspected ?? previous + if inspected != nil, !inspection.hasSavedToolchainPathOverrides { + selection.pathOverrides = previous.pathOverrides + } + if (inspected == nil && selection != ProjectToolchainSelection()) + || (inspected != nil && !inspection.hasSavedToolchainPathOverrides) { + try runStore.saveProjectToolchain(selection, at: root, expecting: before) + guard try inspect(root).projectToolchain == selection else { throw ProjectRuntimeSettingsStoreError.readBack } + } + // Keep the source until the target has been read successfully. An + // existing (including explicitly automatic) toolchain always wins. + guard preferences.data(forKey: key) == preferenceData else { throw ProjectRuntimeSettingsStoreError.conflict } + for field in Self.toolKeys { document.removeValue(forKey: field) } + let cleaned = try JSONSerialization.data(withJSONObject: document, options: [.sortedKeys]) + preferences.set(cleaned, forKey: key) + guard preferences.data(forKey: key) == cleaned else { throw ProjectRuntimeSettingsStoreError.readBack } + return try load(at: root, migrateLegacy: false) + } + var settings = legacy + settings.applyProjectToolchain(inspected ?? ProjectToolchainSelection()) + return ProjectRuntimeSettingsSnapshot(workspaceURL: root, settings: settings, revision: Self.revision(before, preferenceData), hasSavedToolchain: inspected != nil) + } + + func save(_ settings: ProjectRuntimeSettings, at workspace: URL, + expecting snapshot: ProjectRuntimeSettingsSnapshot) throws -> ProjectRuntimeSettingsSnapshot { + let root = workspace.standardizedFileURL + guard snapshot.workspaceURL == root else { throw ProjectRuntimeSettingsStoreError.conflict } + let current = try load(at: root, migrateLegacy: false) + guard current.revision == snapshot.revision else { throw ProjectRuntimeSettingsStoreError.conflict } + if settings == current.settings { return current } + let before = try localData(root) + let key = Self.preferencesKey(root) + let preferenceData = preferences.data(forKey: key) + guard Self.revision(before, preferenceData) == snapshot.revision else { throw ProjectRuntimeSettingsStoreError.conflict } + let selected = settings.projectToolchainSelection() + if selected != current.settings.projectToolchainSelection() { + try runStore.saveProjectToolchain(selected, at: root, expecting: before) + guard try inspect(root).projectToolchain == selected else { throw ProjectRuntimeSettingsStoreError.readBack } + } + guard preferences.data(forKey: key) == preferenceData else { throw ProjectRuntimeSettingsStoreError.conflict } + // Preserve unrelated legacy fields while removing tool selections now + // owned by the canonical document, including all subproject overrides. + var document = try preferenceData.flatMap { try JSONSerialization.jsonObject(with: $0) as? [String: Any] } ?? [:] + guard let encoded = try JSONSerialization.jsonObject(with: JSONEncoder().encode(settings)) as? [String: Any] else { + throw ProjectRuntimeSettingsStoreError.readBack + } + for (field, value) in encoded where !Self.toolKeys.contains(field) && !["mavenSettingsPath", "mavenLocalRepositoryPath"].contains(field) { document[field] = value } + for field in Self.toolKeys { document.removeValue(forKey: field) } + let saved = try JSONSerialization.data(withJSONObject: document, options: [.sortedKeys]) + preferences.set(saved, forKey: key) + guard preferences.data(forKey: key) == saved else { throw ProjectRuntimeSettingsStoreError.readBack } + return try load(at: root, migrateLegacy: false) + } + + private var runStore: MacRunConfigurationStore { + MacRunConfigurationStore(core: core, storage: storage, preferences: preferences) + } + + private func inspect(_ root: URL) throws -> ProjectRunConfigurationInspection { + let result = runStore.inspect(at: root, checkFingerprint: false, javaEntrypoints: nil) + if case .invalid(let message) = result.status { + throw MacRunConfigurationStoreError.invalidConfiguration(message) + } + return result + } + + private func localData(_ root: URL) throws -> Data? { + let url = root.appendingPathComponent(".lithe/run/local.json") + return storage.fileExists(at: url) ? try storage.readData(from: url, options: []) : nil + } + + static func preferencesKey(_ workspace: URL) -> String { + "lithe.project-runtime-settings." + workspace.standardizedFileURL.path.replacingOccurrences(of: "/", with: "_") + } + + private static func revision(_ local: Data?, _ preferences: Data?) -> String { + // Separate missing from empty and use independent digests to avoid + // ambiguous concatenation of two documents. + [local, preferences].map { data in + data.map { SHA256.hash(data: $0).map { String(format: "%02x", $0) }.joined() } ?? "missing" + }.joined(separator: ":") + } +} + +enum ProjectRuntimeSettingsStoreError: LocalizedError { + case conflict + case readBack + var errorDescription: String? { + switch self { + case .conflict: L10n.string("Project environment settings changed on disk. Reload before saving; your draft has been kept.") + case .readBack: L10n.string("Could not verify the saved project environment settings. The previous preferences have been kept when possible.") + } + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/Persistence/MacUserDefaultsStore.swift b/macos/Sources/Lithe/Platform/MacOS/Persistence/MacUserDefaultsStore.swift index 4987ba1263..abdd3b2152 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Persistence/MacUserDefaultsStore.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Persistence/MacUserDefaultsStore.swift @@ -26,4 +26,8 @@ final class MacUserDefaultsStore: KeyValueStore { func set(_ value: Any?, forKey key: String) { defaults.set(value, forKey: key) } + + func keys(withPrefix prefix: String) -> [String]? { + defaults.dictionaryRepresentation().keys.filter { $0.hasPrefix(prefix) }.sorted() + } } diff --git a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacIdeHostTransport.swift b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacIdeHostTransport.swift index a94692dcaf..53d71dd43b 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacIdeHostTransport.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacIdeHostTransport.swift @@ -12,13 +12,14 @@ final class MacIdeHostTransport: IdeHostTransport { self.storage = storage } - func open(workspace: URL, permissions: [String: Bool]) throws -> [String: Any] { + func open(workspace: URL, permissions: [String: Bool], toolExposure: [[String: Any]]) throws -> [String: Any] { // Only the connection descriptor is writable. The signed helper remains in the bundle. let directory = storage.applicationSupportDirectory().appendingPathComponent("mcp", isDirectory: true) let helper = Bundle.main.bundleURL.appendingPathComponent("Contents/Helpers/lithe-mcp") return try control("open", arguments: [ "directory": directory.path, "helperPath": helper.path, - "workspaceKey": workspace.standardizedFileURL.path, "permissions": permissions + "workspaceKey": workspace.standardizedFileURL.path, "permissions": permissions, + "toolExposure": toolExposure ]) } diff --git a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginManager.swift b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginManager.swift index 2056c77087..1773db3f73 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginManager.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginManager.swift @@ -593,7 +593,7 @@ final class MacPluginManager: PluginManaging { PluginSettingsContributions( languages: descriptor.languages, languageServerIDs: descriptor.languageServers.map(\.languageID), - buildSystems: descriptor.buildSystems.map { .init(id: $0.id, displayName: $0.displayName) }, + buildSystems: descriptor.buildSystems.map { .init(id: $0.id, displayName: $0.displayName, nativeConfiguration: $0.nativeConfiguration) }, actions: descriptor.settingActions ) } diff --git a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift index 4d9a05a11a..fdfef12f16 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift @@ -119,6 +119,9 @@ final class MacPluginPackageStore { private let logSink: (any LogSink)? private let rootURL: URL private let bundledRootURL: URL? + /// Generic native editors must keep every installed package read-only, + /// including disabled plugins and versions staged for an update. + var runtimeReadOnlyDirectories: [URL] { [rootURL] + [bundledRootURL].compactMap { $0 } } /// Writable language-server state owned by installed plugins; removed /// together with the plugin so an uninstall leaves nothing behind. private let stateDirectories: MacPluginStateDirectories? diff --git a/macos/Sources/Lithe/Platform/MacOS/RunConfiguration/MacRunConfigurationStore.swift b/macos/Sources/Lithe/Platform/MacOS/RunConfiguration/MacRunConfigurationStore.swift index 92458b5b10..17a4a73fe3 100644 --- a/macos/Sources/Lithe/Platform/MacOS/RunConfiguration/MacRunConfigurationStore.swift +++ b/macos/Sources/Lithe/Platform/MacOS/RunConfiguration/MacRunConfigurationStore.swift @@ -1,5 +1,6 @@ import LitheModuleAPI import Foundation +import Darwin enum MacRunConfigurationStoreError: LocalizedError, Sendable { case invalidProject @@ -24,16 +25,19 @@ struct MacRunConfigurationStore: RunConfigurationOperations, @unchecked Sendable private let documentMutator: any RunConfigurationDocumentMutating private let storage: any FileStorage private let preferences: any KeyValueStore + private let immutableRoots: [URL] init( core: RustCoreBridge, storage: any FileStorage, preferences: any KeyValueStore, - documentMutator: (any RunConfigurationDocumentMutating)? = nil + documentMutator: (any RunConfigurationDocumentMutating)? = nil, + immutableRoots: [URL] = [] ) { self.core = core self.storage = storage self.preferences = preferences + self.immutableRoots = [Bundle.main.bundleURL] + immutableRoots self.documentMutator = documentMutator ?? RustRunConfigurationDocumentMutator(core: core) } @@ -46,24 +50,44 @@ struct MacRunConfigurationStore: RunConfigurationOperations, @unchecked Sendable checkFingerprint: Bool, javaEntrypoints: JavaEntrypoints? ) -> ProjectRunConfigurationInspection { + inspect(at: projectURL, checkFingerprint: checkFingerprint, javaEntrypoints: javaEntrypoints, projectOwnership: nil) + } + + func inspectProviderGeneration(at projectURL: URL, projectOwnership: [RunProjectOwnership]) -> ProjectRunConfigurationInspection { + inspect(at: projectURL, checkFingerprint: false, javaEntrypoints: nil, projectOwnership: projectOwnership) + } + + func inspectProviderGeneration(at projectURL: URL, projectOwnership: [RunProjectOwnership], providerRuns: [RunProviderConfiguration]) -> ProjectRunConfigurationInspection { + inspect(at: projectURL, checkFingerprint: false, javaEntrypoints: nil, projectOwnership: projectOwnership, providerRuns: providerRuns) + } + + private func inspect(at projectURL: URL, checkFingerprint: Bool, javaEntrypoints: JavaEntrypoints?, + projectOwnership: [RunProjectOwnership]?, providerRuns: [RunProviderConfiguration]? = nil) -> ProjectRunConfigurationInspection { switch core.inspectRunConfiguration( at: projectURL, checkFingerprint: checkFingerprint, - javaEntrypoints: javaEntrypoints + javaEntrypoints: javaEntrypoints, + projectOwnership: projectOwnership, providerRuns: providerRuns ) { case .success(let payload): return ProjectRunConfigurationInspection( status: payload.status == "ready" ? .ready : .missing, diagnostics: diagnostics(from: payload.diagnostics), recoveryAction: payload.status == "ready" ? .none : .regenerate, - projectToolchain: payload.toolchain.map { toolchain in + projectToolchain: payload.toolchain.flatMap { toolchain -> ProjectToolchainSelection? in + // Generic preferences do not prove legacy defaults were migrated. + guard toolchain.java != nil || toolchain.maven != nil || toolchain.pathOverrides != nil else { return nil } + return ProjectToolchainSelection( javaHomePath: toolchain.java?.homePath ?? "", mavenExecutablePath: toolchain.maven?.executablePath ?? "", - mavenJavaHomePath: toolchain.maven?.javaHomePath ?? "" + mavenJavaHomePath: toolchain.maven?.javaHomePath ?? "", + pathOverrides: toolchain.pathOverrides ?? [:] ) }, + hasSavedToolchainPathOverrides: payload.toolchain?.pathOverrides != nil, requiresGeneration: payload.requiresGeneration ?? (payload.status == "missing") + ) case .failure(let error): return ProjectRunConfigurationInspection( @@ -75,14 +99,38 @@ struct MacRunConfigurationStore: RunConfigurationOperations, @unchecked Sendable } } - /// Identifies the project's run entries. Core keeps the result in memory - /// and in the application cache; nothing is written into the workspace. + func generate(at projectURL: URL, files: [URL], modulePaths: [String], javaEntrypoints: JavaEntrypoints?, + projectOwnership: [RunProjectOwnership]?, providerRuns: [RunProviderConfiguration]) throws -> RunConfigurationGenerationResult { + try generateCurrent(at: projectURL, files: files, modulePaths: modulePaths, javaEntrypoints: javaEntrypoints, + projectOwnership: projectOwnership, providerRuns: providerRuns) + } + + func generate(at projectURL: URL, files: [URL], modulePaths: [String], javaEntrypoints: JavaEntrypoints?, + projectOwnership: [RunProjectOwnership]?, providerRuns: [RunProviderConfiguration], + models: [BuildModel], lease: RunConfigurationGenerationLease) throws -> RunConfigurationGenerationResult { + try generateCurrent(at: projectURL, files: files, modulePaths: modulePaths, javaEntrypoints: javaEntrypoints, + projectOwnership: projectOwnership, providerRuns: providerRuns, models: models, lease: lease) + } + + /// Identifies the project's run entries in memory and the application cache. + /// The isolated legacy adapter may convert existing user Run documents. func generate( at projectURL: URL, files: [URL], modulePaths: [String], - javaEntrypoints: JavaEntrypoints? + javaEntrypoints: JavaEntrypoints?, + projectOwnership: [RunProjectOwnership]? = nil ) throws -> RunConfigurationGenerationResult { + try generateCurrent(at: projectURL, files: files, modulePaths: modulePaths, javaEntrypoints: javaEntrypoints, + projectOwnership: projectOwnership, providerRuns: nil) + } + + private func generateCurrent(at projectURL: URL, files: [URL], modulePaths: [String], javaEntrypoints: JavaEntrypoints?, + projectOwnership: [RunProjectOwnership]?, providerRuns: [RunProviderConfiguration]?, + models: [BuildModel] = [], lease suppliedLease: RunConfigurationGenerationLease? = nil) throws -> RunConfigurationGenerationResult { + let lease = suppliedLease ?? RunConfigurationGenerationLease() + defer { if suppliedLease == nil { lease.invalidate() } } + try lease.check() let root = projectURL.standardizedFileURL guard storage.metadata(for: root)?.isDirectory == true else { throw MacRunConfigurationStoreError.invalidProject } let paths = files.compactMap { file -> String? in @@ -90,13 +138,30 @@ struct MacRunConfigurationStore: RunConfigurationOperations, @unchecked Sendable guard value.path.hasPrefix(root.path + "/") else { return nil } return String(value.path.dropFirst(root.path.count + 1)) } + let migration = MacLegacyMavenRunConfigurationMigration(storage: storage, preferences: preferences, immutableRoots: immutableRoots) + try lease.check() let result = try core.generateRunConfiguration( at: root, paths: paths, modulePaths: modulePaths, javaEntrypoints: javaEntrypoints, + projectOwnership: projectOwnership, providerRuns: providerRuns, cacheDirectoryURL: storage.cacheDirectory() + ).get() + try lease.check() + let plan = try providerRuns.map { try migration.prepare(workspace: root, projects: projectOwnership ?? [], runs: $0, + models: models, generatedDocument: JSONEncoder().encode(result.generated), lease: lease) } + if let plan, !plan.writes.isEmpty { + let decoder = JSONDecoder() + let team = try plan.teamDocument.map { try decoder.decode(ToolingJSONValue.self, from: $0) } + let local = try plan.localDocument.map { try decoder.decode(ToolingJSONValue.self, from: $0) } + // Validate both prepared layers against the same generated snapshot + // before any user document is published. Core performs no writes. + _ = try core.resolveRunConfiguration(at: root, toolchainCandidates: [], teamDocument: team, localDocument: local).get() + try lease.check() + try migration.apply(plan, workspace: root, lease: lease) + } return RunConfigurationGenerationResult(entryCount: result.entryCount) } @@ -119,24 +184,26 @@ struct MacRunConfigurationStore: RunConfigurationOperations, @unchecked Sendable name: value.name, kind: kind, execution: value.execution.flatMap(RunConfigurationExecution.init(rawValue:)), - modulePath: maven?.module == "." ? nil : maven?.module, - mainClass: maven?.mainClass, + modulePath: (maven?.module ?? java?.module) == "." ? nil : (maven?.module ?? java?.module), + mainClass: maven?.mainClass ?? java?.mainClass, sourcePath: java?.source, mavenReactorPath: maven?.reactorPath, debugAdapter: value.debug?.adapter, disabled: value.disabled, - buildProvider: value.extensions?.build + buildProvider: value.extensions?.build, + buildProject: java?.build ), options: RunOptions( javaHomePath: java?.homePath ?? "", workingDirectoryPath: (value.cwd ?? ".") == "." ? "" : (value.cwd ?? "."), - vmArguments: (maven?.jvmArguments ?? []).joined(separator: " "), - programArguments: (maven?.programArguments ?? value.args ?? []).joined(separator: " "), + vmArguments: RunArgumentText.format(maven?.jvmArguments ?? []), + programArguments: RunArgumentText.format(maven?.programArguments ?? value.args ?? []), activeProfiles: Set(maven?.profiles ?? []), mavenSkipTests: maven?.skipTests, mavenExecutablePath: java?.mavenExecutablePath ?? "", mavenJavaHomePath: java?.mavenJavaHomePath ?? "", - environment: value.env ?? [:] + environment: value.env ?? [:], + providerRunOptions: value.extensions?.buildRun ), source: RunConfigurationSource(rawValue: value.source ?? "generated") ?? .generated ) @@ -148,11 +215,19 @@ struct MacRunConfigurationStore: RunConfigurationOperations, @unchecked Sendable projectToolchain: ProjectToolchainSelection( javaHomePath: payload.toolchain?.java?.homePath ?? "", mavenExecutablePath: payload.toolchain?.maven?.executablePath ?? "", - mavenJavaHomePath: payload.toolchain?.maven?.javaHomePath ?? "" + mavenJavaHomePath: payload.toolchain?.maven?.javaHomePath ?? "", + pathOverrides: payload.toolchain?.pathOverrides ?? [:] ) ) } + func providerLaunchInputs(at projectURL: URL, configurationID: String, providerRoot: String) throws -> ProviderLaunchInputs { + switch core.providerLaunchInputs(at: projectURL, configurationID: configurationID, providerRoot: providerRoot) { + case .success(let value): return value + case .failure(let error): throw RunConfigurationOperationFailure(message: error.userMessage) + } + } + func launchPlan( at projectURL: URL, configurationID: String, @@ -251,7 +326,17 @@ struct MacRunConfigurationStore: RunConfigurationOperations, @unchecked Sendable } func saveProjectToolchain(_ toolchain: ProjectToolchainSelection, at projectURL: URL) throws { + let url = projectURL.appendingPathComponent(".lithe/run/local.json") + let expected = storage.fileExists(at: url) ? try storage.readData(from: url, options: []) : nil + try saveProjectToolchain(toolchain, at: projectURL, expecting: expected) + } + + func saveProjectToolchain(_ toolchain: ProjectToolchainSelection, at projectURL: URL, expecting expected: Data?) throws { + try Task.checkCancellation() let root = projectURL.standardizedFileURL + let url = root.appendingPathComponent(".lithe/run/local.json") + let current = storage.fileExists(at: url) ? try storage.readData(from: url, options: []) : nil + guard current == expected else { throw ProjectRuntimeSettingsStoreError.conflict } let payload = try core.updateRunConfigurationOptions( at: root, configurationID: "", @@ -264,12 +349,72 @@ struct MacRunConfigurationStore: RunConfigurationOperations, @unchecked Sendable } // Settings can be saved before generation creates the normal ignore file. // Protect machine paths before making the local document visible to Git. + try Task.checkCancellation() try ensureLocalRunIgnored(at: root) - try writeMutation( - RunConfigurationDocumentMutation(configurationID: nil, document: data), - to: root.appendingPathComponent(".lithe/run/local.json"), - root: root - ) + try validateWriteTarget(url, root: root) + try storage.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try Task.checkCancellation() + guard try storage.replaceDataPreservingMetadata(data, at: url, expecting: expected) else { + throw ProjectRuntimeSettingsStoreError.conflict + } + MacFileWriteEventSuppression.markWritten(url) + } + + /// The generic editor replaces only preferences; it must not generate any + /// legacy language/build defaults or claim those sources were migrated. + func saveToolPreferences(_ preferences: ToolingJSONValue, at projectURL: URL, expecting expected: Data?) throws { + try Task.checkCancellation() + let root = projectURL.standardizedFileURL + let url = root.appendingPathComponent(".lithe/run/local.json") + try Self.validateLocalPreferenceTarget(url, root: root, immutableRoots: immutableRoots) + let current = try MacToolchainSettingsLoader.safeNativeRead(url, maximumBytes: 2_097_152) + guard current == expected else { throw ProjectRuntimeSettingsStoreError.conflict } + let local = try expected.map { try JSONDecoder().decode(ToolingJSONValue.self, from: $0) } + ?? .object(["version": .integer(2), "configurations": .array([])]) + struct Mutation: Decodable { let document: String } + let payload: ToolingJSONValue = .object(["localDocument": local, "preferences": preferences]) + let result: Result = core.executeResult(command: "runConfig.updateToolPreferences", payload: payload) + let data = Data(try result.get().document.utf8) + guard data.count <= 2_097_152 else { throw ToolchainPreferenceError.invalid } + if data == expected { return } + try Task.checkCancellation() + try ensureLocalRunIgnored(at: root) + try Self.validateLocalPreferenceTarget(url, root: root, immutableRoots: immutableRoots) + try storage.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try Task.checkCancellation() + try Self.validateLocalPreferenceTarget(url, root: root, immutableRoots: immutableRoots) + guard try storage.replaceDataPreservingMetadata(data, at: url, expecting: expected) else { throw ProjectRuntimeSettingsStoreError.conflict } + MacFileWriteEventSuppression.markWritten(url) + } + + /// Foundation does not resolve an existing parent link when the leaf is + /// absent. Inspect each existing component through the shared no-follow + /// path adapter so a missing `run` directory cannot hide such an alias. + static func validateLocalPreferenceTarget(_ url: URL, root: URL, immutableRoots: [URL]) throws { + let root = root.standardizedFileURL + let canonical = root.resolvingSymlinksInPath().path + let source = url.standardizedFileURL.path + guard source.hasPrefix(root.path + "/") else { throw ToolchainPreferenceError.invalid } + let relative = String(source.dropFirst(root.path.count + 1)) + let components = try MacWorkspaceSecurePath.components(of: relative) + let target = canonical + "/" + relative + guard !immutableRoots.contains(where: { + let path = $0.standardizedFileURL.resolvingSymlinksInPath().path + return canonical == path || canonical.hasPrefix(path + "/") || target == path || target.hasPrefix(path + "/") + }) else { throw ToolchainPreferenceError.invalid } + var descriptor = try MacWorkspaceSecurePath.openRoot(root) + defer { Darwin.close(descriptor) } + do { + for component in components.dropLast() { + let next = try MacWorkspaceSecurePath.openDirectory([component][...], below: descriptor) + Darwin.close(descriptor); descriptor = next + } + if let name = components.last { + let status = try MacWorkspaceSecurePath.status(of: name, in: descriptor) + guard MacWorkspaceSecurePath.kind(of: status) == .file else { throw ToolchainPreferenceError.invalid } + } + // log-exempt: 预期的缺失:Run 本机文件或其父目录尚不存在时,无需校验最终路径 + } catch PluginWorkspaceFileError.notFound { return } } func saveEditorChanges( @@ -405,7 +550,7 @@ struct MacRunConfigurationStore: RunConfigurationOperations, @unchecked Sendable return RunConfigurationDiagnostic( configurationID: value["id"], code: code, - message: message, + message: code == "staleProviderModel" ? L10n.string("Build provider model changed. Regenerate Run configurations before running.") : message, toolchain: value["toolchain"] ) } @@ -589,6 +734,12 @@ struct MacRunConfigurationStore: RunConfigurationOperations, @unchecked Sendable "Refusing to write outside the project directory." ) } + let resolvedTarget = url.standardizedFileURL.resolvingSymlinksInPath().path + guard resolvedTarget.hasPrefix(resolvedRoot + "/"), !immutableRoots.contains(where: { + let immutable = $0.standardizedFileURL.resolvingSymlinksInPath().path + return resolvedRoot == immutable || resolvedRoot.hasPrefix(immutable + "/") + || resolvedTarget == immutable || resolvedTarget.hasPrefix(immutable + "/") + }) else { throw ProjectRuntimeSettingsStoreError.conflict } } private func configurationKind(_ value: String) -> RunConfigurationKind { diff --git a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacDefinedToolchainResolver.swift b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacDefinedToolchainResolver.swift new file mode 100644 index 0000000000..9f3fc959ba --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacDefinedToolchainResolver.swift @@ -0,0 +1,100 @@ +import Foundation +import LitheCoreContracts +import LitheModuleAPI + +/// Launch-only adapter for installed definitions. It owns no saved preferences and +/// never promotes a settings-page detection into execution authority. +@MainActor +final class MacDefinedToolchainResolver: DefinedToolchainResolving { + var sources: @MainActor () -> [ToolchainSettingsPluginSource] = { [] } + var discovery = MacToolchainDiscovery() + + func resolve(tool: String, directory: String, workspace: URL, environment: [String: String], + minimumVersion: String?, projectCodeAllowed: Bool) async throws -> MacToolchainDiscovery.Selection? { + try await resolve(tool: tool, directory: directory, workspace: workspace, environment: environment, + minimumVersion: minimumVersion, projectCodeAllowed: projectCodeAllowed, fixed: nil) + } + + func resolve(tool: String, directory: String, workspace: URL, environment: [String: String], + minimumVersion: String?, projectCodeAllowed: Bool, + fixed: FixedToolchainSelection?) async throws -> MacToolchainDiscovery.Selection? { + try await resolve(tool: tool, directory: directory, workspace: workspace, environment: environment, + minimumVersion: minimumVersion, projectCodeAllowed: projectCodeAllowed, fixed: fixed, use: .executable) + } + + func resolve(tool: String, directory: String, workspace: URL, environment: [String: String], + minimumVersion: String?, projectCodeAllowed: Bool, + fixed: FixedToolchainSelection?, use: DefinedToolchainUse) async throws -> MacToolchainDiscovery.Selection? { + try await resolve(tool: tool, directory: directory, workspace: workspace, environment: environment, + minimumVersion: minimumVersion, projectCodeAllowed: projectCodeAllowed, fixed: fixed, use: use, consumerID: nil) + } + + func resolve(tool: String, directory: String, workspace: URL, environment: [String: String], + minimumVersion: String?, projectCodeAllowed: Bool, + fixed: FixedToolchainSelection?, use: DefinedToolchainUse, consumerID: String?) async throws -> MacToolchainDiscovery.Selection? { + let installed = sources() + let discovery = self.discovery + let localURL = workspace.appendingPathComponent(".lithe/toolchains/local.json") + let preferencesURL = workspace.appendingPathComponent(".lithe/run/local.json") + let worker = Task.detached(priority: .userInitiated) { + let bytes = try MacToolchainSettingsLoader.safeNativeRead(localURL) + let preferencesBytes = try Self.readPreferences(preferencesURL, workspace: workspace) + let runDocument = try preferencesBytes.map { try JSONDecoder().decode(ToolingJSONValue.self, from: $0) } + if let runDocument { + guard case .object(let document) = runDocument, let version = document["version"], + [ToolingJSONValue.integer(1), .integer(2), .number(1), .number(2)].contains(version) else { + throw BuildToolResolutionError(L10n.string("The selected toolchain is unavailable."), terminal: true) + } + } + let preferences: ToolingJSONValue + if case .object(let document) = runDocument, case .object(let toolchain) = document["toolchain"] { + preferences = toolchain["preferences"] ?? .object(["version": .number(1)]) + } else if runDocument == nil { + preferences = .object(["version": .number(1)]) + } else if case .object(let document) = runDocument, document["toolchain"] == nil { + preferences = .object(["version": .number(1)]) + } else { throw BuildToolResolutionError(L10n.string("The selected toolchain is unavailable."), terminal: true) } + let chosen = try Self.consumerChoice(document: preferences, tool: tool, directory: directory, consumerID: consumerID, fixed: fixed) + let local: ToolingJSONValue + if let bytes { + local = try JSONDecoder().decode(ToolingJSONValue.self, from: bytes) + } else { + local = .object(["version": .number(1), "toolchains": .object([:])]) + } + let result = try await discovery.resolve(.init(workspace: workspace, sources: installed, environment: environment), + tool: tool, directory: directory, minimumVersion: minimumVersion, local: local, + projectCodeAllowed: projectCodeAllowed, fixed: chosen, requiresDirectLaunch: use == .executable) + guard try MacToolchainSettingsLoader.safeNativeRead(localURL) == bytes else { throw CancellationError() } + guard try Self.readPreferences(preferencesURL, workspace: workspace) == preferencesBytes else { throw CancellationError() } + return result + } + let selected = try await withTaskCancellationHandler { try await worker.value } onCancel: { worker.cancel() } + try Task.checkCancellation() + guard sources() == installed else { throw CancellationError() } + return selected + } + + nonisolated private static func readPreferences(_ url: URL, workspace: URL) throws -> Data? { + let root = workspace.resolvingSymlinksInPath().standardizedFileURL.path + let source = url.resolvingSymlinksInPath().standardizedFileURL.path + guard source.hasPrefix(root + "/") else { + throw BuildToolResolutionError(L10n.string("The selected toolchain is unavailable."), terminal: true) + } + return try MacToolchainSettingsLoader.safeNativeRead(url, maximumBytes: 2_097_152) + } + + /// Core retains inheritance semantics; this adapter supplies captured bytes + /// and decodes one inert input before the existing native proof pipeline. + nonisolated static func consumerChoice(document: ToolingJSONValue, tool: String, directory: String, + consumerID: String?, fixed: FixedToolchainSelection?) throws -> FixedToolchainSelection? { + var payload: [String: Any] = ["document": document.foundationObject, "tool": tool, "directory": directory, + "consumer": consumerID as Any? ?? NSNull()] + if let fixed { payload["fixed"] = try JSONSerialization.jsonObject(with: JSONEncoder().encode(fixed)) } + guard let data = MacToolchainDiscovery().core("toolchains.resolveConsumerPreferences", payload), + data["status"] as? String == "ok", let value = data["preferences"] as? [String: Any] else { + throw BuildToolResolutionError(L10n.string("The selected toolchain is unavailable."), terminal: true) + } + guard let fixed = value["fixed"], !(fixed is NSNull) else { return nil } + return try JSONDecoder().decode(FixedToolchainSelection.self, from: JSONSerialization.data(withJSONObject: fixed)) + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacToolchainDiscovery+Providers.swift b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacToolchainDiscovery+Providers.swift index 410ec5f822..497c34e3d2 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacToolchainDiscovery+Providers.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacToolchainDiscovery+Providers.swift @@ -9,7 +9,7 @@ import LitheModuleAPI /// Every reply passes `toolchains.decodeReply` with native facts read here, so a provider /// never decides what a path is. Providers are only asked; nothing here installs or trusts. extension MacToolchainDiscovery { - private struct Provider { + struct Provider { let pluginID: String let id: String let tools: [String] @@ -143,7 +143,7 @@ extension MacToolchainDiscovery { } } - private enum Outcome { + enum Outcome { /// The decoded snapshot, its `decodeReply` input without `filesystem`, and the paths read for it. case success([String: Any], input: [String: Any], paths: [String]) /// No answer in time, or no backend to answer: the error reply's `decodeReply` input, @@ -153,7 +153,7 @@ extension MacToolchainDiscovery { } /// One query and its validation. `scope` is `nil` for a global-only query. - private func query( + func query( _ provider: Provider, operation: String, generation: UInt64, scope: (directory: String, context: WorkspaceContext)?, registry: [String: Any], tools: [[String: Any]], root: String, caseSensitive: Bool @@ -176,7 +176,7 @@ extension MacToolchainDiscovery { var hostFailure: String? do { let value = try await queryProvider(provider.pluginID, provider.id, wire, Self.queryTimeout) - guard let object = value.foundationObject as? [String: Any], object["id"] is NSNumber, + guard let object = value.foundationObject as? [String: Any], (object["id"] is NSNumber || object["id"] is String), let response = object["response"] as? [String: Any] else { return .failure(L10n.string("The reply could not be read.")) } reply = ["id": object["id"]!, "response": response] @@ -258,7 +258,7 @@ extension MacToolchainDiscovery { } /// Providers of enabled definitions, each with the sorted tools it may be asked about. - private static func providers(of tools: [[String: Any]]) -> [Provider] { + static func providers(of tools: [[String: Any]]) -> [Provider] { var byProvider: [String: Set] = [:] for tool in tools { guard let id = tool["tool"] as? String else { continue } diff --git a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacToolchainDiscovery+Resolution.swift b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacToolchainDiscovery+Resolution.swift new file mode 100644 index 0000000000..070ed728dc --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacToolchainDiscovery+Resolution.swift @@ -0,0 +1,193 @@ +import Foundation +import LitheCoreContracts +import LitheModuleAPI + +extension MacToolchainDiscovery { + /// A fresh consumer resolution. Settings detections never grant launch authority. + typealias Selection = ResolvedDefinedToolchain + + /// Captures native evidence, queries installed providers and lets Core apply the + /// four layers. Extra homes contribute probe evidence, never automatic candidates. + func resolve(_ request: Request, tool: String, directory: String, minimumVersion: String?, + local: ToolingJSONValue, projectCodeAllowed: Bool, + fixed: FixedToolchainSelection? = nil, requiresDirectLaunch: Bool = true) async throws -> Selection? { + let plugins: [[String: Any]] = request.sources.filter(\.isEnabled).compactMap { source in + guard let manifest = try? JSONSerialization.jsonObject(with: source.manifest) as? [String: Any], + manifest["toolDefinitions"] != nil || manifest["toolchainProviders"] != nil else { return nil } + return ["manifest": manifest, "verifiedOfficial": source.verifiedOfficial] + } + let registry: [String: Any] = ["plugins": plugins] + guard let plan = core("toolchains.discoveryPlan", ["registry": registry, "platform": "macos"])?["plan"] as? [String: Any], + let tools = plan["tools"] as? [[String: Any]] else { throw Self.unavailable() } + guard let definition = tools.first(where: { $0["tool"] as? String == tool }) else { return nil } + guard let root = Self.realpath(request.workspace.path) else { throw Self.unavailable() } + let caseSensitive = (try? request.workspace.resourceValues(forKeys: [.volumeSupportsCaseSensitiveNamesKey]))? + .volumeSupportsCaseSensitiveNames ?? false + let generation = Self.generations.withLock { value -> UInt64 in value += 1; return value } + let pin = core("toolchains.resolvePins", ["document": local.foundationObject, "directory": directory, "tool": tool]) + guard let pinResolution = pin?["resolution"] as? [String: Any] else { throw Self.unavailable() } + let fixedHome: String? + let fixedExecutable: String? + switch fixed { + case .installation(let home): fixedHome = home; fixedExecutable = nil + case .executable(let path): fixedHome = nil; fixedExecutable = path + case nil: fixedHome = (pinResolution["pin"] as? [String: Any])?["home"] as? String; fixedExecutable = nil + } + var queries: [[String: Any]] = [] + var reported: [[String: Any]] = [] + var context: [String: Any]? + // Explicit pins do not depend on optional lower-layer providers. + if fixedHome == nil && fixed == nil { + let providers = Self.providers(of: [definition]) + guard providers.count <= 8 else { throw Self.unavailable() } + for provider in providers { + guard let current = await workspaceContext(request.workspace, provider.pluginID) else { throw Self.unavailable() } + let identity: [String: Any] = ["contextID": current.contextID, "generation": current.generation] + if let context, !NSDictionary(dictionary: context).isEqual(to: identity) { throw CancellationError() } + context = identity + switch await query(provider, operation: "toolchain-\(generation)", generation: generation, + scope: (directory, current), registry: registry, tools: tools, + root: root, caseSensitive: caseSensitive) { + case .failure: throw Self.unavailable() + case .unavailable(let input, let paths, _): + queries.append(input.merging(["filesystem": paths.map(Self.fact(of:))]) { $1 }) + case .success(let snapshot, let input, let paths): + queries.append(input.merging(["filesystem": paths.map(Self.fact(of:))]) { $1 }) + reported += snapshot["installations"] as? [[String: Any]] ?? [] + } + } + } + try Task.checkCancellation() + // Only declared command names are searched. Core still checks the canonical + // executable through the definition's layouts; PATH does not imply a home. + let normalized = core("toolchains.registry", registry)?["registry"] as? [String: Any] + let entry = (normalized?["definitions"] as? [[String: Any]])?.first { + ($0["definition"] as? [String: Any])?["id"] as? String == tool + } + guard let declaration = entry?["definition"] as? [String: Any] else { throw Self.unavailable() } + // Build's current tool context transports a program, not a template argv. + // Dropping declared wrapper flags would change the plugin's semantics. + guard !requiresDirectLaunch || declaration["launch"] as? [String] == ["${executable}", "${arguments}"] else { throw Self.unavailable() } + let layouts = (definition["executableLayouts"] as? [[String: Any]] ?? []).filter(Self.applies) + .compactMap { $0["path"] as? String } + var commandHomes: [String: String] = [:] + for command in (fixedHome != nil || fixed != nil ? [] : declaration["commands"] as? [String] ?? []) { + for directory in (request.environment["PATH"] ?? "").split(separator: ":", omittingEmptySubsequences: false) { + guard directory.hasPrefix("/") else { continue } + let path = URL(fileURLWithPath: String(directory)).appendingPathComponent(command).path + guard let executable = Self.realpath(path), Self.fact(of: path)["executable"] as? Bool == true else { continue } + if let layout = layouts.first(where: { executable.hasSuffix("/" + $0) }) { + commandHomes[command] = String(executable.dropLast(layout.count + 1)) + } + break + } + } + var scanner = Scanner(home: homeDirectory(), environment: request.environment, workspace: root, caseSensitive: caseSensitive) + var consumerPlan = definition + if fixedHome != nil || fixed != nil { + consumerPlan["scans"] = [] + consumerPlan["environment"] = [] + } + let input = scanner.input(tool: tool, plan: consumerPlan, reported: reported, + additionalHomes: Array(commandHomes.values) + (fixedHome.map { [$0] } ?? [])) + var facts: [String: [String: Any]] = [:] + func add(_ fact: [String: Any]) { + guard let path = fact["path"] as? String else { return } + facts[caseSensitive ? path : path.lowercased()] = fact + } + input.facts.forEach(add) + if let fixedExecutable { add(Self.fact(of: fixedExecutable)) } + for query in queries { (query["filesystem"] as? [[String: Any]] ?? []).forEach(add) } + // Read project candidates and their markers at every exact ancestor. + var ancestor = directory + for _ in 0..<64 { + let anchor = ancestor == "." ? root : root + "/" + ancestor + for candidate in declaration["executables"] as? [[String: Any]] ?? [] where Self.applies(candidate) && fixedHome == nil && fixed == nil { + guard candidate["kind"] as? String == "projectFile", let path = candidate["path"] as? String else { continue } + add(Self.fact(of: anchor + "/" + path)) + for marker in candidate["requires"] as? [String] ?? [] { add(Self.fact(of: anchor + "/" + marker)) } + if let home = candidate["home"] as? String { add(Self.fact(of: home == "." ? anchor : anchor + "/" + home)) } + } + if ancestor == "." { break } + ancestor = ancestor.lastIndex(of: "/").map { String(ancestor[..<$0]) } ?? "." + } + guard facts.count <= Self.factBudget else { throw Self.unavailable() } + let captured = facts.keys.sorted().compactMap { facts[$0] } + let identities = captured.compactMap { $0["path"] as? String }.map { + MacRuntimeProbeCache.key(executable: URL(fileURLWithPath: $0)) + } + for index in queries.indices { queries[index]["filesystem"] = captured } + var payload: [String: Any] = [ + "registry": registry, "tool": tool, "directory": directory, "operationID": "consumer-\(generation)", + "generation": generation, "context": context as Any? ?? NSNull(), "platform": "macos", + "filesystemCaseSensitive": caseSensitive, "workspaceRoot": root, "workspaceRoots": [root], + "filesystem": captured, "local": local.foundationObject, "environment": input.environment, + "commandHomes": commandHomes, "systemInstallations": input.installations, "queries": queries, + "projectCodeAllowed": projectCodeAllowed, "probes": [] + ] + if let minimumVersion { payload["minimumVersion"] = minimumVersion } + var probeInput: [String: Any] = [ + "registry": registry, "tool": tool, "platform": "macos", "filesystemCaseSensitive": caseSensitive, + "workspaceRoot": root, "filesystem": captured, "environment": input.environment, + "systemInstallations": input.installations + (Array(commandHomes.values) + (fixedHome.map { [$0] } ?? [])).map { ["tool": tool, "home": $0] }, + "providerInstallations": input.reported, "probes": [] + ] + if let fixed { + let value = try JSONSerialization.jsonObject(with: JSONEncoder().encode(fixed)) + payload["fixed"] = value + probeInput["fixed"] = value + probeInput["projectCodeAllowed"] = projectCodeAllowed + } + guard let candidates = core("toolchains.candidates", probeInput)?["candidates"] as? [String: Any] else { throw Self.unavailable() } + var targets = Self.probeTargets(candidates) + if projectCodeAllowed { + // Core first proves a project launcher without asking it for a version. + // Only this explicit consumer action may probe that checked launcher; + // the final resolution still enforces the original minimum version. + var beforeVersion = payload + beforeVersion.removeValue(forKey: "minimumVersion") + let provisional = core("toolchains.resolve", beforeVersion)?["resolution"] as? [String: Any] + let outcome = provisional?["outcome"] as? [String: Any] + let selected = outcome?["selection"] as? [String: Any] + if outcome?["kind"] as? String == "resolved", selected?["projectCode"] as? Bool == true, + let executable = selected?["executable"] as? String { + targets = minimumVersion == nil ? [] : [executable] + } + } + guard targets.count <= Self.probeBudget else { throw Self.unavailable() } + var probing = self + let consumerProbe = self.consumerProbe + probing.probe = { executable, arguments in consumerProbe(executable, arguments, request.environment) } + let observed = await probing.probe(targets.map { Target(tool: tool, executable: $0, arguments: input.arguments) }) + try Task.checkCancellation() + guard !observed.expired else { throw Self.unavailable() } + if let context { + for provider in Self.providers(of: [definition]) { + guard let current = await workspaceContext(request.workspace, provider.pluginID), + current.contextID == context["contextID"] as? String, + current.generation == context["generation"] as? Int else { throw CancellationError() } + } + } + payload["probes"] = observed.outputs.map { ["executable": $0.key.executable, "output": $0.value, "exitCode": 0] as [String: Any] } + // Re-read native facts after asynchronous work. A changed path cannot retain + // the earlier canonical-path or executable attestation. + guard captured.allSatisfy({ fact in + guard let path = fact["path"] as? String else { return false } + return NSDictionary(dictionary: fact).isEqual(to: Self.fact(of: path)) + }), identities.allSatisfy({ MacRuntimeProbeCache.key(executable: URL(fileURLWithPath: $0.executablePath)) == $0 }) + else { throw CancellationError() } + guard let resolution = core("toolchains.resolve", payload)?["resolution"] as? [String: Any], + let outcome = resolution["outcome"] as? [String: Any] else { throw Self.unavailable() } + guard outcome["kind"] as? String == "resolved", let selected = outcome["selection"] as? [String: Any], + let executable = selected["executable"] as? String else { throw Self.unavailable() } + var environment = request.environment.merging(selected["environment"] as? [String: String] ?? [:]) { _, new in new } + let prepend = selected["pathPrepend"] as? [String] ?? [] + if !prepend.isEmpty { environment["PATH"] = (prepend + [environment["PATH"] ?? ""]).joined(separator: ":") } + return Selection(executable: URL(fileURLWithPath: executable), environment: environment, + projectCode: selected["projectCode"] as? Bool == true, version: selected["version"] as? String) + } + + private static func unavailable() -> BuildToolResolutionError { + BuildToolResolutionError(L10n.string("The selected toolchain is unavailable."), terminal: true) + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacToolchainDiscovery.swift b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacToolchainDiscovery.swift index 73ad9cc96f..9d5606060b 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacToolchainDiscovery.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacToolchainDiscovery.swift @@ -81,6 +81,13 @@ struct MacToolchainDiscovery: Sendable { var core: @Sendable (String, [String: Any]) -> [String: Any]? = Self.callCore /// The output of a successful probe; failures are not cached and Core reports them. var probe: @Sendable (_ executable: String, _ arguments: [String]) -> String? = Self.runProbe + /// Consumer probes inherit the exact environment selected for this operation. + var consumerProbe: @Sendable (String, [String], [String: String]) -> String? = { executable, arguments, environment in + let result = MacProcessRunner().run(ProcessRequest(executablePath: executable, arguments: arguments, + environment: environment, timeoutMilliseconds: 5_000)) + guard result.succeeded, result.output.utf8.count <= probeOutputLimit else { return nil } + return result.output + } /// Asks a plugin's provider and returns `{ id, response }`; `nil` leaves providers unqueried. var logSink: (any LogSink)? = nil var queryProvider: (@Sendable (_ pluginID: String, _ providerID: String, _ params: ToolingJSONValue, @@ -277,7 +284,7 @@ struct MacToolchainDiscovery: Sendable { /// Native evidence for one tool: its scanned homes, the declared login-shell variables /// and the facts Core needs to check both. - private struct Input { + struct Input { let tool: String let arguments: [String] let installations: [[String: Any]] @@ -291,7 +298,7 @@ struct MacToolchainDiscovery: Sendable { let locations: [String: String] } - private struct Target: Hashable { + struct Target: Hashable { let tool: String let executable: String let arguments: [String] @@ -313,7 +320,7 @@ struct MacToolchainDiscovery: Sendable { } /// Executables Core checked but could not version without a probe. - private static func probeTargets(_ value: [String: Any]) -> [String] { + static func probeTargets(_ value: [String: Any]) -> [String] { let rows = (value["skipped"] as? [[String: Any]] ?? []) + (value["shell"] as? [[String: Any]] ?? []) var seen: Set = [] return rows.compactMap { row in @@ -325,7 +332,7 @@ struct MacToolchainDiscovery: Sendable { } /// Runs probes a few at a time; once the time budget is spent, no new probe starts. - private func probe(_ targets: [Target]) async -> (outputs: [Target: String], expired: Bool) { + func probe(_ targets: [Target]) async -> (outputs: [Target: String], expired: Bool) { guard !targets.isEmpty else { return ([:], false) } let deadline = ContinuousClock.now + Self.probeTimeBudget let probe = self.probe @@ -374,7 +381,7 @@ struct MacToolchainDiscovery: Sendable { } /// Expands scans under shared budgets and records each path's native metadata once. - private struct Scanner { + struct Scanner { let home: String let environment: [String: String] let workspace: String @@ -391,7 +398,7 @@ struct MacToolchainDiscovery: Sendable { self.caseSensitive = caseSensitive } - mutating func input(tool: String, plan: [String: Any], reported: [[String: Any]], pinned: [String] = []) -> Input { + mutating func input(tool: String, plan: [String: Any], reported: [[String: Any]], pinned: [String] = [], additionalHomes: [String] = []) -> Input { var facts = FactSet(caseSensitive: caseSensitive) facts.add(fact(workspace)) let layouts = (plan["executableLayouts"] as? [[String: Any]] ?? []).filter(MacToolchainDiscovery.applies) @@ -425,7 +432,7 @@ struct MacToolchainDiscovery: Sendable { variables[variable] = value } let reportedHomes = reported.compactMap { $0["home"] as? String } - for home in homes + reportedHomes + pinned.map(Self.normalized) + variables.values.map(Self.normalized) { + for home in homes + reportedHomes + pinned.map(Self.normalized) + variables.values.map(Self.normalized) + additionalHomes.map(Self.normalized) { let found = fact(home) facts.add(found) let canonical = found["kind"] as? String == "directory" ? found["canonicalPath"] as? String : nil @@ -594,7 +601,7 @@ struct MacToolchainDiscovery: Sendable { return remaining.isEmpty } - private static func realpath(_ path: String) -> String? { + static func realpath(_ path: String) -> String? { guard let resolved = Darwin.realpath(path, nil) else { return nil } defer { free(resolved) } return String(cString: resolved) diff --git a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacToolchainPreferencesStore.swift b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacToolchainPreferencesStore.swift new file mode 100644 index 0000000000..a3036652e1 --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacToolchainPreferencesStore.swift @@ -0,0 +1,95 @@ +import CryptoKit +import Foundation +import LitheCoreContracts +import LitheModuleAPI + +/// Settings edits use the existing Run document and conditional native writer. +/// No probe or process starts here; a saved path remains an inert input which +/// the normal toolchain resolver rechecks when a consumer actually runs. +@MainActor +final class MacToolchainPreferencesStore: ToolchainPreferencesStoring { + private let core: RustCoreBridge + private let storage: any FileStorage + private let immutableRoots: @MainActor () -> [URL] + + init(core: RustCoreBridge, storage: any FileStorage, immutableRoots: @escaping @MainActor () -> [URL]) { + self.core = core; self.storage = storage; self.immutableRoots = immutableRoots + } + + func load(workspace: URL) async throws -> ToolchainPreferencesSnapshot { + let core = self.core, storage = self.storage, roots = immutableRoots() + return try await worker { try Self.read(workspace: workspace, core: core, storage: storage, immutable: roots).snapshot } + } + + func inspect(_ address: ToolchainPreferenceAddress, snapshot: ToolchainPreferencesSnapshot) throws -> ToolchainPreferenceReading { + let payload: ToolingJSONValue = .object(["document": snapshot.document, "directory": .string(address.directory), + "consumer": address.consumer.map(ToolingJSONValue.string) ?? .null, "tool": .string(address.tool)]) + struct Outcome: Decodable { let status: String; let preferences: Reading? } + struct Reading: Decodable { let stored: ToolingJSONValue?; let fixed: FixedToolchainSelection?; let source: Source } + struct Source: Decodable { let kind: String } + let result: Result = core.executeResult(command: "toolchains.resolveConsumerPreferences", payload: payload) + let outcome = try result.get() + guard outcome.status == "ok", let reading = outcome.preferences else { throw ToolchainSettingsLoadError.invalidLocal } + return .init(stored: reading.stored == .null ? nil : reading.stored, fixed: reading.fixed, source: reading.source.kind) + } + + func save(_ value: ToolingJSONValue?, at address: ToolchainPreferenceAddress, + expecting snapshot: ToolchainPreferencesSnapshot) async throws -> ToolchainPreferencesSnapshot { + let core = self.core, storage = self.storage, roots = immutableRoots() + return try await worker { + let before = try Self.read(workspace: snapshot.workspace, core: core, storage: storage, immutable: roots) + guard before.snapshot.revision == snapshot.revision else { throw ProjectRuntimeSettingsStoreError.conflict } + struct Outcome: Decodable { let status: String; let edit: Edit? } + struct Edit: Decodable { let document: ToolingJSONValue } + let payload: ToolingJSONValue = .object(["document": before.snapshot.document, "directory": .string(address.directory), + "consumer": address.consumer.map(ToolingJSONValue.string) ?? .null, "tool": .string(address.tool), "value": value ?? .null]) + let result: Result = core.executeResult(command: "toolchains.editConsumerPreferences", payload: payload) + let outcome = try result.get() + guard outcome.status == "ok", let edit = outcome.edit else { throw ToolchainSettingsLoadError.invalidLocal } + if edit.document == before.snapshot.document { return before.snapshot } + try Task.checkCancellation() + let store = MacRunConfigurationStore(core: core, storage: storage, preferences: MacUserDefaultsStore(), immutableRoots: roots) + try store.saveToolPreferences(edit.document, at: snapshot.workspace, expecting: before.bytes) + let after = try Self.read(workspace: snapshot.workspace, core: core, storage: storage, immutable: roots) + guard after.snapshot.document == edit.document else { throw ProjectRuntimeSettingsStoreError.readBack } + return after.snapshot + } + } + + private func worker(_ operation: @escaping @Sendable () throws -> T) async throws -> T { + let task = Task.detached(priority: .userInitiated) { try operation() } + return try await withTaskCancellationHandler { try await task.value } onCancel: { task.cancel() } + } + + private struct Reading: Sendable { + let snapshot: ToolchainPreferencesSnapshot + let bytes: Data? + } + + nonisolated private static func read(workspace: URL, core: RustCoreBridge, storage: any FileStorage, immutable: [URL]) throws -> Reading { + try Task.checkCancellation() + let workspace = workspace.standardizedFileURL + try MacRunConfigurationStore.validateLocalPreferenceTarget(workspace.appendingPathComponent(".lithe/run/local.json"), + root: workspace, immutableRoots: [Bundle.main.bundleURL] + immutable) + let canonical = workspace.resolvingSymlinksInPath().path + let url = workspace.appendingPathComponent(".lithe/run/local.json") + let target = url.resolvingSymlinksInPath().standardizedFileURL.path + guard target.hasPrefix(canonical + "/"), !([Bundle.main.bundleURL] + immutable).contains(where: { + let path = $0.resolvingSymlinksInPath().standardizedFileURL.path + return canonical == path || canonical.hasPrefix(path + "/") || target == path || target.hasPrefix(path + "/") + }) else { throw ToolchainSettingsLoadError.readFailed } + let bytes = try MacToolchainSettingsLoader.safeNativeRead(url, maximumBytes: 2_097_152) + let store = MacRunConfigurationStore(core: core, storage: storage, preferences: MacUserDefaultsStore(), immutableRoots: immutable) + let inspection = store.inspect(at: workspace, checkFingerprint: false, javaEntrypoints: nil) + if case .invalid = inspection.status { throw ToolchainSettingsLoadError.invalidLocal } + guard try MacToolchainSettingsLoader.safeNativeRead(url, maximumBytes: 2_097_152) == bytes else { throw ProjectRuntimeSettingsStoreError.conflict } + let run = try bytes.map { try JSONDecoder().decode(ToolingJSONValue.self, from: $0) } + let document: ToolingJSONValue + if case .object(let object) = run, case .object(let toolchain) = object["toolchain"] { + document = toolchain["preferences"] ?? .object(["version": .integer(1)]) + } else { document = .object(["version": .integer(1)]) } + var identity = Data(canonical.utf8); identity.append(0); identity.append(bytes == nil ? 0 : 1); identity.append(bytes ?? Data()) + let revision = SHA256.hash(data: identity).map { String(format: "%02x", $0) }.joined() + return Reading(snapshot: .init(workspace: workspace, document: document, revision: revision), bytes: bytes) + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacToolchainSettingsLoader.swift b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacToolchainSettingsLoader.swift index 27e30ab599..3e28bf08ae 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacToolchainSettingsLoader.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacToolchainSettingsLoader.swift @@ -21,13 +21,14 @@ struct ToolchainSettingsSavedPin: Equatable, Sendable { } enum ToolchainSettingsLoadError: LocalizedError, Equatable { - case readFailed, invalidLocal, invalidDeclarations + case readFailed, invalidLocal, invalidDeclarations, sourceChanged var errorDescription: String? { switch self { case .readFailed: L10n.string("Could not read toolchain settings.") case .invalidLocal: L10n.string("The saved toolchain settings are invalid.") case .invalidDeclarations: L10n.string("The installed toolchain declarations are invalid.") + case .sourceChanged: L10n.string("Toolchain settings changed while loading. Refresh and try again.") } } } @@ -42,6 +43,7 @@ struct MacToolchainSettingsLoader: ToolchainSettingsLoading { static let rowBudget = 4_096 private let readLocalData: @Sendable (URL) throws -> Data? + private let readPreferenceData: @Sendable (URL) throws -> Data? private let sources: @MainActor @Sendable () -> [ToolchainSettingsPluginSource] private let resolvePin: @Sendable (Data, String, String) throws -> ToolchainSettingsSavedPin? private let directories: @MainActor @Sendable (URL?) -> [String] @@ -62,6 +64,7 @@ struct MacToolchainSettingsLoader: ToolchainSettingsLoading { fileExists: @escaping @Sendable (URL) -> Bool = Self.regularFileExists, isExecutable: @escaping @Sendable (String) -> Bool = Self.executableExists, readLocalData: (@Sendable (URL) throws -> Data?)? = nil, + readPreferenceData: @escaping @Sendable (URL) throws -> Data? = Self.safeNativePreferenceRead, discover: @escaping @Sendable (MacToolchainDiscovery.Request) async -> MacToolchainDiscovery.Result = { await MacToolchainDiscovery().discover($0) } @@ -69,6 +72,7 @@ struct MacToolchainSettingsLoader: ToolchainSettingsLoading { // FileStorage's generic prefix reader may open special files; production // reads use a checked nonblocking native descriptor instead. self.readLocalData = readLocalData ?? Self.safeNativeRead + self.readPreferenceData = readPreferenceData self.sources = sources self.directories = directories self.resolvePin = resolvePin @@ -87,10 +91,18 @@ struct MacToolchainSettingsLoader: ToolchainSettingsLoading { } let installed = await sources() let directoryIDs = await directories(workspace) - let io = IO(readLocalData: readLocalData, resolvePin: resolvePin, environment: environment, + let io = IO(readLocalData: readLocalData, readPreferenceData: readPreferenceData, resolvePin: resolvePin, environment: environment, fileExists: fileExists, isExecutable: isExecutable, discover: discover) let worker = Task.detached(priority: .userInitiated) { - try await Self.snapshot(workspace: workspace, sources: installed, directoryIDs: directoryIDs, io: io) + do { + return try await Self.snapshot(workspace: workspace, sources: installed, directoryIDs: directoryIDs, io: io) + } catch ToolchainSettingsLoadError.sourceChanged { + // A first load has no input observation yet. Retry once with + // fresh bytes; continuous edits produce a visible refreshable + // error rather than a blank idle page or an unbounded loop. + try Task.checkCancellation() + return try await Self.snapshot(workspace: workspace, sources: installed, directoryIDs: directoryIDs, io: io) + } } return try await withTaskCancellationHandler { let snapshot = try await worker.value @@ -103,6 +115,7 @@ struct MacToolchainSettingsLoader: ToolchainSettingsLoading { private struct IO: Sendable { let readLocalData: @Sendable (URL) throws -> Data? + let readPreferenceData: @Sendable (URL) throws -> Data? let resolvePin: @Sendable (Data, String, String) throws -> ToolchainSettingsSavedPin? let environment: @Sendable () -> [String: String] let fileExists: @Sendable (URL) -> Bool @@ -165,6 +178,26 @@ struct MacToolchainSettingsLoader: ToolchainSettingsLoading { } guard definitions.count <= 256 else { throw ToolchainSettingsLoadError.invalidDeclarations } definitions.sort { $0.id < $1.id } + var consumers: [String: [ToolchainSettingsSnapshot.Tool.Consumer]] = [:] + for source in enabled { + let manifest = try JSONSerialization.jsonObject(with: source.manifest) as? [String: Any] ?? [:] + for build in try contribution(manifest, key: "buildSystems", maximum: 32) { + guard let id = text(build["id"], maximum: 64), let title = text(build["displayName"], maximum: 128) else { + throw ToolchainSettingsLoadError.invalidDeclarations + } + let target = BuildSystemTarget(manifest: source.manifest, buildSystemID: id) + // The shared invocation plan owns platform filtering and tool + // references. Reading metadata must never probe or start a task. + guard let plan = try? RustBuildSystemOperations(core: RustCoreBridge()).invocationPlan(target, root: ".") else { continue } + let tools = Set((plan.executables + plan.runtimeDependencies.flatMap(\.executables)).compactMap { candidate -> String? in + if case .toolchain(let id, _) = candidate { return id } + return nil + }) + for tool in tools where knownTools.contains(tool) { + consumers[tool, default: []].append(.init(id: source.pluginID + "/" + id, title: title, pluginName: source.name)) + } + } + } // Disabled definitions also disappear from provider tool capsules; no provider is invoked. providers = providers.map { provider in var result = provider @@ -175,13 +208,18 @@ struct MacToolchainSettingsLoader: ToolchainSettingsLoading { return .init(providers: providers) } let document = try localDocument(workspace: workspace, readLocalData: io.readLocalData) + let preferenceURL = workspace.appendingPathComponent(".lithe/run/local.json") + let preferenceBytes = try io.readPreferenceData(preferenceURL) + let preferenceDirectories: [String] + do { preferenceDirectories = try Self.preferenceDirectories(preferenceBytes) } + catch { throw ToolchainSettingsLoadError.invalidLocal } // Core validates every dormant pin as well as the requested directory. if let document { _ = try io.resolvePin(document, ".", "toolchain-settings-validation") } let pinDirectories = document.flatMap { (try? JSONSerialization.jsonObject(with: $0) as? [String: Any])?["pins"] as? [String: Any] }.map { Array($0.keys) } ?? [] guard directoryIDs.count <= 1_024 else { throw ToolchainSettingsLoadError.invalidLocal } - for id in directoryIDs + pinDirectories { + for id in directoryIDs + pinDirectories + preferenceDirectories { guard validDirectory(id) else { throw ToolchainSettingsLoadError.invalidLocal } } var diagnostics: [String] = [] @@ -189,7 +227,7 @@ struct MacToolchainSettingsLoader: ToolchainSettingsLoading { fileExists: io.fileExists, diagnostics: &diagnostics) // Only directories with their own facts are listed, with the ancestors that connect them. var listed: Set = ["."] - for id in Array(found.keys) + pinDirectories { + for id in Array(found.keys) + pinDirectories + preferenceDirectories { var current = id while current != "." { listed.insert(current) @@ -206,7 +244,7 @@ struct MacToolchainSettingsLoader: ToolchainSettingsLoading { ToolchainSettingsSnapshot.Directory(id: id, title: id == "." ? workspace.lastPathComponent : String(id.split(separator: "/").last ?? ""), depth: id == "." ? 0 : id.split(separator: "/").count, - versionFiles: found[id] ?? []) + versionFiles: found[id] ?? [], hasToolPreferences: preferenceDirectories.contains(id)) } let environment = io.environment() let candidates = definitions.map { candidate(for: $0, environment: environment, isExecutable: io.isExecutable) } @@ -215,6 +253,7 @@ struct MacToolchainSettingsLoader: ToolchainSettingsLoading { directories: ["."] + found.keys.filter { $0 != "." }.sorted(by: treeOrder), local: document)) try Task.checkCancellation() + guard try io.readPreferenceData(preferenceURL) == preferenceBytes else { throw ToolchainSettingsLoadError.sourceChanged } diagnostics += discovered.diagnostics for definition in definitions where discovered.unchecked.contains(definition.id) { diagnostics.append(L10n.string("Installations of \(definition.name) could not be checked.")) @@ -246,7 +285,7 @@ struct MacToolchainSettingsLoader: ToolchainSettingsLoading { versionFiles: files, requirements: discovered.requirements[queried]?[definition.id] ?? [], globalRequirements: discovered.globalRequirements[definition.id] ?? [], candidate: candidate, detection: discovered.detections[definition.id], - resolution: discovered.resolutions[resolved]?[definition.id])) + resolution: discovered.resolutions[resolved]?[definition.id], consumers: consumers[definition.id] ?? [])) } } let queried = providers.map { provider in @@ -254,8 +293,44 @@ struct MacToolchainSettingsLoader: ToolchainSettingsLoading { result.queryState = discovered.providerStates[provider.id] ?? .notQueried return result } + try Task.checkCancellation() + guard try io.readPreferenceData(preferenceURL) == preferenceBytes else { throw ToolchainSettingsLoadError.sourceChanged } return .init(workspaceURL: workspace, directories: directories, tools: rows, providers: queried, - diagnostics: diagnostics, watch: discovered.watch) + diagnostics: diagnostics, watch: Array(Set(discovered.watch + [preferenceURL.path])).sorted()) + } + + /// Directory metadata uses the same inert document validator as consumers; + /// Run parameters and environment values never become settings rows. + private static func preferenceDirectories(_ bytes: Data?) throws -> [String] { + guard let bytes else { return [] } + guard bytes.count <= 2_097_152 else { throw ToolchainSettingsLoadError.invalidLocal } + guard case .object(let run) = try JSONDecoder().decode(ToolingJSONValue.self, from: bytes), + let version = run["version"], [.integer(1), .integer(2), .number(1), .number(2)].contains(version) else { + throw ToolchainSettingsLoadError.invalidLocal + } + guard let raw = run["toolchain"] else { return [] } + guard case .object(let toolchain) = raw else { throw ToolchainSettingsLoadError.invalidLocal } + guard let document = toolchain["preferences"] else { return [] } + struct Outcome: Decodable { let status: String } + let result: Result = RustCoreBridge().executeResult( + command: "toolchains.resolveConsumerPreferences", payload: ToolingJSONValue.object([ + "document": document, "tool": .string("toolchain-settings-validation"), "directory": .string(".")])) + guard try result.get().status == "ok", case .object(let root) = document else { throw ToolchainSettingsLoadError.invalidLocal } + if case .object(let directories) = root["directories"] { return directories.keys.sorted() } + return [] + } + + @Sendable static func safeNativePreferenceRead(_ url: URL) throws -> Data? { + let root = url.deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + do { + var status = stat() + if stat(root.path, &status) == 0 { + try MacRunConfigurationStore.validateLocalPreferenceTarget(url, root: root, immutableRoots: [Bundle.main.bundleURL]) + } + return try safeNativeRead(url, maximumBytes: 2_097_152) + } catch is CancellationError { throw CancellationError() } + catch let error as ToolchainSettingsLoadError { throw error } + catch { throw ToolchainSettingsLoadError.readFailed } } /// Literal provider watch files that exist as regular files; glob patterns stay with the provider. @@ -386,6 +461,13 @@ struct MacToolchainSettingsLoader: ToolchainSettingsLoading { /// Refuses links and special files before any read, including FIFO targets. static func safeNativeRead(_ url: URL) throws -> Data? { + try safeNativeRead(url, maximumBytes: 1_048_576) + } + + /// Run documents also contain unrelated configurations. Their native + /// document owner permits 2 MiB; pin files retain their smaller limit. + static func safeNativeRead(_ url: URL, maximumBytes: Int) throws -> Data? { + guard maximumBytes > 0, maximumBytes <= 2_097_152 else { throw ToolchainSettingsLoadError.invalidLocal } try Task.checkCancellation() let descriptor = url.withUnsafeFileSystemRepresentation { path -> Int32 in guard let path else { return -1 } @@ -400,12 +482,12 @@ struct MacToolchainSettingsLoader: ToolchainSettingsLoading { guard fstat(descriptor, &status) == 0, status.st_mode & S_IFMT == S_IFREG else { throw ToolchainSettingsLoadError.readFailed } - guard status.st_size <= 1_048_576 else { throw ToolchainSettingsLoadError.invalidLocal } + guard status.st_size <= maximumBytes else { throw ToolchainSettingsLoadError.invalidLocal } var data = Data() var buffer = [UInt8](repeating: 0, count: 65_536) - while data.count <= 1_048_576 { + while data.count <= maximumBytes { try Task.checkCancellation() - let wanted = min(buffer.count, 1_048_577 - data.count) + let wanted = min(buffer.count, maximumBytes + 1 - data.count) let count = buffer.withUnsafeMutableBytes { Darwin.read(descriptor, $0.baseAddress, wanted) } if count < 0 { if errno == EINTR { continue } diff --git a/macos/Sources/Lithe/Services/Debug/DebugLaunchConfigurationResolver.swift b/macos/Sources/Lithe/Services/Debug/DebugLaunchConfigurationResolver.swift index ac04e3547f..a5690c0bb8 100644 --- a/macos/Sources/Lithe/Services/Debug/DebugLaunchConfigurationResolver.swift +++ b/macos/Sources/Lithe/Services/Debug/DebugLaunchConfigurationResolver.swift @@ -62,6 +62,14 @@ enum DebugJavaRuntime: Equatable { } } +/// The adapter registry owns the binding throughout process preparation and attach. +@MainActor +struct PreparedProviderConnectionDebug { + let options: BuildLaunchOptions.Debug + let configuration: DebugLaunchConfiguration + let isCurrent: @MainActor @Sendable () -> Bool +} + /// Maps an editor target and the language-neutral run model into adapter launch /// arguments. It contains no process or UI code, so platform composition can /// supply only the Java runtime and plugin launch capabilities. @@ -74,6 +82,7 @@ struct DebugLaunchConfigurationResolver { /// when no adapter serves the language. private let pluginLaunch: (@MainActor (_ languageID: String, _ documentURL: URL, _ workspaceURL: URL) throws -> DebugLaunchConfiguration?)? + private let providerConnection: (@MainActor (String, String?, UInt16) throws -> PreparedProviderConnectionDebug)? private let providerLaunch: (@MainActor (BuildSystemPluginSource, BuildDebugPreparation, URL, String) throws -> DebugLaunchConfiguration)? @MainActor @@ -83,12 +92,20 @@ struct DebugLaunchConfigurationResolver { return try providerLaunch(source, preparation, workspaceURL, title) } + @MainActor + func resolveProviderConnection(configurationType: String, adapterID: String?, port: UInt16) throws -> PreparedProviderConnectionDebug { + guard let providerConnection else { throw DebugLaunchConfigurationResolutionError.providerDebugUnavailable } + return try providerConnection(configurationType, adapterID, port) + } + init( javaTestLaunchResolver: (any JavaTestDebugLaunchResolving)? = nil, + providerConnection: (@MainActor (String, String?, UInt16) throws -> PreparedProviderConnectionDebug)? = nil, providerLaunch: (@MainActor (BuildSystemPluginSource, BuildDebugPreparation, URL, String) throws -> DebugLaunchConfiguration)? = nil, javaExecutablePath: (@MainActor (DebugJavaRuntime) -> String?)? = nil, pluginLaunch: (@MainActor (String, URL, URL) throws -> DebugLaunchConfiguration?)? = nil ) { + self.providerConnection = providerConnection self.providerLaunch = providerLaunch self.pluginLaunch = pluginLaunch self.javaTestLaunchResolver = javaTestLaunchResolver @@ -181,7 +198,8 @@ struct DebugLaunchConfigurationResolver { arguments: [ "hostName": .string(normalizedHost), "port": .integer(port) - ] + ], + adapterProviderID: "java" ) } diff --git a/macos/Sources/Lithe/Services/Debug/DebugLaunchSourceResolver.swift b/macos/Sources/Lithe/Services/Debug/DebugLaunchSourceResolver.swift index e540f23798..1e3e6313b0 100644 --- a/macos/Sources/Lithe/Services/Debug/DebugLaunchSourceResolver.swift +++ b/macos/Sources/Lithe/Services/Debug/DebugLaunchSourceResolver.swift @@ -58,15 +58,18 @@ struct DebugLaunchSourceResolver { .sorted { $0.path < $1.path } guard !javaFiles.isEmpty else { return nil } + // Provider projects carry a generic instance root. Their module paths + // are relative to that root, just like the legacy reactor projection. + let ownerRoot = configuration.buildProject?.root ?? configuration.mavenReactorPath let moduleFiles = filesInSelectedModule( javaFiles, modulePath: configuration.modulePath, - workspaceURL: configuration.mavenReactorPath.map { + workspaceURL: ownerRoot.map { workspaceURL.appendingPathComponent($0, isDirectory: true) } ?? workspaceURL ) - // A detected Maven owner must never fall back to another reactor's class. - let isOwned = configuration.mavenReactorPath != nil + // A detected owner must never fall back to another instance's class. + let isOwned = ownerRoot != nil let preferredFiles = moduleFiles.isEmpty && !isOwned ? javaFiles : moduleFiles if let sourceSuffix = sourceSuffix(for: configuration.mainClass), diff --git a/macos/Sources/Lithe/Services/Debug/DebugPortAvailabilityChecker.swift b/macos/Sources/Lithe/Services/Debug/DebugPortAvailabilityChecker.swift index dd3dc0b041..13ba87f7ec 100644 --- a/macos/Sources/Lithe/Services/Debug/DebugPortAvailabilityChecker.swift +++ b/macos/Sources/Lithe/Services/Debug/DebugPortAvailabilityChecker.swift @@ -6,6 +6,17 @@ import Foundation @MainActor protocol DebugPortAvailabilityChecking: AnyObject { func isPortAvailable(_ port: Int) -> Bool + func unusedLoopbackPort() throws -> UInt16 + func waitForLoopbackListener(_ port: UInt16, processID: @escaping @MainActor () -> Int32?, isCurrent: @escaping @MainActor () -> Bool) async throws +} + +extension DebugPortAvailabilityChecking { + func unusedLoopbackPort() throws -> UInt16 { + throw DebugLaunchConfigurationResolutionError.providerDebugUnavailable + } + func waitForLoopbackListener(_ port: UInt16, processID: @escaping @MainActor () -> Int32?, isCurrent: @escaping @MainActor () -> Bool) async throws { + throw DebugLaunchConfigurationResolutionError.providerDebugUnavailable + } } @MainActor diff --git a/macos/Sources/Lithe/Services/Java/ProjectRuntimeService.swift b/macos/Sources/Lithe/Services/Java/ProjectRuntimeService.swift index 4a5ef639d3..20b9f1b70e 100644 --- a/macos/Sources/Lithe/Services/Java/ProjectRuntimeService.swift +++ b/macos/Sources/Lithe/Services/Java/ProjectRuntimeService.swift @@ -53,12 +53,6 @@ extension ProjectRuntimeService: LanguageToolRuntimePort { } } -extension ProjectRuntimeService: MavenRuntimePort { - package func mavenProcessEnvironment(javaHomePath: String?) -> [String: String] { - environment(for: .maven, javaHomeOverride: javaHomePath) - } -} - @MainActor final class ProjectRuntimeService: ObservableObject { enum JavaLanguageServerRuntimePreparation: Equatable { @@ -73,6 +67,8 @@ final class ProjectRuntimeService: ObservableObject { @Published private(set) var javaEnvironmentReport: JavaEnvironmentReport? @Published private(set) var isDiscovering = false @Published private(set) var settings = ProjectRuntimeSettings() + @Published private(set) var settingsError: String? + private(set) var settingsSnapshot: ProjectRuntimeSettingsSnapshot? private var activeServiceJavaHomePath = "" private var launchJavaRuntimes: [JavaRuntimeCandidate]? private var runtimeSessionID = UUID() @@ -80,7 +76,7 @@ final class ProjectRuntimeService: ObservableObject { private let javaSelector: any JavaRuntimeSelecting private let localRuntimeLocator: any RuntimeLocator private var runtimeLocator: any RuntimeLocator - private let store: any KeyValueStore + private let settingsStore: any ProjectRuntimeSettingsStoring private let localToolDiscovery: any RuntimeToolDiscovery private var toolDiscovery: any RuntimeToolDiscovery private let workspaceRuntime: ((URL) -> (any RuntimeLocator, any RuntimeToolDiscovery)?)? @@ -95,7 +91,7 @@ final class ProjectRuntimeService: ObservableObject { init( runtimeLocator: any RuntimeLocator, - store: any KeyValueStore, + settingsStore: any ProjectRuntimeSettingsStoring, toolDiscovery: (any RuntimeToolDiscovery)? = nil, javaSelector: any JavaRuntimeSelecting = RustCoreBridge(), workspaceRuntime: ((URL) -> (any RuntimeLocator, any RuntimeToolDiscovery)?)? = nil @@ -103,7 +99,7 @@ final class ProjectRuntimeService: ObservableObject { self.javaSelector = javaSelector self.runtimeLocator = runtimeLocator self.localRuntimeLocator = runtimeLocator - self.store = store + self.settingsStore = settingsStore self.toolDiscovery = toolDiscovery ?? DefaultRuntimeToolDiscovery() self.localToolDiscovery = toolDiscovery ?? DefaultRuntimeToolDiscovery() self.workspaceRuntime = workspaceRuntime @@ -127,7 +123,9 @@ final class ProjectRuntimeService: ObservableObject { mavenRuntimes = [] discoveredProjectURL = nil launchJavaRuntimes = nil - settings = loadSettings(for: normalizedURL) + settings = ProjectRuntimeSettings() + settingsSnapshot = nil + reloadSettings(migrateLegacy: true) javaEnvironmentReport = .checking(for: normalizedURL) javaLanguageServerRuntimePreparation = .unprepared discoveryTask = nil @@ -147,6 +145,8 @@ final class ProjectRuntimeService: ObservableObject { discoveredProjectURL = nil launchJavaRuntimes = nil settings = ProjectRuntimeSettings() + settingsSnapshot = nil + if settingsError != nil { settingsError = nil } javaEnvironmentReport = nil isDiscovering = false activeServiceJavaHomePath = "" @@ -250,6 +250,7 @@ final class ProjectRuntimeService: ObservableObject { overridePath: String?, detected: () -> [JavaRuntimeCandidate]? ) -> RuntimeChoice? { + guard reloadSettings() else { return .unavailable(settingsError ?? "") } if let overridePath { let normalizedPath = normalizedOverridePath(overridePath) if !normalizedPath.isEmpty { @@ -416,6 +417,7 @@ final class ProjectRuntimeService: ObservableObject { overridePath: String?, detected: () -> [JavaRuntimeCandidate]? ) -> RuntimeChoice? { + guard reloadSettings() else { return .unavailable(settingsError ?? "") } if let overridePath { let normalizedPath = normalizedOverridePath(overridePath) if !normalizedPath.isEmpty { @@ -564,10 +566,6 @@ final class ProjectRuntimeService: ObservableObject { return runtimeLocator.isExecutable(at: url) ? url : nil } - package func mavenExecutable(for project: MavenProject, overridePath: String? = nil) -> URL? { - mavenExecutable(at: project.rootURL, overridePath: overridePath) - } - func mavenExecutable(at rootURL: URL, overridePath: String? = nil) -> URL? { chooseMavenExecutable(at: rootURL, overridePath: overridePath).url } @@ -575,7 +573,8 @@ final class ProjectRuntimeService: ObservableObject { /// The Maven selection chain behind launches and Settings: an explicit /// path, then the project `mvnw`, then the system Maven. func chooseMavenExecutable(at rootURL: URL, overridePath: String? = nil) -> RuntimeChoice { - let configured = overridePath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard reloadSettings() else { return .unavailable(settingsError ?? "") } + let configured = (overridePath ?? settings.mavenExecutableOverride).trimmingCharacters(in: .whitespacesAndNewlines) if !configured.isEmpty { let resolved = URL(fileURLWithPath: runtimeLocator.normalizedPath(configured, relativeTo: rootURL)) let standardized = resolved.standardizedFileURL @@ -616,20 +615,16 @@ final class ProjectRuntimeService: ObservableObject { return javaRuntimes.first { $0.homePath == home } } - func activeMavenRuntime(for project: MavenProject) -> MavenRuntimeCandidate? { - guard let executable = mavenExecutable(for: project)?.path else { return nil } - return mavenRuntimes.first { $0.executablePath == executable } - } - func loadRunConfigurationToolchainCandidates( - for project: MavenProject?, projectRoot: URL? = nil, + projectRoot: URL? = nil, javaHomeOverride: String? = nil, mavenExecutableOverride: String? = nil ) async throws -> [ProjectToolchainCandidate] { + guard reloadSettings() else { throw RunConfigurationOperationFailure(message: settingsError ?? "") } let sessionID = runtimeSessionID let capturedSettings = settings let locator = runtimeLocator func checkCurrent() throws { - guard !Task.isCancelled, runtimeSessionID == sessionID, settings == capturedSettings else { throw CancellationError() } + guard reloadSettings(), !Task.isCancelled, runtimeSessionID == sessionID, settings == capturedSettings else { throw CancellationError() } } try checkCurrent() if chooseJavaHome(overridePath: javaHomeOverride, detected: { @@ -643,13 +638,12 @@ final class ProjectRuntimeService: ObservableObject { } let javaHome = javaHomeURL(overridePath: javaHomeOverride) let explicitMaven = projectRoot.flatMap { mavenExecutable(at: $0, overridePath: mavenExecutableOverride) } - let knownMaven = project.flatMap(activeMavenRuntime) let fallbackMaven = projectRoot.flatMap { mavenExecutable(at: $0) } let probed: (JavaRuntimeCandidate?, MavenRuntimeCandidate?) = await Self.backgroundProbe { cancelled in guard !cancelled() else { return (nil, nil) } let java = javaHome.flatMap(locator.javaRuntime(at:)) guard !cancelled() else { return (nil, nil) } - let maven = explicitMaven.flatMap(locator.mavenRuntime(at:)) ?? knownMaven + let maven = explicitMaven.flatMap(locator.mavenRuntime(at:)) guard !cancelled() else { return (nil, nil) } return (java, maven ?? fallbackMaven.flatMap(locator.mavenRuntime(at:))) } @@ -674,7 +668,6 @@ final class ProjectRuntimeService: ObservableObject { } func runConfigurationToolchainCandidates( - for project: MavenProject?, projectRoot: URL? = nil, javaHomeOverride: String? = nil, mavenExecutableOverride: String? = nil @@ -683,8 +676,7 @@ final class ProjectRuntimeService: ObservableObject { let maven = projectRoot.flatMap { root in mavenExecutable(at: root, overridePath: mavenExecutableOverride) .flatMap(runtimeLocator.mavenRuntime(at:)) - } ?? project.flatMap(activeMavenRuntime) - ?? projectRoot.flatMap { root in + } ?? projectRoot.flatMap { root in mavenExecutable(at: root).flatMap(runtimeLocator.mavenRuntime(at:)) } return Self.toolchainCandidates(java: java, maven: maven) @@ -716,7 +708,8 @@ final class ProjectRuntimeService: ObservableObject { modulePath: String?, workingDirectory: String? ) -> RunOptions { - settings.overlay( + reloadSettings() + return settings.overlay( onto: options, workspaceRelativePath: ProjectRuntimeInventory.workspaceRelativePath( modulePath: modulePath, @@ -725,115 +718,57 @@ final class ProjectRuntimeService: ObservableObject { ) } - func updateSettings(_ settings: ProjectRuntimeSettings) { - self.settings = settings - runtimeLocator.configureProbePaths([settings.javaHomePath, settings.mavenJavaHomePath, settings.mavenExecutableOverride]) - persistSettings() - if usesWorkspaceRuntime { - discoveredProjectURL = nil - discoveryTask?.cancel() - discoveryTask = Task { [weak self] in await self?.performRuntimeRefresh() } - return - } - if !javaRuntimes.isEmpty { - refreshJavaEnvironmentReport(using: javaRuntimes) - } - } - - /// Mirrors the project defaults saved in `.lithe/run/local.json`, which owns - /// them, so the language server and Maven processes use the JDK and Maven - /// that Run launches with. Other settings are left unchanged. - func adoptProjectToolchain(_ toolchain: ProjectToolchainSelection) { - var next = settings - next.javaHomePath = toolchain.javaHomePath.trimmingCharacters(in: .whitespacesAndNewlines) - next.mavenJavaHomePath = toolchain.mavenJavaHomePath.trimmingCharacters(in: .whitespacesAndNewlines) - let maven = toolchain.mavenExecutablePath.trimmingCharacters(in: .whitespacesAndNewlines) - // Compare the effective executable so an equivalent spelling, or a custom - // path remembered behind the automatic choice, is not rewritten. - if next.mavenExecutableOverride != maven { - if maven.isEmpty { - next.mavenHomeSelection = .automatic - } else if Self.isMavenWrapper(maven) { - next.mavenHomeSelection = .wrapper - } else { - next.mavenHomeSelection = .custom - next.mavenHomePath = maven - } - } - if next != settings { - updateSettings(next) - } - } - - private static func isMavenWrapper(_ path: String) -> Bool { - path == "mvnw" || path == "./mvnw" || path.hasSuffix("/mvnw") - } - - func mergeImportedSettings( - toolchain: ProjectToolchainSelection?, - mavenSettingsPath: String?, - mavenLocalRepositoryPath: String?, - mavenExecutablePath: String?, - mavenJavaHomePath: String? - ) { - var next = settings - if next.javaHomePath.isEmpty { - next.javaHomePath = toolchain?.javaHomePath ?? "" - } - if next.mavenHomeSelection == .automatic, next.mavenHomePath.isEmpty, - let mavenExecutablePath, !mavenExecutablePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - let trimmed = mavenExecutablePath.trimmingCharacters(in: .whitespacesAndNewlines) - if Self.isMavenWrapper(trimmed) { - next.mavenHomeSelection = .wrapper - } else { - next.mavenHomeSelection = .custom - next.mavenHomePath = trimmed + /// An explicit Save action only. The adapter rejects a draft whose source + /// changed externally; failed saves leave the caller's draft intact. + @discardableResult + func updateSettings(_ draft: ProjectRuntimeSettings, expecting expected: ProjectRuntimeSettingsSnapshot? = nil) -> Bool { + guard let projectURL, let settingsSnapshot = expected ?? settingsSnapshot else { return false } + do { + let saved = try settingsStore.save(draft, at: projectURL, expecting: settingsSnapshot) + self.settingsSnapshot = saved + settings = saved.settings + if settingsError != nil { settingsError = nil } + runtimeLocator.configureProbePaths([settings.javaHomePath, settings.mavenJavaHomePath, settings.mavenExecutableOverride]) + if usesWorkspaceRuntime { + discoveredProjectURL = nil + discoveryTask?.cancel() + discoveryTask = Task { [weak self] in await self?.performRuntimeRefresh() } } - } - if next.mavenJavaHomePath.isEmpty { - next.mavenJavaHomePath = mavenJavaHomePath?.trimmingCharacters(in: .whitespacesAndNewlines) - ?? toolchain?.mavenJavaHomePath - ?? "" - } - if next.mavenSettingsPath.isEmpty { - next.mavenSettingsPath = mavenSettingsPath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - } - if next.mavenLocalRepositoryPath.isEmpty { - next.mavenLocalRepositoryPath = mavenLocalRepositoryPath? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - } - if next != settings { - updateSettings(next) + if !javaRuntimes.isEmpty { refreshJavaEnvironmentReport(using: javaRuntimes) } + return true + } catch { + if settingsError != error.localizedDescription { settingsError = error.localizedDescription } + return false } } - /// Records the JDK a new project was created with, before the project - /// opens, so its first language-server start and run use it. A project - /// that already has saved settings keeps them. - func presetJavaHome(_ path: String, for projectURL: URL) { - let key = Self.settingsKey(for: projectURL.standardizedFileURL) - guard store.data(forKey: key) == nil, - let data = try? JSONEncoder().encode(ProjectRuntimeSettings(javaHomePath: path)) else { return } - store.set(data, forKey: key) - } - - private func loadSettings(for projectURL: URL) -> ProjectRuntimeSettings { - guard let data = store.data(forKey: Self.settingsKey(for: projectURL)), - let decoded = try? JSONDecoder().decode(ProjectRuntimeSettings.self, from: data) else { - return ProjectRuntimeSettings() - } - return decoded - } - - private func persistSettings() { - guard let projectURL, - let data = try? JSONEncoder().encode(settings) else { return } - store.set(data, forKey: Self.settingsKey(for: projectURL)) - } - - private static func settingsKey(for projectURL: URL) -> String { - "lithe.project-runtime-settings." - + projectURL.standardizedFileURL.path.replacingOccurrences(of: "/", with: "_") + /// Selection reads use the same current local document as Run. This is a + /// read-only refresh; observing settings never writes defaults or migrates. + @discardableResult + func reloadSettings(migrateLegacy: Bool = false) -> Bool { + guard let projectURL else { return true } + do { + let loaded = try settingsStore.load(at: projectURL, migrateLegacy: migrateLegacy) + settingsSnapshot = loaded + if settings != loaded.settings { settings = loaded.settings } + if settingsError != nil { settingsError = nil } + return true + } catch { + settingsSnapshot = nil + settings = ProjectRuntimeSettings() + if settingsError != error.localizedDescription { settingsError = error.localizedDescription } + return false + } + } + + /// Project creation is an explicit write, before the project is opened. + /// Existing defaults, including an explicitly automatic choice, win. + func presetJavaHome(_ path: String, for projectURL: URL) throws { + let snapshot = try settingsStore.load(at: projectURL, migrateLegacy: true) + guard !snapshot.hasSavedToolchain else { return } + var draft = snapshot.settings + draft.javaHomePath = path + _ = try settingsStore.save(draft, at: projectURL, expecting: snapshot) } private func normalizedOverridePath(_ path: String) -> String { diff --git a/macos/Sources/Lithe/Services/Java/RunExecutableResolver.swift b/macos/Sources/Lithe/Services/Java/RunExecutableResolver.swift index 610abffd95..310f219f21 100644 --- a/macos/Sources/Lithe/Services/Java/RunExecutableResolver.swift +++ b/macos/Sources/Lithe/Services/Java/RunExecutableResolver.swift @@ -1,4 +1,5 @@ import Foundation +import LitheModuleAPI @MainActor final class RunExecutableResolver: RunExecutableResolving { @@ -6,18 +7,34 @@ final class RunExecutableResolver: RunExecutableResolving { private let runtimeService: ProjectRuntimeService private let toolchainRegistry: RunToolchainRegistry private let metadataResolver: (any RunToolchainMetadataResolving)? + private let definedTools: (any DefinedToolchainResolving)? private var metadataByProject: [String: [String: RunToolchainMetadata]] = [:] init( runtimeService: ProjectRuntimeService, toolchainRegistry: RunToolchainRegistry? = nil, metadataResolver: (any RunToolchainMetadataResolving)? = nil, - environmentResolver: any RunLaunchEnvironmentResolving + environmentResolver: any RunLaunchEnvironmentResolving, + definedTools: (any DefinedToolchainResolving)? = nil ) { self.environmentResolver = environmentResolver self.runtimeService = runtimeService self.toolchainRegistry = toolchainRegistry ?? .standard() self.metadataResolver = metadataResolver + self.definedTools = definedTools + } + + func prepare(_ plan: SharedLaunchPlan, projectURL: URL, options: RunOptions) async throws -> ResolvedRunExecutable? { + guard case .toolchain(let id) = plan.executable, !toolchainRegistry.contains(identifier: id) else { return nil } + let environment = runtimeService.processEnvironment().merging(plan.environment) { _, new in new } + .merging(options.environment) { _, new in new } + guard let definedTools, let selected = try await definedTools.resolve(tool: id, directory: plan.workingDirectory, + workspace: projectURL, environment: environment, minimumVersion: nil, projectCodeAllowed: true) else { + throw RunExecutableResolutionError(message: L10n.string("The selected toolchain is unavailable.")) + } + try Task.checkCancellation() + return try finalize(ResolvedRunExecutable(executableURL: selected.executable, environment: selected.environment), + plan: plan, projectURL: projectURL, options: options) } func resolve( @@ -25,11 +42,12 @@ final class RunExecutableResolver: RunExecutableResolving { projectURL: URL, options: RunOptions ) throws -> ResolvedRunExecutable { - let planEnvironment = plan.environment.merging(options.environment) { _, userValue in userValue } - let resolved: ResolvedRunExecutable switch plan.executable { case .toolchain(let id): + if !toolchainRegistry.contains(identifier: id) { + throw RunExecutableResolutionError(message: L10n.string("The selected toolchain is unavailable.")) + } let toolchainProjectURL = id == "project-maven" && plan.workingDirectory != "." ? projectURL.appending(path: plan.workingDirectory, directoryHint: .isDirectory) : projectURL @@ -56,6 +74,12 @@ final class RunExecutableResolver: RunExecutableResolving { ) } + return try finalize(resolved, plan: plan, projectURL: projectURL, options: options) + } + + private func finalize(_ resolved: ResolvedRunExecutable, plan: SharedLaunchPlan, projectURL: URL, + options: RunOptions) throws -> ResolvedRunExecutable { + let planEnvironment = plan.environment.merging(options.environment) { _, userValue in userValue } let identifier: String? if case .toolchain(let id) = plan.executable { identifier = id } else { identifier = nil } let environment = try environmentResolver.resolveLaunchEnvironment( diff --git a/macos/Sources/Lithe/Services/ProjectGeneration/ProjectGenerationService.swift b/macos/Sources/Lithe/Services/ProjectGeneration/ProjectGenerationService.swift index fbd655808d..0f3b72809f 100644 --- a/macos/Sources/Lithe/Services/ProjectGeneration/ProjectGenerationService.swift +++ b/macos/Sources/Lithe/Services/ProjectGeneration/ProjectGenerationService.swift @@ -23,7 +23,7 @@ final class ProjectGenerationService: Sendable { let planner: any ProjectGeneratorPlanning private let platform: any ProjectGenerationPlatform private let initializeGitRepository: @Sendable (URL) -> String? - private let recordJavaHome: @Sendable (URL, String) async -> Void + private let recordJavaHome: @Sendable (URL, String) async throws -> Void /// - Parameters: /// - initializeGitRepository: Creates a repository at the URL and @@ -33,7 +33,7 @@ final class ProjectGenerationService: Sendable { planner: any ProjectGeneratorPlanning, platform: any ProjectGenerationPlatform, initializeGitRepository: @escaping @Sendable (URL) -> String?, - recordJavaHome: @escaping @Sendable (URL, String) async -> Void + recordJavaHome: @escaping @Sendable (URL, String) async throws -> Void ) { self.planner = planner self.platform = platform @@ -98,7 +98,7 @@ final class ProjectGenerationService: Sendable { } for runtime in plan.runtimes where runtime.runtime == "java" { - await recordJavaHome(destination, runtime.home) + try await recordJavaHome(destination, runtime.home) } return CreatedProject( url: destination, diff --git a/macos/Sources/Lithe/Views/App/ContributedBuildConfigurationSection.swift b/macos/Sources/Lithe/Views/App/ContributedBuildConfigurationSection.swift new file mode 100644 index 0000000000..7c56db85a2 --- /dev/null +++ b/macos/Sources/Lithe/Views/App/ContributedBuildConfigurationSection.swift @@ -0,0 +1,213 @@ +import LitheCoreContracts +import LitheExecutionModule +import LitheModuleAPI +import SwiftUI + +/// A provider's native settings, drawn without build-tool-specific knowledge. +struct ContributedBuildConfigurationSection: View { + @EnvironmentObject private var settingsContext: SettingsModelContext + let pluginID: PluginID + let buildSystemIDs: [String] + @State private var feature: BuildSystemsFeatureModel? + @State private var error: String? + + var body: some View { + Group { + if let feature { + NativeBuildConfigurationEntries(feature: feature, pluginID: pluginID, buildSystemIDs: buildSystemIDs, + choosePath: settingsContext.model.pluginSettingsHandlers(for: pluginID).choosePath) + } else { + LitheSettingsSection("Native configuration") { + Text(verbatim: error ?? L10n.string("Open a project to read its native configuration.")) + .foregroundStyle(LitheTheme.secondaryText) + } + } + } + .task(id: settingsContext.model.currentWorkspaceIdentity?.generation) { + feature = nil; error = nil + let model = settingsContext.model + guard let identity = model.currentWorkspaceIdentity else { return } + guard let access = await model.activateExecutionModule(), let build = access.buildSystems, + model.isCurrentWorkspace(identity) else { return } + do { + _ = try await build.prepareLanguageImport(workspaceURL: identity.url) + try Task.checkCancellation() + guard model.isCurrentWorkspace(identity) else { return } + feature = build + } catch is CancellationError {} catch { self.error = error.localizedDescription } + } + } +} + +private struct NativeBuildConfigurationEntries: View { + @ObservedObject var feature: BuildSystemsFeatureModel + let pluginID: PluginID + let buildSystemIDs: [String] + let choosePath: (Bool) -> URL? + + private var entries: [BuildSystemEntry] { + feature.entries.filter { + $0.source.pluginID == pluginID.rawValue && buildSystemIDs.contains($0.instance.buildSystemID) + && $0.declaration.nativeConfiguration + } + } + var body: some View { + if entries.isEmpty { + LitheSettingsSection("Native configuration") { + Text("This project has no matching build-system instance.").foregroundStyle(LitheTheme.secondaryText) + } + } + ForEach(entries) { entry in + NativeBuildConfigurationEditor(feature: feature, entry: entry, choosePath: choosePath) + .id(entry.id + (feature.workspaceURL?.absoluteString ?? "")) + } + } +} + +private struct NativeBuildConfigurationEditor: View { + @ObservedObject var feature: BuildSystemsFeatureModel + let entry: BuildSystemEntry + let choosePath: (Bool) -> URL? + @State private var action: Task? + @State private var error: String? + private var state: BuildNativeConfigurationState { feature.nativeConfigurationState(entry.id) } + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + LitheSettingsSection(title: "Native configuration", accessory: { + LitheSettingsPill(verbatim: entry.instance.displayName, kind: .value) + Spacer(minLength: 0) + if state.isBusy { ProgressView().controlSize(.small) } + Button(LocalizedStringKey(state.requiresExplicitRead ? "Read using project tool" : "Reload")) { + perform { _ = try await feature.readNativeConfiguration(entry.id, allowProjectTool: true) } + } + .buttonStyle(LitheActionButtonStyle()).disabled(state.isBusy) + }) { + if let message = error ?? state.error { + Text(verbatim: message).foregroundStyle(LitheTheme.error).textSelection(.enabled) + } + if state.isStale { + Text("Native configuration changed. Reload before saving; your draft has been kept.") + .foregroundStyle(LitheTheme.warning) + } + if state.snapshot == nil && !state.isBusy { + Text("No native configuration has been read.").foregroundStyle(LitheTheme.secondaryText) + } + } + if let snapshot = state.snapshot { + ForEach(snapshot.sources) { source in + NativeBuildConfigurationSourceEditor(source: source, busy: state.isBusy, stale: state.isStale, + choosePath: choosePath) { edits in + perform { _ = try await feature.saveNativeConfiguration(entry.id, snapshot: snapshot, edits: edits) } + } + } + } + } + .task { + guard state.snapshot == nil else { return } + do { _ = try await feature.readNativeConfiguration(entry.id) } + catch is CancellationError {} catch { self.error = error.localizedDescription } + } + .onDisappear { action?.cancel(); action = nil } + } + + private func perform(_ operation: @escaping @MainActor () async throws -> Void) { + action?.cancel(); error = nil + action = Task { @MainActor in + do { try await operation() } + catch is CancellationError {} catch { self.error = error.localizedDescription } + } + } +} + +struct NativeBuildConfigurationSourceEditor: View { + let source: BuildNativeConfiguration.Source + let busy: Bool + let stale: Bool + let choosePath: (Bool) -> URL? + let save: (BuildNativeConfigurationEdits) -> Void + @State private var draft: BuildNativeConfigurationDraft + + init(source: BuildNativeConfiguration.Source, busy: Bool, stale: Bool, + choosePath: @escaping (Bool) -> URL?, save: @escaping (BuildNativeConfigurationEdits) -> Void) { + self.source = source; self.busy = busy; self.stale = stale; self.choosePath = choosePath; self.save = save + _draft = State(initialValue: BuildNativeConfigurationDraft(source: source)) + } + + var body: some View { + LitheSettingsSection(title: scope, accessory: { + if !source.writable { LitheSettingsPill("Read-only", kind: .value) } + }) { + HStack(alignment: .top) { + Text("Changes are written to") + Text(verbatim: URL(string: source.uri)?.path ?? source.uri) + .foregroundStyle(LitheTheme.secondaryText).textSelection(.enabled) + } + ForEach(source.fields) { field in + VStack(alignment: .leading, spacing: 5) { + HStack { + Text(verbatim: field.id).fontWeight(.medium) + Spacer(minLength: 0) + Toggle("Override", isOn: Binding( + get: { draft.overrides.contains(field.id) }, + set: { draft.setOverride($0, fieldID: field.id) } + )).toggleStyle(.checkbox).disabled(!source.writable || busy || stale) + } + input(field).disabled(!source.writable || busy || stale || !draft.overrides.contains(field.id)) + HStack(alignment: .top, spacing: 6) { + Text("Effective value").foregroundStyle(LitheTheme.tertiaryText) + Text(verbatim: display(field.effectiveValue)).textSelection(.enabled) + LitheSettingsPill(verbatim: field.effectiveSource, kind: .value) + }.font(LitheTheme.uiFont(size: 11)) + } + } + if source.writable { + HStack { + Spacer(minLength: 0) + Button("Cancel") { draft = BuildNativeConfigurationDraft(source: source) } + .buttonStyle(LitheActionButtonStyle()).disabled(!draft.isDirty || busy) + Button("Save") { save(draft.edits) } + .buttonStyle(LitheActionButtonStyle()).disabled(!draft.isDirty || busy || stale) + } + } + } + .onChange(of: source) { value in draft = BuildNativeConfigurationDraft(source: value) } + } + + @ViewBuilder private func input(_ field: BuildNativeConfiguration.Field) -> some View { + let text = Binding(get: { draft.text(field.id) }, set: { draft.setText($0, field: field) }) + switch field.kind { + case .stringList: + LitheTextEditor(text: text, height: 60).accessibilityLabel(Text(verbatim: field.id)) + case .boolean: + Toggle(isOn: Binding(get: { draft.values[field.id] == .bool(true) }, + set: { draft.setValue(.bool($0), fieldID: field.id) })) { Text(verbatim: field.id) } + .labelsHidden().toggleStyle(.switch).controlSize(.small) + case .text, .file, .directory: + HStack { + LitheTextField(verbatim: "", text: text).accessibilityLabel(Text(verbatim: field.id)) + if field.kind == .file || field.kind == .directory { + Button("Choose") { + if let url = choosePath(field.kind == .directory) { draft.setValue(.string(url.path), fieldID: field.id) } + }.buttonStyle(LitheActionButtonStyle()) + } + } + } + } + + private var scope: String { + switch source.scope { + case .installation: "Installation" + case .user: "User" + case .workspace: "Workspace" + } + } + private func display(_ value: ToolingJSONValue) -> String { + switch value { + case .string(let text): text + case .bool(let value): value ? L10n.string("On") : L10n.string("Off") + case .array(let items): items.map(display).joined(separator: ", ") + default: "—" + } + } +} diff --git a/macos/Sources/Lithe/Views/App/ContributedSettingsPageView.swift b/macos/Sources/Lithe/Views/App/ContributedSettingsPageView.swift index 7a0a4945b7..20325d5356 100644 --- a/macos/Sources/Lithe/Views/App/ContributedSettingsPageView.swift +++ b/macos/Sources/Lithe/Views/App/ContributedSettingsPageView.swift @@ -44,12 +44,12 @@ struct ContributedSettingsPageView: View { ContributedLanguageStatusSection(languageID: languageID, title: page.title) } ForEach(page.contributions) { contribution in - if contribution.settings.isEmpty && contribution.actions.isEmpty { + if contribution.settings.isEmpty && contribution.actions.isEmpty && contribution.nativeBuildSystemIDs.isEmpty { LitheSettingsSection(contribution.pluginName) { Text(LocalizedStringKey("This plugin adds no options to this page.")) .foregroundStyle(LitheTheme.tertiaryText) } - } else { + } else if !contribution.settings.isEmpty || !contribution.actions.isEmpty { PluginSettingsSection( settings: contribution.settings, actions: contribution.actions, @@ -59,6 +59,10 @@ struct ContributedSettingsPageView: View { layer: $layer ) } + if !contribution.nativeBuildSystemIDs.isEmpty { + ContributedBuildConfigurationSection(pluginID: contribution.pluginID, + buildSystemIDs: contribution.nativeBuildSystemIDs) + } } } .padding(.horizontal, 26) diff --git a/macos/Sources/Lithe/Views/App/ProjectRuntimeSettingsView.swift b/macos/Sources/Lithe/Views/App/ProjectRuntimeSettingsView.swift index 53a3e4005e..f543a465e2 100644 --- a/macos/Sources/Lithe/Views/App/ProjectRuntimeSettingsView.swift +++ b/macos/Sources/Lithe/Views/App/ProjectRuntimeSettingsView.swift @@ -9,14 +9,15 @@ struct ProjectRuntimeSettingsView: View { @EnvironmentObject private var model: AppModel @ObservedObject var feature: RuntimeSettingsFeatureModel @State private var selectedSubprojectID = ProjectRuntimeInventory.projectDefaultsID - /// Editing waits until the form is seeded from this project's saved defaults. - /// An earlier edit could not reach `.lithe/run/local.json`, and adopting the - /// saved defaults afterwards would silently revert it. + /// Editing waits for the canonical toolchain snapshot. Opening is read-only. @State private var isPrepared = false var body: some View { VStack(spacing: 0) { header + if let message = feature.settingsError { + Text(message).font(LitheTheme.smallFont).foregroundStyle(LitheTheme.error).padding(8) + } if model.workspaceURL == nil { emptyWorkspace } else { @@ -25,23 +26,20 @@ struct ProjectRuntimeSettingsView: View { Rectangle().fill(LitheTheme.divider).frame(width: 1) detail } - .disabled(!isPrepared) + .disabled(!isPrepared || feature.isSaving) } } .background(LitheTheme.settingsSurface) - .task { + .task(id: model.currentWorkspaceIdentity) { + let identity = model.currentWorkspaceIdentity + isPrepared = false await model.prepareProjectRuntimeSettings() + guard !Task.isCancelled, model.currentWorkspaceIdentity == identity else { return } if feature.subprojects.contains(where: { $0.id == selectedSubprojectID }) == false { selectedSubprojectID = ProjectRuntimeInventory.projectDefaultsID } isPrepared = true } - .onDisappear { - // Before preparation the form holds unmerged values; persisting them - // would overwrite the saved project and Maven settings. - guard isPrepared else { return } - model.persistProjectRuntimeSettings() - } } private var header: some View { @@ -51,7 +49,16 @@ struct ProjectRuntimeSettingsView: View { .font(LitheTheme.smallFont) .foregroundStyle(LitheTheme.secondaryText) } + Text(verbatim: ".lithe/run/local.json") + .font(LitheTheme.smallFont) + .foregroundStyle(LitheTheme.secondaryText) Spacer() + Button("Cancel") { feature.cancelChanges() } + .buttonStyle(LitheActionButtonStyle()) + .disabled(!feature.hasChanges || feature.isSaving) + Button("Save") { Task { await model.persistProjectRuntimeSettings() } } + .buttonStyle(LitheActionButtonStyle()) + .disabled(!isPrepared || !feature.hasChanges || feature.isSaving) if feature.isDiscovering { ProgressView() .controlSize(.small) @@ -74,7 +81,7 @@ struct ProjectRuntimeSettingsView: View { Label("Refresh detected runtimes", systemImage: "arrow.clockwise") } .buttonStyle(LitheActionButtonStyle()) - .disabled(feature.isDiscovering) + .disabled(feature.isDiscovering || feature.isSaving) } .padding(.horizontal, 16) .frame(height: 40) @@ -253,21 +260,7 @@ struct ProjectRuntimeSettingsView: View { kind: .java, mode: feature.settings.mavenJavaHomePath.isEmpty ? .projectJDK : .configured ) - pathField( - title: "settings.xml", - value: mavenSettingsPathBinding, - placeholder: effectiveMavenSettingsPath ?? L10n.string("Not found"), - chooseTitle: "Choose Maven settings.xml", - fileOrDirectory: false - ) - pathField( - title: L10n.string("Local Repository"), - value: mavenLocalRepositoryBinding, - placeholder: effectiveMavenLocalRepositoryPath ?? L10n.string("Not found"), - chooseTitle: "Choose Maven Local Repository", - fileOrDirectory: true, - directoryOnly: true - ) + } Text("Project runtime settings are saved locally for this project.") @@ -349,127 +342,6 @@ struct ProjectRuntimeSettingsView: View { model.runFeatureIfActive?.configurationDiagnostics.toolchainRequirementMessages(for: toolchain) ?? [] } - /// The configured override, otherwise the file Maven would read by default - /// or the installation's `conf/settings.xml`. Showing the resolved path is - /// more useful than “Automatic”; when nothing exists, the field says so. - private var effectiveMavenSettingsPath: String? { - if let path = model.mavenFeatureIfActive?.settingsPath, !path.isEmpty { return path } - let configured = feature.settings.mavenSettingsPath.trimmingCharacters(in: .whitespacesAndNewlines) - if !configured.isEmpty { return configured } - let home = FileManager.default.homeDirectoryForCurrentUser - let userSettings = home.appendingPathComponent(".m2/settings.xml") - if FileManager.default.fileExists(atPath: userSettings.path) { return userSettings.path } - guard let workspaceURL = model.workspaceURL, - let executable = feature.mavenChoice( - at: workspaceURL, - overridePath: feature.settings.mavenExecutableOverride - ).url else { return nil } - // A selected launcher may be a symlink outside the installation. - let installationSettings = executable.resolvingSymlinksInPath() - .deletingLastPathComponent() - .deletingLastPathComponent() - .appendingPathComponent("conf/settings.xml") - return FileManager.default.fileExists(atPath: installationSettings.path) - ? installationSettings.path - : nil - } - - /// The same precedence Maven applies: the explicit Lithe override, then - /// `` in the user settings, then the installation's - /// `conf/settings.xml`, and finally the default repository. - private var effectiveMavenLocalRepositoryPath: String? { - if let path = model.mavenFeatureIfActive?.localRepositoryPath, !path.isEmpty { return path } - let configured = feature.settings.mavenLocalRepositoryPath.trimmingCharacters(in: .whitespacesAndNewlines) - if !configured.isEmpty { return configured } - for settings in mavenSettingsCandidates { - switch Self.localRepository(inSettingsAt: settings) { - case .resolved(let repository): - return repository - case .unresolved: - // A configured value that cannot be interpolated must not fall - // back to a guessed default; leave the field unknown instead. - return nil - case .absent: - continue - } - } - return FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".m2/repository").path - } - - /// User settings first; the installation's global settings are merged - /// below them and can define `` when the user file does - /// not. - private var mavenSettingsCandidates: [String] { - var paths: [String] = [] - if let path = model.mavenFeatureIfActive?.settingsPath, !path.isEmpty { - paths.append(path) - } else { - let configured = feature.settings.mavenSettingsPath.trimmingCharacters(in: .whitespacesAndNewlines) - let userSettings = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".m2/settings.xml") - if !configured.isEmpty { - paths.append(configured) - } else if FileManager.default.fileExists(atPath: userSettings.path) { - paths.append(userSettings.path) - } - } - if let workspaceURL = model.workspaceURL, - let executable = feature.mavenChoice( - at: workspaceURL, - overridePath: feature.settings.mavenExecutableOverride - ).url { - let installationSettings = executable.resolvingSymlinksInPath() - .deletingLastPathComponent() - .deletingLastPathComponent() - .appendingPathComponent("conf/settings.xml") - if FileManager.default.fileExists(atPath: installationSettings.path), - !paths.contains(installationSettings.path) { - paths.append(installationSettings.path) - } - } - return paths - } - - /// Distinguishes a missing element from one that is configured but cannot - /// be interpolated, so the caller never reports the default as the value. - private static func localRepository( - inSettingsAt path: String - ) -> MavenLocalRepositoryValue { - guard let data = FileManager.default.contents(atPath: path) else { return .absent } - let parser = XMLParser(data: data) - let reader = MavenLocalRepositoryReader() - parser.delegate = reader - parser.shouldResolveExternalEntities = false - guard parser.parse(), reader.found else { return .absent } - guard let raw = reader.value?.trimmingCharacters(in: .whitespacesAndNewlines), - !raw.isEmpty else { return .absent } - guard let resolved = resolveMavenPath(raw) else { return .unresolved } - return .resolved(resolved) - } - - /// Maven interpolates `${user.home}` and `${env.NAME}` plus a leading `~`. - /// Any other expression or a relative result cannot be reported as the - /// actual repository. - private static func resolveMavenPath(_ raw: String) -> String? { - var value = raw.replacingOccurrences(of: "${user.home}", with: NSHomeDirectory()) - // Bound the expansion so a self-referential environment value cannot - // keep the loop running. - for _ in 0..<16 { - guard let start = value.range(of: "${env.") else { break } - guard let end = value[start.upperBound...].firstIndex(of: "}") else { return nil } - let name = String(value[start.upperBound.. some View { VStack(alignment: .leading, spacing: 4) { Text(LocalizedStringKey(title)) @@ -597,8 +469,8 @@ struct ProjectRuntimeSettingsView: View { private func icon(for kind: ProjectRuntimeSubprojectKind) -> String { switch kind { case .projectDefaults: "internaldrive" - case .mavenReactor: "shippingbox" - case .mavenModule: "cube" + case .buildRoot: "shippingbox" + case .buildProject: "cube" case .other: "globe" } } @@ -618,7 +490,6 @@ struct ProjectRuntimeSettingsView: View { get: { feature.settings.javaHomePath }, set: { value in feature.updateSettings { $0.javaHomePath = value } - model.persistProjectRuntimeSettings() } ) } @@ -628,7 +499,6 @@ struct ProjectRuntimeSettingsView: View { get: { feature.settings.mavenHomeSelection }, set: { value in feature.updateSettings { $0.mavenHomeSelection = value } - model.persistProjectRuntimeSettings() } ) } @@ -638,7 +508,6 @@ struct ProjectRuntimeSettingsView: View { get: { feature.settings.mavenHomePath }, set: { value in feature.updateSettings { $0.mavenHomePath = value } - model.persistProjectRuntimeSettings() } ) } @@ -648,27 +517,6 @@ struct ProjectRuntimeSettingsView: View { get: { feature.settings.mavenJavaHomePath }, set: { value in feature.updateSettings { $0.mavenJavaHomePath = value } - model.persistProjectRuntimeSettings() - } - ) - } - - private var mavenSettingsPathBinding: Binding { - Binding( - get: { feature.settings.mavenSettingsPath }, - set: { value in - feature.updateSettings { $0.mavenSettingsPath = value } - model.persistProjectRuntimeSettings() - } - ) - } - - private var mavenLocalRepositoryBinding: Binding { - Binding( - get: { feature.settings.mavenLocalRepositoryPath }, - set: { value in - feature.updateSettings { $0.mavenLocalRepositoryPath = value } - model.persistProjectRuntimeSettings() } ) } @@ -680,7 +528,6 @@ struct ProjectRuntimeSettingsView: View { feature.updateSettings { $0.setOverride(path: subproject.relativePath, javaHomePath: value) } - model.persistProjectRuntimeSettings() } ) } @@ -692,7 +539,6 @@ struct ProjectRuntimeSettingsView: View { feature.updateSettings { $0.setOverride(path: subproject.relativePath, mavenExecutablePath: value) } - model.persistProjectRuntimeSettings() } ) } @@ -704,52 +550,7 @@ struct ProjectRuntimeSettingsView: View { feature.updateSettings { $0.setOverride(path: subproject.relativePath, mavenJavaHomePath: value) } - model.persistProjectRuntimeSettings() } ) } } - -private enum MavenLocalRepositoryValue { - case absent - case resolved(String) - case unresolved -} - -/// Reads only `` from a Maven settings file. -private final class MavenLocalRepositoryReader: NSObject, XMLParserDelegate { - var value: String? - var found = false - private var isReading = false - private var text = "" - - func parser( - _ parser: XMLParser, - didStartElement elementName: String, - namespaceURI: String?, - qualifiedName qName: String?, - attributes attributeDict: [String: String] = [:] - ) { - if elementName == "localRepository" { - found = true - isReading = true - text = "" - } - } - - func parser(_ parser: XMLParser, foundCharacters string: String) { - if isReading { text += string } - } - - func parser( - _ parser: XMLParser, - didEndElement elementName: String, - namespaceURI: String?, - qualifiedName qName: String? - ) { - if elementName == "localRepository", isReading { - value = text.trimmingCharacters(in: .whitespacesAndNewlines) - isReading = false - } - } -} diff --git a/macos/Sources/Lithe/Views/App/ToolchainEnvironmentSettingsView.swift b/macos/Sources/Lithe/Views/App/ToolchainEnvironmentSettingsView.swift index 928323ff50..180eecdf24 100644 --- a/macos/Sources/Lithe/Views/App/ToolchainEnvironmentSettingsView.swift +++ b/macos/Sources/Lithe/Views/App/ToolchainEnvironmentSettingsView.swift @@ -51,7 +51,7 @@ struct ToolchainEnvironmentSettingsView: View { Text("Directories") .font(LitheTheme.uiFont(size: 12, weight: .semibold)) .lineLimit(1) - LitheInfoTip("Lists the project root and each directory with its own version file or fixed version. A fixed version also applies to the directories below it.") + LitheInfoTip("Lists the project root and directories with version files, fixed versions or saved tool preferences, together with their parents.") Spacer(minLength: 0) } .foregroundStyle(LitheTheme.secondaryText) @@ -79,6 +79,8 @@ struct ToolchainEnvironmentSettingsView: View { Spacer(minLength: 4) if pinned.contains(directory.id) { ToolchainSettingsTag("Fixed", kind: .project) + } else if directory.hasToolPreferences { + ToolchainSettingsTag("Defaults", kind: .project) } } .padding(.vertical, 3) @@ -224,7 +226,8 @@ struct ToolchainEnvironmentSettingsView: View { providers: snapshot.providers.filter { $0.tools.contains(tool.id) }, isExpanded: feature.expandedToolIDs.contains(tool.id), toggle: { feature.toggleExpanded(tool.id) }, - openPlugins: onOpenPlugins + openPlugins: onOpenPlugins, + preferences: feature.preferences, workspace: snapshot.workspaceURL ) } } @@ -243,6 +246,8 @@ private struct ToolchainToolRow: View { let isExpanded: Bool let toggle: () -> Void let openPlugins: () -> Void + let preferences: ToolchainPreferencesFeatureModel + let workspace: URL? private static let indent: CGFloat = 26 @@ -291,6 +296,11 @@ private struct ToolchainToolRow: View { } .padding(.leading, Self.indent) .padding(.bottom, 6) + if let workspace, !tool.consumers.isEmpty, preferences.isAvailable { + ToolchainPreferenceEditorView(editor: preferences, workspace: workspace, tool: tool) + .padding(.leading, Self.indent) + .padding(.bottom, 8) + } } } .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) diff --git a/macos/Sources/Lithe/Views/App/ToolchainPreferenceEditorView.swift b/macos/Sources/Lithe/Views/App/ToolchainPreferenceEditorView.swift new file mode 100644 index 0000000000..717aeb96b8 --- /dev/null +++ b/macos/Sources/Lithe/Views/App/ToolchainPreferenceEditorView.swift @@ -0,0 +1,115 @@ +import LitheCoreContracts +import LitheModuleAPI +import SwiftUI + +/// The feature owns drafts and saves. This view supplies an exact scope and +/// shows saved inputs separately from the page's probed installation facts. +struct ToolchainPreferenceEditorView: View { + @ObservedObject var editor: ToolchainPreferencesFeatureModel + let workspace: URL + let tool: ToolchainSettingsSnapshot.Tool + @State private var action: Task? + + private var isOpen: Bool { + editor.workspace == workspace && editor.address?.tool == tool.id && editor.address?.directory == tool.directoryID + } + private var modes: [ToolchainPreferencesFeatureModel.Mode] { + var result: [ToolchainPreferencesFeatureModel.Mode] = [.inherited] + if tool.directoryID != "." || editor.address?.consumer != nil { result.append(.workspace) } + if tool.directoryID != ".", editor.address?.consumer != nil { result.append(.consumer) } + if editor.address?.consumer != nil { result.append(.directoryTool) } + return result + [.installation, .executable] + } + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: LitheInfoTip.labelSpacing) { + Text("Build tool defaults").font(LitheTheme.uiFont(size: 12, weight: .semibold)) + LitheInfoTip("These machine-local choices apply to Build providers and their Run configurations on the next operation. Saving does not probe or execute a tool. Inherited choices follow later default changes.") + Spacer(minLength: 0) + if !isOpen { + Button("Edit defaults") { open(consumer: nil) }.buttonStyle(LitheActionButtonStyle()) + .accessibilityIdentifier("toolchain-preference-open:" + tool.id) + } + } + if isOpen { + ToolchainSettingsLine("Consumer") { + LitheSettingsSelect(selection: Binding(get: { editor.address?.consumer ?? "" }, set: { open(consumer: $0.isEmpty ? nil : $0) }), + options: [""] + tool.consumers.map(\.id), width: 240, accessibilityLabel: "Consumer", + title: { id in id.isEmpty ? L10n.string("All Build consumers") : tool.consumers.first { $0.id == id }.map { $0.title + " · " + $0.pluginName } ?? id }) + .disabled(editor.isLoading || editor.isSaving) + } + if let draft = editor.draft { + ToolchainSettingsLine("Saved input", info: "A saved input is revalidated by the existing toolchain resolver when the consumer runs. It is not a verified current installation.") { + LitheSettingsSelect(selection: Binding(get: { editor.draft?.mode ?? .inherited }, set: { editor.edit(mode: $0) }), + options: modes, width: 240, accessibilityLabel: "Saved input", title: Self.title) + .disabled(editor.isLoading || editor.isSaving) + } + if draft.mode == .installation || draft.mode == .executable { + LitheTextField("Path", text: Binding(get: { editor.draft?.path ?? "" }, set: { editor.edit(path: $0) })) + .disabled(editor.isLoading || editor.isSaving) + .accessibilityIdentifier("toolchain-preference-path:" + tool.id) + } + ToolchainSettingsLine("Next Build input") { + Text(verbatim: effectiveInput).font(LitheTheme.uiFont(size: 11.5, design: .monospaced)) + .lineLimit(2).textSelection(.enabled) + } + HStack(spacing: 8) { + Button("Save") { perform { await editor.save() } } + .disabled(!draft.isChanged || editor.isLoading || editor.isSaving) + .accessibilityIdentifier("toolchain-preference-save:" + tool.id) + Button("Cancel") { perform { await editor.cancel() } } + .disabled(editor.isLoading || editor.isSaving) + .accessibilityIdentifier("toolchain-preference-cancel:" + tool.id) + Button("Reset") { perform { await editor.save(reset: true) } } + .disabled(editor.isLoading || editor.isSaving || (editor.reading?.stored == nil && !draft.isChanged)) + .accessibilityIdentifier("toolchain-preference-reset:" + tool.id) + if editor.isSaving { ProgressView().controlSize(.mini) } + } + .buttonStyle(LitheActionButtonStyle()) + } else if editor.isLoading { ProgressView().controlSize(.mini) } + if let error = editor.error { + Text(verbatim: error.localizedDescription).font(LitheTheme.uiFont(size: 11.5)) + .foregroundStyle(LitheTheme.error).textSelection(.enabled) + if editor.draft == nil { + Button("Refresh") { open(consumer: editor.address?.consumer) } + .buttonStyle(LitheActionButtonStyle()) + .disabled(editor.isLoading || editor.isSaving) + } + } + } + } + .onDisappear { + action?.cancel(); action = nil + if isOpen, let address = editor.address { editor.hide(address) } + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("toolchain-preference-editor:" + tool.id) + } + + private var effectiveInput: String { + switch editor.reading?.fixed { + case .installation(let home): home + case .executable(let path): path + case nil: L10n.string("Automatic selection") + } + } + private static func title(_ mode: ToolchainPreferencesFeatureModel.Mode) -> String { + switch mode { + case .inherited: L10n.string("Use inherited choice") + case .workspace: L10n.string("Use workspace default") + case .consumer: L10n.string("Use consumer default") + case .directoryTool: L10n.string("Use directory tool default") + case .installation: L10n.string("Installation directory") + case .executable: L10n.string("Executable file") + } + } + private func open(consumer: String?) { + let address = ToolchainPreferenceAddress(directory: tool.directoryID, consumer: consumer, tool: tool.id) + perform { await editor.open(workspace: workspace, address: address) } + } + private func perform(_ operation: @escaping @MainActor () async -> Void) { + action?.cancel() + action = Task { await operation() } + } +} diff --git a/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift b/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift index ddb200985c..8ed776224d 100644 --- a/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift +++ b/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift @@ -26,13 +26,13 @@ enum EditorDocumentIconResolver { private struct EditorDocumentTabStyle: ViewModifier { @ObservedObject var document: EditorDocument @Environment(\.colorScheme) private var colorScheme - let project: MavenProject? + let importedSourceRootKind: String? let isSelected: Bool let isActive: Bool func body(content: Content) -> some View { content.modifier(LitheToolWindowTabStyle(isSelected: isSelected, isActive: isActive, - fileColor: EditorDocument.isTestSourceFile(document.url, project: project, hasTests: document.hasJavaTests) + fileColor: EditorDocument.isTestSourceFile(document.url, importedSourceRootKind: importedSourceRootKind, hasTests: document.hasJavaTests) ? LitheToolWindowTabStyle.testSourceColor(for: colorScheme) : nil)) } } @@ -719,7 +719,7 @@ struct EditorAreaView: View { .allowsHitTesting(isActive || hoveredTabItem == .document(document.id)) .padding(.trailing, 4) } - .modifier(EditorDocumentTabStyle(document: document, project: model.mavenFeatureIfActive?.project, + .modifier(EditorDocumentTabStyle(document: document, importedSourceRootKind: model.buildSystemsFeatureIfActive?.sourceRootKind(containing: document.url), isSelected: isActive, isActive: editorGroupIsActive)) .padding(.horizontal, 4) .padding(.vertical, 4) diff --git a/macos/Sources/Lithe/Views/Language/LanguageTestsView.swift b/macos/Sources/Lithe/Views/Language/LanguageTestsView.swift index a3b3db2263..4ff9f5e6d8 100644 --- a/macos/Sources/Lithe/Views/Language/LanguageTestsView.swift +++ b/macos/Sources/Lithe/Views/Language/LanguageTestsView.swift @@ -19,8 +19,8 @@ struct LanguageTestsView: View { @AppStorage("lithe.tests.detailsWidth") private var detailsWidth = 300.0 @State private var wrapsLines = false @State private var consolePresentation = ConsoleFoldPresentation() - @State private var resultStatuses: [String: String] = [:] - @State private var testDurations: [String: String] = [:] + @State private var resultStatuses: [ResultKey: String] = [:] + @State private var testDurations: [ResultKey: String] = [:] @State private var collapsedTestIDs: Set = [] private var localization: LanguageTestLocalization { @@ -293,17 +293,26 @@ struct LanguageTestsView: View { } private func presentedItems(for providerID: String) -> [LanguageTestItem] { - Self.presentedItems(discovered: service.itemsByProviderID[providerID] ?? [], - results: service.results, resultProviderID: service.activePlan?.providerID, providerID: providerID) + let discovered = (service.itemsByProviderID[providerID] ?? []).map { item in + var item = item + item.projectPath = item.fileURL.flatMap { model.buildSystemsFeatureIfActive?.projectPath(containing: $0) } + return item + } + return Self.presentedItems(discovered: discovered, + results: service.results, references: service.resultReferences, resultProviderID: service.activePlan?.providerID, providerID: providerID) } /// Selection and rendering consume the same nodes, including report-only results. - static func presentedItems(discovered: [LanguageTestItem], results: MavenTestResults?, resultProviderID: String?, providerID: String) -> [LanguageTestItem] { - guard resultProviderID == providerID, let outcomes = results?.testCases else { return discovered } - let statuses = resultStatuses(outcomes) - let reported = discovered.filter { $0.kind == .testCase && statuses[resultKey($0)] != nil } - if !reported.isEmpty { return reported } - return outcomes.isEmpty ? discovered : synthesizeResultItems(from: outcomes, providerID: providerID) + static func presentedItems(discovered: [LanguageTestItem], results: BuildTestResults?, references: [BuildTestCaseKey: BuildTestReference] = [:], resultProviderID: String?, providerID: String) -> [LanguageTestItem] { + guard resultProviderID == providerID, let outcomes = results?.tests else { return discovered } + guard !outcomes.isEmpty else { return discovered } + let semantic = Dictionary(grouping: discovered.filter { $0.kind == .testCase }, by: resultKey) + return synthesizeResultItems(from: outcomes, references: references, providerID: providerID).map { row in + guard let matches = semantic[resultKey(row)], matches.count == 1, let source = matches.first else { return row } + return LanguageTestItem(id: source.id, providerID: row.providerID, label: row.label, kind: row.kind, + fileURL: source.fileURL, testIdentifier: source.testIdentifier, depth: row.depth, + projectPath: row.projectPath, buildReference: row.buildReference, reportIdentity: row.reportIdentity) + } } private func testItemContent(_ item: LanguageTestItem, hasChildren: Bool) -> some View { @@ -338,40 +347,12 @@ struct LanguageTestsView: View { Text(status == "Ignored" ? localization.text("Skipped") : status.map(localization.text) ?? "")) } - static func parseDurations( - from output: String, - outcomes: [MavenTestCaseOutcome] = [] - ) -> [String: String] { - var durations: [String: String] = [:] - - // Class duration line: Time elapsed: 0.582 s -- in demo.CalculatorTest - if let classRegex = try? NSRegularExpression( - pattern: #"Time elapsed:\s*([0-9]+(?:\.[0-9]+)?)\s*s\s*--?\s*in\s+([A-Za-z0-9_$.]+)"# - ) { - let matches = classRegex.matches(in: output, range: NSRange(output.startIndex..., in: output)) - for match in matches { - if let timeRange = Range(match.range(at: 1), in: output), - let classRange = Range(match.range(at: 2), in: output) { - let timeStr = String(output[timeRange]) - let fullClassName = String(output[classRange]) - if let sec = Double(timeStr) { - let formatted = formatDuration(seconds: sec) - if durations[fullClassName] == nil { - durations[fullClassName] = formatted - let shortName = fullClassName.split(separator: ".").last.map(String.init) ?? fullClassName - durations[shortName] = formatted - } - } - } - } + static func reportDurations(_ tests: [BuildTestResults.TestCase]) -> [ResultKey: String] { + var durations: [ResultKey: String] = [:] + for test in tests { + guard let milliseconds = test.durationMillis, milliseconds >= 0 else { continue } + durations[ResultKey(project: test.project, suite: test.suite, name: test.name)] = formatDuration(seconds: Double(milliseconds) / 1000) } - - for outcome in outcomes { - guard let milliseconds = outcome.durationMillis, milliseconds >= 0 else { continue } - let method = outcome.method.split(separator: "(", maxSplits: 1).first.map(String.init) ?? outcome.method - durations[outcome.className + "#" + method] = formatDuration(seconds: Double(milliseconds) / 1000) - } - return durations } @@ -390,44 +371,47 @@ struct LanguageTestsView: View { } static func synthesizeResultItems( - from outcomes: [MavenTestCaseOutcome], + from outcomes: [BuildTestResults.TestCase], + references: [BuildTestCaseKey: BuildTestReference] = [:], providerID: String ) -> [LanguageTestItem] { - var classOrder: [String] = [] - var classGroups: [String: [MavenTestCaseOutcome]] = [:] + var classOrder: [ResultKey] = [] + var classGroups: [ResultKey: [BuildTestResults.TestCase]] = [:] for outcome in outcomes { - if classGroups[outcome.className] == nil { - classOrder.append(outcome.className) - } - classGroups[outcome.className, default: []].append(outcome) + let key = ResultKey(project: outcome.project, suite: outcome.suite, name: nil) + if classGroups[key] == nil { classOrder.append(key) } + classGroups[key, default: []].append(outcome) } var items: [LanguageTestItem] = [] - for fullClassName in classOrder { - let shortClassName = fullClassName.split(separator: ".").last.map(String.init) ?? fullClassName - let methods = classGroups[fullClassName] ?? [] + for key in classOrder { + let methods = classGroups[key] ?? [] + guard let first = methods.first else { continue } + let fullClassName = first.suite let classNode = LanguageTestItem( - id: "testCase:\(providerID):\(fullClassName)", + id: "testCase:\(providerID):\(key.id)", providerID: providerID, - label: shortClassName, + label: first.project.map { $0 + " / " + fullClassName } ?? fullClassName, kind: .testCase, fileURL: nil, testIdentifier: fullClassName, - depth: 1 + depth: 1, projectPath: first.project, buildReference: references[BuildTestCaseKey(first)]?.suiteReference, + reportIdentity: .init(suite: first.suite) ) items.append(classNode) for outcome in methods { - let methodName = outcome.method.split(separator: "(", maxSplits: 1).first.map(String.init) ?? outcome.method + let methodName = outcome.name let methodID = "\(fullClassName)#\(methodName)" let methodNode = LanguageTestItem( - id: "testCase:\(providerID):\(methodID)", + id: "testCase:\(providerID):\(ResultKey(project: outcome.project, suite: outcome.suite, name: outcome.name).id)", providerID: providerID, label: methodName, kind: .testCase, fileURL: nil, testIdentifier: methodID, - depth: 2 + depth: 2, projectPath: outcome.project, buildReference: references[BuildTestCaseKey(outcome)], + reportIdentity: .init(suite: outcome.suite, name: outcome.name) ) items.append(methodNode) } @@ -435,22 +419,32 @@ struct LanguageTestsView: View { return items } - static func resultKey(_ item: LanguageTestItem) -> String { - (item.testIdentifier ?? item.id).split(separator: "(", maxSplits: 1).first.map(String.init) ?? item.id + struct ResultKey: Hashable { + let project: String? + let suite: String + let name: String? + var id: String { + [project, suite, name].map { $0.map { "\($0.utf8.count):" + $0 } ?? "-" }.joined() + } } - static func resultStatuses(_ outcomes: [MavenTestCaseOutcome]) -> [String: String] { - var grouped: [String: [String]] = [:] + static func resultKey(_ item: LanguageTestItem) -> ResultKey { + let identity = item.reportIdentity ?? .init(suite: item.testIdentifier ?? item.id) + return ResultKey(project: item.projectPath, suite: identity.suite, name: identity.name) + } + + static func resultStatuses(_ outcomes: [BuildTestResults.TestCase]) -> [ResultKey: String] { + var grouped: [ResultKey: [BuildTestResults.TestCase.Status]] = [:] for outcome in outcomes { - let method = outcome.method.split(separator: "(", maxSplits: 1).first.map(String.init) ?? outcome.method - grouped[outcome.className, default: []].append(outcome.status) - grouped[outcome.className + "#" + method, default: []].append(outcome.status) + let method = outcome.name + grouped[ResultKey(project: outcome.project, suite: outcome.suite, name: nil), default: []].append(outcome.status) + grouped[ResultKey(project: outcome.project, suite: outcome.suite, name: method), default: []].append(outcome.status) } return grouped.compactMapValues { statuses in - if statuses.contains("error") { return "Error" } - if statuses.contains("failed") { return "Failed" } - if statuses.allSatisfy({ $0 == "skipped" }) { return "Ignored" } - if statuses.allSatisfy({ $0 == "passed" || $0 == "skipped" }) { return "Passed" } + if statuses.contains(.error) { return "Error" } + if statuses.contains(.failed) { return "Failed" } + if statuses.allSatisfy({ $0 == .skipped }) { return "Ignored" } + if statuses.allSatisfy({ $0 == .passed || $0 == .skipped }) { return "Passed" } return nil } } @@ -507,9 +501,9 @@ struct LanguageTestsView: View { } private func updateResultsAndDurations() { - let outcomes = service.results?.testCases ?? [] + let outcomes = service.results?.tests ?? [] resultStatuses = Self.resultStatuses(outcomes) - testDurations = Self.parseDurations(from: service.output, outcomes: outcomes) + testDurations = Self.reportDurations(outcomes) } private func syncPerformance() { @@ -591,13 +585,13 @@ struct LanguageTestsView: View { HStack(spacing: LitheTheme.TestResult.summaryIconTextGap) { LitheIDEAIcon(resourcePath: Self.summaryIcon(for: results, state: service.state), size: 16, preservesOriginalColors: true) - if Self.runFailed(service.state), results.failures + results.errors == 0 { + if Self.runFailed(service.state), results.summary.failed + results.summary.errors == 0 { Text(LocalizedStringKey(Self.summaryText(for: service.state))) .foregroundStyle(LitheTheme.TestResult.failedText) } summaryCounts(results) } - Text(localization.number(results.testsRun == 1 ? "%lld test total" : "%lld tests total", results.testsRun)) + Text(localization.number(results.summary.total == 1 ? "%lld test total" : "%lld tests total", results.summary.total)) .foregroundStyle(LitheTheme.TestResult.descriptionText) } else { Text(LocalizedStringKey(Self.summaryText(for: service.state))) @@ -638,8 +632,8 @@ struct LanguageTestsView: View { if Self.runFailed(service.state) { return LitheTheme.TestResult.failed } if service.state == .cancelled { return nil } if let results = service.results { - if results.failures + results.errors > 0 { return LitheTheme.TestResult.failed } - return results.passed > 0 ? LitheTheme.TestResult.passed : nil + if results.summary.failed + results.summary.errors > 0 { return LitheTheme.TestResult.failed } + return results.summary.passed > 0 ? LitheTheme.TestResult.passed : nil } switch service.state { case .passed: return LitheTheme.TestResult.passed @@ -662,9 +656,8 @@ struct LanguageTestsView: View { } if let results = service.results { - if !results.failureDetails.isEmpty { - testFailureList(results.failureDetails) - } + let failures = results.tests.filter { $0.status == .failed || $0.status == .error } + if !failures.isEmpty { testFailureList(failures) } } if let plan = service.activePlan, @@ -687,24 +680,24 @@ struct LanguageTestsView: View { .frame(maxWidth: .infinity, alignment: .leading) } - private func summaryCounts(_ results: MavenTestResults) -> Text { - let failed = results.failures + results.errors + private func summaryCounts(_ results: BuildTestResults) -> Text { + let failed = results.summary.failed + results.summary.errors let separator = localization.text(", ") var fragments: [Text] = [] if failed > 0 { let format = failed == 1 ? "%lld test failed" : "%lld tests failed" - fragments.append(Text(localization.number(format, failed) + (results.passed + results.skipped > 0 ? separator : "")) + fragments.append(Text(localization.number(format, failed) + (results.summary.passed + results.summary.skipped > 0 ? separator : "")) .foregroundColor(LitheTheme.TestResult.failedText)) } - if results.passed > 0 { - let format = failed + results.skipped == 0 - ? (results.passed == 1 ? "%lld test passed" : "%lld tests passed") : "%lld passed" - fragments.append(Text(localization.number(format, results.passed) + (results.skipped > 0 ? separator : ""))) + if results.summary.passed > 0 { + let format = failed + results.summary.skipped == 0 + ? (results.summary.passed == 1 ? "%lld test passed" : "%lld tests passed") : "%lld passed" + fragments.append(Text(localization.number(format, results.summary.passed) + (results.summary.skipped > 0 ? separator : ""))) } - if results.skipped > 0 { - let format = failed + results.passed == 0 - ? (results.skipped == 1 ? "%lld test ignored" : "%lld tests ignored") : "%lld ignored" - fragments.append(Text(localization.number(format, results.skipped)) + if results.summary.skipped > 0 { + let format = failed + results.summary.passed == 0 + ? (results.summary.skipped == 1 ? "%lld test ignored" : "%lld tests ignored") : "%lld ignored" + fragments.append(Text(localization.number(format, results.summary.skipped)) .foregroundColor(LitheTheme.TestResult.ignoredText)) } return fragments.reduce(Text(""), +).font(LitheTheme.TestResult.summaryFont) @@ -717,34 +710,34 @@ struct LanguageTestsView: View { } } - static func summaryIcon(for results: MavenTestResults, state: LanguageTestRunState = .idle) -> String { + static func summaryIcon(for results: BuildTestResults, state: LanguageTestRunState = .idle) -> String { let status: String - if results.failures > 0 { status = "Failed" } - else if results.errors > 0 || runFailed(state) { status = "Error" } - else if results.passed > 0 { status = results.skipped > 0 ? "PassedIgnored" : "Passed" } - else { status = results.skipped > 0 ? "Ignored" : "NotRunYet" } + if results.summary.failed > 0 { status = "Failed" } + else if results.summary.errors > 0 || runFailed(state) { status = "Error" } + else if results.summary.passed > 0 { status = results.summary.skipped > 0 ? "PassedIgnored" : "Passed" } + else { status = results.summary.skipped > 0 ? "Ignored" : "NotRunYet" } return "expui/run/test\(status).svg" } - private func testFailureList(_ failures: [LanguageTestFailureDetail]) -> some View { + private func testFailureList(_ failures: [BuildTestResults.TestCase]) -> some View { VStack(alignment: .leading, spacing: 4) { Text(localization.text("Failures")) .font(LitheTheme.uiFont(size: 10.5, weight: .medium)) .foregroundStyle(LitheTheme.secondaryText) ForEach(failures) { failure in Button { - guard let fileURL = failure.fileURL else { return } + guard let fileURL = service.sourceURL(for: failure) else { return } model.openSourceLocation( url: fileURL, line: failure.line ?? 1, - column: failure.column + column: nil ) } label: { HStack(alignment: .firstTextBaseline, spacing: 6) { - Image(systemName: failure.kind == "error" ? "exclamationmark.triangle.fill" : "xmark.circle.fill") - .foregroundStyle(failure.kind == "error" ? LitheTheme.warning : LitheTheme.error) + Image(systemName: failure.status == .error ? "exclamationmark.triangle.fill" : "xmark.circle.fill") + .foregroundStyle(failure.status == .error ? LitheTheme.warning : LitheTheme.error) VStack(alignment: .leading, spacing: 1) { - Text(failure.name) + Text(failure.suite + "#" + failure.name) .font(LitheTheme.uiFont(size: 11.5, weight: .medium)) .lineLimit(1) if let message = failure.message, !message.isEmpty { @@ -755,7 +748,7 @@ struct LanguageTestsView: View { } } Spacer(minLength: 0) - if let fileURL = failure.fileURL { + if let fileURL = service.sourceURL(for: failure) { Text(fileURL.lastPathComponent + (failure.line.map { ":\($0)" } ?? "")) .font(LitheTheme.uiFont(size: 10, design: .monospaced)) .foregroundStyle(LitheTheme.secondaryText) @@ -766,7 +759,7 @@ struct LanguageTestsView: View { .padding(.vertical, 3) } .buttonStyle(.litheNoPress) - .disabled(failure.fileURL == nil) + .disabled(service.sourceURL(for: failure) == nil) } } } @@ -780,7 +773,7 @@ struct LanguageTestsView: View { case .testCase: return .testCase( identifier: item.testIdentifier ?? item.label, - fileURL: item.fileURL + fileURL: item.fileURL, buildReference: item.buildReference ) } } diff --git a/macos/Sources/Lithe/Views/Run/BuildOutputView.swift b/macos/Sources/Lithe/Views/Run/BuildOutputView.swift index 88ca9f19e7..d78cf28eae 100644 --- a/macos/Sources/Lithe/Views/Run/BuildOutputView.swift +++ b/macos/Sources/Lithe/Views/Run/BuildOutputView.swift @@ -122,7 +122,7 @@ struct BuildOutputView: View { problemRow( systemImage: "xmark.seal.fill", color: LitheTheme.error, - title: test.suite + " › " + test.name, + title: (test.project.map { $0 + " › " } ?? "") + test.suite + " › " + test.name, message: test.message ?? "", path: test.path, line: test.line ?? 1, diff --git a/macos/Sources/Lithe/Views/Run/BuildSystemToolWindowView.swift b/macos/Sources/Lithe/Views/Run/BuildSystemToolWindowView.swift index 48500dc85b..42f4fa9877 100644 --- a/macos/Sources/Lithe/Views/Run/BuildSystemToolWindowView.swift +++ b/macos/Sources/Lithe/Views/Run/BuildSystemToolWindowView.swift @@ -89,8 +89,13 @@ struct BuildSystemToolWindowView: View { .sheet(item: $argumentsPrompt) { prompt in BuildArgumentsSheet(prompt: prompt) { arguments in argumentsPrompt = nil - run(.task(prompt.request.withUserArguments(arguments), title: prompt.title, needsArguments: false), - entryID: prompt.entryID) + switch prompt.request { + case .task(let request): + run(.task(request.withUserArguments(arguments), title: prompt.title, needsArguments: false), entryID: prompt.entryID) + case .run(var request): + request.userArguments = arguments + run(.application(request, title: prompt.title), entryID: prompt.entryID) + } } onCancel: { argumentsPrompt = nil } @@ -693,7 +698,7 @@ struct BuildSystemToolWindowView: View { private func promptArguments(_ task: DeclarativeBuildSystem.Task, entry: BuildSystemEntry, request: BuildTaskRequest) { let scope = request.target?.name ?? request.project?.name ?? entry.instance.displayName - argumentsPrompt = ArgumentsPrompt(entryID: entry.id, title: Self.title(task.displayName, scope: scope), request: request) + argumentsPrompt = ArgumentsPrompt(entryID: entry.id, title: Self.title(task.displayName, scope: scope), request: .task(request)) } private func testRunnable(_ entry: BuildSystemEntry, project: BuildModel.Project?, isRoot: Bool) -> Runnable? { @@ -996,7 +1001,7 @@ struct BuildSystemToolWindowView: View { private func start(_ runnable: Runnable, entryID: String) { switch runnable { case .task(let request, let title, true): - argumentsPrompt = ArgumentsPrompt(entryID: entryID, title: title, request: request) + argumentsPrompt = ArgumentsPrompt(entryID: entryID, title: title, request: .task(request)) return case .task(let request, let title, false): feature.runTask(entryID, request: request, title: title) @@ -1005,6 +1010,11 @@ struct BuildSystemToolWindowView: View { case .debugApplication(let request, let title): model.debugBuildProviderApplication(entryID: entryID, request: request, title: title) case .application(let request, let title): + if request.userArguments.isEmpty, + feature.entries.first(where: { $0.id == entryID })?.declaration.providerRuns.first(where: { $0.id == request.run })?.execution == .task { + argumentsPrompt = ArgumentsPrompt(entryID: entryID, title: title, request: .run(request)) + return + } model.runBuildProviderApplication(entryID: entryID, request: request, title: title) case .discoveredTest(let testID, let title): feature.runDiscoveredTest(entryID, testID: testID, title: title) @@ -1062,7 +1072,11 @@ private struct ArgumentsPrompt: Identifiable { let id = UUID() let entryID: String let title: String - let request: BuildTaskRequest + enum Request { + case task(BuildTaskRequest) + case run(BuildRunRequest) + } + let request: Request } private extension BuildTaskRequest { diff --git a/macos/Sources/Lithe/Views/Run/JavaRunConfigurationEditorView.swift b/macos/Sources/Lithe/Views/Run/JavaRunConfigurationEditorView.swift index c064b45874..534d1d1731 100644 --- a/macos/Sources/Lithe/Views/Run/JavaRunConfigurationEditorView.swift +++ b/macos/Sources/Lithe/Views/Run/JavaRunConfigurationEditorView.swift @@ -1,4 +1,5 @@ import Foundation +import LitheCoreContracts import LitheModuleAPI import UniformTypeIdentifiers import SwiftUI @@ -56,7 +57,7 @@ struct RunConfigurationEditorView: View { Rectangle().fill(LitheTheme.divider).frame(height: 1) HStack(spacing: LitheTheme.ActionButton.spacing) { Button("Reset") { - options = RunOptions() + options = configuration.buildProvider == nil ? RunOptions() : options.resettingProviderRunOptions() environmentText = "" } .buttonStyle(.litheNoPress) @@ -78,11 +79,8 @@ struct RunConfigurationEditorView: View { Button("Save") { options.environment = Self.environment(from: environmentText) guard let scopedOptions = scopedOptionsForSave() else { return } - // The runtime settings mirror the project defaults and are - // their source until `.lithe/run/local.json` saves them. if feature.saveEditorChanges( scopedOptions, - toolchain: model.runtimeFeature.projectToolchainSelection, for: configuration, scope: saveScope ) { @@ -354,7 +352,9 @@ struct RunConfigurationEditorView: View { private var argumentsSection: some View { section(title: "Arguments") { - if effectiveCapabilities.contains(.javaVMArguments) { + if configuration.buildProvider != nil { + ProviderRunOptionsEditor(value: $options.providerRunOptions) + } else if effectiveCapabilities.contains(.javaVMArguments) { argumentField( title: "VM options", placeholder: "-Xmx1g -Dserver.port=8080", diff --git a/macos/Sources/Lithe/Views/Run/ProviderRunOptionsEditor.swift b/macos/Sources/Lithe/Views/Run/ProviderRunOptionsEditor.swift new file mode 100644 index 0000000000..22a1f64c7d --- /dev/null +++ b/macos/Sources/Lithe/Views/Run/ProviderRunOptionsEditor.swift @@ -0,0 +1,59 @@ +import LitheCoreContracts +import SwiftUI + +/// Each row remains one argument. Quotes, whitespace and empty values are +/// ordinary provider data, not a command line the view interprets. +struct ProviderRunOptionsEditor: View { + @Binding var value: BuildProviderRunOptions? + + private var arguments: [String] { value?.runtimeArguments ?? [] } + private func update(_ edit: (inout BuildProviderRunOptions) -> Void) { + var next = value ?? BuildProviderRunOptions() + edit(&next) + value = next + } + + var body: some View { + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 4) { + Text("Runtime arguments") + .font(LitheTheme.uiFont(size: 11.5)) + .foregroundStyle(LitheTheme.secondaryText) + LitheInfoTip("Each row is one argument. Spaces, quotes and empty values are preserved.") + } + ScrollView { + LazyVStack(spacing: 4) { + ForEach(Array(arguments.indices), id: \.self) { index in + HStack(spacing: 4) { + LitheTextEditor(text: Binding( + get: { arguments.indices.contains(index) ? arguments[index] : "" }, + set: { text in update { if $0.runtimeArguments.indices.contains(index) { $0.runtimeArguments[index] = text } } } + ), height: 22) + .accessibilityLabel(Text("Runtime arguments")) + Button { update { if $0.runtimeArguments.indices.contains(index) { $0.runtimeArguments.remove(at: index) } } } label: { + LitheIDEAIcon(resourcePath: "expui/general/closeSmall.svg", size: 16, preservesOriginalColors: true) + } + .litheIconButton() + .help("Remove") + } + .frame(height: 32) + } + } + } + .frame(height: CGFloat(min(arguments.count, 6)) * 36) + .litheScrollViewChrome(hideHorizontal: true) + Button { update { $0.runtimeArguments.append("") } } label: { + Label { Text("Add argument") } icon: { + LitheIDEAIcon(resourcePath: "expui/general/add.svg", size: 16, preservesOriginalColors: true) + } + } + .buttonStyle(LitheActionButtonStyle()) + .disabled(arguments.count >= 1024) + Text("Entry point") + .font(LitheTheme.uiFont(size: 11.5)) + .foregroundStyle(LitheTheme.secondaryText) + LitheTextField("", text: Binding(get: { value?.entrypoint ?? "" }, set: { text in update { $0.entrypoint = text } })) + .accessibilityLabel(Text("Entry point")) + } + } +} diff --git a/macos/Sources/Lithe/Views/Run/RunView.swift b/macos/Sources/Lithe/Views/Run/RunView.swift index 24cc3d48c5..3c28399156 100644 --- a/macos/Sources/Lithe/Views/Run/RunView.swift +++ b/macos/Sources/Lithe/Views/Run/RunView.swift @@ -191,7 +191,7 @@ struct RunView: View { "exclamationmark.triangle.fill" ) } - if let diagnostic = feature.configurationDiagnostics.first(where: { $0.code == "staleFingerprint" }) { + if let diagnostic = feature.configurationDiagnostics.first(where: { ["staleFingerprint", "staleProviderModel", "providerModelNotReady"].contains($0.code) }) { return ( runText("Run configurations may be out of date"), diagnostic.message, @@ -267,7 +267,7 @@ struct RunView: View { LitheBanner(status: .neutralMuted, layout: .editor, iconStatus: .warning) { Text(notice.title).litheBannerTitle() Text(notice.message).lineLimit(2) - if feature.configurationDiagnostics.contains(where: { $0.code == "staleFingerprint" }) { + if feature.configurationDiagnostics.contains(where: { ["staleFingerprint", "staleProviderModel"].contains($0.code) }) { Button("Identify Again", action: UserActionTrace.action("run.configurations.generate") { feature.requestRunConfigurationGeneration() }) } else if feature.blockingToolchainDiagnostic != nil { @@ -436,11 +436,9 @@ struct RunView: View { } Button(action: UserActionTrace.action("run.panel.stop") { - if let session = selectedModuleSession { - feature.stopModule(session) - } else { - model.stopSelectedRun() - } + if model.isPreparingRun { model.stopSelectedRun() } + else if let session = selectedModuleSession { feature.stopModule(session) } + else { model.stopSelectedRun() } }) { LitheIDEAIcon(resourcePath: "expui/run/stop.svg", size: 16, fallbackSystemImage: "stop.fill", preservesOriginalColors: true) @@ -560,6 +558,7 @@ struct RunView: View { } private var selectedSessionIsRunning: Bool { + if model.isPreparingRun { return true } guard !primaryOutputSelected else { return primaryRunMatchesSelection && feature.isRunning } return selectedModuleSession?.isRunning ?? false } diff --git a/macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift b/macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift index 407efa48d2..0dbd74709f 100644 --- a/macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift +++ b/macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift @@ -41,7 +41,7 @@ enum WorkbenchModuleUIComposition { if model.genericDebugFeatureIfActive?.isSessionActive == true { model.stopDebugging() } else { model.stopSelectedRun() } }, state: { model in - let active = model.genericDebugFeatureIfActive?.isSessionActive == true + let active = model.isPreparingExecution || model.genericDebugFeatureIfActive?.isSessionActive == true || model.runFeatureIfActive?.isSelectedConfigurationRunning == true return .init(isVisible: active, isEnabled: active) }) diff --git a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift index a89472132e..cb2e1d54b1 100644 --- a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift +++ b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift @@ -609,16 +609,7 @@ struct WorkbenchView: View { copyPath: { model.copyProjectItemPath($0, relative: $1) }, relativePath: { model.relativePath(for: $0) }, moduleLabel: { url in - let path = url.standardizedFileURL.path - if let project = model.mavenFeatureIfActive?.project { - let owning = project.allModules - .filter { path.hasPrefix($0.url.standardizedFileURL.path + "/") } - .max { $0.url.standardizedFileURL.path.count < $1.url.standardizedFileURL.path.count } - if let owning { return owning.displayName } - if path.hasPrefix(project.rootURL.standardizedFileURL.path + "/") { - return project.displayName - } - } + if let name = model.buildSystemsFeatureIfActive?.projectName(containing: url) { return name } return model.relativePath(for: url).components(separatedBy: "/").first ?? "" }, actionIcon: { action in diff --git a/macos/Sources/LitheCoreContracts/Debug/DebugAdapterContracts.swift b/macos/Sources/LitheCoreContracts/Debug/DebugAdapterContracts.swift index 0b6135c543..287cf7cd94 100644 --- a/macos/Sources/LitheCoreContracts/Debug/DebugAdapterContracts.swift +++ b/macos/Sources/LitheCoreContracts/Debug/DebugAdapterContracts.swift @@ -244,18 +244,40 @@ public struct JavaDebugLaunchTarget: Codable, Hashable, Sendable { public let projectName: String? public let modulePaths: [String] public let classPaths: [String] + /// The host completed the current build provider preparation before JDT launch resolution. + public let buildPrepared: Bool + + public init(mainClass: String, projectName: String?, modulePaths: [String] = [], classPaths: [String] = []) { + self.init(mainClass: mainClass, projectName: projectName, modulePaths: modulePaths, classPaths: classPaths, buildPrepared: false) + } public init( mainClass: String, projectName: String?, modulePaths: [String] = [], - classPaths: [String] = [] + classPaths: [String] = [], + buildPrepared: Bool ) { self.mainClass = mainClass self.projectName = projectName self.modulePaths = modulePaths self.classPaths = classPaths + self.buildPrepared = buildPrepared } + + private enum CodingKeys: String, CodingKey { case mainClass, projectName, modulePaths, classPaths, buildPrepared } + + /// Core's shared Java preparation has no host resource receipt. An absent + /// receipt must retain Maven preparation rather than bypass it. + public init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + self.init(mainClass: try values.decode(String.self, forKey: .mainClass), + projectName: try values.decodeIfPresent(String.self, forKey: .projectName), + modulePaths: try values.decode([String].self, forKey: .modulePaths), + classPaths: try values.decode([String].self, forKey: .classPaths), + buildPrepared: try values.decodeIfPresent(Bool.self, forKey: .buildPrepared) ?? false) + } + } /// Java test runner family reported by the JDT LS Java Test extension. diff --git a/macos/Sources/LitheCoreContracts/Execution/BuildNativeConfiguration.swift b/macos/Sources/LitheCoreContracts/Execution/BuildNativeConfiguration.swift new file mode 100644 index 0000000000..b874d0f70e --- /dev/null +++ b/macos/Sources/LitheCoreContracts/Execution/BuildNativeConfiguration.swift @@ -0,0 +1,97 @@ +import Foundation +import LitheModuleAPI + +/// Non-sensitive native facts from the selected engine. Full native documents +/// stay in the platform file adapter and never enter the provider wire or UI. +package struct BuildNativeConfiguration: Codable, Equatable, Sendable { + package struct Field: Codable, Equatable, Sendable, Identifiable { + package enum Kind: String, Codable, Sendable { case text, file, directory, boolean, stringList } + package let id: String + package let kind: Kind + package let value: ToolingJSONValue + package let effectiveValue: ToolingJSONValue + package let effectiveSource: String + } + package struct Source: Codable, Equatable, Sendable, Identifiable { + package enum Scope: String, Codable, Sendable { case installation, user, workspace } + package let id: String + package let scope: Scope + package let uri: String + package let revision: String + package let writable: Bool + package let fields: [Field] + } + package let format: String + package let version: Int + package let sources: [Source] +} + +package struct BuildNativeConfigurationEdits: Codable, Equatable, Sendable { + package let sourceID: String + package let revision: String + package let values: [String: ToolingJSONValue] + package init(sourceID: String, revision: String, values: [String: ToolingJSONValue]) { + self.sourceID = sourceID; self.revision = revision; self.values = values + } +} + +/// Public receipt; native source/replacement bytes never enter the wire. +package struct BuildNativeConfigurationEditReceipt: Decodable, Sendable { + package let sourceID: String + package let revision: String + package let changed: Bool +} + +/// Private staging ownership retained through late replies and cancellation. +package struct BuildNativeConfigurationStaging: Sendable { + package let sourceID: String + package let sourceURL: URL + package let url: URL + package let expected: Data? + package let outputFile: URL + package init(sourceID: String, sourceURL: URL, url: URL, expected: Data?, outputFile: URL) { + self.sourceID = sourceID; self.sourceURL = sourceURL; self.url = url + self.expected = expected; self.outputFile = outputFile + } +} + +/// A prepared write contains native bytes, exclusively for the storage adapter. +/// Preparation runs on a worker; commit follows a current-operation check on +/// the owner actor so a workspace switch cannot publish an old draft. +package struct BuildNativeConfigurationPreparedWrite: Sendable { + package let url: URL + package let sourceURL: URL + package let expected: Data? + package let replacement: Data + package let revision: String + package init(url: URL, sourceURL: URL? = nil, expected: Data?, replacement: Data, revision: String) { + self.url = url; self.sourceURL = sourceURL ?? url + self.expected = expected; self.replacement = replacement; self.revision = revision + } +} + +package protocol BuildNativeConfigurationStoring: Sendable { + func prepare(target: BuildSystemTarget, snapshot: BuildNativeConfiguration, + edits: BuildNativeConfigurationEdits, stateDirectory: URL) throws -> BuildNativeConfigurationStaging? + func complete(_ staging: BuildNativeConfigurationStaging, receipt: BuildNativeConfigurationEditReceipt) throws -> BuildNativeConfigurationPreparedWrite? + func discard(_ staging: BuildNativeConfigurationStaging) throws + func commit(_ write: BuildNativeConfigurationPreparedWrite) throws +} + +package extension BuildSystemPlanning { + func configurationPlan(_ target: BuildSystemTarget, context: BuildPlanContext, + hints: BuildProviderHints) throws -> BuildProviderRequest { + throw PluginFunctionFailure(code: "plugin.build.unavailable", message: L10n.string("Native configuration is unavailable.")) + } + func decodeConfiguration(_ target: BuildSystemTarget, result: ToolingJSONValue) throws -> BuildNativeConfiguration { + throw PluginFunctionFailure(code: "plugin.build.unavailable", message: L10n.string("Native configuration is unavailable.")) + } + func configurationEditPlan(_ target: BuildSystemTarget, context: BuildPlanContext, hints: BuildProviderHints, + snapshot: BuildNativeConfiguration, edits: BuildNativeConfigurationEdits, outputFile: URL) throws -> BuildProviderRequest { + throw PluginFunctionFailure(code: "plugin.build.unavailable", message: L10n.string("Native configuration is unavailable.")) + } + func decodeConfigurationEdit(_ target: BuildSystemTarget, snapshot: BuildNativeConfiguration, + edits: BuildNativeConfigurationEdits, result: ToolingJSONValue) throws -> BuildNativeConfigurationEditReceipt { + throw PluginFunctionFailure(code: "plugin.build.unavailable", message: L10n.string("Native configuration is unavailable.")) + } +} diff --git a/macos/Sources/LitheCoreContracts/Execution/BuildSystemContracts.swift b/macos/Sources/LitheCoreContracts/Execution/BuildSystemContracts.swift index 4a4c62e305..183195a1de 100644 --- a/macos/Sources/LitheCoreContracts/Execution/BuildSystemContracts.swift +++ b/macos/Sources/LitheCoreContracts/Execution/BuildSystemContracts.swift @@ -35,6 +35,13 @@ public enum BuildExecution: String, Codable, Hashable, Sendable { /// What a plugin's build system offers, read from the normalized manifest. /// Command templates stay opaque: Rust Core expands them. public struct DeclarativeBuildSystem: Equatable, Sendable { + public struct TaskPreparation: Equatable, Sendable, Decodable { + public let purpose: String + public let sourceRoles: [String] + public init(purpose: String, sourceRoles: [String]) { + self.purpose = purpose; self.sourceRoles = sourceRoles + } + } public struct Task: Equatable, Identifiable, Sendable { public let id: String public let displayName: String @@ -49,16 +56,18 @@ public struct DeclarativeBuildSystem: Equatable, Sendable { /// empty files it by `group`. On a `fromModel` template it is the /// default of the model tasks. public let category: [String] + public let preparation: TaskPreparation? public init( id: String, displayName: String, group: BuildTaskGroup, scopes: [BuildScope], fromModel: Bool, targetTags: [String], requiresUserArguments: Bool, description: String?, - category: [String] = [] + category: [String] = [], preparation: TaskPreparation? = nil ) { self.id = id; self.displayName = displayName; self.group = group; self.scopes = scopes self.fromModel = fromModel; self.targetTags = targetTags self.requiresUserArguments = requiresUserArguments; self.description = description self.category = category + self.preparation = preparation } } @@ -73,15 +82,20 @@ public struct DeclarativeBuildSystem: Equatable, Sendable { /// The display part of an option declaration. Values, kinds, and /// choices come from `build.resolveOptions`. + public enum OptionScope: String, Equatable, Sendable, Decodable { + case workspace, instance + } + public struct Option: Equatable, Identifiable, Sendable { public let id: String + public let scope: OptionScope /// Nil for an option shown only in the option list, and for a /// placement from a newer contract this host does not know. public let presentation: OptionPresentation? public let icon: PluginUIIcon? - public init(id: String, presentation: OptionPresentation? = nil, icon: PluginUIIcon? = nil) { - self.id = id; self.presentation = presentation; self.icon = icon + public init(id: String, presentation: OptionPresentation? = nil, icon: PluginUIIcon? = nil, scope: OptionScope = .workspace) { + self.id = id; self.presentation = presentation; self.icon = icon; self.scope = scope } } @@ -102,13 +116,17 @@ public struct DeclarativeBuildSystem: Equatable, Sendable { public let testScopes: [BuildScope] /// Tags a target must carry to run its tests. public let testTargetTags: [String] + /// Reports arrive with the completing provider operation; no host file discovery. + public let providerTestReports: Bool public struct ProviderRun: Equatable, Identifiable, Sendable { public let id: String public let displayName: String public let targetTags: [String] public let debugAdapter: String? - public init(id: String, displayName: String, targetTags: [String], debugAdapter: String? = nil) { - self.id = id; self.displayName = displayName; self.targetTags = targetTags; self.debugAdapter = debugAdapter + public let debugConfigurationType: String? + public let execution: BuildExecution + public init(id: String, displayName: String, targetTags: [String], debugAdapter: String? = nil, debugConfigurationType: String? = nil, execution: BuildExecution = .application) { + self.id = id; self.displayName = displayName; self.targetTags = targetTags; self.debugAdapter = debugAdapter; self.debugConfigurationType = debugConfigurationType; self.execution = execution } } /// Optional public producer identities; never installation dependencies or private function access. @@ -121,22 +139,28 @@ public struct DeclarativeBuildSystem: Equatable, Sendable { public let modelInputs: [ModelInput] public let providerRuns: [ProviderRun] public let testDiscovery: Bool + public let testSelectorKinds: [String] /// Empty when the build system lists no dependencies. public let dependencyScopes: [BuildScope] /// The build system declares a provider launch. Installation verifies /// its packaged files before the provider can ever start. public let declaresProvider: Bool + /// The provider exposes source-backed, non-sensitive native configuration. + public let nativeConfiguration: Bool public init( id: String, displayName: String, icon: PluginUIIcon? = nil, modelSource: ModelSource?, options: [Option] = [], tasks: [Task], testScopes: [BuildScope], testTargetTags: [String] = [], - dependencyScopes: [BuildScope], declaresProvider: Bool = false, testDiscovery: Bool = false, providerRuns: [ProviderRun] = [], sharesModel: Bool = false, modelInputs: [ModelInput] = [] + dependencyScopes: [BuildScope], declaresProvider: Bool = false, testDiscovery: Bool = false, providerRuns: [ProviderRun] = [], sharesModel: Bool = false, modelInputs: [ModelInput] = [], nativeConfiguration: Bool = false, providerTestReports: Bool = false, testSelectorKinds: [String] = [] ) { self.id = id; self.displayName = displayName; self.icon = icon; self.modelSource = modelSource self.options = options; self.tasks = tasks self.testScopes = testScopes; self.testTargetTags = testTargetTags; self.dependencyScopes = dependencyScopes self.declaresProvider = declaresProvider + self.nativeConfiguration = nativeConfiguration + self.providerTestReports = providerTestReports self.testDiscovery = testDiscovery + self.testSelectorKinds = testSelectorKinds self.sharesModel = sharesModel; self.modelInputs = modelInputs self.providerRuns = providerRuns } @@ -480,11 +504,17 @@ package struct BuildInvocation: Equatable, Sendable { package let versionArguments: [String]? package let minimumVersion: String? + /// Operation-owned choices supplied by a saved Run, never by the manifest. + package var toolSelections: [String: FixedToolchainSelection] = [:] + /// Installed contribution whose defaults this operation consumes. + package var toolConsumerID: String? = nil + /// After resolution fails, a provider must not discard a configured tool /// or runtime by doing its own lookup. Check this only on failure: an /// earlier successful candidate still wins over a later setting. package var hasExplicitExecutableSelection: Bool { (executables + runtimeDependencies.flatMap(\.executables)).contains { + if case .toolchain = $0 { return true } if case .setting(_, let path) = $0 { return path.map { !$0.isEmpty } ?? false } return false } @@ -517,10 +547,13 @@ package struct ResolvedBuildTool: Equatable, Sendable { /// True for a wrapper inside the project, whatever the plugin declares. package let executesProjectCode: Bool package let version: String? + /// Only selected runtime/tool lookup inputs; never the complete host environment. + package let providerEnvironment: [String: String] - package init(executableURL: URL, environment: [String: String], executesProjectCode: Bool, version: String?) { + package init(executableURL: URL, environment: [String: String], executesProjectCode: Bool, version: String?, providerEnvironment: [String: String] = [:]) { self.executableURL = executableURL; self.environment = environment self.executesProjectCode = executesProjectCode; self.version = version + self.providerEnvironment = providerEnvironment } } @@ -584,10 +617,11 @@ package struct BuildResolvedOption: Equatable, Identifiable, Sendable, Decodable package let displayName: String /// Nil for a kind this host does not know; such an option is not shown. package let kind: Kind? + package let scope: DeclarativeBuildSystem.OptionScope package let value: ToolingJSONValue /// `user` when the value came from the stored options. package let source: String - package let choices: [Choice] + package var choices: [Choice] package let allowCustom: Bool package init(from decoder: Decoder) throws { @@ -595,13 +629,14 @@ package struct BuildResolvedOption: Equatable, Identifiable, Sendable, Decodable id = try values.decode(String.self, forKey: .id) displayName = try values.decode(String.self, forKey: .displayName) kind = Kind(rawValue: try values.decode(String.self, forKey: .kind)) + scope = try values.decodeIfPresent(DeclarativeBuildSystem.OptionScope.self, forKey: .scope) ?? .workspace value = try values.decodeIfPresent(ToolingJSONValue.self, forKey: .value) ?? .null source = try values.decodeIfPresent(String.self, forKey: .source) ?? "unset" choices = try values.decodeIfPresent([Choice].self, forKey: .choices) ?? [] allowCustom = try values.decodeIfPresent(Bool.self, forKey: .allowCustom) ?? false } - private enum CodingKeys: String, CodingKey { case id, displayName, kind, value, source, choices, allowCustom } + private enum CodingKeys: String, CodingKey { case id, displayName, kind, scope, value, source, choices, allowCustom } } package struct BuildOptionsResolution: Equatable, Sendable, Decodable { @@ -616,11 +651,27 @@ package struct BuildOptionsResolution: Equatable, Sendable, Decodable { /// it is stored separately so sibling-only choices survive. @MainActor package protocol BuildOptionsStoring: AnyObject { - func options(workspaceURL: URL, buildSystemID: String) -> [String: ToolingJSONValue] - func setOptions(_ options: [String: ToolingJSONValue], workspaceURL: URL, buildSystemID: String) + /// Transfers retired platform preferences only after passive instance discovery. + /// A changed destination forces detection to restart before publishing inputs. + func migrateLegacyConfiguration(workspaceURL: URL, instances: [BuildSystemInstance]) throws -> Bool + func options(workspaceURL: URL, buildSystemID: String) throws -> [String: ToolingJSONValue] + func setOptions(_ options: [String: ToolingJSONValue], workspaceURL: URL, buildSystemID: String) throws + /// Independent values for options declared with scope=instance, keyed by root. + func instanceOptions(workspaceURL: URL, buildSystemID: String) throws -> [String: [String: ToolingJSONValue]] + func setInstanceOptions(_ options: [String: [String: ToolingJSONValue]], workspaceURL: URL, buildSystemID: String) throws + /// Workspace defaults, including machine-local differences, overridden by root values. + func instanceDefaults(workspaceURL: URL, buildSystemID: String) throws -> [String: ToolingJSONValue] + /// Unselected user-defined choices, offered only by options that permit custom values. + func savedChoices(workspaceURL: URL, buildSystemID: String) throws -> [String: [String]] /// Option IDs explicitly cleared at each workspace-relative instance root. - func emptyMultiSelectOptions(workspaceURL: URL, buildSystemID: String) -> [String: Set] - func setEmptyMultiSelectOptions(_ options: [String: Set], workspaceURL: URL, buildSystemID: String) + func emptyMultiSelectOptions(workspaceURL: URL, buildSystemID: String) throws -> [String: Set] + func setEmptyMultiSelectOptions(_ options: [String: Set], workspaceURL: URL, buildSystemID: String) throws +} + +package extension BuildOptionsStoring { + func migrateLegacyConfiguration(workspaceURL: URL, instances: [BuildSystemInstance]) throws -> Bool { false } + func savedChoices(workspaceURL: URL, buildSystemID: String) throws -> [String: [String]] { [:] } + func instanceDefaults(workspaceURL: URL, buildSystemID: String) throws -> [String: ToolingJSONValue] { [:] } } // MARK: Plans @@ -699,13 +750,18 @@ package struct BuildTaskRequest: Equatable, Sendable { package let target: BuildSelection? package let modelTask: BuildSelection? package let userArguments: [String] + /// Per-operation values; never persisted or used to replace current model facts. + package let optionOverrides: [String: ToolingJSONValue] + package let tool: BuildProviderHints? package init( task: String, project: BuildSelection? = nil, target: BuildSelection? = nil, - modelTask: BuildSelection? = nil, userArguments: [String] = [] + modelTask: BuildSelection? = nil, userArguments: [String] = [], + optionOverrides: [String: ToolingJSONValue] = [:], tool: BuildProviderHints? = nil ) { self.task = task; self.project = project; self.target = target self.modelTask = modelTask; self.userArguments = userArguments + self.optionOverrides = optionOverrides; self.tool = tool } } @@ -729,16 +785,50 @@ package struct BuildProviderRunReference: Codable, Hashable, Sendable { package let projectID: String package let targetID: String package let userArguments: [String] + package let execution: BuildExecution? package init(pluginID: String, buildSystemID: String, root: String, run: String, - projectID: String, targetID: String, userArguments: [String] = []) { + projectID: String, targetID: String, userArguments: [String] = [], execution: BuildExecution? = nil) { self.pluginID = pluginID; self.buildSystemID = buildSystemID; self.root = root; self.run = run - self.projectID = projectID; self.targetID = targetID; self.userArguments = userArguments + self.projectID = projectID; self.targetID = targetID; self.userArguments = userArguments; self.execution = execution } package var request: BuildRunRequest { BuildRunRequest(run: run, projectID: projectID, targetID: targetID, userArguments: userArguments) } } +/// Version-10 user choices. Providers own their interpretation and the host owns the process. +package struct BuildLaunchOptions: Codable, Equatable, Sendable { + /// Version 11 carries a registry-selected adapter and a host-owned local endpoint. + package struct Debug: Codable, Equatable, Sendable { + package struct Target: Codable, Equatable, Sendable { + package let kind: String + package let host: String + package let port: UInt16 + } + package let adapterID: String + package let configurationType: String + package let target: Target + package init(adapterID: String, configurationType: String, port: UInt16) { + self.adapterID = adapterID; self.configurationType = configurationType + self.target = .init(kind: "connection", host: "127.0.0.1", port: port) + } + } + package let execution: BuildExecution + package var runtimeArguments: [String] + package var arguments: [String] + package var environment: [String: String] + package var workingDirectory: String? + package var entrypoint: String? + package var debug: Debug? + + package init(execution: BuildExecution, runtimeArguments: [String] = [], arguments: [String] = [], + environment: [String: String] = [:], workingDirectory: String? = nil, entrypoint: String? = nil, debug: Debug? = nil) { + self.execution = execution; self.runtimeArguments = runtimeArguments; self.arguments = arguments + self.environment = environment; self.workingDirectory = workingDirectory; self.entrypoint = entrypoint + self.debug = debug + } +} + /// Only Core-validated, portable process data crosses into the Run host. package struct BuildRunPreparation: Codable, Equatable, Sendable { package struct Executable: Codable, Equatable, Sendable { @@ -769,11 +859,23 @@ package struct BuildDebugPreparation: Decodable, Equatable, Sendable { @MainActor package protocol BuildRunLaunching: AnyObject { + func prepareRunExecutable(_ plan: BuildRunPreparation, workspaceURL: URL, configuration: RunConfiguration?) async throws -> ResolvedRunExecutable? func launchPreparedRun(_ plan: BuildRunPreparation, workspaceURL: URL, title: String, configuration: RunConfiguration?) throws -> String + func launchPreparedRun(_ plan: BuildRunPreparation, workspaceURL: URL, title: String, configuration: RunConfiguration?, resolvedExecutable: ResolvedRunExecutable?) throws -> String func stopPreparedRun(_ operationID: String) } +package extension BuildRunLaunching { + func launchPreparedRun(_ plan: BuildRunPreparation, workspaceURL: URL, title: String, configuration: RunConfiguration?, resolvedExecutable: ResolvedRunExecutable?) throws -> String { + try launchPreparedRun(plan, workspaceURL: workspaceURL, title: title, configuration: configuration) + } + + func prepareRunExecutable(_ plan: BuildRunPreparation, workspaceURL: URL, configuration: RunConfiguration?) async throws -> ResolvedRunExecutable? { nil } +} + package struct BuildTestRequest: Equatable, Sendable { + package let selectorKind: String? + package let selectors: [String] package let project: BuildSelection? package let target: BuildSelection? package let suite: String? @@ -785,10 +887,12 @@ package struct BuildTestRequest: Equatable, Sendable { package init( project: BuildSelection? = nil, target: BuildSelection? = nil, suite: String? = nil, - testCase: String? = nil, userArguments: [String] = [], projects: [String] = [] + testCase: String? = nil, userArguments: [String] = [], projects: [String] = [], + selectorKind: String? = nil, selectors: [String] = [] ) { self.project = project; self.target = target; self.suite = suite; self.testCase = testCase self.userArguments = userArguments; self.projects = projects + self.selectorKind = selectorKind; self.selectors = selectors } } @@ -875,9 +979,11 @@ package struct BuildProviderHints: Equatable, Sendable { /// The build tool resolved from the build system's invocation, so the /// provider need not repeat the lookup. Nil when resolution failed. package let toolExecutable: URL? + package let toolEnvironment: [String: String] - package init(reason: BuildLoadReason = .initial, toolExecutable: URL? = nil) { + package init(reason: BuildLoadReason = .initial, toolExecutable: URL? = nil, toolEnvironment: [String: String] = [:]) { self.reason = reason; self.toolExecutable = toolExecutable + self.toolEnvironment = toolEnvironment } } @@ -893,6 +999,7 @@ package struct BuildTestPlan: Equatable, Sendable { package enum Results: Equatable, Sendable { case reports(decoder: String, patterns: [String]) case hostIntegration + case provider } package let execution: BuildOutputPlan @@ -914,11 +1021,20 @@ package enum BuildCommandOutput: Equatable, Sendable { } package struct BuildTestResults: Equatable, Sendable, Decodable { - package struct TestCase: Equatable, Identifiable, Sendable, Decodable { - package enum Status: String, Equatable, Sendable { + /// Completed backend scope, independent of opaque language selectors. + package struct Coverage: Equatable, Sendable, Decodable { + package let project: String + package let suite: String? + package let testCase: String? + private enum CodingKeys: String, CodingKey { case project, suite; case testCase = "case" } + } + package let coverage: [Coverage]? + package struct TestCase: Equatable, Identifiable, Sendable, Codable { + package enum Status: String, Equatable, Sendable, Codable { case passed, failed, error, skipped } + package let project: String? package let suite: String package let name: String package let status: Status @@ -928,10 +1044,21 @@ package struct BuildTestResults: Equatable, Sendable, Decodable { package let path: String? package let line: Int? package let invocations: Int - package var id: String { suite + "#" + name } + package var id: String { + [project, suite, name].map { $0.map { "\($0.utf8.count):" + $0 } ?? "-" }.joined() + } + + package init(project: String? = nil, suite: String, name: String, status: Status, + message: String? = nil, durationMillis: Int? = nil, path: String? = nil, + line: Int? = nil, invocations: Int = 1) { + self.project = project; self.suite = suite; self.name = name; self.status = status + self.message = message; self.durationMillis = durationMillis; self.path = path + self.line = line; self.invocations = invocations + } package init(from decoder: Decoder) throws { let values = try decoder.container(keyedBy: CodingKeys.self) + project = try values.decodeIfPresent(String.self, forKey: .project) suite = try values.decode(String.self, forKey: .suite) name = try values.decode(String.self, forKey: .case) status = Status(rawValue: try values.decode(String.self, forKey: .status)) ?? .error @@ -942,8 +1069,21 @@ package struct BuildTestResults: Equatable, Sendable, Decodable { invocations = try values.decodeIfPresent(Int.self, forKey: .invocations) ?? 1 } + package func encode(to encoder: Encoder) throws { + var values = encoder.container(keyedBy: CodingKeys.self) + try values.encodeIfPresent(project, forKey: .project) + try values.encode(suite, forKey: .suite) + try values.encode(name, forKey: .case) + try values.encode(status, forKey: .status) + try values.encodeIfPresent(message, forKey: .message) + try values.encodeIfPresent(durationMillis, forKey: .durationMillis) + try values.encodeIfPresent(path, forKey: .path) + try values.encodeIfPresent(line, forKey: .line) + try values.encode(invocations, forKey: .invocations) + } + private enum CodingKeys: String, CodingKey { - case suite, `case`, status, message, durationMillis, path, line, invocations + case project, suite, `case`, status, message, durationMillis, path, line, invocations } } @@ -953,11 +1093,32 @@ package struct BuildTestResults: Equatable, Sendable, Decodable { package let failed: Int package let errors: Int package let skipped: Int + + package init(total: Int, passed: Int, failed: Int, errors: Int, skipped: Int) { + self.total = total; self.passed = passed; self.failed = failed + self.errors = errors; self.skipped = skipped + } } package let tests: [TestCase] package let summary: Summary package let success: Bool + + package init(tests: [TestCase], summary: Summary, success: Bool, coverage: [Coverage]? = nil) { + self.tests = tests; self.summary = summary; self.success = success; self.coverage = coverage + } + + /// Preserve normalized summaries when multiple build instances complete. + package static func combining(_ reports: [Self]) -> Self { + Self(tests: reports.flatMap(\.tests), summary: Summary( + total: reports.reduce(0) { $0 + $1.summary.total }, + passed: reports.reduce(0) { $0 + $1.summary.passed }, + failed: reports.reduce(0) { $0 + $1.summary.failed }, + errors: reports.reduce(0) { $0 + $1.summary.errors }, + skipped: reports.reduce(0) { $0 + $1.summary.skipped }), + success: reports.allSatisfy(\.success), + coverage: reports.contains { $0.coverage != nil } ? reports.flatMap { $0.coverage ?? [] } : nil) + } } package struct BuildTestResultsRequest: Equatable, Sendable { @@ -967,15 +1128,16 @@ package struct BuildTestResultsRequest: Equatable, Sendable { package let project: String? package let projects: [String] package let output: String + package let result: ToolingJSONValue? package let notBefore: Date? package let suite: String? package init( workspaceURL: URL, root: String, project: String?, projects: [String], output: String, - notBefore: Date?, suite: String? + notBefore: Date?, suite: String?, result: ToolingJSONValue? = nil ) { self.workspaceURL = workspaceURL; self.root = root; self.project = project - self.projects = projects; self.output = output; self.notBefore = notBefore; self.suite = suite + self.projects = projects; self.output = output; self.notBefore = notBefore; self.suite = suite; self.result = result } } @@ -1053,11 +1215,17 @@ package enum BuildLanguageImport: Equatable, Sendable, Decodable { /// `DeclarativePluginRejection`. Detection, decoding, and fingerprints read /// workspace files, so callers keep them off the main actor. package protocol BuildSystemPlanning: Sendable { + func configurationPlan(_ target: BuildSystemTarget, context: BuildPlanContext, hints: BuildProviderHints) throws -> BuildProviderRequest + func decodeConfiguration(_ target: BuildSystemTarget, result: ToolingJSONValue) throws -> BuildNativeConfiguration + func configurationEditPlan(_ target: BuildSystemTarget, context: BuildPlanContext, hints: BuildProviderHints, snapshot: BuildNativeConfiguration, edits: BuildNativeConfigurationEdits, outputFile: URL) throws -> BuildProviderRequest + func decodeConfigurationEdit(_ target: BuildSystemTarget, snapshot: BuildNativeConfiguration, edits: BuildNativeConfigurationEdits, result: ToolingJSONValue) throws -> BuildNativeConfigurationEditReceipt func resolveModelInputs(_ target: BuildSystemTarget, revision: UInt64, sources: [BuildModelInputSource]) throws -> ToolingJSONValue func detect( workspaceURL: URL, sources: [BuildSystemPluginSource], - options: [String: [String: ToolingJSONValue]] + options: [String: [String: ToolingJSONValue]], + instanceOptions: [String: [String: [String: ToolingJSONValue]]], + instanceDefaults: [String: [String: ToolingJSONValue]] ) throws -> BuildDetection func languageImport( _ target: BuildSystemTarget, @@ -1086,9 +1254,10 @@ package protocol BuildSystemPlanning: Sendable { func testPlan(_ target: BuildSystemTarget, context: BuildPlanContext, request: BuildTestRequest) throws -> BuildTestPlan func prepareRunPlan(_ target: BuildSystemTarget, context: BuildPlanContext, run: String, - project: BuildSelection, selectedTarget: BuildSelection, userArguments: [String]) throws -> BuildProviderRequest + project: BuildSelection, selectedTarget: BuildSelection, userArguments: [String], + workspaceURL: URL?, launchOptions: BuildLaunchOptions?) throws -> BuildProviderRequest func decodeRunPreparation(_ target: BuildSystemTarget, workspaceURL: URL, root: String, - result: ToolingJSONValue) throws -> BuildRunPreparation + result: ToolingJSONValue, launchOptions: BuildLaunchOptions?) throws -> BuildRunPreparation func prepareDebugPlan(_ target: BuildSystemTarget, context: BuildPlanContext, run: String, project: BuildSelection, selectedTarget: BuildSelection, userArguments: [String]) throws -> BuildProviderRequest func decodeDebugPreparation(_ target: BuildSystemTarget, workspaceURL: URL, root: String, run: String, @@ -1146,7 +1315,11 @@ package struct BuildProviderExecutionResult: Codable, Equatable, Sendable { package let exitCode: Int32 package let output: String - package init(exitCode: Int32, output: String) { self.exitCode = exitCode; self.output = output } + /// A provider-owned canonical report, still validated by the shared decoder. + package let testResults: ToolingJSONValue? + package init(exitCode: Int32, output: String, testResults: ToolingJSONValue? = nil) { + self.exitCode = exitCode; self.output = output; self.testResults = testResults + } } // MARK: Provider processes @@ -1175,6 +1348,8 @@ package struct BuildProviderTarget: Hashable, Sendable { /// to the host alone, over its own stdio channel. @MainActor package protocol BuildProviderRequesting: AnyObject { + /// Host-observed process root for owned worker sampling; never a backend-supplied PID. + func processID(for target: BuildProviderTarget) -> Int32? /// The provider's reply, still undecoded. Cancelling the task cancels /// the request in the process. func send( @@ -1210,6 +1385,7 @@ package protocol BuildProviderRequesting: AnyObject { } package extension BuildProviderRequesting { + func processID(for _: BuildProviderTarget) -> Int32? { nil } func prepareDebug(_ request: BuildProviderRequest, on target: BuildProviderTarget, timeout: Duration, progress: @escaping @MainActor @Sendable (BuildProviderProgress) -> Void) async throws -> ToolingJSONValue { throw PluginFunctionFailure(code: "plugin.build.unavailable", message: L10n.string("The build provider returned an invalid launch plan.")) @@ -1326,3 +1502,17 @@ package protocol BuildCommandRunning: Sendable { onOutput: @escaping @Sendable (String) -> Void ) async throws -> BuildCommandResult } + +// Legacy application callers retain version-4 preparation without inventing user choices. +package extension BuildSystemPlanning { + func prepareRunPlan(_ target: BuildSystemTarget, context: BuildPlanContext, run: String, + project: BuildSelection, selectedTarget: BuildSelection, userArguments: [String]) throws -> BuildProviderRequest { + try prepareRunPlan(target, context: context, run: run, project: project, selectedTarget: selectedTarget, + userArguments: userArguments, workspaceURL: nil, launchOptions: nil) + } + + func decodeRunPreparation(_ target: BuildSystemTarget, workspaceURL: URL, root: String, + result: ToolingJSONValue) throws -> BuildRunPreparation { + try decodeRunPreparation(target, workspaceURL: workspaceURL, root: root, result: result, launchOptions: nil) + } +} diff --git a/macos/Sources/LitheCoreContracts/Execution/ExecutableResolution.swift b/macos/Sources/LitheCoreContracts/Execution/ExecutableResolution.swift index 7fbd94ee33..0f9c734cc3 100644 --- a/macos/Sources/LitheCoreContracts/Execution/ExecutableResolution.swift +++ b/macos/Sources/LitheCoreContracts/Execution/ExecutableResolution.swift @@ -10,12 +10,18 @@ package enum ExecutableCandidate: Equatable, Sendable { case runtime(String) /// Nil is deliberately unconfigured. Core rejects invalid stored values. case setting(id: String, path: String?) + case toolchain(id: String, root: String) } package struct ResolvedExecutableCandidate: Equatable, Sendable { package let url: URL package let executesProjectCode: Bool package let terminal: Bool + package let environment: [String: String] + package init(url: URL, executesProjectCode: Bool, terminal: Bool, environment: [String: String] = [:]) { + self.url = url; self.executesProjectCode = executesProjectCode; self.terminal = terminal + self.environment = environment + } } package enum ExecutableResolution: Equatable, Sendable { @@ -32,11 +38,14 @@ package struct ExecutableCandidateResolver { package let isExecutable: (URL) -> Bool package let commands: (String) -> [URL] package let runtimes: (String) -> URL? + package let toolchains: (String, String) -> ExecutableResolution package init(fileExists: @escaping (URL) -> Bool, isExecutable: @escaping (URL) -> Bool, - commands: @escaping (String) -> [URL], runtimes: @escaping (String) -> URL?) { + commands: @escaping (String) -> [URL], runtimes: @escaping (String) -> URL?, + toolchains: @escaping (String, String) -> ExecutableResolution = { _, _ in .terminalFailure(L10n.string("The selected toolchain is unavailable.")) }) { self.fileExists = fileExists; self.isExecutable = isExecutable self.commands = commands; self.runtimes = runtimes + self.toolchains = toolchains } package func resolve(_ candidate: ExecutableCandidate, workspaceURL: URL, @@ -56,6 +65,7 @@ package struct ExecutableCandidateResolver { return found([URL(fileURLWithPath: base).appendingPathComponent(path).standardizedFileURL]) case .command(let command): return found(commands(command)) case .runtime(let id): return found(runtimes(id).map { [$0] } ?? []) + case .toolchain(let id, let root): return toolchains(id, root) case .setting(_, let path): guard let path, !path.isEmpty else { return .skip } guard path.hasPrefix("/"), path == path.trimmingCharacters(in: .whitespacesAndNewlines), diff --git a/macos/Sources/LitheCoreContracts/Execution/ExecutionContracts.swift b/macos/Sources/LitheCoreContracts/Execution/ExecutionContracts.swift index b667f09a16..d83d744024 100644 --- a/macos/Sources/LitheCoreContracts/Execution/ExecutionContracts.swift +++ b/macos/Sources/LitheCoreContracts/Execution/ExecutionContracts.swift @@ -1,5 +1,15 @@ import Foundation +/// Reversible editor text for Core's argv grammar, without expansion or execution. +package enum RunArgumentText { + package static func format(_ values: [String]) -> String { + values.map { value in + if !value.isEmpty, !value.unicodeScalars.contains(where: { $0.properties.isWhitespace || "\"'\\".unicodeScalars.contains($0) }) { return value } + return "\"" + value.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") + "\"" + }.joined(separator: " ") + } +} + package struct RunOptions: Codable, Hashable, Sendable { package struct JavaCapability: Codable, Hashable, Sendable { package var homePath = "" @@ -45,6 +55,14 @@ package struct RunOptions: Codable, Hashable, Sendable { package var arguments = "" package var environment: [String: String] = [:] package var java = JavaCapability() + package var providerRunOptions: BuildProviderRunOptions? + + /// Reset emits explicit clears for inherited per-Run tools. Omitting this + /// namespace would keep the previous provider choices on disk. + package func resettingProviderRunOptions() -> RunOptions { + let cleared = providerRunOptions?.tools.keys.map { ($0, Optional.none) } ?? [] + return RunOptions(providerRunOptions: BuildProviderRunOptions(tools: Dictionary(uniqueKeysWithValues: cleared))) + } package init( javaHomePath: String = "", @@ -55,11 +73,13 @@ package struct RunOptions: Codable, Hashable, Sendable { mavenSkipTests: Bool? = nil, mavenExecutablePath: String = "", mavenJavaHomePath: String = "", - environment: [String: String] = [:] + environment: [String: String] = [:], + providerRunOptions: BuildProviderRunOptions? = nil ) { self.workingDirectoryPath = workingDirectoryPath arguments = programArguments self.environment = environment + self.providerRunOptions = providerRunOptions java = JavaCapability( homePath: javaHomePath, mavenExecutablePath: mavenExecutablePath, @@ -100,7 +120,7 @@ package struct RunOptions: Codable, Hashable, Sendable { } private enum CodingKeys: String, CodingKey { - case workingDirectoryPath, arguments, environment, java + case workingDirectoryPath, arguments, environment, java, providerRunOptions case javaHomePath, vmArguments, programArguments, activeProfiles } @@ -111,6 +131,7 @@ package struct RunOptions: Codable, Hashable, Sendable { ?? container.decodeIfPresent(String.self, forKey: .programArguments) ?? "" environment = try container.decodeIfPresent([String: String].self, forKey: .environment) ?? [:] + providerRunOptions = try container.decodeIfPresent(BuildProviderRunOptions.self, forKey: .providerRunOptions) java = try container.decodeIfPresent(JavaCapability.self, forKey: .java) ?? JavaCapability( homePath: try container.decodeIfPresent(String.self, forKey: .javaHomePath) ?? "", vmArguments: try container.decodeIfPresent(String.self, forKey: .vmArguments) ?? "", @@ -124,6 +145,7 @@ package struct RunOptions: Codable, Hashable, Sendable { try container.encode(arguments, forKey: .arguments) try container.encode(environment, forKey: .environment) try container.encode(java, forKey: .java) + try container.encodeIfPresent(providerRunOptions, forKey: .providerRunOptions) } } @@ -230,12 +252,15 @@ package struct ResolvedRunExecutable: Sendable { @MainActor package protocol RunExecutableResolving: AnyObject { + /// Acquires fresh plugin-defined tools before the synchronous process handoff. + func prepare(_ plan: SharedLaunchPlan, projectURL: URL, options: RunOptions) async throws -> ResolvedRunExecutable? func resolve(_ plan: SharedLaunchPlan, projectURL: URL, options: RunOptions) throws -> ResolvedRunExecutable func refreshCandidates(projectURL: URL) async func candidates(projectURL: URL) -> [ProjectToolchainCandidate] } package extension RunExecutableResolving { + func prepare(_ plan: SharedLaunchPlan, projectURL: URL, options: RunOptions) async throws -> ResolvedRunExecutable? { nil } func refreshCandidates(projectURL _: URL) async {} func candidates(projectURL _: URL) -> [ProjectToolchainCandidate] { [] } @@ -274,14 +299,12 @@ package protocol RunRuntimePort: AnyObject { func javaHomeURL(overridePath: String?) -> URL? func mavenJavaHomeURL(overridePath: String?) -> URL? func runConfigurationToolchainCandidates( - for project: MavenProject?, projectRoot: URL?, javaHomeOverride: String?, mavenExecutableOverride: String? ) -> [ProjectToolchainCandidate] /// Project restoration awaits version probes instead of running them on the UI executor. func loadRunConfigurationToolchainCandidates( - for project: MavenProject?, projectRoot: URL?, javaHomeOverride: String?, mavenExecutableOverride: String? @@ -327,6 +350,13 @@ package enum LanguageTestItemKind: String, Equatable, Sendable { } package struct LanguageTestItem: Identifiable, Equatable, Sendable { + /// A language provider's explicit association with canonical report names. + /// This is presentation metadata; execution still forwards testIdentifier. + package struct ReportIdentity: Hashable, Sendable { + package let suite: String + package let name: String? + package init(suite: String, name: String? = nil) { self.suite = suite; self.name = name } + } package let id: String package let providerID: String package let label: String @@ -336,6 +366,9 @@ package struct LanguageTestItem: Identifiable, Equatable, Sendable { package let testIdentifier: String? /// Visual nesting below the provider section; source files start at zero. package let depth: Int + package var projectPath: String? + package var buildReference: BuildTestReference? + package let reportIdentity: ReportIdentity? package init( id: String, @@ -344,7 +377,8 @@ package struct LanguageTestItem: Identifiable, Equatable, Sendable { kind: LanguageTestItemKind, fileURL: URL?, testIdentifier: String? = nil, - depth: Int = 0 + depth: Int = 0, projectPath: String? = nil, buildReference: BuildTestReference? = nil, + reportIdentity: ReportIdentity? = nil ) { self.id = id self.providerID = providerID @@ -353,13 +387,39 @@ package struct LanguageTestItem: Identifiable, Equatable, Sendable { self.fileURL = fileURL self.testIdentifier = testIdentifier self.depth = max(0, depth) + self.projectPath = projectPath; self.buildReference = buildReference + self.reportIdentity = reportIdentity + } +} + +/// A report-derived build selector. The current model is revalidated on every +/// rerun; no executable, classpath, source path or cached launch is persisted. +package struct BuildTestReference: Codable, Equatable, Sendable { + package let pluginID: String + package let buildSystemID: String + package let root: String + package let projectID: String + package let suite: String + package let testCase: String? + + package init(pluginID: String, buildSystemID: String, root: String, projectID: String, suite: String, testCase: String?) { + self.pluginID = pluginID; self.buildSystemID = buildSystemID; self.root = root; self.projectID = projectID + self.suite = suite; self.testCase = testCase + } + package var suiteReference: Self { + Self(pluginID: pluginID, buildSystemID: buildSystemID, root: root, projectID: projectID, suite: suite, testCase: nil) } } package enum LanguageTestScope: Equatable, Sendable { case workspace case file(URL) - case testCase(identifier: String, fileURL: URL?) + case testCase(identifier: String, fileURL: URL?, buildReference: BuildTestReference? = nil) + + package var buildReference: BuildTestReference? { + if case .testCase(_, _, let reference) = self { return reference } + return nil + } } package struct LanguageTestContext: Equatable, Sendable { diff --git a/macos/Sources/LitheCoreContracts/Execution/MavenContracts.swift b/macos/Sources/LitheCoreContracts/Execution/MavenContracts.swift index 2f71298a15..2d2646e22e 100644 --- a/macos/Sources/LitheCoreContracts/Execution/MavenContracts.swift +++ b/macos/Sources/LitheCoreContracts/Execution/MavenContracts.swift @@ -1,115 +1,5 @@ import Foundation -package enum MavenSourceRootKind: String, CaseIterable, Identifiable, Hashable, Sendable { - case mainJava - case mainResources - case testJava - case testResources - case generatedMain - case generatedTest - - package var id: String { rawValue } - package var title: String { - switch self { - case .mainJava: "main Java" - case .mainResources: "main resources" - case .testJava: "test Java" - case .testResources: "test resources" - case .generatedMain: "generated main" - case .generatedTest: "generated test" - } - } -} - -package struct MavenSourceRoot: Identifiable, Hashable, Sendable { - package let path: String - package let kind: MavenSourceRootKind - - package init(path: String, kind: MavenSourceRootKind) { - self.path = path - self.kind = kind - } - - package var id: String { "\(kind.rawValue):\(path)" } -} - -package struct MavenProject: Identifiable, Hashable, Sendable { - package let rootURL: URL - package let pomURL: URL - package let groupID: String? - package let artifactID: String - package let version: String? - package let packaging: String - package let sourceRoots: [MavenSourceRoot] - package let modules: [MavenModule] - package let profiles: [MavenProfile] - package let hasWrapper: Bool - - package init( - rootURL: URL, - pomURL: URL, - groupID: String?, - artifactID: String, - version: String?, - packaging: String, - modules: [MavenModule], - profiles: [MavenProfile], - hasWrapper: Bool, - sourceRoots: [MavenSourceRoot] = [] - ) { - self.rootURL = rootURL - self.pomURL = pomURL - self.groupID = groupID - self.artifactID = artifactID - self.version = version - self.packaging = packaging - self.sourceRoots = sourceRoots - self.modules = modules - self.profiles = profiles - self.hasWrapper = hasWrapper - } - - package var id: String { rootURL.path } - package var displayName: String { artifactID.isEmpty ? rootURL.lastPathComponent : artifactID } - package var isMultiModule: Bool { !modules.isEmpty } - package var allModules: [MavenModule] { modules + modules.flatMap { $0.allModules } } -} - -package struct MavenModule: Identifiable, Hashable, Sendable { - package let relativePath: String - package let url: URL - package let groupID: String? - package let artifactID: String - package let version: String? - package let packaging: String - package let sourceRoots: [MavenSourceRoot] - package let modules: [MavenModule] - - package init( - relativePath: String, - url: URL, - groupID: String?, - artifactID: String, - version: String?, - packaging: String, - modules: [MavenModule], - sourceRoots: [MavenSourceRoot] = [] - ) { - self.relativePath = relativePath - self.url = url - self.groupID = groupID - self.artifactID = artifactID - self.version = version - self.packaging = packaging - self.sourceRoots = sourceRoots - self.modules = modules - } - - package var id: String { relativePath } - package var displayName: String { artifactID.isEmpty ? relativePath : artifactID } - package var allModules: [MavenModule] { modules + modules.flatMap { $0.allModules } } -} - package struct MavenProfile: Identifiable, Hashable, Sendable { package let id: String package let isActiveByDefault: Bool @@ -178,182 +68,6 @@ package struct MavenBuildIssue: Identifiable, Hashable, Sendable { } } -package struct MavenTestFailureDetail: Identifiable, Hashable, Sendable { - package let id: String - package let name: String - package let kind: String - package let message: String? - package let fileURL: URL? - package let line: Int? - package let column: Int? - - package init( - id: String, - name: String, - kind: String, - message: String?, - fileURL: URL?, - line: Int?, - column: Int? - ) { - self.id = id - self.name = name - self.kind = kind - self.message = message - self.fileURL = fileURL - self.line = line - self.column = column - } -} - -package struct MavenTestResults: Equatable, Sendable { - package let testsRun: Int - package let failures: Int - package let errors: Int - package let skipped: Int - package let passed: Int - package let success: Bool - package let failureDetails: [MavenTestFailureDetail] - /// Per-method outcomes read from the run's XML reports; empty when the - /// run was not identified or wrote no readable reports. - package let testCases: [MavenTestCaseOutcome] - - package init( - testsRun: Int, - failures: Int, - errors: Int, - skipped: Int, - passed: Int, - success: Bool, - failureDetails: [MavenTestFailureDetail], - testCases: [MavenTestCaseOutcome] = [] - ) { - self.testsRun = testsRun - self.failures = failures - self.errors = errors - self.skipped = skipped - self.passed = passed - self.success = success - self.failureDetails = failureDetails - self.testCases = testCases - } -} - -/// One test method's outcome from Core's `maven.testResults` reports. -/// -/// Note: 设计见 .agents/notes/implemented/architecture/2026-09-22-editor-run-markers-and-test-outcomes.md -package struct MavenTestCaseOutcome: Codable, Equatable, Sendable { - /// Binary class name, such as `demo.OrderTest$Refunds`. - package let className: String - package let method: String - /// `passed`, `failed`, `error`, or `skipped`. - package let status: String - package let message: String? - package let invocations: Int - package let durationMillis: Int? - - package init(className: String, method: String, status: String, message: String? = nil, invocations: Int = 1, durationMillis: Int? = nil) { - self.className = className - self.method = method - self.status = status - self.message = message - self.invocations = invocations - self.durationMillis = durationMillis - } -} - -/// Identifies the XML reports a finished Maven test run wrote. -package struct MavenTestReportRequest: Codable, Equatable, Sendable { - /// Workspace-relative module directory, when the run selected one. - package let module: String? - /// Workspace-relative test file; Core finds its module when `module` is nil. - package let sourcePath: String? - /// Binary class names; empty reads every report the run wrote. - package let classes: [String] - /// Run start in Unix milliseconds; older reports belong to earlier runs. - package let notBeforeMillis: UInt64 - - package init(module: String? = nil, sourcePath: String? = nil, classes: [String], notBeforeMillis: UInt64) { - self.module = module - self.sourcePath = sourcePath - self.classes = classes - self.notBeforeMillis = notBeforeMillis - } -} - -package enum MavenProjectLoadState: Equatable, Sendable { - case idle - case loading - case ready - case failed(String) -} - -package enum MavenTaskState: Equatable, Sendable { - case idle - case running - case stopping - case cancelled - case failed(String) -} - -package struct MavenPortableConfiguration: Codable, Equatable, Sendable { - package static let currentVersion = 1 - - package var version: Int - package var selectedProfiles: [String] - package var customProfiles: [String] - package var skipTests: Bool - - package init( - version: Int = currentVersion, - selectedProfiles: [String] = [], - customProfiles: [String] = [], - skipTests: Bool = false - ) { - self.version = version - self.selectedProfiles = selectedProfiles - self.customProfiles = customProfiles - self.skipTests = skipTests - } -} - -package struct MavenLocalConfiguration: Codable, Equatable, Sendable { - package static let currentVersion = 1 - - package var version: Int - package var settingsPath: String? - package var localRepositoryPath: String? - package var mavenExecutablePath: String? - package var javaHomePath: String? - - package init( - version: Int = currentVersion, - settingsPath: String? = nil, - localRepositoryPath: String? = nil, - mavenExecutablePath: String? = nil, - javaHomePath: String? = nil - ) { - self.version = version - self.settingsPath = settingsPath - self.localRepositoryPath = localRepositoryPath - self.mavenExecutablePath = mavenExecutablePath - self.javaHomePath = javaHomePath - } -} - -package struct MavenStoredConfiguration: Equatable, Sendable { - package let portable: MavenPortableConfiguration? - package let local: MavenLocalConfiguration? - - package init( - portable: MavenPortableConfiguration?, - local: MavenLocalConfiguration? - ) { - self.portable = portable - self.local = local - } -} - package struct MavenLaunchContext: Codable, Equatable, Sendable { package static let currentVersion = 1 @@ -400,105 +114,6 @@ package struct MavenLaunchContext: Codable, Equatable, Sendable { } } -package struct MavenLaunchPlan: Equatable, Sendable { - package let version: Int - package let toolchain: String - package let arguments: [String] - package let workingDirectory: String - package let configurationFingerprint: String - - package init( - version: Int, - toolchain: String, - arguments: [String], - workingDirectory: String, - configurationFingerprint: String - ) { - self.version = version - self.toolchain = toolchain - self.arguments = arguments - self.workingDirectory = workingDirectory - self.configurationFingerprint = configurationFingerprint - } -} - -package enum MavenDependencyResolution: String, Equatable, Sendable { - case resolved - case omittedDuplicate - case omittedConflict -} - -package struct MavenDependency: Equatable, Sendable { - package let modulePath: String - package let groupID: String - package let artifactID: String - package let version: String - package let type: String - package let classifier: String? - package let scope: String - package let resolution: MavenDependencyResolution - package let selectedVersion: String? - /// Version before dependency management replaced it with `version`. - package let premanagedVersion: String? - /// Scope before dependency management replaced it with `scope`. - package let premanagedScope: String? - /// Declared scope before mediation widened it to `scope`. - package let originalScope: String? - /// Wider scope from another path that mediation did not apply here. - package let ignoredScope: String? - package let children: [MavenDependency] - - package init( - modulePath: String, - groupID: String, - artifactID: String, - version: String, - type: String, - classifier: String?, - scope: String, - resolution: MavenDependencyResolution, - selectedVersion: String?, - premanagedVersion: String? = nil, - premanagedScope: String? = nil, - originalScope: String? = nil, - ignoredScope: String? = nil, - children: [MavenDependency] - ) { - self.modulePath = modulePath - self.groupID = groupID - self.artifactID = artifactID - self.version = version - self.type = type - self.classifier = classifier - self.scope = scope - self.resolution = resolution - self.selectedVersion = selectedVersion - self.premanagedVersion = premanagedVersion - self.premanagedScope = premanagedScope - self.originalScope = originalScope - self.ignoredScope = ignoredScope - self.children = children - } -} - -package struct MavenDependencyTree: Equatable, Sendable { - package let modulePath: String - package let dependencies: [MavenDependency] - - package init(modulePath: String, dependencies: [MavenDependency]) { - self.modulePath = modulePath - self.dependencies = dependencies - } -} - -package enum MavenDependencyLoadState: Equatable, Sendable { - case idle - case loading - case ready([MavenDependency]) - case failed(String) - case cancelled -} - package func redactedMavenArgumentsForDisplay(_ arguments: [String]) -> [String] { var result: [String] = [] var index = 0 @@ -542,96 +157,3 @@ package struct MavenOperationError: LocalizedError, Equatable, Sendable { return message + ": " + details } } - -package protocol MavenProjectOperations: Sendable { - func scanMavenProject(at rootURL: URL, files: [URL]) throws -> MavenProject? - func mavenLaunchPlan( - at rootURL: URL, - context: MavenLaunchContext, - module: String?, - goals: [String] - ) throws -> MavenLaunchPlan - /// Plans a dependency-tree query that writes the tree to `outputFile`. - func mavenDependencyPlan( - at rootURL: URL, - context: MavenLaunchContext, - module: String?, - outputFile: URL - ) throws -> MavenLaunchPlan - /// Reads the tree a finished dependency plan wrote to `outputFile`. - func mavenDependencies(modulePath: String, outputFile: URL) throws -> MavenDependencyTree - func mavenDiagnostics(output: String, projectRoot: URL) -> [MavenBuildIssue] - func mavenTestResults(output: String, projectRoot: URL) -> MavenTestResults? - func mavenTestResults( - output: String, - projectRoot: URL, - reports: MavenTestReportRequest? - ) -> MavenTestResults? -} - -extension MavenProjectOperations { - package func mavenDependencyPlan( - at _: URL, - context _: MavenLaunchContext, - module _: String?, - outputFile _: URL - ) throws -> MavenLaunchPlan { - throw MavenOperationError( - code: "not_supported", - message: "Maven dependency planning is unavailable." - ) - } - - package func mavenDependencies( - modulePath _: String, - outputFile _: URL - ) throws -> MavenDependencyTree { - throw MavenOperationError( - code: "not_supported", - message: "Maven dependency parsing is unavailable." - ) - } - - package func mavenTestResults(output _: String, projectRoot _: URL) -> MavenTestResults? { nil } - - /// Operations without report support fall back to the text summary. - package func mavenTestResults( - output: String, - projectRoot: URL, - reports _: MavenTestReportRequest? - ) -> MavenTestResults? { - mavenTestResults(output: output, projectRoot: projectRoot) - } -} - -/// Platform-owned scratch files that each hold one Maven dependency tree. -/// -/// The dependency plugin writes a module's tree to one of these files rather -/// than to the console, so the tree never shares a size budget with Maven's -/// log. Every dependency operation gets its own file and removes it once the -/// operation can no longer produce a result. -package protocol MavenDependencyOutputStoring: Sendable { - /// Returns a fresh absolute file for one operation, removing any file an - /// earlier operation left at that location. - func makeDependencyOutputFile(operationID: String) throws -> URL - /// Removes an operation's file. A file Maven never wrote is not an error. - func removeDependencyOutputFile(_ fileURL: URL) -} - -package protocol MavenConfigurationStoring: Sendable { - func loadMavenConfiguration( - workspaceURL: URL, - reactorPath: String - ) throws -> MavenStoredConfiguration - func saveMavenConfiguration( - _ configuration: MavenStoredConfiguration, - workspaceURL: URL, - reactorPath: String - ) throws -} - -@MainActor -package protocol MavenRuntimePort: AnyObject { - func mavenExecutable(for project: MavenProject, overridePath: String?) -> URL? - func mavenProcessEnvironment(javaHomePath: String?) -> [String: String] -} diff --git a/macos/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift b/macos/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift index 14548afefb..06ff7fb16d 100644 --- a/macos/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift +++ b/macos/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift @@ -1,6 +1,102 @@ import LitheModuleAPI import Foundation +/// Exact owner of a current build-model project, independent of build semantics. +package struct RunBuildProjectReference: Codable, Equatable, Hashable, Sendable { + package let pluginID: String + package let buildSystemID: String + package let root: String + package let projectID: String + package init(pluginID: String, buildSystemID: String, root: String, projectID: String) { + self.pluginID = pluginID; self.buildSystemID = buildSystemID + self.root = root; self.projectID = projectID + } +} + +/// Read-only ownership projected from the current Provider model and import. +/// Run generation must not reconstruct these facts from build descriptors. +package struct RunProjectOwnership: Codable, Equatable, Sendable { + package struct SourceRoot: Codable, Equatable, Sendable { + package let path: String + package let kind: String + package init(path: String, kind: String) { self.path = path; self.kind = kind } + } + package let path: String + package let name: String? + package let minimumJavaVersion: String? + package let build: RunBuildProjectReference? + package let contentRoots: [SourceRoot] + package init(path: String, build: RunBuildProjectReference?, contentRoots: [SourceRoot], name: String? = nil, minimumJavaVersion: String? = nil) { + self.minimumJavaVersion = minimumJavaVersion + self.name = name + self.path = path; self.build = build; self.contentRoots = contentRoots + } +} + +/// Revokes user-document and native-state publication when generation loses its owner. +package final class RunConfigurationGenerationLease: @unchecked Sendable { + private let lock = NSLock() + private var current = true + package init() {} + package func invalidate() { lock.lock(); current = false; lock.unlock() } + package var isCurrent: Bool { lock.lock(); defer { lock.unlock() }; return current } + package func check() throws { if !isCurrent { throw CancellationError() } } +} + +/// An inert per-operation tool choice. Core verifies files and versions before use. +package enum FixedToolchainSelection: Codable, Hashable, Sendable { + case installation(home: String) + case executable(path: String) + private enum CodingKeys: String, CodingKey { case home, executable } + package init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + guard values.allKeys.count == 1 else { + throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: "Expected one explicit tool choice")) + } + if values.contains(.home) { self = .installation(home: try values.decode(String.self, forKey: .home)) } + else { self = .executable(path: try values.decode(String.self, forKey: .executable)) } + } + package func encode(to encoder: Encoder) throws { + var values = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .installation(let home): try values.encode(home, forKey: .home) + case .executable(let path): try values.encode(path, forKey: .executable) + } + } +} + +/// Portable user choices stored apart from the current target selector. +package struct BuildProviderRunOptions: Codable, Hashable, Sendable { + package var runtimeArguments: [String] + /// Empty clears an inherited entrypoint without naming a runtime. + package var entrypoint: String + package var options: [String: ToolingJSONValue] + package var tools: [String: FixedToolchainSelection?] + package init(runtimeArguments: [String] = [], entrypoint: String = "", options: [String: ToolingJSONValue] = [:], + tools: [String: FixedToolchainSelection?] = [:]) { + self.runtimeArguments = runtimeArguments; self.entrypoint = entrypoint; self.options = options + self.tools = tools + } + private enum CodingKeys: CodingKey { case runtimeArguments, entrypoint, options, tools } + package init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + runtimeArguments = try values.decodeIfPresent([String].self, forKey: .runtimeArguments) ?? [] + entrypoint = try values.decodeIfPresent(String.self, forKey: .entrypoint) ?? "" + options = try values.decodeIfPresent([String: ToolingJSONValue].self, forKey: .options) ?? [:] + tools = try values.decodeIfPresent([String: FixedToolchainSelection?].self, forKey: .tools) ?? [:] + } +} + +/// A current model target matched to a public run declaration; no tool semantics. +package struct RunProviderConfiguration: Codable, Equatable, Sendable { + package let projectPath: String + package let name: String + package let reference: BuildProviderRunReference + package init(projectPath: String, name: String, reference: BuildProviderRunReference) { + self.projectPath = projectPath; self.name = name; self.reference = reference + } +} + package enum ProjectRunConfigurationStatus: Equatable, Sendable { case missing case ready @@ -59,6 +155,7 @@ package struct ProjectRunConfigurationInspection: Equatable, Sendable { /// Project defaults saved in the machine-local run layer. `nil` means that /// layer holds no defaults yet, which differs from explicitly automatic ones. package var projectToolchain: ProjectToolchainSelection? = nil + package var hasSavedToolchainPathOverrides = false package init( status: ProjectRunConfigurationStatus, @@ -66,7 +163,9 @@ package struct ProjectRunConfigurationInspection: Equatable, Sendable { recoveryAction: RunConfigurationRecoveryAction = .none, recoveryPath: String? = nil, projectToolchain: ProjectToolchainSelection? = nil, + hasSavedToolchainPathOverrides: Bool = false, requiresGeneration: Bool = false + ) { self.status = status self.requiresGeneration = requiresGeneration @@ -74,6 +173,7 @@ package struct ProjectRunConfigurationInspection: Equatable, Sendable { self.recoveryAction = recoveryAction self.recoveryPath = recoveryPath self.projectToolchain = projectToolchain + self.hasSavedToolchainPathOverrides = hasSavedToolchainPathOverrides } } @@ -283,26 +383,108 @@ package protocol RunConfigurationDocumentMutating: Sendable { } package struct ProjectToolchainSelection: Codable, Equatable, Sendable { + package struct PathOverride: Codable, Equatable, Sendable { + package var javaHomePath: String + package var mavenExecutablePath: String + package var mavenJavaHomePath: String + package init(javaHomePath: String = "", mavenExecutablePath: String = "", mavenJavaHomePath: String = "") { + self.javaHomePath = javaHomePath + self.mavenExecutablePath = mavenExecutablePath + self.mavenJavaHomePath = mavenJavaHomePath + } + package init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + javaHomePath = try values.decodeIfPresent(String.self, forKey: .javaHomePath) ?? "" + mavenExecutablePath = try values.decodeIfPresent(String.self, forKey: .mavenExecutablePath) ?? "" + mavenJavaHomePath = try values.decodeIfPresent(String.self, forKey: .mavenJavaHomePath) ?? "" + } + private enum CodingKeys: String, CodingKey { case javaHomePath, mavenExecutablePath, mavenJavaHomePath } + } package var javaHomePath = "" package var mavenExecutablePath = "" package var mavenJavaHomePath = "" + package var pathOverrides: [String: PathOverride] = [:] package init( javaHomePath: String = "", mavenExecutablePath: String = "", - mavenJavaHomePath: String = "" + mavenJavaHomePath: String = "", + pathOverrides: [String: PathOverride] = [:] ) { self.javaHomePath = javaHomePath self.mavenExecutablePath = mavenExecutablePath self.mavenJavaHomePath = mavenJavaHomePath + self.pathOverrides = pathOverrides + } + + package init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + javaHomePath = try values.decodeIfPresent(String.self, forKey: .javaHomePath) ?? "" + mavenExecutablePath = try values.decodeIfPresent(String.self, forKey: .mavenExecutablePath) ?? "" + mavenJavaHomePath = try values.decodeIfPresent(String.self, forKey: .mavenJavaHomePath) ?? "" + pathOverrides = try values.decodeIfPresent([String: PathOverride].self, forKey: .pathOverrides) ?? [:] + } + + private enum CodingKeys: String, CodingKey { case javaHomePath, mavenExecutablePath, mavenJavaHomePath, pathOverrides } +} + +/// Core resolves document layers without assembling the provider's command line. +package struct ProviderLaunchInputs: Decodable, Equatable, Sendable { + package let reference: BuildProviderRunReference? + package let buildSystemID: String + package let provider: String + package let projectPath: String + package let launchOptions: BuildLaunchOptions + package let optionOverrides: [String: ToolingJSONValue] + package let tools: [String: FixedToolchainSelection] + + /// Saved applications carry opaque Provider arguments from protocol 4. + /// Reclassifying them as opt-in version-10 launch options breaks old SDKs. + package var opaqueReferenceArguments: [String]? { + guard let reference, (reference.execution ?? .application) == .application, + launchOptions.execution == .application, launchOptions.runtimeArguments.isEmpty, + launchOptions.environment.isEmpty, launchOptions.workingDirectory == nil, + launchOptions.entrypoint == nil, launchOptions.debug == nil, + launchOptions.arguments == reference.userArguments else { return nil } + return reference.userArguments + } + + package init(buildSystemID: String, provider: String, projectPath: String, launchOptions: BuildLaunchOptions, + optionOverrides: [String: ToolingJSONValue] = [:], reference: BuildProviderRunReference? = nil, + tools: [String: FixedToolchainSelection] = [:]) { + self.reference = reference + self.buildSystemID = buildSystemID; self.provider = provider; self.projectPath = projectPath + self.launchOptions = launchOptions; self.optionOverrides = optionOverrides + self.tools = tools + } + + private enum CodingKeys: CodingKey { case reference, buildSystemID, provider, projectPath, launchOptions, optionOverrides, tools } + package init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + reference = try values.decodeIfPresent(BuildProviderRunReference.self, forKey: .reference) + buildSystemID = try values.decode(String.self, forKey: .buildSystemID) + provider = try values.decode(String.self, forKey: .provider) + projectPath = try values.decode(String.self, forKey: .projectPath) + launchOptions = try values.decode(BuildLaunchOptions.self, forKey: .launchOptions) + optionOverrides = try values.decodeIfPresent([String: ToolingJSONValue].self, forKey: .optionOverrides) ?? [:] + tools = try values.decodeIfPresent([String: FixedToolchainSelection].self, forKey: .tools) ?? [:] } } package protocol RunConfigurationOperations: Sendable { + func providerLaunchInputs(at projectURL: URL, configurationID: String, providerRoot: String) throws -> ProviderLaunchInputs func saveBuildProviderConfiguration(_ reference: BuildProviderRunReference, name: String, at projectURL: URL) throws -> String /// Saves project defaults without modifying any service or requiring generated configurations. func saveProjectToolchain(_ toolchain: ProjectToolchainSelection, at projectURL: URL) throws func inspect(at projectURL: URL) -> ProjectRunConfigurationInspection + /// Checks generated facts against the current Provider snapshot without hashing source files. + func inspectProviderGeneration(at projectURL: URL, projectOwnership: [RunProjectOwnership]) -> ProjectRunConfigurationInspection + func inspectProviderGeneration(at projectURL: URL, projectOwnership: [RunProjectOwnership], providerRuns: [RunProviderConfiguration]) -> ProjectRunConfigurationInspection + func generate(at projectURL: URL, files: [URL], modulePaths: [String], javaEntrypoints: JavaEntrypoints?, + projectOwnership: [RunProjectOwnership]?, providerRuns: [RunProviderConfiguration]) throws -> RunConfigurationGenerationResult + func generate(at projectURL: URL, files: [URL], modulePaths: [String], javaEntrypoints: JavaEntrypoints?, + projectOwnership: [RunProjectOwnership]?, providerRuns: [RunProviderConfiguration], + models: [BuildModel], lease: RunConfigurationGenerationLease) throws -> RunConfigurationGenerationResult /// `checkFingerprint: false` validates the documents without reading project /// inputs. `javaEntrypoints`, JDT's current answer, additionally reports /// whether the generated Java entries still match it. @@ -317,7 +499,8 @@ package protocol RunConfigurationOperations: Sendable { at projectURL: URL, files: [URL], modulePaths: [String], - javaEntrypoints: JavaEntrypoints? + javaEntrypoints: JavaEntrypoints?, + projectOwnership: [RunProjectOwnership]? ) throws -> RunConfigurationGenerationResult func resolve( at projectURL: URL, @@ -359,6 +542,36 @@ package protocol RunConfigurationOperations: Sendable { } package extension RunConfigurationOperations { + func generate(at projectURL: URL, files: [URL], modulePaths: [String], javaEntrypoints: JavaEntrypoints?, + projectOwnership: [RunProjectOwnership]?, providerRuns: [RunProviderConfiguration], + models: [BuildModel], lease: RunConfigurationGenerationLease) throws -> RunConfigurationGenerationResult { + try lease.check() + return try generate(at: projectURL, files: files, modulePaths: modulePaths, javaEntrypoints: javaEntrypoints, + projectOwnership: projectOwnership, providerRuns: providerRuns) + } + func inspectProviderGeneration(at projectURL: URL, projectOwnership: [RunProjectOwnership], providerRuns: [RunProviderConfiguration]) -> ProjectRunConfigurationInspection { + guard providerRuns.isEmpty else { + return .init(status: .invalid(L10n.string("Load the current build model and enable its provider before running this configuration.")), diagnostics: []) + } + return inspectProviderGeneration(at: projectURL, projectOwnership: projectOwnership) + } + func generate(at projectURL: URL, files: [URL], modulePaths: [String], javaEntrypoints: JavaEntrypoints?, + projectOwnership: [RunProjectOwnership]?, providerRuns: [RunProviderConfiguration]) throws -> RunConfigurationGenerationResult { + guard providerRuns.isEmpty else { throw RunConfigurationOperationFailure(message: L10n.string("Load the current build model and enable its provider before running this configuration.")) } + return try generate(at: projectURL, files: files, modulePaths: modulePaths, javaEntrypoints: javaEntrypoints, projectOwnership: projectOwnership) + } + + func providerLaunchInputs(at projectURL: URL, configurationID: String, providerRoot: String) throws -> ProviderLaunchInputs { + throw RunConfigurationOperationFailure(message: L10n.string("Load the current build model and enable its provider before running this configuration.")) + } + + func inspectProviderGeneration(at projectURL: URL, projectOwnership: [RunProjectOwnership]) -> ProjectRunConfigurationInspection { + inspect(at: projectURL, checkFingerprint: false, javaEntrypoints: nil) + } + func generate(at projectURL: URL, files: [URL], modulePaths: [String], javaEntrypoints: JavaEntrypoints?) throws -> RunConfigurationGenerationResult { + try generate(at: projectURL, files: files, modulePaths: modulePaths, + javaEntrypoints: javaEntrypoints, projectOwnership: nil) + } func saveBuildProviderConfiguration(_: BuildProviderRunReference, name: String, at _: URL) throws -> String { throw RunConfigurationOperationFailure(message: L10n.string("Saving build provider run configurations is unavailable.")) } diff --git a/macos/Sources/LitheCoreContracts/Execution/RunModels.swift b/macos/Sources/LitheCoreContracts/Execution/RunModels.swift index f7072ae3c3..672410c532 100644 --- a/macos/Sources/LitheCoreContracts/Execution/RunModels.swift +++ b/macos/Sources/LitheCoreContracts/Execution/RunModels.swift @@ -2,7 +2,7 @@ import Foundation package struct RunSession: Identifiable, Hashable, Sendable { /// Stable for one execution; replacing the output slot creates a new identity. - package let executionID = UUID().uuidString + package let executionID: String package let id: String package let configurationID: String package let title: String @@ -14,6 +14,7 @@ package struct RunSession: Identifiable, Hashable, Sendable { package var javaUpdateSource: URL? package init( + executionID: String = UUID().uuidString, id: String, configurationID: String, title: String, @@ -21,6 +22,7 @@ package struct RunSession: Identifiable, Hashable, Sendable { isRunning: Bool, exitCode: Int32? = nil ) { + self.executionID = executionID self.id = id self.configurationID = configurationID self.title = title @@ -153,7 +155,10 @@ package enum RunConfigurationKind: Hashable, Identifiable, Sendable { switch self { case .currentFile, .javaMain: return [.workingDirectory, .arguments, .environment, .javaRuntime, .javaVMArguments, .jdwpDebug] - case .mavenModule, .mavenFramework: + case .mavenModule: + return [.workingDirectory, .arguments, .environment, .javaRuntime, + .javaVMArguments, .mavenProfiles, .mavenSkipTests] + case .mavenFramework: return [ .workingDirectory, .arguments, .environment, .javaRuntime, .javaVMArguments, .mavenProfiles, .mavenSkipTests, .jdwpDebug @@ -246,12 +251,27 @@ package struct RunConfiguration: Identifiable, Hashable, Sendable { package let debugAdapter: String? package let disabled: Bool package let buildProvider: BuildProviderRunReference? + package let buildProject: RunBuildProjectReference? package var usesCurrentEditorFile: Bool { kind == .currentFile } + /// These persisted configurations depend on current Provider generation facts. + package var requiresProviderGeneration: Bool { + guard buildProvider == nil else { return false } + switch kind { + case .javaMain, .mavenModule, .mavenFramework: return true + case .currentFile, .process: return false + } + } + /// Only launches backed by a JDT project wait for workspace preparation. package var usesJavaProjectPreparation: Bool { - (kind == .javaMain || kind == .springBoot) && mavenReactorPath != nil + (kind == .javaMain || (kind == .springBoot && sourcePath?.lowercased().hasSuffix(".java") == true)) && (buildProject != nil || mavenReactorPath != nil) + } + + /// Saved provider services may attach through their declared registry type. + package var usesProviderConnectionDebug: Bool { + buildProvider?.execution == .service } package init( @@ -265,7 +285,8 @@ package struct RunConfiguration: Identifiable, Hashable, Sendable { mavenReactorPath: String? = nil, debugAdapter: String? = nil, disabled: Bool = false, - buildProvider: BuildProviderRunReference? = nil + buildProvider: BuildProviderRunReference? = nil, + buildProject: RunBuildProjectReference? = nil ) { self.id = id self.name = name @@ -278,6 +299,7 @@ package struct RunConfiguration: Identifiable, Hashable, Sendable { self.debugAdapter = debugAdapter self.disabled = disabled self.buildProvider = buildProvider + self.buildProject = buildProject } package var systemImage: String { kind.systemImage } diff --git a/macos/Sources/LitheCoreContracts/Language/LanguageToolingContracts.swift b/macos/Sources/LitheCoreContracts/Language/LanguageToolingContracts.swift index 8734842ff1..7fb305b6f3 100644 --- a/macos/Sources/LitheCoreContracts/Language/LanguageToolingContracts.swift +++ b/macos/Sources/LitheCoreContracts/Language/LanguageToolingContracts.swift @@ -970,6 +970,8 @@ package protocol LanguageServerSession: AnyObject { func javaEntrypoints( completion: @escaping (Result) -> Void ) throws + /// Awaited launch discovery owns cancellation of its queued Core operation. + func javaEntrypoints() async throws -> JavaEntrypoints /// Prepares one saved source through Core's shared Java launch operation. func prepareJavaLaunch(fileURL: URL, mainClass: String?) async throws -> JavaLaunchPreparation /// Test classes and methods JDT reports for one source file. @@ -1178,6 +1180,15 @@ package extension LanguageServerSession { } } var serverInfo: LanguageServerInfo? { nil } + func javaEntrypoints() async throws -> JavaEntrypoints { + try Task.checkCancellation() + let value: JavaEntrypoints = try await withCheckedThrowingContinuation { continuation in + do { try javaEntrypoints { continuation.resume(with: $0) } } + catch { continuation.resume(throwing: error) } + } + try Task.checkCancellation() + return value + } var onServerInfoChange: ((LanguageServerInfo?) -> Void)? { get { nil } set {} diff --git a/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift b/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift index e59f276766..b39158a566 100644 --- a/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift +++ b/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift @@ -707,6 +707,7 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { displayName: $0.displayName, adapterID: $0.adapterID ?? $0.id, requests: Set($0.requests.compactMap(DebugRequestKind.init(rawValue:))), + configurationTypes: $0.configurationTypes, languageIDs: $0.languageIDs, fileLaunchLanguageIDs: $0.fileLaunchLanguageIDs, consoleCommands: $0.consoleCommands, @@ -1196,6 +1197,7 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { let displayName: String let adapterID: String? let requests: [String] + let configurationTypes: [String] let selectors: [Selector] let capabilities: Capabilities? let console: Console? @@ -1241,6 +1243,7 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { // Rust Core fills in `launch` when the manifest omits `requests`; // the same default keeps a hand-built descriptor consistent. requests = try values.decodeIfPresent([String].self, forKey: .requests) ?? ["launch"] + configurationTypes = try values.decodeIfPresent([String].self, forKey: .configurationTypes) ?? [] selectors = try values.decodeIfPresent([Selector].self, forKey: .selectors) ?? [] capabilities = try values.decodeIfPresent(Capabilities.self, forKey: .capabilities) console = try values.decodeIfPresent(Console.self, forKey: .console) @@ -1248,7 +1251,7 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { } private enum CodingKeys: String, CodingKey { - case id, displayName, adapterID, requests, selectors, capabilities, console, configurations + case configurationTypes, id, displayName, adapterID, requests, selectors, capabilities, console, configurations } } @@ -1269,6 +1272,7 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { let requiresUserArguments: Bool let description: String? let category: [String] + let preparation: DeclarativeBuildSystem.TaskPreparation? init(from decoder: Decoder) throws { let values = try decoder.container(keyedBy: CodingKeys.self) @@ -1282,11 +1286,12 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { ?? false description = try values.decodeIfPresent(String.self, forKey: .description) category = try values.decodeIfPresent([String].self, forKey: .category) ?? [] + preparation = try values.decodeIfPresent(DeclarativeBuildSystem.TaskPreparation.self, forKey: .preparation) } private enum CodingKeys: String, CodingKey { case id, displayName, group, scopes, fromModel, targetTags, requiresUserArguments, description - case category + case category, preparation } } /// Only where the window offers the option; Core resolves the rest. @@ -1294,20 +1299,25 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { let id: String let presentation: String? let icon: PluginUIIcon? + let scope: DeclarativeBuildSystem.OptionScope? } struct ProviderRun: Decodable { - struct Debug: Decodable { let adapter: String } + struct Debug: Decodable { let adapter: String?; let configurationType: String? } let debug: Debug? struct Targets: Decodable { let tags: [String] } let id: String let source: String? let name: String? + let execution: BuildExecution? let fromTargets: Targets? } struct Test: Decodable { + let selectorKinds: [String]? + struct Results: Decodable { let decoder: String } let scopes: [BuildScope] let targetTags: [String]? let discovery: Bool? + let results: Results? } let id: String @@ -1319,6 +1329,7 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { let test: Test? let run: [ProviderRun] let dependencies: Source? + let nativeConfiguration: Bool let declaresProvider: Bool let sharesModel: Bool let modelInputs: [DeclarativeBuildSystem.ModelInput] @@ -1334,13 +1345,14 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { test = try values.decodeIfPresent(Test.self, forKey: .test) run = try values.decodeIfPresent([ProviderRun].self, forKey: .run) ?? [] dependencies = try values.decodeIfPresent(Source.self, forKey: .dependencies) + nativeConfiguration = values.contains(.configuration) declaresProvider = values.contains(.provider) sharesModel = values.contains(.modelSharing) modelInputs = try values.decodeIfPresent([DeclarativeBuildSystem.ModelInput].self, forKey: .modelInputs) ?? [] } private enum CodingKeys: String, CodingKey { - case id, displayName, icon, options, model, tasks, test, dependencies, provider, run, modelSharing, modelInputs + case id, displayName, icon, options, model, tasks, test, dependencies, provider, run, modelSharing, modelInputs, configuration } func declaration() -> DeclarativeBuildSystem { @@ -1353,7 +1365,8 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { .init( id: $0.id, presentation: $0.presentation.flatMap(DeclarativeBuildSystem.OptionPresentation.init), - icon: $0.icon + icon: $0.icon, + scope: $0.scope ?? .workspace ) }, tasks: tasks.map { @@ -1361,7 +1374,7 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { id: $0.id, displayName: $0.displayName, group: $0.group, scopes: $0.scopes, fromModel: $0.fromModel, targetTags: $0.targetTags, requiresUserArguments: $0.requiresUserArguments, description: $0.description, - category: $0.category + category: $0.category, preparation: $0.preparation ) }, testScopes: test?.scopes ?? [], @@ -1372,8 +1385,10 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { declaresProvider: declaresProvider, testDiscovery: test?.discovery ?? false, providerRuns: run.filter { $0.source == "provider" }.map { - .init(id: $0.id, displayName: $0.name ?? $0.id, targetTags: $0.fromTargets?.tags ?? [], debugAdapter: $0.debug?.adapter) - }, sharesModel: sharesModel, modelInputs: modelInputs + .init(id: $0.id, displayName: $0.name ?? $0.id, targetTags: $0.fromTargets?.tags ?? [], debugAdapter: $0.debug?.adapter, debugConfigurationType: $0.debug?.configurationType, execution: $0.execution ?? .application) + }, sharesModel: sharesModel, modelInputs: modelInputs, nativeConfiguration: nativeConfiguration, + providerTestReports: test?.results?.decoder == "provider", + testSelectorKinds: test?.selectorKinds ?? [] ) } } @@ -1447,6 +1462,8 @@ public struct DeclarativeDebugAdapter: Equatable, Sendable { /// The `adapterID` of the DAP `initialize` request. public let adapterID: String public let requests: Set + /// Public registry aliases advertised by this adapter. + public let configurationTypes: [String] /// Every language a selector of the adapter names, whatever the request or /// target kind. It says which languages the plugin has a debugger for, so a /// debug-only declaration is recognised; it does not choose the adapter for @@ -1475,6 +1492,7 @@ public struct DeclarativeDebugAdapter: Equatable, Sendable { displayName: String, adapterID: String, requests: Set = [.launch, .attach], + configurationTypes: [String] = [], languageIDs: [String], fileLaunchLanguageIDs: [String]? = nil, consoleCommands: [DebugConsoleCommand] = [], @@ -1486,6 +1504,7 @@ public struct DeclarativeDebugAdapter: Equatable, Sendable { self.displayName = displayName self.adapterID = adapterID self.requests = requests + self.configurationTypes = configurationTypes self.languageIDs = languageIDs self.fileLaunchLanguageIDs = fileLaunchLanguageIDs ?? languageIDs self.consoleCommands = consoleCommands @@ -1585,10 +1604,12 @@ public struct DeclarativeDebugStartRequest: Equatable, Sendable { public enum Request: String, Sendable { case launch, attach } public let request: Request - /// What the target is: `file` or `process`. + /// The registry target kind, including `file`, `executable`, `process` and `connection`. public let targetKind: String public let path: String? public let processID: Int? + public let host: String? + public let port: UInt16? public let workingDirectory: String? /// The program the host found for a launch whose adapter binds `program` to /// `target.executable`; an absolute path the host has checked is runnable. @@ -1600,6 +1621,7 @@ public struct DeclarativeDebugStartRequest: Equatable, Sendable { public init( request: Request, targetKind: String, path: String? = nil, processID: Int? = nil, + host: String? = nil, port: UInt16? = nil, workingDirectory: String? = nil, executable: String? = nil, registrationGeneration: Int, currentRegistrationGeneration: Int, arguments: [String]? = nil, environment: [String: String]? = nil @@ -1608,6 +1630,7 @@ public struct DeclarativeDebugStartRequest: Equatable, Sendable { self.targetKind = targetKind self.path = path self.processID = processID + self.host = host; self.port = port self.workingDirectory = workingDirectory self.executable = executable self.arguments = arguments diff --git a/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift b/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift index 18ee07dd10..48c4649a69 100644 --- a/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift +++ b/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift @@ -434,6 +434,8 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu /// Notifies the host after a debugger session has been stopped and its /// adapter resources have been released. public var onSessionStopped: ((DebugSessionID) -> Void)? + /// Resource owners must see terminal states of inactive sessions as well. + public var onSessionStateChanged: ((DebugSessionID, DebugAdapterState) -> Void)? private let sessions: DebugAdapterSessionManager private let breakpointPersistence: (any DebugBreakpointPersisting)? @@ -524,6 +526,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu ) } self.reconcileRemovedSessions() + self.onSessionStateChanged?(sessionID, state) } sessions.onSessionEvent = { [weak self] sessionID, providerID, event in guard let self else { return } diff --git a/macos/Sources/LitheExecutionModule/Application/BuildProjectPresentation.swift b/macos/Sources/LitheExecutionModule/Application/BuildProjectPresentation.swift new file mode 100644 index 0000000000..0a163caeae --- /dev/null +++ b/macos/Sources/LitheExecutionModule/Application/BuildProjectPresentation.swift @@ -0,0 +1,38 @@ +import Foundation +import LitheCoreContracts + +/// Read-only labels and source roles from upstream build models. No descriptor +/// parsing or file-name inference belongs in this presentation projection. +package enum BuildProjectPresentation { + package static func projectName(for file: URL, workspace: URL, models: [BuildModel]) -> String? { + models.flatMap(\.projects).compactMap { project -> (String, String)? in + guard let directory = containingDirectory(project.path, file: file, workspace: workspace) else { return nil } + return (directory, project.displayName ?? project.name) + }.sorted { left, right in + if left.0.count != right.0.count { return left.0.count > right.0.count } + return left < right + }.first?.1 + } + + package static func sourceRootKind(for file: URL, workspace: URL, models: [BuildModel]) -> String? { + let sourceKinds: Set = ["source", "resource", "testSource", "testResource", "generatedSource", "generatedTestSource"] + return models.flatMap(\.contentRoots).compactMap { root -> (String, String)? in + guard sourceKinds.contains(root.kind), + let directory = containingDirectory(root.path, file: file, workspace: workspace) else { return nil } + return (directory, root.kind) + }.sorted { left, right in + if left.0.count != right.0.count { return left.0.count > right.0.count } + return left < right + }.first?.1 + } + + private static func containingDirectory(_ path: String, file: URL, workspace: URL) -> String? { + guard file.isFileURL, workspace.isFileURL, !path.hasPrefix("/") else { return nil } + let root = workspace.standardizedFileURL.path + let directory = workspace.appendingPathComponent(path).standardizedFileURL.path + let target = file.standardizedFileURL.path + guard directory == root || directory.hasPrefix(root + "/"), + target == directory || target.hasPrefix(directory + "/") else { return nil } + return directory + } +} diff --git a/macos/Sources/LitheExecutionModule/Application/BuildSystemsFeatureModel.swift b/macos/Sources/LitheExecutionModule/Application/BuildSystemsFeatureModel.swift index 35ab49fe6b..7a435e2922 100644 --- a/macos/Sources/LitheExecutionModule/Application/BuildSystemsFeatureModel.swift +++ b/macos/Sources/LitheExecutionModule/Application/BuildSystemsFeatureModel.swift @@ -29,12 +29,14 @@ package final class BuildSystemsFeatureModel: ObservableObject { package var testResults: BuildTestResults? { service.testResults } package var isRunning: Bool { service.isRunning } package var currentRunID: UUID? { service.currentRunID } + package var operation: BuildOperationSnapshot? { service.operation } package var runEnded: AnyPublisher { service.runEnded.eraseToAnyPublisher() } package var applicationLaunched: AnyPublisher { service.applicationLaunched.eraseToAnyPublisher() } package var hasActiveWork: Bool { service.hasActiveWork } package var hasBuildSystemPlugins: Bool { service.hasBuildSystemPlugins } package var workspaceURL: URL? { service.workspaceURL } package var languageImport: JavaLanguageImportConfiguration? { service.languageImport } + package var inputRevision: UInt64 { service.inputRevision } package var onLanguageImportChanged: ((URL) -> Void)? { get { service.onLanguageImportChanged } set { service.onLanguageImportChanged = newValue } @@ -44,6 +46,29 @@ package final class BuildSystemsFeatureModel: ObservableObject { } package func entry(_ id: String) -> BuildSystemEntry? { service.entry(id) } + package func nativeConfigurationState(_ entryID: String) -> BuildNativeConfigurationState { + service.nativeConfigurationState(entryID) + } + @discardableResult + package func readNativeConfiguration(_ entryID: String, allowProjectTool: Bool = false) async throws -> BuildNativeConfiguration { + try await service.readNativeConfiguration(entryID, allowProjectTool: allowProjectTool) + } + @discardableResult + package func saveNativeConfiguration(_ entryID: String, snapshot: BuildNativeConfiguration, + edits: BuildNativeConfigurationEdits) async throws -> BuildNativeConfiguration { + try await service.saveNativeConfiguration(entryID, snapshot: snapshot, edits: edits) + } + private var currentModels: [BuildModel] { + entries.filter { $0.modelState == .loaded && !$0.isReloadRequired }.compactMap(\.model) + } + package func projectName(containing fileURL: URL) -> String? { + guard let workspaceURL else { return nil } + return BuildProjectPresentation.projectName(for: fileURL, workspace: workspaceURL, models: currentModels) + } + package func sourceRootKind(containing fileURL: URL) -> String? { + guard let workspaceURL else { return nil } + return BuildProjectPresentation.sourceRootKind(for: fileURL, workspace: workspaceURL, models: currentModels) + } package func reset(workspaceURL: URL?) { service.reset(workspaceURL: workspaceURL) } package func detect() { service.detect() } package func workspaceFilesChanged(_ urls: [URL]) { service.workspaceFilesChanged(urls) } @@ -69,6 +94,55 @@ package final class BuildSystemsFeatureModel: ObservableObject { package func runTests(_ entryID: String, request: BuildTestRequest, title: String) -> UUID? { service.runTests(entryID, request: request, title: title) } + package func prepareLanguageTestModels(selectorKind: String? = nil, reference: BuildTestReference? = nil, workspace: URL) async throws { + try await service.prepareLanguageTestModels(selectorKind: selectorKind, reference: reference, workspace: workspace) + } + package func prepareRunGenerationModels(workspace: URL) async throws { + _ = try await service.prepareLanguageImport(workspaceURL: workspace) + try await service.prepareLanguageTestModels(allModels: true, workspace: workspace) + _ = try await service.prepareLanguageImport(workspaceURL: workspace) + } + @discardableResult + package func prepareLanguageRun(fileURL: URL, workspace: URL, purpose: String, + optionOverrides: [String: ToolingJSONValue] = [:], + tool: @MainActor (String) throws -> BuildProviderHints? = { _ in nil }, + isCurrent: @escaping @MainActor () -> Bool) async throws -> BuildLanguageImport? { + try await service.prepareLanguageRun(fileURL: fileURL, workspace: workspace, purpose: purpose, optionOverrides: optionOverrides, tool: tool, isCurrent: isCurrent) + } + package func languageTestEntries(selectorKind: String, fileURL: URL?, workspace: URL) -> [BuildSystemEntry] { + service.languageTestEntries(selectorKind: selectorKind, fileURL: fileURL, workspace: workspace) + } + package func languageTestProject(in entry: BuildSystemEntry, fileURL: URL?, workspace: URL) -> BuildSelection? { + service.languageTestProject(in: entry, fileURL: fileURL, workspace: workspace) + } + package func performLanguageTests(_ entryID: String, request: BuildTestRequest, title: String, + output: @escaping @MainActor (String) -> Void, + process: @escaping @MainActor (Int32?) -> Void = { _ in }) async throws -> LanguageTestBackendResult { + try await service.performLanguageTests(entryID, request: request, title: title, output: output, process: process) + } + package func performLanguageTests(selectorKind: String, fileURL: URL?, workspace: URL, title: String, + isCurrent: @escaping @MainActor () -> Bool, + beforePreparing: @escaping @MainActor () async throws -> Void = {}, + prepareSelectors: @escaping @MainActor () async throws -> [String], + output: @escaping @MainActor (String) -> Void, + process: @escaping @MainActor (Int32?) -> Void = { _ in }) async throws -> LanguageTestBackendResult { + try await service.performLanguageTests(selectorKind: selectorKind, fileURL: fileURL, workspace: workspace, + title: title, isCurrent: isCurrent, beforePreparing: beforePreparing, prepareSelectors: prepareSelectors, output: output, process: process) + } + package func performLanguageTests(reference: BuildTestReference, workspace: URL, title: String, + isCurrent: @escaping @MainActor () -> Bool = { true }, + beforePreparing: @escaping @MainActor () async throws -> Void = {}, + output: @escaping @MainActor (String) -> Void, + process: @escaping @MainActor (Int32?) -> Void) async throws -> LanguageTestBackendResult { + try await service.performLanguageTests(reference: reference, workspace: workspace, title: title, isCurrent: isCurrent, beforePreparing: beforePreparing, output: output, process: process) + } + package func projectPath(containing fileURL: URL) -> String? { + guard let workspaceURL else { return nil } + return currentModels.flatMap(\.projects).filter { + let directory = workspaceURL.appendingPathComponent($0.path).standardizedFileURL.path + return fileURL.isFileURL && (fileURL.standardizedFileURL.path == directory || fileURL.standardizedFileURL.path.hasPrefix(directory + "/")) + }.sorted { $0.path.count > $1.path.count }.first?.path + } package func cancelDebugPreparation() { service.cancelDebugPreparation() } @discardableResult @@ -101,11 +175,16 @@ package final class BuildSystemsFeatureModel: ObservableObject { package func setOption(_ entryID: String, optionID: String, value: ToolingJSONValue?) { service.setOption(entryID, optionID: optionID, value: value) } + package func configureOption(_ entryID: String, optionID: String, value: ToolingJSONValue?) throws -> BuildResolvedOption { + try service.configureOption(entryID, optionID: optionID, value: value) + } package func setInstanceOption(_ entryID: String, optionID: String, value: ToolingJSONValue?) { service.setInstanceOption(entryID, optionID: optionID, value: value) } package func pluginsChanged() { service.pluginsChanged() } + package func pluginPackageWillChange(_ pluginID: String) { service.pluginPackageWillChange(pluginID) } package func stopRun() { service.stopRun() } + package func cancelRun(_ id: UUID) { service.cancelRun(id) } package func stop() { service.stop() } package func clearOutput() { service.clearOutput() } } diff --git a/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift b/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift index e308a0613f..3445b6638b 100644 --- a/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift +++ b/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift @@ -3,190 +3,6 @@ import Foundation import LitheCoreContracts import LitheModuleAPI -/// UI-facing projection for Maven state and commands. -/// The view layer does not depend on MavenService or its process adapter. -@MainActor -package final class MavenFeatureModel: ObservableObject { - private let service: MavenService - private var observation: AnyCancellable? - - package init(service: MavenService) { - self.service = service - observation = service.objectWillChange.sink { [weak self] _ in - self?.objectWillChange.send() - } - } - - package var project: MavenProject? { service.project } - package var projectState: MavenProjectLoadState { service.projectState } - package var taskState: MavenTaskState { service.taskState } - package var outputOperationID: String? { service.outputOperationID } - package var isLoadingProject: Bool { service.isLoadingProject } - package var isRunning: Bool { service.isRunning } - package var runningTitle: String? { service.runningTitle } - package var output: String { service.output } - package var issues: [MavenBuildIssue] { service.issues } - package var lastExitCode: Int32? { service.lastExitCode } - package var availableProfiles: [MavenProfile] { service.availableProfiles } - package var selectedProfiles: Set { service.selectedProfiles } - package var skipTests: Bool { service.skipTests } - package var settingsPath: String? { service.settingsPath } - package var localRepositoryPath: String? { service.localRepositoryPath } - package var mavenExecutablePath: String? { service.mavenExecutablePath } - package var javaHomePath: String? { service.javaHomePath } - package var configurationSaveError: String? { service.configurationSaveError } - package var javaConfigurationError: String? { service.javaConfigurationError } - /// Connects Maven configuration changes to the running Java language session. - package func configureJavaConfigurationSync(_ apply: @escaping @MainActor (URL) throws -> Bool) { - service.applyConfigurationToJava = apply - } - package var isReloadRequired: Bool { service.isReloadRequired } - package var isProjectReloadRequired: Bool { service.isProjectReloadRequired } - package var isReloading: Bool { service.isReloading } - package var reloadError: String? { service.reloadError } - package func markPomChanged(_ url: URL) { service.markPomChanged(url) } - package func reloadProject( - files: [URL], rescan: Bool, - synchronizeJava: @escaping @MainActor () async throws -> Void - ) async { - await service.reloadProject(files: files, rescan: rescan, synchronizeJava: synchronizeJava) - } - package var dependencyStates: [String: MavenDependencyLoadState] { service.dependencyStates } - package var isResolvingDependencies: Bool { service.isResolvingDependencies } - package var launchContext: MavenLaunchContext? { service.launchContext } - - /// Projects artifacts from Maven's already-resolved dependency tree into - /// paths that the generic dependency Provider can display. It never starts - /// Maven or scans the local repository. - package func resolvedDependencyArtifactPaths( - modulePath: String, - defaultRepositoryURL: URL? = nil - ) -> [URL] { - guard case .ready(let dependencies) = service.dependencyState(for: modulePath) else { - return [] - } - let repositoryURL: URL - if let repository = service.localRepositoryPath { - let expanded = (repository as NSString).expandingTildeInPath - repositoryURL = URL( - fileURLWithPath: expanded, - relativeTo: service.project?.rootURL - ).standardizedFileURL - } else if let defaultRepositoryURL { - repositoryURL = defaultRepositoryURL.standardizedFileURL - } else { - return [] - } - var paths: Set = [] - for dependency in dependencies { - Self.collectResolvedArtifacts( - dependency, - repositoryURL: repositoryURL, - into: &paths - ) - } - return paths.map { URL(fileURLWithPath: $0) } - .sorted { $0.path.localizedStandardCompare($1.path) == .orderedAscending } - } - - private static func collectResolvedArtifacts( - _ dependency: MavenDependency, - repositoryURL: URL, - into paths: inout Set - ) { - if dependency.resolution == .resolved { - let groupPath = dependency.groupID.replacingOccurrences(of: ".", with: "/") - let classifier = dependency.classifier.map { "-\($0)" } ?? "" - let fileName = "\(dependency.artifactID)-\(dependency.version)\(classifier).\(dependency.type)" - paths.insert( - repositoryURL - .appendingPathComponent(groupPath, isDirectory: true) - .appendingPathComponent(dependency.artifactID, isDirectory: true) - .appendingPathComponent(dependency.version, isDirectory: true) - .appendingPathComponent(fileName) - .standardizedFileURL.path - ) - } - for child in dependency.children { - collectResolvedArtifacts(child, repositoryURL: repositoryURL, into: &paths) - } - } - - package func loadProject(at workspaceURL: URL, files: [URL], snapshotID: UUID? = nil) async { - await service.loadProject(at: workspaceURL, files: files) - } - - package func run(phase: MavenLifecyclePhase, module: MavenModule?) { - service.run(phase: phase, module: module) - } - - package func runCustomGoal(_ value: String, module: MavenModule?) { - service.runCustomGoal(value, module: module) - } - - package func setSelectedProfiles(_ profiles: Set) { - service.setSelectedProfiles(profiles) - } - - @discardableResult - package func addCustomProfile(_ value: String) -> Bool { - service.addCustomProfile(value) - } - - package func restoreDefaultProfiles() { - service.restoreDefaultProfiles() - } - - package func setSkipTests(_ enabled: Bool) { - service.setSkipTests(enabled) - } - - package func saveConfiguration() async -> String? { - await service.saveConfiguration() - } - - package func updateLocalConfiguration( - settingsPath: String?, - localRepositoryPath: String?, - mavenExecutablePath: String?, - javaHomePath: String? - ) { - service.updateLocalConfiguration( - settingsPath: settingsPath, - localRepositoryPath: localRepositoryPath, - mavenExecutablePath: mavenExecutablePath, - javaHomePath: javaHomePath - ) - } - - package func acknowledgeReload() { - service.acknowledgeReload() - } - - package func dependencyState(for modulePath: String) -> MavenDependencyLoadState { - service.dependencyState(for: modulePath) - } - - package func loadDependencies(for modulePath: String) { - service.loadDependencies(for: modulePath) - } - - package func cancelDependencies(for modulePath: String) { - service.cancelDependencies(for: modulePath) - } - - package func reset() { service.reset() } - - package func stop() { - service.stop() - } - - package func clearOutput() { - service.clearOutput() - } - -} - /// UI-facing projection for language-neutral run configurations and process sessions. package enum RunConfigurationGenerationIntent: Sendable { case identifyOnly @@ -264,10 +80,14 @@ package final class RunFeatureModel: ObservableObject { /// Provider applications and Current File use the primary output stream. package var selectedProjectSessionID: String? { guard let configuration = selectedConfiguration, configuration.kind != .currentFile else { return nil } + if service.providerDebugConfigurationID == configuration.id { return nil } return configuration.id } package var isSelectedConfigurationRunning: Bool { guard let configuration = selectedConfiguration else { return false } + if service.providerDebugConfigurationID == configuration.id { + return isRunning && lastConfiguration?.id == configuration.id + } if configuration.kind != .currentFile && configuration.buildProvider == nil { return moduleSessions.contains { $0.configurationID == configuration.id && $0.isRunning } } @@ -283,6 +103,16 @@ package final class RunFeatureModel: ObservableObject { package var lastExitCode: Int32? { service.lastExitCode } package var mavenProfiles: [MavenProfile] { service.mavenProfiles } package var primaryExecutionID: String? { service.primaryExecutionID } + package var providerDebugEnded: AnyPublisher { service.providerDebugEnded.eraseToAnyPublisher() } + package func prepareProviderDebug(_ configuration: RunConfiguration, + debug: @escaping @MainActor (String) throws -> BuildLaunchOptions.Debug, + languageImport: @escaping @MainActor (BuildLanguageImport) async throws -> Void = { _ in }, + isCurrent: @escaping @MainActor @Sendable () -> Bool) async throws -> String { + try await service.prepareProviderDebug(configuration, debug: debug, languageImport: languageImport, isCurrent: isCurrent) + } + package func isProviderDebugActive(_ operation: String) -> Bool { service.isProviderDebugActive(operation) } + package func providerDebugProcessID(_ operation: String) -> Int32? { service.providerDebugProcessID(operation) } + package func stopProviderDebug() { service.stopProviderDebug() } package var moduleSessions: [RunSession] { service.moduleSessions } package var portConflicts: [RunPortConflict] { service.portConflicts } package var configurationStatus: ProjectRunConfigurationStatus { service.configurationStatus } @@ -339,6 +169,10 @@ package final class RunFeatureModel: ObservableObject { package func isProjectReady(for workspace: URL, snapshotID: UUID?) -> Bool { service.isProjectReady(for: workspace, snapshotID: snapshotID) } package func hasReadyInventory(for workspace: URL) -> Bool { service.hasReadyInventory(for: workspace) } + package func validateProviderGeneration(for configuration: RunConfiguration) throws { + try service.validateProviderGeneration(for: configuration) + } + package func options(for configuration: RunConfiguration) -> RunOptions { service.options(for: configuration) } @@ -369,13 +203,11 @@ package final class RunFeatureModel: ObservableObject { @discardableResult package func saveEditorChanges( _ options: RunOptions, - toolchain: ProjectToolchainSelection, for configuration: RunConfiguration, scope: RunConfigurationSaveScope ) -> Bool { service.saveEditorChanges( options, - toolchain: toolchain, for: configuration, scope: scope ) @@ -419,10 +251,9 @@ package final class RunFeatureModel: ObservableObject { package func loadProject( at workspaceURL: URL, files: [URL], - mavenProject: MavenProject?, snapshotID: UUID? = nil ) async { - await service.loadProject(at: workspaceURL, files: files, mavenProject: mavenProject, snapshotID: snapshotID) + await service.loadProject(at: workspaceURL, files: files, snapshotID: snapshotID) } /// Adds JDT's view of the generated Java entries to the Run diagnostics. @@ -479,32 +310,12 @@ package final class RunFeatureModel: ObservableObject { /// added here without changing the workspace/UI composition boundary. @MainActor package final class ProjectDevelopmentFeatureModel { - private let mavenFeature: MavenFeatureModel private let runFeature: RunFeatureModel - package init(mavenFeature: MavenFeatureModel, runFeature: RunFeatureModel) { - self.mavenFeature = mavenFeature - self.runFeature = runFeature - } + package init(runFeature: RunFeatureModel) { self.runFeature = runFeature } package func loadProject(at workspaceURL: URL, files: [URL], snapshotID: UUID? = nil) async { - // Maven is one build-system Provider, not a workspace prerequisite. - // Avoid scanning every project as Maven; non-Maven ecosystems should - // reach the generic run pipeline without paying for Java discovery. - let hasMavenDescriptor = files.contains { file in - file.lastPathComponent.lowercased() == "pom.xml" - } - if hasMavenDescriptor { - await mavenFeature.loadProject(at: workspaceURL, files: files) - } else if !mavenFeature.isProjectReloadRequired && !mavenFeature.isReloading { - mavenFeature.reset() - } - await runFeature.loadProject( - at: workspaceURL, - files: files, - mavenProject: mavenFeature.project, - snapshotID: snapshotID - ) + await runFeature.loadProject(at: workspaceURL, files: files, snapshotID: snapshotID) } } diff --git a/macos/Sources/LitheExecutionModule/Module/ExecutionFeatureGraph.swift b/macos/Sources/LitheExecutionModule/Module/ExecutionFeatureGraph.swift index 35ad2a6ff2..577262bffd 100644 --- a/macos/Sources/LitheExecutionModule/Module/ExecutionFeatureGraph.swift +++ b/macos/Sources/LitheExecutionModule/Module/ExecutionFeatureGraph.swift @@ -5,36 +5,34 @@ import LitheModuleAPI @MainActor package final class ExecutionFeatureGraph: NSObject, ExecutionServiceGraph { - package let maven: MavenService package let run: RunService package let tests: LanguageTestService - package let mavenFeature: MavenFeatureModel package let runFeature: RunFeatureModel package let projectDevelopment: ProjectDevelopmentFeatureModel /// Nil when the host runs no plugin build systems. package let buildSystems: BuildSystemService? package let buildSystemsFeature: BuildSystemsFeatureModel? private var activityObservers: Set = [] - private var mavenLease: ModuleLease? private var runLease: ModuleLease? private var testLease: ModuleLease? private var buildLease: ModuleLease? package init( - maven: MavenService, run: RunService, tests: LanguageTestService, buildSystems: BuildSystemService? = nil ) { - self.maven = maven; self.run = run; self.tests = tests + self.run = run; self.tests = tests self.buildSystems = buildSystems buildSystemsFeature = buildSystems.map(BuildSystemsFeatureModel.init(service:)) - let mavenFeature = MavenFeatureModel(service: maven) - self.mavenFeature = mavenFeature runFeature = RunFeatureModel(service: run) - projectDevelopment = ProjectDevelopmentFeatureModel(mavenFeature: mavenFeature, runFeature: runFeature) - run.configureMavenContextProvider { [weak maven] in - maven?.launchContext + projectDevelopment = ProjectDevelopmentFeatureModel(runFeature: runFeature) + run.configureBuildLanguageImportProvider { [weak buildSystems] workspace, root in + try buildSystems?.languageImport(workspaceURL: workspace, root: root) ?? JavaLanguageImportConfiguration() + } + run.configureBuildProjectImportProvider { [weak buildSystems] workspace, project in + guard let buildSystems else { throw CancellationError() } + return try buildSystems.languageImport(workspaceURL: workspace, project: project) } run.configureBuildProviderRuns(start: { [weak buildSystems] configuration, workspace in buildSystems?.runProviderConfiguration(configuration, workspace: workspace) @@ -43,19 +41,30 @@ package final class ExecutionFeatureGraph: NSObject, ExecutionServiceGraph { let message: String? = if case .failed(let message) = ended.state { message } else { nil } run?.finishBuildPreparation(ended.runID, message: message) }.store(in: &activityObservers) - maven.onProjectReloaded = { [weak run] workspace, project in - run?.acceptMavenProject(project, at: workspace) + run.configureBuildModels { [weak buildSystems] in + buildSystems?.entries.filter { $0.modelState == .loaded && !$0.isReloadRequired }.compactMap(\.model) ?? [] + } + run.configureProviderRunConfigurations { [weak buildSystems] in try buildSystems?.providerRunConfigurations() ?? [] } + run.configureBuildProjectOwnership { [weak buildSystems] in try buildSystems?.runProjectOwnership() ?? [] } + run.configureProviderConfigurationRuns(source: { [weak buildSystems] inputs, root in + buildSystems?.configurationRunSource(inputs: inputs, instanceRoot: root) + }) { [weak buildSystems] inputs, root, workspace, hints, current, progress, languageImport in + guard let buildSystems else { throw CancellationError() } + return try await buildSystems.prepareConfigurationRun(inputs: inputs, instanceRoot: root, workspace: workspace, + tool: hints, isCurrent: current, progress: progress, onLanguageImport: languageImport) } - maven.$dependencyStates.dropFirst().sink { [weak run] _ in - Task { @MainActor [weak run] in - run?.syncLanguageDependencyPaths(languageID: "java") - } + + buildSystems?.providerSourcesChanged.sink { [weak run] sources in + run?.retainProviderConfigurations(sources) }.store(in: &activityObservers) + buildSystems?.runGenerationInputsChanged.sink { [weak run] in + run?.requestProviderGenerationInspection() + }.store(in: &activityObservers) + } package var isActive: Bool { - maven.isRunning || maven.isResolvingDependencies || maven.isReloading - || run.isRunning || run.hasRunningServices || tests.isRunning + run.isRunning || run.hasRunningServices || tests.isRunning || buildSystems?.hasActiveWork == true } package var hasActiveExecutionWork: Bool { isActive } @@ -67,21 +76,6 @@ package final class ExecutionFeatureGraph: NSObject, ExecutionServiceGraph { } package func configureModuleLeases(acquire: @escaping @MainActor (String) -> ModuleLease) { - Publishers.CombineLatest3(maven.$taskState, maven.$dependencyStates, maven.$isReloading).map { state, dependencies, reloading in - let buildIsActive = switch state { - case .running, .stopping: true - case .idle, .cancelled, .failed: false - } - let dependenciesAreActive = dependencies.values.contains { - if case .loading = $0 { return true } - return false - } - return buildIsActive || dependenciesAreActive || reloading - }.removeDuplicates().sink { [weak self] active in - guard let self else { return } - if active, mavenLease == nil { mavenLease = acquire("Maven operation is running") } - if !active { mavenLease?.release(); mavenLease = nil } - }.store(in: &activityObservers) run.$isRunning.removeDuplicates().sink { [weak self] active in guard let self else { return } if active, runLease == nil { runLease = acquire("Run configuration is running") } @@ -103,20 +97,21 @@ package final class ExecutionFeatureGraph: NSObject, ExecutionServiceGraph { /// run that launched them, so they are stopped here as well: nothing the /// IDE started may keep running after the session that owns it is gone. package func stop() { - maven.stop(); run.stop(); run.stopAllServices(); tests.stop(); buildSystems?.stop() - releaseLeases() + // Provider teardown publishes input changes; detach before cancelling + // inspections so shutdown cannot enqueue new work in the stopped graph. activityObservers.removeAll() + run.cancelProviderGenerationInspection() + run.stop(); run.stopAllServices(); tests.stop(); buildSystems?.stop() + releaseLeases() } private func releaseLeases() { - mavenLease?.release(); mavenLease = nil runLease?.release(); runLease = nil testLease?.release(); testLease = nil buildLease?.release(); buildLease = nil } } - private struct ExecutionModuleSleepError: LocalizedError { let errorDescription: String? = "Build, run, or test work is still active." static let activeWork = Self() diff --git a/macos/Sources/LitheExecutionModule/Module/ExecutionModule.swift b/macos/Sources/LitheExecutionModule/Module/ExecutionModule.swift index 41d2545b58..db9a013606 100644 --- a/macos/Sources/LitheExecutionModule/Module/ExecutionModule.swift +++ b/macos/Sources/LitheExecutionModule/Module/ExecutionModule.swift @@ -3,7 +3,6 @@ import LitheModuleAPI @MainActor package protocol ExecutionServiceGraph: AnyObject { - var mavenFeature: MavenFeatureModel { get } var runFeature: RunFeatureModel { get } var tests: LanguageTestService { get } var projectDevelopment: ProjectDevelopmentFeatureModel { get } @@ -16,13 +15,11 @@ package protocol ExecutionServiceGraph: AnyObject { @MainActor public final class ExecutionModuleCapability: NSObject { - package let mavenFeature: MavenFeatureModel package let runFeature: RunFeatureModel package let testService: LanguageTestService package let projectDevelopment: ProjectDevelopmentFeatureModel package let buildSystemsFeature: BuildSystemsFeatureModel? fileprivate init(graph: any ExecutionServiceGraph) { - mavenFeature = graph.mavenFeature runFeature = graph.runFeature testService = graph.tests projectDevelopment = graph.projectDevelopment diff --git a/macos/Sources/LitheExecutionModule/Services/BuildLanguageTests.swift b/macos/Sources/LitheExecutionModule/Services/BuildLanguageTests.swift new file mode 100644 index 0000000000..b0abd34185 --- /dev/null +++ b/macos/Sources/LitheExecutionModule/Services/BuildLanguageTests.swift @@ -0,0 +1,242 @@ +import Combine +import Foundation +import LitheCoreContracts +import LitheModuleAPI + +/// Generic handoff between the language test UI and a build provider's operation. +package struct LanguageTestBackendResult: Sendable { + package let exitCode: Int32 + package let reports: [BuildTestResults] + package let references: [BuildTestCaseKey: BuildTestReference] + + package init(exitCode: Int32, reports: [BuildTestResults], references: [BuildTestCaseKey: BuildTestReference] = [:]) { + self.exitCode = exitCode; self.reports = reports; self.references = references + } +} + +package struct BuildTestCaseKey: Hashable, Sendable { + package let project: String? + package let suite: String + package let testCase: String + package init(project: String?, suite: String, testCase: String) { self.project = project; self.suite = suite; self.testCase = testCase } + package init(_ test: BuildTestResults.TestCase) { project = test.project; suite = test.suite; testCase = test.name } +} + +@MainActor +package extension BuildSystemService { + /// Resolves ownership from current model boundaries, never a build descriptor + /// or a test file's basename. A missing/stale provider remains unavailable. + func languageTestEntries(selectorKind: String, fileURL: URL?, workspace: URL) -> [BuildSystemEntry] { + guard workspace.standardizedFileURL == workspaceURL else { return [] } + let candidates = entries.filter { + $0.declaration.testSelectorKinds.contains(selectorKind) + && $0.modelState == .loaded && !$0.isReloadRequired && $0.model != nil + } + guard let fileURL else { return candidates.sorted { $0.instance.root < $1.instance.root } } + let path = fileURL.resolvingSymlinksInPath().standardizedFileURL.path + return candidates.filter { entry in + entry.model?.projects.contains { project in + let directory = workspace.appendingPathComponent(project.path).resolvingSymlinksInPath().standardizedFileURL.path + return path == directory || path.hasPrefix(directory + "/") + } == true + }.sorted { $0.instance.root.count > $1.instance.root.count }.prefix(1).map { $0 } + } + + func languageTestProject(in entry: BuildSystemEntry, fileURL: URL?, workspace: URL) -> BuildSelection? { + guard let fileURL else { return nil } + let path = fileURL.resolvingSymlinksInPath().standardizedFileURL.path + return entry.model?.projects.filter { project in + let directory = workspace.appendingPathComponent(project.path).resolvingSymlinksInPath().standardizedFileURL.path + return path == directory || path.hasPrefix(directory + "/") + }.sorted { $0.path.count > $1.path.count }.first.map(BuildSelection.init(project:)) + } + + func performLanguageTests(selectorKind: String, fileURL: URL?, workspace: URL, title: String, + isCurrent: @escaping @MainActor () -> Bool, + beforePreparing: @escaping @MainActor () async throws -> Void = {}, + prepareSelectors: @escaping @MainActor () async throws -> [String], + output: @escaping @MainActor (String) -> Void, + process: @escaping @MainActor (Int32?) -> Void = { _ in }) async throws -> LanguageTestBackendResult { + var previousOperation = operation?.id + try await beforePreparing() + guard isCurrent(), operation?.id == previousOperation, !Task.isCancelled else { throw CancellationError() } + try await prepareLanguageTestModels(selectorKind: selectorKind, workspace: workspace) + let revision = inputRevision + let selectors = try await prepareSelectors() + guard isCurrent(), inputRevision == revision, operation?.id == previousOperation, + !Task.isCancelled else { throw CancellationError() } + let entries = languageTestEntries(selectorKind: selectorKind, fileURL: fileURL, workspace: workspace) + guard !entries.isEmpty else { throw BuildSystemServiceError(L10n.string("Load the current build model and enable its provider before running this configuration.")) } + var reports: [BuildTestResults] = [] + var references: [BuildTestCaseKey: BuildTestReference] = [:] + var exitCode: Int32 = 0 + var completedOutput = "" + for entry in entries { + try Task.checkCancellation() + guard isCurrent(), inputRevision == revision, operation?.id == previousOperation else { throw CancellationError() } + let request = BuildTestRequest(project: languageTestProject(in: entry, fileURL: fileURL, workspace: workspace), + projects: entry.model?.projects.map(\.path) ?? [], selectorKind: selectorKind, selectors: selectors) + var currentOutput = "" + let prefix = completedOutput + let result = try await performLanguageTests(entry.id, request: request, title: title) { text in + currentOutput = text + output(prefix + text) + } process: { process($0) } + previousOperation = operation?.id + reports += result.reports + for (key, reference) in result.references { + guard references[key] == nil || references[key] == reference else { + throw BuildSystemServiceError(L10n.string("The build provider returned an invalid launch plan.")) + } + references[key] = reference + } + completedOutput += currentOutput + if result.exitCode != 0 { exitCode = result.exitCode } + } + guard isCurrent(), inputRevision == revision, !Task.isCancelled else { throw CancellationError() } + return LanguageTestBackendResult(exitCode: exitCode, reports: reports, references: references) + } + + /// Selects a declared preparation by purpose and the owning model source role. + /// Task identity and execution semantics remain with the build provider. + @discardableResult + func prepareLanguageRun(fileURL: URL, workspace: URL, purpose: String, + optionOverrides: [String: ToolingJSONValue] = [:], + tool: @MainActor (String) throws -> BuildProviderHints?, + isCurrent: @escaping @MainActor () -> Bool) async throws -> BuildLanguageImport? { + let previous = operation?.id + try await prepareLanguageTestModels(preparationPurpose: purpose, workspace: workspace) + let revision = inputRevision + let path = fileURL.resolvingSymlinksInPath().standardizedFileURL.path + let candidates = entries.filter { entry in + let directory = workspace.appendingPathComponent(entry.instance.root).resolvingSymlinksInPath().standardizedFileURL.path + return entry.modelState == .loaded && !entry.isReloadRequired + && entry.declaration.tasks.contains { $0.preparation?.purpose == purpose } + && (path == directory || path.hasPrefix(directory + "/")) + }.sorted { $0.instance.root.count > $1.instance.root.count } + guard isCurrent(), !Task.isCancelled, operation?.id == previous else { throw CancellationError() } + guard let entry = candidates.first else { + throw BuildSystemServiceError(L10n.string("Load the current build model and enable its provider before running this configuration.")) + } + guard let initialTask = entry.declaration.tasks.first(where: { $0.preparation?.purpose == purpose }) else { + throw BuildSystemServiceError(L10n.string("Load the current build model and enable its provider before running this configuration.")) + } + let hints = try tool(entry.instance.root) + let (events, continuation) = AsyncStream.makeStream(of: BuildRunEnd.self) + let observation = runEnded.sink { continuation.yield($0) } + defer { observation.cancel(); continuation.finish() } + var preparedImport: BuildLanguageImport? + guard inputRevision == revision, isCurrent(), operation?.id == previous, + let id = runTask(entry.id, request: BuildTaskRequest(task: initialTask.id, + optionOverrides: optionOverrides, tool: hints), title: entry.instance.displayName, + onLanguageImport: { preparedImport = $0 }, selectFromModel: { model in + guard !model.truncated else { + throw BuildSystemServiceError(L10n.string("Load the current build model and enable its provider before running this configuration.")) + } + let owners = model.projects.filter { project in + let directory = workspace.appendingPathComponent(project.path).resolvingSymlinksInPath().standardizedFileURL.path + return path == directory || path.hasPrefix(directory + "/") + } + let depth = owners.map { $0.path.count }.max() + let deepest = owners.filter { $0.path.count == depth } + guard deepest.count == 1, let owner = deepest.first else { + throw BuildSystemServiceError(L10n.string("Load the current build model and enable its provider before running this configuration.")) + } + let roots = model.contentRoots.filter { root in + let directory = workspace.appendingPathComponent(root.path).resolvingSymlinksInPath().standardizedFileURL.path + return root.projectID == owner.id && (path == directory || path.hasPrefix(directory + "/")) + } + let rootDepth = roots.map { $0.path.count }.max() + let roles = Set(roots.filter { $0.path.count == rootDepth }.map(\.kind)) + let matching = entry.declaration.tasks.filter { task in + task.preparation?.purpose == purpose && task.scopes.contains(.project) + && task.preparation?.sourceRoles.contains(where: roles.contains) == true + } + guard !roles.contains("excluded"), matching.count == 1, let task = matching.first else { + throw BuildSystemServiceError(L10n.string("Load the current build model and enable its provider before running this configuration.")) + } + return BuildTaskRequest(task: task.id, project: BuildSelection(project: owner), optionOverrides: optionOverrides, tool: hints) + }) + else { throw CancellationError() } + defer { cancelRun(id) } + return try await withTaskCancellationHandler { + for await ended in events where ended.runID == id { + try Task.checkCancellation() + guard isCurrent(), operation?.id == id, inputRevision == revision else { throw CancellationError() } + guard ended.exitCode == 0 else { + if case .cancelled = ended.state { throw CancellationError() } + if case .failed(let message) = ended.state { throw BuildSystemServiceError(message) } + throw BuildSystemServiceError(L10n.string("The build provider returned an invalid launch plan.")) + } + return preparedImport + } + throw CancellationError() + } onCancel: { [weak self] in Task { @MainActor in self?.cancelRun(id) } } + } + + /// Report-only items need no guessed source association. Membership and + /// backend availability are resolved from current declarations and models. + func performLanguageTests(reference: BuildTestReference, workspace: URL, title: String, + isCurrent: @escaping @MainActor () -> Bool = { true }, + beforePreparing: @escaping @MainActor () async throws -> Void = {}, + output: @escaping @MainActor (String) -> Void, + process: @escaping @MainActor (Int32?) -> Void) async throws -> LanguageTestBackendResult { + let previousOperation = operation?.id + try await beforePreparing() + try await prepareLanguageTestModels(reference: reference, workspace: workspace) + let revision = inputRevision + guard isCurrent(), !Task.isCancelled, operation?.id == previousOperation, workspace.standardizedFileURL == workspaceURL, + let entry = entries.first(where: { $0.instance.pluginID == reference.pluginID && $0.instance.buildSystemID == reference.buildSystemID + && $0.instance.root == reference.root }), entry.modelState == .loaded, !entry.isReloadRequired, + let project = entry.model?.projects.first(where: { $0.id == reference.projectID }) else { + throw BuildSystemServiceError(L10n.string("Load the current build model and enable its provider before running this configuration.")) + } + let result = try await performLanguageTests(entry.id, request: BuildTestRequest(project: BuildSelection(project: project), + suite: reference.suite, testCase: reference.testCase), title: title, output: output, process: process) + guard isCurrent(), inputRevision == revision, !Task.isCancelled else { throw CancellationError() } + return result + } + + /// Subscriptions exist only for this awaited operation. Stop, replacement, + /// plugin withdrawal and workspace reset all settle through runEnded. + func performLanguageTests(_ entryID: String, request: BuildTestRequest, title: String, + output: @escaping @MainActor (String) -> Void, + process: @escaping @MainActor (Int32?) -> Void = { _ in }) async throws -> LanguageTestBackendResult { + try Task.checkCancellation() + let (events, continuation) = AsyncStream.makeStream(of: (BuildRunEnd, BuildTestResults?).self) + let completion = runEnded.sink { [weak self] ended in + continuation.yield((ended, self?.testResults)) + } + defer { completion.cancel(); continuation.finish() } + guard let entry = entry(entryID), let id = runTests(entryID, request: request, title: title) else { throw CancellationError() } + let progress = $operation.sink { snapshot in + guard let snapshot, snapshot.id == id else { return } + output(snapshot.output) + process(snapshot.processID) + } + defer { progress.cancel(); cancelRun(id) } + return try await withTaskCancellationHandler { + for await (ended, results) in events where ended.runID == id { + try Task.checkCancellation() + guard operation?.id == id else { throw CancellationError() } + switch ended.state { + case .cancelled: throw CancellationError() + case .failed(let message) where ended.exitCode == nil: + throw BuildSystemServiceError(message) + default: + var references: [BuildTestCaseKey: BuildTestReference] = [:] + for test in results?.tests ?? [] { + guard let project = entry.model?.projects.first(where: { $0.path == test.project }) else { continue } + references[BuildTestCaseKey(test)] = BuildTestReference(pluginID: entry.instance.pluginID, + buildSystemID: entry.instance.buildSystemID, root: entry.instance.root, + projectID: project.id, suite: test.suite, testCase: test.name) + } + return LanguageTestBackendResult(exitCode: ended.exitCode ?? -1, reports: results.map { [$0] } ?? [], references: references) + } + } + throw CancellationError() + } onCancel: { [weak self] in + Task { @MainActor in self?.cancelRun(id) } + } + } +} diff --git a/macos/Sources/LitheExecutionModule/Services/BuildNativeConfigurationCoordinator.swift b/macos/Sources/LitheExecutionModule/Services/BuildNativeConfigurationCoordinator.swift new file mode 100644 index 0000000000..9dcb94cc8c --- /dev/null +++ b/macos/Sources/LitheExecutionModule/Services/BuildNativeConfigurationCoordinator.swift @@ -0,0 +1,261 @@ +import Combine +import Foundation +import LitheCoreContracts +import LitheModuleAPI + +/// One operation's current provider identity. Options have a monotonic revision +/// in addition to their values so changing A -> B -> A invalidates old drafts. +package struct BuildNativeConfigurationContext: Equatable, Sendable { + package let entryID: String + package let source: BuildSystemPluginSource + package let buildSystemID: String + package let workspace: URL + package let generation: Int + package let configurationRevision: Int + package let optionsRevision: Int + package let settings: [String: ToolingJSONValue] + package let plan: BuildPlanContext + package var target: BuildSystemTarget { BuildSystemTarget(manifest: source.manifest, buildSystemID: buildSystemID, workspaceURL: workspace) } + package var provider: BuildProviderTarget { + BuildProviderTarget(pluginID: source.pluginID, manifest: source.manifest, + packageURL: source.packageURL, buildSystemID: buildSystemID, workspaceURL: workspace) + } + /// A successful native write invalidates model/file revisions itself. Its + /// read-back still requires the same workspace, package, settings and options. + fileprivate func sameIntent(as other: Self) -> Bool { + entryID == other.entryID && source == other.source && buildSystemID == other.buildSystemID + && workspace == other.workspace && generation == other.generation + && optionsRevision == other.optionsRevision && settings == other.settings + && plan.root == other.plan.root && plan.options == other.plan.options + } +} + +package struct BuildNativeConfigurationState: Equatable, Sendable { + package enum Phase: Equatable, Sendable { case idle, loading, saving } + package var phase: Phase = .idle + package var snapshot: BuildNativeConfiguration? + package var error: String? + package var isStale = false + package var requiresExplicitRead = false + package var isBusy: Bool { phase != .idle } +} + +/// Page-independent native configuration lifecycle, including guarded write and +/// upstream read-back. Original documents never enter this observable state. +@MainActor +package final class BuildNativeConfigurationCoordinator: ObservableObject { + private let planner: any BuildSystemPlanning + private let tools: any BuildToolResolving + private let providers: (any BuildProviderRequesting)? + private let files: (any BuildNativeConfigurationStoring)? + private let context: @MainActor (String) throws -> BuildNativeConfigurationContext? + private let didSave: @MainActor (String) -> Void + package var onActivityChanged: (() -> Void)? + @Published private var states: [String: BuildNativeConfigurationState] = [:] + private var snapshots: [String: BuildNativeConfigurationContext] = [:] + private struct Job { + let id: UUID + let context: BuildNativeConfigurationContext + let task: Task + } + private var jobs: [String: Job] = [:] + package var isBusy: Bool { !jobs.isEmpty } + + package init(planner: any BuildSystemPlanning, tools: any BuildToolResolving, + providers: (any BuildProviderRequesting)?, files: (any BuildNativeConfigurationStoring)?, + context: @escaping @MainActor (String) throws -> BuildNativeConfigurationContext?, + didSave: @escaping @MainActor (String) -> Void) { + self.planner = planner; self.tools = tools; self.providers = providers; self.files = files + self.context = context; self.didSave = didSave + } + + package func state(_ entryID: String) -> BuildNativeConfigurationState { + var value = states[entryID] ?? BuildNativeConfigurationState() + if let saved = snapshots[entryID] { value.isStale = saved != (try? context(entryID)) } + return value + } + + package func read(_ entryID: String, allowProjectTool: Bool = false) async throws -> BuildNativeConfiguration { + if let job = jobs[entryID] { return try await job.task.value } + return try await start(entryID, saving: nil, allowProjectTool: allowProjectTool).value + } + + package func save(_ entryID: String, snapshot: BuildNativeConfiguration, + edits: BuildNativeConfigurationEdits) async throws -> BuildNativeConfiguration { + guard jobs[entryID] == nil else { throw NativeConfigurationOperationError.busy } + guard let current = try context(entryID), snapshots[entryID] == current, + states[entryID]?.snapshot == snapshot else { throw NativeConfigurationOperationError.stale } + return try await start(entryID, saving: (snapshot, edits), allowProjectTool: true).value + } + + package func stop() { + let active = jobs.values + jobs = [:] + for job in active { job.task.cancel() } + states = [:]; snapshots = [:] + onActivityChanged?() + } + + /// Retain a visible draft as stale while preventing its old owner from + /// publishing a reply or writing after option/package changes. + package func invalidate(buildSystemID: String? = nil) { + for (entryID, job) in jobs where buildSystemID == nil || job.context.buildSystemID == buildSystemID { + jobs[entryID] = nil + job.task.cancel() + var value = states[entryID] ?? BuildNativeConfigurationState() + value.phase = .idle; value.isStale = true + value.error = NativeConfigurationOperationError.stale.localizedDescription + states[entryID] = value + } + onActivityChanged?() + } + + private func start(_ entryID: String, saving: (BuildNativeConfiguration, BuildNativeConfigurationEdits)?, allowProjectTool: Bool) throws -> Task { + guard let current = try context(entryID), providers != nil else { throw NativeConfigurationOperationError.unavailable } + let id = UUID() + var state = states[entryID] ?? BuildNativeConfigurationState() + state.phase = saving == nil ? .loading : .saving; state.error = nil + state.requiresExplicitRead = false + states[entryID] = state + let task = Task { @MainActor [weak self] () throws -> BuildNativeConfiguration in + guard let self else { throw CancellationError() } + defer { + if self.jobs[entryID]?.id == id { + self.jobs[entryID] = nil + var value = self.states[entryID] ?? BuildNativeConfigurationState() + value.phase = .idle; self.states[entryID] = value + self.onActivityChanged?() + } + } + do { + var readContext = current + var published: BuildNativeConfigurationPreparedWrite? + if let (snapshot, edits) = saving { + guard let files = self.files else { throw NativeConfigurationOperationError.unavailable } + let prepared = try await self.prepareEdit(entryID, owner: id, context: current, + files: files, snapshot: snapshot, edits: edits) + try self.check(entryID, owner: id, expected: current) + guard let prepared else { return snapshot } + // No actor suspension between the ownership check and CAS. + try files.commit(prepared) + published = prepared + self.didSave(entryID) + guard let after = try self.context(entryID), current.sameIntent(as: after) else { + throw NativeConfigurationOperationError.stale + } + readContext = after + } + let result = try await self.probe(readContext, allowProjectTool: allowProjectTool) + try self.check(entryID, owner: id, expected: readContext, readBack: published != nil) + if let published, let (_, edits) = saving { + guard let source = result.sources.first(where: { $0.id == edits.sourceID }), + URL(string: source.uri)?.standardizedFileURL.resolvingSymlinksInPath() == published.url, + source.revision == published.revision else { throw NativeConfigurationOperationError.readBack } + } + self.snapshots[entryID] = try self.context(entryID) + self.states[entryID] = BuildNativeConfigurationState(snapshot: result) + return result + } catch { + if self.jobs[entryID]?.id == id { + var value = self.states[entryID] ?? BuildNativeConfigurationState() + value.error = error.localizedDescription + value.requiresExplicitRead = (error as? NativeConfigurationOperationError) == .explicitRead + self.states[entryID] = value + } + throw error + } + } + jobs[entryID] = Job(id: id, context: current, task: task) + onActivityChanged?() + return task + } + + private func prepareEdit(_ entryID: String, owner: UUID, context: BuildNativeConfigurationContext, + files: any BuildNativeConfigurationStoring, snapshot: BuildNativeConfiguration, + edits: BuildNativeConfigurationEdits) async throws -> BuildNativeConfigurationPreparedWrite? { + guard let state = context.plan.workspaceStateDirectory, let providers else { throw NativeConfigurationOperationError.unavailable } + let staging = try await Task.detached(priority: .userInitiated) { + try files.prepare(target: context.target, snapshot: snapshot, edits: edits, stateDirectory: state) + }.value + guard let staging else { return nil } + let outcome: Result + do { + try check(entryID, owner: owner, expected: context) + let hints = try await providerHints(context, allowProjectTool: true) + try check(entryID, owner: owner, expected: context) + let request = try planner.configurationEditPlan(context.target, context: context.plan, + hints: hints, snapshot: snapshot, edits: edits, outputFile: staging.outputFile) + let result = try await providers.send(request, to: context.provider, timeout: .seconds(60)) + try check(entryID, owner: owner, expected: context) + let receipt = try planner.decodeConfigurationEdit(context.target, snapshot: snapshot, edits: edits, result: result) + let write = try await Task.detached(priority: .userInitiated) { try files.complete(staging, receipt: receipt) }.value + try check(entryID, owner: owner, expected: context) + outcome = .success(write) + } catch { outcome = .failure(error) } + // Even a cancelled/late prepare owns its staging until this cleanup ends. + // Backend writers use no-CREATE, so they cannot resurrect a removed file. + try await Task.detached(priority: .userInitiated) { try files.discard(staging) }.value + return try outcome.get() + } + + private func check(_ entryID: String, owner: UUID, expected: BuildNativeConfigurationContext, readBack: Bool = false) throws { + try Task.checkCancellation() + guard jobs[entryID]?.id == owner, let current = try context(entryID), + readBack ? expected.sameIntent(as: current) : expected == current else { + throw NativeConfigurationOperationError.stale + } + } + + private func probe(_ context: BuildNativeConfigurationContext, allowProjectTool: Bool) async throws -> BuildNativeConfiguration { + guard let providers else { throw NativeConfigurationOperationError.unavailable } + let planner = planner + let hints = try await providerHints(context, allowProjectTool: allowProjectTool) + let request = try planner.configurationPlan(context.target, context: context.plan, + hints: hints) + let result = try await providers.send(request, to: context.provider, timeout: .seconds(60)) + try Task.checkCancellation() + return try await Task.detached(priority: .userInitiated) { + try planner.decodeConfiguration(context.target, result: result) + }.value + } + private func providerHints(_ context: BuildNativeConfigurationContext, allowProjectTool: Bool) async throws -> BuildProviderHints { + let planner = planner + let invocation = try planner.invocationPlan(context.target, root: context.plan.root, settings: context.settings) + let tool: ResolvedBuildTool? + do { + if invocation.executables.isEmpty { tool = nil } + else { + let selected = try await tools.resolve(invocation, + displayName: context.buildSystemID, workspaceURL: context.workspace) { runtime, output in + try planner.checkToolVersion(context.target, runtime: runtime, output: output) + } + // Opening a settings page cannot run a repository wrapper or + // trigger its cold download. Explicit Read/Save owns that work. + if selected.executesProjectCode && !allowProjectTool { throw NativeConfigurationOperationError.explicitRead } + tool = selected + } + } catch is CancellationError { throw CancellationError() } + catch { + if error as? NativeConfigurationOperationError == .explicitRead { throw error } + if invocation.hasExplicitExecutableSelection || (error as? BuildToolResolutionError)?.terminal == true { throw error } + if !allowProjectTool { throw NativeConfigurationOperationError.explicitRead } + tool = nil + } + try Task.checkCancellation() + return BuildProviderHints(reason: .refresh, toolExecutable: tool?.executableURL, toolEnvironment: tool?.providerEnvironment ?? [:]) + } + +} + +private enum NativeConfigurationOperationError: LocalizedError, Equatable { + case unavailable, stale, busy, readBack, explicitRead + var errorDescription: String? { + switch self { + case .unavailable: L10n.string("Native configuration is unavailable.") + case .stale: L10n.string("Native configuration changed. Reload before saving; your draft has been kept.") + case .busy: L10n.string("Native configuration is busy. Wait for the current operation to finish.") + case .readBack: L10n.string("Native configuration was saved, but its current effective values could not be verified. Reload before editing again.") + case .explicitRead: L10n.string("Reading configuration with this project tool requires an explicit action.") + } + } +} diff --git a/macos/Sources/LitheExecutionModule/Services/BuildSystemService.swift b/macos/Sources/LitheExecutionModule/Services/BuildSystemService.swift index 00b8f98e9a..888452a80d 100644 --- a/macos/Sources/LitheExecutionModule/Services/BuildSystemService.swift +++ b/macos/Sources/LitheExecutionModule/Services/BuildSystemService.swift @@ -44,6 +44,19 @@ package struct BuildRunEnd: Equatable, Sendable { } } +/// The execution retained by the Build output slot, including after completion. +/// Only this operation's output is exposed to callers; background model and +/// dependency loads may share the UI console without changing its history. +package struct BuildOperationSnapshot: Equatable, Sendable { + package let id: UUID + package let entryID: String + package let title: String + package var state: BuildRunState + package var exitCode: Int32? + package var output: String + package var processID: Int32? = nil +} + /// One detected instance with what the host has loaded for it. package struct BuildSystemEntry: Equatable, Identifiable, Sendable { package let instance: BuildSystemInstance @@ -100,7 +113,12 @@ package final class BuildSystemService: ObservableObject { /// Rust Core refuses larger output. private static let decodedOutputLimit = 32 * 1024 * 1024 - @Published package private(set) var entries: [BuildSystemEntry] = [] + /// Emitted after input storage changes, so subscribers never read willSet values. + package let runGenerationInputsChanged = PassthroughSubject() + package let providerSourcesChanged = PassthroughSubject<[BuildSystemPluginSource], Never>() + @Published package private(set) var entries: [BuildSystemEntry] = [] { + didSet { runGenerationInputsChanged.send() } + } @Published package private(set) var isDetecting = false /// Manifests Core refused and build systems another plugin answers for. @Published package private(set) var issues: [String] = [] @@ -116,6 +134,7 @@ package final class BuildSystemService: ObservableObject { @Published package private(set) var outputRoots: [URL] = [] @Published package private(set) var diagnostics: [BuildDiagnostic] = [] @Published package private(set) var testResults: BuildTestResults? + @Published package private(set) var operation: BuildOperationSnapshot? @Published private var testDiscoveries: [String: BuildTestDiscoveryState] = [:] private var discoverySnapshots: [String: DiscoverySnapshot] = [:] private struct DiscoverySnapshot: Equatable { @@ -126,7 +145,13 @@ package final class BuildSystemService: ObservableObject { } package private(set) var workspaceURL: URL? /// Nil while detection is pending. An empty value means no plugin import. - @Published package private(set) var languageImport: JavaLanguageImportConfiguration? + @Published package private(set) var languageImport: JavaLanguageImportConfiguration? { + didSet { runGenerationInputsChanged.send() } + } + private var languageImportsByRoot: [String: JavaLanguageImportConfiguration] = [:] + private var languageImportsByEntryID: [String: BuildLanguageImport] = [:] { + didSet { runGenerationInputsChanged.send() } + } package var onLanguageImportChanged: ((URL) -> Void)? private var languageImportFailure: String? private var detectionID: UUID? @@ -144,7 +169,12 @@ package final class BuildSystemService: ObservableObject { private var preparedApplication: (source: BuildSystemPluginSource, operationID: String)? private let settings: @MainActor (String) -> [String: ToolingJSONValue] private let waitForRedetection: @Sendable () async throws -> Void - private let sources: @MainActor () -> [BuildSystemPluginSource] + private let registeredSources: @MainActor () -> [BuildSystemPluginSource] + // Startup registrations cannot describe a replacement package until restart. + private var replacedPluginIDs: Set = [] + private func sources() -> [BuildSystemPluginSource] { + registeredSources().filter { !replacedPluginIDs.contains($0.pluginID) } + } /// Bumped whenever the workspace changes, so late results are dropped. private var generation = 0 @@ -162,7 +192,13 @@ package final class BuildSystemService: ObservableObject { /// before the change can tell its result is based on older files. private var fileRevision = 0 private var providerConfigurationRevision = 0 + private var nativeOptionsRevisions: [String: Int] = [:] + private var nativeConfiguration: BuildNativeConfigurationCoordinator? + private var nativeObservation: AnyCancellable? private var modelInputRevision: UInt64 = 0 + /// Revokes native consumers' prepared work when provider inputs change, + /// including a change away and back to identical option values. + package private(set) var inputRevision: UInt64 = 0 private var providerRunID: UUID? private var preparingApplicationID: UUID? private var detectionTask: Task? @@ -212,6 +248,7 @@ package final class BuildSystemService: ObservableObject { runner: any BuildCommandRunning, providers: (any BuildProviderRequesting)? = nil, runLauncher: (any BuildRunLaunching)? = nil, + nativeConfigurationFiles: (any BuildNativeConfigurationStoring)? = nil, settings: @escaping @MainActor (String) -> [String: ToolingJSONValue] = { _ in [:] }, waitForRedetection: @escaping @Sendable () async throws -> Void = { try await Task.sleep(for: .milliseconds(500)) @@ -229,7 +266,15 @@ package final class BuildSystemService: ObservableObject { self.runLauncher = runLauncher self.settings = settings self.waitForRedetection = waitForRedetection - self.sources = sources + self.registeredSources = sources + let configuration = BuildNativeConfigurationCoordinator( + planner: planner, tools: tools, providers: providers, files: nativeConfigurationFiles, + context: { [weak self] id in try self?.nativeConfigurationContext(id) }, + didSave: { [weak self] id in self?.nativeConfigurationDidSave(id) } + ) + nativeConfiguration = configuration + configuration.onActivityChanged = { [weak self] in self?.refreshActivity() } + nativeObservation = configuration.objectWillChange.sink { [weak self] _ in self?.objectWillChange.send() } } /// Every run that ends, stopped or superseded runs included, so a caller @@ -249,6 +294,58 @@ package final class BuildSystemService: ObservableObject { entries.first { $0.id == id } } + package func nativeConfigurationState(_ entryID: String) -> BuildNativeConfigurationState { + nativeConfiguration?.state(entryID) ?? BuildNativeConfigurationState() + } + + @discardableResult + package func readNativeConfiguration(_ entryID: String, allowProjectTool: Bool = false) async throws -> BuildNativeConfiguration { + guard let nativeConfiguration else { throw BuildSystemServiceError(L10n.string("Native configuration is unavailable.")) } + return try await nativeConfiguration.read(entryID, allowProjectTool: allowProjectTool) + } + + @discardableResult + package func saveNativeConfiguration(_ entryID: String, snapshot: BuildNativeConfiguration, + edits: BuildNativeConfigurationEdits) async throws -> BuildNativeConfiguration { + guard let nativeConfiguration else { throw BuildSystemServiceError(L10n.string("Native configuration is unavailable.")) } + return try await nativeConfiguration.save(entryID, snapshot: snapshot, edits: edits) + } + + private func nativeConfigurationContext(_ entryID: String) throws -> BuildNativeConfigurationContext? { + guard let workspaceURL, let entry = entry(entryID), entry.declaration.nativeConfiguration, + sources().contains(entry.source) else { return nil } + return BuildNativeConfigurationContext(entryID: entryID, source: entry.source, + buildSystemID: entry.instance.buildSystemID, workspace: workspaceURL, generation: generation, + configurationRevision: providerConfigurationRevision, + optionsRevision: nativeOptionsRevisions[entry.instance.buildSystemID, default: 0], + settings: settings(entry.source.pluginID), plan: try planContext(entry, workspaceURL: workspaceURL)) + } + + private func nativeConfigurationDidSave(_ entryID: String) { + guard let entry = entry(entryID), let workspaceURL else { return } + let buildSystemID = entry.instance.buildSystemID + providerConfigurationRevision += 1 + invalidateTestDiscovery(buildSystemID: buildSystemID) + if providerRunID != nil { stopRun() } + for affected in entries where affected.instance.buildSystemID == buildSystemID { + modelTasks.removeValue(forKey: affected.id)?.cancel() + for key in Array(dependencyTasks.keys) where Self.entryID(ofDependencyTask: key) == affected.id { + dependencyTasks.removeValue(forKey: key)?.cancel() + } + update(affected.id) { + $0.isReloadRequired = true + if $0.modelState == .loading { $0.modelState = $0.model == nil ? .notLoaded : .loaded } + $0.dependencies = [:] + } + modelRevisions[affected.id, default: 0] += 1 + dependencyRevisions[affected.id, default: 0] += 1 + } + invalidateProducerConfiguration(buildSystemID: buildSystemID) + // The selected path can stay the same while its native content changes. + // Language import must re-read that source before its next project sync. + onLanguageImportChanged?(workspaceURL) + } + // MARK: Workspace /// Forgets the previous workspace and detects the new one. @@ -262,6 +359,7 @@ package final class BuildSystemService: ObservableObject { entries = [] modelRevisions = [:] modelConfigurationRevisions = [:] + nativeOptionsRevisions = [:] dependencyRevisions = [:] fileRevision = 0 issues = [] @@ -269,6 +367,7 @@ package final class BuildSystemService: ObservableObject { runState = .idle runningTitle = nil clearOutput() + operation = nil detect() } @@ -277,11 +376,14 @@ package final class BuildSystemService: ObservableObject { /// stale when the files it depends on changed. package func detect() { guard let workspaceURL else { return } + inputRevision += 1 redetectTask?.cancel(); redetectTask = nil detectionTask?.cancel() let detectionID = UUID() self.detectionID = detectionID languageImport = nil + languageImportsByRoot = [:] + languageImportsByEntryID = [:] languageImportFailure = nil let sources = sources() guard !sources.isEmpty else { @@ -292,18 +394,40 @@ package final class BuildSystemService: ObservableObject { return } isDetecting = true - let storedOptions = Dictionary( - sources.flatMap(\.buildSystems).map { - ($0.id, optionsStore.options(workspaceURL: workspaceURL, buildSystemID: $0.id)) - }, - uniquingKeysWith: { first, _ in first } - ) - let emptyMultiSelectOptions = Dictionary( - sources.flatMap(\.buildSystems).map { - ($0.id, optionsStore.emptyMultiSelectOptions(workspaceURL: workspaceURL, buildSystemID: $0.id)) - }, - uniquingKeysWith: { first, _ in first } - ) + let storedOptions: [String: [String: ToolingJSONValue]] + let emptyMultiSelectOptions: [String: [String: Set]] + let instanceOptions: [String: [String: [String: ToolingJSONValue]]] + let instanceDefaults: [String: [String: ToolingJSONValue]] + do { + storedOptions = Dictionary( + try sources.flatMap(\.buildSystems).map { + ($0.id, try optionsStore.options(workspaceURL: workspaceURL, buildSystemID: $0.id)) + }, + uniquingKeysWith: { first, _ in first } + ) + emptyMultiSelectOptions = Dictionary( + try sources.flatMap(\.buildSystems).map { + ($0.id, try optionsStore.emptyMultiSelectOptions(workspaceURL: workspaceURL, buildSystemID: $0.id)) + }, + uniquingKeysWith: { first, _ in first } + ) + instanceOptions = Dictionary( + try sources.flatMap(\.buildSystems).map { + ($0.id, try optionsStore.instanceOptions(workspaceURL: workspaceURL, buildSystemID: $0.id)) + }, + uniquingKeysWith: { first, _ in first } + ) + instanceDefaults = Dictionary( + try sources.flatMap(\.buildSystems).map { + ($0.id, try optionsStore.instanceDefaults(workspaceURL: workspaceURL, buildSystemID: $0.id)) + }, uniquingKeysWith: { first, _ in first } + ) + } catch { + entries = [] + isDetecting = false + failLanguageImport(error) + return + } let previous = Dictionary(uniqueKeysWithValues: entries.map { ($0.id, $0) }) let modelRevisions = modelRevisions let dependencyRevisions = dependencyRevisions @@ -317,6 +441,8 @@ package final class BuildSystemService: ObservableObject { workspaceURL: workspaceURL, sources: sources, storedOptions: storedOptions, + instanceOptions: instanceOptions, + instanceDefaults: instanceDefaults, emptyMultiSelectOptions: emptyMultiSelectOptions, previous: previous ) @@ -327,6 +453,19 @@ package final class BuildSystemService: ObservableObject { self.isDetecting = false switch result { case .success(let detected): + do { + let migrated = try self.optionsStore.migrateLegacyConfiguration( + workspaceURL: workspaceURL, instances: detected.entries.map(\.instance)) + if try migrated || self.migrateInstanceOptions(entries: detected.entries, workspaceURL: workspaceURL) { + self.detect() + return + } + } catch { + self.entries = [] + self.issues = [] + self.failLanguageImport(error) + return + } let current = Dictionary(uniqueKeysWithValues: self.entries.map { ($0.id, $0) }) let publishedBefore = self.entries.filter { $0.declaration.sharesModel } self.entries = detected.entries.map { detectedEntry in @@ -357,12 +496,20 @@ package final class BuildSystemService: ObservableObject { } if publishedBefore != self.entries.filter({ $0.declaration.sharesModel }) { self.invalidateModelInputs() } self.issues = detected.issues - self.persistDroppedOptions() - let inputs = self.entries.map { entry in - LanguageImportInput( - entry: entry, - options: self.storedOptions(for: entry, workspaceURL: workspaceURL) - ) + let inputs: [LanguageImportInput] + do { + try self.persistDroppedOptions() + try self.restoreSavedChoices(workspaceURL: workspaceURL) + inputs = try self.entries.map { entry in + LanguageImportInput( + entry: entry, + options: try self.storedOptions(for: entry, workspaceURL: workspaceURL) + ) + } + } catch { + self.entries = [] + self.failLanguageImport(error) + return } // Discovery is passive: importing a project cannot execute a // wrapper or probe the build tool before user confirmation. @@ -405,6 +552,10 @@ package final class BuildSystemService: ObservableObject { !Task.isCancelled else { return } switch configured { case .success(let values): + self.languageImportsByEntryID = values + self.languageImportsByRoot = Dictionary(uniqueKeysWithValues: Set(inputs.map { $0.entry.instance.root }).map { root in + (root, Self.workspaceImport(inputs: inputs.filter { $0.entry.instance.root == root }, imports: values)) + }) self.publishLanguageImport(Self.workspaceImport(inputs: inputs, imports: values), workspaceURL: workspaceURL) case .failure(let error): self.failLanguageImport(error) } @@ -420,7 +571,16 @@ package final class BuildSystemService: ObservableObject { /// Ends what a now disabled plugin started and detects again with the /// plugins enabled now, so its instances leave the tool window. + package func pluginPackageWillChange(_ pluginID: String) { + replacedPluginIDs.insert(pluginID) + pluginsChanged() + } + package func pluginsChanged() { + let enabledSources = sources() + providerSourcesChanged.send(enabledSources) + nativeConfiguration?.invalidate() + providerConfigurationRevision += 1 for producer in entries where producer.declaration.sharesModel { modelConfigurationRevisions[producer.id, default: 0] += 1 } @@ -466,11 +626,18 @@ package final class BuildSystemService: ObservableObject { testDiscoveries = [:] discoverySnapshots = [:] } - let affected = entries.contains { entry in + let preferencesChanged = changed.contains(".lithe/build/options.json") + if preferencesChanged { + nativeConfiguration?.invalidate() + for id in Set(entries.map(\.instance.buildSystemID)) { + nativeOptionsRevisions[id, default: 0] += 1 + } + } + let affected = preferencesChanged || entries.contains { entry in entry.sync?.watch.contains(where: changed.contains) == true } guard affected else { return } - for entry in entries where entry.declaration.sharesModel && entry.sync?.watch.contains(where: changed.contains) == true { + for entry in entries where entry.declaration.sharesModel && (preferencesChanged || entry.sync?.watch.contains(where: changed.contains) == true) { update(entry.id) { $0.isReloadRequired = true } } invalidateModelInputs() @@ -481,6 +648,8 @@ package final class BuildSystemService: ObservableObject { detectionTask?.cancel(); detectionTask = nil detectionID = nil languageImport = nil + languageImportsByRoot = [:] + languageImportsByEntryID = [:] languageImportFailure = nil isDetecting = false redetectTask?.cancel() @@ -496,6 +665,87 @@ package final class BuildSystemService: ObservableObject { // MARK: Language import + /// Only current, complete upstream projects reach the generator. An empty + /// snapshot is authoritative when no build provider is enabled. + package func runProjectOwnership() throws -> [RunProjectOwnership] { + guard workspaceURL != nil, languageImport != nil else { throw CancellationError() } + return try entries.filter { $0.instance.modelSource != nil && languageImportsByEntryID[$0.id] != nil }.flatMap { entry in + guard entry.modelState == .loaded, !entry.isReloadRequired, let model = entry.model, !model.truncated else { + throw BuildSystemServiceError(L10n.string("Load the current build model and enable its provider before running this configuration.")) + } + return model.projects.map { project in + return RunProjectOwnership(path: project.path, build: RunBuildProjectReference( + pluginID: entry.instance.pluginID, buildSystemID: entry.instance.buildSystemID, + root: entry.instance.root, projectID: project.id), + contentRoots: model.contentRoots.filter { $0.projectID == project.id && ["source", "testSource", "generatedSource", "generatedTestSource"].contains($0.kind) } + .map { RunProjectOwnership.SourceRoot(path: $0.path, kind: $0.kind) }, + name: project.name, minimumJavaVersion: project.properties["java.minimumVersion"]) + } + } + } + + /// Run discovery is language-neutral and never reads provider-specific properties. + package func providerRunConfigurations() throws -> [RunProviderConfiguration] { + guard workspaceURL != nil else { throw CancellationError() } + let enabled = sources() + var configurations: [RunProviderConfiguration] = [] + for entry in entries where !entry.declaration.providerRuns.isEmpty { + guard enabled.contains(entry.source), entry.modelState == .loaded, !entry.isReloadRequired, + let model = entry.model, !model.truncated else { + throw BuildSystemServiceError(L10n.string("Load the current build model and enable its provider before running this configuration.")) + } + let targetsByProject = Dictionary(grouping: model.targets, by: \.projectID) + for project in model.projects { + for run in entry.declaration.providerRuns { + let tags = Set(run.targetTags) + let targets = (targetsByProject[project.id] ?? []).filter { tags.isEmpty || !tags.isDisjoint(with: $0.tags) } + for target in targets { + // Match Core's snapshot budget before expanding a large declaration/model product. + guard configurations.count < 10_000 else { + throw BuildSystemServiceError(L10n.string("The field value does not match its declaration or exceeds its size limit.")) + } + let name = targets.count > 1 ? "\(project.name) · \(run.displayName) · \(target.name)" : "\(project.name) · \(run.displayName)" + configurations.append(RunProviderConfiguration(projectPath: project.path, name: name, + reference: BuildProviderRunReference(pluginID: entry.instance.pluginID, buildSystemID: entry.instance.buildSystemID, + root: entry.instance.root, run: run.id, projectID: project.id, targetID: target.id, execution: run.execution))) + } + } + } + } + return configurations + } + + /// Run preparation uses the same accepted options as Java import, scoped to + /// its recorded build root. Pending detection must never reuse an old root. + package func languageImport(workspaceURL: URL, root: String?) throws -> JavaLanguageImportConfiguration { + guard self.workspaceURL == workspaceURL.standardizedFileURL else { throw CancellationError() } + if let languageImportFailure { throw BuildSystemServiceError(languageImportFailure) } + guard let languageImport else { + throw BuildSystemServiceError(L10n.string("Build-system language import is still preparing. Retry when project loading completes.")) + } + guard let root else { return languageImport } + return languageImportsByRoot[root] ?? JavaLanguageImportConfiguration() + } + + /// Routes only an exact current model identity; names and directory guesses + /// cannot select another plugin's language preparation inputs. + package func languageImport(workspaceURL: URL, project: RunBuildProjectReference) throws -> JavaLanguageImportConfiguration { + guard self.workspaceURL == workspaceURL.standardizedFileURL, languageImportFailure == nil, + languageImport != nil else { throw CancellationError() } + let current = sources() + let matches = entries.filter { entry in + entry.instance.pluginID == project.pluginID && entry.instance.buildSystemID == project.buildSystemID + && entry.instance.root == project.root && current.contains(entry.source) + && entry.modelState == .loaded && !entry.isReloadRequired && entry.model?.truncated == false + && entry.model?.projects.contains(where: { $0.id == project.projectID }) == true + } + guard matches.count == 1, let entry = matches.first, let imported = languageImportsByEntryID[entry.id] else { throw CancellationError() } + switch imported { + case .maven(let value): return JavaLanguageImportConfiguration(mavenContext: value) + case .gradle(let value): return JavaLanguageImportConfiguration(javaGradle: value) + } + } + /// Waits for the current detection, including option normalization. A /// cancelled waiter never cancels detection shared by other consumers. package func prepareLanguageImport(workspaceURL: URL) async throws -> JavaLanguageImportConfiguration { @@ -599,6 +849,45 @@ package final class BuildSystemService: ObservableObject { /// Runs the model command of a command-sourced instance. Other sources /// are read again by detection. `reason` tells a provider whether it /// may answer from its own snapshot. + /// Explicit execution readiness joins the service-owned model work. The + /// model remains shared if this caller stops; its native deadline still applies. + package func prepareLanguageTestModels(selectorKind: String? = nil, reference: BuildTestReference? = nil, taskIDs: Set = [], preparationPurpose: String? = nil, allModels: Bool = false, workspace: URL) async throws { + guard workspace.standardizedFileURL == workspaceURL else { throw CancellationError() } + let workspaceGeneration = generation + let configurationRevision = providerConfigurationRevision + let sourcesRevision = fileRevision + let ids = entries.filter { entry in + if allModels { return entry.instance.modelSource != nil && languageImportsByEntryID[entry.id] != nil } + if let reference { return entry.instance.pluginID == reference.pluginID && entry.instance.buildSystemID == reference.buildSystemID && entry.instance.root == reference.root } + if let preparationPurpose { return entry.declaration.tasks.contains { $0.preparation?.purpose == preparationPurpose } } + return selectorKind.map(entry.declaration.testSelectorKinds.contains) ?? entry.declaration.tasks.contains { taskIDs.contains($0.id) } + }.map(\.id) + if ids.isEmpty { return } + let (changes, continuation) = AsyncStream.makeStream(of: Void.self, bufferingPolicy: .bufferingNewest(1)) + let observation = $entries.sink { _ in continuation.yield(()) } + defer { observation.cancel(); continuation.finish() } + for id in ids { + guard let entry = entry(id) else { throw CancellationError() } + if entry.modelState != .loading && (entry.modelState != .loaded || entry.isReloadRequired) { loadModel(id) } + } + for await _ in changes { + try Task.checkCancellation() + guard workspace.standardizedFileURL == workspaceURL, generation == workspaceGeneration, + providerConfigurationRevision == configurationRevision, fileRevision == sourcesRevision else { throw CancellationError() } + var ready = true + for id in ids { + guard let entry = entry(id) else { throw CancellationError() } + if case .failed(let message) = entry.modelState { throw BuildSystemServiceError(message) } + if entry.modelState != .loaded || entry.isReloadRequired { + guard modelTasks[id] != nil else { throw CancellationError() } + ready = false + } + } + if ready { return } + } + throw CancellationError() + } + package func loadModel(_ entryID: String, reason: BuildLoadReason = .initial) { guard let workspaceURL, let entry = entry(entryID) else { return } if preparingApplicationID != nil, runEntryID == entryID { stopRun() } @@ -655,7 +944,12 @@ package final class BuildSystemService: ObservableObject { $0.dependencies = [:] } self.noteModelChanged(entryID) - self.refreshOptions(buildSystemID: entry.instance.buildSystemID) + do { try self.refreshOptions(buildSystemID: entry.instance.buildSystemID) } + catch { + self.entries = [] + self.failLanguageImport(error) + return + } // Model-provided choices may remove an import option even // when the user has not changed it. Discard any old snapshot. self.detect() @@ -779,12 +1073,15 @@ package final class BuildSystemService: ObservableObject { !entry.isReloadRequired, entry.modelState == .loaded, let project = model.projects.first(where: { $0.id == request.projectID }), let target = model.targets.first(where: { $0.id == request.targetID && $0.projectID == project.id }), - entry.declaration.providerRuns.contains(where: { $0.id == request.run && (debugLaunch == nil || $0.debugAdapter != nil) }) else { return nil } + let declaration = entry.declaration.providerRuns.first(where: { $0.id == request.run && (debugLaunch == nil || $0.debugAdapter != nil) }) else { return nil } + guard configuration?.buildProvider.map({ ($0.execution ?? .application) == declaration.execution }) ?? true else { return nil } + let launchOptions = declaration.execution == .application ? nil : BuildLaunchOptions( + execution: declaration.execution, arguments: request.userArguments) let origin = configuration ?? RunConfiguration(id: "build:" + entryID + ":" + request.run + ":" + request.targetID, - name: title, kind: .process(provider: "build.provider"), execution: .application, modulePath: nil, mainClass: nil, + name: title, kind: .process(provider: "build.provider"), execution: declaration.execution == .service ? .service : (declaration.execution == .task ? .task : .application), modulePath: nil, mainClass: nil, buildProvider: BuildProviderRunReference(pluginID: entry.instance.pluginID, buildSystemID: entry.instance.buildSystemID, root: entry.instance.root, run: request.run, projectID: request.projectID, targetID: request.targetID, - userArguments: request.userArguments)) + userArguments: request.userArguments, execution: declaration.execution == .application ? nil : declaration.execution)) if debugLaunch == nil { stopPreparedApplication() } let sourceRevision = fileRevision return startRun(entryID, title: title) { service, run in @@ -809,7 +1106,8 @@ package final class BuildSystemService: ObservableObject { project: BuildSelection(project: project), selectedTarget: BuildSelection(target: target), userArguments: request.userArguments) } else { planned = try service.planner.prepareRunPlan(entry.target, context: context, run: request.run, - project: BuildSelection(project: project), selectedTarget: BuildSelection(target: target), userArguments: request.userArguments) + project: BuildSelection(project: project), selectedTarget: BuildSelection(target: target), + userArguments: launchOptions == nil ? request.userArguments : [], workspaceURL: workspaceURL, launchOptions: launchOptions) } let validConfiguration = service.runValidity(.provider(planned), entry: entry, workspaceURL: workspaceURL, run: run) let isValid: @MainActor @Sendable () -> Bool = { [weak service] in @@ -824,8 +1122,8 @@ package final class BuildSystemService: ObservableObject { guard isValid() else { throw CancellationError() } let progress: @MainActor @Sendable (BuildProviderProgress) -> Void = { [weak service] progress in guard let service, isValid() else { return } - if let output = progress.output { service.appendOutput(output, in: service.outputDirectory(entry)) } - else if let message = progress.message { service.appendOutput(message + "\n", in: service.outputDirectory(entry)) } + if let output = progress.output { service.appendOutput(output, in: service.outputDirectory(entry), owner: .run(run)) } + else if let message = progress.message { service.appendOutput(message + "\n", in: service.outputDirectory(entry), owner: .run(run)) } } let providerTarget = Self.providerTarget(entry, workspaceURL: workspaceURL) let result: ToolingJSONValue @@ -846,11 +1144,13 @@ package final class BuildSystemService: ObservableObject { return 0 } let preparation = try await Task.detached(priority: .userInitiated) { - try planner.decodeRunPreparation(entry.target, workspaceURL: workspaceURL, root: context.root, result: result) + try planner.decodeRunPreparation(entry.target, workspaceURL: workspaceURL, root: context.root, result: result, launchOptions: launchOptions) }.value guard isValid() else { throw CancellationError() } guard let launcher = service.runLauncher else { throw BuildSystemServiceError(Self.providerUnsupportedMessage) } - let operationID = try launcher.launchPreparedRun(preparation, workspaceURL: workspaceURL, title: title, configuration: origin) + let executable = try await launcher.prepareRunExecutable(preparation, workspaceURL: workspaceURL, configuration: origin) + guard isValid() else { throw CancellationError() } + let operationID = try launcher.launchPreparedRun(preparation, workspaceURL: workspaceURL, title: title, configuration: origin, resolvedExecutable: executable) service.preparedApplication = (entry.source, operationID) service.applicationLaunched.send(entryID) return 0 @@ -858,6 +1158,126 @@ package final class BuildSystemService: ObservableObject { } } + /// Capture the exact enabled package before any asynchronous preparation starts. + package func configurationRunSource(inputs: ProviderLaunchInputs, instanceRoot: String) -> BuildSystemPluginSource? { + let enabled = sources() + let candidates = entries.filter { $0.instance.root == instanceRoot && $0.instance.buildSystemID == inputs.buildSystemID + && (inputs.reference == nil || inputs.reference?.pluginID == $0.instance.pluginID) + && enabled.contains($0.source) } + return candidates.count == 1 ? candidates.first?.source : nil + } + + /// Independent service preparations share provider sessions, never the single Build task slot. + package func prepareConfigurationRun(inputs: ProviderLaunchInputs, instanceRoot: String, workspace: URL, + tool: BuildProviderHints, isCurrent: @escaping @MainActor @Sendable () -> Bool, + progress: @escaping @MainActor @Sendable (BuildProviderProgress) -> Void, + onLanguageImport: @escaping @MainActor (BuildLanguageImport) async throws -> Void = { _ in }) async throws -> BuildRunPreparation { + guard workspace.standardizedFileURL == workspaceURL, let providers else { throw CancellationError() } + let candidates = entries.filter { $0.instance.root == instanceRoot && $0.instance.buildSystemID == inputs.buildSystemID + && (inputs.reference == nil || inputs.reference?.pluginID == $0.instance.pluginID) + && $0.instance.modelSource == .provider && $0.declaration.providerRuns.contains(where: { $0.execution == inputs.launchOptions.execution }) } + guard candidates.count == 1, let entry = candidates.first, entry.modelState == .loaded, !entry.isReloadRequired else { + throw BuildSystemServiceError(Self.providerUnsupportedMessage) + } + let workspaceGeneration = generation, revision = providerConfigurationRevision, files = fileRevision + let initialModel = entry.model, launchSettings = settings(entry.source.pluginID) + let fingerprint = optionFingerprint(entry, workspaceURL: workspace) + let valid: @MainActor @Sendable () -> Bool = { [weak self] in + guard let self, !Task.isCancelled, isCurrent(), self.workspaceURL == workspace.standardizedFileURL, + self.generation == workspaceGeneration, self.providerConfigurationRevision == revision, self.fileRevision == files, + self.settings(entry.source.pluginID) == launchSettings, let current = self.entry(entry.id), + current.source == entry.source, current.model == initialModel, current.modelState == .loaded, !current.isReloadRequired, + self.sources().contains(entry.source), fingerprint != nil else { return false } + return self.optionFingerprint(current, workspaceURL: workspace) == fingerprint + } + return try await withPlanContext(entry, workspaceURL: workspace) { base, _ in + guard valid() else { throw CancellationError() } + let proposed = base.options.merging(inputs.optionOverrides) { _, value in value } + let resolution = try planner.resolveOptions(entry.target, options: proposed, modelValues: initialModel?.optionValues) + var overrides: [String: ToolingJSONValue] = [:] + for key in inputs.optionOverrides.keys { + guard !resolution.dropped.contains(key), let value = resolution.options.first(where: { $0.id == key })?.value else { + throw BuildSystemServiceError(L10n.string("The build option value is invalid. Inspect its current kind and choices.")) + } + // Core normalizes declared values (for example multi-select order). + // A normalized value is valid; a discarded or unknown choice is not. + overrides[key] = value + } + let context = BuildPlanContext(root: base.root, options: base.options.merging(overrides) { _, value in value }, pluginDirectory: base.pluginDirectory, + workspaceStateDirectory: base.workspaceStateDirectory, modelInputs: base.modelInputs) + let launchTool: BuildProviderHints + if tool.toolExecutable != nil && inputs.tools.isEmpty { launchTool = tool } + else { + let planner = planner, target = entry.target + var invocation = try planner.invocationPlan(target, root: entry.instance.root, settings: launchSettings) + invocation.toolSelections = inputs.tools + let selected = try await tools.resolve(invocation, displayName: entry.instance.displayName, workspaceURL: workspace) { runtime, output in + try planner.checkToolVersion(target, runtime: runtime, output: output) + } + guard valid() else { throw CancellationError() } + launchTool = BuildProviderHints(toolExecutable: selected.executableURL, toolEnvironment: selected.providerEnvironment) + } + guard case .provider(let modelRequest) = try planner.modelPlan(entry.target, context: context, outputFile: nil, providerHints: launchTool) else { + throw BuildSystemServiceError(Self.providerUnsupportedMessage) + } + let target = Self.providerTarget(entry, workspaceURL: workspace) + let raw = try await providers.send(modelRequest, to: target, timeout: Self.modelTimeout) + guard valid(), modelInputsAreCurrent(modelRequest) else { throw CancellationError() } + let planner = planner + let model = try await Task.detached(priority: .userInitiated) { + try planner.decodeModel(entry.target, workspaceURL: workspace, root: context.root, output: .providerResult(raw), options: context.options) + }.value + guard valid(), !model.truncated, + let project = model.projects.first(where: { project in + if let reference = inputs.reference { return project.id == reference.projectID } + return project.path == inputs.projectPath + }) else { throw CancellationError() } + let targets = model.targets.filter { target in + guard target.projectID == project.id else { return false } + if let reference = inputs.reference { return target.id == reference.targetID } + return target.name == inputs.provider + } + guard targets.count == 1, let selected = targets.first else { throw BuildSystemServiceError(Self.providerUnsupportedMessage) } + let runs = entry.declaration.providerRuns.filter { $0.execution == inputs.launchOptions.execution + && (inputs.reference == nil || $0.id == inputs.reference?.run) + && ($0.targetTags.isEmpty || !Set($0.targetTags).isDisjoint(with: selected.tags)) } + guard runs.count == 1, let run = runs.first else { throw BuildSystemServiceError(Self.providerUnsupportedMessage) } + if inputs.launchOptions.debug != nil { + let resolution = try planner.resolveOptions(entry.target, options: context.options, modelValues: model.optionValues) + if let imported = try planner.languageImport(entry.target, workspaceURL: workspace, root: context.root, + options: Self.effectiveOptionValues(resolution, stored: context.options), executableURL: launchTool.toolExecutable) { + guard valid() else { throw CancellationError() } + try await onLanguageImport(imported) + } + guard valid() else { throw CancellationError() } + } + let opaqueArguments = inputs.opaqueReferenceArguments + let launchOptions = opaqueArguments == nil ? inputs.launchOptions : nil + let request = try planner.prepareRunPlan(entry.target, context: context, run: run.id, project: BuildSelection(project: project), + selectedTarget: BuildSelection(target: selected), userArguments: opaqueArguments ?? [], workspaceURL: workspace, launchOptions: launchOptions) + let prepared = try await providers.prepareRun(Self.request(request, with: launchTool), on: target, timeout: Self.modelTimeout) { value in + if valid() { progress(value) } + } + guard valid(), modelInputsAreCurrent(request) else { throw CancellationError() } + let result = try await Task.detached(priority: .userInitiated) { + try planner.decodeRunPreparation(entry.target, workspaceURL: workspace, root: context.root, result: prepared, launchOptions: launchOptions) + }.value + guard valid() else { throw CancellationError() } + return result + } + } + + /// A portable prepared plan must use the exact executable selected for its private model read. + private static func request(_ request: BuildProviderRequest, with hints: BuildProviderHints) throws -> BuildProviderRequest { + guard let executable = hints.toolExecutable, case .object(var params) = request.params else { throw CancellationError() } + params["tool"] = .object(["executable": .string(executable.path)]) + var environment = hints.toolEnvironment.mapValues(ToolingJSONValue.string) + if case .object(let options)? = params["environment"] { environment.merge(options) { _, value in value } } + params["environment"] = .object(environment) + return BuildProviderRequest(method: request.method, params: .object(params), optionFingerprint: request.optionFingerprint, + executesProjectCode: request.executesProjectCode, mayUseNetwork: request.mayUseNetwork) + } + private func stopPreparedApplication() { if let preparedApplication { runLauncher?.stopPreparedRun(preparedApplication.operationID) } preparedApplication = nil @@ -925,19 +1345,74 @@ package final class BuildSystemService: ObservableObject { /// Runs a declared task, or a model task through its template. @discardableResult - package func runTask(_ entryID: String, request: BuildTaskRequest, title: String) -> UUID? { + package func runTask(_ entryID: String, request: BuildTaskRequest, title: String, + onLanguageImport: (@MainActor (BuildLanguageImport?) -> Void)? = nil, + selectFromModel: (@MainActor (BuildModel) throws -> BuildTaskRequest)? = nil) -> UUID? { guard let workspaceURL, let entry = entry(entryID) else { return nil } return startRun(entryID, title: title) { service, run in - try await service.withPlanContext(entry, workspaceURL: workspaceURL) { context, _ in - let plan = try service.planner.taskPlan(entry.target, context: context, request: request) + try await service.withPlanContext(entry, workspaceURL: workspaceURL, allocatesOutputFile: selectFromModel != nil) { base, outputFile in + let proposed = base.options.merging(request.optionOverrides) { _, override in override } + if !request.optionOverrides.isEmpty { + let resolution = try service.planner.resolveOptions(entry.target, options: proposed, modelValues: entry.model?.optionValues) + guard request.optionOverrides.allSatisfy({ key, value in + !resolution.dropped.contains(key) && resolution.options.contains { $0.id == key && $0.value == value } + }) else { throw BuildSystemServiceError(L10n.string("The build option value is invalid. Inspect its current kind and choices.")) } + } + let context = BuildPlanContext(root: base.root, options: proposed, pluginDirectory: base.pluginDirectory, + workspaceStateDirectory: base.workspaceStateDirectory, modelInputs: base.modelInputs) + if let onLanguageImport { + onLanguageImport(try service.planner.languageImport(entry.target, workspaceURL: workspaceURL, + root: context.root, options: context.options, executableURL: request.tool?.toolExecutable)) + } + var selectedRequest = request + if let selectFromModel { + let model: BuildModel + if entry.instance.modelSource == .provider || entry.instance.modelSource == .command { + // The run's own model load is a model attempt like any other. + let attempt = service.pluginOperations.begin( + pluginID: entry.instance.pluginID, operation: .buildModel, at: .plan + ) + do { + let plan = try service.planner.modelPlan(entry.target, context: context, outputFile: outputFile, + providerHints: request.tool ?? BuildProviderHints()) + let valid = service.runValidity(plan, entry: entry, workspaceURL: workspaceURL, run: run) + let output = try await service.captureOutput(plan, entry: entry, workspaceURL: workspaceURL, + outputFile: outputFile, title: title, timeout: Self.modelTimeout, owner: .run(run), + attempt: attempt) + let planner = service.planner, target = entry.target + model = try await Task.detached(priority: .userInitiated) { + try planner.decodeModel(target, workspaceURL: workspaceURL, root: context.root, + output: output, options: context.options) + }.value + guard valid() else { throw CancellationError() } + attempt.succeed() + } catch { + attempt.end(throwing: error) + throw error + } + } else if let loaded = entry.model { model = loaded } + else { throw BuildSystemServiceError(Self.providerUnsupportedMessage) } + selectedRequest = try selectFromModel(model) + } + let plan = try service.planner.taskPlan(entry.target, context: context, request: selectedRequest) let isValid = service.runValidity(plan, entry: entry, workspaceURL: workspaceURL, run: run) defer { if service.providerRunID == run.id { service.providerRunID = nil } } - let collected = try await service.runExecutionPlan(plan, entry: entry, workspaceURL: workspaceURL, run: run) + let collected = try await service.runExecutionPlan(plan, entry: entry, workspaceURL: workspaceURL, run: run, tool: request.tool) let diagnostics = await service.decodeDiagnostics(entry, workspaceURL: workspaceURL, output: collected.text) // The decode runs after the command; a stop, replacement, or // workspace switch in between must not publish into the new state. guard isValid() else { throw CancellationError() } service.diagnostics = diagnostics + if entry.declaration.providerTestReports, let report = collected.testResults { + let target = entry.target + let planner = service.planner + let request = BuildTestResultsRequest(workspaceURL: workspaceURL, root: entry.instance.root, + project: selectedRequest.project?.path, projects: entry.model?.projects.map(\.path) ?? [], + output: collected.text, notBefore: nil, suite: nil, result: report) + let results = try await Task.detached(priority: .userInitiated) { try planner.decodeTestResults(target, request: request) }.value + guard isValid() else { throw CancellationError() } + service.testResults = results + } return collected.exitCode } } @@ -951,7 +1426,7 @@ package final class BuildSystemService: ObservableObject { let plan = try service.planner.testPlan(entry.target, context: context, request: request) let isValid = service.runValidity(plan.execution, entry: entry, workspaceURL: workspaceURL, run: run) defer { if service.providerRunID == run.id { service.providerRunID = nil } } - if plan.results != nil, request.project == nil, + if plan.results != nil, plan.results != .provider, request.project == nil, entry.instance.modelSource != nil, entry.model == nil || entry.isReloadRequired { // Report globs are relative to each project; without a current @@ -977,7 +1452,8 @@ package final class BuildSystemService: ObservableObject { projects: request.projects, output: collected.text, notBefore: started, - suite: request.suite + suite: request.suite, + result: collected.testResults ) let planner = service.planner let target = entry.target @@ -991,8 +1467,9 @@ package final class BuildSystemService: ObservableObject { throw CancellationError() } catch { guard isValid() else { throw CancellationError() } + if plan.results == .provider { throw error } service.appendOutput( - "\n" + error.localizedDescription + "\n", in: service.outputDirectory(entry) + "\n" + error.localizedDescription + "\n", in: service.outputDirectory(entry), owner: .run(run) ) } } @@ -1018,16 +1495,21 @@ package final class BuildSystemService: ObservableObject { if runState == .running { runState = .cancelled } + if let ended, operation?.id == ended.runID { operation?.state = .cancelled } if let ended { runEnded.send(ended) } } /// Ends every process this service started. package func stop() { + inputRevision += 1 + nativeConfiguration?.stop() stopPreparedApplication() detectionTask?.cancel(); detectionTask = nil detectionID = nil redetectTask?.cancel(); redetectTask = nil languageImport = nil + languageImportsByRoot = [:] + languageImportsByEntryID = [:] languageImportFailure = nil isDetecting = false finishImportWaiters(.failure(CancellationError())) @@ -1054,6 +1536,7 @@ package final class BuildSystemService: ObservableObject { outputRoots = [] diagnostics = [] testResults = nil + operation?.output = "" } private func startRun( @@ -1069,6 +1552,8 @@ package final class BuildSystemService: ObservableObject { runEntryID = entryID runState = .running runningTitle = title + operation = BuildOperationSnapshot(id: id, entryID: entryID, title: title, + state: .running, exitCode: nil, output: "") runTask = Task { [weak self] in guard let self, self.isCurrent(run) else { return } let state: BuildRunState @@ -1087,13 +1572,15 @@ package final class BuildSystemService: ObservableObject { } catch { if self.runID == id { self.appendOutput( - error.localizedDescription + "\n", in: self.outputDirectory(entryID: entryID) + error.localizedDescription + "\n", in: self.outputDirectory(entryID: entryID), owner: .run(run) ) } state = .failed(error.localizedDescription) } guard self.runID == id else { return } self.runState = state + self.operation?.state = state + self.operation?.exitCode = exitCode self.runTask = nil self.runID = nil self.runEntryID = nil @@ -1290,22 +1777,37 @@ package final class BuildSystemService: ObservableObject { } package func setOption(_ entryID: String, optionID: String, value: ToolingJSONValue?) { + do { try writeOption(entryID, optionID: optionID, value: value) } + catch { issues.append(error.localizedDescription) } + } + + private func writeOption(_ entryID: String, optionID: String, value: ToolingJSONValue?) throws { guard let workspaceURL, let entry = entry(entryID) else { return } let buildSystemID = entry.instance.buildSystemID + nativeOptionsRevisions[buildSystemID, default: 0] += 1 + nativeConfiguration?.invalidate(buildSystemID: buildSystemID) invalidateTestDiscovery(buildSystemID: buildSystemID) if providerRunID != nil, let runEntryID, self.entry(runEntryID)?.instance.buildSystemID == buildSystemID { stopRun() } - var stored = optionsStore.options(workspaceURL: workspaceURL, buildSystemID: buildSystemID) - stored[optionID] = value - var empty = optionsStore.emptyMultiSelectOptions(workspaceURL: workspaceURL, buildSystemID: buildSystemID) - for root in Array(empty.keys) { - empty[root]?.remove(optionID) - if empty[root]?.isEmpty == true { empty[root] = nil } + if entry.declaration.options.first(where: { $0.id == optionID })?.scope == .instance { + var instances = try optionsStore.instanceOptions(workspaceURL: workspaceURL, buildSystemID: buildSystemID) + var stored = instances[entry.instance.root] ?? [:] + stored[optionID] = value + instances[entry.instance.root] = stored.isEmpty ? nil : stored + try optionsStore.setInstanceOptions(instances, workspaceURL: workspaceURL, buildSystemID: buildSystemID) + } else { + var stored = try optionsStore.options(workspaceURL: workspaceURL, buildSystemID: buildSystemID) + stored[optionID] = value + var empty = try optionsStore.emptyMultiSelectOptions(workspaceURL: workspaceURL, buildSystemID: buildSystemID) + for root in Array(empty.keys) { + empty[root]?.remove(optionID) + if empty[root]?.isEmpty == true { empty[root] = nil } + } + try optionsStore.setEmptyMultiSelectOptions(empty, workspaceURL: workspaceURL, buildSystemID: buildSystemID) + try optionsStore.setOptions(stored, workspaceURL: workspaceURL, buildSystemID: buildSystemID) } - optionsStore.setEmptyMultiSelectOptions(empty, workspaceURL: workspaceURL, buildSystemID: buildSystemID) - optionsStore.setOptions(stored, workspaceURL: workspaceURL, buildSystemID: buildSystemID) - refreshOptions(buildSystemID: buildSystemID) + try refreshOptions(buildSystemID: buildSystemID) if entry.declaration.sharesModel { invalidateProducerConfiguration(buildSystemID: buildSystemID) } @@ -1319,24 +1821,35 @@ package final class BuildSystemService: ObservableObject { /// values would otherwise be rejected by Core and revive a non-empty default. /// Reset still removes the option's complete shared and instance overrides. package func setInstanceOption(_ entryID: String, optionID: String, value: ToolingJSONValue?) { + do { try writeInstanceOption(entryID, optionID: optionID, value: value) } + catch { issues.append(error.localizedDescription) } + } + + private func writeInstanceOption(_ entryID: String, optionID: String, value: ToolingJSONValue?) throws { guard let workspaceURL, let entry = entry(entryID) else { return } + if entry.declaration.options.first(where: { $0.id == optionID })?.scope == .instance { + try writeOption(entryID, optionID: optionID, value: value) + return + } guard case .array(let next)? = value, let option = entry.options.first(where: { $0.id == optionID }), option.kind == .multiSelect, case .array(let visible) = option.value else { - setOption(entryID, optionID: optionID, value: value) + try writeOption(entryID, optionID: optionID, value: value) return } let buildSystemID = entry.instance.buildSystemID + nativeOptionsRevisions[buildSystemID, default: 0] += 1 + nativeConfiguration?.invalidate(buildSystemID: buildSystemID) invalidateTestDiscovery(buildSystemID: buildSystemID) if providerRunID != nil, let runEntryID, self.entry(runEntryID)?.instance.buildSystemID == buildSystemID { stopRun() } - var stored = optionsStore.options(workspaceURL: workspaceURL, buildSystemID: buildSystemID) + var stored = try optionsStore.options(workspaceURL: workspaceURL, buildSystemID: buildSystemID) let values: [ToolingJSONValue] if case .array(let items)? = stored[optionID] { values = items } else { values = [] } let siblingValues = values.filter { !visible.contains($0) } stored[optionID] = .array(siblingValues + next.filter { !siblingValues.contains($0) }) - var empty = optionsStore.emptyMultiSelectOptions(workspaceURL: workspaceURL, buildSystemID: buildSystemID) + var empty = try optionsStore.emptyMultiSelectOptions(workspaceURL: workspaceURL, buildSystemID: buildSystemID) let root = entry.instance.root if next.isEmpty { empty[root, default: []].insert(optionID) @@ -1344,9 +1857,9 @@ package final class BuildSystemService: ObservableObject { empty[root]?.remove(optionID) if empty[root]?.isEmpty == true { empty[root] = nil } } - optionsStore.setEmptyMultiSelectOptions(empty, workspaceURL: workspaceURL, buildSystemID: buildSystemID) - optionsStore.setOptions(stored, workspaceURL: workspaceURL, buildSystemID: buildSystemID) - refreshOptions(buildSystemID: buildSystemID) + try optionsStore.setEmptyMultiSelectOptions(empty, workspaceURL: workspaceURL, buildSystemID: buildSystemID) + try optionsStore.setOptions(stored, workspaceURL: workspaceURL, buildSystemID: buildSystemID) + try refreshOptions(buildSystemID: buildSystemID) if entry.declaration.sharesModel { invalidateProducerConfiguration(buildSystemID: buildSystemID) } @@ -1360,10 +1873,70 @@ package final class BuildSystemService: ObservableObject { } } - private func refreshOptions(buildSystemID: String) { + /// A declaration upgrade can move a previously shared option to instance + /// scope. Copy every detected root first, preserve existing local choices, + /// and prove the destination before removing any source value. A second + /// detection then generates static models with the migrated input. + private func migrateInstanceOptions(entries: [BuildSystemEntry], workspaceURL: URL) throws -> Bool { + var changed = false + for buildSystemID in Set(entries.map(\.instance.buildSystemID)).sorted() { + let instances = entries.filter { $0.instance.buildSystemID == buildSystemID } + guard let declaration = instances.first?.declaration else { continue } + let shared = try optionsStore.options(workspaceURL: workspaceURL, buildSystemID: buildSystemID) + let moving = Set(declaration.options.filter { $0.scope == .instance && shared[$0.id] != nil }.map(\.id)) + guard !moving.isEmpty else { continue } + var local = try optionsStore.instanceOptions(workspaceURL: workspaceURL, buildSystemID: buildSystemID) + var empty = try optionsStore.emptyMultiSelectOptions(workspaceURL: workspaceURL, buildSystemID: buildSystemID) + let roots = Set(instances.map(\.instance.root)).union(empty.keys).union(local.keys) + let multiSelect = Set(instances.first?.options.filter { $0.kind == .multiSelect }.map(\.id) ?? []) + for root in roots.sorted() { + for id in moving where local[root]?[id] == nil { + let isCleared = empty[root]?.contains(id) == true && multiSelect.contains(id) + local[root, default: [:]][id] = isCleared ? .array([]) : shared[id] + } + } + try optionsStore.setInstanceOptions(local, workspaceURL: workspaceURL, buildSystemID: buildSystemID) + guard try optionsStore.instanceOptions(workspaceURL: workspaceURL, buildSystemID: buildSystemID) == local, + try optionsStore.options(workspaceURL: workspaceURL, buildSystemID: buildSystemID) == shared else { + throw BuildSystemServiceError(L10n.string("Build option migration could not be verified. The previous configuration has been retained. Reload the build instance to retry.")) + } + let remaining = shared.filter { !moving.contains($0.key) } + try optionsStore.setOptions(remaining, workspaceURL: workspaceURL, buildSystemID: buildSystemID) + guard try optionsStore.options(workspaceURL: workspaceURL, buildSystemID: buildSystemID) == remaining else { + throw BuildSystemServiceError(L10n.string("Build option migration could not be verified. The previous configuration has been retained. Reload the build instance to retry.")) + } + for root in Array(empty.keys) { + empty[root]?.subtract(moving) + if empty[root]?.isEmpty == true { empty[root] = nil } + } + try optionsStore.setEmptyMultiSelectOptions(empty, workspaceURL: workspaceURL, buildSystemID: buildSystemID) + changed = true + } + return changed + } + + /// Choices are presentation data, never selected values or model facts. + private func restoreSavedChoices(workspaceURL: URL) throws { + for system in Set(entries.map(\.instance.buildSystemID)).sorted() { + let saved = try optionsStore.savedChoices(workspaceURL: workspaceURL, buildSystemID: system) + for index in entries.indices where entries[index].instance.buildSystemID == system { + for option in entries[index].options.indices where entries[index].options[option].allowCustom { + let existing = Set(entries[index].options[option].choices.map(\.value)) + let id = entries[index].options[option].id + entries[index].options[option].choices += Array(Set(saved[id] ?? [])).sorted() + .filter { !existing.contains($0) }.map { BuildResolvedOption.Choice(value: $0) } + } + } + } + } + + private func refreshOptions(buildSystemID: String) throws { guard let workspaceURL else { return } - let stored = optionsStore.options(workspaceURL: workspaceURL, buildSystemID: buildSystemID) - let empty = optionsStore.emptyMultiSelectOptions(workspaceURL: workspaceURL, buildSystemID: buildSystemID) + let stored = try optionsStore.options(workspaceURL: workspaceURL, buildSystemID: buildSystemID) + let empty = try optionsStore.emptyMultiSelectOptions(workspaceURL: workspaceURL, buildSystemID: buildSystemID) + var instances = try optionsStore.instanceOptions(workspaceURL: workspaceURL, buildSystemID: buildSystemID) + let defaults = try optionsStore.instanceDefaults(workspaceURL: workspaceURL, buildSystemID: buildSystemID) + let originalInstances = instances var resolutions: [BuildOptionsResolution] = [] for index in entries.indices where entries[index].instance.buildSystemID == buildSystemID { let entry = entries[index] @@ -1373,12 +1946,22 @@ package final class BuildSystemService: ObservableObject { // Shared storage is pruned against the shared value, never an // empty instance override that could hide a sibling's valid choice. resolutions.append(resolution) + let scoped = Self.scopedOptions( + declaration: entry.declaration, workspace: stored, + instance: defaults.merging(instances[entry.instance.root] ?? [:]) { _, root in root } + ) let input = Self.applyingEmptySelections( - to: stored, optionIDs: empty[entry.instance.root] ?? [], options: resolution.options + to: scoped, optionIDs: empty[entry.instance.root] ?? [], options: resolution.options ) - entries[index].options = input == stored ? resolution.options : ( + let effective = input == stored ? resolution : ( try? planner.resolveOptions(entry.target, options: input, modelValues: entry.model?.optionValues) - )?.options ?? resolution.options + ) ?? resolution + entries[index].options = effective.options + if let local = instances[entry.instance.root] { + let declared = Set(entry.declaration.options.filter { $0.scope == .instance }.map(\.id)) + let pruned = Self.mergedOptionValues(stored: local.filter { declared.contains($0.key) }, resolutions: [effective]) + instances[entry.instance.root] = pruned.isEmpty ? nil : pruned + } } // Options are stored per workspace and build system, but a model // value is valid for one instance. Keep a value as long as any @@ -1386,8 +1969,12 @@ package final class BuildSystemService: ObservableObject { // model, so one instance cannot delete another's selection. let merged = Self.mergedOptionValues(stored: stored, resolutions: resolutions) if merged != stored { - optionsStore.setOptions(merged, workspaceURL: workspaceURL, buildSystemID: buildSystemID) + try optionsStore.setOptions(merged, workspaceURL: workspaceURL, buildSystemID: buildSystemID) } + if instances != originalInstances { + try optionsStore.setInstanceOptions(instances, workspaceURL: workspaceURL, buildSystemID: buildSystemID) + } + try restoreSavedChoices(workspaceURL: workspaceURL) } /// Stored values reduced to the ones at least one instance still accepts. @@ -1442,28 +2029,46 @@ package final class BuildSystemService: ObservableObject { /// Stores the effective values Core reported after detection, so a value /// the current model removed cannot stay in the options of the next plan. - private func persistDroppedOptions() { + private func persistDroppedOptions() throws { for buildSystemID in Set(entries.map(\.instance.buildSystemID)) { - refreshOptions(buildSystemID: buildSystemID) + try refreshOptions(buildSystemID: buildSystemID) } } - private func storedOptions(for entry: BuildSystemEntry, workspaceURL: URL) -> [String: ToolingJSONValue] { + private func storedOptions(for entry: BuildSystemEntry, workspaceURL: URL) throws -> [String: ToolingJSONValue] { let buildSystemID = entry.instance.buildSystemID return Self.applyingEmptySelections( - to: optionsStore.options(workspaceURL: workspaceURL, buildSystemID: buildSystemID), - optionIDs: optionsStore.emptyMultiSelectOptions(workspaceURL: workspaceURL, buildSystemID: buildSystemID)[entry.instance.root] ?? [], + to: Self.scopedOptions( + declaration: entry.declaration, + workspace: try optionsStore.options(workspaceURL: workspaceURL, buildSystemID: buildSystemID), + instance: try optionsStore.instanceDefaults(workspaceURL: workspaceURL, buildSystemID: buildSystemID) + .merging(optionsStore.instanceOptions(workspaceURL: workspaceURL, buildSystemID: buildSystemID)[entry.instance.root] ?? [:]) { _, root in root } + ), + optionIDs: try optionsStore.emptyMultiSelectOptions(workspaceURL: workspaceURL, buildSystemID: buildSystemID)[entry.instance.root] ?? [], options: entry.options ) } + /// A scope is an ownership boundary: workspace values never act as hidden + /// overrides for instance options, nor can local data replace shared options. + nonisolated private static func scopedOptions( + declaration: DeclarativeBuildSystem, + workspace: [String: ToolingJSONValue], instance: [String: ToolingJSONValue] + ) -> [String: ToolingJSONValue] { + var values = workspace + for option in declaration.options where option.scope == .instance { + values[option.id] = instance[option.id] + } + return values + } + nonisolated private static func applyingEmptySelections( to stored: [String: ToolingJSONValue], optionIDs: Set, options: [BuildResolvedOption] ) -> [String: ToolingJSONValue] { var values = stored // A plugin update may change the kind; an old empty marker must not // turn a text or boolean option into an array. - for option in options where option.kind == .multiSelect && optionIDs.contains(option.id) { + for option in options where option.scope == .workspace && option.kind == .multiSelect && optionIDs.contains(option.id) { values[option.id] = .array([]) } return values @@ -1471,18 +2076,58 @@ package final class BuildSystemService: ObservableObject { // MARK: Commands + /// Agent and native callers share the instance editor. Invalid values must + /// fail before storage rather than silently reverting to a default. + package func configureOption(_ entryID: String, optionID: String, value: ToolingJSONValue?) throws -> BuildResolvedOption { + guard let workspaceURL, let entry = entry(entryID), entry.options.contains(where: { $0.id == optionID }) else { + throw BuildSystemServiceError(L10n.string("The build option is no longer available. Reload the build instance.")) + } + var proposed = try storedOptions(for: entry, workspaceURL: workspaceURL) + proposed[optionID] = value + let resolution = try planner.resolveOptions(entry.target, options: proposed, modelValues: entry.model?.optionValues) + guard let accepted = resolution.options.first(where: { $0.id == optionID }), + !resolution.dropped.contains(optionID), value == nil || accepted.value == value else { + throw BuildSystemServiceError(L10n.string("The build option value is invalid. Inspect its current kind and choices.")) + } + try writeInstanceOption(entryID, optionID: optionID, value: value) + guard let saved = self.entry(entryID)?.options.first(where: { $0.id == optionID }), value == nil || saved.value == accepted.value else { + throw BuildSystemServiceError(L10n.string("The saved build option could not be verified. Reload before editing again.")) + } + return saved + } + private func optionFingerprint(_ entry: BuildSystemEntry, workspaceURL: URL) -> String? { - let stored = storedOptions(for: entry, workspaceURL: workspaceURL) + guard let stored = try? storedOptions(for: entry, workspaceURL: workspaceURL) else { return nil } return (try? planner.resolveOptions( entry.target, options: stored, modelValues: entry.model?.optionValues ))?.fingerprint } + private func planContext(_ entry: BuildSystemEntry, workspaceURL: URL) throws -> BuildPlanContext { + let stored = try storedOptions(for: entry, workspaceURL: workspaceURL) + let resolution = try? planner.resolveOptions( + entry.target, options: stored, modelValues: entry.model?.optionValues + ) + return BuildPlanContext( + root: entry.instance.root, + // A plan gets only values Core still accepts for the current + // model; otherwise a removed Cargo feature would reach the argv. + options: Self.effectiveOptionValues(resolution, stored: stored), + pluginDirectory: entry.source.packageURL, + workspaceStateDirectory: try stateLocator.workspaceStateDirectory( + pluginID: entry.instance.pluginID, + buildSystemID: entry.instance.buildSystemID, + workspaceURL: workspaceURL + ), + modelInputs: try resolvedModelInputs(entry, workspaceURL: workspaceURL) + ) + } + private func withPlanContext( _ entry: BuildSystemEntry, workspaceURL: URL, allocatesOutputFile: Bool = false, perform: (BuildPlanContext, URL?) async throws -> Value ) async throws -> Value { - let stored = storedOptions(for: entry, workspaceURL: workspaceURL) + let stored = try storedOptions(for: entry, workspaceURL: workspaceURL) let resolution = try? planner.resolveOptions( entry.target, options: stored, modelValues: entry.model?.optionValues ) @@ -1516,8 +2161,8 @@ package final class BuildSystemService: ObservableObject { guard !consumer.declaration.modelInputs.isEmpty else { return nil } let enabled = sources() let required = Set(consumer.declaration.modelInputs.map(\.pluginID)) - let facts = enabled.filter { required.contains($0.pluginID) }.map { source in - BuildModelInputSource(manifest: source.manifest, instances: entries.filter { $0.source == source && $0.declaration.sharesModel }.map { producer in + let facts = try enabled.filter { required.contains($0.pluginID) }.map { source in + BuildModelInputSource(manifest: source.manifest, instances: try entries.filter { $0.source == source && $0.declaration.sharesModel }.map { producer in let state: String switch producer.modelState { case .loading: state = "loading" @@ -1526,7 +2171,7 @@ package final class BuildSystemService: ObservableObject { default: state = "stale" } return BuildModelInputSource.Instance(buildSystemID: producer.instance.buildSystemID, root: producer.instance.root, - state: state, model: state == "ready" ? producer.model : nil, options: storedOptions(for: producer, workspaceURL: workspaceURL)) + state: state, model: state == "ready" ? producer.model : nil, options: try storedOptions(for: producer, workspaceURL: workspaceURL)) }) } return try planner.resolveModelInputs(consumer.target, revision: modelInputRevision, sources: facts) @@ -1546,6 +2191,7 @@ package final class BuildSystemService: ObservableObject { /// Producers are independent in v1: consumers cannot re-export their inputs. /// Revoke consumers immediately; cancellation-ignoring replies still fail revision checks. private func invalidateModelInputs() { + inputRevision += 1 modelInputRevision += 1 let consumers = Set(entries.filter { !$0.declaration.modelInputs.isEmpty }.map(\.id)) if let runEntryID, consumers.contains(runEntryID) { stopRun() } @@ -1622,6 +2268,7 @@ package final class BuildSystemService: ObservableObject { let text: String /// Nil when the command timed out. let exitCode: Int32? + var testResults: ToolingJSONValue? = nil } /// Runs a task or test command; its output is the run's output. @@ -1635,11 +2282,11 @@ package final class BuildSystemService: ObservableObject { plan, entry: entry, workspaceURL: workspaceURL, capturesStandardOutput: false, timeout: nil ) let directory = outputDirectory(entry) - if canAppend(owner) { appendOutput(Self.commandLine(request), in: directory) } + if canAppend(owner) { appendOutput(Self.commandLine(request), in: directory, owner: owner) } let collector = OutputCollector(limit: Self.decodedOutputLimit) let result = try await run(request, owner: owner) { [weak self] chunk in collector.append(chunk) - self?.appendOutput(chunk, in: directory) + self?.appendOutput(chunk, in: directory, owner: owner) } let exitCode: Int32? = switch result.termination { case .exited(let code): code @@ -1655,67 +2302,87 @@ package final class BuildSystemService: ObservableObject { ) -> @MainActor @Sendable () -> Bool { let revision = providerConfigurationRevision let launchSettings = settings(entry.source.pluginID) + let baseline = optionFingerprint(entry, workspaceURL: workspaceURL) return { [weak self] in guard let self, self.isCurrent(run) else { return false } guard case .provider(let request) = plan else { return true } - guard self.modelInputsAreCurrent(request), self.providerConfigurationRevision == revision, + guard let baseline, self.modelInputsAreCurrent(request), self.providerConfigurationRevision == revision, self.settings(entry.source.pluginID) == launchSettings, let current = self.entry(entry.id), current.source == entry.source else { return false } - return self.optionFingerprint(current, workspaceURL: workspaceURL) == request.optionFingerprint + return self.optionFingerprint(current, workspaceURL: workspaceURL) == baseline } } private func runExecutionPlan( - _ plan: BuildOutputPlan, entry: BuildSystemEntry, workspaceURL: URL, run: RunIdentity + _ plan: BuildOutputPlan, entry: BuildSystemEntry, workspaceURL: URL, run: RunIdentity, tool: BuildProviderHints? = nil ) async throws -> CollectedOutput { switch plan { case .process(let process): return try await runStreamedCommand(process, entry: entry, workspaceURL: workspaceURL, owner: .run(run)) case .provider(let request): - return try await runProviderTask(request, entry: entry, workspaceURL: workspaceURL, run: run) + return try await runProviderTask(request, entry: entry, workspaceURL: workspaceURL, run: run, tool: tool) } } /// Provider tasks share model/dependency sessions, not plugin page sessions. /// Progress/results are bound to this run and its effective configuration. private func runProviderTask( - _ request: BuildProviderRequest, entry: BuildSystemEntry, workspaceURL: URL, run: RunIdentity + _ request: BuildProviderRequest, entry: BuildSystemEntry, workspaceURL: URL, run: RunIdentity, tool: BuildProviderHints? = nil ) async throws -> CollectedOutput { guard let providers else { throw BuildSystemServiceError(Self.providerUnsupportedMessage) } providerRunID = run.id let isValid = runValidity(.provider(request), entry: entry, workspaceURL: workspaceURL, run: run) - let planned = try await providerRequestWithTool(request, entry: entry, workspaceURL: workspaceURL, run: run) + let planned = try await providerRequestWithTool(request, entry: entry, workspaceURL: workspaceURL, run: run, override: tool) guard isValid() else { throw CancellationError() } let directory = outputDirectory(entry) let streamed = OutputCollector(limit: Self.decodedOutputLimit) + let target = Self.providerTarget(entry, workspaceURL: workspaceURL) + if isValid() { operation?.processID = providers.processID(for: target) } let result = try await providers.execute( - planned, on: Self.providerTarget(entry, workspaceURL: workspaceURL), timeout: .seconds(3600) + planned, on: target, timeout: .seconds(3600) ) { [weak self] progress in guard let self, isValid() else { return } + self.operation?.processID = providers.processID(for: target) if let text = progress.output { streamed.append(text) - self.appendOutput(text, in: directory) + self.appendOutput(text, in: directory, owner: .run(run)) } else if let message = progress.message { - self.appendOutput(message + "\n", in: directory) + self.appendOutput(message + "\n", in: directory, owner: .run(run)) } } guard isValid() else { throw CancellationError() } if result.output.utf8.starts(with: streamed.text.utf8) { - appendOutput(String(decoding: result.output.utf8.dropFirst(streamed.text.utf8.count), as: UTF8.self), in: directory) + appendOutput(String(decoding: result.output.utf8.dropFirst(streamed.text.utf8.count), as: UTF8.self), in: directory, owner: .run(run)) } else { // A malformed stream must not hide the final diagnostic output. - appendOutput(result.output, in: directory) + appendOutput(result.output, in: directory, owner: .run(run)) } - return CollectedOutput(text: result.output, exitCode: result.exitCode) + return CollectedOutput(text: result.output, exitCode: result.exitCode, testResults: result.testResults) } private func providerRequestWithTool( - _ request: BuildProviderRequest, entry: BuildSystemEntry, workspaceURL: URL, run: RunIdentity + _ request: BuildProviderRequest, entry: BuildSystemEntry, workspaceURL: URL, run: RunIdentity, override: BuildProviderHints? = nil ) async throws -> BuildProviderRequest { + if let override { + guard let executable = override.toolExecutable else { throw BuildSystemServiceError(Self.providerUnsupportedMessage) } + var params = request.params + if case .object(var object) = params { + object["tool"] = .object(["executable": .string(executable.path)]) + var environment = override.toolEnvironment.mapValues(ToolingJSONValue.string) + if case .object(let options)? = object["environment"] { environment.merge(options) { _, value in value } } + object["environment"] = .object(environment) + params = .object(object) + } + return BuildProviderRequest(method: request.method, params: params, optionFingerprint: request.optionFingerprint, + executesProjectCode: request.executesProjectCode, mayUseNetwork: request.mayUseNetwork) + } let tool = try await providerTool(entry, workspaceURL: workspaceURL, owner: .run(run)) var params = request.params if let tool, case .object(var object) = params { - object["tool"] = .object(["executable": .string(tool.path)]) + object["tool"] = .object(["executable": .string(tool.executableURL.path)]) + var environment = tool.providerEnvironment.mapValues(ToolingJSONValue.string) + if case .object(let options)? = object["environment"] { environment.merge(options) { _, contribution in contribution } } + object["environment"] = .object(environment) params = .object(object) } return BuildProviderRequest(method: request.method, params: params, optionFingerprint: request.optionFingerprint, @@ -1744,13 +2411,13 @@ package final class BuildSystemService: ObservableObject { let executable = try await providerTool(entry, workspaceURL: workspaceURL, owner: owner) attempt.advance(to: .plan) guard let executable else { return planned } - return try plan(BuildProviderHints(reason: reason, toolExecutable: executable)) + return try plan(BuildProviderHints(reason: reason, toolExecutable: executable.executableURL, toolEnvironment: executable.providerEnvironment)) } /// The executable the build system's invocation resolves to, as a run /// would use it. Automatic discovery may fall back to the provider's lookup; /// an explicit selection must fail rather than use a different installation. - private func providerTool(_ entry: BuildSystemEntry, workspaceURL: URL, owner: CommandOwner) async throws -> URL? { + private func providerTool(_ entry: BuildSystemEntry, workspaceURL: URL, owner: CommandOwner) async throws -> ResolvedBuildTool? { let planner = planner let target = entry.target let invocation = try planner.invocationPlan( @@ -1762,7 +2429,7 @@ package final class BuildSystemService: ObservableObject { invocation, displayName: entry.instance.displayName, workspaceURL: workspaceURL ) { runtime, output in try planner.checkToolVersion(target, runtime: runtime, output: output) - }.executableURL + } } catch is CancellationError { throw CancellationError() } catch { @@ -1772,7 +2439,7 @@ package final class BuildSystemService: ObservableObject { if canAppend(owner) { appendOutput( "\n" + L10n.string("The provider looks up the build tool itself: \(error.localizedDescription)") + "\n", - in: outputDirectory(entry) + in: outputDirectory(entry), owner: owner ) } return nil @@ -1891,8 +2558,15 @@ package final class BuildSystemService: ObservableObject { } } - private func appendOutput(_ chunk: String, in directory: URL?) { + private func appendOutput(_ chunk: String, in directory: URL?, owner: CommandOwner? = nil) { guard !chunk.isEmpty else { return } + if case .run(let run)? = owner, var snapshot = operation, snapshot.id == run.id, canAppend(.run(run)) { + snapshot.output += chunk + if snapshot.output.utf8.count > Self.visibleOutputLimit { + snapshot.output = String(snapshot.output.suffix(Self.visibleOutputLimit / 2)) + } + operation = snapshot + } if let directory, !outputRoots.contains(directory) { outputRoots = (outputRoots + [directory]).sorted { $0.path < $1.path } } @@ -1913,7 +2587,7 @@ package final class BuildSystemService: ObservableObject { } private func refreshActivity() { - let active = isRunning || isLoadingModel || isLoadingDependencies + let active = isRunning || isLoadingModel || isLoadingDependencies || nativeConfiguration?.isBusy == true if hasActiveWork != active { hasActiveWork = active } } @@ -1966,10 +2640,12 @@ package final class BuildSystemService: ObservableObject { workspaceURL: URL, sources: [BuildSystemPluginSource], storedOptions: [String: [String: ToolingJSONValue]], + instanceOptions: [String: [String: [String: ToolingJSONValue]]], + instanceDefaults: [String: [String: ToolingJSONValue]], emptyMultiSelectOptions: [String: [String: Set]], previous: [String: BuildSystemEntry] ) throws -> Detected { - let detection = try planner.detect(workspaceURL: workspaceURL, sources: sources, options: storedOptions) + let detection = try planner.detect(workspaceURL: workspaceURL, sources: sources, options: storedOptions, instanceOptions: instanceOptions, instanceDefaults: instanceDefaults) var issues = detection.rejected.map { rejected in let pluginID = sources.indices.contains(rejected.index) ? sources[rejected.index].pluginID : "?" return "\(pluginID): \(rejected.error.message)" @@ -1981,7 +2657,11 @@ package final class BuildSystemService: ObservableObject { guard let source = sources.first(where: { $0.pluginID == instance.pluginID }), let declaration = source.buildSystems.first(where: { $0.id == instance.buildSystemID }) else { return nil } - let stored = storedOptions[instance.buildSystemID] ?? [:] + let stored = Self.scopedOptions( + declaration: declaration, + workspace: storedOptions[instance.buildSystemID] ?? [:], + instance: (instanceDefaults[instance.buildSystemID] ?? [:]).merging(instanceOptions[instance.buildSystemID]?[instance.root] ?? [:]) { _, root in root } + ) let target = BuildSystemTarget(manifest: source.manifest, buildSystemID: instance.buildSystemID, workspaceURL: workspaceURL) let earlier = previous[instance.id] // The instance ID is the build-system ID plus its root, so another diff --git a/macos/Sources/LitheExecutionModule/Services/LanguageTestService.swift b/macos/Sources/LitheExecutionModule/Services/LanguageTestService.swift index 2ea00fd18f..9e65f270f2 100644 --- a/macos/Sources/LitheExecutionModule/Services/LanguageTestService.swift +++ b/macos/Sources/LitheExecutionModule/Services/LanguageTestService.swift @@ -17,11 +17,11 @@ package final class LanguageTestService: ObservableObject { @Published package private(set) var itemsByProviderID: [String: [LanguageTestItem]] = [:] @Published package private(set) var state: LanguageTestRunState = .idle @Published package private(set) var activePlan: LanguageTestPlan? - @Published package private(set) var results: MavenTestResults? - /// Latest outcome of every Maven test method run in this workspace. A run - /// replaces the outcomes of the classes it produced and keeps the rest, - /// so editor Run markers survive unrelated runs. - @Published package private(set) var testOutcomes: [MavenTestCaseOutcome] = [] + @Published package private(set) var results: BuildTestResults? + /// Backend coverage replaces only completed scopes; unrelated editor markers survive. + @Published package private(set) var testOutcomesByProviderID: [String: [BuildTestResults.TestCase]] = [:] + package private(set) var resultReferences: [BuildTestCaseKey: BuildTestReference] = [:] + private var resultsWorkspaceURL: URL? @Published package private(set) var output = "" @Published package private(set) var errorMessage: String? @Published package private(set) var runID: UUID? @@ -32,40 +32,38 @@ package final class LanguageTestService: ObservableObject { private let executableResolver: any RunExecutableResolving private let processFactory: () -> any StreamingProcess private let extensionRequiredLanguageIDs: Set - private let resultParser: (@Sendable (String, URL, MavenTestReportRequest?) -> MavenTestResults?)? - private let now: () -> Date - private var activeReportRequest: MavenTestReportRequest? private var process: (any StreamingProcess)? private var extensionSession: (any LanguageExecutionSession)? private var languageTestExtensions: [String: RegisteredLanguageTestExtension] = [:] private var activeOperationID: String? private var timedOutOperationID: String? private var activeWorkspaceURL: URL? - private var outputCapture: LanguageTestOutputCapture? + private var backendTask: Task? + private var lastBackendRun: (@MainActor () -> Void)? private var lastRun: LastRun? private let maximumOutputCharacters = 400_000 - private static let mavenTestTimeoutMilliseconds = 120_000 + private static let javaTestTimeoutMilliseconds = 120_000 package init( catalog: LanguageProviderCatalog = .compatibilityFallback, registry: LanguageTestProviderRegistry? = nil, executableResolver: any RunExecutableResolving, processFactory: @escaping () -> any StreamingProcess, - extensionRequiredLanguageIDs: Set = [], - resultParser: (@Sendable (String, URL, MavenTestReportRequest?) -> MavenTestResults?)? = nil, - now: @escaping () -> Date = Date.init + extensionRequiredLanguageIDs: Set = [] ) { self.catalog = catalog self.registry = registry ?? .standard(catalog: catalog) self.executableResolver = executableResolver self.processFactory = processFactory self.extensionRequiredLanguageIDs = extensionRequiredLanguageIDs - self.resultParser = resultParser - self.now = now + } + + package func testOutcomes(for providerID: String) -> [BuildTestResults.TestCase] { + testOutcomesByProviderID[providerID] ?? [] } package var isRunning: Bool { state == .running } - package var canRerun: Bool { lastRun != nil && !isRunning } + package var canRerun: Bool { (lastRun != nil || lastBackendRun != nil) && !isRunning } package func hasRun(providerID: String, scope: LanguageTestScope) -> Bool { lastRun?.providerID == providerID && lastRun?.scope == scope @@ -146,9 +144,12 @@ package final class LanguageTestService: ObservableObject { options: RunOptions = RunOptions() ) -> Bool { stop(markCancelled: false) + lastBackendRun = nil runID = nil output = "" results = nil + resultReferences = [:] + resultsWorkspaceURL = nil errorMessage = nil let root = workspaceURL.standardizedFileURL do { @@ -197,8 +198,6 @@ package final class LanguageTestService: ObservableObject { let operationID = id.uuidString activeOperationID = operationID activeWorkspaceURL = root - let outputCapture = LanguageTestOutputCapture(maximumCharacters: maximumOutputCharacters) - self.outputCapture = outputCapture let timeoutMarker = LanguageTestTimeoutMarker() lastRun = LastRun( providerID: providerID, @@ -208,9 +207,6 @@ package final class LanguageTestService: ObservableObject { options: options ) activePlan = plan - activeReportRequest = plan.frameworkID == "maven" - ? Self.mavenReportRequest(scope: scope, workspaceURL: root, startedAt: now()) - : nil state = .running timedOutOperationID = nil append("$ \(resolved.executableURL.lastPathComponent) \(plan.launchPlan.arguments.joined(separator: " "))\n\n") @@ -220,7 +216,6 @@ package final class LanguageTestService: ObservableObject { configureExtensionSession( session, operationID: operationID, - outputCapture: outputCapture, timeoutMarker: timeoutMarker ) extensionSession = session @@ -237,7 +232,6 @@ package final class LanguageTestService: ObservableObject { configureProcess( process, operationID: operationID, - outputCapture: outputCapture, timeoutMarker: timeoutMarker ) self.process = process @@ -259,7 +253,6 @@ package final class LanguageTestService: ObservableObject { activeOperationID = nil processID = nil timedOutOperationID = nil - outputCapture = nil activePlan = nil state = .failed(exitCode: -1) errorMessage = error.localizedDescription @@ -268,12 +261,81 @@ package final class LanguageTestService: ObservableObject { } } + /// Uses an operation-owning backend while preserving the native test UI, + /// rerun and cancellation lifecycle. The closure resolves fresh facts on rerun. + package func runUsingBackend(providerID: String, scope: LanguageTestScope, workspaceURL: URL, + label: String, + execute: @escaping @MainActor (@escaping @MainActor (String) -> Void, @escaping @MainActor (Int32?) -> Void) async throws -> LanguageTestBackendResult) { + stop(markCancelled: false) + let id = UUID() + runID = id + activeOperationID = id.uuidString + activeWorkspaceURL = workspaceURL.standardizedFileURL + output = ""; results = nil; resultReferences = [:]; resultsWorkspaceURL = nil; errorMessage = nil + activePlan = LanguageTestPlan(providerID: providerID, label: label, frameworkID: "build.provider", + launchPlan: SharedLaunchPlan(executable: .command("build-provider"), arguments: [], workingDirectory: ".")) + lastRun = LastRun(providerID: providerID, scope: scope, workspaceURL: workspaceURL, projectFiles: [], options: RunOptions()) + lastBackendRun = { [weak self] in + self?.runUsingBackend(providerID: providerID, scope: scope, workspaceURL: workspaceURL, label: label, execute: execute) + } + state = .running + backendTask = Task { [weak self] in + guard let self else { return } + defer { if activeOperationID == id.uuidString { activeOperationID = nil; backendTask = nil; processID = nil; activeWorkspaceURL = nil } } + do { + let result = try await execute({ [weak self] text in + guard let self, activeOperationID == id.uuidString else { return } + output = String(text.suffix(maximumOutputCharacters)) + }, { [weak self] pid in + guard let self, activeOperationID == id.uuidString else { return } + let next = pid.flatMap { $0 > 0 ? $0 : nil } + if processID != next { processID = next } + }) + try Task.checkCancellation() + guard activeOperationID == id.uuidString else { return } + let report = BuildTestResults.combining(result.reports) + results = report + resultReferences = result.references + resultsWorkspaceURL = workspaceURL.standardizedFileURL + // Only the backend can say that a whole suite/project completed. + // Legacy reports without coverage replace their exact returned cases. + let keys = Set(result.reports.flatMap(\.tests).map(BuildTestCaseKey.init)) + let coverage = result.reports.flatMap { $0.coverage ?? [] } + var outcomes = testOutcomesByProviderID[providerID] ?? [] + outcomes.removeAll { outcome in + keys.contains(BuildTestCaseKey(outcome)) + || coverage.contains { scope in + scope.project == outcome.project + && (scope.suite == nil || scope.suite == outcome.suite) + && (scope.testCase == nil || scope.testCase == outcome.name) + } + } + testOutcomesByProviderID[providerID] = outcomes + report.tests + state = result.exitCode == 0 ? .passed : .failed(exitCode: result.exitCode) + } catch is CancellationError { + if activeOperationID == id.uuidString { state = .cancelled } + } catch { + guard activeOperationID == id.uuidString else { return } + errorMessage = error.localizedDescription + append(error.localizedDescription + "\n") + state = .failed(exitCode: -1) + } + } + } + + package func sourceURL(for test: BuildTestResults.TestCase) -> URL? { + guard let root = resultsWorkspaceURL, let path = test.path, + !path.hasPrefix("/"), !path.contains("\\"), + !path.split(separator: "/").contains("..") else { return nil } + return root.appendingPathComponent(path).standardizedFileURL + } + package func stop() { stop(markCancelled: true) } package func reset() { closeRun() itemsByProviderID = [:] - testOutcomes = [] + testOutcomesByProviderID = [:] } /// Dispose the retained run without discarding discovery or editor test markers. @@ -282,9 +344,11 @@ package final class LanguageTestService: ObservableObject { activePlan = nil activeWorkspaceURL = nil lastRun = nil + lastBackendRun = nil runID = nil results = nil - activeReportRequest = nil + resultReferences = [:] + resultsWorkspaceURL = nil output = "" errorMessage = nil state = .idle @@ -293,10 +357,13 @@ package final class LanguageTestService: ObservableObject { package func clearOutput() { output = "" results = nil + resultReferences = [:] + resultsWorkspaceURL = nil } @discardableResult package func rerun() -> Bool { + if let lastBackendRun { lastBackendRun(); return true } guard let lastRun else { return false } return run( providerID: lastRun.providerID, @@ -310,10 +377,10 @@ package final class LanguageTestService: ObservableObject { private func stop(markCancelled: Bool) { let wasRunning = state == .running activeOperationID = nil + backendTask?.cancel(); backendTask = nil processID = nil timedOutOperationID = nil activeWorkspaceURL = nil - outputCapture = nil process?.stop() process = nil extensionSession?.stop() @@ -350,11 +417,9 @@ package final class LanguageTestService: ObservableObject { private func configureProcess( _ process: any StreamingProcess, operationID: String, - outputCapture: LanguageTestOutputCapture, timeoutMarker: LanguageTestTimeoutMarker ) { - process.onOutput = { [weak self, outputCapture] chunk in - outputCapture.append(chunk) + process.onOutput = { [weak self] chunk in Task { @MainActor [weak self] in guard self?.activeOperationID == operationID else { return } self?.append(chunk) @@ -387,11 +452,9 @@ package final class LanguageTestService: ObservableObject { private func configureExtensionSession( _ session: any LanguageExecutionSession, operationID: String, - outputCapture: LanguageTestOutputCapture, timeoutMarker: LanguageTestTimeoutMarker ) { - session.onOutput = { [weak self, outputCapture] chunk in - outputCapture.append(chunk) + session.onOutput = { [weak self] chunk in Task { @MainActor [weak self] in guard self?.activeOperationID == operationID else { return } self?.append(chunk) @@ -448,127 +511,26 @@ package final class LanguageTestService: ObservableObject { errorMessage = message append(message + "\n") } - let capturedOutput = outputCapture?.snapshot() ?? output - let parsedResults: MavenTestResults? - let parsingWorkspaceURL = activeWorkspaceURL - let reportRequest = activeReportRequest - let requestedMethod = lastRun.flatMap { Self.mavenTestMethod(in: $0.scope) } - if activePlan?.frameworkID == "maven", - let resultParser, - let parsingWorkspaceURL { - parsedResults = await Task.detached(priority: .utility) { - resultParser(capturedOutput, parsingWorkspaceURL, reportRequest) - }.value - } else { - parsedResults = nil - } - // Parsing may outlive Stop, reset, or a replacement run. Only the - // operation and workspace that produced the output may publish it. - guard activeOperationID == operationID, - activeWorkspaceURL == parsingWorkspaceURL else { return } - results = parsedResults - if let reportRequest, let testCases = parsedResults?.testCases { - testOutcomes = Self.mergedOutcomes( - testOutcomes, - requestedClasses: reportRequest.classes, - requestedMethod: requestedMethod, - recorded: testCases - ) - } - activeReportRequest = nil state = timedOut ? .timedOut : (exitCode == 0 ? .passed : .failed(exitCode: exitCode)) activeOperationID = nil processID = nil timedOutOperationID = nil activeWorkspaceURL = nil - outputCapture = nil process = nil extensionSession = nil } private func testTimeoutMessage() -> String { - let framework = activePlan?.frameworkID == "junit" ? "JUnit" : "Maven" - return "\(framework) test run timed out after \(Self.mavenTestTimeoutMilliseconds / 1000) seconds." + L10n.string("The build tool timed out.") } private static func testTimeoutMilliseconds(for frameworkID: String?) -> Int? { switch frameworkID { - case "maven", "junit": return mavenTestTimeoutMilliseconds + case "junit": return javaTestTimeoutMilliseconds default: return nil } } - /// Names the reports a Maven run will write. Maven runs from the reactor - /// root, so Core locates the module from the test file; a test-case - /// identifier (`demo.OrderTest#creates()`) also names the class, while a - /// whole-file run lets the run's start time select its reports. - nonisolated static func mavenReportRequest( - scope: LanguageTestScope, - workspaceURL: URL, - startedAt: Date - ) -> MavenTestReportRequest { - let fileURL: URL? - var classes: [String] = [] - switch scope { - case .workspace: - fileURL = nil - case .file(let url): - fileURL = url - case .testCase(let identifier, let url): - fileURL = url - let className = identifier.split(separator: "#", maxSplits: 1).first.map(String.init) ?? "" - if !className.isEmpty, !className.contains("(") { classes = [className] } - } - let rootPath = workspaceURL.standardizedFileURL.path - let sourcePath = fileURL.map(\.standardizedFileURL.path).flatMap { path -> String? in - guard path.hasPrefix(rootPath + "/") else { return nil } - return String(path.dropFirst(rootPath.count + 1)) - } - return MavenTestReportRequest( - sourcePath: sourcePath, - classes: classes, - notBeforeMillis: UInt64(max(0, startedAt.timeIntervalSince1970 * 1000)) - ) - } - - /// A method selector narrows the outcome replacement to that method; - /// a class or file run may replace the complete class report. - nonisolated static func mavenTestMethod(in scope: LanguageTestScope) -> String? { - guard case .testCase(let identifier, _) = scope, - let separator = identifier.firstIndex(of: "#") else { return nil } - let method = identifier[identifier.index(after: separator)...] - .split(separator: "(", maxSplits: 1).first.map(String.init) - return method?.isEmpty == false ? method : nil - } - - /// Replaces only the selected method for a method run, or the classes - /// a class/file run covered, including nested classes. Without a requested - /// class list, only the classes present in the report are covered. - nonisolated static func mergedOutcomes( - _ previous: [MavenTestCaseOutcome], - requestedClasses: [String], - requestedMethod: String? = nil, - recorded: [MavenTestCaseOutcome] - ) -> [MavenTestCaseOutcome] { - let covered = requestedClasses.isEmpty - ? Array(Set(recorded.map(\.className))) - : requestedClasses - let isCovered: (String) -> Bool = { className in - covered.contains { className == $0 || className.hasPrefix($0 + "$") } - } - let retained = previous.filter { outcome in - if let requestedMethod { - return !covered.contains(outcome.className) || outcome.method != requestedMethod - } - return !isCovered(outcome.className) - } - let selected = recorded.filter { outcome in - guard let requestedMethod else { return true } - return covered.contains(outcome.className) && outcome.method == requestedMethod - } - return retained + selected - } - private func relativeProjectPaths(_ files: [URL], workspaceURL: URL) -> [String] { files.compactMap { relativePath($0, workspaceURL: workspaceURL) }.sorted() } @@ -611,6 +573,7 @@ package final class LanguageTestService: ObservableObject { _ scope: LanguageTestScope, workspaceURL: URL ) throws -> LanguageTestExtensionScope { + guard scope.buildReference == nil else { throw LanguageTestPlanError.unsupportedProvider("Build Provider") } switch scope { case .workspace: return .workspace @@ -619,7 +582,7 @@ package final class LanguageTestService: ObservableObject { throw LanguageTestPlanError.fileOutsideWorkspace(fileURL) } return .file(relativePath: path) - case .testCase(let identifier, let fileURL): + case .testCase(let identifier, let fileURL, _): let path: String? if let fileURL { guard let relative = relativePath(fileURL, workspaceURL: workspaceURL) else { @@ -659,31 +622,6 @@ private struct LastRun { let options: RunOptions } -private final class LanguageTestOutputCapture: @unchecked Sendable { - private let lock = NSLock() - private let maximumCharacters: Int - private var value = "" - - init(maximumCharacters: Int) { - self.maximumCharacters = maximumCharacters - } - - func append(_ chunk: String) { - lock.lock() - defer { lock.unlock() } - value += chunk - if value.count > maximumCharacters { - value.removeFirst(value.count - maximumCharacters) - } - } - - func snapshot() -> String { - lock.lock() - defer { lock.unlock() } - return value - } -} - private final class LanguageTestTimeoutMarker: @unchecked Sendable { private let lock = NSLock() private var value = false diff --git a/macos/Sources/LitheExecutionModule/Services/MavenService.swift b/macos/Sources/LitheExecutionModule/Services/MavenService.swift deleted file mode 100644 index 6f3016b23d..0000000000 --- a/macos/Sources/LitheExecutionModule/Services/MavenService.swift +++ /dev/null @@ -1,1171 +0,0 @@ -import Combine -import Foundation -import LitheModuleAPI -import LitheCoreContracts - -private enum MavenReloadError: LocalizedError { - case projectUnavailable - var errorDescription: String? { - L10n.string("The Maven project could not be reloaded. The previous model is still available.") - } -} - -private struct MavenProjectInventory: Equatable { - let rootURL: URL - let descriptorPaths: [String] - - init(rootURL: URL, files: [URL]) { - let root = rootURL.standardizedFileURL - let rootComponents = root.pathComponents - self.rootURL = root - descriptorPaths = Set(files.compactMap { fileURL -> String? in - let file = fileURL.standardizedFileURL - guard file.lastPathComponent.lowercased() == "pom.xml", - file.pathComponents.starts(with: rootComponents) else { return nil } - return file.pathComponents - .dropFirst(rootComponents.count) - .joined(separator: "/") - }).sorted() - } -} - -@MainActor -package final class MavenService: ObservableObject { - private let logSink: (any LogSink)? - @Published package private(set) var project: MavenProject? - @Published package private(set) var projectState: MavenProjectLoadState = .idle - @Published package private(set) var taskState: MavenTaskState = .idle - @Published package private(set) var runningTitle: String? - @Published package private(set) var output = "" - package private(set) var outputOperationID: String? - @Published package private(set) var issues: [MavenBuildIssue] = [] - @Published package private(set) var lastExitCode: Int32? - @Published package private(set) var selectedProfiles: Set = [] - @Published package private(set) var customProfiles: [String] = [] - @Published package private(set) var skipTests = false - @Published package private(set) var settingsPath: String? - @Published package private(set) var localRepositoryPath: String? - @Published package private(set) var mavenExecutablePath: String? - @Published package private(set) var javaHomePath: String? - @Published package private(set) var configurationSaveError: String? - /// Why the running Java language session did not take the latest Maven - /// configuration. The reload action restarts that session. - @Published package private(set) var javaConfigurationError: String? - @Published package private(set) var isReloadRequired = false - @Published package private(set) var isProjectReloadRequired = false - @Published package private(set) var isReloading = false - @Published package private(set) var reloadError: String? - private var reloadRevision = 0 - private var reloadTask: Task? - package var onProjectReloaded: (@MainActor (URL, MavenProject) -> Void)? - /// Hands the current configuration to the workspace's running Java language - /// session. Returns whether a running session took it and throws when one - /// could not. JDT LS re-resolves in place, so no reload is needed (#970). - package var applyConfigurationToJava: (@MainActor (URL) throws -> Bool)? - @Published package private(set) var dependencyStates: [String: MavenDependencyLoadState] = [:] - - package var isLoadingProject: Bool { - if case .loading = projectState { return true } - return false - } - - package var isRunning: Bool { - switch taskState { - case .running, .stopping: true - case .idle, .cancelled, .failed: false - } - } - - package var isResolvingDependencies: Bool { - dependencyStates.values.contains { state in - if case .loading = state { return true } - return false - } - } - - package var availableProfiles: [MavenProfile] { - var seen = Set() - let discovered = project?.profiles.filter { seen.insert($0.id).inserted } ?? [] - let custom = customProfiles.compactMap { id -> MavenProfile? in - guard seen.insert(id).inserted else { return nil } - return MavenProfile(id: id, isActiveByDefault: false) - } - return discovered + custom - } - - package var launchContext: MavenLaunchContext? { - guard let reactorPath else { return nil } - return MavenLaunchContext( - reactorPath: reactorPath, - profiles: selectedProfiles.sorted(), - settingsPath: settingsPath, - localRepositoryPath: localRepositoryPath, - skipTests: skipTests, - mavenExecutablePath: resolvedMavenExecutablePath, - javaHomePath: javaHomePath - ) - } - - /// Maven this workspace runs, falling back to the wrapper or a discovered - /// installation when Maven Settings leaves the path empty. - /// - /// Project import reads the local repository and mirrors from the - /// installation's `conf/settings.xml`. Passing an empty path would leave the - /// language server on its embedded defaults while builds keep using the - /// resolved installation, so the same project would resolve against two - /// different local repositories. - private var resolvedMavenExecutablePath: String? { - if let mavenExecutablePath { return mavenExecutablePath } - guard let project else { return nil } - return runtimeService.mavenExecutable(for: project, overridePath: nil)?.path - } - - private let process: any StreamingProcess - private let dependencyProcess: any StreamingProcess - private let mavenOperations: any MavenProjectOperations - private let dependencyOutputs: (any MavenDependencyOutputStoring)? - private let runtimeService: any MavenRuntimePort - private let configurationWriter: MavenConfigurationWriter - private var workspaceURL: URL? - private var reactorPath: String? - private var projectLoadID = UUID() - private var acceptedProjectInventory: MavenProjectInventory? - private var projectLoadInventory: MavenProjectInventory? - private var projectLoadTask: Task? - private var launchPlanID = UUID() - private var activeOperationID: String? - private var dependencyLoadID = UUID() - private var activeDependencyOperationID: String? - private var activeDependencyModulePath: String? - /// Scratch file the active dependency operation's Maven writes its tree to. - private var activeDependencyOutputFile: URL? - private var dependencyTimedOut = false - private var configurationRevision = 0 - private var configurationSaveTask: Task? - private var configurationFingerprint: String? - private var fingerprintRevision = 0 - private let maximumOutputCharacters = 500_000 - private let dependencyTimeoutMilliseconds = 60_000 - - package init( - logSink: (any LogSink)? = nil, - runtimeService: any MavenRuntimePort, - process: any StreamingProcess, - dependencyProcess: any StreamingProcess, - mavenOperations: any MavenProjectOperations, - configurationStore: (any MavenConfigurationStoring)? = nil, - dependencyOutputs: (any MavenDependencyOutputStoring)? = nil - ) { - self.logSink = logSink - self.runtimeService = runtimeService - self.process = process - self.dependencyProcess = dependencyProcess - self.mavenOperations = mavenOperations - self.dependencyOutputs = dependencyOutputs - configurationWriter = MavenConfigurationWriter(store: configurationStore, logSink: logSink) - process.onOutput = { [weak self] chunk in - Task { @MainActor [weak self] in - guard self?.activeOperationID != nil else { return } - self?.append(chunk) - } - } - process.onTermination = { [weak self] exitCode in - Task { @MainActor [weak self] in - guard self?.activeOperationID != nil else { return } - self?.finishProcess(exitCode: exitCode) - } - } - process.onStateChange = { [weak self] event in - Task { @MainActor [weak self] in - self?.consumeLifecycle(event) - } - } - // The dependency process writes its tree to a scratch file; its - // console output is Maven's log, not dependency data. - dependencyProcess.onOutput = { _ in } - dependencyProcess.onTermination = { [weak self] exitCode in - Task { @MainActor [weak self] in - guard self?.activeDependencyOperationID != nil else { return } - self?.finishDependencyProcess(exitCode: exitCode) - } - } - dependencyProcess.onStateChange = { [weak self] event in - Task { @MainActor [weak self] in - self?.consumeDependencyLifecycle(event) - } - } - } - - package func loadProject(at workspaceURL: URL, files: [URL]) async { - let inventory = MavenProjectInventory(rootURL: workspaceURL, files: files) - if let currentRoot = self.workspaceURL ?? projectLoadInventory?.rootURL, - currentRoot != inventory.rootURL { - reset() - } - - // A ready model is the cache for its accepted POM inventory. Snapshot - // refreshes must not rescan unchanged descriptors or accept changed - // descriptors before the explicit Reload transaction synchronizes Java. - if self.workspaceURL == inventory.rootURL, project != nil { - if acceptedProjectInventory != inventory, !isProjectReloadRequired { - reloadRevision += 1 - isProjectReloadRequired = true - isReloadRequired = true - } - return - } - - if projectLoadInventory == inventory, let projectLoadTask { - await projectLoadTask.value - return - } - if projectLoadTask != nil { - projectLoadID = UUID() - projectLoadTask?.cancel() - projectLoadTask = nil - projectLoadInventory = nil - } - - invalidateDependencies() - let loadID = UUID() - projectLoadID = loadID - projectLoadInventory = inventory - projectState = .loading - let task = Task { @MainActor [weak self] in - guard let self else { return } - defer { - if self.projectLoadID == loadID { - self.projectLoadTask = nil - self.projectLoadInventory = nil - if self.projectState == .loading { - self.projectState = self.project == nil ? .idle : .ready - } - } - } - // Before a model is accepted, a POM event invalidates the scan rather - // than asking the user to reload a model that does not exist yet. - // All matching inventory callers keep awaiting this owned task. - while !Task.isCancelled, self.projectLoadID == loadID { - let revision = self.reloadRevision - await self.performProjectLoad( - inventory: inventory, - files: files, - loadID: loadID, - revision: revision - ) - guard self.project == nil, self.reloadRevision != revision else { return } - } - } - projectLoadTask = task - await task.value - } - - private func performProjectLoad( - inventory: MavenProjectInventory, - files: [URL], - loadID: UUID, - revision: Int - ) async { - let rootURL = inventory.rootURL - let mavenOperations = mavenOperations, logSink = logSink - let configurationWriter = configurationWriter - let result = await Task.detached(priority: .utility) { - do { - let project = try mavenOperations.scanMavenProject(at: rootURL, files: files) - let reactorPath = project.map { Self.relativePath(from: rootURL, to: $0.rootURL) } - let stored: MavenStoredConfiguration? - if let reactorPath { - stored = try await configurationWriter.load( - workspaceURL: rootURL, - reactorPath: reactorPath - ) - } else { - stored = nil - } - return MavenProjectLoadResult( - project: project, - reactorPath: reactorPath, - stored: stored, - errorMessage: nil - ) - } catch { - logSink?.emit(RunPreparationFailed(error: error)) - return MavenProjectLoadResult( - project: nil, - reactorPath: nil, - stored: nil, - errorMessage: error.localizedDescription - ) - } - }.value - guard !Task.isCancelled, projectLoadID == loadID, reloadRevision == revision else { return } - - guard let errorMessage = result.errorMessage else { - self.workspaceURL = rootURL - reactorPath = result.reactorPath - project = result.project - acceptedProjectInventory = result.project == nil ? nil : inventory - applyStoredConfiguration(result.stored, project: result.project) - if let context = launchContext { - let fingerprint = await Task.detached(priority: .utility) { () -> String? in - do { return try mavenOperations.mavenLaunchPlan( - at: rootURL, - context: context, - module: nil, - goals: [MavenLifecyclePhase.validate.rawValue] - ).configurationFingerprint } - catch { - logSink?.emit(RunConfigurationOperationFailed(error: error, stage: "fingerprint")) - return nil - } - }.value - guard !Task.isCancelled, projectLoadID == loadID, reloadRevision == revision else { return } - configurationFingerprint = fingerprint - } else { - configurationFingerprint = nil - } - projectState = .ready - configurationSaveError = nil - isReloadRequired = isProjectReloadRequired - return - } - project = nil - self.workspaceURL = nil - reactorPath = nil - acceptedProjectInventory = nil - projectState = .failed(errorMessage) - } - - package func run(phase: MavenLifecyclePhase, module: MavenModule?) { - run(goals: [phase.rawValue], module: module, title: taskTitle(name: phase.title, module: module)) - } - - package func runCustomGoal(_ value: String, module: MavenModule?) { - let goals = value.split(whereSeparator: \.isWhitespace).map(String.init) - run(goals: goals, module: module, title: taskTitle(name: value, module: module)) - } - - package func setSelectedProfiles(_ profiles: Set) { - let knownProfiles = Set(availableProfiles.map(\.id)) - let normalized = Set(profiles.filter { knownProfiles.contains($0) }) - guard normalized != selectedProfiles else { return } - selectedProfiles = normalized - configurationDidChange() - } - - @discardableResult - package func addCustomProfile(_ value: String) -> Bool { - let profile = value.trimmingCharacters(in: .whitespacesAndNewlines) - guard isValidProfile(profile) else { return false } - if !customProfiles.contains(profile), project?.profiles.contains(where: { $0.id == profile }) != true { - customProfiles.append(profile) - customProfiles.sort() - } - selectedProfiles.insert(profile) - configurationDidChange() - return true - } - - package func restoreDefaultProfiles() { - let defaults = Set(project?.profiles.filter(\.isActiveByDefault).map(\.id) ?? []) - guard selectedProfiles != defaults else { return } - selectedProfiles = defaults - configurationDidChange() - } - - package func setSkipTests(_ enabled: Bool) { - guard skipTests != enabled else { return } - skipTests = enabled - configurationDidChange() - } - - package func updateLocalConfiguration( - settingsPath: String?, - localRepositoryPath: String?, - mavenExecutablePath: String?, - javaHomePath: String? - ) { - let settings = normalizedLocalPath(settingsPath) - let localRepository = normalizedLocalPath(localRepositoryPath) - let executable = normalizedLocalPath(mavenExecutablePath) - let javaHome = normalizedLocalPath(javaHomePath) - guard settings != self.settingsPath - || localRepository != self.localRepositoryPath - || executable != self.mavenExecutablePath - || javaHome != self.javaHomePath else { return } - self.settingsPath = settings - self.localRepositoryPath = localRepository - self.mavenExecutablePath = executable - self.javaHomePath = javaHome - configurationDidChange() - } - - package func acknowledgeReload() { - guard !isProjectReloadRequired else { return } - isReloadRequired = false - javaConfigurationError = nil - refreshConfigurationFingerprint(establishBaseline: true) - } - - /// Marks only descriptors owned by this workspace; deletion is a change too. - package func markPomChanged(_ fileURL: URL) { - guard let workspaceURL = workspaceURL ?? projectLoadInventory?.rootURL else { return } - let file = fileURL.standardizedFileURL - guard file.lastPathComponent.lowercased() == "pom.xml", - file.path.hasPrefix(workspaceURL.path + "/") else { return } - reloadRevision += 1 - guard project != nil else { return } - isProjectReloadRequired = true - isReloadRequired = true - } - - /// Keeps the accepted model visible until both scan and Java import succeed. - /// One owned task coalesces repeated clicks and is cancelled on workspace reset. - package func reloadProject( - files: [URL], - rescan: Bool, - synchronizeJava: @escaping @MainActor () async throws -> Void - ) async { - if let reloadTask { await reloadTask.value; return } - guard let root = workspaceURL, let context = launchContext else { return } - // Invalidate inventory scans that started before this explicit transaction. - reloadRevision += 1 - let revision = reloadRevision - let loadID = projectLoadID - let previousProject = project - let operations = mavenOperations - let inventory = MavenProjectInventory(rootURL: root, files: files) - isReloading = true - reloadError = nil - let task = Task { @MainActor [weak self] in - guard let self else { return } - defer { - if self.projectLoadID == loadID { - self.isReloading = false - self.reloadTask = nil - } - } - do { - let candidate = try await Task.detached(priority: .utility) { - let project = rescan - ? try operations.scanMavenProject(at: root, files: files) - : previousProject - guard let project, project.rootURL.standardizedFileURL == previousProject?.rootURL.standardizedFileURL else { - throw MavenReloadError.projectUnavailable - } - let fingerprint = try operations.mavenLaunchPlan( - at: root, context: context, module: nil, goals: ["validate"] - ).configurationFingerprint - return (project, fingerprint) - }.value - try Task.checkCancellation() - guard self.projectLoadID == loadID, self.reloadRevision == revision else { return } - try await synchronizeJava() - try Task.checkCancellation() - guard self.projectLoadID == loadID, self.reloadRevision == revision else { return } - self.project = candidate.0 - self.acceptedProjectInventory = inventory - self.configurationFingerprint = candidate.1 - self.fingerprintRevision += 1 - self.invalidateDependencies() - self.isProjectReloadRequired = false - self.isReloadRequired = false - self.javaConfigurationError = nil - self.projectState = .ready - self.onProjectReloaded?(root, candidate.0) - } catch { - guard self.projectLoadID == loadID, self.reloadRevision == revision else { return } - self.reloadError = error is CancellationError - ? L10n.string("Maven reload was cancelled or timed out.") - : error.localizedDescription - self.isReloadRequired = true - } - } - reloadTask = task - // Java import owns its progress-aware and absolute deadlines in Core. - // An outer wall-clock timeout would abort a healthy import downloading - // dependencies. Explicit cancellation still resumes the readiness waiter - // and releases only the Java session owned by this reload. - await withTaskCancellationHandler { await task.value } onCancel: { task.cancel() } - } - - package func dependencyState(for modulePath: String) -> MavenDependencyLoadState { - dependencyStates[modulePath] ?? .idle - } - - package func loadDependencies(for modulePath: String) { - if case .ready = dependencyState(for: modulePath) { return } - if activeDependencyModulePath == modulePath, - case .loading = dependencyState(for: modulePath) { return } - guard let project, let workspaceURL, let context = launchContext else { return } - - cancelActiveDependency(markCancelled: true) - let loadID = UUID() - dependencyLoadID = loadID - activeDependencyModulePath = modulePath - activeDependencyOperationID = nil - dependencyTimedOut = false - dependencyStates[modulePath] = .loading - let outputFile: URL - do { - guard let dependencyOutputs else { - throw MavenOperationError( - code: "not_supported", - message: "Maven dependency resolution is unavailable." - ) - } - outputFile = try dependencyOutputs.makeDependencyOutputFile(operationID: loadID.uuidString) - } catch { - failDependency(modulePath: modulePath, message: error.localizedDescription) - return - } - activeDependencyOutputFile = outputFile - let operations = mavenOperations, logSink = logSink - Task { [weak self] in - let result = await Task.detached(priority: .utility) { - do { - return MavenPlanResult( - plan: try operations.mavenDependencyPlan( - at: workspaceURL, - context: context, - module: modulePath == "." ? nil : modulePath, - outputFile: outputFile - ), - errorMessage: nil - ) - } catch { - logSink?.emit(RunPreparationFailed(error: error)) - return MavenPlanResult(plan: nil, errorMessage: error.localizedDescription) - } - }.value - guard let self, - self.dependencyLoadID == loadID, - self.activeDependencyModulePath == modulePath else { return } - guard let plan = result.plan else { - self.failDependency( - modulePath: modulePath, - message: result.errorMessage ?? "Unable to create the Maven dependency plan." - ) - return - } - self.startDependencyProcess( - plan: plan, - project: project, - context: context, - modulePath: modulePath - ) - } - } - - package func cancelDependencies(for modulePath: String) { - guard case .loading = dependencyState(for: modulePath) else { return } - guard activeDependencyModulePath == modulePath else { - dependencyStates[modulePath] = .cancelled - return - } - cancelActiveDependency(markCancelled: true) - } - - package func cancelAllDependencies() { - cancelActiveDependency(markCancelled: true) - } - - package func stop() { - reloadTask?.cancel() - launchPlanID = UUID() - cancelActiveDependency(markCancelled: true) - guard isRunning else { return } - taskState = .stopping - if activeOperationID != nil { - process.stop() - } - activeOperationID = nil - runningTitle = nil - lastExitCode = nil - taskState = .cancelled - if !output.isEmpty, !output.hasSuffix("\n") { - append("\n") - } - append("Maven task cancelled.\n") - } - - package func reset() { - configurationRevision += 1 - configurationSaveTask = nil - projectLoadTask?.cancel() - projectLoadTask = nil - projectLoadInventory = nil - reloadTask?.cancel() - reloadTask = nil - isReloading = false - reloadError = nil - reloadRevision += 1 - isProjectReloadRequired = false - stop() - invalidateDependencies() - projectLoadID = UUID() - launchPlanID = UUID() - project = nil - workspaceURL = nil - reactorPath = nil - acceptedProjectInventory = nil - projectState = .idle - taskState = .idle - outputOperationID = nil - runningTitle = nil - output = "" - issues = [] - lastExitCode = nil - selectedProfiles = [] - customProfiles = [] - skipTests = false - settingsPath = nil - localRepositoryPath = nil - mavenExecutablePath = nil - javaHomePath = nil - configurationFingerprint = nil - fingerprintRevision += 1 - configurationSaveError = nil - isReloadRequired = false - dependencyStates = [:] - } - - package func clearOutput() { - output = "" - issues = [] - lastExitCode = nil - if taskState == .cancelled { - taskState = .idle - } - } - - private func run(goals: [String], module: MavenModule?, title: String) { - guard let project, let workspaceURL, let reactorPath else { return } - stop() - resetOutput() - let planID = UUID() - outputOperationID = "maven:" + planID.uuidString - launchPlanID = planID - runningTitle = title - taskState = .running - let context = MavenLaunchContext( - reactorPath: reactorPath, - profiles: selectedProfiles.sorted(), - settingsPath: settingsPath, - localRepositoryPath: localRepositoryPath, - skipTests: skipTests, - mavenExecutablePath: mavenExecutablePath, - javaHomePath: javaHomePath - ) - let operations = mavenOperations, logSink = logSink - Task { [weak self] in - let result = await Task.detached(priority: .userInitiated) { - do { - return MavenPlanResult( - plan: try operations.mavenLaunchPlan( - at: workspaceURL, - context: context, - module: module?.relativePath, - goals: goals - ), - errorMessage: nil - ) - } catch { - logSink?.emit(RunPreparationFailed(error: error)) - return MavenPlanResult( - plan: nil, - errorMessage: error.localizedDescription - ) - } - }.value - guard let self, self.launchPlanID == planID else { return } - if let plan = result.plan { - self.startProcess(plan: plan, project: project, context: context, title: title) - } else { - self.failTask(message: result.errorMessage ?? "Unable to create the Maven launch plan.") - } - } - } - - private func startProcess( - plan: MavenLaunchPlan, - project: MavenProject, - context: MavenLaunchContext, - title: String - ) { - guard let workspaceURL else { return } - guard let executable = runtimeService.mavenExecutable( - for: project, - overridePath: context.mavenExecutablePath - ) else { - failTask(message: "No Maven executable was found. Choose Maven Home or an executable in Maven Settings.") - return - } - runningTitle = title - taskState = .running - recordConfigurationFingerprint(plan.configurationFingerprint) - append( - "$ " + executable.lastPathComponent + " " - + redactedMavenArgumentsForDisplay(plan.arguments).joined(separator: " ") + "\n\n" - ) - - let operationID = UUID().uuidString - activeOperationID = operationID - let workingDirectory = plan.workingDirectory == "." - ? workspaceURL - : workspaceURL.appendingPathComponent(plan.workingDirectory, isDirectory: true) - do { - try process.start(ProcessRequest( - operationID: operationID, - executablePath: executable.path, - arguments: plan.arguments, - workingDirectory: workingDirectory.standardizedFileURL.path, - environment: runtimeService.mavenProcessEnvironment(javaHomePath: context.javaHomePath) - )) - } catch { - guard activeOperationID == operationID else { return } - activeOperationID = nil - failTask(message: "Unable to start Maven: " + error.localizedDescription) - } - } - - private func startDependencyProcess( - plan: MavenLaunchPlan, - project: MavenProject, - context: MavenLaunchContext, - modulePath: String - ) { - guard let workspaceURL else { return } - guard let executable = runtimeService.mavenExecutable( - for: project, - overridePath: context.mavenExecutablePath - ) else { - failDependency( - modulePath: modulePath, - message: "No Maven executable was found. Choose Maven Home or an executable in Maven Settings." - ) - return - } - let operationID = UUID().uuidString - activeDependencyOperationID = operationID - let workingDirectory = plan.workingDirectory == "." - ? workspaceURL - : workspaceURL.appendingPathComponent(plan.workingDirectory, isDirectory: true) - do { - try dependencyProcess.start(ProcessRequest( - operationID: operationID, - executablePath: executable.path, - arguments: plan.arguments, - workingDirectory: workingDirectory.standardizedFileURL.path, - environment: runtimeService.mavenProcessEnvironment(javaHomePath: context.javaHomePath), - timeoutMilliseconds: dependencyTimeoutMilliseconds - )) - } catch { - guard activeDependencyOperationID == operationID else { return } - failDependency( - modulePath: modulePath, - message: "Unable to start Maven dependency resolution: " + error.localizedDescription - ) - } - } - - private func finishDependencyProcess(exitCode: Int32) { - guard let modulePath = activeDependencyModulePath else { return } - let loadID = dependencyLoadID - let timedOut = dependencyTimedOut - activeDependencyOperationID = nil - dependencyTimedOut = false - if timedOut { - failDependency( - modulePath: modulePath, - message: "Maven dependency resolution timed out after 60 seconds." - ) - return - } - guard exitCode == 0 else { - failDependency( - modulePath: modulePath, - message: "Maven dependency resolution exited with code \(exitCode)." - ) - return - } - guard let outputFile = activeDependencyOutputFile else { - failDependency( - modulePath: modulePath, - message: "Unable to parse Maven dependencies for this module." - ) - return - } - // The read below now owns the file, so a later cancellation or a new - // request cannot remove it while Core is still reading it. - activeDependencyOutputFile = nil - let operations = mavenOperations, logSink = logSink - let dependencyOutputs = dependencyOutputs - Task { [weak self] in - let result = await Task.detached(priority: .utility) { - defer { dependencyOutputs?.removeDependencyOutputFile(outputFile) } - do { - return MavenDependencyParseResult( - tree: try operations.mavenDependencies( - modulePath: modulePath, - outputFile: outputFile - ), - errorMessage: nil - ) - } catch { - logSink?.emit(RunPreparationFailed(error: error)) - return MavenDependencyParseResult( - tree: nil, - errorMessage: error.localizedDescription - ) - } - }.value - guard let self, - self.dependencyLoadID == loadID, - self.activeDependencyModulePath == modulePath, - case .loading = self.dependencyState(for: modulePath) else { return } - self.activeDependencyModulePath = nil - if let tree = result.tree { - self.dependencyStates[modulePath] = .ready(tree.dependencies) - } else { - self.dependencyStates[modulePath] = .failed( - result.errorMessage ?? "Unable to parse Maven dependencies for this module." - ) - } - } - } - - private func consumeDependencyLifecycle(_ event: ProcessLifecycleEvent) { - guard event.operationID == activeDependencyOperationID else { return } - switch event.state { - case .starting, .running: - break - case .stopping: - if event.message == "Process timed out" { - dependencyTimedOut = true - } - case .failed: - guard let modulePath = activeDependencyModulePath else { return } - failDependency( - modulePath: modulePath, - message: event.message ?? "Unable to resolve Maven dependencies." - ) - case .finished: - if let exitCode = event.exitCode { - finishDependencyProcess(exitCode: exitCode) - } - } - } - - private func failDependency(modulePath: String, message: String) { - dependencyStates[modulePath] = .failed(message) - activeDependencyOperationID = nil - activeDependencyModulePath = nil - releaseDependencyOutputFile() - dependencyTimedOut = false - } - - /// Removes the active operation's scratch file once it can no longer - /// produce a result. A file Maven writes after a stop is left for the - /// store's startup cleanup rather than read by a later operation. - private func releaseDependencyOutputFile() { - guard let outputFile = activeDependencyOutputFile else { return } - activeDependencyOutputFile = nil - dependencyOutputs?.removeDependencyOutputFile(outputFile) - } - - private func cancelActiveDependency(markCancelled: Bool) { - dependencyLoadID = UUID() - let modulePath = activeDependencyModulePath - if activeDependencyOperationID != nil { - dependencyProcess.stop() - } - activeDependencyOperationID = nil - activeDependencyModulePath = nil - releaseDependencyOutputFile() - dependencyTimedOut = false - if markCancelled, let modulePath { - dependencyStates[modulePath] = .cancelled - } - } - - private func invalidateDependencies() { - cancelActiveDependency(markCancelled: false) - dependencyStates = [:] - } - - private func finishProcess(exitCode: Int32) { - guard let project else { return } - if taskState == .stopping { - activeOperationID = nil - runningTitle = nil - lastExitCode = nil - taskState = .cancelled - append("Maven task cancelled.\n") - return - } - taskState = exitCode == 0 ? .idle : .failed("Maven exited with code \(exitCode).") - runningTitle = nil - lastExitCode = exitCode - issues = mavenOperations.mavenDiagnostics(output: output, projectRoot: project.rootURL) - activeOperationID = nil - } - - private func consumeLifecycle(_ event: ProcessLifecycleEvent) { - guard event.operationID == activeOperationID else { return } - switch event.state { - case .starting, .running: - taskState = .running - case .stopping: - taskState = .stopping - case .failed: - activeOperationID = nil - failTask(message: event.message ?? "Unable to run Maven.") - case .finished: - if let exitCode = event.exitCode { - finishProcess(exitCode: exitCode) - } - } - } - - private func failTask(message: String) { - append(message + (message.hasSuffix("\n") ? "" : "\n")) - taskState = .failed(message) - runningTitle = nil - lastExitCode = 1 - issues = [MavenBuildIssue( - id: "maven-error-" + UUID().uuidString, - fileURL: nil, - line: nil, - column: nil, - severity: .error, - message: message - )] - } - - private func applyStoredConfiguration( - _ stored: MavenStoredConfiguration?, - project: MavenProject? - ) { - let defaults = project?.profiles.filter(\.isActiveByDefault).map(\.id) ?? [] - let portable = stored?.portable - selectedProfiles = Set(portable?.selectedProfiles ?? defaults) - customProfiles = normalizedProfiles(portable?.customProfiles ?? []) - skipTests = portable?.skipTests ?? false - settingsPath = normalizedLocalPath(stored?.local?.settingsPath) - localRepositoryPath = normalizedLocalPath(stored?.local?.localRepositoryPath) - mavenExecutablePath = normalizedLocalPath(stored?.local?.mavenExecutablePath) - javaHomePath = normalizedLocalPath(stored?.local?.javaHomePath) - } - - private func configurationDidChange() { - reloadRevision += 1 - invalidateDependencies() - configurationSaveError = nil - persistConfiguration() - guard let workspaceURL, let applyConfigurationToJava else { - isReloadRequired = isProjectReloadRequired || configurationFingerprint != nil - refreshConfigurationFingerprint() - return - } - do { - // A running session took the change, or the next start reads it; - // either way JDT LS holds this configuration from now on. - _ = try applyConfigurationToJava(workspaceURL) - javaConfigurationError = nil - isReloadRequired = isProjectReloadRequired - refreshConfigurationFingerprint(establishBaseline: true) - } catch { - javaConfigurationError = error.localizedDescription - isReloadRequired = true - } - } - - private func refreshConfigurationFingerprint(establishBaseline: Bool = false) { - guard let workspaceURL, let context = launchContext else { return } - fingerprintRevision += 1 - let revision = fingerprintRevision - let operations = mavenOperations, logSink = logSink - Task { [weak self] in - let fingerprint = await Task.detached(priority: .utility) { () -> String? in - do { return try operations.mavenLaunchPlan( - at: workspaceURL, - context: context, - module: nil, - goals: [MavenLifecyclePhase.validate.rawValue] - ).configurationFingerprint } - catch { - logSink?.emit(RunConfigurationOperationFailed(error: error, stage: "fingerprint")) - return nil - } - }.value - guard let self, self.fingerprintRevision == revision, let fingerprint else { return } - if establishBaseline || self.configurationFingerprint == nil { - self.configurationFingerprint = fingerprint - self.isReloadRequired = self.isProjectReloadRequired - } else { - self.isReloadRequired = self.isProjectReloadRequired || self.configurationFingerprint != fingerprint - } - } - } - - private func recordConfigurationFingerprint(_ fingerprint: String) { - if let configurationFingerprint { - isReloadRequired = isProjectReloadRequired || configurationFingerprint != fingerprint - } else { - configurationFingerprint = fingerprint - isReloadRequired = isProjectReloadRequired - } - } - - /// API callers must await the actual write before reporting that settings were saved. - package func saveConfiguration() async -> String? { - guard let workspaceURL, let reactorPath else { - return "No Maven project is loaded" - } - persistConfiguration() - let revision = configurationRevision - let error = await configurationSaveTask?.value - guard configurationRevision == revision, - self.workspaceURL == workspaceURL, self.reactorPath == reactorPath else { - return "Maven settings changed while saving. Inspect the current settings before retrying." - } - return error - } - - private func persistConfiguration() { - guard let workspaceURL, let reactorPath else { return } - configurationRevision += 1 - let revision = configurationRevision - let stored = MavenStoredConfiguration( - portable: MavenPortableConfiguration( - selectedProfiles: selectedProfiles.sorted(), - customProfiles: normalizedProfiles(customProfiles), - skipTests: skipTests - ), - local: MavenLocalConfiguration( - settingsPath: settingsPath, - localRepositoryPath: localRepositoryPath, - mavenExecutablePath: mavenExecutablePath, - javaHomePath: javaHomePath - ) - ) - let writer = configurationWriter - configurationSaveTask = Task { [weak self] in - let errorMessage = await writer.save( - revision: revision, - configuration: stored, - workspaceURL: workspaceURL, - reactorPath: reactorPath - ) - if let self, self.configurationRevision == revision { - self.configurationSaveError = errorMessage - } - return errorMessage - } - } - - private func resetOutput() { - output = "" - issues = [] - lastExitCode = nil - } - - private func append(_ value: String) { - output.append(value.replacingOccurrences(of: "\r", with: "")) - if output.count > maximumOutputCharacters { - output.removeFirst(output.count - maximumOutputCharacters) - } - } - - private func taskTitle(name: String, module: MavenModule?) -> String { - let target = module?.displayName ?? project?.displayName ?? "Project" - return name + " · " + target - } - - private func isValidProfile(_ value: String) -> Bool { - !value.isEmpty - && !value.contains(",") - && !value.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) - } - - private func normalizedProfiles(_ values: [String]) -> [String] { - Array(Set(values.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter(isValidProfile))) - .sorted() - } - - private func normalizedLocalPath(_ value: String?) -> String? { - guard let value else { return nil } - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : (trimmed as NSString).expandingTildeInPath - } - - nonisolated private static func relativePath(from rootURL: URL, to childURL: URL) -> String { - let root = rootURL.standardizedFileURL.path - let child = childURL.standardizedFileURL.path - if root == child { return "." } - let prefix = root.hasSuffix("/") ? root : root + "/" - guard child.hasPrefix(prefix) else { return "." } - return String(child.dropFirst(prefix.count)) - } -} - -private struct MavenProjectLoadResult: Sendable { - let project: MavenProject? - let reactorPath: String? - let stored: MavenStoredConfiguration? - let errorMessage: String? -} - -private struct MavenPlanResult: Sendable { - let plan: MavenLaunchPlan? - let errorMessage: String? -} - -private struct MavenDependencyParseResult: Sendable { - let tree: MavenDependencyTree? - let errorMessage: String? -} - -private actor MavenConfigurationWriter { - private let store: (any MavenConfigurationStoring)? - private var latestRevision = 0 - - private let logSink: (any LogSink)? - init(store: (any MavenConfigurationStoring)?, logSink: (any LogSink)?) { - self.logSink = logSink - self.store = store - } - - func load( - workspaceURL: URL, - reactorPath: String - ) throws -> MavenStoredConfiguration { - try store?.loadMavenConfiguration( - workspaceURL: workspaceURL, - reactorPath: reactorPath - ) ?? MavenStoredConfiguration(portable: nil, local: nil) - } - - func save( - revision: Int, - configuration: MavenStoredConfiguration, - workspaceURL: URL, - reactorPath: String - ) -> String? { - guard revision > latestRevision else { return nil } - latestRevision = revision - do { - try store?.saveMavenConfiguration( - configuration, - workspaceURL: workspaceURL, - reactorPath: reactorPath - ) - return nil - } catch { - logSink?.emit(RunConfigurationWriteFailed(error: error)) - return error.localizedDescription - } - } -} diff --git a/macos/Sources/LitheExecutionModule/Services/RunService.swift b/macos/Sources/LitheExecutionModule/Services/RunService.swift index 3e3468e5e8..820d5ac1d4 100644 --- a/macos/Sources/LitheExecutionModule/Services/RunService.swift +++ b/macos/Sources/LitheExecutionModule/Services/RunService.swift @@ -23,13 +23,17 @@ package final class RunService: ObservableObject, BuildRunLaunching { @Published package private(set) var output = "" package private(set) var primaryExecutionID: String? @Published package private(set) var lastExitCode: Int32? - @Published package private(set) var optionsByConfigurationID: [String: RunOptions] = [:] - @Published package private(set) var projectToolchain = ProjectToolchainSelection() + @Published package private(set) var optionsByConfigurationID: [String: RunOptions] = [:] { didSet { providerLaunchRevision &+= 1 } } + @Published package private(set) var projectToolchain = ProjectToolchainSelection() { didSet { providerLaunchRevision &+= 1 } } /// Defaults saved in `.lithe/run/local.json`, read even before generation. /// `nil` until that layer saves defaults or while it cannot be read. @Published package private(set) var savedProjectToolchain: ProjectToolchainSelection? @Published package private(set) var effectiveSourcesByConfigurationID: [String: RunConfigurationSource] = [:] - @Published package private(set) var mavenProfiles: [MavenProfile] = [] + /// Presentation choices come from current provider models, never a second POM scan. + package var mavenProfiles: [MavenProfile] { + Array(Set(buildModels().flatMap { $0.optionValues["profiles"] ?? [] })).sorted() + .map { MavenProfile(id: $0, isActiveByDefault: false) } + } @Published package private(set) var moduleSessions: [RunSession] = [] @Published package private(set) var portConflicts: [RunPortConflict] = [] @Published package private(set) var configurationStatus: ProjectRunConfigurationStatus = .missing @@ -90,8 +94,7 @@ package final class RunService: ObservableObject, BuildRunLaunching { configurationSaveError = nil do { let id = try runConfigurationOperations.saveBuildProviderConfiguration(reference, name: configuration.name, at: workspace) - let resolution = try resolveWithServiceToolchains(operations: runConfigurationOperations, projectURL: workspace, - mavenProject: mavenProject, preferredConfigurationID: id) + let resolution = try resolveWithServiceToolchains(operations: runConfigurationOperations, projectURL: workspace, preferredConfigurationID: id) guard let saved = resolution.configurations.first(where: { $0.configuration.id == id }), saved.configuration.buildProvider == reference else { throw RunConfigurationEditorSaveFailure(stage: .reload, @@ -121,17 +124,23 @@ package final class RunService: ObservableObject, BuildRunLaunching { private var activeLanguageExecutionSession: (any LanguageExecutionSession)? private var projectURL: URL? private var projectFiles: [URL] = [] - private var mavenProject: MavenProject? + private var buildModels: () -> [BuildModel] = { [] } + private var buildProjectOwnership: () throws -> [RunProjectOwnership]? = { nil } + private var providerRunConfigurations: () throws -> [RunProviderConfiguration]? = { nil } private var dependencyWorkspaceURL: URL? private var dependencyConfiguration = WorkspaceDependencyConfiguration() private var dependencyIndexes = WorkspaceDependencyIndexes() private var languageSnapshots: [String: LanguageDependencySnapshot] = [:] private var dependencySources: [String: DependencyServiceDescriptor] = [:] - private var mavenModelRevision = 0 private var projectLoadID = UUID() /// Advances on every regeneration so a JDT freshness answer computed against /// the previous document cannot be attached to the new one. private var generationRevision = 0 + private var generationLease: RunConfigurationGenerationLease? + private var providerInspectionRevision: UInt64 = 0 + private var providerInspectionTask: Task? + private var providerInspectionOwner = UUID() + private var providerInspectionRequested = false private var selectedConfigurationIDsByProject: [String: String] = [:] private var lastRunConfiguration: RunConfiguration? private var lastCurrentFileURL: URL? @@ -146,6 +155,215 @@ package final class RunService: ObservableObject, BuildRunLaunching { /// exists and a stopped session cannot still start one. private var modulePreLaunchProcesses: [String: any StreamingProcess] = [:] private var moduleOperationIDs: [String: String] = [:] + private var primaryProviderSource: BuildSystemPluginSource? + private var providerDebugOperationID: String? + private var providerDebugPreparation: (id: UUID, cancel: @MainActor () -> Void)? + /// Keeps the primary preparation/output visible after this debug process ends. + package private(set) var providerDebugConfigurationID: String? + private var activeProcessID: Int32? + package let providerDebugEnded = PassthroughSubject() + private var moduleProviderSources: [String: BuildSystemPluginSource] = [:] + + /// Enabled package identities, rather than tool names, own their running processes. + package func retainProviderConfigurations(_ sources: [BuildSystemPluginSource]) { + if let owner = primaryProviderSource, !sources.contains(owner) { stop() } + for (id, owner) in moduleProviderSources where !sources.contains(owner) { + stopModule(sessionID: id) + } + } + + private var providerLaunchRevision: UInt64 = 0 + private var providerPrimaryPreparation: Task? + private var providerModulePreparations: [String: Task] = [:] + private typealias ConfigurationRunPreparer = @MainActor (ProviderLaunchInputs, String, URL, BuildProviderHints, + @escaping @MainActor @Sendable () -> Bool, @escaping @MainActor @Sendable (BuildProviderProgress) -> Void, + @escaping @MainActor (BuildLanguageImport) async throws -> Void) async throws -> BuildRunPreparation + private var configurationRunPreparer: ConfigurationRunPreparer? + private var configurationRunSource: (@MainActor (ProviderLaunchInputs, String) -> BuildSystemPluginSource?)? + + package func configureProviderConfigurationRuns( + source: (@MainActor (ProviderLaunchInputs, String) -> BuildSystemPluginSource?)? = nil, + _ prepare: @escaping @MainActor (ProviderLaunchInputs, String, URL, BuildProviderHints, + @escaping @MainActor @Sendable () -> Bool, @escaping @MainActor @Sendable (BuildProviderProgress) -> Void, + @escaping @MainActor (BuildLanguageImport) async throws -> Void) async throws -> BuildRunPreparation) { + configurationRunPreparer = prepare + configurationRunSource = source + } + + private func needsProviderLaunch(_ configuration: RunConfiguration, javaLaunch: JavaDebugLaunchTarget?) -> Bool { + configurationRunPreparer != nil && javaLaunch == nil && configuration.buildProvider?.execution != nil + } + + /// Debug uses the same canonical inputs and process owner as Run. Only the + /// registered adapter and local connection cross the boundary, never runtime flags. + package func prepareProviderDebug(_ configuration: RunConfiguration, + debug: @escaping @MainActor (String) throws -> BuildLaunchOptions.Debug, + languageImport: @escaping @MainActor (BuildLanguageImport) async throws -> Void = { _ in }, + isCurrent: @escaping @MainActor @Sendable () -> Bool) async throws -> String { + guard configuration.usesProviderConnectionDebug, projectURL != nil, configurationRunPreparer != nil else { throw CancellationError() } + stop() + let owner = UUID() + let task = Task { + try Task.checkCancellation() + guard self.providerDebugPreparation?.id == owner else { throw CancellationError() } + return try await self.prepareProviderDebugExecution(configuration, debug: debug, languageImport: languageImport, isCurrent: isCurrent) + } + providerDebugPreparation = (owner, { task.cancel() }) + defer { if providerDebugPreparation?.id == owner { providerDebugPreparation = nil } } + return try await withTaskCancellationHandler { try await task.value } onCancel: { task.cancel() } + } + + private func prepareProviderDebugExecution(_ configuration: RunConfiguration, + debug: @escaping @MainActor (String) throws -> BuildLaunchOptions.Debug, + languageImport: @escaping @MainActor (BuildLanguageImport) async throws -> Void, + isCurrent: @escaping @MainActor @Sendable () -> Bool) async throws -> String { + guard let workspace = projectURL, let prepare = configurationRunPreparer else { throw CancellationError() } + let identity = UUID().uuidString, load = projectLoadID, revision = providerLaunchRevision, generated = generationRevision + primaryExecutionID = identity; providerDebugOperationID = identity + providerDebugConfigurationID = configuration.id + lastRunConfiguration = configuration; selectedConfigurationID = configuration.id + isRunning = true; isPreparingBuildRun = true; runningTitle = configuration.name + output = ""; lastExitCode = nil + var transferred = false + defer { if !transferred && primaryExecutionID == identity { stop() } } + let valid: @MainActor @Sendable () -> Bool = { [weak self] in + guard let self else { return false } + return !Task.isCancelled && isCurrent() && self.projectURL == workspace && self.projectLoadID == load + && self.providerLaunchRevision == revision && self.generationRevision == generated + && self.primaryExecutionID == identity && self.isRunning + } + guard let configurationReference = configuration.buildProvider else { throw CancellationError() } + let root = configurationReference.root + guard valid() else { throw CancellationError() } + let operations = runConfigurationOperations + let original = try await Task.detached(priority: .userInitiated) { + try operations.providerLaunchInputs(at: workspace, configurationID: configuration.id, providerRoot: root) + }.value + guard valid(), let owner = configurationRunSource?(original, root) else { throw CancellationError() } + primaryProviderSource = owner + var choices = original.launchOptions + guard let reference = original.reference, + let declaration = owner.buildSystems.first(where: { $0.id == original.buildSystemID })? + .providerRuns.first(where: { $0.id == reference.run }), + declaration.execution == .service, let configurationType = declaration.debugConfigurationType else { + throw RunConfigurationOperationFailure(message: L10n.string("The build provider's debug program or adapter is unavailable. Build it again and check that the plugin is enabled.")) + } + let selected = try debug(configurationType) + guard selected.configurationType == configurationType, valid() else { throw CancellationError() } + choices.debug = selected + let inputs = ProviderLaunchInputs(buildSystemID: original.buildSystemID, provider: original.provider, + projectPath: original.projectPath, launchOptions: choices, optionOverrides: original.optionOverrides, reference: original.reference, + tools: original.tools) + let prepared = try await prepare(inputs, root, workspace, BuildProviderHints(), valid, { [weak self] value in + if valid() { self?.append(value.output ?? value.message.map { $0 + "\n" } ?? "") } + }) { value in + guard valid() else { throw CancellationError() } + try await languageImport(value) + guard valid() else { throw CancellationError() } + } + let latest = try await Task.detached(priority: .userInitiated) { + try operations.providerLaunchInputs(at: workspace, configurationID: configuration.id, providerRoot: root) + }.value + guard valid(), latest == original else { throw CancellationError() } + let executable = try await prepareRunExecutable(prepared, workspaceURL: workspace, configuration: configuration) + guard valid() else { throw CancellationError() } + try validateProviderGeneration(for: configuration) + // Handoff is synchronous. Internal process replacement must not cancel + // the preparation that has just produced this process. + providerDebugPreparation = nil + let operation = try launchPreparedRun(prepared, workspaceURL: workspace, title: configuration.name, + configuration: configuration, retainingIdentity: identity, resolvedExecutable: executable) + providerDebugOperationID = operation + transferred = true + return operation + } + + package func isProviderDebugActive(_ operation: String) -> Bool { + providerDebugOperationID == operation && activeOperationID == operation && isRunning + } + + package func providerDebugProcessID(_ operation: String) -> Int32? { + isProviderDebugActive(operation) ? activeProcessID : nil + } + + package func stopProviderDebug() { + if providerDebugOperationID != nil || providerDebugPreparation != nil { stop() } + } + + /// Each service owns its own preparation; a second Start never replaces another service's work. + private func prepareProviderConfiguration(_ configuration: RunConfiguration, workspace: URL, primary: Bool) { + guard let prepare = configurationRunPreparer else { return } + let operationID = primary ? (primaryExecutionID ?? UUID().uuidString) : UUID().uuidString + let load = projectLoadID, revision = providerLaunchRevision, generated = generationRevision + if primary { primaryExecutionID = operationID; isRunning = true; isPreparingBuildRun = true; runningTitle = configuration.name } + else { + moduleOperationIDs[configuration.id] = operationID + moduleSessions.append(RunSession(id: configuration.id, configurationID: configuration.id, title: configuration.name, output: "", isRunning: true, exitCode: nil)) + } + let current: @MainActor @Sendable () -> Bool = { [weak self] in + guard let self, !Task.isCancelled, self.projectURL == workspace, self.projectLoadID == load, + self.providerLaunchRevision == revision, self.generationRevision == generated else { return false } + return primary ? (self.primaryExecutionID == operationID && self.isRunning) : self.moduleOperationIDs[configuration.id] == operationID + } + let task = Task { @MainActor [weak self] in + guard let self else { return } + defer { + if primary { + if self.primaryExecutionID == operationID { self.providerPrimaryPreparation = nil; self.isPreparingBuildRun = false } + } else if self.moduleOperationIDs[configuration.id] == operationID { self.providerModulePreparations[configuration.id] = nil } + } + do { + guard current() else { throw CancellationError() } + guard let reference = configuration.buildProvider else { throw CancellationError() } + let providerRoot = reference.root + let operations = self.runConfigurationOperations + let inputs = try await Task.detached(priority: .userInitiated) { + try operations.providerLaunchInputs(at: workspace, configurationID: configuration.id, providerRoot: providerRoot) + }.value + guard current() else { throw CancellationError() } + if let source = self.configurationRunSource { + guard let owner = source(inputs, providerRoot) else { throw CancellationError() } + if primary { self.primaryProviderSource = owner } + else { self.moduleProviderSources[configuration.id] = owner } + } + let prepared = try await prepare(inputs, providerRoot, workspace, BuildProviderHints(), current, { [weak self] value in + guard let self, current() else { return } + let text = value.output ?? value.message.map { $0 + "\n" } ?? "" + if primary { self.append(text) } else { self.appendModuleOutput(text, sessionID: configuration.id) } + }) { _ in } + let latest = try await Task.detached(priority: .userInitiated) { + try operations.providerLaunchInputs(at: workspace, configurationID: configuration.id, providerRoot: providerRoot) + }.value + guard current(), latest == inputs else { throw CancellationError() } + let executable = try await self.prepareRunExecutable(prepared, workspaceURL: workspace, configuration: configuration) + guard current() else { throw CancellationError() } + try self.validateProviderGeneration(for: configuration) + if primary { + self.providerPrimaryPreparation = nil + _ = try self.launchPreparedRun(prepared, workspaceURL: workspace, title: configuration.name, configuration: configuration, retainingIdentity: operationID, resolvedExecutable: executable) + } else { + self.providerModulePreparations[configuration.id] = nil + self.startModuleSession(configuration, preparedPlan: prepared.launchPlan, preparationOperationID: operationID, resolvedExecutable: executable) + } + } catch { + guard (primary ? self.primaryExecutionID == operationID : self.moduleOperationIDs[configuration.id] == operationID) else { return } + if primary { + if error is CancellationError { self.isRunning = false; self.runningTitle = nil } + else { self.fail(error.localizedDescription) } + } else { + self.providerModulePreparations[configuration.id] = nil + self.moduleOperationIDs[configuration.id] = nil + if let index = self.moduleSessions.firstIndex(where: { $0.id == configuration.id }) { + self.moduleSessions[index].isRunning = false + self.moduleSessions[index].exitCode = error is CancellationError ? nil : 1 + } + if !(error is CancellationError) { self.appendModuleOutput(error.localizedDescription + "\n", sessionID: configuration.id) } + } + } + } + if primary { providerPrimaryPreparation = task } else { providerModulePreparations[configuration.id] = task } + } + private let maximumOutputCharacters = 500_000 /// The same bound the Windows pre-launch runner applies (`PRE_LAUNCH_TIMEOUT` /// in `windows/tauri/src-tauri/src/run.rs`). A resource step is a Maven @@ -155,7 +373,8 @@ package final class RunService: ObservableObject, BuildRunLaunching { private let runtime: any RunRuntimePort private let executableResolver: any RunExecutableResolving private let javaLaunchArgumentPreparer: (any JavaLaunchArgumentPreparing)? - private var mavenContextProvider: @MainActor () -> MavenLaunchContext? = { nil } + private var buildLanguageImportProvider: @MainActor (URL, String?) throws -> JavaLanguageImportConfiguration = { _, _ in .init() } + private var buildProjectImportProvider: @MainActor (URL, RunBuildProjectReference) throws -> JavaLanguageImportConfiguration = { _, _ in throw CancellationError() } private var languageDependencyProvider: @MainActor (String, URL, String) -> LanguageDependencySnapshot? = { _, _, _ in nil } @@ -249,10 +468,16 @@ package final class RunService: ObservableObject, BuildRunLaunching { generationState = .projectNotReady } - package func configureMavenContextProvider( - _ provider: @escaping @MainActor () -> MavenLaunchContext? + package func configureBuildLanguageImportProvider( + _ provider: @escaping @MainActor (URL, String?) throws -> JavaLanguageImportConfiguration ) { - mavenContextProvider = provider + buildLanguageImportProvider = provider + } + + package func configureBuildProjectImportProvider( + _ provider: @escaping @MainActor (URL, RunBuildProjectReference) throws -> JavaLanguageImportConfiguration + ) { + buildProjectImportProvider = provider } /// Looks up only an already active plugin capability; opening the tree must @@ -281,13 +506,112 @@ package final class RunService: ObservableObject, BuildRunLaunching { languageRunExtensions[languageID] = nil } - /// 供输出文本定位源码使用:项目根 + 各 Maven 模块根。 + /// Output navigation uses only roots published by current build providers. package var sourceSearchRoots: [URL] { - var roots = projectURL.map { [$0] } ?? [] - if let mavenProject { - roots.append(contentsOf: mavenProject.allModules.map(\.url)) + guard let projectURL else { return [] } + return [projectURL] + Array(Set(buildModels().flatMap { + $0.projects.map(\.path) + $0.contentRoots.filter { $0.kind != "excluded" }.map(\.path) + }).subtracting(["."])) + .sorted().map { projectURL.appendingPathComponent($0, isDirectory: true) } + } + + package func configureBuildModels(_ models: @escaping () -> [BuildModel]) { + buildModels = models + } + + package func configureProviderRunConfigurations(_ configurations: @escaping () throws -> [RunProviderConfiguration]?) { + providerRunConfigurations = configurations + } + + package func configureBuildProjectOwnership(_ ownership: @escaping () throws -> [RunProjectOwnership]?) { + buildProjectOwnership = ownership + } + + package func cancelProviderGenerationInspection() { + providerInspectionRevision &+= 1 + providerInspectionOwner = UUID() + providerInspectionRequested = false + providerInspectionTask?.cancel(); providerInspectionTask = nil + } + + package func requestProviderGenerationInspection() { + generationLease?.invalidate() + providerInspectionRevision &+= 1 + providerInspectionRequested = true + guard providerInspectionTask == nil else { return } + let owner = providerInspectionOwner + providerInspectionTask = Task { [weak self] in + while let self, !Task.isCancelled, self.providerInspectionOwner == owner, + self.providerInspectionRequested { + self.providerInspectionRequested = false + await self.refreshProviderGeneration() + } + if let self, self.providerInspectionOwner == owner { self.providerInspectionTask = nil } + } + } + + /// Build notifications may arrive during import/model publication. Inspect + /// off the main actor, and drop replies after any newer input or workspace. + package func refreshProviderGeneration() async { + providerInspectionRevision &+= 1 + let ticket = providerInspectionRevision + guard configurationStatus == .ready, let projectURL, + configurations.contains(where: { $0.requiresProviderGeneration || $0.buildProvider != nil }) else { return } + let loadID = projectLoadID, revision = generationRevision + let ownership: [RunProjectOwnership] + let providerRuns: [RunProviderConfiguration]? + do { guard let current = try buildProjectOwnership() else { return }; ownership = current + providerRuns = try providerRunConfigurations() + } + catch { + publishProviderDiagnostics([RunConfigurationDiagnostic(configurationID: nil, code: "providerModelNotReady", + message: L10n.string("Load the current build model and enable its provider before running this configuration."))]) + return + } + let operations = runConfigurationOperations + let inspection = await withCheckedContinuation { continuation in + DispatchQueue.global(qos: .utility).async { + let result = providerRuns.map { operations.inspectProviderGeneration(at: projectURL, projectOwnership: ownership, providerRuns: $0) } + ?? operations.inspectProviderGeneration(at: projectURL, projectOwnership: ownership) + continuation.resume(returning: result) + } + } + guard !Task.isCancelled, providerInspectionRevision == ticket, projectLoadID == loadID, + generationRevision == revision, self.projectURL == projectURL else { return } + if case .invalid(let message) = inspection.status { + publishProviderDiagnostics([RunConfigurationDiagnostic(configurationID: nil, code: "providerModelNotReady", message: message)]) + } else { publishProviderDiagnostics(inspection.diagnostics.filter { $0.code == "staleProviderModel" }) } + } + + private func publishProviderDiagnostics(_ diagnostics: [RunConfigurationDiagnostic]) { + configurationDiagnostics.removeAll { ["staleProviderModel", "providerModelNotReady"].contains($0.code) } + configurationDiagnostics += diagnostics + } + + /// Called immediately before every generated Java/Maven launch, including + /// Agent and service entry points. Displayed diagnostics are never authority. + package func validateProviderGeneration(for configuration: RunConfiguration) throws { + guard configuration.requiresProviderGeneration else { return } + providerInspectionRevision &+= 1 + guard let projectURL else { throw CancellationError() } + do { + guard let ownership = try buildProjectOwnership() else { return } + let inspection: ProjectRunConfigurationInspection + if let runs = try providerRunConfigurations() { + inspection = runConfigurationOperations.inspectProviderGeneration(at: projectURL, projectOwnership: ownership, providerRuns: runs) + } else { inspection = runConfigurationOperations.inspectProviderGeneration(at: projectURL, projectOwnership: ownership) } + if case .invalid(let message) = inspection.status { + throw RunConfigurationOperationFailure(message: message) + } + let diagnostics = inspection.diagnostics.filter { $0.code == "staleProviderModel" } + publishProviderDiagnostics(diagnostics) + if let first = diagnostics.first { throw RunConfigurationOperationFailure(message: first.message) } + } catch { + if error is CancellationError { + throw RunConfigurationOperationFailure(message: L10n.string("Load the current build model and enable its provider before running this configuration.")) + } + throw error } - return roots } /// A language capability explicitly opts into the dependency browser. @@ -446,18 +770,6 @@ package final class RunService: ObservableObject, BuildRunLaunching { } } - /// Applies an accepted Maven model without replacing the file snapshot or - /// reloading run configuration from disk. The execution graph owns delivery. - package func acceptMavenProject(_ project: MavenProject, at workspace: URL) { - let workspace = workspace.standardizedFileURL - guard projectURL == workspace else { return } - if case .loading(let pendingWorkspace) = projectLoadState, - pendingWorkspace != workspace { return } - mavenModelRevision += 1 - mavenProject = project - mavenProfiles = project.profiles - } - /// Loads run state for a workspace. /// /// `snapshotID` identifies the workspace snapshot `files` came from. Passing @@ -466,16 +778,16 @@ package final class RunService: ObservableObject, BuildRunLaunching { package func loadProject( at projectURL: URL, files: [URL], - mavenProject: MavenProject?, snapshotID: UUID? = nil ) async { + generationLease?.invalidate() + cancelProviderGenerationInspection() // An overlapping load may still expose the initial Current File placeholder. // Only a completed load owns a selection worth preserving on reload. if !isLoadingProject, let currentProject = self.projectURL { selectedConfigurationIDsByProject[currentProject.path] = selectedConfigurationID } let loadID = UUID() - let modelRevision = mavenModelRevision projectLoadID = loadID let workspace = projectURL.standardizedFileURL if let buildRunWorkspace, buildRunWorkspace != workspace { @@ -517,12 +829,6 @@ package final class RunService: ObservableObject, BuildRunLaunching { if dependenciesChanged { dependencyRevision &+= 1 } - // A Reload may commit while inspection is suspended. Preserve that - // accepted model instead of restoring the caller's earlier snapshot. - if mavenModelRevision == modelRevision { - self.mavenProject = mavenProject - } - mavenProfiles = self.mavenProject?.profiles ?? [] self.projectFiles = files configurationStatus = inspection.status requiresConfigurationGeneration = inspection.requiresGeneration @@ -540,7 +846,6 @@ package final class RunService: ObservableObject, BuildRunLaunching { let resolution = try await resolveLoadedProjectToolchains( operations: operations, projectURL: projectURL, - mavenProject: self.mavenProject, preferredConfigurationID: preferredID, loadID: loadID ) @@ -575,6 +880,7 @@ package final class RunService: ObservableObject, BuildRunLaunching { projectLoadState = snapshotID .map { .ready(workspace: workspace, snapshotID: $0) } ?? .bound(workspace: workspace) + await refreshProviderGeneration() } /// Adds what JDT reports about the generated Java entries. @@ -608,7 +914,7 @@ package final class RunService: ObservableObject, BuildRunLaunching { at projectURL: URL, resolution: [RunConfigurationDiagnostic] ) -> [RunConfigurationDiagnostic] { - let freshness = configurationDiagnostics.filter { $0.code == "staleFingerprint" } + let freshness = configurationDiagnostics.filter { ["staleFingerprint", "staleProviderModel", "providerModelNotReady"].contains($0.code) } let inspected = runConfigurationOperations.inspect( at: projectURL, checkFingerprint: false, @@ -657,38 +963,54 @@ package final class RunService: ObservableObject, BuildRunLaunching { return } let loadID = projectLoadID + generationLease?.invalidate() + let lease = RunConfigurationGenerationLease() + generationLease = lease generationRevision &+= 1 + let generationTicket = generationRevision isLoadingProject = true defer { - if projectLoadID == loadID { + if projectLoadID == loadID, generationRevision == generationTicket { isLoadingProject = false } + lease.invalidate() } let operations = runConfigurationOperations let files = projectFiles - let modulePaths = mavenProject?.allModules.map(\.relativePath) ?? [] - let result = await withCheckedContinuation { continuation in - DispatchQueue.global(qos: .userInitiated).async { - continuation.resume(returning: Result { - try operations.generate( - at: projectURL, - files: files, - modulePaths: modulePaths, - javaEntrypoints: javaDiscovery.entrypoints - ) - }) + let models = buildModels() + let modulePaths = Array(Set(models.flatMap { $0.projects.map(\.path) })).sorted() + let projectOwnership: [RunProjectOwnership]? + let providerRuns: [RunProviderConfiguration]? + do { projectOwnership = try buildProjectOwnership(); providerRuns = try providerRunConfigurations() } + catch { generationState = .failed(error.localizedDescription); return } + let result = await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + continuation.resume(returning: Result { + if let providerRuns { + return try operations.generate(at: projectURL, files: files, modulePaths: modulePaths, + javaEntrypoints: javaDiscovery.entrypoints, projectOwnership: projectOwnership, providerRuns: providerRuns, models: models, lease: lease) + } + return try operations.generate( + at: projectURL, + files: files, + modulePaths: modulePaths, + javaEntrypoints: javaDiscovery.entrypoints, + projectOwnership: projectOwnership + ) + }) + } } - } - guard !Task.isCancelled, projectLoadID == loadID else { return } + } onCancel: { lease.invalidate() } + guard !Task.isCancelled, projectLoadID == loadID, generationRevision == generationTicket, lease.isCurrent else { return } switch result { case .success(let result): do { await executableResolver.refreshCandidates(projectURL: projectURL) - guard !Task.isCancelled, projectLoadID == loadID else { return } + guard !Task.isCancelled, projectLoadID == loadID, generationRevision == generationTicket, lease.isCurrent else { return } var resolution = try resolveWithServiceToolchains( operations: operations, projectURL: projectURL, - mavenProject: mavenProject, preferredConfigurationID: nil ) try operations.migrateLegacySettings( @@ -698,7 +1020,6 @@ package final class RunService: ObservableObject, BuildRunLaunching { resolution = try resolveWithServiceToolchains( operations: operations, projectURL: projectURL, - mavenProject: mavenProject, preferredConfigurationID: selectedConfigurationIDsByProject[projectURL.standardizedFileURL.path] ?? resolution.defaultConfigurationID ) @@ -730,6 +1051,7 @@ package final class RunService: ObservableObject, BuildRunLaunching { preferredConfigurationID: selectedConfigurationIDsByProject[projectURL.standardizedFileURL.path] ?? resolution.defaultConfigurationID ) + await refreshProviderGeneration() } catch { configurationStatus = .invalid(error.localizedDescription) recoveryAction = .editConfiguration @@ -799,7 +1121,6 @@ package final class RunService: ObservableObject, BuildRunLaunching { let resolution = try resolveWithServiceToolchains( operations: runConfigurationOperations, projectURL: projectURL, - mavenProject: mavenProject, preferredConfigurationID: selectedConfiguration?.id ) configurationDiagnostics = resolution.diagnostics @@ -813,16 +1134,11 @@ package final class RunService: ObservableObject, BuildRunLaunching { } } - /// Saves one configuration's editor changes. The editor no longer edits the - /// project defaults, but Core rewrites them with every save, so they must be - /// the effective defaults: the ones `.lithe/run/local.json` already holds, or - /// `toolchain` (the caller's mirror) when that layer has never saved any. - /// Writing the resolved, empty selection instead would turn "no saved - /// defaults" into explicit automatic ones and discard the user's JDK. + /// Reads current project defaults at save time. The configuration editor + /// never writes a runtime-settings mirror back over the local document. @discardableResult package func saveEditorChanges( _ options: RunOptions, - toolchain: ProjectToolchainSelection, for configuration: RunConfiguration, scope: RunConfigurationSaveScope ) -> Bool { @@ -831,7 +1147,12 @@ package final class RunService: ObservableObject, BuildRunLaunching { configurationSaveError = "Identify the project before editing its run configuration." return false } - let projectDefaults = savedProjectToolchain ?? toolchain + let inspection = runConfigurationOperations.inspect(at: projectURL, checkFingerprint: false, javaEntrypoints: nil) + if case .invalid(let message) = inspection.status { + configurationSaveError = message + return false + } + let projectDefaults = inspection.projectToolchain ?? ProjectToolchainSelection() do { try runConfigurationOperations.saveEditorChanges( options, @@ -849,7 +1170,6 @@ package final class RunService: ObservableObject, BuildRunLaunching { let resolution = try resolveWithServiceToolchains( operations: runConfigurationOperations, projectURL: projectURL, - mavenProject: mavenProject, preferredConfigurationID: configuration.id ) configurationDiagnostics = diagnosticsAfterDocumentEdit( @@ -870,9 +1190,10 @@ package final class RunService: ObservableObject, BuildRunLaunching { } package func resetOptions(for configuration: RunConfiguration) { + let resetTools: [String: FixedToolchainSelection?] = Dictionary(uniqueKeysWithValues: + (options(for: configuration).providerRunOptions?.tools.keys.map { ($0, Optional.none) }) ?? []) saveEditorChanges( - RunOptions(), - toolchain: projectToolchain, + RunOptions(providerRunOptions: configuration.buildProvider == nil ? nil : BuildProviderRunOptions(tools: resetTools)), for: configuration, scope: .local ) @@ -890,7 +1211,6 @@ package final class RunService: ObservableObject, BuildRunLaunching { let resolution = try resolveWithServiceToolchains( operations: runConfigurationOperations, projectURL: projectURL, - mavenProject: mavenProject, preferredConfigurationID: id ) guard resolution.configurations.contains(where: { $0.configuration.id == id }) else { @@ -925,7 +1245,34 @@ package final class RunService: ObservableObject, BuildRunLaunching { /// A build provider has already prepared and Core has validated the launch. /// Reuse the native resolver/process/argument lease without a shadow run configuration. + package func prepareRunExecutable(_ preparation: BuildRunPreparation, workspaceURL: URL, configuration: RunConfiguration?) async throws -> ResolvedRunExecutable? { + let options: RunOptions + if let configuration { + options = effectiveOptions(for: configuration, mavenContext: try mavenContext(for: configuration)) + } else { + let inspection = runConfigurationOperations.inspect(at: workspaceURL, checkFingerprint: false, javaEntrypoints: nil) + if case .invalid(let message) = inspection.status { throw RunConfigurationOperationFailure(message: message) } + let toolchain = inspection.projectToolchain ?? ProjectToolchainSelection() + options = RunOptions(javaHomePath: toolchain.javaHomePath, mavenExecutablePath: toolchain.mavenExecutablePath, + mavenJavaHomePath: toolchain.mavenJavaHomePath) + } + return try await executableResolver.prepare(preparation.launchPlan, projectURL: workspaceURL, options: options) + } + package func launchPreparedRun(_ preparation: BuildRunPreparation, workspaceURL: URL, title: String, configuration: RunConfiguration? = nil) throws -> String { + try launchPreparedRun(preparation, workspaceURL: workspaceURL, title: title, configuration: configuration, + resolvedExecutable: nil) + } + + package func launchPreparedRun(_ preparation: BuildRunPreparation, workspaceURL: URL, title: String, + configuration: RunConfiguration?, resolvedExecutable: ResolvedRunExecutable?) throws -> String { + try launchPreparedRun(preparation, workspaceURL: workspaceURL, title: title, configuration: configuration, + retainingIdentity: nil, resolvedExecutable: resolvedExecutable) + } + + private func launchPreparedRun(_ preparation: BuildRunPreparation, workspaceURL: URL, title: String, + configuration: RunConfiguration?, retainingIdentity: String?, resolvedExecutable: ResolvedRunExecutable?) throws -> String { + if retainingIdentity == nil { providerDebugConfigurationID = nil } let plan = preparation.launchPlan let workspace = workspaceURL.standardizedFileURL if let projectURL, projectURL != workspace { throw CancellationError() } @@ -941,17 +1288,25 @@ package final class RunService: ObservableObject, BuildRunLaunching { throw RunConfigurationOperationFailure(message: message) } let toolchain = inspection.projectToolchain ?? ProjectToolchainSelection() - let options = RunOptions(javaHomePath: toolchain.javaHomePath, - mavenExecutablePath: toolchain.mavenExecutablePath, mavenJavaHomePath: toolchain.mavenJavaHomePath) - let resolved = try executableResolver.resolve(plan, projectURL: workspace, options: options) + let options: RunOptions + if let configuration { + options = effectiveOptions(for: configuration, mavenContext: try mavenContext(for: configuration)) + } else { + options = RunOptions(javaHomePath: toolchain.javaHomePath, + mavenExecutablePath: toolchain.mavenExecutablePath, mavenJavaHomePath: toolchain.mavenJavaHomePath) + } + let resolved = try resolvedExecutable ?? executableResolver.resolve(plan, projectURL: workspace, options: options) let arguments = try prepareJavaLaunch(executablePath: resolved.executableURL.path, arguments: plan.arguments) // The preparation is handing off successfully, so Stop must not cancel // the provider request that is currently returning this application. - let runIdentity = buildPreparationID == nil ? nil : primaryExecutionID + let runIdentity = retainingIdentity ?? (buildPreparationID == nil ? nil : primaryExecutionID) buildPreparationID = nil isPreparingBuildRun = false + let preparationOutput = retainingIdentity == nil ? "" : output + let providerOwner = retainingIdentity == nil ? nil : primaryProviderSource stop() - output = "" + primaryProviderSource = providerOwner + output = preparationOutput lastExitCode = nil lastRunConfiguration = configuration buildRunWorkspace = workspace @@ -999,14 +1354,23 @@ package final class RunService: ObservableObject, BuildRunLaunching { currentFileURL: URL?, javaLaunch: JavaDebugLaunchTarget? = nil ) { - if configuration.buildProvider != nil { startBuildProviderRun(configuration); return } + providerDebugConfigurationID = nil + if configuration.buildProvider != nil && !needsProviderLaunch(configuration, javaLaunch: javaLaunch) { startBuildProviderRun(configuration); return } stop() + do { try validateProviderGeneration(for: configuration) } + catch { fail(error.localizedDescription); return } output = "" lastExitCode = nil primaryExecutionID = UUID().uuidString lastRunConfiguration = configuration lastCurrentFileURL = currentFileURL - let mavenContext = mavenContext(for: configuration) + if needsProviderLaunch(configuration, javaLaunch: javaLaunch), let projectURL { + prepareProviderConfiguration(configuration, workspace: projectURL, primary: true) + return + } + let mavenContext: MavenLaunchContext? + do { mavenContext = try self.mavenContext(for: configuration) } + catch { fail(error.localizedDescription); return } let options = effectiveOptions(for: configuration, mavenContext: mavenContext) let usesGenericCurrentFile = configuration.kind == .currentFile && isGenericCurrentFile(currentFileURL) @@ -1031,7 +1395,6 @@ package final class RunService: ObservableObject, BuildRunLaunching { return } let currentFile = currentFileURL.flatMap { relativePath(for: $0, root: projectURL) } - let planClassPath = currentFileURL.flatMap(classPath(for:)) let requiredExtensionLanguageID = configuration.kind == .currentFile ? currentFileURL.flatMap { languageProviderCatalog.provider(for: $0)?.id } : configuration.kind.providerID @@ -1069,7 +1432,7 @@ package final class RunService: ObservableObject, BuildRunLaunching { at: projectURL, configurationID: configuration.id, currentFile: currentFile, - classPath: planClassPath, + classPath: nil, javaLaunch: javaLaunch, debugPort: nil, mavenContext: mavenContext @@ -1175,11 +1538,14 @@ package final class RunService: ObservableObject, BuildRunLaunching { package func runAllServices( javaLaunches: [String: JavaDebugLaunchTarget] = [:] ) { + providerDebugConfigurationID = nil let serviceConfigurations = configurations.filter { $0.execution == .service } guard !serviceConfigurations.isEmpty else { fail(L10n.string("No runnable services were detected in this project.")) return } + do { for configuration in serviceConfigurations { try validateProviderGeneration(for: configuration) } } + catch { fail(error.localizedDescription); return } stopAllServices() moduleSessions = [] for configuration in serviceConfigurations { @@ -1191,7 +1557,8 @@ package final class RunService: ObservableObject, BuildRunLaunching { _ configuration: RunConfiguration, javaLaunch: JavaDebugLaunchTarget? = nil ) { - if configuration.buildProvider != nil { startBuildProviderRun(configuration); return } + providerDebugConfigurationID = nil + if configuration.buildProvider != nil && !needsProviderLaunch(configuration, javaLaunch: javaLaunch) { startBuildProviderRun(configuration); return } guard configuration.kind != .currentFile else { return } stopModule(sessionID: configuration.id) startModuleSession(configuration, javaLaunch: javaLaunch) @@ -1202,6 +1569,7 @@ package final class RunService: ObservableObject, BuildRunLaunching { } package func restartModule(_ session: RunSession) { + providerDebugConfigurationID = nil guard let configuration = configurations.first(where: { $0.id == session.configurationID }) else { return } stopModule(sessionID: session.id) moduleSessions.removeAll { $0.id == session.id } @@ -1214,12 +1582,14 @@ package final class RunService: ObservableObject, BuildRunLaunching { !moduleProcesses.isEmpty || !moduleLanguageExecutionSessions.isEmpty || !modulePreLaunchProcesses.isEmpty + || !providerModulePreparations.isEmpty } package func stopAllServices() { let sessionIDs = Set(moduleProcesses.keys) .union(moduleLanguageExecutionSessions.keys) .union(modulePreLaunchProcesses.keys) + .union(providerModulePreparations.keys) for sessionID in sessionIDs { stopModule(sessionID: sessionID) } @@ -1237,6 +1607,12 @@ package final class RunService: ObservableObject, BuildRunLaunching { } package func stop() { + providerDebugPreparation?.cancel(); providerDebugPreparation = nil + activeProcessID = nil + let debugOperation = providerDebugOperationID + providerDebugOperationID = nil + primaryProviderSource = nil + providerPrimaryPreparation?.cancel(); providerPrimaryPreparation = nil let preparation = buildPreparationID buildPreparationID = nil isPreparingBuildRun = false @@ -1250,9 +1626,13 @@ package final class RunService: ObservableObject, BuildRunLaunching { isRunning = false runningTitle = nil activeOperationID = nil + if let debugOperation { providerDebugEnded.send(debugOperation) } } package func reset() { + generationLease?.invalidate() + providerDebugConfigurationID = nil + cancelProviderGenerationInspection() stop() stopAllServices() projectLoadID = UUID() @@ -1261,7 +1641,6 @@ package final class RunService: ObservableObject, BuildRunLaunching { projectLoadState = .idle selectedConfigurationIDsByProject = [:] projectFiles = [] - mavenProject = nil dependencyWorkspaceURL = nil dependencyConfiguration = WorkspaceDependencyConfiguration() dependencyIndexes = WorkspaceDependencyIndexes() @@ -1276,7 +1655,6 @@ package final class RunService: ObservableObject, BuildRunLaunching { projectToolchain = ProjectToolchainSelection() savedProjectToolchain = nil effectiveSourcesByConfigurationID = [:] - mavenProfiles = [] moduleSessions = [] portConflicts = [] configurationStatus = .missing @@ -1451,11 +1829,9 @@ package final class RunService: ObservableObject, BuildRunLaunching { private func toolchainCandidates( projectURL: URL, - mavenProject: MavenProject?, options: RunOptions? = nil ) -> [ProjectToolchainCandidate] { let runtimeCandidates = runtime.runConfigurationToolchainCandidates( - for: mavenProject, projectRoot: projectURL, javaHomeOverride: options?.javaHomePath, mavenExecutableOverride: options?.mavenExecutablePath @@ -1475,13 +1851,12 @@ package final class RunService: ObservableObject, BuildRunLaunching { private func resolveLoadedProjectToolchains( operations: any RunConfigurationOperations, projectURL: URL, - mavenProject: MavenProject?, preferredConfigurationID: String?, loadID: UUID ) async throws -> RunConfigurationResolution { func candidates(options: RunOptions? = nil) async throws -> [ProjectToolchainCandidate] { let runtimeCandidates = try await runtime.loadRunConfigurationToolchainCandidates( - for: mavenProject, projectRoot: projectURL, + projectRoot: projectURL, javaHomeOverride: options?.javaHomePath, mavenExecutableOverride: options?.mavenExecutablePath ) @@ -1503,17 +1878,15 @@ package final class RunService: ObservableObject, BuildRunLaunching { private func resolveWithServiceToolchains( operations: any RunConfigurationOperations, projectURL: URL, - mavenProject: MavenProject?, preferredConfigurationID: String? ) throws -> RunConfigurationResolution { let initial = try operations.resolve( at: projectURL, - toolchainCandidates: toolchainCandidates(projectURL: projectURL, mavenProject: mavenProject) + toolchainCandidates: toolchainCandidates(projectURL: projectURL) ) guard let options = javaServiceOptions(in: initial, preferredID: preferredConfigurationID) else { return initial } let candidates = toolchainCandidates( projectURL: projectURL, - mavenProject: mavenProject, options: options ) return try operations.resolve(at: projectURL, toolchainCandidates: candidates) @@ -1576,12 +1949,16 @@ package final class RunService: ObservableObject, BuildRunLaunching { } private func finishProcess(exitCode: Int32) { + activeProcessID = nil + let debugOperation = providerDebugOperationID + providerDebugOperationID = nil activeLanguageExecutionSession = nil activeLaunchArgumentLease = nil isRunning = false runningTitle = nil lastExitCode = exitCode activeOperationID = nil + if let debugOperation { providerDebugEnded.send(debugOperation) } } private func prepareJavaLaunch( @@ -1601,6 +1978,7 @@ package final class RunService: ObservableObject, BuildRunLaunching { guard event.operationID == activeOperationID else { return } switch event.state { case .starting, .running: + if let pid = event.processID, pid > 0 { activeProcessID = pid } isRunning = true case .stopping, .finished: isRunning = false @@ -1866,38 +2244,26 @@ package final class RunService: ObservableObject, BuildRunLaunching { ) } - private func classPath(for fileURL: URL) -> String? { - var candidateRoots: [URL] = [] - if let mavenProject { - candidateRoots += mavenProject.allModules - .filter { Self.isInside(fileURL, directory: $0.url) } - .sorted { $0.url.path.count > $1.url.path.count } - .map(\.url) - candidateRoots.append(mavenProject.rootURL) - } - if let projectURL { - candidateRoots.append(projectURL) - } - - var seenPaths = Set() - for root in candidateRoots { - let classesURL = root.appendingPathComponent("target/classes", isDirectory: true) - guard seenPaths.insert(classesURL.standardizedFileURL.path).inserted else { continue } - guard fileAccess.isDirectory(at: classesURL) else { continue } - return classesURL.standardizedFileURL.path - } - return nil - } - private func startModuleSession( _ configuration: RunConfiguration, - javaLaunch: JavaDebugLaunchTarget? = nil + javaLaunch: JavaDebugLaunchTarget? = nil, + preparedPlan: SharedLaunchPlan? = nil, + preparationOperationID: String? = nil, + resolvedExecutable: ResolvedRunExecutable? = nil ) { guard configurationStatus == .ready, let projectURL else { return } + let preparingSession = preparedPlan == nil ? nil : moduleSessions.first(where: { $0.id == configuration.id }) + let preparationOutput = preparingSession?.output ?? "" moduleSessions.removeAll { $0.id == configuration.id } + do { try validateProviderGeneration(for: configuration) } + catch { + moduleSessions.append(RunSession(executionID: preparingSession?.executionID ?? UUID().uuidString, id: configuration.id, configurationID: configuration.id, + title: configuration.name, output: error.localizedDescription + "\n", isRunning: false, exitCode: 1)) + return + } if let diagnostic = blockingToolchainDiagnostic(for: configuration) { - moduleSessions.append(RunSession( + moduleSessions.append(RunSession(executionID: preparingSession?.executionID ?? UUID().uuidString, id: configuration.id, configurationID: configuration.id, title: configuration.name, @@ -1909,7 +2275,7 @@ package final class RunService: ObservableObject, BuildRunLaunching { } if extensionRequiredLanguageIDs.contains(configuration.kind.providerID), languageRunExtension(providerID: configuration.kind.providerID) == nil { - moduleSessions.append(RunSession( + moduleSessions.append(RunSession(executionID: preparingSession?.executionID ?? UUID().uuidString, id: configuration.id, configurationID: configuration.id, title: configuration.name, @@ -1919,7 +2285,17 @@ package final class RunService: ObservableObject, BuildRunLaunching { )) return } - let mavenContext = mavenContext(for: configuration) + if preparedPlan == nil && needsProviderLaunch(configuration, javaLaunch: javaLaunch) { + prepareProviderConfiguration(configuration, workspace: projectURL, primary: false) + return + } + let mavenContext: MavenLaunchContext? + do { mavenContext = try self.mavenContext(for: configuration) } + catch { + moduleSessions.append(RunSession(executionID: preparingSession?.executionID ?? UUID().uuidString, id: configuration.id, configurationID: configuration.id, + title: configuration.name, output: error.localizedDescription + "\n", isRunning: false, exitCode: 1)) + return + } let options = effectiveOptions(for: configuration, mavenContext: mavenContext) let launchesJavaDirectly = configuration.kind == .javaMain || javaLaunch != nil let configuredJavaHome = (launchesJavaDirectly || options.mavenJavaHomePath.isEmpty @@ -1929,7 +2305,7 @@ package final class RunService: ObservableObject, BuildRunLaunching { ? runtime.javaHomeURL(overridePath: configuredJavaHome) : runtime.mavenJavaHomeURL(overridePath: configuredJavaHome) if !configuredJavaHome.isEmpty && resolvedJavaHome == nil { - moduleSessions.append(RunSession( + moduleSessions.append(RunSession(executionID: preparingSession?.executionID ?? UUID().uuidString, id: configuration.id, configurationID: configuration.id, title: configuration.name, @@ -1942,6 +2318,7 @@ package final class RunService: ObservableObject, BuildRunLaunching { let plan: SharedLaunchPlan do { + if let preparedPlan { plan = preparedPlan } else { plan = try runConfigurationOperations.launchPlan( at: projectURL, configurationID: configuration.id, @@ -1951,8 +2328,9 @@ package final class RunService: ObservableObject, BuildRunLaunching { debugPort: nil, mavenContext: mavenContext ) + } } catch { - moduleSessions.append(RunSession( + moduleSessions.append(RunSession(executionID: preparingSession?.executionID ?? UUID().uuidString, id: configuration.id, configurationID: configuration.id, title: configuration.name, @@ -1966,7 +2344,7 @@ package final class RunService: ObservableObject, BuildRunLaunching { let resolved: ResolvedRunExecutable let preparedSteps: [PreparedLaunchStep] do { - resolved = try executableResolver.resolve(plan, projectURL: projectURL, options: options) + resolved = try resolvedExecutable ?? executableResolver.resolve(plan, projectURL: projectURL, options: options) preparedSteps = try plan.preLaunchSteps.map { step in let stepResolved = try executableResolver.resolve( step: step, plan: plan, projectURL: projectURL, options: options @@ -1985,7 +2363,7 @@ package final class RunService: ObservableObject, BuildRunLaunching { } catch { // A service that cannot start still becomes a session so the panel // shows which one failed and why, rather than silently omitting it. - moduleSessions.append(RunSession( + moduleSessions.append(RunSession(executionID: preparingSession?.executionID ?? UUID().uuidString, id: configuration.id, configurationID: configuration.id, title: configuration.name, @@ -2002,11 +2380,11 @@ package final class RunService: ObservableObject, BuildRunLaunching { ) let workingDirectory = resolvedWorkingDirectory(plan.workingDirectory, fallback: projectURL) - var session = RunSession( + var session = RunSession(executionID: preparingSession?.executionID ?? UUID().uuidString, id: configuration.id, configurationID: configuration.id, title: configuration.name, - output: "$ " + resolved.executableURL.lastPathComponent + " " + output: preparationOutput + "$ " + resolved.executableURL.lastPathComponent + " " + (configuration.kind.isMavenBacked ? redactedMavenArgumentsForDisplay(requestedArguments) : requestedArguments).joined(separator: " ") + "\n\n", @@ -2019,7 +2397,7 @@ package final class RunService: ObservableObject, BuildRunLaunching { } moduleSessions.append(session) - let operationID = UUID().uuidString + let operationID = preparationOperationID ?? UUID().uuidString moduleOperationIDs[configuration.id] = operationID // The JVM waits for every pre-launch step, so an entry source with a // Maven resource step starts against the resources the step just wrote. @@ -2093,6 +2471,9 @@ package final class RunService: ObservableObject, BuildRunLaunching { } private func stopModule(sessionID: String) { + moduleProviderSources[sessionID] = nil + moduleOperationIDs[sessionID] = nil + providerModulePreparations.removeValue(forKey: sessionID)?.cancel() modulePreLaunchProcesses[sessionID]?.stop() modulePreLaunchProcesses[sessionID] = nil moduleProcesses[sessionID]?.stop() @@ -2145,6 +2526,7 @@ package final class RunService: ObservableObject, BuildRunLaunching { let activeSessionIDs = Set(moduleProcesses.keys) .union(moduleLanguageExecutionSessions.keys) .union(modulePreLaunchProcesses.keys) + .union(providerModulePreparations.keys) let staleSessionIDs = activeSessionIDs.filter { !validConfigurationIDs.contains($0) } for sessionID in staleSessionIDs { stopModule(sessionID: sessionID) @@ -2156,19 +2538,23 @@ package final class RunService: ObservableObject, BuildRunLaunching { _ process: any StreamingProcess, sessionID: String ) { + let operationID = moduleOperationIDs[sessionID] process.onOutput = { [weak self] chunk in Task { @MainActor [weak self] in - self?.appendModuleOutput(chunk, sessionID: sessionID) + guard let self, self.moduleOperationIDs[sessionID] == operationID else { return } + self.appendModuleOutput(chunk, sessionID: sessionID) } } process.onTermination = { [weak self] exitCode in Task { @MainActor [weak self] in - self?.finishModule(sessionID: sessionID, exitCode: exitCode) + guard let self, self.moduleOperationIDs[sessionID] == operationID else { return } + self.finishModule(sessionID: sessionID, exitCode: exitCode) } } process.onStateChange = { [weak self] event in Task { @MainActor [weak self] in - self?.consumeModuleLifecycle(event, sessionID: sessionID) + guard let self, self.moduleOperationIDs[sessionID] == operationID else { return } + self.consumeModuleLifecycle(event, sessionID: sessionID) } } } @@ -2251,8 +2637,9 @@ package final class RunService: ObservableObject, BuildRunLaunching { } } - let moduleRoot = configuration.modulePath.flatMap { modulePath in - mavenProject?.modules.first(where: { $0.relativePath == modulePath })?.url + let moduleRoot = configuration.modulePath.flatMap { path in + buildModels().flatMap(\.projects).first(where: { $0.path == path }) + .flatMap { project in projectURL?.appendingPathComponent(project.path, isDirectory: true) } } ?? projectURL guard let moduleRoot else { return nil } let resourceFiles = projectFiles.filter { fileURL in @@ -2323,8 +2710,12 @@ package final class RunService: ObservableObject, BuildRunLaunching { return options } - private func mavenContext(for configuration: RunConfiguration) -> MavenLaunchContext? { - guard let context = mavenContextProvider() else { return nil } + private func mavenContext(for configuration: RunConfiguration) throws -> MavenLaunchContext? { + if let projectURL, let project = configuration.buildProject { + return try buildProjectImportProvider(projectURL, project).mavenContext + } + guard let projectURL, configuration.mavenReactorPath != nil || configuration.kind.isMavenBacked, + let context = try buildLanguageImportProvider(projectURL, configuration.mavenReactorPath).mavenContext else { return nil } if let reactor = configuration.mavenReactorPath { return reactor == context.reactorPath ? context : nil } diff --git a/macos/Sources/LitheExecutionModule/Services/StandardLanguageTestProvider.swift b/macos/Sources/LitheExecutionModule/Services/StandardLanguageTestProvider.swift index b643a9848c..b553ef1b4e 100644 --- a/macos/Sources/LitheExecutionModule/Services/StandardLanguageTestProvider.swift +++ b/macos/Sources/LitheExecutionModule/Services/StandardLanguageTestProvider.swift @@ -21,7 +21,6 @@ package enum LanguageTestPlanError: LocalizedError, Equatable, Sendable { package struct StandardLanguageTestProvider: LanguageTestProvider { private enum StandardTestFramework: String { - case maven case gradle case go case pytest @@ -47,7 +46,9 @@ package struct StandardLanguageTestProvider: LanguageTestProvider { /// contains a file whose name looks like a test. The legacy overload above /// remains permissive for callers that only have a file list. package func discoverTests(context: LanguageTestContext) -> [LanguageTestItem] { - guard framework(for: descriptor.id, context: context) != nil else { return [] } + guard descriptor.id == "java" || framework(for: descriptor.id, context: context) != nil else { return [] } + if descriptor.id == "java", !context.projectFiles.contains(where: { descriptor.handles(fileURL: $0) }), + context.projectFileNames.isDisjoint(with: ["build.gradle", "build.gradle.kts", "gradlew"]) { return [] } return makeTestItems( workspaceURL: context.workspaceURL, files: context.projectFiles @@ -84,17 +85,12 @@ package struct StandardLanguageTestProvider: LanguageTestProvider { context: LanguageTestContext ) throws -> LanguageTestPlan { let root = context.workspaceURL + guard scope.buildReference == nil else { throw LanguageTestPlanError.unsupportedProvider(descriptor.displayName) } guard let framework = framework(for: descriptor.id, context: context) else { throw LanguageTestPlanError.unsupportedProvider(descriptor.displayName) } let plan: SharedLaunchPlan switch framework { - case .maven: - plan = SharedLaunchPlan( - executable: .toolchain("project-maven"), - arguments: ["-Dstyle.color=always"] + javaArguments(scope: scope, root: root), - workingDirectory: "." - ) case .gradle: plan = SharedLaunchPlan( executable: .toolchain("project-gradle"), @@ -150,10 +146,7 @@ package struct StandardLanguageTestProvider: LanguageTestProvider { || names.contains("gradlew") { return .gradle } - // Keep the old workspaceURL-only overload usable by treating an - // empty marker set as Maven, but never claim Maven for a real - // workspace that has no Maven/Gradle descriptor. - return names.isEmpty || names.contains("pom.xml") ? .maven : nil + return nil case "go": return context.projectFiles.isEmpty || names.contains("go.mod") @@ -197,17 +190,6 @@ package struct StandardLanguageTestProvider: LanguageTestProvider { } } - private func javaArguments(scope: LanguageTestScope, root: URL) -> [String] { - switch scope { - case .workspace: - return ["test"] - case .file(let url): - return ["-Dtest=" + url.deletingPathExtension().lastPathComponent, "test"] - case .testCase(let identifier, _): - return ["-Dtest=" + normalizedJavaTestIdentifier(identifier), "test"] - } - } - private func gradleArguments(scope: LanguageTestScope, root: URL) throws -> [String] { // Keep test workers in this invocation's owned process tree, including // when an unrelated reusable Gradle daemon is already running. @@ -217,7 +199,7 @@ package struct StandardLanguageTestProvider: LanguageTestProvider { break case .file(let url): arguments.append(contentsOf: ["--tests", try gradleSelector(for: url, root: root)]) - case .testCase(let identifier, _): + case .testCase(let identifier, _, _): arguments.append(contentsOf: [ "--tests", normalizedJavaTestIdentifier(identifier).replacingOccurrences(of: "#", with: "."), @@ -241,7 +223,7 @@ package struct StandardLanguageTestProvider: LanguageTestProvider { return ["test", "./..."] case .file(let url): return ["test", try packageArgument(for: url, root: root)] - case .testCase(let identifier, let fileURL): + case .testCase(let identifier, let fileURL, _): let package: String if let fileURL { package = try packageArgument(for: fileURL, root: root) @@ -261,7 +243,7 @@ package struct StandardLanguageTestProvider: LanguageTestProvider { break case .file(let url): arguments.append(try checkedRelativePath(url, root: root)) - case .testCase(let identifier, let fileURL): + case .testCase(let identifier, let fileURL, _): if let fileURL { arguments.append(try checkedRelativePath(fileURL, root: root) + "::" + identifier) } else { @@ -285,7 +267,7 @@ package struct StandardLanguageTestProvider: LanguageTestProvider { break case .file(let url): arguments.append(try checkedRelativePath(url, root: root)) - case .testCase(let identifier, let fileURL): + case .testCase(let identifier, let fileURL, _): if let fileURL { arguments.append(try checkedRelativePath(fileURL, root: root)) } switch framework { case .bun: @@ -307,7 +289,7 @@ package struct StandardLanguageTestProvider: LanguageTestProvider { return ["test", "--test", url.deletingPathExtension().lastPathComponent] } return ["test"] - case .testCase(let identifier, _): + case .testCase(let identifier, _, _): return ["test", identifier] } } @@ -321,7 +303,7 @@ package struct StandardLanguageTestProvider: LanguageTestProvider { switch scope { case .workspace: return "All \(descriptor.displayName) Tests" case .file(let url): return url.lastPathComponent - case .testCase(let identifier, _): return identifier + case .testCase(let identifier, _, _): return identifier } } diff --git a/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift b/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift index 4866e51510..8b1840d8f8 100644 --- a/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift +++ b/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift @@ -94,6 +94,8 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { private var startsInFlight = 0 private var state: LanguageServerSessionState = .stopped private var processID: Int32? + /// Native runtime identity, available only while this owned session runs. + package var processIdentifier: Int32? { isRunning ? processID : nil } /// Requests being written off the main actor. Core writes a request to /// the server's stdin synchronously, and a server that stopped reading /// blocks that write, so plugin requests are sent from a background task. @@ -1242,6 +1244,36 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { } /// Asks JDT, through Core, which classes in the workspace can be launched. + package func javaEntrypoints() async throws -> JavaEntrypoints { + try Task.checkCancellation() + guard let sessionID, state == .ready else { throw LanguageServerRuntimeSessionError.notReady } + let operation: LanguageServerRuntimeOperation + switch await send(timeout: requestTimeout, { core in + core.requestLanguageServerOperation(sessionID: sessionID, operation: .javaEntrypoints, fileURL: nil, + virtualURI: nil, position: nil, newName: nil, range: nil, diagnostics: [], + completionContext: nil, completionItem: nil, codeAction: nil, command: nil) + }) { + case .success(let started): operation = started + case .failure(let error): + sendsInFlight -= 1 + try Task.checkCancellation() + throw LanguageServerRuntimeSessionError.requestRejected(error.userMessage) + } + let value: JavaEntrypoints = try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + registerPendingOperation(operation, name: LanguageServerOperation.javaEntrypoints.rawValue) { result in + continuation.resume(with: result.flatMap { Self.decodeEventResult($0, as: JavaEntrypoints.self) }) + } + sendsInFlight -= 1 + } + } onCancel: { + Task { @MainActor [weak self] in self?.cancelOffMain(operation.operationID) } + } + try Task.checkCancellation() + return value + } + + /// Callback discovery is retained for presentation clients. package func javaEntrypoints( completion: @escaping (Result) -> Void ) throws { diff --git a/macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift b/macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift index 9707519564..7e2c3819cf 100644 --- a/macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift +++ b/macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift @@ -84,6 +84,11 @@ package final class LanguageToolingSessionManager: ObservableObject, /// Core can clear Maven settings only for a session launched with Maven /// inputs. Keep this with the session so a pure Gradle start stays nil. private var languageServerMavenContexts: [String: MavenLaunchContext] = [:] + /// Ephemeral import changes also revoke in-flight launch metadata (including ABA). + private var javaPreparationCancellations: [UUID: @MainActor () -> Void] = [:] + package private(set) var javaImportRevision: UInt64 = 0 { + didSet { for cancel in javaPreparationCancellations.values { cancel() } } + } private var languageServerWorkspaceFingerprints: [String: String] = [:] private var languageServerSessionIdentities: [String: ObjectIdentifier] = [:] private var diagnosticsByProviderID: [String: [URL: [LanguageServerDiagnostic]]] = [:] @@ -93,7 +98,6 @@ package final class LanguageToolingSessionManager: ObservableObject, private let workspaceFingerprintProvider: (LanguageProviderDescriptor, URL) throws -> String? private let workspaceStateResetter: ((LanguageProviderDescriptor, URL, String?) throws -> Void)? private let workspaceStateCleaner: ((LanguageProviderDescriptor, URL, String?) throws -> Int)? - private var mavenContextProvider: (LanguageProviderDescriptor, URL) -> MavenLaunchContext? = { _, _ in nil } private var javaLanguageImportProvider: ((LanguageProviderDescriptor, URL) throws -> JavaLanguageImportConfiguration)? private let pluginOperations: PluginOperationReporter @@ -227,12 +231,6 @@ package final class LanguageToolingSessionManager: ObservableObject, }) } - package func configureMavenContextProvider( - _ provider: @escaping (LanguageProviderDescriptor, URL) -> MavenLaunchContext? - ) { - mavenContextProvider = provider - } - /// The host must prepare plugin imports before any entry point can start /// Java. Throwing here prevents a synchronous document/run path from using /// a previous workspace's context while that preparation is pending. @@ -247,7 +245,7 @@ package final class LanguageToolingSessionManager: ObservableObject, ) throws -> JavaLanguageImportConfiguration { guard descriptor.id == "java" else { return JavaLanguageImportConfiguration() } if let javaLanguageImportProvider { return try javaLanguageImportProvider(descriptor, rootURL) } - return JavaLanguageImportConfiguration(mavenContext: mavenContextProvider(descriptor, rootURL)) + return JavaLanguageImportConfiguration() } package func retryMavenProfiles(providerID: String) { @@ -515,6 +513,7 @@ package final class LanguageToolingSessionManager: ObservableObject, } switch session.updateMavenConfiguration(context, reloadProjects: reloadProjects) { case .success(let update): + if languageServerMavenContexts["java"] != context || reloadProjects { javaImportRevision &+= 1 } languageServerMavenContexts["java"] = context return .updated(update) case .failure(let failure): return .failed(failure) @@ -638,6 +637,13 @@ package final class LanguageToolingSessionManager: ObservableObject, try await prepareJavaLaunchTarget(fileURL: fileURL, rootURL: rootURL, mainClass: mainClass) } + /// The exact options of the successful build preparation, without saving + /// a project override or reconstructing a second Maven configuration. + package func prepareJavaRunLaunchTarget(fileURL: URL, rootURL: URL, + languageImport: JavaLanguageImportConfiguration, mainClass: String? = nil) async throws -> JavaLaunchPreparation { + try await prepareJavaLaunchTarget(fileURL: fileURL, rootURL: rootURL, mainClass: mainClass, languageImport: languageImport) + } + /// Rebuilds the original running target without allowing a failed build to proceed. package func buildJavaServiceUpdate( target: JavaDebugLaunchTarget, fileURL: URL, rootURL: URL @@ -673,7 +679,37 @@ package final class LanguageToolingSessionManager: ObservableObject, /// /// Note: 入口点归属见 .agents/notes/implemented/architecture/2026-09-21-java-entrypoints-owned-by-jdt.md package func javaEntrypoints(rootURL: URL) async throws -> JavaEntrypoints { - try await javaEntrypoints(in: rootURL.standardizedFileURL) + try prepareJavaImportSession(rootURL: rootURL.standardizedFileURL) + let revision = javaImportRevision + let value = try await javaEntrypoints(in: rootURL.standardizedFileURL) + guard javaImportRevision == revision else { throw CancellationError() } + return value + } + + /// Reuse the current session when Core can update it in place. A session + /// originally started without Maven inputs must restart with those inputs. + private func prepareJavaImportSession(rootURL: URL, override: JavaLanguageImportConfiguration? = nil) throws { + guard let descriptor = catalog.descriptors.first(where: { $0.id == "java" }) else { + throw LanguageToolingSessionError.toolingUnavailable("Java") + } + let configuration = try override ?? javaLanguageImport(descriptor, rootURL: rootURL) + let session = try ensureLanguageServerSession(descriptor: descriptor, rootURL: rootURL, + operationID: nil, javaImportOverride: configuration) + if let context = configuration.mavenContext, languageServerMavenContexts["java"] != context { + switch session.updateMavenConfiguration(context, reloadProjects: false) { + case .success: + javaImportRevision &+= 1 + languageServerMavenContexts["java"] = context + case .failure: + stopLanguageServer(providerID: "java") + _ = try ensureLanguageServerSession(descriptor: descriptor, rootURL: rootURL, + operationID: nil, javaImportOverride: configuration) + } + } else if configuration.mavenContext == nil, languageServerMavenContexts["java"] != nil { + if case .failed(let failure) = updateJavaMavenConfiguration(rootURL: rootURL, reloadProjects: false) { + throw LanguageToolingSessionError.toolingUnavailable(failure.userMessage) + } + } } private func javaEntrypoints(in normalizedRoot: URL) async throws -> JavaEntrypoints { @@ -682,15 +718,7 @@ package final class LanguageToolingSessionManager: ObservableObject, guard let session = languageServers["java"], session.isRunning else { throw LanguageToolingSessionError.toolingUnavailable("Java") } - return try await withCheckedThrowingContinuation { continuation in - do { - try session.javaEntrypoints { result in - continuation.resume(with: result) - } - } catch { - continuation.resume(throwing: error) - } - } + return try await session.javaEntrypoints() } /// `file` relative to `root` with `/` separators, comparing the paths @@ -713,18 +741,51 @@ package final class LanguageToolingSessionManager: ObservableObject, } private func prepareJavaLaunchTarget( - fileURL: URL, - rootURL: URL, - mainClass: String? = nil + fileURL: URL, rootURL: URL, mainClass: String? = nil, + languageImport: JavaLanguageImportConfiguration? = nil ) async throws -> JavaLaunchPreparation { let normalizedRoot = rootURL.standardizedFileURL let resolvedFile = fileURL.standardizedFileURL.resolvingSymlinksInPath() - _ = try startLanguageServer(providerID: "java", rootURL: normalizedRoot) + try prepareJavaImportSession(rootURL: normalizedRoot, override: languageImport) + let revision = javaImportRevision, sessionIdentity = languageServerSessionIdentities["java"] + func checkInputs() throws { + try Task.checkCancellation() + guard javaImportRevision == revision, languageServerRoots["java"] == normalizedRoot, + languageServerSessionIdentities["java"] == sessionIdentity else { throw CancellationError() } + } try await waitUntilLanguageServerReady(providerID: "java", rootURL: normalizedRoot) + try checkInputs() guard let session = languageServers["java"], session.isRunning else { throw LanguageToolingSessionError.toolingUnavailable("Java") } - return try await session.prepareJavaLaunch(fileURL: resolvedFile, mainClass: mainClass) + let preparation = try await awaitJavaPreparation { + try await session.prepareJavaLaunch(fileURL: resolvedFile, mainClass: mainClass) + } + try checkInputs() + return preparation + } + + /// Framework goals build with Maven; only synchronize the same import and + /// await Core's configuration barrier, without a second JDT build or launch. + package func applyJavaRunImport(rootURL: URL, languageImport: JavaLanguageImportConfiguration) async throws { + let root = rootURL.standardizedFileURL + try prepareJavaImportSession(rootURL: root, override: languageImport) + let revision = javaImportRevision, identity = languageServerSessionIdentities["java"] + try await waitUntilLanguageServerReady(providerID: "java", rootURL: root) + guard javaImportRevision == revision, languageServerRoots["java"] == root, + languageServerSessionIdentities["java"] == identity, + let session = languageServers["java"], session.isRunning else { throw CancellationError() } + _ = try await awaitJavaPreparation { try await session.javaEntrypoints() } + try Task.checkCancellation() + guard javaImportRevision == revision, languageServerRoots["java"] == root, + languageServerSessionIdentities["java"] == identity else { throw CancellationError() } + } + + private func awaitJavaPreparation(_ operation: @escaping @MainActor () async throws -> T) async throws -> T { + let id = UUID(), task = Task { try await operation() } + javaPreparationCancellations[id] = { task.cancel() } + defer { javaPreparationCancellations[id] = nil; task.cancel() } + return try await withTaskCancellationHandler { try await task.value } onCancel: { task.cancel() } } /// Resolves one Java source file or discovered test item through the Java @@ -850,6 +911,24 @@ package final class LanguageToolingSessionManager: ObservableObject, /// Discovers the Java test classes and methods in one source file for the /// native Tests tree. This reuses the same identifiers accepted by Debug. + /// JDT owns semantic identities and file membership. Build providers consume + /// these public fullName values unchanged; the host never derives a selector + /// from a filename, package declaration or method spelling. + package func javaBuildTestSelectors(fileURL: URL, identifier: String?, rootURL: URL) async throws -> [String] { + let items = try await resolvedJavaTestItems(fileURL: fileURL.standardizedFileURL, rootURL: rootURL.standardizedFileURL) + let selected: [JavaTestItem] + if let identifier { + selected = Self.flattenJavaTestItems(items).filter { $0.matches(identifier: identifier) } + } else { + selected = items + } + let values = Array(Set(selected.map(\.fullName).filter { !$0.isEmpty })).sorted() + guard !values.isEmpty, values.count <= 64, identifier == nil || values.count == 1 else { + throw LanguageToolingSessionError.toolingUnavailable("Java") + } + return values + } + package func discoverJavaTestItems( fileURL: URL, rootURL: URL @@ -871,24 +950,19 @@ package final class LanguageToolingSessionManager: ObservableObject, arguments: [ToolingJSONValue], rootURL: URL ) async throws -> ToolingJSONValue { + guard isLanguageServerEnabled("java", rootURL.standardizedFileURL) else { + throw LanguageToolingSessionError.providerDisabled("Java") + } let command = LanguageServerCommand( title: commandID, command: commandID, arguments: arguments ) - return try await withCheckedThrowingContinuation { continuation in - do { - try executeReturningValue( - command, - fileURL: rootURL.appendingPathComponent("Main.java"), - rootURL: rootURL - ) { result in - continuation.resume(with: result) - } - } catch { - continuation.resume(throwing: error) - } + guard let session = languageServers["java"], session.isRunning, + languageServerRoots["java"] == rootURL.standardizedFileURL else { + throw LanguageToolingSessionError.toolingUnavailable("Java") } + return try await session.executeReturningValue(command, fileURL: rootURL) } private func executeJavaTestCommand( @@ -903,12 +977,13 @@ package final class LanguageToolingSessionManager: ObservableObject, fileURL: URL, rootURL: URL ) async throws -> [JavaTestItem] { - _ = try startLanguageServer(providerID: "java", rootURL: rootURL) + try prepareJavaImportSession(rootURL: rootURL) + let revision = javaImportRevision try await waitUntilLanguageServerReady(providerID: "java", rootURL: rootURL) guard let session = languageServers["java"], session.isRunning else { throw LanguageToolingSessionError.toolingUnavailable("Java") } - return try await withCheckedThrowingContinuation { continuation in + let items: [JavaTestItem] = try await withCheckedThrowingContinuation { continuation in do { try session.javaTestItems(fileURL: fileURL) { result in continuation.resume(with: result.map(\.items)) @@ -917,6 +992,9 @@ package final class LanguageToolingSessionManager: ObservableObject, continuation.resume(throwing: error) } } + try Task.checkCancellation() + guard javaImportRevision == revision else { throw CancellationError() } + return items } private static func stringValues(_ value: ToolingJSONValue) -> [String] { @@ -954,7 +1032,8 @@ package final class LanguageToolingSessionManager: ObservableObject, kind: .testCase, fileURL: fileURL, testIdentifier: item.fullName, - depth: depth + depth: depth, + reportIdentity: javaTestReportIdentity(item) )] + projectJavaTestItems( item.children, fileURL: fileURL, @@ -963,6 +1042,16 @@ package final class LanguageToolingSessionManager: ObservableObject, } } + /// Keep JDT's display-name convention in its existing language adapter; + /// the generic Tests view never parses report names or execution selectors. + private static func javaTestReportIdentity(_ item: JavaTestItem) -> LanguageTestItem.ReportIdentity? { + if item.level == 5 { return .init(suite: item.fullName) } + guard item.level == 6, let separator = item.fullName.firstIndex(of: "#") else { return nil } + let name = item.fullName[item.fullName.index(after: separator)...] + return .init(suite: String(item.fullName[.. [JavaTestItem] { @@ -1146,6 +1235,7 @@ package final class LanguageToolingSessionManager: ObservableObject, let removed = languageServers.removeValue(forKey: providerID) if stopSession { removed?.stop() } languageServerRoots[providerID] = nil + if providerID == "java" { javaImportRevision &+= 1 } languageServerMavenContexts[providerID] = nil languageServerWorkspaceFingerprints[providerID] = nil languageServerFeatures[providerID] = nil @@ -1645,7 +1735,8 @@ package final class LanguageToolingSessionManager: ObservableObject, private func ensureLanguageServerSession( descriptor: LanguageProviderDescriptor, rootURL: URL, - operationID requestedOperationID: UUID? + operationID requestedOperationID: UUID?, + javaImportOverride: JavaLanguageImportConfiguration? = nil ) throws -> any LanguageServerSession { guard stoppingServers[descriptor.id] == nil else { throw LanguageToolingSessionError.toolingUnavailable( @@ -1789,7 +1880,8 @@ package final class LanguageToolingSessionManager: ObservableObject, ) do { - let importConfiguration = try javaLanguageImport(descriptor, rootURL: rootURL) + let importConfiguration = try javaImportOverride ?? javaLanguageImport(descriptor, rootURL: rootURL) + if descriptor.id == "java" { javaImportRevision &+= 1 } languageServerMavenContexts[descriptor.id] = importConfiguration.mavenContext try created.start( rootURL: rootURL, @@ -1835,6 +1927,7 @@ package final class LanguageToolingSessionManager: ObservableObject, languageServerSessionIdentities[providerID] = nil let session = languageServers.removeValue(forKey: providerID) languageServerRoots[providerID] = nil + if providerID == "java" { javaImportRevision &+= 1 } languageServerMavenContexts[providerID] = nil languageServerWorkspaceFingerprints[providerID] = nil languageServerFeatures[providerID] = nil diff --git a/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift b/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift index e21b398e67..9131612bac 100644 --- a/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift +++ b/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift @@ -522,15 +522,14 @@ public struct PluginManifest: Equatable, Codable, Sendable { "keymapImportProviders": [1], "packageTrust": [1], "backend": [1, 2], - "buildProviders": [1, 2, 3, 4, 5, 6], "buildModelInputs": [1], - "buildSystems": [1, 2], "debugAdapters": [1, 2], "executableResolution": [1, 2], + "buildProviders": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], "buildModelInputs": [1], + "buildSystems": [1, 2, 3, 4], "debugAdapters": [1, 2], "executableResolution": [1, 2], "executionProviders": [1], "languageServers": [1, 2], "languages": [1, 2], "projectGenerators": [1], "serverProcess": [1], "settings": [1, 2], - // Toolchain discovery and provider queries are hosted by the read-only settings pages. "toolDefinitions": [1], "toolchainProviders": [1], "ui": [1, 2, 3, 4], diff --git a/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift b/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift index c1dfd703b4..cb0f95c4c0 100644 --- a/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift +++ b/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift @@ -7,6 +7,28 @@ import Testing @MainActor struct DebugModuleTests { + @Test + func terminalStateIsReportedForAnInactiveSessionWithoutChangingTheSelection() throws { + let provider = DebugProviderDescriptor(id: "python", displayName: "Python", fileExtensions: ["py"]) + var created: [DeferredInspectionDebugSession] = [] + let manager = DebugAdapterSessionManager(providers: [provider]) { _, _ in + let session = DeferredInspectionDebugSession(); created.append(session); return session + } + let feature = GenericDebugFeatureModel(sessions: manager) + defer { feature.reset() } + var ended: [DebugSessionID] = [] + feature.onSessionStateChanged = { id, state in if state == .failed { ended.append(id) } } + let root = URL(fileURLWithPath: "/workspace") + let configuration = DebugLaunchConfiguration(name: "Service", request: .launch, arguments: [:]) + #expect(feature.startAdditional(fileURL: root.appendingPathComponent("a.py"), rootURL: root, configuration: configuration)) + let first = try #require(feature.activeSessionID) + #expect(feature.startAdditional(fileURL: root.appendingPathComponent("b.py"), rootURL: root, configuration: configuration)) + let second = try #require(feature.activeSessionID), state = feature.state + created[0].transition(to: .failed) + #expect(ended == [first]) + #expect(feature.activeSessionID == second && feature.state == state) + } + @Test(arguments: [DebugRequestKind.launch, .attach], [false, true]) func explicitEndpointUsesItsFactoryAndPreservesTheDAPRequest(request: DebugRequestKind, explicit: Bool) throws { let descriptor = DebugProviderDescriptor(id: "python", displayName: "Python", fileExtensions: ["py"]) diff --git a/macos/Tests/LitheExecutionModuleTests/BuildProjectPresentationTests.swift b/macos/Tests/LitheExecutionModuleTests/BuildProjectPresentationTests.swift new file mode 100644 index 0000000000..801c8c7017 --- /dev/null +++ b/macos/Tests/LitheExecutionModuleTests/BuildProjectPresentationTests.swift @@ -0,0 +1,48 @@ +import Foundation +import LitheCoreContracts +@testable import LitheExecutionModule +import Testing + +struct BuildProjectPresentationTests { + private let workspace = URL(fileURLWithPath: "/workspace/demo") + + @Test + func labelsAndRolesFollowTheDeepestImportedDirectory() throws { + let model = try importedModel() + #expect(BuildProjectPresentation.projectName(for: workspace.appendingPathComponent("modules/app/src/Main.java"), + workspace: workspace, models: [model]) == "Application") + #expect(BuildProjectPresentation.projectName(for: workspace.appendingPathComponent("modules/app-copy/Main.java"), + workspace: workspace, models: [model]) == "Reactor") + #expect(BuildProjectPresentation.sourceRootKind(for: workspace.appendingPathComponent("modules/app/src/checks/Helper.java"), + workspace: workspace, models: [model]) == "testSource") + #expect(BuildProjectPresentation.sourceRootKind(for: workspace.appendingPathComponent("modules/app/src/checks-copy/Main.java"), + workspace: workspace, models: [model]) == "source") + #expect(BuildProjectPresentation.sourceRootKind(for: workspace.appendingPathComponent("modules/app/build/generated/Fixture.java"), + workspace: workspace, models: [model]) == "generatedTestSource") + #expect(BuildProjectPresentation.sourceRootKind(for: workspace.appendingPathComponent("modules/app/test-data/input.json"), + workspace: workspace, models: [model]) == "testResource") + } + + @Test + func outsideWorkspaceAndVirtualDocumentsCannotAcquireAnImportedRole() throws { + let model = try importedModel() + let outside = URL(fileURLWithPath: "/workspace/demo-copy/modules/app/src/Main.java") + #expect(BuildProjectPresentation.projectName(for: outside, workspace: workspace, models: [model]) == nil) + #expect(BuildProjectPresentation.sourceRootKind(for: outside, workspace: workspace, models: [model]) == nil) + let virtual = try #require(URL(string: "jdt://contents/demo/Main.java")) + #expect(BuildProjectPresentation.projectName(for: virtual, workspace: workspace, models: [model]) == nil) + #expect(BuildProjectPresentation.sourceRootKind(for: virtual, workspace: workspace, models: [model]) == nil) + } + + private func importedModel() throws -> BuildModel { + try JSONDecoder().decode(BuildModel.self, from: Data(#""" + {"projects":[{"id":"root","path":".","name":"Reactor"}, + {"id":"app","path":"modules/app","name":"app","displayName":"Application","parentID":"root"}], + "contentRoots":[ + {"projectID":"app","path":"modules/app/src","kind":"source"}, + {"projectID":"app","path":"modules/app/src/checks","kind":"testSource"}, + {"projectID":"app","path":"modules/app/build/generated","kind":"generatedTestSource"}, + {"projectID":"app","path":"modules/app/test-data","kind":"testResource"}]} + """#.utf8)) + } +} diff --git a/macos/Tests/LitheExecutionModuleTests/BuildSystemServiceTests.swift b/macos/Tests/LitheExecutionModuleTests/BuildSystemServiceTests.swift index 0ccaf0a291..ff477ca7cb 100644 --- a/macos/Tests/LitheExecutionModuleTests/BuildSystemServiceTests.swift +++ b/macos/Tests/LitheExecutionModuleTests/BuildSystemServiceTests.swift @@ -8,6 +8,334 @@ import Testing @MainActor struct BuildSystemServiceTests { + @Test + func runGenerationProjectsUseCurrentImportedModelsAndRevokePendingSnapshots() async throws { + let planner = FakeBuildPlanner() + planner.importEnabled = true; planner.dynamicOptions = true + planner.detection = BuildDetection(instances: [try Self.instance(modelSource: "static", model: + #"{"projects":[{"id":"p","path":"app","name":"App","properties":{"run.modelVersion":"1","run.provider":"quarkus.maven","java.minimumVersion":"21","maven.minimumVersion":"3.9.9","maven.wrapper":"mvnw.cmd"}}],"contentRoots":[{"projectID":"p","path":"app/checks","kind":"testSource"},{"projectID":"p","path":"app/resources","kind":"resource"}]}"#)]) + let harness = Harness(planner: planner) + defer { harness.service.stop() } + harness.service.reset(workspaceURL: Self.workspace) + _ = try await harness.service.prepareLanguageImport(workspaceURL: Self.workspace) + let ownership = try harness.service.runProjectOwnership() + #expect(ownership == [RunProjectOwnership(path: "app", build: .init(pluginID: "dev.example", buildSystemID: "fixture", root: "app", projectID: "p"), + contentRoots: [.init(path: "app/checks", kind: "testSource")], name: "App", minimumJavaVersion: "21")]) + let project = try #require(ownership.first?.build) + #expect(try harness.service.languageImport(workspaceURL: Self.workspace, project: project) + == harness.service.languageImport(workspaceURL: Self.workspace, root: "app")) + for wrong in [ + RunBuildProjectReference(pluginID: "dev.other", buildSystemID: project.buildSystemID, root: project.root, projectID: project.projectID), + RunBuildProjectReference(pluginID: project.pluginID, buildSystemID: "other", root: project.root, projectID: project.projectID), + RunBuildProjectReference(pluginID: project.pluginID, buildSystemID: project.buildSystemID, root: ".", projectID: project.projectID), + RunBuildProjectReference(pluginID: project.pluginID, buildSystemID: project.buildSystemID, root: project.root, projectID: "other") + ] { + #expect(throws: CancellationError.self) { try harness.service.languageImport(workspaceURL: Self.workspace, project: wrong) } + } + harness.service.setOption(Self.instanceID, optionID: "features", value: .array([.string("next")])) + #expect(throws: (any Error).self) { try harness.service.runProjectOwnership() } + #expect(throws: CancellationError.self) { try harness.service.languageImport(workspaceURL: Self.workspace, project: project) } + _ = try await harness.service.prepareLanguageImport(workspaceURL: Self.workspace) + #expect(try harness.service.runProjectOwnership() == ownership) + harness.service.reset(workspaceURL: nil) + #expect(throws: (any Error).self) { try harness.service.runProjectOwnership() } + } + + @Test + func runGenerationUsesDeclaredTargetsWithoutLanguageImportOrToolProperties() async throws { + let planner = FakeBuildPlanner() + planner.dynamicOptions = true + planner.detection = BuildDetection(instances: [try Self.instance(modelSource: "static", model: + #"{"projects":[{"id":"p","path":"app","name":"App"},{"id":"q","path":"app/library","name":"Library"}],"targets":[{"id":"http/opaque:1","projectID":"p","name":"Application","path":"app","tags":["endpoint"]},{"id":"check/opaque:2","projectID":"p","name":"Checks","path":"app","tags":["checks"]},{"id":"foreign","projectID":"q","name":"Other","path":"app/library","tags":["other"]}]}"#)]) + let source = fixtureSource(providerRuns: [ + .init(id: "custom-serve", displayName: "Serve", targetTags: ["endpoint"], execution: .service), + .init(id: "custom-check", displayName: "Check", targetTags: ["checks"], execution: .task), + .init(id: "any-target", displayName: "Any", targetTags: [], execution: .application)]) + let harness = Harness(planner: planner, sources: [source]) + defer { harness.service.stop() } + harness.service.reset(workspaceURL: Self.workspace) + _ = try await harness.service.prepareLanguageImport(workspaceURL: Self.workspace) + #expect(try harness.service.runProjectOwnership().isEmpty) + let runs = try harness.service.providerRunConfigurations() + #expect(runs.prefix(2).map(\.reference) == [ + .init(pluginID: "dev.example", buildSystemID: "fixture", root: "app", run: "custom-serve", projectID: "p", targetID: "http/opaque:1", execution: .service), + .init(pluginID: "dev.example", buildSystemID: "fixture", root: "app", run: "custom-check", projectID: "p", targetID: "check/opaque:2", execution: .task)]) + let wildcard = runs.filter { $0.reference.run == "any-target" } + #expect(Set(wildcard.map { $0.reference.targetID }) == ["http/opaque:1", "check/opaque:2", "foreign"]) + #expect(Set(wildcard.map(\.projectPath)) == ["app", "app/library"]) + #expect(Set(wildcard.map(\.name)).count == 3) + harness.service.reset(workspaceURL: nil) + #expect(throws: (any Error).self) { try harness.service.providerRunConfigurations() } + } + + @Test + func runGenerationRejectsAnOversizedDeclarationTargetProduct() async throws { + let planner = FakeBuildPlanner() + let targets = (0..<400).map { ["id": "target-\($0)", "projectID": "p", "name": "Target \($0)", "path": "app"] } + let data = try JSONSerialization.data(withJSONObject: ["projects": [["id": "p", "path": "app", "name": "App"]], "targets": targets]) + planner.detection = BuildDetection(instances: [try Self.instance(modelSource: "static", model: String(decoding: data, as: UTF8.self))]) + let runs = (0..<32).map { DeclarativeBuildSystem.ProviderRun(id: "run-\($0)", displayName: "Run", targetTags: [], execution: .task) } + let harness = Harness(planner: planner, sources: [fixtureSource(providerRuns: runs)]) + defer { harness.service.stop() } + harness.service.reset(workspaceURL: Self.workspace) + _ = try await harness.service.prepareLanguageImport(workspaceURL: Self.workspace) + #expect(harness.service.entries.first?.model?.targets.count == 400) + #expect(throws: (any Error).self) { try harness.service.providerRunConfigurations() } + } + + @Test(arguments: ["success", "failed", "cancel", "configuration", "workspace"]) + func languageResourcePreparationUsesCurrentSourceRoleAndRejectsFailedOrRevokedOperations(mode: String) async throws { + let planner = FakeBuildPlanner() + planner.fingerprint = "o" + planner.dynamicOptions = true + planner.importEnabled = true + planner.detection = BuildDetection(instances: [try Self.instance(modelSource: "static", model: + #"{"projects":[{"id":"p","path":"app","name":"App"},{"id":"other","path":".","name":"Other"}],"contentRoots":[{"projectID":"p","path":"app/spec","kind":"testSource"},{"projectID":"other","path":"app/spec/deeper","kind":"source"}],"optionValues":{"features":["run-only","changed"]}}"#)]) + planner.providerRequest = Self.providerRequest(method: "lithe/build/execute") + let providers = FakeBuildProviders() + let release = BuildTestGate(), finished = BuildTestGate() + providers.replyGate = release + providers.executionResult = BuildProviderExecutionResult(exitCode: mode == "failed" ? 2 : 0, output: "resources") + let tasks = ["prepare.main", "prepare.test"].map { DeclarativeBuildSystem.Task(id: $0, displayName: $0, + group: .build, scopes: [.project], fromModel: false, targetTags: [], requiresUserArguments: false, description: nil, + preparation: .init(purpose: "run", sourceRoles: $0 == "prepare.main" ? ["source", "generatedSource"] : ["testSource", "generatedTestSource"])) } + let harness = Harness(planner: planner, sources: [fixtureSource(tasks: tasks)], providers: providers) + harness.service.reset(workspaceURL: Self.workspace) + try #require(await harness.waitForEntries { !$0.isEmpty }) + var error: Error? + var prepared: BuildLanguageImport? + let task = Task { + defer { finished.open() } + // A deeper root published by another project must not turn this + // project's test source into a main-source preparation. + do { prepared = try await harness.service.prepareLanguageRun(fileURL: Self.workspace.appendingPathComponent("app/spec/deeper/UnrelatedName.java"), + workspace: Self.workspace, purpose: "run", + optionOverrides: ["features": .array([.string("run-only")])], + tool: { _ in BuildProviderHints(toolExecutable: URL(fileURLWithPath: "/selected/tool"), toolEnvironment: ["JAVA_HOME": "/selected/jdk"]) }, + isCurrent: { true }) } + catch let failure { error = failure } + } + defer { task.cancel(); release.open(); harness.service.stop() } + try #require(await providers.requestStarted.waitUntilOpen()) + #expect(planner.taskRequests.last?.task == "prepare.test") + #expect(planner.taskRequests.last?.project?.path == "app") + #expect(planner.taskPlanContexts.last?.options["features"] == .array([.string("run-only")])) + #expect(harness.optionsStore.values["fixture"]?["features"] == nil) + if case .object(let params)? = providers.sent.last?.request.params { + #expect(params["tool"] == .object(["executable": .string("/selected/tool")])) + } else { Issue.record("Preparation must carry the selected build tool") } + if mode == "cancel" { task.cancel() } + if mode == "configuration" { harness.service.setOption(Self.instanceID, optionID: "features", value: .array([.string("changed")])) } + if mode == "workspace" { harness.service.reset(workspaceURL: nil) } + release.open() + try #require(await finished.waitUntilOpen()) + await task.value + #expect((error == nil) == (mode == "success")) + if mode == "success" { + guard case .maven(let context)? = prepared else { Issue.record("Successful preparation must return its exact import"); return } + #expect(context.profiles == ["run-only"]) + #expect(context.mavenExecutablePath == "/selected/tool") + } + if ["cancel", "configuration", "workspace"].contains(mode) { #expect(error is CancellationError) } + } + + @Test + func languageRunUsesTheModelForItsTemporaryOptionsWithoutPublishingItAsTheDefaultModel() async throws { + let planner = FakeBuildPlanner() + planner.fingerprint = "o"; planner.importEnabled = true; planner.dynamicOptions = true + let baseline = #"{"projects":[{"id":"p","path":"app","name":"App"}],"contentRoots":[{"projectID":"p","path":"app/spec","kind":"source"}],"optionValues":{"features":["run-only"]}}"# + planner.detection = BuildDetection(instances: [try Self.instance(modelSource: "provider", model: baseline)]) + planner.decodedModel = try JSONDecoder().decode(BuildModel.self, from: Data( + #"{"projects":[{"id":"p","path":"app","name":"App"}],"contentRoots":[{"projectID":"p","path":"app/spec","kind":"testSource"}],"optionValues":{"features":["run-only"]}}"#.utf8)) + planner.providerRequest = Self.providerRequest(method: "lithe/build/execute") + let providers = FakeBuildProviders() + let tasks = ["prepare.main", "prepare.test"].map { DeclarativeBuildSystem.Task(id: $0, displayName: $0, + group: .build, scopes: [.project], fromModel: false, targetTags: [], requiresUserArguments: false, description: nil, + preparation: .init(purpose: "run", sourceRoles: $0 == "prepare.main" ? ["source", "generatedSource"] : ["testSource", "generatedTestSource"])) } + let harness = Harness(planner: planner, sources: [fixtureSource(tasks: tasks)], providers: providers) + harness.service.reset(workspaceURL: Self.workspace) + try #require(await harness.waitForEntries { !$0.isEmpty }) + let finished = BuildTestGate() + var result: Result? + let task = Task { + defer { finished.open() } + do { result = .success(try await harness.service.prepareLanguageRun( + fileURL: Self.workspace.appendingPathComponent("app/spec/Main.java"), workspace: Self.workspace, + purpose: "run", optionOverrides: ["features": .array([.string("run-only")])], + tool: { _ in BuildProviderHints(toolExecutable: URL(fileURLWithPath: "/selected/maven")) }, isCurrent: { true })) } + catch { result = .failure(error) } + } + defer { task.cancel(); harness.service.stop() } + try #require(await finished.waitUntilOpen()) + await task.value + guard case .maven(let context)? = try #require(result).get() else { Issue.record("Expected the task import"); return } + #expect(context.profiles == ["run-only"]) + #expect(planner.taskRequests.last?.task == "prepare.test") + #expect(planner.decodedModelOutputs.count == 1) + #expect(planner.modelPlanHints.last?.toolExecutable?.path == "/selected/maven") + #expect(harness.service.entries.first?.model?.contentRoots.first?.kind == "source") + #expect(harness.optionsStore.values["fixture"]?["features"] == nil) + } + + @Test + func languagePreparationAndTestOwnershipAcceptTheCanonicalSourceOfALinkedWorkspace() async throws { + let files = FileManager.default + let scratch = files.temporaryDirectory.appendingPathComponent("build-linked-workspace-\(UUID().uuidString)") + let actual = scratch.appendingPathComponent("actual") + let workspace = scratch.appendingPathComponent("link") + try files.createDirectory(at: actual.appendingPathComponent("app/spec"), withIntermediateDirectories: true) + defer { try? files.removeItem(at: scratch) } + try files.createSymbolicLink(at: workspace, withDestinationURL: actual) + let source = actual.appendingPathComponent("app/spec/Main.java").resolvingSymlinksInPath() + try Data("class Main {}".utf8).write(to: source) + let planner = FakeBuildPlanner() + planner.fingerprint = "o" + planner.detection = BuildDetection(instances: [try Self.instance(modelSource: "static", model: + #"{"projects":[{"id":"p","path":"app","name":"App"}],"contentRoots":[{"projectID":"p","path":"app/spec","kind":"testSource"}]}"#)]) + planner.providerRequest = Self.providerRequest(method: "lithe/build/execute") + let providers = FakeBuildProviders() + let tasks = ["prepare.main", "prepare.test"].map { DeclarativeBuildSystem.Task(id: $0, displayName: $0, + group: .build, scopes: [.project], fromModel: false, targetTags: [], requiresUserArguments: false, description: nil, + preparation: .init(purpose: "run", sourceRoles: $0 == "prepare.main" ? ["source", "generatedSource"] : ["testSource", "generatedTestSource"])) } + let harness = Harness(planner: planner, sources: [fixtureSource(testSelectorKinds: ["org.example.semantic"], tasks: tasks)], providers: providers) + defer { harness.service.stop() } + harness.service.reset(workspaceURL: workspace) + try #require(await harness.waitForEntries { !$0.isEmpty }) + let entry = try #require(harness.service.languageTestEntries(selectorKind: "org.example.semantic", fileURL: source, workspace: workspace).first) + #expect(harness.service.languageTestProject(in: entry, fileURL: source, workspace: workspace)?.name == "App") + try await harness.service.prepareLanguageRun(fileURL: source, workspace: workspace, + purpose: "run", tool: { _ in nil }, isCurrent: { true }) + #expect(planner.taskRequests.last?.task == "prepare.test") + #expect(planner.taskRequests.last?.project?.path == "app") + } + + @Test(arguments: ["purpose", "role", "ambiguous", "unknownRoot", "ambiguousRole", "ambiguousProject"]) + func languagePreparationRequiresOneExplicitPurposeAndSourceRoleBinding(missing: String) async throws { + let planner = FakeBuildPlanner() + planner.fingerprint = "o"; planner.importEnabled = true + let duplicateProject = missing == "ambiguousProject" ? #",{"id":"other","path":"app","name":"Other"}"# : "" + let duplicateRoot = missing == "ambiguousRole" ? #",{"projectID":"p","path":"app/spec","kind":"source"}"# : "" + let model = """ + {"projects":[{"id":"p","path":"app","name":"App"}\(duplicateProject)], + "contentRoots":[{"projectID":"p","path":"app/spec","kind":"testSource"}\(duplicateRoot)]} + """ + planner.detection = BuildDetection(instances: [try Self.instance(modelSource: "static", model: model)]) + planner.providerRequest = Self.providerRequest(method: "lithe/build/execute") + let providers = FakeBuildProviders() + let count = ["ambiguous", "ambiguousRole"].contains(missing) ? 2 : 1 + let tasks = (0..? + var pids: [Int32] = [] + let task = Task { + defer { finished.open() } + do { result = .success(try await harness.service.performLanguageTests(Self.instanceID, request: BuildTestRequest(), title: "Tests", + output: { _ in }, process: { if let pid = $0 { pids.append(pid) } })) } + catch { result = .failure(error) } + } + defer { task.cancel(); release.open(); harness.service.stop() } + try #require(await providers.requestStarted.waitUntilOpen()) + if cancel { task.cancel() } + release.open() + try #require(await finished.waitUntilOpen()) + await task.value + if cancel { + guard case .failure(let error) = result else { Issue.record("Cancellation must settle the native handoff"); return } + #expect(error is CancellationError) + } else { + let completed = try #require(result).get() + #expect(completed.exitCode == 1) + #expect(completed.reports == [expected]) + #expect(pids.contains(412)) + let reference = try #require(completed.references.values.first) + #expect(reference.projectID == "p") + #expect(reference.suite == "Suite") + let rerun = try await harness.service.performLanguageTests(reference: reference, workspace: Self.workspace, + title: "Rerun", output: { _ in }, process: { _ in }) + #expect(rerun.reports == [expected]) + let requests = providers.sent.count + harness.service.reset(workspaceURL: nil) + await #expect(throws: (any Error).self) { + try await harness.service.performLanguageTests(reference: reference, workspace: Self.workspace, + title: "Revoked", output: { _ in }, process: { _ in }) + } + #expect(providers.sent.count == requests) + } + } + + @Test + func languageTestOwnershipUsesCurrentProjectBoundariesAndRevokesOldWorkspaces() async throws { + let planner = FakeBuildPlanner() + planner.fingerprint = "o" + planner.detection = BuildDetection(instances: [try Self.instance(modelSource: "static", model: + #"{"projects":[{"id":"app","path":"app","name":"App"},{"id":"nested","path":"app/nested","name":"Nested"}]}"#)]) + let harness = Harness(planner: planner, sources: [fixtureSource(testSelectorKinds: ["org.example.semantic"])]) + defer { harness.service.stop() } + harness.service.reset(workspaceURL: Self.workspace) + try #require(await harness.waitForEntries { !$0.isEmpty }) + let source = Self.workspace.appendingPathComponent("app/nested/Test.java") + let entries = harness.service.languageTestEntries(selectorKind: "org.example.semantic", fileURL: source, workspace: Self.workspace) + let entry = try #require(entries.first) + #expect(harness.service.languageTestProject(in: entry, fileURL: source, workspace: Self.workspace)?.name == "Nested") + #expect(harness.service.languageTestEntries(selectorKind: "unclaimed", fileURL: source, workspace: Self.workspace).isEmpty) + #expect(harness.service.languageTestEntries(selectorKind: "org.example.semantic", fileURL: Self.workspace.appendingPathComponent("app-copy/Test.java"), workspace: Self.workspace).isEmpty) + harness.service.reset(workspaceURL: nil) + #expect(harness.service.languageTestEntries(selectorKind: "org.example.semantic", fileURL: source, workspace: Self.workspace).isEmpty) + } + + @Test + func nativePreparationIdentityRejectsOptionsChangedAwayAndBackEvenWithoutModelSharing() async throws { + let planner = FakeBuildPlanner() + planner.importEnabled = true + planner.dynamicOptions = true + planner.detection = BuildDetection(instances: [try Self.instance(modelSource: "static")]) + let harness = Harness(planner: planner) + harness.optionsStore.values["fixture"] = ["features": .array([.string("first")])] + harness.service.reset(workspaceURL: Self.workspace) + let initial = try await harness.service.prepareLanguageImport(workspaceURL: Self.workspace) + let revision = harness.service.inputRevision + harness.service.setOption(Self.instanceID, optionID: "features", value: .array([.string("next")])) + _ = try await harness.service.prepareLanguageImport(workspaceURL: Self.workspace) + harness.service.setOption(Self.instanceID, optionID: "features", value: .array([.string("first")])) + let returned = try await harness.service.prepareLanguageImport(workspaceURL: Self.workspace) + #expect(returned == initial) + #expect(harness.service.inputRevision > revision) + harness.service.stop() + } + private static let workspace = URL(fileURLWithPath: "/workspace/demo") @Test @@ -29,6 +357,58 @@ struct BuildSystemServiceTests { #expect(harness.service.languageImport == next) } + @Test(arguments: [false, true]) + func runPreparationUsesItsBuildRootAndRefusesPendingOrOldWorkspaceInputs(primary: Bool) async throws { + let planner = FakeBuildPlanner() + planner.importEnabled = true + planner.dynamicOptions = true + planner.detection = BuildDetection(instances: [ + try Self.nestedInstance(id: "fixture:a", root: "a", features: ["cli"]), + try Self.nestedInstance(id: "fixture:b", root: "b", features: ["gui"]) + ]) + let harness = Harness(planner: planner) + let configuration = RunConfiguration(id: "main", name: "Main", kind: .javaMain, + modulePath: "b", mainClass: "example.Main", mavenReactorPath: "b") + let operations = MavenContextRunOperations(configuration: configuration) + let (runtime, graph) = try await makeBuildSystemTestRuntime(harness.service, runOperations: operations) + defer { graph.stop() } + harness.optionsStore.values["fixture"] = ["features": .array([.string("cli"), .string("gui")])] + harness.service.reset(workspaceURL: Self.workspace) + _ = try await harness.service.prepareLanguageImport(workspaceURL: Self.workspace) + #expect(harness.service.languageImport?.mavenContext?.reactorPath == "a") + await graph.run.loadProject(at: Self.workspace, files: []) + func launch() { + if primary { graph.run.run(configuration: configuration, currentFileURL: nil) } + else { graph.run.startConfiguration(configuration) } + } + launch() + #expect(operations.callCount == 1) + #expect(operations.context?.reactorPath == "b") + #expect(operations.context?.profiles == ["gui"]) + + // Invalidation is synchronous, before coalesced detection can finish. + // Both Run entry points must report failure without planning old input. + harness.service.setOption("fixture:b", optionID: "features", value: .array([])) + launch() + #expect(operations.callCount == 1) + _ = try await harness.service.prepareLanguageImport(workspaceURL: Self.workspace) + launch() + #expect(operations.callCount == 2) + #expect(operations.context?.profiles == []) + + harness.service.reset(workspaceURL: URL(fileURLWithPath: "/workspace/other")) + launch() + #expect(operations.callCount == 2) + harness.service.reset(workspaceURL: Self.workspace) + harness.sources = [] + harness.service.pluginsChanged() + _ = try await harness.service.prepareLanguageImport(workspaceURL: Self.workspace) + launch() + #expect(operations.callCount == 3) + #expect(operations.context == nil) + await runtime.shutdownAll() + } + @Test func languageImportWaiterCancellationDoesNotCancelSharedDetection() async throws { let planner = FakeBuildPlanner() @@ -1344,8 +1724,8 @@ struct BuildSystemServiceTests { }) } - @Test - func providerRunHandsValidatedPlanToNativeRunAndStopsOnDisable() async throws { + @Test(arguments: [false, true]) + func providerRunHandsValidatedPlanToNativeRunAndStopsOnDisableOrReplacement(replace: Bool) async throws { let (planner, providers, launcher, harness) = try Self.runPreparationHarness() defer { harness.service.stop() } harness.service.reset(workspaceURL: Self.workspace) @@ -1356,7 +1736,8 @@ struct BuildSystemServiceTests { #expect(launcher.launched.count == 1) #expect(launcher.launched.first?.arguments == ["example.Main", "a b"]) #expect(planner.diagnosticOutputs.isEmpty) - harness.sources = [] + if replace { harness.service.pluginPackageWillChange("dev.example") } + else { harness.sources = [] } // Detection may already have removed the model before plugin disable // is delivered. Process ownership must survive that disappearance. harness.service.detect() @@ -1365,6 +1746,22 @@ struct BuildSystemServiceTests { #expect(launcher.stopped == ["prepared-app"]) } + @Test(arguments: [BuildExecution.service, .task]) + func providerTreeRunsKeepExecutionKindAndTypedArguments(execution: BuildExecution) async throws { + let (planner, _, launcher, harness) = try Self.runPreparationHarness() + harness.sources = [fixtureSource(providerRuns: [.init(id: "application", displayName: "Run", + targetTags: ["application"], execution: execution)])] + defer { harness.service.stop() } + harness.service.reset(workspaceURL: Self.workspace) + try #require(await harness.waitForEntries { !$0.isEmpty }) + var request = Self.runRequest + request.userArguments = ["a b", "雪", ""] + try #require(harness.service.runApplication(Self.instanceID, request: request, title: "Main") != nil) + try #require(await harness.waitForRunState { $0 == .succeeded }) + #expect(planner.runLaunchOptions == [BuildLaunchOptions(execution: execution, arguments: request.userArguments)]) + #expect(launcher.lastConfiguration?.execution == (execution == .service ? .service : .task)) + } + @Test func savedRunRoutesOnlyToItsEnabledWorkspaceInstance() async throws { let (_, providers, launcher, harness) = try Self.runPreparationHarness() @@ -1661,13 +2058,21 @@ struct BuildSystemServiceTests { providers.executionResult = BuildProviderExecutionResult(exitCode: exitCode, output: "completed") let runner = FakeBuildRunner() let harness = Harness(planner: planner, runner: runner, providers: providers) + harness.tools.providerEnvironment = ["JAVA_HOME": "/fixture/selected-build-jdk"] defer { harness.service.stop() } harness.service.reset(workspaceURL: Self.workspace) try #require(await harness.waitForEntries { !$0.isEmpty }) - try #require(harness.service.runTask(Self.instanceID, request: BuildTaskRequest(task: "build"), title: "Build") != nil) + let id = try #require(harness.service.runTask(Self.instanceID, request: BuildTaskRequest(task: "build"), title: "Build")) try #require(await harness.waitForRunState { $0 != .running }) + #expect(harness.service.currentRunID == nil) + #expect(harness.service.operation?.id == id) + #expect(harness.service.operation?.exitCode == exitCode) + #expect(harness.service.operation?.output == "completed") #expect(providers.sent.map(\.request.method) == ["lithe/build/execute"]) #expect(providers.sent.first?.target.workspaceURL == Self.workspace) + if case .object(let params)? = providers.sent.first?.request.params { + #expect(params["environment"] == .object(["JAVA_HOME": .string("/fixture/selected-build-jdk")])) + } else { Issue.record("The task must carry the selected runtime environment") } #expect(runner.requests.isEmpty) #expect(harness.service.output.contains("completed")) if exitCode == 0 { #expect(harness.service.runState == .succeeded) } @@ -1776,12 +2181,16 @@ struct BuildSystemServiceTests { providers.executionResult = BuildProviderExecutionResult(exitCode: exitCode, output: "test output") let runner = FakeBuildRunner() let harness = Harness(planner: planner, runner: runner, providers: providers) + harness.tools.providerEnvironment = ["JAVA_HOME": "/fixture/selected-test-jdk"] defer { harness.service.stop() } harness.service.reset(workspaceURL: Self.workspace) try #require(await harness.waitForEntries { !$0.isEmpty }) harness.service.runTests(Self.instanceID, request: BuildTestRequest(projects: ["app"]), title: "Tests") try #require(await harness.waitForRunState { $0 != .running }) #expect(providers.sent.map(\.request.method) == ["lithe/build/test"]) + if case .object(let params)? = providers.sent.first?.request.params { + #expect(params["environment"] == .object(["JAVA_HOME": .string("/fixture/selected-test-jdk")])) + } else { Issue.record("The test must carry the selected runtime environment") } #expect(runner.requests.isEmpty) #expect(harness.service.testResults == expected) #expect(planner.diagnosticOutputs == ["test output"]) @@ -1789,6 +2198,65 @@ struct BuildSystemServiceTests { else { if case .failed = harness.service.runState {} else { Issue.record("Expected failed test exit") } } } + @Test(arguments: [false, true]) + func inlineReportsAreDecodedFromTheExactReplyAndDecodeFailureFailsTheRun(fail: Bool) async throws { + let planner = FakeBuildPlanner() + planner.fingerprint = "o" + planner.detection = BuildDetection(instances: [try Self.instance(modelSource: "provider")]) + planner.providerRequest = Self.providerRequest(method: "lithe/build/test") + planner.testPlanDeclaresResults = true + planner.inlineTestResults = true + let expected = try Self.testResults("failure") + if !fail { planner.decodedTestResults = expected } + let providers = FakeBuildProviders() + let report: ToolingJSONValue = .object(["format": .string("litheTestResults"), "version": .integer(1), "tests": .array([])]) + providers.executionResult = BuildProviderExecutionResult(exitCode: 0, output: "test output", testResults: report) + let harness = Harness(planner: planner, providers: providers) + defer { harness.service.stop() } + harness.service.reset(workspaceURL: Self.workspace) + try #require(await harness.waitForEntries { !$0.isEmpty }) + #expect(harness.service.runTests(Self.instanceID, request: BuildTestRequest(), title: "Tests") != nil) + try #require(await harness.waitForRunState { $0 != .running }) + #expect(planner.testReportInputs.map(\.result) == [report]) + #expect(harness.service.testResults == (fail ? nil : expected)) + if fail { if case .failed = harness.service.runState {} else { Issue.record("A report decode failure must fail the run") } } + else { #expect(harness.service.runState == .succeeded) } + } + + @Test(arguments: [false, true]) + func ordinaryProviderTasksPublishInlineReportsOnlyWhileTheRunStillOwnsThem(cancel: Bool) async throws { + let planner = FakeBuildPlanner() + planner.fingerprint = "o" + planner.detection = BuildDetection(instances: [try Self.instance(modelSource: "provider")]) + planner.providerRequest = Self.providerRequest(method: "lithe/build/execute") + let expected = try Self.testResults("task failure") + planner.decodedTestResults = expected + let gate = GatedBuildDecode() + planner.testResultsGate = gate + let providers = FakeBuildProviders() + let report: ToolingJSONValue = .object(["format": .string("litheTestResults"), "version": .integer(1), "tests": .array([])]) + providers.executionResult = BuildProviderExecutionResult(exitCode: 1, output: "tests failed during package", testResults: report) + let harness = Harness(planner: planner, sources: [fixtureSource(providerTestReports: true)], providers: providers) + defer { gate.releaseDecode(); harness.service.stop() } + harness.service.reset(workspaceURL: Self.workspace) + try #require(await harness.waitForEntries { !$0.isEmpty }) + #expect(harness.service.runTask(Self.instanceID, request: BuildTaskRequest(task: "package"), title: "Package") != nil) + try #require(await gate.entered.waitUntilOpen()) + if cancel { harness.service.stopRun() } + gate.releaseDecode() + try #require(await gate.finished.waitUntilOpen()) + if cancel { + #expect(await harness.waitsForStaleTestResults(expected) == false) + #expect(harness.service.testResults == nil) + #expect(harness.service.runState == .cancelled) + } else { + try #require(await harness.waitForRunState { $0 != .running }) + #expect(harness.service.testResults == expected) + #expect(planner.testReportInputs.map(\.result) == [report]) + if case .failed = harness.service.runState {} else { Issue.record("The task must retain its failing exit code") } + } + } + /// The provider operation remains cancellable while its reports decode. @Test func providerTestOptionsCancelDuringReportDecode() async throws { @@ -1799,6 +2267,7 @@ struct BuildSystemServiceTests { ]) planner.providerRequest = Self.providerRequest(method: "lithe/build/test") planner.testPlanDeclaresResults = true + planner.inlineTestResults = true let stale = try Self.testResults("stale") planner.decodedTestResults = stale let gate = GatedBuildDecode() @@ -2166,6 +2635,7 @@ struct BuildSystemServiceTests { planner.providerRequest = Self.providerRequest(method: "lithe/build/model") let providers = FakeBuildProviders() let harness = Harness(planner: planner, providers: providers) + harness.tools.providerEnvironment = ["JAVA_HOME": "/fixture/selected-model-jdk"] defer { harness.service.stop() } harness.service.reset(workspaceURL: Self.workspace) #expect(await harness.waitForEntries { $0.first?.modelState == .notLoaded }) @@ -2177,7 +2647,7 @@ struct BuildSystemServiceTests { // provider, and the request is planned again with it. #expect(planner.modelPlanHints == [ BuildProviderHints(reason: .initial), - BuildProviderHints(reason: .initial, toolExecutable: FakeToolResolver.executableURL), + BuildProviderHints(reason: .initial, toolExecutable: FakeToolResolver.executableURL, toolEnvironment: harness.tools.providerEnvironment), ]) #expect(harness.tools.resolutions == 1) #expect(providers.sent.count == 1) @@ -2194,6 +2664,7 @@ struct BuildSystemServiceTests { planner.providerRequest = Self.providerRequest(method: "lithe/build/model") let providers = FakeBuildProviders() let harness = Harness(planner: planner, providers: providers) + harness.tools.providerEnvironment = ["JAVA_HOME": "/fixture/selected-refresh-jdk"] defer { harness.service.stop() } harness.service.reset(workspaceURL: Self.workspace) #expect(await harness.waitForEntries { !$0.isEmpty }) @@ -2201,7 +2672,7 @@ struct BuildSystemServiceTests { harness.service.loadModel(Self.instanceID, reason: .refresh) #expect(await harness.waitForEntries { $0.first?.modelState == .loaded }) #expect(planner.modelPlanHints.last == BuildProviderHints( - reason: .refresh, toolExecutable: FakeToolResolver.executableURL + reason: .refresh, toolExecutable: FakeToolResolver.executableURL, toolEnvironment: harness.tools.providerEnvironment )) harness.service.loadDependencies(Self.instanceID, project: nil, reason: .refresh) @@ -2211,7 +2682,7 @@ struct BuildSystemServiceTests { }) #expect(planner.dependencyPlanHints == [ BuildProviderHints(reason: .refresh), - BuildProviderHints(reason: .refresh, toolExecutable: FakeToolResolver.executableURL), + BuildProviderHints(reason: .refresh, toolExecutable: FakeToolResolver.executableURL, toolEnvironment: harness.tools.providerEnvironment), ]) } @@ -2611,7 +3082,7 @@ struct BuildSystemServiceTests { ) throws -> BuildSystemInstance { let featureList = features.map { "\"\($0)\"" }.joined(separator: ", ") let model = """ - {"projects": [{"id": "\(id)", "path": ".", "name": "\(id)"}], + {"projects": [{"id": "\(id)", "path": ".", "name": "\(id)", "properties": {"run.modelVersion": "1"}}], "optionValues": {"features": [\(featureList)]}} """ return try decode(BuildSystemInstance.self, """ @@ -2635,23 +3106,233 @@ struct BuildSystemServiceTests { """) } - private static func testResults(_ message: String) throws -> BuildTestResults { - try decode(BuildTestResults.self, """ - {"tests": [{"suite": "Suite", "case": "case", "status": "failed", "message": "\(message)"}], + private static func testResults(_ message: String, project: String? = nil) throws -> BuildTestResults { + let field = project.map { "\"project\": \"\($0)\", " } ?? "" + return try decode(BuildTestResults.self, """ + {"tests": [{\(field)"suite": "Suite", "case": "case", "status": "failed", "message": "\(message)"}], "summary": {"total": 1, "passed": 0, "failed": 1, "errors": 0, "skipped": 0}, "success": false} """) } } -private func fixtureSource(pluginID: String = "dev.example", testDiscovery: Bool = false, providerRuns: [DeclarativeBuildSystem.ProviderRun] = [], buildSystemID: String = "fixture", sharesModel: Bool = false, modelInputs: [DeclarativeBuildSystem.ModelInput] = []) -> BuildSystemPluginSource { +extension BuildSystemServiceTests { + @Test + func passiveNativeReadDoesNotRunAProjectWrapperOrSilentlyFallBackToProviderDiscovery() async throws { + let harness = try NativeConfigurationHarness() + defer { harness.coordinator.stop() } + harness.tools.executesProjectCode = true + await #expect(throws: (any Error).self) { try await harness.coordinator.read("entry") } + #expect(harness.providers.sent.isEmpty) + #expect(harness.coordinator.state("entry").requiresExplicitRead) + _ = try await harness.coordinator.read("entry", allowProjectTool: true) + #expect(harness.providers.sent.count == 1) + harness.tools.failure = BuildSystemServiceTestsError(message: "Automatic discovery failed") + await #expect(throws: (any Error).self) { try await harness.coordinator.read("entry") } + #expect(harness.providers.sent.count == 1) + #expect(harness.coordinator.state("entry").requiresExplicitRead) + _ = try await harness.coordinator.read("entry", allowProjectTool: true) + #expect(harness.providers.sent.count == 2) + #expect(harness.files.commits == 0) + } + + @Test + func nativeSaveReadBackInvalidatesBuildFactsAndNeverUsesTheDraftAsEffectiveState() async throws { + let harness = try NativeConfigurationHarness() + defer { harness.coordinator.stop() } + let first = try await harness.coordinator.read("entry") + #expect(harness.files.commits == 0) + #expect(harness.providers.sent.first?.request.method == "lithe/build/configuration") + harness.providers.reply = try nativeConfigurationReply(revision: "b", value: "/raw", effective: "/command-line") + let next = try await harness.coordinator.save("entry", snapshot: first, edits: nativeEdits(first)) + #expect(harness.files.commits == 1) + #expect(harness.ownership.configurationRevision == 1) + #expect(next.sources.first?.fields.first?.effectiveValue == .string("/command-line")) + #expect(harness.coordinator.state("entry").snapshot == next) + #expect(!harness.coordinator.state("entry").isStale) + #expect(!harness.coordinator.isBusy) + } + + @Test + func cancelledProviderEditReplyCannotPublishAndItsStagingIsReleased() async throws { + let harness = try NativeConfigurationHarness() + let release = BuildTestGate(), started = BuildTestGate() + defer { release.open(); harness.coordinator.stop() } + let snapshot = try await harness.coordinator.read("entry") + let plugin = harness.ownership.context.source.pluginID + harness.providers.replyGate = release + harness.providers.requestStartedByPluginID[plugin] = started + let saving = Task { try await harness.coordinator.save("entry", snapshot: snapshot, edits: nativeEdits(snapshot)) } + defer { saving.cancel() } + try #require(await started.waitUntilOpen()) + #expect(harness.providers.sent.last?.request.method == "lithe/build/editConfiguration") + harness.coordinator.stop() + release.open() + if case .success = await saving.result { Issue.record("A cancelled provider edit was published") } + #expect(harness.files.commits == 0) + #expect(harness.files.discards == 1) + } + + @Test + func optionABADuringNativePreparationRejectsTheWriteAndRetainsTheOldSnapshot() async throws { + let harness = try NativeConfigurationHarness() + let gate = GatedBuildDecode() + defer { gate.releaseDecode(); harness.coordinator.stop() } + let first = try await harness.coordinator.read("entry") + harness.files.prepareGate = gate + let save = Task { try await harness.coordinator.save("entry", snapshot: first, edits: nativeEdits(first)) } + defer { save.cancel() } + let entered = await gate.entered.waitUntilOpen() + #expect(entered, "Native preparation did not reach its controlled boundary") + harness.ownership.optionsRevision += 2 + gate.releaseDecode() + let result = await save.result + if case .success = result { Issue.record("An obsolete draft was saved") } + #expect(harness.files.commits == 0) + #expect(harness.coordinator.state("entry").snapshot == first) + #expect(harness.coordinator.state("entry").isStale) + } + + @Test + func nativeReadBackFailureKeepsTheDraftStaleUntilExplicitReload() async throws { + let harness = try NativeConfigurationHarness() + defer { harness.coordinator.stop() } + let first = try await harness.coordinator.read("entry") + harness.providers.failure = BuildSystemServiceTestsError(message: "upstream rejected configuration") + await #expect(throws: (any Error).self) { + try await harness.coordinator.save("entry", snapshot: first, edits: nativeEdits(first)) + } + #expect(harness.files.commits == 1) + #expect(harness.coordinator.state("entry").snapshot == first) + #expect(harness.coordinator.state("entry").isStale) + await #expect(throws: (any Error).self) { + try await harness.coordinator.save("entry", snapshot: first, edits: nativeEdits(first)) + } + #expect(harness.files.commits == 1) + harness.providers.failure = nil + harness.providers.reply = try nativeConfigurationReply(revision: "b", value: "/raw", effective: "/raw") + _ = try await harness.coordinator.read("entry") + #expect(!harness.coordinator.state("entry").isStale) + } + + @Test + func nativeStopRejectsCancellationIgnoringProviderReplyFromTheOldWorkspace() async throws { + let harness = try NativeConfigurationHarness() + let gate = BuildTestGate() + defer { gate.open(); harness.coordinator.stop() } + harness.providers.ignoresCancellation = true + harness.providers.replyGate = gate + let read = Task { try await harness.coordinator.read("entry") } + defer { read.cancel() } + #expect(await harness.providers.requestStarted.waitUntilOpen()) + harness.coordinator.stop() + harness.ownership.generation += 1 + gate.open() + if case .success = await read.result { Issue.record("An old workspace published native settings") } + #expect(harness.coordinator.state("entry").snapshot == nil) + #expect(!harness.coordinator.isBusy) + #expect(harness.files.commits == 0) + } + + @Test + func serviceRejectsNativeDraftAfterOptionsChangeBackToTheirOriginalValues() async throws { + let planner = FakeBuildPlanner() + planner.detection = BuildDetection(instances: [try Self.instance(modelSource: "command")]) + let providers = FakeBuildProviders() + providers.reply = try nativeConfigurationReply() + let files = NativeConfigurationFiles() + let harness = Harness(planner: planner, sources: [fixtureSource(nativeConfiguration: true)], + providers: providers, nativeConfigurationFiles: files) + defer { harness.service.stop() } + harness.service.reset(workspaceURL: Self.workspace) + _ = try await harness.service.prepareLanguageImport(workspaceURL: Self.workspace) + let snapshot = try await harness.service.readNativeConfiguration(Self.instanceID) + harness.service.setOption(Self.instanceID, optionID: "release", value: .bool(true)) + harness.service.setOption(Self.instanceID, optionID: "release", value: nil) + await #expect(throws: (any Error).self) { + try await harness.service.saveNativeConfiguration(Self.instanceID, snapshot: snapshot, edits: nativeEdits(snapshot)) + } + #expect(files.commits == 0) + #expect(harness.service.nativeConfigurationState(Self.instanceID).isStale) + } +} + +@MainActor +private final class NativeConfigurationOwnership { + var optionsRevision = 0 + var configurationRevision = 0 + var generation = 0 + var context: BuildNativeConfigurationContext { + BuildNativeConfigurationContext(entryID: "entry", source: fixtureSource(nativeConfiguration: true), + buildSystemID: "fixture", workspace: URL(fileURLWithPath: "/workspace"), generation: generation, + configurationRevision: configurationRevision, optionsRevision: optionsRevision, settings: [:], + plan: BuildPlanContext(root: ".", options: [:], workspaceStateDirectory: URL(fileURLWithPath: "/fixture/state"))) + } +} + +@MainActor +private final class NativeConfigurationHarness { + let ownership = NativeConfigurationOwnership() + let files = NativeConfigurationFiles() + let providers = FakeBuildProviders() + let tools = FakeToolResolver() + let coordinator: BuildNativeConfigurationCoordinator + init() throws { + let ownership = ownership + providers.reply = try nativeConfigurationReply() + providers.repliesByMethod["lithe/build/editConfiguration"] = .object(["sourceID": .string("user"), "revision": .string("sha256:" + String(repeating: "b", count: 64)), "changed": .bool(true)]) + coordinator = BuildNativeConfigurationCoordinator(planner: FakeBuildPlanner(), tools: tools, + providers: providers, files: files, context: { _ in ownership.context }, + didSave: { _ in ownership.configurationRevision += 1 }) + } +} + +private final class NativeConfigurationFiles: BuildNativeConfigurationStoring, @unchecked Sendable { + private let lock = NSLock() + private var committed = 0 + private var discarded = 0 + var discards: Int { lock.withLock { discarded } } + var prepareGate: GatedBuildDecode? + var commits: Int { lock.withLock { committed } } + func prepare(target: BuildSystemTarget, snapshot: BuildNativeConfiguration, + edits: BuildNativeConfigurationEdits, stateDirectory: URL) throws -> BuildNativeConfigurationStaging? { + if let gate = prepareGate { + gate.entered.open() + guard gate.waitForRelease() else { throw GatedDecodeError.releaseTimedOut } + } + return BuildNativeConfigurationStaging(sourceID: edits.sourceID, sourceURL: URL(fileURLWithPath: "/workspace/settings.xml"), + url: URL(fileURLWithPath: "/workspace/settings.xml"), expected: Data(), outputFile: stateDirectory.appendingPathComponent("output")) + } + func complete(_ staging: BuildNativeConfigurationStaging, receipt: BuildNativeConfigurationEditReceipt) throws -> BuildNativeConfigurationPreparedWrite? { + BuildNativeConfigurationPreparedWrite(url: staging.url, expected: staging.expected, + replacement: Data("native bytes".utf8), revision: receipt.revision) + } + func discard(_ staging: BuildNativeConfigurationStaging) throws { lock.withLock { discarded += 1 } } + func commit(_ write: BuildNativeConfigurationPreparedWrite) throws { lock.withLock { committed += 1 } } +} + +private func nativeConfigurationReply(revision: String = "a", value: String = "/before", effective: String = "/before") throws -> ToolingJSONValue { + try decode(ToolingJSONValue.self, """ + {"format":"litheBuildConfiguration","version":1,"sources":[{"id":"user","scope":"user", + "uri":"file:///workspace/settings.xml","revision":"sha256:\(String(repeating: revision, count: 64))", + "writable":true,"fields":[ + {"id":"localRepository","kind":"directory","value":"\(value)","effectiveValue":"\(effective)", + "effectiveSource":"upstream"}]}]} + """) +} + +private func nativeEdits(_ snapshot: BuildNativeConfiguration) -> BuildNativeConfigurationEdits { + BuildNativeConfigurationEdits(sourceID: "user", revision: snapshot.sources[0].revision, values: ["localRepository": .string("/raw")]) +} + +private func fixtureSource(pluginID: String = "dev.example", testDiscovery: Bool = false, providerRuns: [DeclarativeBuildSystem.ProviderRun] = [], buildSystemID: String = "fixture", sharesModel: Bool = false, modelInputs: [DeclarativeBuildSystem.ModelInput] = [], nativeConfiguration: Bool = false, providerTestReports: Bool = false, testSelectorKinds: [String] = [], tasks: [DeclarativeBuildSystem.Task] = []) -> BuildSystemPluginSource { BuildSystemPluginSource( pluginID: pluginID, manifest: Data("{}".utf8), packageURL: URL(fileURLWithPath: "/plugins/\(pluginID)"), buildSystems: [DeclarativeBuildSystem( - id: buildSystemID, displayName: "Fixture", modelSource: .command, tasks: [], - testScopes: [], dependencyScopes: [], testDiscovery: testDiscovery, providerRuns: providerRuns, sharesModel: sharesModel, modelInputs: modelInputs + id: buildSystemID, displayName: "Fixture", modelSource: .command, tasks: tasks, + testScopes: [], dependencyScopes: [], testDiscovery: testDiscovery, providerRuns: providerRuns, sharesModel: sharesModel, modelInputs: modelInputs, nativeConfiguration: nativeConfiguration, providerTestReports: providerTestReports, testSelectorKinds: testSelectorKinds )] ) } @@ -2680,6 +3361,7 @@ private final class Harness { sources: [BuildSystemPluginSource] = [fixtureSource()], providers: (any BuildProviderRequesting)? = nil, runLauncher: (any BuildRunLaunching)? = nil, + nativeConfigurationFiles: (any BuildNativeConfigurationStoring)? = nil, settings: @escaping @MainActor (String) -> [String: ToolingJSONValue] = { _ in [:] }, waitForRedetection: @escaping @Sendable () async throws -> Void = {}, pluginOperations: PluginOperationReporter = .disabled @@ -2695,6 +3377,7 @@ private final class Harness { runner: runner, providers: providers, runLauncher: runLauncher, + nativeConfigurationFiles: nativeConfigurationFiles, settings: settings, waitForRedetection: waitForRedetection, sources: { enabledSources.value } @@ -2912,9 +3595,12 @@ private final class FakeBuildPlanner: BuildSystemPlanning, @unchecked Sendable { var detectGate: GatedBuildDecode? var decodedTestResults: BuildTestResults? var testPlanDeclaresResults = false + var inlineTestResults = false + var testReportInputs: [BuildTestResultsRequest] = [] var discovery: BuildTestDiscovery? var discoveryGate: GatedBuildDecode? var runPreparationGate: GatedBuildDecode? + var runLaunchOptions: [BuildLaunchOptions?] = [] var testRequests: [BuildTestRequest] = [] var optionsResolution: BuildOptionsResolution? var loadedOptionsResolution: BuildOptionsResolution? @@ -2924,8 +3610,10 @@ private final class FakeBuildPlanner: BuildSystemPlanning, @unchecked Sendable { var modelOptionValues: [String: [String]]? var fingerprint = "initial" var decodedModelOutputs: [BuildCommandOutput] = [] + var decodedModel: BuildModel? var diagnosticOutputs: [String] = [] var taskPlanContexts: [BuildPlanContext] = [] + var taskRequests: [BuildTaskRequest] = [] var modelInputSources: [[BuildModelInputSource]] = [] var modelPlanHints: [BuildProviderHints] = [] var modelPlanFailure: (any Error)? @@ -2956,6 +3644,7 @@ private final class FakeBuildPlanner: BuildSystemPlanning, @unchecked Sendable { get { access { $0.runPreparationGate } } set { access { $0.runPreparationGate = newValue } } } + var runLaunchOptions: [BuildLaunchOptions?] { access { $0.runLaunchOptions } } var decodedDependencyOutputs: [BuildCommandOutput] { access { $0.decodedDependencyOutputs } } var diagnostics: [BuildDiagnostic] { get { access { $0.diagnostics } } @@ -3011,11 +3700,17 @@ private final class FakeBuildPlanner: BuildSystemPlanning, @unchecked Sendable { set { access { $0.discoveryGate = newValue } } } var testRequests: [BuildTestRequest] { access { $0.testRequests } } + var inlineTestResults: Bool { + get { access { $0.inlineTestResults } } + set { access { $0.inlineTestResults = newValue } } + } + var testReportInputs: [BuildTestResultsRequest] { access { $0.testReportInputs } } var testPlanDeclaresResults: Bool { get { access { $0.testPlanDeclaresResults } } set { access { $0.testPlanDeclaresResults = newValue } } } var taskPlanContexts: [BuildPlanContext] { access { $0.taskPlanContexts } } + var taskRequests: [BuildTaskRequest] { access { $0.taskRequests } } /// The provider hints of every model and dependency plan, in order. var modelPlanHints: [BuildProviderHints] { access { $0.modelPlanHints } } /// When set, the model plan fails like a declaration Core cannot plan. @@ -3029,12 +3724,18 @@ private final class FakeBuildPlanner: BuildSystemPlanning, @unchecked Sendable { set { access { $0.fingerprint = newValue } } } var decodedModelOutputs: [BuildCommandOutput] { access { $0.decodedModelOutputs } } + var decodedModel: BuildModel? { + get { access { $0.decodedModel } } + set { access { $0.decodedModel = newValue } } + } var diagnosticOutputs: [String] { access { $0.diagnosticOutputs } } func detect( workspaceURL: URL, sources: [BuildSystemPluginSource], - options: [String: [String: ToolingJSONValue]] + options: [String: [String: ToolingJSONValue]], + instanceOptions: [String: [String: [String: ToolingJSONValue]]], + instanceDefaults: [String: [String: ToolingJSONValue]] ) throws -> BuildDetection { if let gate = access({ let gate = $0.detectGate; $0.detectGate = nil; return gate }) { gate.entered.open() @@ -3074,6 +3775,26 @@ private final class FakeBuildPlanner: BuildSystemPlanning, @unchecked Sendable { return BuildInvocation(executables: [.setting(id: "toolPath", path: configured), .command("fake")]) } + func configurationPlan(_ target: BuildSystemTarget, context: BuildPlanContext, hints: BuildProviderHints) throws -> BuildProviderRequest { + BuildProviderRequest(method: "lithe/build/configuration", params: .object([ + "root": .string(context.root), "options": .object(context.options) + ]), optionFingerprint: "native", executesProjectCode: false, mayUseNetwork: false) + } + + func decodeConfiguration(_ target: BuildSystemTarget, result: ToolingJSONValue) throws -> BuildNativeConfiguration { + try JSONDecoder().decode(BuildNativeConfiguration.self, from: JSONEncoder().encode(result)) + } + + func configurationEditPlan(_ target: BuildSystemTarget, context: BuildPlanContext, hints: BuildProviderHints, + snapshot: BuildNativeConfiguration, edits: BuildNativeConfigurationEdits, outputFile: URL) throws -> BuildProviderRequest { + BuildProviderRequest(method: "lithe/build/editConfiguration", params: .object([:]), + optionFingerprint: "native", executesProjectCode: false, mayUseNetwork: false) + } + func decodeConfigurationEdit(_ target: BuildSystemTarget, snapshot: BuildNativeConfiguration, + edits: BuildNativeConfigurationEdits, result: ToolingJSONValue) throws -> BuildNativeConfigurationEditReceipt { + try JSONDecoder().decode(BuildNativeConfigurationEditReceipt.self, from: JSONEncoder().encode(result)) + } + func checkToolVersion(_ target: BuildSystemTarget, runtime: String?, output: String) throws -> BuildVersionCheck { BuildVersionCheck(satisfied: true, version: nil) } @@ -3158,7 +3879,7 @@ private final class FakeBuildPlanner: BuildSystemPlanning, @unchecked Sendable { func taskPlan(_ target: BuildSystemTarget, context: BuildPlanContext, request: BuildTaskRequest) throws -> BuildOutputPlan { - access { $0.taskPlanContexts.append(context) } + access { $0.taskPlanContexts.append(context); $0.taskRequests.append(request) } if let request = providerRequest { return .provider(withInputs(request, context: context)) } return .process(plan([request.task, "with space"], output: nil)) } @@ -3169,12 +3890,14 @@ private final class FakeBuildPlanner: BuildSystemPlanning, @unchecked Sendable { let declaresResults = access { $0.testPlanDeclaresResults } return BuildTestPlan( execution: providerRequest.map { .provider(withInputs($0, context: context)) } ?? .process(plan(["test"], output: nil)), - results: declaresResults ? .reports(decoder: "junitXml", patterns: []) : nil + results: declaresResults ? (access { $0.inlineTestResults } ? .provider : .reports(decoder: "junitXml", patterns: [])) : nil ) } func prepareRunPlan(_ target: BuildSystemTarget, context: BuildPlanContext, run: String, - project: BuildSelection, selectedTarget: BuildSelection, userArguments: [String]) throws -> BuildProviderRequest { + project: BuildSelection, selectedTarget: BuildSelection, userArguments: [String], + workspaceURL: URL? = nil, launchOptions: BuildLaunchOptions? = nil) throws -> BuildProviderRequest { + access { $0.runLaunchOptions.append(launchOptions) } guard let providerRequest else { throw CancellationError() } return withInputs(providerRequest, context: context) } @@ -3196,7 +3919,7 @@ private final class FakeBuildPlanner: BuildSystemPlanning, @unchecked Sendable { } func decodeRunPreparation(_ target: BuildSystemTarget, workspaceURL: URL, root: String, - result: ToolingJSONValue) throws -> BuildRunPreparation { + result: ToolingJSONValue, launchOptions: BuildLaunchOptions? = nil) throws -> BuildRunPreparation { if let gate = access({ let gate = $0.runPreparationGate; $0.runPreparationGate = nil; return gate }) { gate.entered.open() guard gate.waitForRelease() else { throw GatedDecodeError.releaseTimedOut } @@ -3241,6 +3964,7 @@ private final class FakeBuildPlanner: BuildSystemPlanning, @unchecked Sendable { ) throws -> BuildModel { try waitForBackgroundDecode() access { $0.decodedModelOutputs.append(output) } + if let model = access({ $0.decodedModel }) { return model } let name: String if case .stdout(let text) = output, text == "OLD" || text == "NEW" { name = text @@ -3255,6 +3979,7 @@ private final class FakeBuildPlanner: BuildSystemPlanning, @unchecked Sendable { } func decodeTestResults(_ target: BuildSystemTarget, request: BuildTestResultsRequest) throws -> BuildTestResults { + access { $0.testReportInputs.append(request) } let results = access { $0.decodedTestResults } if let gate = access({ let gate = $0.testResultsGate; $0.testResultsGate = nil; return gate }) { gate.entered.open() @@ -3322,6 +4047,8 @@ private final class FakeToolResolver: BuildToolResolving { static let executableURL = URL(fileURLWithPath: "/tool/bin/fake") /// When set, resolution fails like a build tool that is not installed. var failure: (any Error)? + var executesProjectCode = false + var providerEnvironment: [String: String] = [:] private(set) var resolutions = 0 func resolve( @@ -3335,8 +4062,9 @@ private final class FakeToolResolver: BuildToolResolving { return ResolvedBuildTool( executableURL: Self.executableURL, environment: ["PATH": "/tool/bin"], - executesProjectCode: false, - version: nil + executesProjectCode: executesProjectCode, + version: nil, + providerEnvironment: providerEnvironment ) } } @@ -3345,6 +4073,7 @@ private final class FakeToolResolver: BuildToolResolving { private final class FakeOptionsStore: BuildOptionsStoring { var values: [String: [String: ToolingJSONValue]] = [:] var emptyValues: [String: [String: Set]] = [:] + var instanceValues: [String: [String: [String: ToolingJSONValue]]] = [:] func options(workspaceURL: URL, buildSystemID: String) -> [String: ToolingJSONValue] { values[buildSystemID] ?? [:] @@ -3358,6 +4087,14 @@ private final class FakeOptionsStore: BuildOptionsStoring { emptyValues[buildSystemID] ?? [:] } + func instanceOptions(workspaceURL: URL, buildSystemID: String) -> [String: [String: ToolingJSONValue]] { + instanceValues[buildSystemID] ?? [:] + } + + func setInstanceOptions(_ options: [String: [String: ToolingJSONValue]], workspaceURL: URL, buildSystemID: String) { + instanceValues[buildSystemID] = options.isEmpty ? nil : options + } + func setEmptyMultiSelectOptions(_ options: [String: Set], workspaceURL: URL, buildSystemID: String) { emptyValues[buildSystemID] = options.isEmpty ? nil : options } @@ -3539,6 +4276,7 @@ private final class FakeBuildProviders: BuildProviderRequesting { } var reply: ToolingJSONValue = .object(["projects": .array([])]) + var repliesByMethod: [String: ToolingJSONValue] = [:] var failure: (any Error)? var replyGate: BuildTestGate? var replyGatesByPluginID: [String: BuildTestGate] = [:] @@ -3561,10 +4299,13 @@ private final class FakeBuildProviders: BuildProviderRequesting { defer { requestFinished.open() } if let gate = replyGatesByPluginID[target.pluginID] ?? replyGate, !(await gate.waitUntilOpen(ignoringCancellation: ignoresCancellation)) { throw CancellationError() } + if let override = repliesByMethod[request.method] { return override } if let failure { throw failure } return reply } + var nativeProcessID: Int32? + func processID(for _: BuildProviderTarget) -> Int32? { nativeProcessID } var ignoresCancellation = false var executionResult = BuildProviderExecutionResult(exitCode: 0, output: "completed") var executionProgress: (@MainActor @Sendable (BuildProviderProgress) -> Void)? diff --git a/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift b/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift index 7ca5888129..fbfb7d0a5b 100644 --- a/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift +++ b/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift @@ -8,6 +8,498 @@ import Testing @MainActor struct ExecutionModuleTests { + @Test(arguments: ["launch", "stop", "reset", "disable", "changed", "cancel"]) + func providerDebugKeepsCanonicalInputsAndOwnsItsProcessAcrossCancellation(outcome: String) async throws { + let owner = BuildSystemPluginSource(pluginID: "dev.first", manifest: Data(), packageURL: URL(fileURLWithPath: "/fixture/first"), buildSystems: [ + .init(id: "maven", displayName: "Provider", modelSource: .provider, tasks: [], testScopes: [], dependencyScopes: [], + providerRuns: [.init(id: "service", displayName: "Service", targetTags: ["service"], debugConfigurationType: "example", execution: .service)])]) + let operations = ProviderChoicesRunOperations(withReference: true), gate = ProviderPreparationGate(), process = TestStreamingProcess() + let root = URL(fileURLWithPath: "/workspace", isDirectory: true) + let service = RunService(runtime: TestRuntime(), process: process, processFactory: { TestStreamingProcess() }, + fileAccess: TestRunFileAccess(directories: [root]), preferences: TestRunPreferences(), serverPortParser: TestServerPortParser(), + runConfigurationOperations: operations, executableResolver: TestExecutableResolver(), languageProviderCatalog: .compatibilityFallback, + languageRunProviders: .standard(catalog: .compatibilityFallback)) + service.configureBuildLanguageImportProvider { _, _ in .init(mavenContext: .init(reactorPath: ".", profiles: [], settingsPath: nil, skipTests: false, mavenExecutablePath: nil, javaHomePath: nil)) } + var current = true + var imports = 0 + var cancelled = false + var ended: [String] = [] + let observation = service.providerDebugEnded.sink { ended.append($0) } + service.configureProviderConfigurationRuns(source: { _, _ in owner }) { inputs, _, _, _, _, _, languageImport in + #expect(inputs.launchOptions.debug == .init(adapterID: "dev.adapter/remote", configurationType: "example", port: 51235)) + #expect(inputs.launchOptions.arguments == ["a b", "雪"]) + gate.entered.continuation.yield(()) + let waiter = Task { try await awaitSignal(gate.release.stream) } + defer { waiter.cancel() } + try await waiter.value + cancelled = Task.isCancelled + try await languageImport(.maven(.init(reactorPath: ".", profiles: ["run-only"], settingsPath: nil, skipTests: false, mavenExecutablePath: "/test", javaHomePath: nil))) + return try JSONDecoder().decode(BuildRunPreparation.self, from: Data(#"{"executable":{"kind":"toolchain","value":"project-maven"},"arguments":["prepared-debug"],"workingDirectory":".","environment":{}}"#.utf8)) + } + await service.loadProject(at: root, files: []) + let task = Task { try await service.prepareProviderDebug(operations.configurations[0], debug: { type in + #expect(type == "example") + return .init(adapterID: "dev.adapter/remote", configurationType: type, port: 51235) + }, languageImport: { value in + guard case .maven(let context) = value else { throw CancellationError() } + #expect(context.profiles == ["run-only"]); imports += 1 + }, isCurrent: { current }) } + let feature = RunFeatureModel(service: service) + defer { task.cancel(); gate.release.continuation.yield(()); service.reset(); observation.cancel() } + try await awaitSignal(gate.entered.stream) + #expect(feature.selectedProjectSessionID == nil && feature.isSelectedConfigurationRunning) + if outcome == "stop" { service.stopProviderDebug() } + if outcome == "reset" { service.reset() } + if outcome == "disable" { service.retainProviderConfigurations([]) } + if outcome == "changed" { current = false } + if outcome == "cancel" { task.cancel() } + gate.release.continuation.yield(()) + if outcome == "launch" { + let operation = try await task.value + #expect(service.isProviderDebugActive(operation) && imports == 1) + #expect(process.startRequests.first?.arguments == ["prepared-debug"]) + service.retainProviderConfigurations([]) + #expect(!service.isProviderDebugActive(operation) && !process.isRunning) + #expect(feature.selectedProjectSessionID == nil && !feature.isSelectedConfigurationRunning) + #expect(ended.filter { $0 == operation }.count == 1) + } else { + do { _ = try await task.value; Issue.record("Stale debug preparation launched") } + catch is CancellationError { } + #expect(process.startRequests.isEmpty && !service.isRunning && imports == 0) + #expect(cancelled == (outcome != "changed")) + } + } + + @Test + func providerServicesPrepareIndependentlyAndStoppedRepliesCannotLaunchOverReplacements() async throws { + let firstOwner = BuildSystemPluginSource(pluginID: "dev.first", manifest: Data(), packageURL: URL(fileURLWithPath: "/fixture/first"), buildSystems: []) + let secondOwner = BuildSystemPluginSource(pluginID: "dev.second", manifest: Data(), packageURL: URL(fileURLWithPath: "/fixture/second"), buildSystems: []) + let operations = ProviderChoicesRunOperations() + let root = URL(fileURLWithPath: "/workspace") + var processes: [TestStreamingProcess] = [] + let started = AsyncStream.makeStream(bufferingPolicy: .unbounded) + let first = ProviderPreparationGate(), second = ProviderPreparationGate(), replacement = ProviderPreparationGate() + var calls: [String: Int] = [:] + let service = RunService(runtime: TestRuntime(), process: TestStreamingProcess(), processFactory: { + let process = TestStreamingProcess(); process.onStart = { started.continuation.yield(()) }; processes.append(process); return process + }, fileAccess: TestRunFileAccess(directories: [root]), preferences: TestRunPreferences(), serverPortParser: TestServerPortParser(), + runConfigurationOperations: operations, executableResolver: TestExecutableResolver(), languageProviderCatalog: .compatibilityFallback, + languageRunProviders: .standard(catalog: .compatibilityFallback)) + defer { service.reset(); first.release.continuation.yield(()); second.release.continuation.yield(()); replacement.release.continuation.yield(()); started.continuation.finish() } + service.configureBuildLanguageImportProvider { _, _ in .init(mavenContext: .init(reactorPath: ".", profiles: [], settingsPath: nil, skipTests: false, mavenExecutablePath: nil, javaHomePath: nil)) } + service.configureProviderConfigurationRuns(source: { inputs, _ in inputs.projectPath == "second" ? secondOwner : firstOwner }) { inputs, _, _, _, current, progress, _ in + let count = calls[inputs.projectPath, default: 0]; calls[inputs.projectPath] = count + 1 + let gate = inputs.projectPath == "second" ? second : (count == 0 ? first : replacement) + gate.entered.continuation.yield(()) + // This bounded fake deliberately ignores caller cancellation until its owned gate opens. + let waiter = Task { try await awaitSignal(gate.release.stream) } + defer { waiter.cancel(); gate.finished.continuation.yield(()) } + try await waiter.value + progress(BuildProviderProgress(message: current() ? "current preparation" : "stale preparation")) + return try JSONDecoder().decode(BuildRunPreparation.self, from: Data(#"{"executable":{"kind":"toolchain","value":"project-maven"},"arguments":["prepared"],"workingDirectory":".","environment":{}}"#.utf8)) + } + await service.loadProject(at: root, files: []) + service.runAllServices() + try await awaitSignal(first.entered.stream); try await awaitSignal(second.entered.stream) + #expect(service.hasRunningServices && processes.isEmpty) + let oldSession = try #require(service.moduleSessions.first { $0.configurationID == "first" }) + service.stopModule(oldSession) + second.release.continuation.yield(()) + try await awaitSignal(started.stream) + #expect(processes.count == 1) + #expect(service.moduleSessions.first { $0.configurationID == "second" }?.isRunning == true) + service.startConfiguration(operations.configurations[0]) + try await awaitSignal(replacement.entered.stream) + first.release.continuation.yield(()) + try await awaitSignal(first.finished.stream) + replacement.release.continuation.yield(()) + try await awaitSignal(started.stream) + #expect(processes.count == 2) + #expect(!service.moduleSessions.contains { $0.output.contains("stale preparation") }) + #expect(service.moduleSessions.allSatisfy { $0.isRunning }) + let oldOutput = processes[0].onOutput, oldExit = processes[0].onTermination + let secondSession = try #require(service.moduleSessions.first { $0.configurationID == "second" }) + service.retainProviderConfigurations([firstOwner]) + #expect(service.moduleSessions.first { $0.id == secondSession.id }?.isRunning == false) + #expect(service.moduleSessions.first { $0.configurationID == "first" }?.isRunning == true) + oldOutput?("late stopped output"); oldExit?(7) + processes[1].onOutput?("replacement barrier") + try await awaitTestValue(service.$moduleSessions) { $0.contains { $0.output.contains("replacement barrier") } } + #expect(!service.moduleSessions.contains { $0.output.contains("late stopped output") }) + service.retainProviderConfigurations([]) + #expect(!service.hasRunningServices && processes.allSatisfy { !$0.isRunning }) + } + + @Test(arguments: ["launch", "stop", "reset", "disable"]) + func providerPrimaryPreparationRetainsIdentityAndRejectsStoppedOrResetResults(outcome: String) async throws { + let firstOwner = BuildSystemPluginSource(pluginID: "dev.first", manifest: Data(), packageURL: URL(fileURLWithPath: "/fixture/first"), buildSystems: []) + let secondOwner = BuildSystemPluginSource(pluginID: "dev.second", manifest: Data(), packageURL: URL(fileURLWithPath: "/fixture/second"), buildSystems: []) + let operations = ProviderChoicesRunOperations(), gate = ProviderPreparationGate() + let root = URL(fileURLWithPath: "/workspace", isDirectory: true), process = TestStreamingProcess() + let started = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) + process.onStart = { started.continuation.yield(()) } + let service = RunService(runtime: TestRuntime(), process: process, processFactory: { TestStreamingProcess() }, fileAccess: TestRunFileAccess(directories: [root]), + preferences: TestRunPreferences(), serverPortParser: TestServerPortParser(), runConfigurationOperations: operations, + executableResolver: TestExecutableResolver(), languageProviderCatalog: .compatibilityFallback, + languageRunProviders: .standard(catalog: .compatibilityFallback)) + defer { service.reset(); gate.release.continuation.yield(()); started.continuation.finish() } + service.configureBuildLanguageImportProvider { _, _ in .init(mavenContext: .init(reactorPath: ".", profiles: [], settingsPath: nil, skipTests: false, mavenExecutablePath: nil, javaHomePath: nil)) } + service.configureProviderConfigurationRuns(source: { _, _ in firstOwner }) { inputs, _, _, _, _, _, _ in + #expect(inputs.launchOptions.arguments == ["a b", "雪"]) + gate.entered.continuation.yield(()) + let waiter = Task { try await awaitSignal(gate.release.stream) } + defer { waiter.cancel(); gate.finished.continuation.yield(()) } + try await waiter.value + return try JSONDecoder().decode(BuildRunPreparation.self, from: Data(#"{"executable":{"kind":"toolchain","value":"project-maven"},"arguments":["prepared"],"workingDirectory":".","environment":{}}"#.utf8)) + } + await service.loadProject(at: root, files: []) + service.run(configuration: operations.configurations[0], currentFileURL: nil) + let identity = service.primaryExecutionID + try await awaitSignal(gate.entered.stream) + #expect(service.isRunning && service.isPreparingBuildRun) + if outcome == "stop" { service.stop() } + if outcome == "reset" { service.reset() } + if outcome == "disable" { service.retainProviderConfigurations([]) } + gate.release.continuation.yield(()) + if outcome == "launch" { + try await awaitSignal(started.stream) + #expect(service.primaryExecutionID == identity) + #expect(process.startRequests.first?.arguments == ["prepared"]) + #expect(service.isRunning && !service.isPreparingBuildRun) + service.retainProviderConfigurations([firstOwner, secondOwner]) + #expect(service.isRunning) + service.retainProviderConfigurations([]) + #expect(!service.isRunning && !process.isRunning) + } else { + try await awaitSignal(gate.finished.stream) + #expect(process.startRequests.isEmpty && !service.isRunning) + } + } + + @Test + func staleProviderGenerationBlocksPrimaryServicesAndAgentPathsAndClearsAfterABA() async throws { + let operations = FreshnessRecordingRunConfigurationOperations() + operations.staleProviderModel = true + let primary = TestStreamingProcess() + var created = 0 + let service = RunService(runtime: TestRuntime(), process: primary, + processFactory: { created += 1; return TestStreamingProcess() }, fileAccess: TestRunFileAccess(), + preferences: TestRunPreferences(), serverPortParser: TestServerPortParser(), runConfigurationOperations: operations, + executableResolver: TestExecutableResolver(), languageProviderCatalog: .compatibilityFallback, + languageRunProviders: .standard(catalog: .compatibilityFallback)) + defer { service.reset() } + var pending = false + service.configureBuildProjectOwnership { + if pending { throw CancellationError() } + return [] + } + await service.loadProject(at: URL(fileURLWithPath: "/workspace"), files: []) + let configuration = try #require(service.configurations.first { $0.id == "Custom" }) + #expect(service.configurationDiagnostics.contains { $0.code == "staleProviderModel" }) + service.run(configuration: configuration, currentFileURL: nil) + #expect(!service.isRunning) + service.startConfiguration(configuration) + #expect(service.moduleSessions.first?.exitCode == 1) + service.runAllServices() + #expect(created == 0) + #expect(throws: (any Error).self) { try service.validateProviderGeneration(for: configuration) } + operations.staleProviderModel = false + await service.refreshProviderGeneration() + #expect(!service.configurationDiagnostics.contains { $0.code == "staleProviderModel" }) + pending = true + await service.refreshProviderGeneration() + #expect(service.configurationDiagnostics.contains { $0.code == "providerModelNotReady" }) + #expect(throws: (any Error).self) { try service.validateProviderGeneration(for: configuration) } + pending = false + await service.refreshProviderGeneration() + #expect(!service.configurationDiagnostics.contains { ["staleProviderModel", "providerModelNotReady"].contains($0.code) }) + try service.validateProviderGeneration(for: configuration) + #expect(!operations.providerInspections.isEmpty) + #expect(created == 0) + } + + @Test + func genericProviderOnlyInventoryRefreshesTheIndependentRunSnapshot() async throws { + let operations = FreshnessRecordingRunConfigurationOperations() + let reference = BuildProviderRunReference(pluginID: "dev.example", buildSystemID: "engine", root: ".", + run: "serve", projectID: "opaque/project", targetID: "opaque/target", execution: .service) + operations.providerConfiguration = RunConfiguration(id: "build.provider:generated", name: "Service", kind: .process(provider: "build.provider"), + execution: .service, modulePath: nil, mainClass: nil, buildProvider: reference) + let service = RunService(runtime: TestRuntime(), process: TestStreamingProcess(), + processFactory: { TestStreamingProcess() }, fileAccess: TestRunFileAccess(), + preferences: TestRunPreferences(), serverPortParser: TestServerPortParser(), runConfigurationOperations: operations, + executableResolver: TestExecutableResolver(), languageProviderCatalog: .compatibilityFallback, + languageRunProviders: .standard(catalog: .compatibilityFallback)) + defer { service.reset() } + service.configureBuildProjectOwnership { [] } + var runs = [RunProviderConfiguration(projectPath: ".", name: "Service", reference: reference)] + service.configureProviderRunConfigurations { runs } + await service.loadProject(at: URL(fileURLWithPath: "/workspace"), files: []) + #expect(operations.runInspections.last == runs) + operations.staleProviderModel = true + runs = [] + await service.refreshProviderGeneration() + #expect(operations.runInspections.last == []) + #expect(service.configurationDiagnostics.contains { $0.code == "staleProviderModel" }) + operations.staleProviderModel = false + runs = [RunProviderConfiguration(projectPath: ".", name: "Service", reference: reference)] + await service.refreshProviderGeneration() + #expect(!service.configurationDiagnostics.contains { $0.code == "staleProviderModel" }) + } + + @Test(arguments: ["task", "workspace", "model", "reset", "new-generation"]) + func generationPublicationLeaseIsRevokedBeforeAnObsoleteWorkerCanPublish(change: String) async throws { + let operations = FreshnessRecordingRunConfigurationOperations() + let service = RunService(runtime: TestRuntime(), process: TestStreamingProcess(), + processFactory: { TestStreamingProcess() }, fileAccess: TestRunFileAccess(), + preferences: TestRunPreferences(), serverPortParser: TestServerPortParser(), runConfigurationOperations: operations, + executableResolver: TestExecutableResolver(), languageProviderCatalog: .compatibilityFallback, + languageRunProviders: .standard(catalog: .compatibilityFallback)) + service.configureBuildProjectOwnership { [] } + service.configureProviderRunConfigurations { [] } + let gate = InventoryInspectionGate() + defer { service.reset(); gate.release.signal(); gate.entered.continuation.finish() } + await service.loadProject(at: URL(fileURLWithPath: "/old"), files: [], snapshotID: UUID()) + operations.generationGate = gate + let worker = Task { await service.generateRunConfigurations() } + defer { worker.cancel(); gate.release.signal() } + try await awaitSignal(gate.entered.stream) + let lease = try #require(operations.generationLeases.first) + operations.generationGate = nil + if change == "task" { worker.cancel() } + else if change == "workspace" { await service.loadProject(at: URL(fileURLWithPath: "/new"), files: [], snapshotID: UUID()) } + else if change == "model" { service.requestProviderGenerationInspection() } + else if change == "reset" { service.reset() } + else { await service.generateRunConfigurations() } + #expect(!lease.isCurrent) + let status = service.configurationStatus, state = service.generationState, ids = service.configurations.map(\.id) + gate.release.signal() + await worker.value + #expect(service.configurationStatus == status) + #expect(service.generationState == state) + #expect(service.configurations.map(\.id) == ids) + #expect(!service.isLoadingProject) + } + + @Test(arguments: ["inputs", "workspace", "reset"]) + func lateProviderInspectionCannotReplaceNewerInputWorkspaceOrReset(change: String) async throws { + let operations = FreshnessRecordingRunConfigurationOperations() + let service = RunService(runtime: TestRuntime(), process: TestStreamingProcess(), + processFactory: { TestStreamingProcess() }, fileAccess: TestRunFileAccess(), + preferences: TestRunPreferences(), serverPortParser: TestServerPortParser(), runConfigurationOperations: operations, + executableResolver: TestExecutableResolver(), languageProviderCatalog: .compatibilityFallback, + languageRunProviders: .standard(catalog: .compatibilityFallback)) + service.configureBuildProjectOwnership { [] } + let gate = InventoryInspectionGate() + defer { service.reset(); gate.release.signal(); gate.entered.continuation.finish() } + await service.loadProject(at: URL(fileURLWithPath: "/old"), files: []) + operations.providerInspectionGate = gate + let old = Task { await service.refreshProviderGeneration() } + defer { old.cancel(); gate.release.signal() } + try await awaitSignal(gate.entered.stream) + operations.providerInspectionGate = nil + operations.staleProviderModel = true + if change == "inputs" { await service.refreshProviderGeneration() } + else if change == "workspace" { await service.loadProject(at: URL(fileURLWithPath: "/new"), files: []) } + else { service.reset() } + let expected = service.configurationDiagnostics + gate.release.signal() + await old.value + #expect(service.configurationDiagnostics == expected) + if change != "reset" { #expect(expected.contains { $0.code == "staleProviderModel" }) } + } + + @Test + func providerGenerationNotificationsCoalesceBehindOneOwnedInspection() async throws { + let operations = FreshnessRecordingRunConfigurationOperations() + let service = RunService(runtime: TestRuntime(), process: TestStreamingProcess(), + processFactory: { TestStreamingProcess() }, fileAccess: TestRunFileAccess(), + preferences: TestRunPreferences(), serverPortParser: TestServerPortParser(), runConfigurationOperations: operations, + executableResolver: TestExecutableResolver(), languageProviderCatalog: .compatibilityFallback, + languageRunProviders: .standard(catalog: .compatibilityFallback)) + service.configureBuildProjectOwnership { [] } + let gate = InventoryInspectionGate() + defer { service.reset(); gate.release.signal(); gate.entered.continuation.finish() } + await service.loadProject(at: URL(fileURLWithPath: "/workspace"), files: []) + let baseline = operations.providerInspections.count + operations.providerInspectionGate = gate + service.requestProviderGenerationInspection() + try await awaitSignal(gate.entered.stream) + operations.providerInspectionGate = nil + operations.staleProviderModel = true + for _ in 0..<100 { service.requestProviderGenerationInspection() } + #expect(operations.providerInspections.count == baseline + 1, "No second worker may start while the first is held") + gate.release.signal() + try await awaitTestValue(service.$configurationDiagnostics) { $0.contains { $0.code == "staleProviderModel" } } + #expect(operations.providerInspections.count == baseline + 2, "All pending changes need only one current read") + } + + @Test + func backendTestsPreserveProjectIdentityAndRerunWithFreshFactsWithoutAHostProcess() async throws { + var processes = 0 + let service = LanguageTestService(executableResolver: TestExecutableResolver(), processFactory: { + processes += 1; return TestStreamingProcess() + }) + defer { service.reset() } + let report = try JSONDecoder().decode(BuildTestResults.self, from: Data(#"{"tests":[{"project":"a","suite":"Same","case":"checks","status":"failed"},{"project":"b","suite":"Same","case":"checks","status":"passed"}],"summary":{"total":2,"passed":1,"failed":1,"errors":0,"skipped":0},"success":false}"#.utf8)) + var requests = 0 + service.runUsingBackend(providerID: "java", scope: .workspace, workspaceURL: URL(fileURLWithPath: "/workspace"), label: "Tests") { output, _ in + requests += 1 + output("request \(requests)") + return LanguageTestBackendResult(exitCode: 1, reports: [report]) + } + try await awaitTestValue(service.$state) { $0 == .failed(exitCode: 1) } + #expect(service.results?.summary.total == 2) + #expect(service.testOutcomes(for: "java").map(\.project) == ["a", "b"]) + #expect(service.canRerun) + #expect(service.rerun()) + try await awaitTestValue(service.$state) { $0 == .failed(exitCode: 1) } + #expect(requests == 2) + #expect(service.output == "request 2") + #expect(service.testOutcomes(for: "java").count == 2) + #expect(processes == 0) + } + + @Test + func backendReportsPreserveInvocationSummariesSourceLocationsAndOpaqueRerunReferences() async throws { + let service = LanguageTestService(executableResolver: TestExecutableResolver(), processFactory: { TestStreamingProcess() }) + defer { service.reset() } + let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + let report = try JSONDecoder().decode(BuildTestResults.self, from: Data(contentsOf: + root.appendingPathComponent("shared/fixtures/build-provider/test-results-v1.json"))) + let second = BuildTestResults(tests: [.init(project: "other", suite: "other", name: "repeated", status: .passed, invocations: 3)], + summary: .init(total: 1, passed: 1, failed: 0, errors: 0, skipped: 0), success: true) + let test = report.tests[1] + let reference = BuildTestReference(pluginID: "org.example.build", buildSystemID: "example", root: ".", + projectID: "project-token", suite: "opaque-suite", testCase: "opaque-case") + service.runUsingBackend(providerID: "example", scope: .workspace, workspaceURL: URL(fileURLWithPath: "/workspace"), label: "Tests") { _, _ in + LanguageTestBackendResult(exitCode: 1, reports: [report, second], references: [BuildTestCaseKey(test): reference]) + } + try await awaitTestValue(service.$state) { $0 == .failed(exitCode: 1) } + let result = try #require(service.results) + #expect(result.tests == report.tests + second.tests) + #expect(result.summary == .init(total: 5, passed: 3, failed: 0, errors: 1, skipped: 1)) + #expect(result.tests.first?.invocations == 2) + #expect(result.tests.last?.invocations == 3) + #expect(!result.success) + #expect(service.resultReferences[BuildTestCaseKey(test)] == reference) + #expect(service.sourceURL(for: test)?.path == "/workspace/tests/checkout.spec") + let wire = try #require(JSONSerialization.jsonObject(with: JSONEncoder().encode(test)) as? [String: Any]) + #expect(wire["suite"] as? String == "checkout / HTTP") + #expect(wire["case"] as? String == "accepts(payload)[2]") + #expect(wire["className"] == nil) + #expect(wire["method"] == nil) + #expect(try JSONDecoder().decode(BuildTestResults.TestCase.self, from: JSONEncoder().encode(test)) == test) + service.clearOutput() + #expect(service.resultReferences.isEmpty) + #expect(service.sourceURL(for: test) == nil) + #expect(service.testOutcomes(for: "example") == report.tests + second.tests) + } + + @Test + func genericReportHistoryNeverCrossesLanguageProviderBoundaries() async throws { + let service = LanguageTestService(executableResolver: TestExecutableResolver(), processFactory: { TestStreamingProcess() }) + defer { service.reset() } + func run(_ provider: String, status: BuildTestResults.TestCase.Status) async throws { + let report = BuildTestResults(tests: [.init(suite: "Same", name: "checks", status: status)], + summary: .init(total: 1, passed: status == .passed ? 1 : 0, failed: status == .failed ? 1 : 0, errors: 0, skipped: 0), success: status == .passed) + service.runUsingBackend(providerID: provider, scope: .workspace, workspaceURL: URL(fileURLWithPath: "/workspace"), label: "Tests") { _, _ in + LanguageTestBackendResult(exitCode: 0, reports: [report]) + } + try await awaitTestValue(service.$state) { $0 == .passed } + } + try await run("java", status: .failed) + try await run("example", status: .passed) + #expect(service.testOutcomes(for: "java").first?.status == .failed) + #expect(service.testOutcomes(for: "example").first?.status == .passed) + service.closeRun() + #expect(service.testOutcomes(for: "java").count == 1) + #expect(service.testOutcomes(for: "example").count == 1) + } + + @Test + func backendCoverageClearsRemovedClassMethodsButPreservesMethodSiblingsAndOtherProjects() async throws { + let service = LanguageTestService(executableResolver: TestExecutableResolver(), processFactory: { TestStreamingProcess() }) + defer { service.reset() } + func run(_ tests: [[String: String]], coverage: [[String: String]]? = nil) async throws { + var json: [String: Any] = ["tests": tests, "summary": ["total": tests.count, "passed": tests.count, "failed": 0, "errors": 0, "skipped": 0], "success": true] + if let coverage { json["coverage"] = coverage } + let report = try JSONDecoder().decode(BuildTestResults.self, from: JSONSerialization.data(withJSONObject: json)) + service.runUsingBackend(providerID: "java", scope: .workspace, workspaceURL: URL(fileURLWithPath: "/workspace"), label: "Tests") { _, _ in + LanguageTestBackendResult(exitCode: 0, reports: [report]) + } + try await awaitTestValue(service.$state) { $0 == .passed } + } + func test(_ project: String, _ method: String) -> [String: String] { + ["project": project, "suite": "Same", "case": method, "status": "passed"] + } + try await run([test("a", "old"), test("a", "kept"), test("b", "old")]) + try await run([test("a", "kept")], coverage: [["project": "a", "suite": "Same", "case": "kept"]]) + #expect(service.testOutcomes(for: "java").count == 3) + try await run([test("a", "new")], coverage: [["project": "a", "suite": "Same"]]) + #expect(service.testOutcomes(for: "java").map { ($0.project ?? "") + ":" + $0.name }.sorted() == ["a:new", "b:old"]) + try await run([], coverage: [["project": "a", "suite": "Same"]]) + #expect(service.testOutcomes(for: "java").map(\.project) == ["b"]) + } + + @Test + func backendTestsIgnoreCancelledRequestsThatReturnAfterReplacement() async throws { + let service = LanguageTestService(executableResolver: TestExecutableResolver(), processFactory: { TestStreamingProcess() }) + var observedProcesses: [Int32] = [] + let observation = service.$processID.sink { if let pid = $0 { observedProcesses.append(pid) } } + defer { observation.cancel() } + let entered = AsyncStream.makeStream() + let release = AsyncStream.makeStream() + let finished = AsyncStream.makeStream() + defer { service.reset(); entered.continuation.finish(); release.continuation.finish(); finished.continuation.finish() } + service.runUsingBackend(providerID: "java", scope: .workspace, workspaceURL: URL(fileURLWithPath: "/old"), label: "Old") { output, process in + entered.continuation.yield(()) + for await _ in release.stream { break } + process(999) + output("late old output") + finished.continuation.yield(()) + return LanguageTestBackendResult(exitCode: 3, reports: []) + } + try await awaitSignal(entered.stream) + service.stop() + #expect(service.state == .cancelled) + service.runUsingBackend(providerID: "java", scope: .workspace, workspaceURL: URL(fileURLWithPath: "/new"), label: "New") { output, process in + process(412) + output("new output") + return LanguageTestBackendResult(exitCode: 0, reports: []) + } + release.continuation.yield(()) + try await awaitSignal(finished.stream) + try await awaitTestValue(service.$state) { $0 == .passed } + #expect(service.output == "new output") + #expect(service.errorMessage == nil) + #expect(observedProcesses.contains(412)) + #expect(!observedProcesses.contains(999)) + #expect(service.processID == nil) + } + + @Test + func projectLoadingUsesProviderFactsAndRevokesTheirProjectionWhenTheModelDisappears() async { + let root = URL(fileURLWithPath: "/workspace", isDirectory: true) + let graph = makeTestGraph(runOperations: TestReadyRunConfigurationOperations()) + defer { graph.stop() } + var models = [BuildModel(projects: [.init(id: "module", path: "apps/service", name: "Service")], + optionValues: ["profiles": ["qa", "ci", "qa"]])] + graph.run.configureBuildModels { models } + // POM files are inventory entries. Opening them cannot create another + // Maven scanner, run its toolchain, or inject a second project model. + await graph.projectDevelopment.loadProject(at: root, files: [root.appendingPathComponent("pom.xml")], snapshotID: UUID()) + #expect(graph.run.hasReadyInventory(for: root)) + #expect(graph.run.sourceSearchRoots == [root, root.appendingPathComponent("apps/service", isDirectory: true)]) + #expect(graph.run.mavenProfiles.map(\.id) == ["ci", "qa"]) + models = [] + #expect(graph.run.sourceSearchRoots == [root]) + #expect(graph.run.mavenProfiles.isEmpty) + await graph.projectDevelopment.loadProject(at: URL(fileURLWithPath: "/other"), files: [], snapshotID: UUID()) + #expect(graph.run.sourceSearchRoots.map(\.path) == ["/other"]) + } + @Test func providerLaunchUsesNativeProcessAndOldOwnerCannotStopReplacement() async throws { let process = TestStreamingProcess() @@ -53,7 +545,7 @@ struct ExecutionModuleTests { @Test(arguments: [false, true]) func providerLaunchFailureLeavesNoRunningProcess(invalidDirectory: Bool) throws { let process = TestStreamingProcess() - process.startFailure = ReloadTestError.failed + process.startFailure = RunFixtureError.failed let root = URL(fileURLWithPath: "/workspace", isDirectory: true) let service = RunService(runtime: TestRuntime(), process: process, processFactory: { TestStreamingProcess() }, fileAccess: TestRunFileAccess(directories: invalidDirectory ? [] : [root]), @@ -116,7 +608,7 @@ struct ExecutionModuleTests { #expect(calls == [reference, reference]) #expect(process.startRequests.count == 2) #expect(feature.isSelectedConfigurationRunning) - await service.loadProject(at: URL(fileURLWithPath: "/other"), files: [], mavenProject: nil) + await service.loadProject(at: URL(fileURLWithPath: "/other"), files: []) #expect(!process.isRunning) #expect(!service.hasBuildProviderRun) #expect(service.lastConfiguration == nil) @@ -130,12 +622,16 @@ struct ExecutionModuleTests { mavenReactorPath: reactor) let operations = MavenContextRunOperations(configuration: configuration) let graph = makeTestGraph(runOperations: operations) - defer { graph.run.reset(); graph.maven.reset() } + defer { graph.run.reset() } let context = MavenLaunchContext(reactorPath: "services/alpha", profiles: ["dev"], settingsPath: "/fixture/settings.xml", skipTests: true, mavenExecutablePath: nil, javaHomePath: nil) - graph.run.configureMavenContextProvider { context } - await graph.run.loadProject(at: URL(fileURLWithPath: "/workspace"), files: [], mavenProject: nil) + graph.run.configureBuildLanguageImportProvider { workspace, requestedRoot in + #expect(workspace.path == "/workspace") + #expect(requestedRoot == reactor) + return .init(mavenContext: context) + } + await graph.run.loadProject(at: URL(fileURLWithPath: "/workspace"), files: []) #expect(graph.run.defaultConfigurationID == configuration.id) graph.run.startConfiguration(configuration) #expect(operations.called) @@ -144,184 +640,6 @@ struct ExecutionModuleTests { #expect(graph.run.moduleSessions.first?.exitCode == 1) } - @Test - func mavenInventoryRefreshReusesAcceptedProject() async { - let operations = ReloadMavenOperations() - let graph = makeTestGraph(mavenOperations: operations) - let root = URL(fileURLWithPath: "/workspace", isDirectory: true) - defer { graph.maven.reset(); graph.run.reset() } - - await graph.maven.loadProject(at: root, files: [root.appendingPathComponent("old")]) - await graph.maven.loadProject(at: root, files: [root.appendingPathComponent("inventory")]) - - #expect(operations.scanCount == 1) - #expect(graph.maven.project?.artifactID == "old") - #expect(graph.maven.projectState == .ready) - #expect(!graph.maven.isLoadingProject) - } - - @Test - func mavenInventoryDescriptorChangeWaitsForExplicitReload() async { - let operations = ReloadMavenOperations() - let graph = makeTestGraph(mavenOperations: operations) - let root = URL(fileURLWithPath: "/workspace", isDirectory: true) - let pom = root.appendingPathComponent("pom.xml") - defer { graph.maven.reset(); graph.run.reset() } - - await graph.maven.loadProject( - at: root, - files: [root.appendingPathComponent("old"), pom] - ) - await graph.maven.loadProject( - at: root, - files: [root.appendingPathComponent("new"), pom, root.appendingPathComponent("module/pom.xml")] - ) - - #expect(operations.scanCount == 1) - #expect(graph.maven.project?.artifactID == "old") - #expect(graph.maven.isProjectReloadRequired) - #expect(graph.maven.isReloadRequired) - } - - @Test - func mavenInitialLoadCoalescesMatchingInventory() async throws { - let gate = ReloadScanGate() - let secondStarted = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) - let operations = ReloadMavenOperations(scanGate: gate) - let graph = makeTestGraph(mavenOperations: operations) - let root = URL(fileURLWithPath: "/workspace", isDirectory: true) - let pom = root.appendingPathComponent("pom.xml") - let first = Task { @MainActor in - await graph.maven.loadProject(at: root, files: [pom]) - } - defer { - first.cancel() - secondStarted.continuation.finish() - gate.entered.continuation.finish() - gate.release.signal() - gate.release.signal() - graph.maven.reset() - graph.run.reset() - } - - try await awaitSignal(gate.entered.stream) - let second = Task { @MainActor in - secondStarted.continuation.yield(()) - await graph.maven.loadProject( - at: root, - files: [root.appendingPathComponent("README.md"), pom] - ) - } - try await awaitSignal(secondStarted.stream) - gate.release.signal() - await first.value - await second.value - - #expect(operations.scanCount == 1) - #expect(graph.maven.projectState == .ready) - #expect(!graph.maven.isLoadingProject) - } - - @Test(arguments: ["changed", "coalesced", "unrelated", "reset", "workspace"]) - func mavenInitialLoadRejectsChangedPomContents(outcome: String) async { - let gate = ReloadScanGate() - let operations = ReloadMavenOperations(scanGate: gate, scanArtifacts: ["old", "new"]) - let graph = makeTestGraph(mavenOperations: operations) - let root = URL(fileURLWithPath: "/workspace", isDirectory: true) - let pom = root.appendingPathComponent("pom.xml") - let first = Task { @MainActor in - await graph.maven.loadProject(at: root, files: [pom]) - } - defer { - first.cancel() - gate.entered.continuation.finish() - gate.release.signal() - gate.release.signal() - graph.maven.reset() - graph.run.reset() - } - do { - try await awaitSignal(gate.entered.stream) - } catch { - Issue.record("Initial Maven scan did not reach its gate: \(error)") - graph.maven.reset() - gate.release.signal() - await first.value - return - } - - #expect(graph.maven.project == nil) - if outcome == "unrelated" { - graph.maven.markPomChanged(URL(fileURLWithPath: "/workspace-copy/pom.xml")) - graph.maven.markPomChanged(root.appendingPathComponent("README.md")) - } else { - // The first scan has captured old contents. Multiple edits must - // invalidate that result without starting parallel replacement scans. - graph.maven.markPomChanged(pom) - graph.maven.markPomChanged(pom) - } - #expect(!graph.maven.isProjectReloadRequired) - if outcome == "reset" { graph.maven.reset() } - gate.release.signal() - gate.release.signal() - if outcome == "coalesced" { - // No MainActor suspension occurs between release and this call, so - // the matching request joins the first task before it can commit. - await graph.maven.loadProject(at: root, files: [pom]) - } else if outcome == "workspace" { - let other = URL(fileURLWithPath: "/other", isDirectory: true) - await graph.maven.loadProject(at: other, files: [other.appendingPathComponent("pom.xml")]) - #expect(graph.maven.project?.rootURL == other) - } - await first.value - - if outcome == "reset" { - #expect(operations.scanCount == 1) - #expect(graph.maven.project == nil) - #expect(graph.maven.projectState == .idle) - } else { - #expect(operations.scanCount == (outcome == "unrelated" ? 1 : 2)) - #expect(graph.maven.project?.artifactID == (outcome == "unrelated" ? "old" : "new")) - #expect(graph.maven.projectState == .ready) - #expect(!graph.maven.isReloadRequired) - // The fresh result must itself be reusable by later snapshots. - if outcome != "workspace" { - await graph.maven.loadProject(at: root, files: [pom]) - #expect(operations.scanCount == (outcome == "unrelated" ? 1 : 2)) - } - } - } - - @Test(arguments: ["success", "failure", "new-pom", "workspace"]) - func mavenReloadSynchronizesAcceptedRunProfiles(outcome: String) async throws { - let graph = makeTestGraph(mavenOperations: ReloadMavenOperations()) - defer { graph.maven.reset(); graph.run.reset() } - let root = URL(fileURLWithPath: "/workspace", isDirectory: true) - let snapshot = UUID() - let pom = root.appendingPathComponent("pom.xml") - await graph.projectDevelopment.loadProject( - at: root, files: [root.appendingPathComponent("old"), pom], snapshotID: snapshot - ) - graph.maven.markPomChanged(pom) - await graph.projectDevelopment.loadProject( - at: root, files: [root.appendingPathComponent("new"), pom], snapshotID: snapshot - ) - #expect(graph.run.mavenProfiles.map(\.id) == ["old"]) - await graph.maven.reloadProject(files: [root.appendingPathComponent("new")], rescan: true) { - switch outcome { - case "failure": throw ReloadTestError.failed - case "new-pom": graph.maven.markPomChanged(pom) - case "workspace": - await graph.run.loadProject(at: URL(fileURLWithPath: "/other"), files: [], mavenProject: nil) - default: break - } - } - #expect(graph.run.mavenProfiles.map(\.id) == (outcome == "workspace" ? [] : [outcome == "success" ? "new" : "old"])) - if outcome != "workspace" { - #expect(graph.run.isProjectReady(for: root, snapshotID: snapshot)) - } - } - @Test(arguments: ["complete", "reset", "switch", "cancel"]) func projectLoadAwaitsToolchainProbesWithoutBlockingOrApplyingStaleResults(outcome: String) async throws { let entered = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) @@ -346,7 +664,7 @@ struct ExecutionModuleTests { languageRunProviders: .standard(catalog: .compatibilityFallback) ) let snapshotID = UUID() - let loading = Task { await service.loadProject(at: root, files: [], mavenProject: nil, snapshotID: snapshotID) } + let loading = Task { await service.loadProject(at: root, files: [], snapshotID: snapshotID) } defer { loading.cancel() entered.continuation.finish() @@ -367,7 +685,7 @@ struct ExecutionModuleTests { #expect(!service.isProjectReady(for: root, snapshotID: snapshotID)) switch outcome { case "reset": service.reset() - case "switch": await service.loadProject(at: other, files: [], mavenProject: nil) + case "switch": await service.loadProject(at: other, files: []) case "cancel": loading.cancel() default: break } @@ -411,7 +729,7 @@ struct ExecutionModuleTests { ) let root = URL(fileURLWithPath: "/workspace") let snapshot = UUID() - let loading = Task { await service.loadProject(at: root, files: [], mavenProject: nil, snapshotID: snapshot) } + let loading = Task { await service.loadProject(at: root, files: [], snapshotID: snapshot) } defer { loading.cancel() entered.continuation.finish() @@ -428,7 +746,7 @@ struct ExecutionModuleTests { // An entry point must not consume the inventory before its configuration // resolves. A concurrent snapshot load must not save the initial placeholder. #expect(!service.isProjectReady(for: root, snapshotID: snapshot)) - await service.loadProject(at: root, files: [], mavenProject: nil, snapshotID: snapshot) + await service.loadProject(at: root, files: [], snapshotID: snapshot) #expect(service.selectedConfigurationID == configuration.id) #expect(service.isProjectReady(for: root, snapshotID: snapshot)) release.continuation.yield(()) @@ -436,260 +754,10 @@ struct ExecutionModuleTests { #expect(service.selectedConfigurationID == configuration.id) // A completed user's explicit Current File choice still survives reload. service.selectedConfigurationID = RunConfiguration.currentFileID - await service.loadProject(at: root, files: [], mavenProject: nil, snapshotID: snapshot) + await service.loadProject(at: root, files: [], snapshotID: snapshot) #expect(service.selectedConfigurationID == RunConfiguration.currentFileID) } - @Test - func runInventoryReturningAfterReloadKeepsAcceptedProfiles() async throws { - let gate = ReloadScanGate() - let graph = makeTestGraph( - mavenOperations: ReloadMavenOperations(), - runOperations: TestRunConfigurationOperations(inspectionGate: gate) - ) - let root = URL(fileURLWithPath: "/workspace", isDirectory: true) - let snapshot = UUID() - let pom = root.appendingPathComponent("pom.xml") - gate.release.signal() - await graph.projectDevelopment.loadProject(at: root, files: [root.appendingPathComponent("old"), pom]) - for await _ in gate.entered.stream { break } - graph.maven.markPomChanged(pom) - let background = Task { - await graph.projectDevelopment.loadProject( - at: root, files: [root.appendingPathComponent("new"), pom], snapshotID: snapshot - ) - } - let watchdog = Task { - // test-stability: allow(swift-real-sleep) reason: watchdog bounds the event wait if Run inspection never enters the controlled synchronous port. - try? await Task.sleep(for: .seconds(2)) - guard !Task.isCancelled else { return } - Issue.record("Run inspection did not reach its gate") - gate.entered.continuation.finish() - gate.release.signal() - background.cancel() - } - defer { - watchdog.cancel() - background.cancel() - gate.entered.continuation.finish() - gate.release.signal() - graph.maven.reset() - graph.run.reset() - } - for await _ in gate.entered.stream { break } - await graph.maven.reloadProject(files: [root.appendingPathComponent("new")], rescan: true) {} - gate.release.signal() - await background.value - #expect(graph.run.mavenProfiles.map(\.id) == ["new"]) - #expect(graph.run.isProjectReady(for: root, snapshotID: snapshot)) - } - - @Test - func mavenReloadCoalescesConcurrentRequests() async throws { - let (service, root) = await makeReloadService() - let entered = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) - let release = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) - var javaCalls = 0 - let first = Task { - await service.reloadProject(files: [root.appendingPathComponent("new")], rescan: true) { - javaCalls += 1 - entered.continuation.yield(()) - for await _ in release.stream { break } - } - } - let watchdog = Task { - // test-stability: allow(swift-real-sleep) reason: watchdog bounds both event-driven gates if reload never reaches or leaves Java import. - try? await Task.sleep(for: .seconds(2)) - guard !Task.isCancelled else { return } - Issue.record("Maven reload did not reach its synchronization boundary") - entered.continuation.finish() - release.continuation.finish() - first.cancel() - } - defer { - watchdog.cancel() - entered.continuation.finish() - release.continuation.finish() - first.cancel() - service.reset() - } - for await _ in entered.stream { break } - // The first task cannot resume on MainActor until the second call reaches - // its await, so it must observe and join the in-flight operation. - release.continuation.yield(()) - await service.reloadProject(files: [root.appendingPathComponent("invalid")], rescan: true) { - javaCalls += 1 - } - await first.value - #expect(javaCalls == 1) - #expect(service.project?.artifactID == "new") - #expect(service.reloadError == nil) - } - - @Test - func mavenReloadCommitsOnlyAfterJavaImportAndPreservesConfiguration() async throws { - let (service, root) = await makeReloadService() - defer { service.reset() } - service.setSkipTests(true) - service.markPomChanged(root.appendingPathComponent("module/pom.xml")) - await service.reloadProject(files: [root.appendingPathComponent("new")], rescan: true) { - #expect(service.project?.artifactID == "old") - #expect(service.isReloading) - #expect(service.isProjectReloadRequired) - } - #expect(service.project?.artifactID == "new") - #expect(service.skipTests) - #expect(!service.isReloadRequired) - #expect(!service.isReloading) - #expect(service.reloadError == nil) - } - - @Test(arguments: [true, false]) - func mavenReloadFailureKeepsAcceptedModel(scanFailure: Bool) async throws { - let (service, root) = await makeReloadService() - defer { service.reset() } - service.markPomChanged(root.appendingPathComponent("pom.xml")) - var javaCalls = 0 - await service.reloadProject(files: [root.appendingPathComponent(scanFailure ? "invalid" : "new")], rescan: true) { - javaCalls += 1 - throw ReloadTestError.failed - } - #expect(javaCalls == (scanFailure ? 0 : 1)) - #expect(service.project?.artifactID == "old") - #expect(service.projectState == .ready) - #expect(service.isReloadRequired) - #expect(service.reloadError != nil) - } - - @Test(arguments: ["pom", "configuration", "reset", "workspace"]) - func mavenReloadRejectsChangesDuringJavaImport(change: String) async throws { - let (service, root) = await makeReloadService() - defer { service.reset() } - service.markPomChanged(root.appendingPathComponent("pom.xml")) - await service.reloadProject(files: [root.appendingPathComponent("new")], rescan: true) { - switch change { - case "pom": service.markPomChanged(root.appendingPathComponent("module/pom.xml")) - case "configuration": service.setSkipTests(true) - case "workspace": - let next = URL(fileURLWithPath: "/next-workspace") - await Task { @MainActor in - await service.loadProject(at: next, files: [next.appendingPathComponent("other")]) - }.value - default: service.reset() - } - } - #expect(service.project?.artifactID == (change == "reset" ? nil : change == "workspace" ? "other" : "old")) - #expect(service.isReloadRequired == (change == "pom" || change == "configuration")) - #expect(service.reloadError == nil) - #expect(!service.isReloading) - } - - @Test - func mavenPomChangeSurvivesInventoryRefreshAndAcknowledgement() async throws { - let (service, root) = await makeReloadService() - defer { service.reset() } - service.markPomChanged(URL(fileURLWithPath: "/workspace-copy/pom.xml")) - service.markPomChanged(root.appendingPathComponent("README.md")) - #expect(!service.isReloadRequired) - service.markPomChanged(root.appendingPathComponent("pom.xml")) - service.acknowledgeReload() - await service.loadProject(at: root, files: [root.appendingPathComponent("new")]) - #expect(service.project?.artifactID == "old") - #expect(service.isProjectReloadRequired) - } - - @Test - func mavenSettingsGoStraightToTheRunningJavaSession() async throws { - // #970: a settings change only marked a reload, and the reload restarted - // JDT LS on unchanged workspace state. The running session now takes it. - let (service, root) = await makeReloadService() - defer { service.reset() } - var appliedRoots: [URL] = [] - service.applyConfigurationToJava = { workspace in - appliedRoots.append(workspace) - return true - } - - service.updateLocalConfiguration( - settingsPath: "/maven/conf/settings.xml", - localRepositoryPath: "/repository", - mavenExecutablePath: nil, - javaHomePath: nil - ) - - #expect(appliedRoots.map(\.standardizedFileURL.path) == [root.standardizedFileURL.path]) - #expect(!service.isReloadRequired) - #expect(service.javaConfigurationError == nil) - #expect(service.launchContext?.localRepositoryPath == "/repository") - } - - @Test - func aJavaSessionThatRejectsMavenSettingsOffersTheReload() async throws { - let (service, _) = await makeReloadService() - defer { service.reset() } - service.applyConfigurationToJava = { _ in throw ReloadTestError.failed } - - service.setSkipTests(true) - - #expect(service.isReloadRequired) - #expect(service.javaConfigurationError != nil) - service.acknowledgeReload() - #expect(service.javaConfigurationError == nil) - } - - @Test - func mavenConfigurationOnlyReloadDoesNotScanPom() async throws { - let (service, root) = await makeReloadService() - defer { service.reset() } - service.setSkipTests(true) - await service.reloadProject(files: [root.appendingPathComponent("invalid")], rescan: false) {} - #expect(service.project?.artifactID == "old") - #expect(service.reloadError == nil) - #expect(!service.isReloadRequired) - } - - @Test - func mavenReloadCancellationReleasesJavaWaitWithoutAcceptingTheCandidate() async throws { - let (service, root) = await makeReloadService() - let entered = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) - let waiting = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) - var javaWaitEnded = false - service.markPomChanged(root.appendingPathComponent("pom.xml")) - let reload = Task { - await service.reloadProject(files: [root.appendingPathComponent("new")], rescan: true) { - entered.continuation.yield(()) - for await _ in waiting.stream { } - javaWaitEnded = true - try Task.checkCancellation() - } - } - let watchdog = Task { - // test-stability: allow(swift-real-sleep) reason: bounds cancellation regression when the Java readiness stand-in fails to terminate. - do { try await Task.sleep(for: .seconds(2)) } catch { return } - Issue.record("Maven reload did not cancel its Java readiness wait") - entered.continuation.finish() - waiting.continuation.finish() - reload.cancel() - service.stop() - } - defer { - watchdog.cancel() - entered.continuation.finish() - waiting.continuation.finish() - reload.cancel() - service.reset() - } - for await _ in entered.stream { break } - // Cancel the caller, not the Java stand-in: the service must forward it. - reload.cancel() - await reload.value - #expect(javaWaitEnded) - #expect(service.project?.artifactID == "old") - #expect(service.isProjectReloadRequired) - #expect(service.reloadError != nil) - #expect(!service.isReloading) - } - @Test func configuredServerPortUsesArgumentsEnvironmentResourcesAndFrameworkDefault() async throws { let root = URL(fileURLWithPath: "/workspace/service-port", isDirectory: true) @@ -712,7 +780,7 @@ struct ExecutionModuleTests { fileAccess: TestRunFileAccess(contents: [properties: "server.port=18084"]), serverPortParser: FixedServerPortParser(port: 18084) ) - await argumentService.loadProject(at: root, files: [properties], mavenProject: nil) + await argumentService.loadProject(at: root, files: [properties]) #expect(argumentService.configuredServerPort(for: configuration) == 18082) let environmentService = makeRunService( @@ -721,7 +789,7 @@ struct ExecutionModuleTests { fileAccess: TestRunFileAccess(contents: [properties: "server.port=18084"]), serverPortParser: FixedServerPortParser(port: 18084) ) - await environmentService.loadProject(at: root, files: [properties], mavenProject: nil) + await environmentService.loadProject(at: root, files: [properties]) #expect(environmentService.configuredServerPort(for: configuration) == 18083) let resourceService = makeRunService( @@ -730,7 +798,7 @@ struct ExecutionModuleTests { fileAccess: TestRunFileAccess(contents: [properties: "server.port=18084"]), serverPortParser: FixedServerPortParser(port: 18084) ) - await resourceService.loadProject(at: root, files: [properties], mavenProject: nil) + await resourceService.loadProject(at: root, files: [properties]) #expect(resourceService.configuredServerPort(for: configuration) == 18084) let defaultService = makeRunService( @@ -739,7 +807,7 @@ struct ExecutionModuleTests { fileAccess: TestRunFileAccess(), serverPortParser: FixedServerPortParser(port: nil) ) - await defaultService.loadProject(at: root, files: [], mavenProject: nil) + await defaultService.loadProject(at: root, files: []) #expect(defaultService.configuredServerPort(for: configuration) == 8080) } @@ -826,7 +894,7 @@ struct ExecutionModuleTests { languageRunProviders: .standard(catalog: .compatibilityFallback) ) defer { service.reset() } - await service.loadProject(at: URL(fileURLWithPath: "/workspace"), files: [], mavenProject: nil) + await service.loadProject(at: URL(fileURLWithPath: "/workspace"), files: []) let target = JavaDebugLaunchTarget(mainClass: "example.Main", projectName: "app", classPaths: ["/workspace/classes", "/repository/spring-boot-devtools-3.5.0.jar"]) service.startConfiguration(configuration, javaLaunch: target) @@ -859,7 +927,7 @@ struct ExecutionModuleTests { languageRunProviders: .standard(catalog: .compatibilityFallback) ) defer { service.reset() } - await service.loadProject(at: URL(fileURLWithPath: "/workspace"), files: [], mavenProject: nil) + await service.loadProject(at: URL(fileURLWithPath: "/workspace"), files: []) // Model two already-running sessions: changing selection must not need // a process transition or a new output event to update the log target. service.startConfiguration(first) @@ -885,7 +953,7 @@ struct ExecutionModuleTests { let operations = FreshnessRecordingRunConfigurationOperations() let service = makeFreshnessRecordingService(operations) defer { service.reset() } - await service.loadProject(at: URL(fileURLWithPath: "/workspace"), files: [], mavenProject: nil) + await service.loadProject(at: URL(fileURLWithPath: "/workspace"), files: []) #expect(service.configurationDiagnostics.map(\.code) == ["staleFingerprint"]) let created = service.createConfiguration(RunConfigurationDraft( @@ -905,7 +973,7 @@ struct ExecutionModuleTests { operations.inputsChanged = false let service = makeFreshnessRecordingService(operations) defer { service.reset() } - await service.loadProject(at: URL(fileURLWithPath: "/workspace"), files: [], mavenProject: nil) + await service.loadProject(at: URL(fileURLWithPath: "/workspace"), files: []) #expect(service.configurationDiagnostics.isEmpty) let entrypoints = JavaEntrypoints(entries: []) @@ -955,7 +1023,7 @@ struct ExecutionModuleTests { #expect(service.generationState == .projectNotReady) // Binding without a snapshot only unlocks reading existing configuration. - await service.loadProject(at: root, files: [], mavenProject: nil) + await service.loadProject(at: root, files: []) #expect(service.projectLoadState == .bound(workspace: root)) #expect(!service.isProjectReady(for: root, snapshotID: UUID())) await service.generateRunConfigurations() @@ -963,7 +1031,7 @@ struct ExecutionModuleTests { #expect(operations.generateCallCount == 0) let snapshotID = UUID() - await service.loadProject(at: root, files: [], mavenProject: nil, snapshotID: snapshotID) + await service.loadProject(at: root, files: [], snapshotID: snapshotID) #expect(service.projectLoadState == .ready(workspace: root, snapshotID: snapshotID)) #expect(service.isProjectReady(for: root, snapshotID: snapshotID)) // A superseded snapshot of the same workspace is not ready. @@ -997,7 +1065,7 @@ struct ExecutionModuleTests { let source = root.appendingPathComponent("src/main/java/demo/App.java") // The workspace snapshot has not arrived, so the inventory is empty. - await service.loadProject(at: root, files: [], mavenProject: nil) + await service.loadProject(at: root, files: []) await service.generateRunConfigurations() #expect(service.generationState == .projectNotReady) #expect(operations.generatedInventories.isEmpty, "a provisional inventory must not be scanned") @@ -1005,7 +1073,6 @@ struct ExecutionModuleTests { await service.loadProject( at: root, files: [source], - mavenProject: nil, snapshotID: UUID() ) await service.generateRunConfigurations() @@ -1039,7 +1106,7 @@ struct ExecutionModuleTests { let root = URL(fileURLWithPath: "/workspace", isDirectory: true) let snapshotID = UUID() - await service.loadProject(at: root, files: [], mavenProject: nil, snapshotID: snapshotID) + await service.loadProject(at: root, files: [], snapshotID: snapshotID) #expect(service.configurationStatus == .invalid("configurations.json is invalid")) #expect(service.isProjectReady(for: root, snapshotID: snapshotID)) @@ -1079,7 +1146,7 @@ struct ExecutionModuleTests { let root = URL(fileURLWithPath: "/workspace", isDirectory: true) let source = root.appendingPathComponent("cmd/server/main.go") - await service.loadProject(at: root, files: [source], mavenProject: nil) + await service.loadProject(at: root, files: [source]) service.run(configuration: .currentFile, currentFileURL: source) #expect(builtInProcess.startRequests.isEmpty) @@ -1121,8 +1188,7 @@ struct ExecutionModuleTests { let root = URL(fileURLWithPath: "/workspace", isDirectory: true) await service.loadProject( at: root, - files: [root.appendingPathComponent("go.mod")], - mavenProject: nil + files: [root.appendingPathComponent("go.mod")] ) let configuration = try #require( service.configurations.first { $0.kind.providerID == "go" } @@ -1162,7 +1228,7 @@ struct ExecutionModuleTests { let goModule = root.appendingPathComponent("go.mod") let main = root.appendingPathComponent("cmd/api/main.go") - await service.loadProject(at: root, files: [goModule, main], mavenProject: nil) + await service.loadProject(at: root, files: [goModule, main]) #expect(service.configurations.contains { $0.id == "go:api" }) #expect(service.dependencyServices.isEmpty) @@ -1209,7 +1275,7 @@ struct ExecutionModuleTests { serverPortParser: FixedServerPortParser(port: nil) ) defer { service.reset() } - await service.loadProject(at: URL(fileURLWithPath: "/workspace"), files: [], mavenProject: nil) + await service.loadProject(at: URL(fileURLWithPath: "/workspace"), files: []) #expect(service.configurations.contains { $0.id == configuration.id }) #expect(service.dependencyServices.isEmpty) @@ -1258,7 +1324,7 @@ struct ExecutionModuleTests { virtualDocuments: [.init(title: "external.go", uri: virtualURI)] ) } - await service.loadProject(at: root, files: [moduleFile], mavenProject: nil) + await service.loadProject(at: root, files: [moduleFile]) #expect(service.dependencyServices.isEmpty) service.registerDependencySource(languageID: "go", displayName: "Go") let graph = try #require(try await service.resolveDependencies(serviceID: "language:go")) @@ -1334,7 +1400,7 @@ struct ExecutionModuleTests { firstService.configureLanguageDependencyProvider { _, _, _ in LanguageDependencySnapshot(dependencyRoots: [cachedDependency]) } - await firstService.loadProject(at: root, files: [moduleFile], mavenProject: nil) + await firstService.loadProject(at: root, files: [moduleFile]) firstService.registerDependencySource(languageID: "go", displayName: "Go") _ = try await firstService.resolveDependencies(serviceID: "language:go") #expect(store.indexes.services["language:go"] != nil) @@ -1354,7 +1420,7 @@ struct ExecutionModuleTests { ) defer { secondService.reset() } secondService.configureLanguageDependencyProvider { _, _, _ in nil } - await secondService.loadProject(at: root, files: [moduleFile], mavenProject: nil) + await secondService.loadProject(at: root, files: [moduleFile]) secondService.registerDependencySource(languageID: "go", displayName: "Go") let graph = try #require( @@ -1423,72 +1489,13 @@ struct ExecutionModuleTests { #expect(service.errorMessage == "go testing extension is not active.") } - @Test - func mavenTestsParseResultsAndRerunTheLastSelection() async throws { - let root = URL(fileURLWithPath: "/workspace/maven-tests", isDirectory: true) - let source = root.appendingPathComponent( - "src/test/java/com/example/CalculatorTest.java" - ) - let pom = root.appendingPathComponent("pom.xml") - let firstProcess = TestStreamingProcess() - let secondProcess = TestStreamingProcess() - var factoryCall = 0 - let parsedResults = MavenTestResults( - testsRun: 3, - failures: 1, - errors: 0, - skipped: 1, - passed: 1, - success: false, - failureDetails: [] - ) - let parser = TestResultParserRecorder(result: parsedResults) - let service = LanguageTestService( - executableResolver: TestExecutableResolver(), - processFactory: { - factoryCall += 1 - return factoryCall == 1 ? firstProcess : secondProcess - }, - resultParser: parser.parse, - now: { Date(timeIntervalSince1970: 1_700_000_000) } - ) - - #expect(service.run( - providerID: "java", - scope: .file(source), - workspaceURL: root, - projectFiles: [pom, source] - )) - #expect(firstProcess.startRequests.first?.arguments == ["-Dstyle.color=always", "-Dtest=CalculatorTest", "test"]) - #expect(firstProcess.startRequests.first?.timeoutMilliseconds == 120_000) - defer { service.reset() } - - firstProcess.onOutput?("Tests run: 3, Failures: 1, Errors: 0, Skipped: 1\n") - firstProcess.onTermination?(1) - try await awaitTestValue(service.$state, matching: { $0 == .failed(exitCode: 1) }) - #expect(service.state == .failed(exitCode: 1)) - #expect(service.results == parsedResults) - #expect(parser.calls == 1) - #expect(parser.output.contains("Tests run: 3")) - #expect(parser.reports == MavenTestReportRequest( - sourcePath: "src/test/java/com/example/CalculatorTest.java", - classes: [], - notBeforeMillis: 1_700_000_000_000 - )) - #expect(!parser.ranOnMainThread) - #expect(service.canRerun) - - #expect(service.rerun()) - #expect(secondProcess.startRequests.first?.arguments == ["-Dstyle.color=always", "-Dtest=CalculatorTest", "test"]) - } - @Test func testProcessIdentityRejectsOldRunsAndClearsOnStop() async throws { let root = URL(fileURLWithPath: "/workspace/test-performance") let process = TestStreamingProcess() let service = LanguageTestService(executableResolver: TestExecutableResolver(), processFactory: { process }) defer { service.reset() } #expect(service.run(providerID: "java", scope: .workspace, workspaceURL: root, - projectFiles: [root.appendingPathComponent("pom.xml")])) + projectFiles: [root.appendingPathComponent("build.gradle")])) let firstID = try #require(service.runID) let firstEvent = try #require(process.onStateChange) firstEvent(ProcessLifecycleEvent(operationID: firstID.uuidString, state: .running, @@ -1525,38 +1532,38 @@ struct ExecutionModuleTests { let files = [root.appendingPathComponent("pom.xml"), first, second, root.appendingPathComponent("package.json"), root.appendingPathComponent("sample.test.js"), root.appendingPathComponent("pyproject.toml"), root.appendingPathComponent("test_sample.py")] - let outcomes = [MavenTestCaseOutcome(className: "demo.FirstTest", method: "check", status: "passed")] - let results = MavenTestResults(testsRun: 1, failures: 0, errors: 0, skipped: 0, passed: 1, - success: true, failureDetails: [], testCases: outcomes) - let process = TestStreamingProcess() - let service = LanguageTestService(executableResolver: TestExecutableResolver(), - processFactory: { process }, resultParser: { _, _, _ in results }) + let report = try JSONDecoder().decode(BuildTestResults.self, from: Data(#"{"tests":[{"suite":"demo.FirstTest","case":"check","status":"passed"}],"summary":{"total":1,"passed":1,"failed":0,"errors":0,"skipped":0},"success":true}"#.utf8)) + let service = LanguageTestService(executableResolver: TestExecutableResolver(), processFactory: { TestStreamingProcess() }) + func run(_ scope: LanguageTestScope) { + service.runUsingBackend(providerID: "java", scope: scope, workspaceURL: root, label: "Tests") { _, _ in + LanguageTestBackendResult(exitCode: 0, reports: [report]) + } + } defer { service.reset() } service.discover(workspaceURL: root, files: files) let discovered = service.itemsByProviderID for provider in ["java", "node", "python"] { #expect(discovered[provider]?.contains { $0.kind == .workspace } == true) } - #expect(service.run(providerID: "java", scope: .testCase(identifier: "demo.FirstTest#check", fileURL: first), - workspaceURL: root, projectFiles: files)) - process.onTermination?(0) + run(.testCase(identifier: "demo.FirstTest#check", fileURL: first)) try await awaitTestValue(service.$state, matching: { $0 == .passed }) - #expect(service.results == results) - #expect(service.testOutcomes == outcomes) + let outcomes = service.testOutcomes(for: "java") + #expect(service.results?.summary.total == 1) + #expect(outcomes.first?.suite == "demo.FirstTest") if clearOnly { service.clearOutput() } else { service.closeRun() } #expect(service.results == nil) - #expect(service.testOutcomes == outcomes, "Editor marker history must outlive the visible result") + #expect(service.testOutcomes(for: "java") == outcomes, "Editor marker history must outlive the visible result") #expect(service.itemsByProviderID == discovered) - #expect(service.run(providerID: "java", scope: .file(second), workspaceURL: root, projectFiles: files)) + run(.file(second)) #expect(service.hasRun(providerID: "java", scope: .file(second))) - #expect(service.run(providerID: "java", scope: .workspace, workspaceURL: root, projectFiles: files)) + run(.workspace) #expect(service.hasRun(providerID: "java", scope: .workspace)) } @Test(arguments: [false, true]) func closingTestRunPreservesDiscoveryAndAllowsNewRun(completed: Bool) async throws { let root = URL(fileURLWithPath: "/workspace/test-tab", isDirectory: true) - let files = [root.appendingPathComponent("pom.xml")] + let files = [root.appendingPathComponent("build.gradle")] let process = TestStreamingProcess() let service = LanguageTestService( executableResolver: TestExecutableResolver(), @@ -1634,7 +1641,7 @@ struct ExecutionModuleTests { defer { service.reset() } let root = URL(fileURLWithPath: "/workspace", isDirectory: true) - await service.loadProject(at: root, files: [], mavenProject: nil) + await service.loadProject(at: root, files: []) let mainStarted = AsyncStream.makeStream() mainProcess.onStart = { mainStarted.continuation.yield(()) } @@ -1697,8 +1704,7 @@ struct ExecutionModuleTests { await service.loadProject( at: URL(fileURLWithPath: "/workspace", isDirectory: true), - files: [], - mavenProject: nil + files: [] ) service.startConfiguration(configuration, javaLaunch: JavaDebugLaunchTarget( mainClass: "example.Main", @@ -1744,12 +1750,6 @@ struct ExecutionModuleTests { languageRunProviders: .standard(catalog: .compatibilityFallback) ) let graph = ExecutionFeatureGraph( - maven: MavenService( - runtimeService: runtime, - process: TestStreamingProcess(), - dependencyProcess: TestStreamingProcess(), - mavenOperations: TestMavenOperations() - ), run: service, tests: LanguageTestService( catalog: .compatibilityFallback, @@ -1762,8 +1762,7 @@ struct ExecutionModuleTests { await service.loadProject( at: URL(fileURLWithPath: "/workspace", isDirectory: true), - files: [], - mavenProject: nil + files: [] ) service.startConfiguration(configuration, javaLaunch: JavaDebugLaunchTarget( mainClass: "example.Main", @@ -1823,7 +1822,7 @@ struct ExecutionModuleTests { defer { service.reset() } let root = URL(fileURLWithPath: "/workspace", isDirectory: true) - await service.loadProject(at: root, files: [], mavenProject: nil) + await service.loadProject(at: root, files: []) let mainStarted = AsyncStream.makeStream() mainProcess.onStart = { mainStarted.continuation.yield(()) } @@ -1874,7 +1873,7 @@ struct ExecutionModuleTests { defer { service.reset() } let root = URL(fileURLWithPath: "/workspace", isDirectory: true) - await service.loadProject(at: root, files: [], mavenProject: nil) + await service.loadProject(at: root, files: []) service.run(configuration: configuration, currentFileURL: nil) let step = try #require(stepProcesses.processes.first) @@ -1934,7 +1933,7 @@ struct ExecutionModuleTests { languageRunProviders: .standard(catalog: .compatibilityFallback) ) defer { service.reset() } - await service.loadProject(at: root, files: [], mavenProject: nil) + await service.loadProject(at: root, files: []) service.startConfiguration(configuration) @@ -1997,7 +1996,7 @@ struct ExecutionModuleTests { languageRunProviders: .standard(catalog: .compatibilityFallback) ) defer { service.reset() } - await service.loadProject(at: root, files: [], mavenProject: nil) + await service.loadProject(at: root, files: []) service.startConfiguration(configuration) let step = try #require(recorder.processes.first) @@ -2046,7 +2045,7 @@ struct ExecutionModuleTests { languageRunProviders: .standard(catalog: .compatibilityFallback) ) defer { service.reset() } - await service.loadProject(at: root, files: [], mavenProject: nil) + await service.loadProject(at: root, files: []) service.startConfiguration(configuration) // Drain the step's own start signal so the later check observes only a @@ -2108,7 +2107,7 @@ struct ExecutionModuleTests { languageRunProviders: .standard(catalog: .compatibilityFallback) ) defer { service.reset() } - await service.loadProject(at: root, files: [], mavenProject: nil) + await service.loadProject(at: root, files: []) service.startConfiguration(configuration) let step = try #require(recorder.processes.first) @@ -2133,66 +2132,17 @@ struct ExecutionModuleTests { #expect(recorder.processes.count == 1) } - @Test - func mavenTestTimeoutIsPreservedWhenTerminationArrivesLate() async throws { - let root = URL(fileURLWithPath: "/workspace/maven-timeout", isDirectory: true) - let source = root.appendingPathComponent( - "src/test/java/com/example/CalculatorTest.java" - ) - let process = TestStreamingProcess() - let service = LanguageTestService( - executableResolver: TestExecutableResolver(), - processFactory: { process }, - resultParser: { _, _, _ in nil } - ) - - #expect(service.run( - providerID: "java", - scope: .file(source), - workspaceURL: root, - projectFiles: [root.appendingPathComponent("pom.xml"), source] - )) - let request = try #require(process.startRequests.first) - #expect(request.timeoutMilliseconds == 120_000) - defer { service.reset() } - - process.onStateChange?(ProcessLifecycleEvent( - operationID: request.operationID, - state: .stopping, - exitCode: nil, - message: "Process timed out" - )) - try await awaitTestValue(service.$errorMessage, matching: { $0 != nil }) - #expect(service.state == .running) - #expect(service.errorMessage == "Maven test run timed out after 120 seconds.") - - process.onTermination?(0) - try await awaitTestValue(service.$state, matching: { $0 == .timedOut }) - #expect(service.state == .timedOut) - #expect(!service.isRunning) - } - @Test(arguments: [LanguageTestRunState.passed, .failed(exitCode: 7), .cancelled]) - func nonMavenLanguageTestsDoNotParseMavenResults(expectedState: LanguageTestRunState) async throws { + func processLanguageTestsRemainIndependentFromProviderReports(expectedState: LanguageTestRunState) async throws { let root = URL(fileURLWithPath: "/workspace/gradle-tests", isDirectory: true) let source = root.appendingPathComponent( "src/test/java/com/example/CalculatorTest.java" ) let buildFile = root.appendingPathComponent("build.gradle") let process = TestStreamingProcess() - let parser = TestResultParserRecorder(result: MavenTestResults( - testsRun: 1, - failures: 0, - errors: 0, - skipped: 0, - passed: 1, - success: true, - failureDetails: [] - )) let service = LanguageTestService( executableResolver: TestExecutableResolver(), - processFactory: { process }, - resultParser: parser.parse + processFactory: { process } ) #expect(service.run( @@ -2211,598 +2161,6 @@ struct ExecutionModuleTests { try await awaitTestValue(service.$state, matching: { $0 == expectedState }) #expect(service.state == expectedState) #expect(service.results == nil) - #expect(parser.calls == 0) - } - - @Test - func stoppingMavenTestsIgnoresLateTerminationEvents() async throws { - let root = URL(fileURLWithPath: "/workspace/maven-cancel", isDirectory: true) - let source = root.appendingPathComponent( - "src/test/java/com/example/CalculatorTest.java" - ) - let process = TestStreamingProcess() - let service = LanguageTestService( - executableResolver: TestExecutableResolver(), - processFactory: { process }, - resultParser: { _, _, _ in - MavenTestResults( - testsRun: 1, - failures: 0, - errors: 0, - skipped: 0, - passed: 1, - success: true, - failureDetails: [] - ) - } - ) - - #expect(service.run( - providerID: "java", - scope: .file(source), - workspaceURL: root, - projectFiles: [root.appendingPathComponent("pom.xml"), source] - )) - service.stop() - #expect(service.state == .cancelled) - #expect(!process.isRunning) - - process.onTermination?(0) - await Task.yield() - await Task.yield() - - #expect(service.state == .cancelled) - #expect(service.results == nil) - } - - @Test - func mavenServiceExecutesTheSharedLaunchPlanWithLocalRuntimeOverrides() async throws { - let workspace = URL(fileURLWithPath: "/workspace", isDirectory: true) - let reactor = workspace.appendingPathComponent("projects/demo", isDirectory: true) - let module = MavenModule( - relativePath: "service-api", - url: reactor.appendingPathComponent("service-api", isDirectory: true), - groupID: "dev.lithe", - artifactID: "service-api", - version: "1.0", - packaging: "jar", - modules: [] - ) - let project = MavenProject( - rootURL: reactor, - pomURL: reactor.appendingPathComponent("pom.xml"), - groupID: "dev.lithe", - artifactID: "demo", - version: "1.0", - packaging: "pom", - modules: [module], - profiles: [MavenProfile(id: "dev", isActiveByDefault: false)], - hasWrapper: false - ) - let plan = MavenLaunchPlan( - version: 1, - toolchain: "project-maven", - arguments: ["core-owned-argument", "-s", "/local/settings.xml", "verify"], - workingDirectory: "projects/demo", - configurationFingerprint: "sha256:test" - ) - let operations = RecordingMavenOperations(project: project, plan: plan) - let process = MavenRecordingProcess() - let runtime = MavenRecordingRuntime() - let store = RecordingMavenConfigurationStore(configuration: MavenStoredConfiguration( - portable: MavenPortableConfiguration( - selectedProfiles: ["dev"], - customProfiles: [], - skipTests: true - ), - local: MavenLocalConfiguration( - settingsPath: "/local/settings.xml", - mavenExecutablePath: "/local/apache-maven", - javaHomePath: "/local/jdk" - ) - )) - let service = MavenService( - runtimeService: runtime, - process: process, - dependencyProcess: TestStreamingProcess(), - mavenOperations: operations, - configurationStore: store - ) - - await service.loadProject(at: workspace, files: [project.pomURL]) - service.runCustomGoal( - "help:evaluate -Dexpression=fixture.config -q -DforceStdout", - module: module - ) - let request = try #require(await process.nextStart(timeout: .seconds(1))) - - #expect(request.arguments == plan.arguments) - #expect(request.workingDirectory == reactor.path) - #expect(request.environment?["TEST_JAVA_HOME"] == "/local/jdk") - #expect(runtime.lastMavenOverride == "/local/apache-maven") - #expect(operations.lastContext?.reactorPath == "projects/demo") - #expect(operations.lastContext?.profiles == ["dev"]) - #expect(operations.lastContext?.settingsPath == "/local/settings.xml") - #expect(operations.lastContext?.skipTests == true) - #expect(operations.lastModule == "service-api") - #expect(operations.lastGoals == [ - "help:evaluate", - "-Dexpression=fixture.config", - "-q", - "-DforceStdout" - ]) - #expect(service.output.contains("-s ")) - #expect(!service.output.contains("/local/settings.xml")) - } - - @Test - func mavenAPISaveWaitsForPersistenceAndReportsWriteFailure() async { - let root = URL(fileURLWithPath: "/workspace", isDirectory: true) - let store = RecordingMavenConfigurationStore( - configuration: MavenStoredConfiguration(portable: nil, local: nil), - saveError: "Fixture configuration is read-only" - ) - let service = MavenService( - runtimeService: TestRuntime(), process: TestStreamingProcess(), - dependencyProcess: TestStreamingProcess(), mavenOperations: ReloadMavenOperations(), - configurationStore: store - ) - defer { service.reset() } - await service.loadProject(at: root, files: [root.appendingPathComponent("pom.xml")]) - service.setSkipTests(true) - let error = await service.saveConfiguration() - #expect(error == "Fixture configuration is read-only") - #expect(service.configurationSaveError == error) - } - - @Test - func mavenServiceReportsCancellationWithoutInventingAnExitCode() async throws { - let workspace = URL(fileURLWithPath: "/workspace", isDirectory: true) - let project = MavenProject( - rootURL: workspace, - pomURL: workspace.appendingPathComponent("pom.xml"), - groupID: "dev.lithe", - artifactID: "demo", - version: "1.0", - packaging: "jar", - modules: [], - profiles: [], - hasWrapper: false - ) - let plan = MavenLaunchPlan( - version: 1, - toolchain: "project-maven", - arguments: ["-B", "-ntp", "validate"], - workingDirectory: ".", - configurationFingerprint: "sha256:test" - ) - let process = MavenRecordingProcess() - let service = MavenService( - runtimeService: MavenRecordingRuntime(), - process: process, - dependencyProcess: TestStreamingProcess(), - mavenOperations: RecordingMavenOperations(project: project, plan: plan) - ) - - await service.loadProject(at: workspace, files: [project.pomURL]) - service.run(phase: .validate, module: nil) - _ = try #require(await process.nextStart(timeout: .seconds(1))) - service.stop() - - #expect(service.taskState == .cancelled) - #expect(service.runningTitle == nil) - #expect(service.lastExitCode == nil) - #expect(service.output.hasSuffix("Maven task cancelled.\n")) - #expect(!process.isRunning) - } - - @Test - func mavenServiceClearsReloadWhenConfigurationFingerprintReturnsToBaseline() async throws { - let workspace = URL(fileURLWithPath: "/workspace", isDirectory: true) - let project = MavenProject( - rootURL: workspace, - pomURL: workspace.appendingPathComponent("pom.xml"), - groupID: "dev.lithe", - artifactID: "demo", - version: "1.0", - packaging: "jar", - modules: [], - profiles: [], - hasWrapper: false - ) - let process = MavenRecordingProcess() - let service = MavenService( - runtimeService: MavenRecordingRuntime(), - process: process, - dependencyProcess: TestStreamingProcess(), - mavenOperations: FingerprintingMavenOperations(project: project) - ) - - await service.loadProject(at: workspace, files: [project.pomURL]) - #expect(!service.isReloadRequired) - - service.setSkipTests(true) - service.run(phase: .validate, module: nil) - _ = try #require(await process.nextStart(timeout: .seconds(1))) - #expect(service.isReloadRequired) - - service.stop() - service.setSkipTests(false) - service.run(phase: .validate, module: nil) - _ = try #require(await process.nextStart(timeout: .seconds(1))) - #expect(!service.isReloadRequired) - } - - @Test(arguments: [String?.none, "/custom/repository"]) - func mavenServiceResolvesDependenciesOnTheSecondBoundedProcess(configuredRepository: String?) async throws { - let workspace = URL(fileURLWithPath: "/workspace", isDirectory: true) - let module = MavenModule( - relativePath: "service", - url: workspace.appendingPathComponent("service", isDirectory: true), - groupID: "dev.lithe", - artifactID: "service", - version: "1.0", - packaging: "jar", - modules: [] - ) - let project = MavenProject( - rootURL: workspace, - pomURL: workspace.appendingPathComponent("pom.xml"), - groupID: "dev.lithe", - artifactID: "demo", - version: "1.0", - packaging: "pom", - modules: [module], - profiles: [], - hasWrapper: false - ) - let plan = MavenLaunchPlan( - version: 1, - toolchain: "project-maven", - arguments: ["dependency:tree"], - workingDirectory: ".", - configurationFingerprint: "sha256:dependency" - ) - let dependency = MavenDependency( - modulePath: "service", - groupID: "org.example", - artifactID: "library", - version: "1.0", - type: "jar", - classifier: nil, - scope: "compile", - resolution: .resolved, - selectedVersion: nil, - children: [MavenDependency( - modulePath: "service", - groupID: "org.example", - artifactID: "transitive", - version: "2.0", - type: "jar", - classifier: nil, - scope: "compile", - resolution: .resolved, - selectedVersion: nil, - children: [] - )] - ) - let operations = RecordingMavenOperations( - project: project, - plan: plan, - dependencyTree: MavenDependencyTree(modulePath: "service", dependencies: [dependency]) - ) - let buildProcess = MavenRecordingProcess() - let dependencyProcess = MavenRecordingProcess() - let outputs = RecordingDependencyOutputs() - let service = MavenService( - runtimeService: MavenRecordingRuntime(), - process: buildProcess, - dependencyProcess: dependencyProcess, - mavenOperations: operations, - dependencyOutputs: outputs - ) - - await service.loadProject(at: workspace, files: [project.pomURL, module.url]) - if let configuredRepository { - service.updateLocalConfiguration( - settingsPath: nil, - localRepositoryPath: configuredRepository, - mavenExecutablePath: nil, - javaHomePath: nil - ) - } - service.loadDependencies(for: "service") - let request = try #require(await dependencyProcess.nextStart(timeout: .seconds(1))) - - #expect(request.arguments == plan.arguments) - #expect(request.timeoutMilliseconds == 60_000) - #expect(!buildProcess.isRunning) - #expect(operations.lastDependencyModule == "service") - let outputFile = try #require(outputs.created.first) - #expect(operations.lastDependencyPlanFile == outputFile) - // Console output is Maven's log; only the file carries the tree. - dependencyProcess.onOutput?("[INFO] dependency tree\n") - dependencyProcess.onTermination?(0) - let state = await dependencyState( - service, - modulePath: "service", - matching: { if case .ready = $0 { true } else { false } } - ) - - #expect(state == .ready([dependency])) - #expect(operations.lastDependencyReadFile == outputFile) - #expect(outputs.removed == [outputFile]) - let projection = MavenFeatureModel(service: service) - #expect(projection.resolvedDependencyArtifactPaths(modulePath: "service").isEmpty - == (configuredRepository == nil)) - let selectedRepository = configuredRepository ?? "/repository" - #expect(projection.resolvedDependencyArtifactPaths( - modulePath: "service", - defaultRepositoryURL: URL(fileURLWithPath: "/repository", isDirectory: true) - ).map(\.path) == [ - "\(selectedRepository)/org/example/library/1.0/library-1.0.jar", - "\(selectedRepository)/org/example/transitive/2.0/transitive-2.0.jar" - ]) - #expect(projection.resolvedDependencyArtifactPaths( - modulePath: ".", - defaultRepositoryURL: URL(fileURLWithPath: "/repository", isDirectory: true) - ).isEmpty) - } - - @Test - func resolvedMavenTreeUpdatesAnAlreadyRegisteredJavaDependencySource() async throws { - let workspace = URL(fileURLWithPath: "/workspace", isDirectory: true) - let project = MavenProject( - rootURL: workspace, pomURL: workspace.appendingPathComponent("pom.xml"), - groupID: "org.example", artifactID: "example", version: "1.0", - packaging: "jar", modules: [], profiles: [], hasWrapper: false - ) - let dependency = MavenDependency( - modulePath: ".", groupID: "junit", artifactID: "junit", version: "4.12", - type: "jar", classifier: nil, scope: "compile", resolution: .resolved, - selectedVersion: nil, children: [] - ) - let operations = RecordingMavenOperations( - project: project, - plan: MavenLaunchPlan( - version: 1, toolchain: "project-maven", arguments: ["dependency:tree"], - workingDirectory: ".", configurationFingerprint: "sha256:dependency" - ), - dependencyTree: MavenDependencyTree(modulePath: ".", dependencies: [dependency]) - ) - let dependencyProcess = MavenRecordingProcess() - let maven = MavenService( - runtimeService: MavenRecordingRuntime(), process: MavenRecordingProcess(), - dependencyProcess: dependencyProcess, mavenOperations: operations, - dependencyOutputs: RecordingDependencyOutputs() - ) - let graph = makeTestGraph(mavenService: maven) - defer { graph.run.reset(); graph.maven.reset() } - let mavenFeature = graph.mavenFeature - graph.run.configureLanguageDependencyProvider { [weak mavenFeature] languageID, root, _ in - guard languageID == "java", root == workspace else { return nil } - let roots = mavenFeature?.resolvedDependencyArtifactPaths( - modulePath: ".", - defaultRepositoryURL: URL(fileURLWithPath: "/repository", isDirectory: true) - ) ?? [] - return roots.isEmpty ? nil : LanguageDependencySnapshot(dependencyRoots: roots) - } - await graph.projectDevelopment.loadProject( - at: workspace, files: [project.pomURL], snapshotID: UUID() - ) - graph.run.registerDependencySource(languageID: "java", displayName: "Java") - let initial = try #require(try await graph.run.resolveDependencies(serviceID: "language:java")) - #expect(initial.roots.first?.children[2].children.isEmpty == true) - let revision = graph.run.dependencyRevision - - maven.loadDependencies(for: ".") - _ = try #require(await dependencyProcess.nextStart(timeout: .seconds(1))) - dependencyProcess.onTermination?(0) - _ = await dependencyState( - maven, modulePath: ".", - matching: { if case .ready = $0 { true } else { false } } - ) - try await awaitTestValue(graph.run.$dependencyRevision, matching: { $0 > revision }) - let updated = try #require(try await graph.run.resolveDependencies(serviceID: "language:java")) - #expect(updated.roots.first?.children[2].children.map(\.id) == [ - "/repository/junit/junit/4.12/junit-4.12.jar" - ]) - } - - @Test - func mavenServiceStopCancelsAnActiveDependencyProcess() async throws { - let workspace = URL(fileURLWithPath: "/workspace", isDirectory: true) - let project = MavenProject( - rootURL: workspace, - pomURL: workspace.appendingPathComponent("pom.xml"), - groupID: "dev.lithe", - artifactID: "demo", - version: "1.0", - packaging: "jar", - modules: [], - profiles: [], - hasWrapper: false - ) - let plan = MavenLaunchPlan( - version: 1, - toolchain: "project-maven", - arguments: ["dependency:tree"], - workingDirectory: ".", - configurationFingerprint: "sha256:dependency" - ) - let dependencyProcess = MavenRecordingProcess() - let outputs = RecordingDependencyOutputs() - let service = MavenService( - runtimeService: MavenRecordingRuntime(), - process: MavenRecordingProcess(), - dependencyProcess: dependencyProcess, - mavenOperations: RecordingMavenOperations(project: project, plan: plan), - dependencyOutputs: outputs - ) - - await service.loadProject(at: workspace, files: [project.pomURL]) - service.loadDependencies(for: ".") - _ = try #require(await dependencyProcess.nextStart(timeout: .seconds(1))) - - service.stop() - - #expect(!dependencyProcess.isRunning) - #expect(service.dependencyState(for: ".") == .cancelled) - #expect(!service.isResolvingDependencies) - #expect(outputs.removed == outputs.created) - #expect(outputs.created.count == 1) - } - - @Test - func mavenServiceReportsDependencyTimeoutFromTheProcessLifecycle() async throws { - let workspace = URL(fileURLWithPath: "/workspace", isDirectory: true) - let project = MavenProject( - rootURL: workspace, - pomURL: workspace.appendingPathComponent("pom.xml"), - groupID: "dev.lithe", - artifactID: "demo", - version: "1.0", - packaging: "jar", - modules: [], - profiles: [], - hasWrapper: false - ) - let plan = MavenLaunchPlan( - version: 1, - toolchain: "project-maven", - arguments: ["dependency:tree"], - workingDirectory: ".", - configurationFingerprint: "sha256:dependency" - ) - let dependencyProcess = MavenRecordingProcess() - let outputs = RecordingDependencyOutputs() - let service = MavenService( - runtimeService: MavenRecordingRuntime(), - process: MavenRecordingProcess(), - dependencyProcess: dependencyProcess, - mavenOperations: RecordingMavenOperations(project: project, plan: plan), - dependencyOutputs: outputs - ) - - await service.loadProject(at: workspace, files: [project.pomURL]) - service.loadDependencies(for: ".") - let request = try #require(await dependencyProcess.nextStart(timeout: .seconds(1))) - dependencyProcess.onStateChange?(ProcessLifecycleEvent( - operationID: request.operationID, - state: .stopping, - exitCode: nil, - message: "Process timed out" - )) - dependencyProcess.onStateChange?(ProcessLifecycleEvent( - operationID: request.operationID, - state: .finished, - exitCode: 15, - message: nil - )) - let state = await dependencyState( - service, - modulePath: ".", - matching: { if case .failed = $0 { true } else { false } } - ) - - guard case .failed(let message) = state else { - Issue.record("Expected a failed Maven dependency state") - return - } - #expect(message == "Maven dependency resolution timed out after 60 seconds.") - #expect(outputs.removed == outputs.created) - #expect(outputs.created.count == 1) - } - - @Test - func mavenServiceRemovesTheTreeFileWhenMavenFailsOrIsSuperseded() async throws { - let workspace = URL(fileURLWithPath: "/workspace", isDirectory: true) - let project = MavenProject( - rootURL: workspace, - pomURL: workspace.appendingPathComponent("pom.xml"), - groupID: "dev.lithe", - artifactID: "demo", - version: "1.0", - packaging: "jar", - modules: [], - profiles: [], - hasWrapper: false - ) - let plan = MavenLaunchPlan( - version: 1, - toolchain: "project-maven", - arguments: ["dependency:tree"], - workingDirectory: ".", - configurationFingerprint: "sha256:dependency" - ) - let operations = RecordingMavenOperations(project: project, plan: plan) - let dependencyProcess = MavenRecordingProcess() - let outputs = RecordingDependencyOutputs() - let service = MavenService( - runtimeService: MavenRecordingRuntime(), - process: MavenRecordingProcess(), - dependencyProcess: dependencyProcess, - mavenOperations: operations, - dependencyOutputs: outputs - ) - await service.loadProject(at: workspace, files: [project.pomURL]) - - service.loadDependencies(for: ".") - _ = try #require(await dependencyProcess.nextStart(timeout: .seconds(1))) - dependencyProcess.onTermination?(1) - let failed = await dependencyState( - service, - modulePath: ".", - matching: { if case .failed = $0 { true } else { false } } - ) - #expect(failed == .failed("Maven dependency resolution exited with code 1.")) - #expect(operations.lastDependencyReadFile == nil) - #expect(outputs.removed == outputs.created) - - // A retry gets a fresh file; changing Skip Tests invalidates it mid-run. - service.loadDependencies(for: ".") - _ = try #require(await dependencyProcess.nextStart(timeout: .seconds(1))) - #expect(outputs.created.count == 2) - #expect(outputs.created[0] != outputs.created[1]) - service.setSkipTests(true) - #expect(outputs.removed == outputs.created) - #expect(!dependencyProcess.isRunning) - } - - @Test - func mavenServiceFailsVisiblyWithoutADependencyOutputStore() async throws { - let workspace = URL(fileURLWithPath: "/workspace", isDirectory: true) - let project = MavenProject( - rootURL: workspace, - pomURL: workspace.appendingPathComponent("pom.xml"), - groupID: "dev.lithe", - artifactID: "demo", - version: "1.0", - packaging: "jar", - modules: [], - profiles: [], - hasWrapper: false - ) - let plan = MavenLaunchPlan( - version: 1, - toolchain: "project-maven", - arguments: ["dependency:tree"], - workingDirectory: ".", - configurationFingerprint: "sha256:dependency" - ) - let dependencyProcess = MavenRecordingProcess() - let service = MavenService( - runtimeService: MavenRecordingRuntime(), - process: MavenRecordingProcess(), - dependencyProcess: dependencyProcess, - mavenOperations: RecordingMavenOperations(project: project, plan: plan) - ) - await service.loadProject(at: workspace, files: [project.pomURL]) - - service.loadDependencies(for: ".") - - #expect(service.dependencyState(for: ".") == .failed("Maven dependency resolution is unavailable.")) - #expect(!dependencyProcess.isRunning) } private func factory(recorder: Recorder) -> ModuleFactory { @@ -2833,7 +2191,8 @@ private struct SelectionRunConfigurationOperations: RunConfigurationOperations { at _: URL, files _: [URL], modulePaths _: [String], - javaEntrypoints _: JavaEntrypoints? + javaEntrypoints _: JavaEntrypoints?, + projectOwnership _: [RunProjectOwnership]? = nil ) throws -> RunConfigurationGenerationResult { RunConfigurationGenerationResult(entryCount: configurations.count) } @@ -2855,6 +2214,41 @@ private struct SelectionRunConfigurationOperations: RunConfigurationOperations { /// so only project loads may request it (issue #507). private final class FreshnessRecordingRunConfigurationOperations: RunConfigurationOperations, @unchecked Sendable { var inputsChanged = true + var staleProviderModel = false + var providerInspectionGate: InventoryInspectionGate? + private(set) var providerInspections: [[RunProjectOwnership]] = [] + var providerConfiguration: RunConfiguration? + private(set) var runInspections: [[RunProviderConfiguration]] = [] + var generationGate: InventoryInspectionGate? + private(set) var generationLeases: [RunConfigurationGenerationLease] = [] + func generate(at projectURL: URL, files: [URL], modulePaths: [String], javaEntrypoints: JavaEntrypoints?, + projectOwnership: [RunProjectOwnership]?, providerRuns: [RunProviderConfiguration], + models: [BuildModel], lease: RunConfigurationGenerationLease) throws -> RunConfigurationGenerationResult { + generationLeases.append(lease) + try lease.check() + if let gate = generationGate { + gate.entered.continuation.yield(()) + if gate.release.wait(timeout: .now() + 2) != .success { Issue.record("Generation publication gate expired") } + } + try lease.check() + return try generate(at: projectURL, files: files, modulePaths: modulePaths, javaEntrypoints: javaEntrypoints, projectOwnership: projectOwnership) + } + func inspectProviderGeneration(at projectURL: URL, projectOwnership: [RunProjectOwnership], providerRuns: [RunProviderConfiguration]) -> ProjectRunConfigurationInspection { + runInspections.append(providerRuns) + return inspectProviderGeneration(at: projectURL, projectOwnership: projectOwnership) + } + func inspectProviderGeneration(at _: URL, projectOwnership: [RunProjectOwnership]) -> ProjectRunConfigurationInspection { + providerInspections.append(projectOwnership) + let stale = staleProviderModel + if let gate = providerInspectionGate { + gate.entered.continuation.yield(()) + // The synchronous port runs on RunService's utility queue; the + // test releases it and every assertion path has cleanup/deadline. + if gate.release.wait(timeout: .now() + 2) != .success { Issue.record("Provider inspection gate expired") } + } + return ProjectRunConfigurationInspection(status: .ready, diagnostics: stale + ? [RunConfigurationDiagnostic(configurationID: nil, code: "staleProviderModel", message: "regenerate")] : []) + } private(set) var fingerprintChecks: [Bool] = [] private(set) var comparedEntrypoints: [JavaEntrypoints] = [] private let custom = RunConfiguration( @@ -2891,13 +2285,14 @@ private final class FreshnessRecordingRunConfigurationOperations: RunConfigurati at _: URL, files _: [URL], modulePaths _: [String], - javaEntrypoints _: JavaEntrypoints? + javaEntrypoints _: JavaEntrypoints?, + projectOwnership _: [RunProjectOwnership]? = nil ) throws -> RunConfigurationGenerationResult { RunConfigurationGenerationResult(entryCount: 1) } func resolve(at _: URL, toolchainCandidates _: [ProjectToolchainCandidate]) throws -> RunConfigurationResolution { RunConfigurationResolution( - configurations: [.currentFile, custom].map { + configurations: [.currentFile, providerConfiguration ?? custom].map { EffectiveRunConfiguration(configuration: $0, options: RunOptions()) }, diagnostics: [], @@ -2926,7 +2321,8 @@ private struct FixedLaunchPlanRunConfigurationOperations: RunConfigurationOperat at _: URL, files _: [URL], modulePaths _: [String], - javaEntrypoints _: JavaEntrypoints? + javaEntrypoints _: JavaEntrypoints?, + projectOwnership _: [RunProjectOwnership]? = nil ) throws -> RunConfigurationGenerationResult { RunConfigurationGenerationResult(entryCount: 1) } @@ -3054,9 +2450,10 @@ private func makeRunService( @MainActor func makeBuildSystemTestRuntime( - _ buildSystems: BuildSystemService + _ buildSystems: BuildSystemService, + runOperations: any RunConfigurationOperations = TestRunConfigurationOperations() ) async throws -> (runtime: ModuleRuntime, graph: ExecutionFeatureGraph) { - let graph = makeTestGraph(buildSystems: buildSystems) + let graph = makeTestGraph(runOperations: runOperations, buildSystems: buildSystems) let runtime = ModuleRuntime() try runtime.register(ModuleFactory( manifest: ModuleManifest(id: .workspace, displayName: "Workspace", scope: .workspace) @@ -3071,19 +2468,11 @@ func makeBuildSystemTestRuntime( @MainActor private func makeTestGraph( - mavenOperations: any MavenProjectOperations = TestMavenOperations(), - mavenService: MavenService? = nil, runOperations: any RunConfigurationOperations = TestRunConfigurationOperations(), buildSystems: BuildSystemService? = nil ) -> ExecutionFeatureGraph { let runtime = TestRuntime() let resolver = TestExecutableResolver() - let maven = mavenService ?? MavenService( - runtimeService: runtime, - process: TestStreamingProcess(), - dependencyProcess: TestStreamingProcess(), - mavenOperations: mavenOperations - ) let run = RunService( runtime: runtime, process: TestStreamingProcess(), @@ -3102,36 +2491,7 @@ private func makeTestGraph( executableResolver: resolver, processFactory: { TestStreamingProcess() } ) - return ExecutionFeatureGraph(maven: maven, run: run, tests: tests, buildSystems: buildSystems) -} - -@MainActor -private func dependencyState( - _ service: MavenService, - modulePath: String, - matching: @escaping @Sendable (MavenDependencyLoadState) -> Bool -) async -> MavenDependencyLoadState? { - let stateTask: Task = Task { @MainActor in - let initial = service.dependencyState(for: modulePath) - if matching(initial) { return initial } - for await states in service.$dependencyStates.values { - let state = states[modulePath] ?? .idle - if matching(state) { return state } - } - return nil - } - return await withTaskGroup(of: MavenDependencyLoadState?.self) { group in - group.addTask { await stateTask.value } - group.addTask { - // test-stability: allow(swift-real-sleep) reason: this task is the bounded failure deadline for event-driven dependency-state observation. - try? await ContinuousClock().sleep(for: .seconds(1)) - return nil - } - let result = await group.next() ?? nil - group.cancelAll() - stateTask.cancel() - return result - } + return ExecutionFeatureGraph(run: run, tests: tests, buildSystems: buildSystems) } /// Subscribe synchronously so an event between setup and suspension is buffered. @@ -3184,59 +2544,12 @@ private func awaitSignal(_ stream: AsyncStream, timeout: Duration = .secon if !received { throw TestObservationError.deadlineExceeded } } -private final class TestResultParserRecorder: @unchecked Sendable { - private let lock = NSLock() - private let result: MavenTestResults? - private var recordedCalls = 0 - private var recordedOutput = "" - private var recordedMainThread = false - private var recordedReports: MavenTestReportRequest? - - init(result: MavenTestResults?) { - self.result = result - } - - func parse(output: String, rootURL: URL, reports: MavenTestReportRequest?) -> MavenTestResults? { - lock.lock() - recordedCalls += 1 - recordedOutput = output - recordedReports = reports - recordedMainThread = Thread.isMainThread - lock.unlock() - return result - } - - var reports: MavenTestReportRequest? { - lock.lock() - defer { lock.unlock() } - return recordedReports - } - - var calls: Int { - lock.lock() - defer { lock.unlock() } - return recordedCalls - } - - var output: String { - lock.lock() - defer { lock.unlock() } - return recordedOutput - } - - var ranOnMainThread: Bool { - lock.lock() - defer { lock.unlock() } - return recordedMainThread - } -} - @MainActor -private final class TestRuntime: MavenRuntimePort, RunRuntimePort { +private final class TestRuntime: RunRuntimePort { var synchronousToolchainCalls = 0 var loadCandidates: (@MainActor (URL?) async throws -> [ProjectToolchainCandidate])? func loadRunConfigurationToolchainCandidates( - for project: MavenProject?, projectRoot: URL?, + projectRoot: URL?, javaHomeOverride: String?, mavenExecutableOverride: String? ) async throws -> [ProjectToolchainCandidate] { try await loadCandidates?(projectRoot) ?? [] @@ -3249,13 +2562,10 @@ private final class TestRuntime: MavenRuntimePort, RunRuntimePort { self.mavenJavaHome = mavenJavaHome } - func mavenExecutable(for project: MavenProject, overridePath: String?) -> URL? { nil } - func mavenProcessEnvironment(javaHomePath: String?) -> [String: String] { [:] } func setActiveServiceJavaHomePath(_ path: String) {} func javaHomeURL(overridePath: String?) -> URL? { javaHome } func mavenJavaHomeURL(overridePath: String?) -> URL? { mavenJavaHome } func runConfigurationToolchainCandidates( - for project: MavenProject?, projectRoot: URL?, javaHomeOverride: String?, mavenExecutableOverride: String? @@ -3282,331 +2592,6 @@ private final class TestStreamingProcess: StreamingProcess, @unchecked Sendable func stop() { isRunning = false } } -private enum ReloadTestError: Error { case failed } - -@MainActor -private func makeReloadService() async -> (MavenService, URL) { - let root = URL(fileURLWithPath: "/workspace", isDirectory: true) - let service = MavenService( - runtimeService: TestRuntime(), process: TestStreamingProcess(), - dependencyProcess: TestStreamingProcess(), mavenOperations: ReloadMavenOperations() - ) - await service.loadProject(at: root, files: [root.appendingPathComponent("old")]) - return (service, root) -} - -private final class ReloadMavenOperations: MavenProjectOperations, @unchecked Sendable { - private let lock = NSLock() - private let scanGate: ReloadScanGate? - private let scanArtifacts: [String] - private var recordedScanCount = 0 - - init(scanGate: ReloadScanGate? = nil, scanArtifacts: [String] = []) { - self.scanGate = scanGate - self.scanArtifacts = scanArtifacts - } - - var scanCount: Int { - lock.lock() - defer { lock.unlock() } - return recordedScanCount - } - - func scanMavenProject(at rootURL: URL, files: [URL]) throws -> MavenProject? { - lock.lock() - let scanIndex = recordedScanCount - recordedScanCount += 1 - lock.unlock() - let name = scanArtifacts.indices.contains(scanIndex) - ? scanArtifacts[scanIndex] - : files.first?.lastPathComponent ?? "old" - if let scanGate { - scanGate.entered.continuation.yield(()) - // The full test lane can delay the main-actor release while many - // suites start; keep deadlocks bounded without treating that load as failure. - guard scanGate.release.wait(timeout: .now() + 10) == .success else { - Issue.record("Maven scan was not released before its deadline") - throw ReloadTestError.failed - } - } - if name == "invalid" { throw ReloadTestError.failed } - return MavenProject( - rootURL: rootURL, pomURL: rootURL.appendingPathComponent("pom.xml"), - groupID: "example", artifactID: name, version: "1", packaging: "jar", - modules: [], profiles: [MavenProfile(id: name, isActiveByDefault: false)], hasWrapper: false - ) - } - func mavenLaunchPlan(at rootURL: URL, context: MavenLaunchContext, module: String?, goals: [String]) throws -> MavenLaunchPlan { - MavenLaunchPlan(version: 1, toolchain: "project-maven", arguments: goals, - workingDirectory: ".", configurationFingerprint: context.skipTests ? "skip" : "run") - } - func mavenDiagnostics(output: String, projectRoot: URL) -> [MavenBuildIssue] { [] } -} - -private final class ReloadScanGate: Sendable { - let entered = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) - let release = DispatchSemaphore(value: 0) -} - -private struct TestMavenOperations: MavenProjectOperations { - func scanMavenProject(at rootURL: URL, files: [URL]) throws -> MavenProject? { nil } - func mavenLaunchPlan( - at rootURL: URL, - context: MavenLaunchContext, - module: String?, - goals: [String] - ) throws -> MavenLaunchPlan { - MavenLaunchPlan( - version: 1, - toolchain: "project-maven", - arguments: goals, - workingDirectory: ".", - configurationFingerprint: "test" - ) - } - func mavenDiagnostics(output: String, projectRoot: URL) -> [MavenBuildIssue] { [] } -} - -private final class RecordingMavenOperations: MavenProjectOperations, @unchecked Sendable { - private let lock = NSLock() - private let project: MavenProject - private let plan: MavenLaunchPlan - private let dependencyTree: MavenDependencyTree - private var recordedContext: MavenLaunchContext? - private var recordedModule: String? - private var recordedGoals: [String] = [] - private var recordedDependencyModule: String? - private var recordedDependencyPlanFile: URL? - private var recordedDependencyReadFile: URL? - - init( - project: MavenProject, - plan: MavenLaunchPlan, - dependencyTree: MavenDependencyTree = MavenDependencyTree(modulePath: ".", dependencies: []) - ) { - self.project = project - self.plan = plan - self.dependencyTree = dependencyTree - } - - var lastContext: MavenLaunchContext? { - lock.lock() - defer { lock.unlock() } - return recordedContext - } - - var lastModule: String? { - lock.lock() - defer { lock.unlock() } - return recordedModule - } - - var lastGoals: [String] { - lock.lock() - defer { lock.unlock() } - return recordedGoals - } - - var lastDependencyModule: String? { - lock.lock() - defer { lock.unlock() } - return recordedDependencyModule - } - - var lastDependencyPlanFile: URL? { - lock.lock() - defer { lock.unlock() } - return recordedDependencyPlanFile - } - - var lastDependencyReadFile: URL? { - lock.lock() - defer { lock.unlock() } - return recordedDependencyReadFile - } - - func scanMavenProject(at rootURL: URL, files: [URL]) throws -> MavenProject? { - project - } - - func mavenLaunchPlan( - at rootURL: URL, - context: MavenLaunchContext, - module: String?, - goals: [String] - ) throws -> MavenLaunchPlan { - lock.lock() - recordedContext = context - recordedModule = module - recordedGoals = goals - lock.unlock() - return plan - } - - func mavenDiagnostics(output: String, projectRoot: URL) -> [MavenBuildIssue] { [] } - - func mavenDependencyPlan( - at rootURL: URL, - context: MavenLaunchContext, - module: String?, - outputFile: URL - ) throws -> MavenLaunchPlan { - lock.lock() - recordedDependencyModule = module - recordedDependencyPlanFile = outputFile - lock.unlock() - return plan - } - - func mavenDependencies(modulePath: String, outputFile: URL) throws -> MavenDependencyTree { - lock.lock() - recordedDependencyReadFile = outputFile - lock.unlock() - return dependencyTree - } -} - -/// Records the scratch files a dependency operation creates and removes. -private final class RecordingDependencyOutputs: MavenDependencyOutputStoring, @unchecked Sendable { - private let lock = NSLock() - private var createdFiles: [URL] = [] - private var removedFiles: [URL] = [] - - var created: [URL] { - lock.lock() - defer { lock.unlock() } - return createdFiles - } - - var removed: [URL] { - lock.lock() - defer { lock.unlock() } - return removedFiles - } - - func makeDependencyOutputFile(operationID: String) throws -> URL { - let file = URL(fileURLWithPath: "/scratch/\(operationID).txt") - lock.lock() - createdFiles.append(file) - lock.unlock() - return file - } - - func removeDependencyOutputFile(_ fileURL: URL) { - lock.lock() - removedFiles.append(fileURL) - lock.unlock() - } -} - -private final class FingerprintingMavenOperations: MavenProjectOperations, @unchecked Sendable { - private let project: MavenProject - - init(project: MavenProject) { - self.project = project - } - - func scanMavenProject(at rootURL: URL, files: [URL]) throws -> MavenProject? { - project - } - - func mavenLaunchPlan( - at rootURL: URL, - context: MavenLaunchContext, - module: String?, - goals: [String] - ) throws -> MavenLaunchPlan { - MavenLaunchPlan( - version: 1, - toolchain: "project-maven", - arguments: goals, - workingDirectory: context.reactorPath, - configurationFingerprint: context.skipTests ? "sha256:skip-tests" : "sha256:run-tests" - ) - } - - func mavenDiagnostics(output: String, projectRoot: URL) -> [MavenBuildIssue] { [] } -} - -private final class RecordingMavenConfigurationStore: MavenConfigurationStoring, @unchecked Sendable { - private let configuration: MavenStoredConfiguration - private let saveError: String? - - init(configuration: MavenStoredConfiguration, saveError: String? = nil) { - self.configuration = configuration - self.saveError = saveError - } - - func loadMavenConfiguration( - workspaceURL: URL, - reactorPath: String - ) throws -> MavenStoredConfiguration { - configuration - } - - func saveMavenConfiguration( - _ configuration: MavenStoredConfiguration, - workspaceURL: URL, - reactorPath: String - ) throws { - if let saveError { - throw NSError(domain: "MavenConfigurationFixture", code: 1, userInfo: [NSLocalizedDescriptionKey: saveError]) - } - } -} - -@MainActor -private final class MavenRecordingRuntime: MavenRuntimePort { - private(set) var lastMavenOverride: String? - - func mavenExecutable(for project: MavenProject, overridePath: String?) -> URL? { - lastMavenOverride = overridePath - return URL(fileURLWithPath: "/test/bin/mvn") - } - - func mavenProcessEnvironment(javaHomePath: String?) -> [String: String] { - ["TEST_JAVA_HOME": javaHomePath ?? ""] - } -} - -private final class MavenRecordingProcess: StreamingProcess, @unchecked Sendable { - var isRunning = false - var onOutput: (@Sendable (String) -> Void)? - var onTermination: (@Sendable (Int32) -> Void)? - var onStateChange: (@Sendable (ProcessLifecycleEvent) -> Void)? - - private let stream: AsyncStream - private let continuation: AsyncStream.Continuation - - init() { - (stream, continuation) = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(4)) - } - - func start(_ request: ProcessRequest) throws { - isRunning = true - continuation.yield(request) - } - - func send(_ input: Data) throws {} - func stop() { isRunning = false } - - func nextStart(timeout: Duration) async -> ProcessRequest? { - await withTaskGroup(of: ProcessRequest?.self) { group in - let stream = stream - group.addTask { - var iterator = stream.makeAsyncIterator() - return await iterator.next() - } - group.addTask { - try? await ContinuousClock().sleep(for: timeout) - return nil - } - let first = await group.next() ?? nil - group.cancelAll() - return first - } - } -} private struct TestRunFileAccess: RunFileAccess { let contents: [URL: String] @@ -3667,13 +2652,15 @@ private struct FixedServerPortParser: RunServerPortParsing { func serverPort(content _: String, fileExtension _: String) -> Int? { port } } -private final class MavenContextRunOperations: RunConfigurationOperations, @unchecked Sendable { +final class MavenContextRunOperations: RunConfigurationOperations, @unchecked Sendable { let configuration: RunConfiguration private let lock = NSLock() private var recordedContext: MavenLaunchContext? private var didCall = false + private var recordedCallCount = 0 var context: MavenLaunchContext? { lock.lock(); defer { lock.unlock() }; return recordedContext } var called: Bool { lock.lock(); defer { lock.unlock() }; return didCall } + var callCount: Int { lock.lock(); defer { lock.unlock() }; return recordedCallCount } init(configuration: RunConfiguration) { self.configuration = configuration } func inspect(at _: URL) -> ProjectRunConfigurationInspection { ProjectRunConfigurationInspection(status: .ready, diagnostics: []) @@ -3686,7 +2673,8 @@ private final class MavenContextRunOperations: RunConfigurationOperations, @unch at _: URL, files _: [URL], modulePaths _: [String], - javaEntrypoints _: JavaEntrypoints? + javaEntrypoints _: JavaEntrypoints?, + projectOwnership _: [RunProjectOwnership]? = nil ) throws -> RunConfigurationGenerationResult { RunConfigurationGenerationResult(entryCount: 1) } @@ -3698,6 +2686,7 @@ private final class MavenContextRunOperations: RunConfigurationOperations, @unch lock.lock() recordedContext = mavenContext didCall = true + recordedCallCount += 1 lock.unlock() throw RunConfigurationOperationFailure(message: "Fixture launch failure") } @@ -3716,7 +2705,8 @@ private struct SingleRunConfigurationOperations: RunConfigurationOperations { at _: URL, files _: [URL], modulePaths _: [String], - javaEntrypoints _: JavaEntrypoints? + javaEntrypoints _: JavaEntrypoints?, + projectOwnership _: [RunProjectOwnership]? = nil ) throws -> RunConfigurationGenerationResult { RunConfigurationGenerationResult(entryCount: 1) } @@ -3751,15 +2741,22 @@ private final class TestExecutableResolver: RunExecutableResolving { func candidates(projectURL: URL) -> [ProjectToolchainCandidate] { [] } } +private enum RunFixtureError: Error { case failed } + +private final class InventoryInspectionGate: Sendable { + let entered = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) + let release = DispatchSemaphore(value: 0) +} + private struct TestRunConfigurationOperations: RunConfigurationOperations { var toolchain = ProjectToolchainSelection() var rejectsResolutionWithoutGeneratedDocument = false var savedProvider: BuildProviderRunReference? = nil func saveBuildProviderConfiguration(_ reference: BuildProviderRunReference, name: String, at projectURL: URL) throws -> String { - guard reference == savedProvider else { throw ReloadTestError.failed } + guard reference == savedProvider else { throw RunFixtureError.failed } return "user:provider" } - var inspectionGate: ReloadScanGate? = nil + var inspectionGate: InventoryInspectionGate? = nil func inspect(at projectURL: URL) -> ProjectRunConfigurationInspection { if let inspectionGate { inspectionGate.entered.continuation.yield(()) @@ -3774,12 +2771,13 @@ private struct TestRunConfigurationOperations: RunConfigurationOperations { at projectURL: URL, files: [URL], modulePaths: [String], - javaEntrypoints: JavaEntrypoints? + javaEntrypoints: JavaEntrypoints?, + projectOwnership: [RunProjectOwnership]? = nil ) throws -> RunConfigurationGenerationResult { RunConfigurationGenerationResult(entryCount: 0) } func resolve(at projectURL: URL, toolchainCandidates: [ProjectToolchainCandidate]) throws -> RunConfigurationResolution { - if rejectsResolutionWithoutGeneratedDocument { throw ReloadTestError.failed } + if rejectsResolutionWithoutGeneratedDocument { throw RunFixtureError.failed } if let savedProvider { let configuration = RunConfiguration(id: "user:provider", name: "Main", kind: .process(provider: "build.provider"), modulePath: nil, mainClass: nil, buildProvider: savedProvider) @@ -3813,7 +2811,8 @@ private final class RecordingRunConfigurationOperations: RunConfigurationOperati at projectURL: URL, files: [URL], modulePaths: [String], - javaEntrypoints: JavaEntrypoints? + javaEntrypoints: JavaEntrypoints?, + projectOwnership: [RunProjectOwnership]? = nil ) throws -> RunConfigurationGenerationResult { generatedInventories.append(files) return RunConfigurationGenerationResult(entryCount: 1) @@ -3848,7 +2847,8 @@ private struct FailingInspectionRunConfigurationOperations: RunConfigurationOper at projectURL: URL, files: [URL], modulePaths: [String], - javaEntrypoints: JavaEntrypoints? + javaEntrypoints: JavaEntrypoints?, + projectOwnership: [RunProjectOwnership]? = nil ) throws -> RunConfigurationGenerationResult { RunConfigurationGenerationResult(entryCount: 0) } @@ -3870,7 +2870,8 @@ private struct TestReadyRunConfigurationOperations: RunConfigurationOperations { at projectURL: URL, files: [URL], modulePaths: [String], - javaEntrypoints: JavaEntrypoints? + javaEntrypoints: JavaEntrypoints?, + projectOwnership: [RunProjectOwnership]? = nil ) throws -> RunConfigurationGenerationResult { RunConfigurationGenerationResult(entryCount: 1) } @@ -3908,7 +2909,8 @@ private struct TestGoProjectRunConfigurationOperations: RunConfigurationOperatio at projectURL: URL, files: [URL], modulePaths: [String], - javaEntrypoints: JavaEntrypoints? + javaEntrypoints: JavaEntrypoints?, + projectOwnership: [RunProjectOwnership]? = nil ) throws -> RunConfigurationGenerationResult { RunConfigurationGenerationResult(entryCount: 1) } @@ -4018,68 +3020,35 @@ private final class TestLanguageExecutionSession: LanguageExecutionSession { func exportedCapabilities() -> [ModuleCapabilityID: AnyObject] { [:] } } -/// Editor Run markers read per-method outcomes from the reports a Maven test -/// run wrote; these pure helpers decide which reports and which outcomes. -struct MavenTestOutcomeTests { - private let root = URL(fileURLWithPath: "/work/app") - - @Test - func testCaseRunNamesItsClassAndSourceFile() { - let request = LanguageTestService.mavenReportRequest( - scope: .testCase( - identifier: "demo.OrderTest$Refunds#refunds()", - fileURL: root.appendingPathComponent("service/src/test/java/demo/OrderTest.java") - ), - workspaceURL: root, - startedAt: Date(timeIntervalSince1970: 1_700_000_000) - ) - #expect(request == MavenTestReportRequest( - sourcePath: "service/src/test/java/demo/OrderTest.java", - classes: ["demo.OrderTest$Refunds"], - notBeforeMillis: 1_700_000_000_000 - )) +/// Two saved Provider services. Any legacy goal-planning call fails the test. +private struct ProviderChoicesRunOperations: RunConfigurationOperations { + var withReference = true + var configurations: [RunConfiguration] { ["first", "second"].map { id in RunConfiguration(id: id, name: id, + kind: withReference ? .process(provider: "build.provider") : .mavenFramework(.quarkus), execution: .service, modulePath: id, mainClass: nil, mavenReactorPath: withReference ? nil : ".", + buildProvider: withReference ? .init(pluginID: "dev.first", buildSystemID: "maven", root: ".", run: "service", projectID: id, targetID: "service", execution: .service) : nil) } } + func inspect(at _: URL) -> ProjectRunConfigurationInspection { .init(status: .ready, diagnostics: []) } + func generate(at _: URL, files _: [URL], modulePaths _: [String], javaEntrypoints _: JavaEntrypoints?, projectOwnership _: [RunProjectOwnership]?) throws -> RunConfigurationGenerationResult { .init(entryCount: 2) } + func resolve(at _: URL, toolchainCandidates _: [ProjectToolchainCandidate]) throws -> RunConfigurationResolution { + .init(configurations: configurations.map { .init(configuration: $0, options: RunOptions()) }, diagnostics: [], defaultConfigurationID: configurations.first?.id) } - - @Test - func methodRerunPreservesFailuresOfOtherMethodsAndNestedClasses() { - let className = "demo.OrderTest" - let previous = [ - MavenTestCaseOutcome(className: className, method: "creates", status: "failed"), - MavenTestCaseOutcome(className: className, method: "deletes", status: "failed"), - MavenTestCaseOutcome(className: className + "$Nested", method: "creates", status: "failed"), - ] - let passed = MavenTestCaseOutcome(className: className, method: "creates", status: "passed") - let scope = LanguageTestScope.testCase(identifier: className + "#creates()", fileURL: root.appendingPathComponent("OrderTest.java")) - let method = LanguageTestService.mavenTestMethod(in: scope) - #expect(method == "creates") - #expect(LanguageTestService.mergedOutcomes( - previous, requestedClasses: [className], requestedMethod: method, recorded: [passed] - ) == [previous[1], previous[2], passed]) - #expect(LanguageTestService.mavenTestMethod(in: .workspace) == nil) - #expect(LanguageTestService.mavenTestMethod(in: .testCase(identifier: className, fileURL: nil)) == nil) + func providerLaunchInputs(at _: URL, configurationID: String, providerRoot _: String) throws -> ProviderLaunchInputs { + .init(buildSystemID: "maven", provider: "quarkus.maven", projectPath: configurationID, + launchOptions: .init(execution: .service, arguments: ["a b", "雪"]), + reference: withReference ? .init(pluginID: "dev.first", buildSystemID: "maven", root: ".", run: "service", projectID: configurationID, targetID: "service", execution: .service) : nil) } - - @Test - func runReplacesOnlyTheOutcomesOfClassesItCovered() { - let previous = [ - MavenTestCaseOutcome(className: "demo.OrderTest", method: "creates", status: "passed"), - MavenTestCaseOutcome(className: "demo.OrderTest$Refunds", method: "refunds", status: "passed"), - MavenTestCaseOutcome(className: "demo.OtherTest", method: "other", status: "failed"), - ] - let recorded = [ - MavenTestCaseOutcome(className: "demo.OrderTest", method: "creates", status: "failed", message: "boom"), - ] - - #expect(LanguageTestService.mergedOutcomes( - previous, - requestedClasses: ["demo.OrderTest"], - recorded: recorded - ) == [previous[2], recorded[0]]) - #expect(LanguageTestService.mergedOutcomes( - previous, - requestedClasses: [], - recorded: recorded - ) == [previous[2], recorded[0]]) + func launchPlan(at _: URL, configurationID _: String, currentFile _: String?, classPath _: String?, debugPort _: Int?) throws -> SharedLaunchPlan { + Issue.record("Provider-owned Maven service reached the legacy Core goal planner") + throw RunFixtureError.failed } + func createConfiguration(_ draft: RunConfigurationDraft, at _: URL) throws -> String { draft.name } + func migrateLegacySettings(at _: URL, configurationIDs _: [String]) throws {} +} + +@MainActor +private final class ProviderPreparationGate { + let entered = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) + let release = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) + let finished = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) + deinit { entered.continuation.finish(); release.continuation.finish(); finished.continuation.finish() } } diff --git a/macos/Tests/LitheLanguageIntelligenceModuleTests/LanguageIntelligenceModuleTests.swift b/macos/Tests/LitheLanguageIntelligenceModuleTests/LanguageIntelligenceModuleTests.swift index 9140d83951..7a2ab84104 100644 --- a/macos/Tests/LitheLanguageIntelligenceModuleTests/LanguageIntelligenceModuleTests.swift +++ b/macos/Tests/LitheLanguageIntelligenceModuleTests/LanguageIntelligenceModuleTests.swift @@ -7,6 +7,30 @@ import Testing @MainActor struct LanguageIntelligenceModuleTests { + @Test(arguments: ["file", "method-handler", "missing"]) + func buildTestSelectorsComeFromJdtAndRetainOpaqueFullNames(selection: String) async throws { + let root = URL(fileURLWithPath: "/workspace/semantic-tests") + let source = root.appendingPathComponent("custom-source/Unexpected.java") + let descriptor = try #require(LanguageProviderCatalog.compatibilityFallback.provider(for: source)) + let session = WorkspaceStateLanguageServerSession() + let manager = LanguageToolingSessionManager(runtimes: [WorkspaceStateLanguageProviderRuntime(descriptor: descriptor, session: session)]) + let task = Task { try await manager.javaBuildTestSelectors(fileURL: source, + identifier: selection == "file" ? nil : selection, rootURL: root) } + defer { task.cancel(); manager.stopAllLanguageServers() } + try await session.waitUntilStarted() + session.publish(.ready) + let request = try await session.waitForExecuteCommand() + #expect(request.command == "javaTestItems") + #expect(request.arguments == [.string(source.absoluteString)]) + session.completeJavaTestItems(.success(JavaTestItems(items: [ + JavaTestItem(id: "class", label: "Display class", fullName: "example.Actual$Nested", projectName: "module", kind: 0, level: 5, + children: [JavaTestItem(id: "method", label: "Display method", fullName: "example.Actual$Nested#works()", projectName: "module", + kind: 0, level: 6, jdtHandler: "method-handler")]) + ]))) + if selection == "missing" { await #expect(throws: (any Error).self) { try await task.value } } + else { #expect(try await task.value == [selection == "file" ? "example.Actual$Nested" : "example.Actual$Nested#works()"] ) } + } + @Test(arguments: [false, true]) func hostStagesAreTypedOnceAndReadyTerminationRemainLegacyOnly(cancelStartup: Bool) throws { let root = URL(fileURLWithPath: "/workspace/stages") @@ -240,7 +264,7 @@ struct LanguageIntelligenceModuleTests { localRepositoryPath: "/repository", skipTests: false, mavenExecutablePath: "/maven/bin/mvn", javaHomePath: nil ) - manager.configureMavenContextProvider { _, _ in context } + manager.configureJavaLanguageImportProvider { _, _ in .init(mavenContext: context) } defer { manager.stopLanguageServer(providerID: "java") } try manager.startLanguageServer(providerID: "java", rootURL: root) session.publish(.ready) @@ -268,11 +292,11 @@ struct LanguageIntelligenceModuleTests { catalog: .compatibilityFallback, runtimes: [WorkspaceStateLanguageProviderRuntime(descriptor: descriptor, session: session)] ) - manager.configureMavenContextProvider { _, _ in - MavenLaunchContext( + manager.configureJavaLanguageImportProvider { _, _ in + .init(mavenContext: MavenLaunchContext( reactorPath: ".", profiles: [], settingsPath: nil, skipTests: false, mavenExecutablePath: nil, javaHomePath: nil - ) + )) } try manager.startLanguageServer(providerID: "java", rootURL: root) session.publish(.ready) @@ -303,7 +327,6 @@ struct LanguageIntelligenceModuleTests { let next = MavenLaunchContext(reactorPath: ".", profiles: ["next"], settingsPath: "/settings.xml", skipTests: false, mavenExecutablePath: nil, javaHomePath: nil) var context = first let gradle = JavaGradleImportConfiguration(wrapperPresent: true, offline: true, userHome: "/gradle-cache") - manager.configureMavenContextProvider { _, _ in Issue.record("Plugin import must take precedence"); return nil } manager.configureJavaLanguageImportProvider { _, requestedRoot in #expect(requestedRoot == root) return JavaLanguageImportConfiguration(mavenContext: context, javaGradle: gradle) @@ -373,6 +396,46 @@ struct LanguageIntelligenceModuleTests { #expect(session.startCallCount == 0) } + @Test + func failedPluginImportDoesNotPublishEmptySettingsAndCanLaterClearItsInputs() throws { + let root = URL(fileURLWithPath: "/workspace/failed-import", isDirectory: true) + let descriptor = try #require(LanguageProviderCatalog.compatibilityFallback.provider(for: root.appendingPathComponent("Main.java"))) + let session = WorkspaceStateLanguageServerSession() + let manager = LanguageToolingSessionManager(runtimes: [WorkspaceStateLanguageProviderRuntime(descriptor: descriptor, session: session)]) + defer { manager.stopAllLanguageServers() } + var unavailable = false + var configuration = JavaLanguageImportConfiguration(mavenContext: MavenLaunchContext( + reactorPath: "nested", profiles: ["plugin"], settingsPath: "/plugin/settings.xml", + localRepositoryPath: "/plugin/repository", skipTests: false, + mavenExecutablePath: "/plugin/maven/bin/mvn", javaHomePath: nil + )) + manager.configureJavaLanguageImportProvider { _, _ in + if unavailable { throw CancellationError() } + return configuration + } + try manager.startLanguageServer(providerID: "java", rootURL: root) + unavailable = true + guard case .failed(let failure) = manager.updateJavaMavenConfiguration(rootURL: root, reloadProjects: false) else { + Issue.record("An unavailable current import must fail without sending replacement settings") + return + } + #expect(failure.code == "languageImportUnavailable") + #expect(session.mavenUpdates.isEmpty) + + // A failed read did not revoke the old session's ownership record. + // Once detection confirms removal, clear every old override through the same session. + unavailable = false + configuration = JavaLanguageImportConfiguration() + let update = LanguageServerMavenConfigurationUpdate(settingsChanged: true, projectsReloaded: false, profilesUpdating: false) + session.mavenUpdateResult = .success(update) + #expect(manager.updateJavaMavenConfiguration(rootURL: root, reloadProjects: false) == .updated(update)) + #expect(session.mavenUpdates.count == 1) + #expect(session.mavenUpdates.first?.context == MavenLaunchContext( + reactorPath: "nested", profiles: [], settingsPath: nil, localRepositoryPath: nil, + skipTests: false, mavenExecutablePath: nil, javaHomePath: nil + )) + } + @Test func mavenSettingsReachOnlyARunningJavaSessionOfTheSameWorkspace() throws { let root = URL(fileURLWithPath: "/workspace/java-settings", isDirectory: true) @@ -384,11 +447,11 @@ struct LanguageIntelligenceModuleTests { catalog: .compatibilityFallback, runtimes: [WorkspaceStateLanguageProviderRuntime(descriptor: descriptor, session: session)] ) - manager.configureMavenContextProvider { _, _ in - MavenLaunchContext( + manager.configureJavaLanguageImportProvider { _, _ in + .init(mavenContext: MavenLaunchContext( reactorPath: ".", profiles: [], settingsPath: nil, skipTests: false, mavenExecutablePath: nil, javaHomePath: nil - ) + )) } defer { manager.stopLanguageServer(providerID: "java") } @@ -676,10 +739,10 @@ struct LanguageIntelligenceModuleTests { session: session )] ) - manager.configureMavenContextProvider { requestedDescriptor, requestedRoot in + manager.configureJavaLanguageImportProvider { requestedDescriptor, requestedRoot in #expect(requestedDescriptor.id == "java") #expect(requestedRoot == root.standardizedFileURL) - return context + return .init(mavenContext: context) } try manager.startLanguageServer(providerID: "java", rootURL: root) @@ -967,6 +1030,72 @@ struct LanguageIntelligenceModuleTests { #expect(session.executedCommands.first?.arguments == [.string(source.path)]) } + @Test(arguments: ["success", "cold", "changed", "aba", "cancel", "import-success", "import-cold", "import-changed", "import-aba", "import-cancel"]) + func javaRunUsesThePreparedImportAndRejectsChangesDuringDiscovery(mode: String) async throws { + let importOnly = mode.hasPrefix("import-") + let phase = mode.replacingOccurrences(of: "import-", with: "") + let root = URL(fileURLWithPath: "/workspace/run-import") + let source = root.appendingPathComponent("app/custom/Main.java") + let descriptor = try #require(LanguageProviderCatalog.compatibilityFallback.provider(for: source)) + let session = WorkspaceStateLanguageServerSession() + session.mavenUpdateResult = .success(.init(settingsChanged: false, projectsReloaded: false, profilesUpdating: true)) + session.deferredJavaLaunch = true + session.javaLaunchResult = .success(.ready(JavaDebugLaunchTarget(mainClass: "demo.Main", projectName: "app", classPaths: ["/current/classes"]))) + let manager = LanguageToolingSessionManager(runtimes: [WorkspaceStateLanguageProviderRuntime(descriptor: descriptor, session: session)]) + let baseline = MavenLaunchContext(reactorPath: ".", profiles: ["default"], settingsPath: "/settings.xml", + skipTests: false, mavenExecutablePath: "/default/mvn", javaHomePath: nil) + let prepared = MavenLaunchContext(reactorPath: "app", profiles: ["run-only"], settingsPath: "/settings.xml", + skipTests: false, mavenExecutablePath: "/selected/mvn", javaHomePath: nil) + var current = baseline + manager.configureJavaLanguageImportProvider { _, _ in .init(mavenContext: current) } + if phase != "cold" { + try manager.startLanguageServer(providerID: "java", rootURL: root) + session.publish(.ready) + } + let task = Task { + if importOnly { + try await manager.applyJavaRunImport(rootURL: root, languageImport: .init(mavenContext: prepared)) + return JavaLaunchPreparation.ready(JavaDebugLaunchTarget(mainClass: "demo.Main", projectName: "app", classPaths: ["/current/classes"])) + } + return try await manager.prepareJavaRunLaunchTarget(fileURL: source, rootURL: root, + languageImport: .init(mavenContext: prepared)) + } + defer { task.cancel(); session.completeExecuteReturningValue(.failure(CancellationError())); session.completeJavaEntrypoints(.failure(CancellationError())); manager.stopAllLanguageServers() } + if phase == "cold" { try await session.waitUntilStarted(); session.publish(.ready) } + _ = try await session.waitForExecuteCommand() + #expect((phase == "cold" ? session.startedMavenContext : session.mavenUpdates.last?.context) == prepared) + #expect(current == baseline) + let revision = manager.javaImportRevision + if phase == "changed" || phase == "aba" { + _ = manager.updateJavaMavenConfiguration(rootURL: root, reloadProjects: false) + if phase == "aba" { + current = prepared + _ = manager.updateJavaMavenConfiguration(rootURL: root, reloadProjects: false) + } + #expect(manager.javaImportRevision > revision) + } + if phase == "cancel" { task.cancel() } + #expect(session.executedCommands.first?.command == (importOnly ? "javaEntrypoints" : "java.prepareLaunch")) + if importOnly { session.completeJavaEntrypoints(.success(JavaEntrypoints(entries: []))) } + else { session.completeExecuteReturningValue(.success(.null)) } + if ["changed", "aba", "cancel"].contains(phase) { + await #expect(throws: CancellationError.self) { try await task.value } + #expect(session.executedCommands.count == 1) + return + } + guard case .ready(let target) = try await task.value else { Issue.record("Expected current launch metadata"); return } + #expect(target.classPaths == ["/current/classes"]) + #expect(session.startCallCount == 1) + // Generation/discovery restores the current Build defaults rather than + // retaining the previous Run's temporary profile as a shadow setting. + let discovery = Task { try await manager.javaEntrypoints(rootURL: root) } + defer { discovery.cancel() } + _ = try await session.waitForExecuteCommand(number: 2) + #expect(session.mavenUpdates.last?.context == baseline) + session.completeJavaEntrypoints(.success(JavaEntrypoints(entries: []))) + #expect(try await discovery.value.entries.isEmpty) + } + @Test func javaServiceUpdateUsesTheSameAutomaticClasspathAsLaunch() async throws { let root = URL(fileURLWithPath: "/workspace/java-update", isDirectory: true) @@ -1110,7 +1239,7 @@ struct LanguageIntelligenceModuleTests { JavaTestItem( id: "service@example.UserServiceTest#logsOut", label: "logsOut()", - fullName: "example.UserServiceTest#logsOut", + fullName: "example.UserServiceTest#logsOut()", projectName: "service", kind: 0, level: 6, @@ -1152,7 +1281,12 @@ struct LanguageIntelligenceModuleTests { "example.AccountTest", "example.UserServiceTest", "example.UserServiceTest#logsIn", - "example.UserServiceTest#logsOut", + "example.UserServiceTest#logsOut()", + ]) + #expect(items.map(\.reportIdentity) == [ + .init(suite: "example.AccountTest"), .init(suite: "example.UserServiceTest"), + .init(suite: "example.UserServiceTest", name: "logsIn"), + .init(suite: "example.UserServiceTest", name: "logsOut") ]) } @@ -1818,12 +1952,14 @@ private final class WorkspaceStateLanguageServerSession: LanguageServerSession { var startError: Error? var javaLaunchResult: Result = .failure(WorkspaceStateSessionError.unexpectedOperation) + var deferredJavaLaunch = false func prepareJavaLaunch(fileURL: URL, mainClass: String?) async throws -> JavaLaunchPreparation { try Task.checkCancellation() - recordExecutedCommand(LanguageServerCommand( - title: "java.prepareLaunch", command: "java.prepareLaunch", - arguments: [.string(fileURL.path)] + (mainClass.map { [.string($0)] } ?? []) - )) + let command = LanguageServerCommand(title: "java.prepareLaunch", command: "java.prepareLaunch", + arguments: [.string(fileURL.path)] + (mainClass.map { [.string($0)] } ?? [])) + if deferredJavaLaunch { _ = try await executeReturningValue(command, fileURL: fileURL) } + else { recordExecutedCommand(command) } + try Task.checkCancellation() return try javaLaunchResult.get() } private(set) var executedCommands: [LanguageServerCommand] = [] diff --git a/macos/Tests/LitheTests/BuildNativeConfigurationTests.swift b/macos/Tests/LitheTests/BuildNativeConfigurationTests.swift new file mode 100644 index 0000000000..4ebb080947 --- /dev/null +++ b/macos/Tests/LitheTests/BuildNativeConfigurationTests.swift @@ -0,0 +1,280 @@ +import CryptoKit +import AppKit +import Darwin +import Foundation +import LitheCoreContracts +import LitheModuleAPI +import Testing +import SwiftUI +@testable import Lithe + +@Suite("Native build configuration files and drafts") +struct BuildNativeConfigurationTests { + @Test + func genericJsonReplacementRemainsPrivateUntilHashValidationAndAtomicCommit() throws { + let fixture = try NativeConfigurationFixture() + defer { fixture.cleanup() } + let original = Data(#"{"localRepository":null,"password":"fixture-private-value","other":{"keep":true}}"#.utf8) + try original.write(to: fixture.file) + let snapshot = try fixture.snapshot() + let store = fixture.store() + let staged = try #require(try store.prepare(target: fixture.target, snapshot: snapshot, + edits: fixture.edits(snapshot), stateDirectory: fixture.root.appendingPathComponent("state"))) + defer { try? store.discard(staged) } + let permissions = try FileManager.default.attributesOfItem(atPath: staged.outputFile.path) + #expect((permissions[.posixPermissions] as? NSNumber)?.intValue == 0o600) + let replacement = Data(#"{"localRepository":"/new/repository","password":"fixture-private-value","other":{"keep":true}}"#.utf8) + try replacement.write(to: staged.outputFile) + func receipt(_ revision: String) throws -> BuildNativeConfigurationEditReceipt { + try JSONDecoder().decode(BuildNativeConfigurationEditReceipt.self, from: JSONSerialization.data(withJSONObject: [ + "sourceID": "user", "revision": revision, "changed": true])) + } + #expect(throws: (any Error).self) { try store.complete(staged, receipt: receipt("sha256:" + String(repeating: "b", count: 64))) } + #expect(try Data(contentsOf: fixture.file) == original) + let revision = "sha256:" + SHA256.hash(data: replacement).map { String(format: "%02x", $0) }.joined() + let write = try #require(try store.complete(staged, receipt: receipt(revision))) + try store.discard(staged) + #expect(!FileManager.default.fileExists(atPath: staged.outputFile.path)) + #expect(try Data(contentsOf: fixture.file) == original) + try store.commit(write) + #expect(try Data(contentsOf: fixture.file) == replacement) + } + + @Test + func inheritedValuesPopulateWithoutCreatingOverridesAndCancelRestoresBaseline() throws { + let fixture = try NativeConfigurationFixture() + defer { fixture.cleanup() } + let snapshot = try fixture.snapshot() + let source = try #require(snapshot.sources.first) + let field = try #require(source.fields.first) + var draft = BuildNativeConfigurationDraft(source: source) + #expect(draft.text(field.id) == "/inherited/repository") + #expect(!draft.isDirty) + draft.setOverride(true, fieldID: field.id) + #expect(draft.edits.values[field.id] == .string("/inherited/repository")) + draft.setText("/edited/repository", field: field) + #expect(draft.isDirty) + draft = BuildNativeConfigurationDraft(source: source) + #expect(!draft.isDirty) + #expect(try fixture.text() == fixture.original) + } + + @Test + func savePreservesSecretsCommentsModeAndExtendedAttributes() throws { + let fixture = try NativeConfigurationFixture() + defer { fixture.cleanup() } + try FileManager.default.setAttributes([.posixPermissions: 0o640], ofItemAtPath: fixture.file.path) + let attribute = "com.lithe.fixture" + let marker = Data("retained".utf8) + let set = marker.withUnsafeBytes { bytes in + setxattr(fixture.file.path, attribute, bytes.baseAddress, bytes.count, 0, 0) + } + try #require(set == 0) + let snapshot = try fixture.snapshot() + let write = try #require(try fixture.prepare(target: fixture.target, snapshot: snapshot, edits: fixture.edits(snapshot))) + #expect(try fixture.text() == fixture.original) + try fixture.store().commit(write) + let text = try fixture.text() + #expect(text.contains("/new/repository")) + #expect(text.contains("")) + #expect(text.contains("test-secret-preserved")) + let attributes = try FileManager.default.attributesOfItem(atPath: fixture.file.path) + #expect((attributes[.posixPermissions] as? NSNumber)?.intValue == 0o640) + var buffer = [UInt8](repeating: 0, count: 32) + let count = getxattr(fixture.file.path, attribute, &buffer, buffer.count, 0, 0) + #expect(count == marker.count) + #expect(Data(buffer.prefix(max(0, count))) == marker) + #expect(try FileManager.default.contentsOfDirectory(atPath: fixture.root.path) == ["settings.xml"]) + } + + @Test + func externalEditsBeforePreparationAndInsideAtomicPublishAreRetained() throws { + let fixture = try NativeConfigurationFixture() + defer { fixture.cleanup() } + let snapshot = try fixture.snapshot() + try Data("external".utf8).write(to: fixture.file) + #expect(throws: (any Error).self) { + try fixture.prepare(target: fixture.target, snapshot: snapshot, edits: fixture.edits(snapshot)) + } + #expect(try fixture.text() == "external") + try Data(fixture.original.utf8).write(to: fixture.file) + let write = try #require(try fixture.prepare(target: fixture.target, snapshot: snapshot, edits: fixture.edits(snapshot))) + var storage = MacFileStorage() + let file = fixture.file + storage.afterPreparingTemporaryForTesting = { try? Data("external in exchange".utf8).write(to: file) } + #expect(throws: (any Error).self) { try fixture.store(storage: storage).commit(write) } + #expect(try fixture.text() == "external in exchange") + #expect(try FileManager.default.contentsOfDirectory(atPath: fixture.root.path) == ["settings.xml"]) + } + + @Test + func missingFileStaysMissingOnNoOpAndExplicitSaveCreatesPrivateFile() throws { + let fixture = try NativeConfigurationFixture() + defer { fixture.cleanup() } + try FileManager.default.removeItem(at: fixture.file) + let snapshot = try fixture.snapshot() + #expect(try fixture.prepare(target: fixture.target, snapshot: snapshot, + edits: BuildNativeConfigurationEdits(sourceID: "user", revision: "missing", values: [:])) == nil) + #expect(!FileManager.default.fileExists(atPath: fixture.file.path)) + let write = try #require(try fixture.prepare(target: fixture.target, snapshot: snapshot, edits: fixture.edits(snapshot))) + try fixture.store().commit(write) + #expect(try fixture.text().contains("/new/repository")) + let attributes = try FileManager.default.attributesOfItem(atPath: fixture.file.path) + #expect((attributes[.posixPermissions] as? NSNumber)?.intValue == 0o600) + } + + @Test + func packageRootsAndChangedSymlinkTargetsAreRejected() throws { + let fixture = try NativeConfigurationFixture() + defer { fixture.cleanup() } + let snapshot = try fixture.snapshot() + #expect(throws: (any Error).self) { + try fixture.prepare(immutableRoots: [fixture.root],target: fixture.target, snapshot: snapshot, edits: fixture.edits(snapshot)) + } + let link = fixture.root.appendingPathComponent("selected.xml") + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: fixture.file) + let linked = try fixture.snapshot(sourceURL: link) + let write = try #require(try fixture.prepare(target: fixture.target, snapshot: linked, edits: fixture.edits(linked))) + try FileManager.default.removeItem(at: link) + let other = fixture.root.appendingPathComponent("other.xml") + try Data(fixture.original.utf8).write(to: other) + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: other) + #expect(throws: (any Error).self) { try fixture.store().commit(write) } + #expect(try fixture.text() == fixture.original) + #expect(try String(contentsOf: other, encoding: .utf8) == fixture.original) + } +} + +@Suite("Native configuration settings controls", .serialized) +@MainActor +struct NativeConfigurationSettingsControlTests { + @Test(arguments: [600.0, 900.0], [false, true]) + func inheritedValueIsVisibleAndChoosingThenSavingRequiresAnExplicitOverride(width: Double, dark: Bool) async throws { + let fixture = try NativeConfigurationFixture() + defer { fixture.cleanup() } + let source = try #require(try fixture.snapshot().sources.first) + var picked = false + var saved: BuildNativeConfigurationEdits? + let selected = URL(fileURLWithPath: "/fixture/chosen repository") + let editor = NativeBuildConfigurationSourceEditor(source: source, busy: false, stale: false, + choosePath: { directory in picked = directory; return selected }, save: { saved = $0 }) + .padding(14).frame(width: width, height: 290, alignment: .topLeading) + .background(LitheTheme.settingsSurface) + .environment(\.colorScheme, dark ? .dark : .light) + let host = NSHostingView(rootView: editor) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: width, height: 290), + styleMask: [.borderless], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.appearance = NSAppearance(named: dark ? .darkAqua : .aqua) + window.contentView = host + defer { window.close() } + let application = NSApplication.shared + let accessibilityAttribute = NSAccessibility.Attribute(rawValue: "AXEnhancedUserInterface") + let previous = (application.accessibilityAttributeValue(accessibilityAttribute) as? Bool) ?? false + application.accessibilitySetValue(true, forAttribute: accessibilityAttribute) + defer { application.accessibilitySetValue(previous, forAttribute: accessibilityAttribute) } + let override = try #require(await element(in: host, role: .checkBox, label: L10n.string("Override"))) + let save = try #require(await element(in: host, role: .button, label: L10n.string("Save"))) + #expect(save.isAccessibilityEnabled?() == false) + #expect(descendants(host).compactMap { $0 as? NSTextField }.contains { $0.stringValue == "/inherited/repository" }) + #expect(override.accessibilityPerformPress?() == true) + let choose = try #require(await element(in: host, role: .button, label: L10n.string("Choose"), enabled: true)) + #expect(choose.accessibilityPerformPress?() == true) + #expect(picked) + let enabledSave = try #require(await element(in: host, role: .button, label: L10n.string("Save"), enabled: true)) + #expect(enabledSave.accessibilityPerformPress?() == true) + #expect(saved?.values["localRepository"] == .string(selected.path)) + #expect(try fixture.text() == fixture.original) + let field = try #require(descendants(host).compactMap { $0 as? NSTextField }.first { $0.stringValue == selected.path }) + #expect(host.bounds.contains(field.convert(field.bounds, to: host))) + if let directory = ProcessInfo.processInfo.environment["LITHE_NATIVE_CONFIGURATION_CAPTURES"] { + let bitmap = try nativeSRGBCapture(host) + let data = try #require(bitmap.representation(using: .png, properties: [:])) + let output = URL(fileURLWithPath: directory, isDirectory: true) + try FileManager.default.createDirectory(at: output, withIntermediateDirectories: true) + try data.write(to: output.appendingPathComponent("native-configuration-\(Int(width))-\(dark ? "dark" : "light").png")) + } + let cancel = try #require(await element(in: host, role: .button, label: L10n.string("Cancel"), enabled: true)) + #expect(cancel.accessibilityPerformPress?() == true) + #expect(await element(in: host, role: .button, label: L10n.string("Save"), enabled: false) != nil) + } + + private func descendants(_ view: NSView) -> [NSView] { view.subviews.flatMap { [$0] + descendants($0) } } + private func element(in host: NSView, role: NSAccessibility.Role, label: String, enabled: Bool? = nil) async -> AnyObject? { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(2)) + repeat { + host.layoutSubtreeIfNeeded() + for element in accessibleElements(host) { + guard element.accessibilityRole?() == role else { continue } + let title = element.accessibilityLabel?() ?? element.accessibilityTitle?() + guard title == label else { continue } + if let enabled, element.isAccessibilityEnabled?() != enabled { continue } + return element + } + await Task.yield() + } while clock.now < deadline + return nil + } + private func accessibleElements(_ element: Any) -> [AnyObject] { + let accessible = element as AnyObject + let children = accessible.accessibilityChildren?() ?? [] + let descendants: [Any] = children.isEmpty ? ((element as? NSView)?.subviews ?? []) : children + return [accessible] + descendants.flatMap(accessibleElements) + } +} + +private struct NativeConfigurationFixture { + let root: URL + let file: URL + let target: BuildSystemTarget + let planner = RustBuildSystemOperations(core: RustCoreBridge()) + let original = """ + + test-secret-preserved + """ + init() throws { + root = FileManager.default.temporaryDirectory.appendingPathComponent("lithe-native-settings-\(UUID().uuidString)") + file = root.appendingPathComponent("settings.xml") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let manifest = try Data(contentsOf: DeclarativePluginTestSupport.repositoryRoot.appendingPathComponent( + "shared/contracts/plugins/v1/fixtures/build-provider-configuration.plugin.json")) + target = BuildSystemTarget(manifest: manifest, buildSystemID: "gradle") + try Data(original.utf8).write(to: file) + } + func cleanup() { try? FileManager.default.removeItem(at: root) } + func text() throws -> String { try String(contentsOf: file, encoding: .utf8) } + func store(storage: MacFileStorage = MacFileStorage(), immutableRoots: [URL] = []) -> MacBuildNativeConfigurationStore { + MacBuildNativeConfigurationStore(storage: storage, planner: planner, immutableRoots: immutableRoots) + } + /// Platform tests supply private replacement bytes as a provider would; + /// Maven syntax editing is tested in the backend's integration suite. + func prepare(immutableRoots: [URL] = [], target: BuildSystemTarget, snapshot: BuildNativeConfiguration, + edits: BuildNativeConfigurationEdits) throws -> BuildNativeConfigurationPreparedWrite? { + let store = store(immutableRoots: immutableRoots) + let state = root.appendingPathComponent("state") + guard let staging = try store.prepare(target: target, snapshot: snapshot, edits: edits, stateDirectory: state) else { return nil } + defer { try? store.discard(staging); try? FileManager.default.removeItem(at: state) } + let data = Data((staging.expected.flatMap { String(data: $0, encoding: .utf8) } ?? "") + .replacingOccurrences(of: "", with: "/new/repository").utf8) + try data.write(to: staging.outputFile) + let receipt = try JSONDecoder().decode(BuildNativeConfigurationEditReceipt.self, from: JSONSerialization.data(withJSONObject: [ + "sourceID": "user", "revision": "sha256:" + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined(), "changed": true])) + return try store.complete(staging, receipt: receipt) + } + func snapshot(sourceURL: URL? = nil) throws -> BuildNativeConfiguration { + let data = try? Data(contentsOf: file) + let revision = data.map { "sha256:" + SHA256.hash(data: $0).map { String(format: "%02x", $0) }.joined() } ?? "missing" + let value: [String: Any] = ["format": "litheBuildConfiguration", "version": 1, "sources": [[ + "id": "user", "scope": "user", "uri": (sourceURL ?? file).absoluteString, "revision": revision, "writable": true, + "fields": [["id": "localRepository", "kind": "directory", "value": NSNull(), + "effectiveValue": "/inherited/repository", "effectiveSource": "default"]] + ]]] + let wire = try JSONDecoder().decode(ToolingJSONValue.self, from: JSONSerialization.data(withJSONObject: value)) + return try planner.decodeConfiguration(target, result: wire) + } + func edits(_ snapshot: BuildNativeConfiguration) -> BuildNativeConfigurationEdits { + BuildNativeConfigurationEdits(sourceID: "user", revision: snapshot.sources[0].revision, + values: ["localRepository": .string("/new/repository")]) + } +} diff --git a/macos/Tests/LitheTests/EditorTabLayoutTests.swift b/macos/Tests/LitheTests/EditorTabLayoutTests.swift index b4512cf9da..f61af54589 100644 --- a/macos/Tests/LitheTests/EditorTabLayoutTests.swift +++ b/macos/Tests/LitheTests/EditorTabLayoutTests.swift @@ -25,15 +25,12 @@ struct EditorTabLayoutTests { @MainActor @Test - func testTabScopeUsesImportedRootsIncludingHelpersAndCustomDirectories() { + func testTabScopeUsesImportedRolesBeforeSemanticFallback() { let root = URL(fileURLWithPath: "/workspace") - let project = MavenProject(rootURL: root, pomURL: root.appendingPathComponent("pom.xml"), - groupID: nil, artifactID: "example", version: nil, packaging: "jar", modules: [], profiles: [], hasWrapper: false, - sourceRoots: [.init(path: "checks/java", kind: .testJava), .init(path: "src/main/java", kind: .mainJava)]) - #expect(EditorDocument.isTestSourceFile(root.appendingPathComponent("checks/java/Helper.java"), project: project, hasTests: false)) - #expect(!EditorDocument.isTestSourceFile(root.appendingPathComponent("checks/java-copy/Helper.java"), project: project, hasTests: false)) - #expect(!EditorDocument.isTestSourceFile(root.appendingPathComponent("src/main/java/SomeTest.java"), project: project, hasTests: true)) - #expect(EditorDocument.isTestSourceFile(root.appendingPathComponent("Single.java"), project: nil, hasTests: true)) + #expect(EditorDocument.isTestSourceFile(root.appendingPathComponent("checks/java/Helper.java"), importedSourceRootKind: "testSource", hasTests: false)) + #expect(EditorDocument.isTestSourceFile(root.appendingPathComponent("fixtures/input.json"), importedSourceRootKind: "testResource", hasTests: false)) + #expect(!EditorDocument.isTestSourceFile(root.appendingPathComponent("src/main/java/SomeTest.java"), importedSourceRootKind: "source", hasTests: true)) + #expect(EditorDocument.isTestSourceFile(root.appendingPathComponent("Single.java"), importedSourceRootKind: nil, hasTests: true)) } @Test diff --git a/macos/Tests/LitheTests/IdeCapabilitiesTests.swift b/macos/Tests/LitheTests/IdeCapabilitiesTests.swift index dba277d35b..54e275180c 100644 --- a/macos/Tests/LitheTests/IdeCapabilitiesTests.swift +++ b/macos/Tests/LitheTests/IdeCapabilitiesTests.swift @@ -3,17 +3,95 @@ import Combine import Foundation import LitheCoreContracts import LitheExecutionModule +import LitheLanguageIntelligenceModule import Testing import SwiftUI @testable import Lithe @MainActor struct IdeCapabilitiesTests { + @Test + func environmentUsesCanonicalSettingsWithoutActivatingLegacyProjectLoading() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("agent-toolchains-" + UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + try Data("".utf8).write(to: settings) + try options.setOptions(["profiles": .array([.string("ci")]), "settingsFile": .string(settings.path)], + workspaceURL: fixture.root, buildSystemID: "maven") + let build = BuildSystemService(planner: RustBuildSystemOperations(core: core), + tools: CapabilityBuildToolsFixture(executable: maven), optionsStore: options, + stateLocator: MacBuildSystemStateLocator(directories: directories, fileStorage: MacFileStorage()), + runner: CapabilityBuildRunnerFixture(), providers: sessions, sources: { [source] }) + defer { build.stop() } + let actions = CapabilityActionsFixture(root: fixture.root) + let access = fixture.execution + actions.execution = ExecutionFeatureAccess(runFeature: access.runFeature, + tests: access.tests, projectDevelopment: access.projectDevelopment, buildSystems: BuildSystemsFeatureModel(service: build)) + let feature = IdeCapabilitiesFeatureModel(model: actions) + feature.allowExecute = true + feature.allowConfigure = true + feature.enable() + let result: Result + do { + build.reset(workspaceURL: fixture.root) + _ = try await build.prepareLanguageImport(workspaceURL: fixture.root) + let entry = try #require(build.entries.first) + let configured = await feature.call("lithe_build_configure", arguments: ["instanceID": entry.id, + "optionID": "skipTests", "value": true]) + #expect(configured["saved"] as? Bool == true) + #expect((configured["option"] as? [String: Any])?["value"] as? Bool == true) + #expect(try options.instanceOptions(workspaceURL: fixture.root, buildSystemID: "maven")[entry.instance.root]?["skipTests"] == .bool(true)) + #expect(try options.options(workspaceURL: fixture.root, buildSystemID: "maven")["skipTests"] == nil) + let invalid = await feature.call("lithe_build_configure", arguments: ["instanceID": entry.id, + "optionID": "skipTests", "value": "invalid"]) + #expect(invalid["error"] != nil) + #expect(try options.instanceOptions(workspaceURL: fixture.root, buildSystemID: "maven")[entry.instance.root]?["skipTests"] == .bool(true)) + let loaded = TestGate() + let modelObservation = build.$entries.sink { entries in + if entries.contains(where: { $0.id == entry.id && $0.modelState == .loaded }) { loaded.open() } + } + defer { modelObservation.cancel() } + let reload = await feature.call("lithe_build_reload", arguments: ["instanceID": entry.id]) + #expect(reload["reloadRequested"] as? Bool == true) + try #require(await loaded.waitUntilOpen(timeout: .seconds(60))) + let inspect = await feature.call("lithe_build_inspect", arguments: [:]) + let projects = try #require((inspect["instances"] as? [[String: Any]])?.first?["projects"] as? [[String: String]]) + let projectID = try #require(projects.first { $0["name"] == "core" }?["id"]) + let finished = TestGate() + let operationObservation = build.runEnded.sink { _ in finished.open() } + defer { operationObservation.cancel() } + let start = await feature.call("lithe_build_execute", arguments: ["instanceID": entry.id, + "taskID": "compile", "projectID": projectID]) + let id = try #require(start["operationID"] as? String) + try #require(await finished.waitUntilOpen(timeout: .seconds(60))) + let output = await feature.call("lithe_operation_output", arguments: ["operationID": id]) + #expect(output["state"] as? String == "completed") + #expect(output["exitCode"] as? Int32 == 0) + #expect((output["output"] as? String)?.contains("BUILD SUCCESS") == true) + #expect(FileManager.default.fileExists(atPath: fixture.root.appendingPathComponent( + "nested/core/target/classes/demo/core/Greeter.class").path)) + let stop = await feature.call("lithe_operation_stop", arguments: ["operationID": id]) + #expect(stop["stopRequested"] as? Bool == true) + #expect(build.operation?.state == .succeeded) + result = .success(()) + } catch { result = .failure(error) } + await feature.shutdown() + build.stop() + #expect(await released.waitUntilOpen(timeout: .seconds(5))) + #expect(try MacNativeBuildProviderIntegrationTests.inventory(package) == before) + try result.get() + } + @Test func revokedAndReplacedWorkspacesCannotReachProductActions() async { let actions = CapabilityActionsFixture() @@ -79,7 +332,7 @@ struct IdeCapabilitiesTests { actions.transport.validation = ["ok": false, "error": ["code": "PERMISSION_DENIED"]] let feature = IdeCapabilitiesFeatureModel(model: actions) feature.enable() - let result = await feature.call("lithe_maven_execute", arguments: ["goals": ["verify"]]) + let result = await feature.call("lithe_build_execute", arguments: ["instanceID": "maven@.", "taskID": "verify"]) #expect((result["error"] as? [String: String])?["code"] == "PERMISSION_DENIED") #expect(actions.activationCount == 0) await feature.shutdown() @@ -96,11 +349,48 @@ struct IdeCapabilitiesTests { #expect(replacement.executionID != first.executionID) } + @Test + func savedProviderServiceKeepsAgentIdentityThroughPreparationOutputAndStop() async throws { + let fixture = try ProviderCapabilityExecutionFixture(execution: .service) + defer { fixture.remove() } + let entered = TestGate(), release = TestGate(), launched = TestGate() + fixture.run.configureProviderConfigurationRuns { inputs, _, _, _, _, _, _ in + #expect(inputs.reference == fixture.reference) + entered.open() + guard await release.waitUntilOpen(timeout: .seconds(5)) else { throw CancellationError() } + return try JSONDecoder().decode(BuildRunPreparation.self, from: Data( + #"{"executable":{"kind":"command","value":"fixture"},"arguments":["Main"],"workingDirectory":".","environment":{}}"#.utf8)) + } + await fixture.run.loadProject(at: fixture.root, files: []) + let observer = fixture.run.$moduleSessions.sink { sessions in + if sessions.contains(where: { $0.output.contains("Main") }) { launched.open() } + } + defer { observer.cancel(); release.open() } + let actions = CapabilityActionsFixture(root: fixture.root) + actions.execution = fixture.execution + actions.startConfiguration = { fixture.run.startConfiguration($0) } + let feature = IdeCapabilitiesFeatureModel(model: actions) + feature.allowExecute = true; feature.enable() + try await WorkbenchTestScope.withCleanup({ await feature.shutdown() }) { _ in + let started = await feature.call("lithe_run_start", arguments: ["configurationID": fixture.configurationID]) + let operation = try #require(started["operationID"] as? String) + #expect(await entered.waitUntilOpen(timeout: .seconds(5))) + release.open() + #expect(await launched.waitUntilOpen(timeout: .seconds(5))) + let running = await feature.call("lithe_operation_output", arguments: ["operationID": operation]) + #expect(running["state"] as? String == "running") + #expect((running["output"] as? String)?.contains("Main") == true) + let stopped = await feature.call("lithe_operation_stop", arguments: ["operationID": operation]) + #expect(stopped["stopRequested"] as? Bool == true) + #expect(!fixture.run.hasRunningServices) + } + } + @Test func savedProviderRunKeepsItsAgentIdentityAcrossPreparationAndLaunch() async throws { let fixture = try ProviderCapabilityExecutionFixture() defer { fixture.remove() } - await fixture.run.loadProject(at: fixture.root, files: [], mavenProject: nil) + await fixture.run.loadProject(at: fixture.root, files: []) var preparations: [UUID] = [] var cancelled: [UUID] = [] let reference = fixture.reference @@ -189,12 +479,22 @@ struct IdeCapabilitiesTests { @MainActor private final class CapabilityTransportFixture: IdeHostTransport { var validation: [String: Any] = ["ok": true] + var validateWithCore = false + var openedExposure: [[String: Any]] = [] var closed = false - func open(workspace: URL, permissions: [String: Bool]) throws -> [String: Any] { - ["hostID": "fixture-host", "configuration": ["mcpServers": [String: String]()]] + func open(workspace: URL, permissions: [String: Bool], toolExposure: [[String: Any]]) throws -> [String: Any] { + openedExposure = toolExposure + return ["hostID": "fixture-host", "configuration": ["mcpServers": [String: String]()]] } func control(_ action: String, arguments: [String: Any]) throws -> [String: Any] { - if action == "validate" { return validation } + if action == "validate" { + if validateWithCore { + let payload = try #require(ToolingJSONValue.fromFoundation(["action": action, "arguments": arguments])) + let result: Result = RustCoreBridge().executeResult(command: "ideHost.control", payload: payload) + return try #require(try result.get().foundationObject as? [String: Any]) + } + return validation + } if action == "close" { closed = true } if action == "output" { return arguments["snapshot"] as? [String: Any] ?? [:] } return ["requests": []] @@ -211,7 +511,11 @@ private final class CapabilityActionsFixture: IdeCapabilityActions { var execution: ExecutionFeatureAccess? var startConfiguration: ((RunConfiguration) -> Void)? var currentWorkspaceIdentity: WorkspaceIdentity? - var runtimeFeature: RuntimeSettingsFeatureModel { preconditionFailure("Authorization tests must not inspect runtimes") } + var runtime: RuntimeSettingsFeatureModel? + var runtimeFeature: RuntimeSettingsFeatureModel { + guard let runtime else { preconditionFailure("Authorization tests must not inspect runtimes") } + return runtime + } var ideHostTransport: (any IdeHostTransport)? { transport } init(root: URL = URL(fileURLWithPath: "/fixture/project")) { self.root = root @@ -220,9 +524,6 @@ private final class CapabilityActionsFixture: IdeCapabilityActions { func isCurrentWorkspace(_ identity: WorkspaceIdentity) -> Bool { identity == currentWorkspaceIdentity } func activateExecutionModule() async -> ExecutionFeatureAccess? { activationCount += 1; return execution } func ensureRunProjectReady(_ run: RunFeatureModel, for identity: WorkspaceIdentity) async -> RunProjectReadiness { .stale } - func prepareProjectRuntimeSettings() async {} - func persistProjectRuntimeSettings() {} - func reloadMavenProject(rescan: Bool) async {} func startAPIConfiguration(_ configuration: RunConfiguration) async { startConfiguration?(configuration) } func registerIdeCapabilities(_ feature: IdeCapabilitiesFeatureModel) {} func unregisterIdeCapabilities() {} @@ -232,14 +533,15 @@ private final class CapabilityActionsFixture: IdeCapabilityActions { @MainActor private final class ProviderCapabilityExecutionFixture { let root: URL - let reference = BuildProviderRunReference(pluginID: "dev.example", buildSystemID: "fixture", root: ".", - run: "application", projectID: "project", targetID: "main", userArguments: []) + let reference: BuildProviderRunReference let configurationID: String let process = CapabilityProcessFixture() let run: RunService let execution: ExecutionFeatureAccess - init() throws { + init(execution: BuildExecution? = nil) throws { + reference = BuildProviderRunReference(pluginID: "dev.example", buildSystemID: "fixture", root: ".", + run: "application", projectID: "project", targetID: "main", userArguments: [], execution: execution) let core = RustCoreBridge() try #require(core.isAvailable) let root = FileManager.default.temporaryDirectory.appendingPathComponent("provider-agent-" + UUID().uuidString, isDirectory: true) @@ -256,12 +558,10 @@ private final class ProviderCapabilityExecutionFixture { serverPortParser: CapabilityPortParserFixture(), runConfigurationOperations: store, executableResolver: resolver, languageProviderCatalog: .compatibilityFallback, languageRunProviders: .standard(catalog: .compatibilityFallback)) - let maven = MavenFeatureModel(service: MavenService(runtimeService: runtime, process: CapabilityProcessFixture(), - dependencyProcess: CapabilityProcessFixture(), mavenOperations: RustJavaMavenOperations(core: core))) let runFeature = RunFeatureModel(service: run) - execution = ExecutionFeatureAccess(mavenFeature: maven, runFeature: runFeature, + self.execution = ExecutionFeatureAccess(runFeature: runFeature, tests: LanguageTestService(executableResolver: resolver, processFactory: { CapabilityProcessFixture() }), - projectDevelopment: ProjectDevelopmentFeatureModel(mavenFeature: maven, runFeature: runFeature), buildSystems: nil) + projectDevelopment: ProjectDevelopmentFeatureModel(runFeature: runFeature), buildSystems: nil) } catch { try? FileManager.default.removeItem(at: root) throw error @@ -270,7 +570,6 @@ private final class ProviderCapabilityExecutionFixture { func remove() { run.reset() - execution.mavenFeature.reset() execution.tests.stop() try? FileManager.default.removeItem(at: root) } @@ -288,15 +587,13 @@ private final class CapabilityProcessFixture: StreamingProcess, @unchecked Senda } @MainActor -private final class CapabilityRuntimeFixture: RunRuntimePort, MavenRuntimePort { - func mavenExecutable(for project: MavenProject, overridePath: String?) -> URL? { nil } - func mavenProcessEnvironment(javaHomePath: String?) -> [String: String] { [:] } +private final class CapabilityRuntimeFixture: RunRuntimePort { func setActiveServiceJavaHomePath(_ path: String) {} func javaHomeURL(overridePath: String?) -> URL? { nil } func mavenJavaHomeURL(overridePath: String?) -> URL? { nil } - func runConfigurationToolchainCandidates(for project: MavenProject?, projectRoot: URL?, + func runConfigurationToolchainCandidates(projectRoot: URL?, javaHomeOverride: String?, mavenExecutableOverride: String?) -> [ProjectToolchainCandidate] { [] } - func loadRunConfigurationToolchainCandidates(for project: MavenProject?, projectRoot: URL?, + func loadRunConfigurationToolchainCandidates(projectRoot: URL?, javaHomeOverride: String?, mavenExecutableOverride: String?) async throws -> [ProjectToolchainCandidate] { [] } } @@ -320,3 +617,55 @@ private final class CapabilityPreferencesFixture: KeyValueStore, RunPreferenceSt private struct CapabilityPortParserFixture: RunServerPortParsing { func serverPort(content: String, fileExtension: String) -> Int? { nil } } + +private struct CapabilityBuildError: Error {} + +@MainActor +private final class CapabilityBuildProviderFixture: BuildProviderRequesting { + var started = TestGate() + var release = TestGate() + let exitCode: Int32 + var requests: [BuildProviderRequest] = [] + var progress: (@MainActor @Sendable (BuildProviderProgress) -> Void)? + init(exitCode: Int32) { self.exitCode = exitCode } + func send(_ request: BuildProviderRequest, to target: BuildProviderTarget, timeout: Duration) async throws -> ToolingJSONValue { + throw CapabilityBuildError() + } + func prepareRun(_ request: BuildProviderRequest, on target: BuildProviderTarget, timeout: Duration, + progress: @escaping @MainActor @Sendable (BuildProviderProgress) -> Void) async throws -> ToolingJSONValue { + throw CapabilityBuildError() + } + func execute(_ request: BuildProviderRequest, on target: BuildProviderTarget, timeout: Duration, + progress: @escaping @MainActor @Sendable (BuildProviderProgress) -> Void) async throws -> BuildProviderExecutionResult { + requests.append(request) + self.progress = progress + started.open() + guard await release.waitUntilOpen() else { throw CancellationError() } + try Task.checkCancellation() + return BuildProviderExecutionResult(exitCode: exitCode, output: "early\nfinished\n") + } + func retainProviders(_ keep: (BuildProviderTarget) -> Bool) {} +} + +@MainActor +private final class CapabilityBuildToolsFixture: BuildToolResolving { + let executable: URL + init(executable: URL = URL(fileURLWithPath: "/fixture/tool")) { self.executable = executable } + func resolve(_ invocation: BuildInvocation, displayName: String, workspaceURL: URL, + checkVersion: @escaping @Sendable (String?, String) throws -> BuildVersionCheck) async throws -> ResolvedBuildTool { + ResolvedBuildTool(executableURL: executable, environment: [:], executesProjectCode: false, version: nil) + } +} + +private struct CapabilityBuildStateFixture: BuildSystemStateLocating { + let root: URL + func workspaceStateDirectory(pluginID: String, buildSystemID: String, workspaceURL: URL) throws -> URL { root } + func allocateOutputFile(in workspaceStateDirectory: URL) throws -> URL { root.appendingPathComponent(UUID().uuidString) } + func removeOutputFile(_ url: URL) {} +} + +private struct CapabilityBuildRunnerFixture: BuildCommandRunning { + func run(_ request: BuildCommandRequest, onOutput: @escaping @Sendable (String) -> Void) async throws -> BuildCommandResult { + throw CapabilityBuildError() + } +} diff --git a/macos/Tests/LitheTests/JavaTestDebugLaunchServiceTests.swift b/macos/Tests/LitheTests/JavaTestDebugLaunchServiceTests.swift index 7b4de67fd7..6cf5bd35ff 100644 --- a/macos/Tests/LitheTests/JavaTestDebugLaunchServiceTests.swift +++ b/macos/Tests/LitheTests/JavaTestDebugLaunchServiceTests.swift @@ -7,6 +7,37 @@ import Testing @Suite("Java test debug launch workflow") @MainActor struct JavaTestDebugLaunchServiceTests { + @Test + func stoppingOrReplacingRunPreparationRevokesIdenticalSelections() { + let state = JavaTestWorkflowState(notify: { _ in }) + let first = state.beginRunPreparation() + let next = state.beginRunPreparation() + state.finishRunPreparation(first) + #expect(!state.isCurrentRunPreparation(first)) + #expect(state.isCurrentRunPreparation(next)) + state.cancelRunPreparation() + #expect(!state.isCurrentRunPreparation(next)) + } + + @Test + func cancelledDebugTestCannotPrepareAfterTheSaveFinishes() async throws { + let state = JavaTestWorkflowState(notify: { _ in }) + let coordinator = JavaTestDebugWorkflowCoordinator(notify: { _ in }) + let saving = TestGate(), release = TestGate() + var launches = 0 + coordinator.start(request: JavaTestDebugRequest(fileURL: URL(fileURLWithPath: "/workspace/Test.java"), testIdentifier: nil), state: state, + prepareDirtyDocument: { saving.open(); return await release.waitUntilOpen(timeout: .seconds(2)) }, + prepareLaunch: { launches += 1; throw CancellationError() }, startDebug: { _ in false }, errorMessage: { nil }) + let task = try #require(state.debugLaunchTask) + defer { state.cancelDebugLaunch(); release.open() } + try #require(await saving.waitUntilOpen(timeout: .seconds(2))) + state.cancelDebugLaunch() + release.open() + await task.value + #expect(launches == 0) + #expect(state.resultServer == nil) + } + @Test func fileSelectionResolvesTargetStartsResultsAndBuildsSharedConfiguration() async throws { let root = URL(fileURLWithPath: "/workspace/java-tests", isDirectory: true) diff --git a/macos/Tests/LitheTests/LanguageTestPresentationTests.swift b/macos/Tests/LitheTests/LanguageTestPresentationTests.swift index 8602b55969..08a1560c2b 100644 --- a/macos/Tests/LitheTests/LanguageTestPresentationTests.swift +++ b/macos/Tests/LitheTests/LanguageTestPresentationTests.swift @@ -8,25 +8,53 @@ import Testing @MainActor struct LanguageTestPresentationTests { @Test - func resultTreeUsesReportStatusesAndNormalizesMethodSelectors() { + func sameNamedResultsRetainTheirProjectsAndReportRerunSelectors() throws { + let a = BuildTestReference(pluginID: "org.example.build", buildSystemID: "build", root: ".", projectID: "a", suite: "demo.Same", testCase: "checks") + let b = BuildTestReference(pluginID: "org.example.build", buildSystemID: "build", root: ".", projectID: "b", suite: "demo.Same", testCase: "checks") + let outcomes = [ + BuildTestResults.TestCase(project: "a", suite: "demo.Same", name: "checks", status: .passed, durationMillis: 10), + BuildTestResults.TestCase(project: "b", suite: "demo.Same", name: "checks", status: .failed, durationMillis: 20) + ] + let items = LanguageTestsView.synthesizeResultItems(from: outcomes, references: [BuildTestCaseKey(outcomes[0]): a, BuildTestCaseKey(outcomes[1]): b], providerID: "java") + #expect(items.count == 4) + #expect(Set(items.map(\.id)).count == 4) + let first = try #require(items.first { $0.projectPath == "a" && $0.depth == 2 }) + let second = try #require(items.first { $0.projectPath == "b" && $0.depth == 2 }) + let statuses = LanguageTestsView.resultStatuses(outcomes) + #expect(statuses[LanguageTestsView.resultKey(first)] == "Passed") + #expect(statuses[LanguageTestsView.resultKey(second)] == "Failed") + let durations = LanguageTestsView.reportDurations(outcomes) + #expect(durations[LanguageTestsView.resultKey(first)] == "10 ms") + #expect(durations[LanguageTestsView.resultKey(second)] == "20 ms") + #expect(first.buildReference == a) + #expect(second.buildReference == b) + #expect(items.first?.buildReference == a.suiteReference) + let scope = LanguageTestScope.testCase(identifier: "demo.Same#checks", fileURL: nil, buildReference: b) + #expect(scope.buildReference == b) + } + + @Test + func resultTreeUsesReportStatusesAndPreservesExactCaseNames() { let statuses = LanguageTestsView.resultStatuses([ - MavenTestCaseOutcome(className: "demo.Example", method: "passes", status: "passed"), - MavenTestCaseOutcome(className: "demo.Example", method: "skips", status: "skipped"), - MavenTestCaseOutcome(className: "demo.Example", method: "fails(String)[1]", status: "passed"), - MavenTestCaseOutcome(className: "demo.Example", method: "fails(String)[2]", status: "error"), - MavenTestCaseOutcome(className: "demo.Failed", method: "assertion", status: "failed"), - MavenTestCaseOutcome(className: "demo.Skipped", method: "disabled", status: "skipped"), - MavenTestCaseOutcome(className: "demo.Unknown", method: "pending", status: "unknown") + BuildTestResults.TestCase(suite: "demo.Example", name: "passes", status: .passed), + BuildTestResults.TestCase(suite: "demo.Example", name: "skips", status: .skipped), + BuildTestResults.TestCase(suite: "demo.Example", name: "fails(String)[1]", status: .passed), + BuildTestResults.TestCase(suite: "demo.Example", name: "fails(String)[2]", status: .error), + BuildTestResults.TestCase(suite: "demo.Failed", name: "assertion", status: .failed), + BuildTestResults.TestCase(suite: "demo.Skipped", name: "disabled", status: .skipped) ]) - #expect(statuses["demo.Example"] == "Error") - #expect(statuses["demo.Example#passes"] == "Passed") - #expect(statuses["demo.Example#skips"] == "Ignored") - #expect(statuses["demo.Example#fails"] == "Error") - #expect(statuses["demo.Failed"] == "Failed") - #expect(statuses["demo.Failed#assertion"] == "Failed") - #expect(statuses["demo.Skipped"] == "Ignored") - #expect(statuses["demo.Unknown"] == nil) - let item = testItem("method", selector: "demo.Example#passes()", depth: 2) + #expect(statuses[key("demo.Example")] == "Error") + #expect(statuses[key("demo.Example", "passes")] == "Passed") + #expect(statuses[key("demo.Example", "skips")] == "Ignored") + #expect(statuses[key("demo.Example", "fails(String)[1]")] == "Passed") + #expect(statuses[key("demo.Example", "fails(String)[2]")] == "Error") + #expect(statuses[key("demo.Example", "fails")] == nil) + #expect(statuses[key("demo.Failed")] == "Failed") + #expect(statuses[key("demo.Failed", "assertion")] == "Failed") + #expect(statuses[key("demo.Skipped")] == "Ignored") + let item = LanguageTestItem(id: "method", providerID: "example", label: "passes", kind: .testCase, + fileURL: nil, testIdentifier: "opaque-selector", depth: 2, + reportIdentity: .init(suite: "demo.Example", name: "passes")) #expect(statuses[LanguageTestsView.resultKey(item)] == "Passed") } @@ -85,10 +113,9 @@ struct LanguageTestPresentationTests { } } - @Test func unsuccessfulMavenRunOverridesZeroOrPassingReportCounts() { + @Test func unsuccessfulProviderRunOverridesZeroOrPassingReportCounts() { for passed in [0, 3] { - let results = MavenTestResults(testsRun: passed, failures: 0, errors: 0, skipped: 0, - passed: passed, success: true, failureDetails: []) + let results = BuildTestResults(tests: [], summary: .init(total: passed, passed: passed, failed: 0, errors: 0, skipped: 0), success: true) for state in [LanguageTestRunState.failed(exitCode: 1), .timedOut] { #expect(LanguageTestsView.runFailed(state)) #expect(LanguageTestsView.summaryIcon(for: results, state: state) == "expui/run/testError.svg") @@ -102,65 +129,56 @@ struct LanguageTestPresentationTests { for (failures, errors, passed, skipped, icon) in [(1, 0, 2, 1, "Failed"), (0, 1, 0, 0, "Error"), (0, 0, 2, 1, "PassedIgnored"), (0, 0, 1, 0, "Passed"), (0, 0, 0, 2, "Ignored"), (0, 0, 0, 0, "NotRunYet")] { - let results = MavenTestResults(testsRun: failures + errors + passed + skipped, - failures: failures, errors: errors, skipped: skipped, passed: passed, - success: failures + errors == 0, failureDetails: []) + let results = BuildTestResults(tests: [], summary: .init(total: failures + errors + passed + skipped, passed: passed, failed: failures, errors: errors, skipped: skipped), success: failures + errors == 0) #expect(LanguageTestsView.summaryIcon(for: results) == "expui/run/test\(icon).svg") } } @Test - func parseDurationsExtractsClassAndMethodDurations() { - let output = """ - [INFO] Running demo.CalculatorTest - Checking 2 + 3 - [INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.582 s -- in demo.CalculatorTest - [INFO] Results: - [INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 9.999 s - """ - let outcomes = [ - MavenTestCaseOutcome(className: "demo.CalculatorTest", method: "addsPositiveNumbers()", status: "passed", durationMillis: 17) - ] - let durations = LanguageTestsView.parseDurations(from: output, outcomes: outcomes) - #expect(durations["demo.CalculatorTest"] == "582 ms") - #expect(durations["CalculatorTest"] == "582 ms") - #expect(durations["demo.CalculatorTest#addsPositiveNumbers"] == "17 ms") - #expect(durations["addsPositiveNumbers"] == nil, "Method names from different classes must not share a duration") + func durationsAndCaseNamesComeDirectlyFromTheGenericReport() throws { + let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + let report = try JSONDecoder().decode(BuildTestResults.self, from: Data(contentsOf: + root.appendingPathComponent("shared/fixtures/build-provider/test-results-v1.json"))) + let durations = LanguageTestsView.reportDurations(report.tests) + #expect(durations[key("checkout / HTTP", "accepts(payload)[1]")] == "10 ms") + #expect(durations[key("checkout / HTTP", "accepts(payload)[2]")] == "125 ms") + #expect(durations[key("checkout / HTTP", "disabled")] == "0 ms") + #expect(durations[key("checkout / HTTP", "unmeasured")] == nil) + #expect(durations[key("checkout / HTTP")] == nil, "A report does not supply a suite duration") + let rows = LanguageTestsView.synthesizeResultItems(from: report.tests, providerID: "example") + #expect(rows.map(\.label) == ["checkout / HTTP", "accepts(payload)[1]", "accepts(payload)[2]", "disabled", "unmeasured"]) + #expect(Set(rows.map(\.id)).count == rows.count) } @Test - func methodDurationsComeFromReportsAndNeverFromASuiteSummary() throws { - struct Fixture: Decodable { let expected: [MavenTestCaseOutcome] } - let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent() - .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() - let fixture = try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: - root.appendingPathComponent("shared/fixtures/maven/testcase-durations-v1.json"))) - let durations = LanguageTestsView.parseDurations(from: "Time elapsed: 9.999 s", outcomes: fixture.expected) - #expect(durations["demo.DurationTest#fast"] == "10 ms") - #expect(durations["demo.DurationTest#slow"] == "125 ms") - #expect(durations["demo.DurationTest#repeat"] == "50 ms") - #expect(durations["demo.DurationTest#zero"] == "0 ms") - for method in ["missing", "partial", "invalid", "negative", "overflow"] { - #expect(durations["demo.DurationTest#" + method] == nil) - } - let unknown = MavenTestCaseOutcome(className: "demo.OtherTest", method: "fast", status: "passed") - #expect(LanguageTestsView.parseDurations(from: "Time elapsed: 9.999 s", outcomes: [unknown]).isEmpty) + func reportIdentityDoesNotCollideWhenNamesContainSeparators() { + let tests: [BuildTestResults.TestCase] = [ + .init(suite: "a#b", name: "c", status: .passed), + .init(suite: "a", name: "b#c", status: .failed), + .init(project: "module:part", suite: "a", name: "b#c", status: .skipped) + ] + let rows = LanguageTestsView.synthesizeResultItems(from: tests, providerID: "example") + #expect(Set(tests.map(\.id)).count == 3) + #expect(Set(rows.map(\.id)).count == rows.count) + let statuses = LanguageTestsView.resultStatuses(tests) + #expect(rows.filter { $0.depth == 2 }.map { statuses[LanguageTestsView.resultKey($0)] } == ["Passed", "Failed", "Ignored"]) } @Test func synthesizeResultItemsGeneratesClassAndMethodNodes() { let outcomes = [ - MavenTestCaseOutcome(className: "demo.CalculatorTest", method: "addsPositiveNumbers()", status: "passed"), - MavenTestCaseOutcome(className: "demo.CalculatorTest", method: "handlesZero()", status: "passed") + BuildTestResults.TestCase(suite: "demo.CalculatorTest", name: "addsPositiveNumbers()", status: .passed), + BuildTestResults.TestCase(suite: "demo.CalculatorTest", name: "handlesZero()", status: .passed) ] let items = LanguageTestsView.synthesizeResultItems(from: outcomes, providerID: "java") #expect(items.count == 3) - #expect(items[0].label == "CalculatorTest") + #expect(items[0].label == "demo.CalculatorTest") #expect(items[0].depth == 1) #expect(items[0].kind == .testCase) - #expect(items[1].label == "addsPositiveNumbers") + #expect(items[1].label == "addsPositiveNumbers()") #expect(items[1].depth == 2) - #expect(items[2].label == "handlesZero") + #expect(items[2].label == "handlesZero()") #expect(items[2].depth == 2) } @@ -169,29 +187,44 @@ struct LanguageTestPresentationTests { let source = URL(fileURLWithPath: "/fixture/Tests.java") let discovered = [LanguageTestItem(id: "file", providerID: "java", label: "Tests.java", kind: .file, fileURL: source, testIdentifier: nil, depth: 0)] - let outcomes = [MavenTestCaseOutcome(className: "demo.Tests", method: "passes()", status: "passed")] - let results = MavenTestResults(testsRun: 1, failures: 0, errors: 0, skipped: 0, passed: 1, - success: true, failureDetails: [], testCases: outcomes) + let outcomes = [BuildTestResults.TestCase(suite: "demo.Tests", name: "passes()", status: .passed)] + let results = BuildTestResults(tests: outcomes, summary: .init(total: 1, passed: 1, failed: 0, errors: 0, skipped: 0), success: true) let rows = LanguageTestsView.presentedItems(discovered: discovered, results: results, resultProviderID: "java", providerID: "java") #expect(rows.count == 2) for row in rows { let selected = try #require(LanguageTestsView.presentedItems(discovered: discovered, results: results, resultProviderID: "java", providerID: "java").first { $0.id == row.id }) #expect(selected.kind == .testCase) - #expect(selected.testIdentifier == (row.depth == 1 ? "demo.Tests" : "demo.Tests#passes")) + #expect(selected.testIdentifier == (row.depth == 1 ? "demo.Tests" : "demo.Tests#passes()")) #expect(selected.fileURL == nil, "A report does not establish semantic source ownership for Debug") } #expect(LanguageTestsView.presentedItems(discovered: discovered, results: nil, resultProviderID: "java", providerID: "java") == discovered) let semantic = LanguageTestItem(id: "jdt-method", providerID: "java", label: "passes()", - kind: .testCase, fileURL: source, testIdentifier: "demo.Tests#passes()", depth: 2) - #expect(LanguageTestsView.presentedItems(discovered: [semantic], results: results, resultProviderID: "java", providerID: "java") == [semantic]) + kind: .testCase, fileURL: source, testIdentifier: "demo.Tests#passes()", depth: 2, reportIdentity: .init(suite: "demo.Tests", name: "passes()")) + #expect(LanguageTestsView.presentedItems(discovered: [semantic], results: results, resultProviderID: "java", providerID: "java").first { $0.id == semantic.id } == semantic) + } + + @Test + func partialSemanticDiscoveryKeepsEveryReportedCaseAndOnlyExactSourceAssociations() throws { + let source = URL(fileURLWithPath: "/fixture/example.test") + let discovered = LanguageTestItem(id: "semantic", providerID: "example", label: "Known", kind: .testCase, + fileURL: source, testIdentifier: "opaque-engine-selector", depth: 2, reportIdentity: .init(suite: "Suite", name: "known")) + let tests: [BuildTestResults.TestCase] = [.init(suite: "Suite", name: "known", status: .passed), + .init(suite: "Suite", name: "orphan(payload)[1]", status: .failed)] + let report = BuildTestResults(tests: tests, summary: .init(total: 2, passed: 1, failed: 1, errors: 0, skipped: 0), success: false) + let rows = LanguageTestsView.presentedItems(discovered: [discovered], results: report, resultProviderID: "example", providerID: "example") + #expect(rows.count == 3) + let known = try #require(rows.first { $0.id == "semantic" }) + #expect(known.fileURL == source && known.testIdentifier == "opaque-engine-selector") + let orphan = try #require(rows.first { $0.label == "orphan(payload)[1]" }) + #expect(orphan.fileURL == nil) + #expect(LanguageTestsView.resultStatuses(tests)[LanguageTestsView.resultKey(orphan)] == "Failed") } @Test func currentResultsBelongOnlyToTheirProviderAndDisappearWhenCleared() { - let outcomes = [MavenTestCaseOutcome(className: "demo.Tests", method: "passes", status: "passed")] - let results = MavenTestResults(testsRun: 1, failures: 0, errors: 0, skipped: 0, passed: 1, - success: true, failureDetails: [], testCases: outcomes) + let outcomes = [BuildTestResults.TestCase(suite: "demo.Tests", name: "passes", status: .passed)] + let results = BuildTestResults(tests: outcomes, summary: .init(total: 1, passed: 1, failed: 0, errors: 0, skipped: 0), success: true) for provider in ["java", "node", "python"] { let discovered = [LanguageTestItem(id: "workspace-" + provider, providerID: provider, label: provider, kind: .workspace, fileURL: nil, testIdentifier: nil, depth: 0), @@ -230,6 +263,10 @@ struct LanguageTestPresentationTests { debugTarget: launchName, selectedTarget: target.name) == .resume) } + private func key(_ suite: String, _ name: String? = nil) -> LanguageTestsView.ResultKey { + .init(project: nil, suite: suite, name: name) + } + private func testItem(_ id: String, selector: String? = nil, depth: Int) -> LanguageTestItem { LanguageTestItem(id: id, providerID: "java", label: id, kind: .testCase, fileURL: nil, testIdentifier: selector, depth: depth) diff --git a/macos/Tests/LitheTests/LegacyMavenConfigurationMigrationTests.swift b/macos/Tests/LitheTests/LegacyMavenConfigurationMigrationTests.swift new file mode 100644 index 0000000000..d15623d644 --- /dev/null +++ b/macos/Tests/LitheTests/LegacyMavenConfigurationMigrationTests.swift @@ -0,0 +1,451 @@ +import CryptoKit +import Foundation +import LitheCoreContracts +import Testing +@testable import Lithe + +@Suite("Legacy Maven configuration transfer", .serialized) +@MainActor +struct LegacyMavenConfigurationMigrationTests { + @Test + func productionCutoverOnlyTransfersTheOfficialDetectedRootsAndPreservesNewDefaults() throws { + let context = try Context() + defer { context.clean() } + try context.legacy.saveMavenConfiguration(.init(portable: .init(selectedProfiles: ["ci"]), + local: .init(settingsPath: "/fixture/retired.xml")), workspaceURL: context.workspace, reactorPath: "a") + var document = BuildProjectOptionsDocument() + var system = BuildProjectOptionsDocument.System() + system.instanceDefaults["settingsFile"] = .string("/fixture/new-source.xml") + document.systems["maven"] = system + try context.options.saveProjectDocument(document, workspace: context.workspace, expecting: nil) + func instance(plugin: String) -> BuildSystemInstance { + BuildSystemInstance(id: "maven:a", pluginID: plugin, buildSystemID: "maven", displayName: "Maven", + root: "a", markers: ["a/pom.xml"], modelSource: .provider, optionFingerprint: "fixture", model: nil, modelError: nil) + } + #expect(!(try context.options.migrateLegacyConfiguration(workspaceURL: context.workspace, instances: []))) + #expect(!(try context.options.migrateLegacyConfiguration(workspaceURL: context.workspace, instances: [instance(plugin: "fixture.other-plugin")]))) + #expect(context.storage.fileExists(at: context.portable)) + #expect(try context.options.migrateLegacyConfiguration(workspaceURL: context.workspace, instances: [instance(plugin: "io.github.lithe-idea.maven-build")])) + #expect(!context.storage.fileExists(at: context.portable)) + #expect(!context.storage.fileExists(at: context.local("a"))) + #expect(try context.options.instanceDefaults(workspaceURL: context.workspace, buildSystemID: "maven")["settingsFile"] == .string("/fixture/new-source.xml")) + #expect(try context.options.instanceOptions(workspaceURL: context.workspace, buildSystemID: "maven")["a"]?["settingsFile"] == nil) + #expect(!(try context.options.migrateLegacyConfiguration(workspaceURL: context.workspace, instances: [instance(plugin: "io.github.lithe-idea.maven-build")]))) + } + + @Test + func runtimeMavenFieldsRemainPrivateDefaultsWhileExistingRootAndTeamChoicesWin() throws { + let context = try Context() + defer { context.clean() } + let key = MacProjectRuntimeSettingsStore.preferencesKey(context.workspace) + context.preferences.set(try JSONSerialization.data(withJSONObject: [ + "mavenSettingsPath": "/fixture/old-user-settings.xml", "mavenLocalRepositoryPath": "/fixture/private-repository", + "javaHomePath": "/fixture/unrelated-jdk", "futurePreference": ["keep": true] + ]), forKey: key) + var document = BuildProjectOptionsDocument() + var maven = BuildProjectOptionsDocument.System() + maven.instanceDefaults["settingsFile"] = .string("/fixture/team-settings.xml") + document.systems["maven"] = maven + try context.options.saveProjectDocument(document, workspace: context.workspace, expecting: nil) + let sharedBytes = try context.options.projectDocument(context.workspace).bytes + try context.options.setInstanceOptions(["b": ["localRepository": .string("")]], workspaceURL: context.workspace, buildSystemID: "maven") + #expect(try context.migration().migrate(workspace: context.workspace, reactorRoots: ["a", "b"])) + let defaults = try context.options.instanceDefaults(workspaceURL: context.workspace, buildSystemID: "maven") + #expect(defaults == ["settingsFile": .string("/fixture/team-settings.xml"), "localRepository": .string("/fixture/private-repository")]) + #expect(try context.options.instanceOptions(workspaceURL: context.workspace, buildSystemID: "maven")["b"]?["localRepository"] == .string("")) + #expect(try context.options.projectDocument(context.workspace).bytes == sharedBytes) + let saved = try #require(context.preferences.data(forKey: key)) + let cleaned = try #require(JSONSerialization.jsonObject(with: saved) as? [String: Any]) + #expect(cleaned["mavenSettingsPath"] == nil && cleaned["mavenLocalRepositoryPath"] == nil) + #expect(cleaned["javaHomePath"] as? String == "/fixture/unrelated-jdk") + #expect((cleaned["futurePreference"] as? [String: Bool])?["keep"] == true) + // Defaults continue to apply when another reactor appears after cutover. + #expect(!(try context.migration().migrate(workspace: context.workspace, reactorRoots: ["later"]))) + #expect(try context.options.instanceDefaults(workspaceURL: context.workspace, buildSystemID: "maven") == defaults) + #expect(context.preferences.migrationReceipts.isEmpty) + } + + @Test + func runtimeFieldsWaitForAReactorAndTheirFailedPublicationCanRetry() throws { + let context = try Context() + defer { context.clean() } + let key = MacProjectRuntimeSettingsStore.preferencesKey(context.workspace) + let bytes = try JSONSerialization.data(withJSONObject: ["mavenSettingsPath": "/fixture/settings.xml", "future": 1]) + context.preferences.set(bytes, forKey: key) + #expect(!(try context.migration().migrate(workspace: context.workspace, reactorRoots: []))) + #expect(context.preferences.data(forKey: key) == bytes) + context.preferences.refuseDefaults = true + #expect(throws: (any Error).self) { try context.migration().migrate(workspace: context.workspace, reactorRoots: ["."]) } + #expect(context.preferences.data(forKey: key) == bytes) + #expect(!context.preferences.migrationReceipts.isEmpty) + context.preferences.refuseDefaults = false + #expect(try context.migration().migrate(workspace: context.workspace, reactorRoots: ["."])) + #expect(try context.options.instanceDefaults(workspaceURL: context.workspace, buildSystemID: "maven")["settingsFile"] == .string("/fixture/settings.xml")) + #expect(context.preferences.migrationReceipts.isEmpty) + } + + @Test + func failedRuntimeSourceCleanupRetainsItsReceiptAndCanRetryAfterReadback() throws { + let context = try Context() + defer { context.clean() } + let key = MacProjectRuntimeSettingsStore.preferencesKey(context.workspace) + let bytes = try JSONSerialization.data(withJSONObject: ["mavenSettingsPath": "/fixture/settings.xml"]) + context.preferences.set(bytes, forKey: key) + context.preferences.refuseRuntimeWrites = true + #expect(throws: (any Error).self) { try context.migration().migrate(workspace: context.workspace, reactorRoots: ["."]) } + #expect(context.preferences.data(forKey: key) == bytes) + #expect(try context.options.instanceDefaults(workspaceURL: context.workspace, buildSystemID: "maven")["settingsFile"] == .string("/fixture/settings.xml")) + #expect(!context.preferences.migrationReceipts.isEmpty) + context.preferences.refuseRuntimeWrites = false + #expect(try context.migration().migrate(workspace: context.workspace, reactorRoots: ["."])) + #expect(context.preferences.migrationReceipts.isEmpty) + } + + @Test(arguments: [false, true]) + func emptyRuntimeFieldsDoNotCreateOverridesOrReplaceExistingDefaults(existing: Bool) throws { + let context = try Context() + defer { context.clean() } + let key = MacProjectRuntimeSettingsStore.preferencesKey(context.workspace) + if existing { + try context.options.setInstanceDefaults(["settingsFile": .string("/fixture/already-saved.xml")], workspaceURL: context.workspace, buildSystemID: "maven") + } + context.preferences.set(try JSONSerialization.data(withJSONObject: ["mavenSettingsPath": "", "mavenLocalRepositoryPath": " ", "future": true]), forKey: key) + #expect(try context.migration().migrate(workspace: context.workspace, reactorRoots: ["."])) + #expect(try context.options.instanceDefaults(workspaceURL: context.workspace, buildSystemID: "maven") == (existing ? ["settingsFile": .string("/fixture/already-saved.xml")] : [:])) + #expect(!context.storage.fileExists(at: context.workspace.appendingPathComponent(".lithe/build/options.json"))) + #expect(!context.storage.fileExists(at: context.workspace.appendingPathComponent(".lithe/run/local.json"))) + let saved = try #require(context.preferences.data(forKey: key)) + let fields = try #require(JSONSerialization.jsonObject(with: saved) as? [String: Bool]) + #expect(fields == ["future": true]) + #expect(context.preferences.migrationReceipts.isEmpty) + } + + @Test(arguments: ["wrong-type", "corrupt", "invalid-field", "source-change"]) + func invalidOrChangedRuntimeFieldsRetainTheirSource(kind: String) throws { + let context = try Context() + defer { context.clean() } + let key = MacProjectRuntimeSettingsStore.preferencesKey(context.workspace) + let original = try JSONSerialization.data(withJSONObject: ["mavenSettingsPath": "/fixture/original.xml"]) + let changed = try JSONSerialization.data(withJSONObject: ["mavenSettingsPath": "/fixture/external.xml"]) + switch kind { + case "wrong-type": context.preferences.set("retain me", forKey: key) + case "corrupt": context.preferences.set(Data("{broken".utf8), forKey: key) + case "invalid-field": context.preferences.set(try JSONSerialization.data(withJSONObject: ["mavenSettingsPath": 12]), forKey: key) + default: + context.preferences.set(original, forKey: key) + context.preferences.beforeWrite = { writtenKey in + if writtenKey.hasSuffix(".InstanceDefaults") { + context.preferences.beforeWrite = nil + context.preferences.set(changed, forKey: key) + } + } + } + #expect(throws: (any Error).self) { try context.migration().migrate(workspace: context.workspace, reactorRoots: ["."]) } + if kind == "source-change" { + #expect(context.preferences.data(forKey: key) == changed) + #expect(throws: (any Error).self) { try context.migration().migrate(workspace: context.workspace, reactorRoots: ["."]) } + #expect(context.preferences.data(forKey: key) == changed) + } else if kind == "wrong-type" { #expect(context.preferences.object(forKey: key) as? String == "retain me") } + else { #expect(context.preferences.data(forKey: key) != nil) } + } + + @Test + func transfersTeamChoicesAndReactorLocalPathsWithoutReplacingCanonicalValues() throws { + let context = try Context() + defer { context.clean() } + let portable = MavenPortableConfiguration(selectedProfiles: [], customProfiles: ["not-selected", "ci"], skipTests: true) + try context.legacy.saveMavenConfiguration(.init(portable: portable, local: .init(settingsPath: "/fixture/a.xml", localRepositoryPath: "/fixture/a-repository", mavenExecutablePath: "./mvnw", javaHomePath: "/fixture/a-jdk")), workspaceURL: context.workspace, reactorPath: "a") + try context.legacy.saveMavenConfiguration(.init(portable: portable, local: .init(settingsPath: "/fixture/b.xml", javaHomePath: "/fixture/b-jdk")), workspaceURL: context.workspace, reactorPath: "b") + try context.legacy.saveMavenConfiguration(.init(portable: portable, local: .init(settingsPath: "/fixture/absent.xml")), workspaceURL: context.workspace, reactorPath: "temporarily-absent") + var shared = BuildProjectOptionsDocument() + var maven = BuildProjectOptionsDocument.System() + maven.instanceDefaults["skipTests"] = .bool(false) + maven.savedChoices["profiles"] = ["new-custom"] + shared.systems["maven"] = maven + try context.options.saveProjectDocument(shared, workspace: context.workspace, expecting: nil) + try context.options.setInstanceOptions(["b": ["settingsFile": .string("")]], workspaceURL: context.workspace, buildSystemID: "maven") + let canonical = ProjectToolchainSelection(javaHomePath: "/fixture/project-jdk", pathOverrides: ["b": .init()]) + try context.run.saveProjectToolchain(canonical, at: context.workspace) + #expect(try context.migration().migrate(workspace: context.workspace, reactorRoots: ["b", "a"])) + let migrated = try context.options.projectDocument(context.workspace).document.systems["maven"] + #expect(migrated?.instanceDefaults == ["profiles": .array([]), "skipTests": .bool(false)]) + #expect(migrated?.savedChoices["profiles"] == ["ci", "new-custom", "not-selected"]) + let local = try context.options.instanceOptions(workspaceURL: context.workspace, buildSystemID: "maven") + #expect(local["a"] == ["settingsFile": .string("/fixture/a.xml"), "localRepository": .string("/fixture/a-repository")]) + #expect(local["b"] == ["settingsFile": .string("")]) + let toolchain = try #require(context.run.inspect(at: context.workspace, checkFingerprint: false, javaEntrypoints: nil).projectToolchain) + #expect(toolchain.javaHomePath == canonical.javaHomePath) + #expect(toolchain.pathOverrides["a"] == .init(mavenExecutablePath: "./mvnw", mavenJavaHomePath: "/fixture/a-jdk")) + #expect(toolchain.pathOverrides["b"] == .init()) + #expect(!context.storage.fileExists(at: context.portable)) + #expect(!context.storage.fileExists(at: context.local("a"))) + #expect(!context.storage.fileExists(at: context.local("b"))) + #expect(context.storage.fileExists(at: context.local("temporarily-absent"))) + let bytes = try context.options.projectDocument(context.workspace).bytes + #expect(!(try context.migration().migrate(workspace: context.workspace, reactorRoots: ["a", "b"]))) + #expect(try context.options.projectDocument(context.workspace).bytes == bytes) + #expect(try context.migration().migrate(workspace: context.workspace, reactorRoots: ["temporarily-absent"])) + #expect(try context.options.instanceOptions(workspaceURL: context.workspace, buildSystemID: "maven")["temporarily-absent"]?["settingsFile"] == .string("/fixture/absent.xml")) + #expect(!context.storage.fileExists(at: context.local("temporarily-absent"))) + #expect(context.preferences.migrationReceipts.isEmpty) + } + + @Test(arguments: [false, true]) + func rootReactorUsesProjectDefaultsAndPreservesAnExplicitCanonicalAutomaticChoice(existing: Bool) throws { + let context = try Context() + defer { context.clean() } + try context.legacy.saveMavenConfiguration(.init(portable: nil, local: .init(mavenExecutablePath: "./mvnw", javaHomePath: "/fixture/legacy-jdk")), workspaceURL: context.workspace, reactorPath: ".") + if existing { try context.run.saveProjectToolchain(ProjectToolchainSelection(), at: context.workspace) } + #expect(try context.migration().migrate(workspace: context.workspace, reactorRoots: ["."])) + let saved = try #require(context.run.inspect(at: context.workspace, checkFingerprint: false, javaEntrypoints: nil).projectToolchain) + #expect(saved.mavenExecutablePath == (existing ? "" : "./mvnw")) + #expect(saved.mavenJavaHomePath == (existing ? "" : "/fixture/legacy-jdk")) + #expect(saved.pathOverrides.isEmpty) + } + + @Test + func portableChoicesDoNotReadOrRepairAnUnrelatedInvalidRunDocument() throws { + let context = try Context() + defer { context.clean() } + try context.legacy.saveMavenConfiguration(.init(portable: .init(selectedProfiles: ["ci"]), local: .init(settingsPath: "/fixture/settings.xml")), workspaceURL: context.workspace, reactorPath: ".") + let run = context.workspace.appendingPathComponent(".lithe/run/local.json") + try context.storage.createDirectory(at: run.deletingLastPathComponent(), withIntermediateDirectories: true) + let original = Data("{unrelated broken run configuration".utf8) + try original.write(to: run) + #expect(try context.migration().migrate(workspace: context.workspace, reactorRoots: ["."])) + #expect(try Data(contentsOf: run) == original) + #expect(!context.storage.fileExists(at: context.portable)) + #expect(try context.options.instanceOptions(workspaceURL: context.workspace, buildSystemID: "maven")["."]?["settingsFile"] == .string("/fixture/settings.xml")) + } + + @Test + func oversizedRootInventoryRetainsSourcesBeforeAnyTargetPublication() throws { + let context = try Context() + defer { context.clean() } + try context.legacy.saveMavenConfiguration(.init(portable: .init(selectedProfiles: ["ci"]), local: nil), workspaceURL: context.workspace, reactorPath: ".") + let original = try Data(contentsOf: context.portable) + #expect(throws: (any Error).self) { + try context.migration().migrate(workspace: context.workspace, reactorRoots: (0..<257).map { "root-\($0)" }) + } + #expect(try Data(contentsOf: context.portable) == original) + #expect(context.preferences.migrationReceipts.isEmpty) + #expect(!context.storage.fileExists(at: context.workspace.appendingPathComponent(".lithe/build/options.json"))) + } + + @Test + func partialPublicationCanRetryWithoutRemovingAnyUnverifiedSource() throws { + let context = try Context() + defer { context.clean() } + try context.legacy.saveMavenConfiguration(.init(portable: .init(selectedProfiles: ["ci"], customProfiles: ["later"], skipTests: true), local: .init(settingsPath: "/fixture/settings.xml", mavenExecutablePath: "./mvnw")), workspaceURL: context.workspace, reactorPath: ".") + let original = try Data(contentsOf: context.portable) + let local = try Data(contentsOf: context.local(".")) + var failing = context.storage + failing.failRunPublication = true + #expect(throws: (any Error).self) { try context.migration(storage: failing).migrate(workspace: context.workspace, reactorRoots: ["."]) } + #expect(try Data(contentsOf: context.portable) == original) + #expect(try Data(contentsOf: context.local(".")) == local) + #expect(!context.preferences.migrationReceipts.isEmpty) + #expect(try context.options.projectDocument(context.workspace).document.systems["maven"]?.instanceDefaults["profiles"] == .array([.string("ci")])) + #expect(try context.migration().migrate(workspace: context.workspace, reactorRoots: ["."])) + #expect(!context.storage.fileExists(at: context.portable)) + #expect(!context.storage.fileExists(at: context.local("."))) + #expect(context.preferences.migrationReceipts.isEmpty) + } + + @Test + func anExternalSourceEditAtRemovalIsRetainedAndCannotBeErasedOnRetry() throws { + let context = try Context() + defer { context.clean() } + try context.legacy.saveMavenConfiguration(.init(portable: .init(selectedProfiles: ["old"]), local: nil), workspaceURL: context.workspace, reactorPath: ".") + let changed = try JSONEncoder().encode(MavenPortableConfiguration(selectedProfiles: ["external-after-publication"])) + let source = context.portable + var racing = context.storage + racing.beforeRemoval = { url in if url == source { try changed.write(to: url) } } + #expect(throws: (any Error).self) { try context.migration(storage: racing).migrate(workspace: context.workspace, reactorRoots: ["."]) } + #expect(try Data(contentsOf: context.portable) == changed) + #expect(throws: (any Error).self) { try context.migration().migrate(workspace: context.workspace, reactorRoots: ["."]) } + #expect(try Data(contentsOf: context.portable) == changed) + #expect(!context.preferences.migrationReceipts.isEmpty) + } + + @Test(arguments: ["unknown", "future-version", "corrupt", "outside-link"]) + func invalidOrUnownedLegacySourcesNeverCreateDestinations(kind: String) throws { + let context = try Context() + defer { context.clean() } + try context.storage.createDirectory(at: context.portable.deletingLastPathComponent(), withIntermediateDirectories: true) + var value: [String: Any] = ["version": 1, "selectedProfiles": [], "customProfiles": [], "skipTests": false] + if kind == "unknown" { value["futureOption"] = "keep me" } + if kind == "future-version" { value["version"] = 2 } + let bytes = kind == "corrupt" ? Data("{broken".utf8) : try JSONSerialization.data(withJSONObject: value) + if kind == "outside-link" { + let outside = context.root.appendingPathComponent("outside.json") + try bytes.write(to: outside) + try FileManager.default.createSymbolicLink(at: context.portable, withDestinationURL: outside) + } else { try bytes.write(to: context.portable) } + #expect(throws: (any Error).self) { try context.migration().migrate(workspace: context.workspace, reactorRoots: ["."]) } + #expect(try Data(contentsOf: context.portable) == bytes) + #expect(!context.storage.fileExists(at: context.workspace.appendingPathComponent(".lithe/build/options.json"))) + #expect(!context.storage.fileExists(at: context.workspace.appendingPathComponent(".lithe/run/local.json"))) + #expect(context.preferences.migrationReceipts.isEmpty) + } + + @MainActor + private struct Context { + let root: URL + let workspace: URL + let storage: MigrationFileStorage + let preferences = MigrationPreferences() + let options: MacBuildOptionsStore + let run: MacRunConfigurationStore + var legacy: LegacyMavenFixtureWriter { .init(storage: storage) } + var portable: URL { workspace.appendingPathComponent(".lithe/maven/config.json") } + init() throws { + root = FileManager.default.temporaryDirectory.appendingPathComponent("maven-configuration-transfer-" + UUID().uuidString).resolvingSymlinksInPath() + workspace = root.appendingPathComponent("workspace") + storage = MigrationFileStorage(root: root) + try storage.createDirectory(at: workspace, withIntermediateDirectories: true) + options = MacBuildOptionsStore(store: preferences, storage: storage) + run = MacRunConfigurationStore(core: RustCoreBridge(), storage: storage, preferences: preferences) + } + func migration(storage replacement: MigrationFileStorage? = nil) -> MacLegacyMavenConfigurationMigration { + let storage = replacement ?? storage + return MacLegacyMavenConfigurationMigration(storage: storage, preferences: preferences, + runStore: MacRunConfigurationStore(core: RustCoreBridge(), storage: storage, preferences: preferences), + options: MacBuildOptionsStore(store: preferences, storage: storage), immutableRoots: [Bundle.main.bundleURL]) + } + func local(_ path: String) -> URL { + let digest = SHA256.hash(data: Data((workspace.path + "\0" + path).utf8)).map { String(format: "%02x", $0) }.joined() + return storage.applicationSupportDirectory().appendingPathComponent("Lithe/Maven/" + digest + ".json") + } + func clean() { try? FileManager.default.removeItem(at: root) } + } +} + +private final class MigrationPreferences: KeyValueStore { + private var values: [String: Any] = [:] + var refuseDefaults = false + var refuseRuntimeWrites = false + var beforeWrite: ((String) -> Void)? + var migrationReceipts: [String] { values.keys.filter { $0.hasPrefix("BuildSystems.Migrations.Maven.") } } + func data(forKey key: String) -> Data? { values[key] as? Data } + func object(forKey key: String) -> Any? { values[key] } + func string(forKey key: String) -> String? { values[key] as? String } + func stringArray(forKey key: String) -> [String]? { values[key] as? [String] } + func set(_ value: Any?, forKey key: String) { + beforeWrite?(key) + guard !refuseDefaults || !key.hasSuffix(".InstanceDefaults") else { return } + guard !refuseRuntimeWrites || !key.hasPrefix("lithe.project-runtime-settings.") else { return } + values[key] = value + } +} + +private struct MigrationFileStorage: FileStorage { + let root: URL + var failRunPublication = false + var beforeRemoval: (@Sendable (URL) throws -> Void)? + private let native = MacFileStorage() + func homeDirectory() -> URL { root } + func cacheDirectory() -> URL { root.appendingPathComponent("cache") } + func applicationSupportDirectory() -> URL { root.appendingPathComponent("support") } + func temporaryDirectory() -> URL { root.appendingPathComponent("temporary") } + func metadata(for url: URL) -> FileMetadata? { native.metadata(for: url) } + func fileExists(at url: URL) -> Bool { native.fileExists(at: url) } + func isExecutable(at url: URL) -> Bool { native.isExecutable(at: url) } + func listDirectory(at url: URL) -> [URL] { native.listDirectory(at: url) } + func readPrefix(from url: URL, byteCount: Int) throws -> Data { try native.readPrefix(from: url, byteCount: byteCount) } + func readData(from url: URL, options: Data.ReadingOptions) throws -> Data { try native.readData(from: url, options: options) } + func writeData(_ data: Data, to url: URL, options: Data.WritingOptions) throws { try native.writeData(data, to: url, options: options) } + func replaceData(_ data: Data, at url: URL, expecting expected: Data?) throws -> Bool { + if failRunPublication && url.lastPathComponent == "local.json" { throw CocoaError(.fileWriteNoPermission) } + return try native.replaceData(data, at: url, expecting: expected) + } + func replaceDataPreservingMetadata(_ data: Data, at url: URL, expecting expected: Data?) throws -> Bool { + // The run store publishes `local.json` through this metadata-preserving + // path; the injected failure has to cover it as well as `replaceData`. + if failRunPublication && url.lastPathComponent == "local.json" { throw CocoaError(.fileWriteNoPermission) } + return try native.replaceDataPreservingMetadata(data, at: url, expecting: expected) + } + func removeData(at url: URL, expecting expected: Data) throws -> Bool { + try beforeRemoval?(url) + return try native.removeData(at: url, expecting: expected) + } + func createDirectory(at url: URL, withIntermediateDirectories: Bool) throws { try native.createDirectory(at: url, withIntermediateDirectories: withIntermediateDirectories) } + func removeItem(at url: URL) throws { try native.removeItem(at: url) } + func moveItem(at sourceURL: URL, to destinationURL: URL) throws { try native.moveItem(at: sourceURL, to: destinationURL) } + func copyItem(at sourceURL: URL, to destinationURL: URL) throws { try native.copyItem(at: sourceURL, to: destinationURL) } +} + +// Fixed saved-format fixtures survive deletion of the production legacy store. +private struct MavenPortableConfiguration: Codable, Equatable, Sendable { + static let currentVersion = 1 + + var version: Int + var selectedProfiles: [String] + var customProfiles: [String] + var skipTests: Bool + + init( + version: Int = currentVersion, + selectedProfiles: [String] = [], + customProfiles: [String] = [], + skipTests: Bool = false + ) { + self.version = version + self.selectedProfiles = selectedProfiles + self.customProfiles = customProfiles + self.skipTests = skipTests + } +} + +private struct MavenLocalConfiguration: Codable, Equatable, Sendable { + static let currentVersion = 1 + + var version: Int + var settingsPath: String? + var localRepositoryPath: String? + var mavenExecutablePath: String? + var javaHomePath: String? + + init( + version: Int = currentVersion, + settingsPath: String? = nil, + localRepositoryPath: String? = nil, + mavenExecutablePath: String? = nil, + javaHomePath: String? = nil + ) { + self.version = version + self.settingsPath = settingsPath + self.localRepositoryPath = localRepositoryPath + self.mavenExecutablePath = mavenExecutablePath + self.javaHomePath = javaHomePath + } +} + +private struct MavenStoredConfiguration: Equatable, Sendable { + let portable: MavenPortableConfiguration? + let local: MavenLocalConfiguration? + + init( + portable: MavenPortableConfiguration?, + local: MavenLocalConfiguration? + ) { + self.portable = portable + self.local = local + } +} + +private struct LegacyMavenFixtureWriter { + let storage: MigrationFileStorage + func saveMavenConfiguration(_ value: MavenStoredConfiguration, workspaceURL: URL, reactorPath: String) throws { + let portable = workspaceURL.appendingPathComponent(".lithe/maven/config.json") + let identity = MacLegacyMavenConfigurationMigration.storageIdentity(workspacePath: workspaceURL.path, reactorPath: reactorPath) + let digest = SHA256.hash(data: Data(identity.utf8)).map { String(format: "%02x", $0) }.joined() + let local = storage.applicationSupportDirectory().appendingPathComponent("Lithe/Maven/" + digest + ".json") + for (url, data) in [(portable, try value.portable.map { try JSONEncoder().encode($0) }), + (local, try value.local.map { try JSONEncoder().encode($0) })] { + guard let data else { continue } + try storage.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try storage.writeData(data, to: url, options: []) + } + } +} diff --git a/macos/Tests/LitheTests/LitheTreeListTests.swift b/macos/Tests/LitheTests/LitheTreeListTests.swift index 9e39fab9f0..8f599d3d2c 100644 --- a/macos/Tests/LitheTests/LitheTreeListTests.swift +++ b/macos/Tests/LitheTests/LitheTreeListTests.swift @@ -375,7 +375,7 @@ struct LitheTreeListTests { do { let access = try #require(await model.activateExecutionModule()) let feature = access.runFeature - await feature.loadProject(at: root, files: [], mavenProject: nil) + await feature.loadProject(at: root, files: []) // Use a registered source with an explicit local path, isolating UI dispatch // from language-server startup while exercising the real dependency feature. feature.registerDependencySource(languageID: "acceptance", displayName: "Acceptance dependencies") diff --git a/macos/Tests/LitheTests/MacBuildProviderSessionsTests.swift b/macos/Tests/LitheTests/MacBuildProviderSessionsTests.swift index e8fa7b7f7a..db83ba8dc0 100644 --- a/macos/Tests/LitheTests/MacBuildProviderSessionsTests.swift +++ b/macos/Tests/LitheTests/MacBuildProviderSessionsTests.swift @@ -544,6 +544,31 @@ struct MacBuildProviderSessionsTests { try #require(await core.destroyed.waitUntilOpen(), "The language session did not release its resources") } + @Test + func cancellingAwaitedJavaEntrypointsRevokesTheExactCoreOperationWithoutStoppingTheSession() async throws { + let core = ScriptedProviderCore() + let session = LanguageServerRuntimeSession(providerID: "java", executableURL: URL(fileURLWithPath: "/tools/jdtls"), + arguments: [], environment: [:], core: core) + try session.start(rootURL: URL(fileURLWithPath: "/workspace/java-profile"), workspaceFingerprint: nil) + let finished = TestGate() + let task = Task { @MainActor () -> Result in + defer { finished.open() } + do { return .success(try await session.javaEntrypoints()) } + catch { return .failure(error) } + } + defer { task.cancel(); session.stop() } + try #require(await core.commandStarted.waitUntilOpen()) + task.cancel() + try #require(await core.functionCancelled.waitUntilOpen()) + try #require(await finished.waitUntilOpen()) + if case .failure(let error) = await task.value { #expect(error is CancellationError) } + else { Issue.record("Cancelled launch discovery must not succeed") } + #expect(session.isRunning) + #expect(core.stopCalls.isEmpty) + session.stop() + try #require(await core.destroyed.waitUntilOpen()) + } + /// A second request reuses the process; stopping the plugin's providers, /// which the plugin manager does before replacing its package, ends it /// and the next request starts a new one. @@ -1356,6 +1381,10 @@ private final class ScriptedProviderCore: LanguageServerRuntimeCore, @unchecked diagnostics _: [LanguageServerDiagnostic], completionContext: LanguageServerCompletionContext?, completionItem _: LanguageServerCompletionItem?, codeAction _: LanguageServerCodeAction?, command: LanguageServerCommand? ) -> Result { + if operation == .javaEntrypoints { + commandStarted.open() + return .success(LanguageServerRuntimeOperation(operationID: "java-entrypoints")) + } if operation == .executeCommand, command?.command == "example.startDebugSession" { commandStarted.open() return .success(LanguageServerRuntimeOperation(operationID: "debug-start")) diff --git a/macos/Tests/LitheTests/MacBuildToolResolverTests.swift b/macos/Tests/LitheTests/MacBuildToolResolverTests.swift index 4f425f3ac0..bc04ac7a58 100644 --- a/macos/Tests/LitheTests/MacBuildToolResolverTests.swift +++ b/macos/Tests/LitheTests/MacBuildToolResolverTests.swift @@ -6,6 +6,104 @@ import Testing @Suite("Build tool resolver version output") @MainActor struct MacBuildToolResolverTests { + @Test + func definedToolchainsKeepEachRootAndSendOnlySelectedEnvironment() async throws { + let files = FileManager.default + let scratch = files.temporaryDirectory.appendingPathComponent("lithe-defined-build-tools-\(UUID().uuidString)") + defer { try? files.removeItem(at: scratch) } + let workspace = scratch.appendingPathComponent("workspace") + try files.createDirectory(at: workspace.appendingPathComponent(".lithe/toolchains"), withIntermediateDirectories: true) + let marker = scratch.appendingPathComponent("probe-ran") + var pins: [String: [String: Any]] = [:] + for root in ["a", "b"] { + let vm = scratch.appendingPathComponent("\(root) vm") + let tool = scratch.appendingPathComponent("\(root) tool") + try files.createDirectory(at: tool.appendingPathComponent("helpers"), withIntermediateDirectories: true) + for (url, output) in [(vm.appendingPathComponent("bin/vm"), "VM 21"), (tool.appendingPathComponent("bin/worker"), "Fixture 1.0")] { + try files.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data("#!/bin/sh\ntouch '\(marker.path)'\nprintf '%s\\n' '\(output)'\n".utf8).write(to: url) + try files.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) + } + pins[root] = ["compiler": ["home": tool.path, "version": "1.0", "provider": "host:manual"], + "vm": ["home": vm.path, "version": "21", "provider": "host:manual"]] + } + let local = workspace.appendingPathComponent(".lithe/toolchains/local.json") + try JSONSerialization.data(withJSONObject: ["version": 1, "toolchains": [:], "pins": pins]).write(to: local) + let definitions: [[String: Any]] = [("compiler", "worker", "Fixture", []), + ("vm", "vm", "VM", [["name": "JAVA_HOME", "value": "${home}"]])].map { id, executable, label, exports in + ["id": id, "displayName": label, "executableLayouts": [["path": "bin/" + executable]], + "executables": [["id": "pinned", "kind": "setting", "settingID": "executable", "layer": "project"], ["id": "shell", "kind": "command", "command": executable]], + "version": ["arguments": ["--version"], "pattern": label + #" (\d+(?:\.\d+)*)"#], + "commands": [executable], "exports": exports, "pathPrepend": id == "compiler" ? ["${home}/bin", "${home}/helpers"] : ["${home}/bin"], "launch": ["${executable}", "${arguments}"]] + } + let source = ToolchainSettingsPluginSource(pluginID: "dev.lithe.fixture.build-tools", name: "Fixture", + manifest: try JSONSerialization.data(withJSONObject: ["schemaVersion": 2, "id": "dev.lithe.fixture.build-tools", + "displayName": "Fixture", "version": "1.0.0", "pluginAPIVersion": 3, + "requires": ["toolDefinitions": 1, "executableResolution": 2], + "vendor": ["id": "org.example", "displayName": "Fixture", "signatureRequirement": "publisherPackage"], + "permissions": [], "toolDefinitions": definitions]), isEnabled: true, verifiedOfficial: true) + let defined = MacDefinedToolchainResolver() + defined.sources = { [source] } + let runtime = ProjectRuntimeService(runtimeLocator: CanonicalToolchainRuntimeLocator(), store: ToolchainPreferenceStore()) + runtime.openProject(at: workspace) + let resolver = MacBuildToolResolver(runtime: runtime, processRunner: MacProcessRunner(), fileStorage: MacFileStorage(), definedTools: defined) + let fixture = DeclarativePluginTestSupport.repositoryRoot.appendingPathComponent("shared/contracts/plugins/v1/fixtures/build-toolchains.plugin.json") + let text = try String(contentsOf: fixture, encoding: .utf8) + .replacingOccurrences(of: "dev.lithe.fixture.build-toolchains:vm", with: "vm") + .replacingOccurrences(of: "dev.lithe.fixture.build-toolchains:compiler", with: "compiler") + var manifest = try #require(JSONSerialization.jsonObject(with: Data(text.utf8)) as? [String: Any]) + var systems = try #require(manifest["buildSystems"] as? [[String: Any]]) + var invocationJSON = try #require(systems[0]["invocation"] as? [String: Any]) + var dependencies = try #require(invocationJSON["runtimeDependencies"] as? [[String: Any]]) + dependencies[0]["id"] = "vm" + dependencies[0]["version"] = ["arguments": ["--version"], "pattern": #"VM (\d+)"#] + invocationJSON["runtimeDependencies"] = dependencies + systems[0]["invocation"] = invocationJSON + manifest["buildSystems"] = systems + let target = BuildSystemTarget(manifest: try JSONSerialization.data(withJSONObject: manifest), buildSystemID: "cargo") + let core = RustBuildSystemOperations(core: RustCoreBridge()) + for root in ["a", "b"] { + let invocation = try core.invocationPlan(target, root: root, settings: [:]) + #expect(resolver.languageImportExecutable(invocation, workspaceURL: workspace) == nil) + #expect(!files.fileExists(atPath: marker.path)) + } + for root in ["a", "b"] { + let invocation = try core.invocationPlan(target, root: root, settings: [:]) + let tool = try await resolver.resolve(invocation, displayName: "Fixture", workspaceURL: workspace) { runtime, output in + try core.checkToolVersion(target, runtime: runtime, output: output) + } + let executable = try #require(MacToolchainDiscovery.realpath(scratch.appendingPathComponent("\(root) tool/bin/worker").path)) + let home = try #require(MacToolchainDiscovery.realpath(scratch.appendingPathComponent("\(root) vm").path)) + #expect(tool.executableURL == URL(fileURLWithPath: executable)) + #expect(tool.environment["JAVA_HOME"] == home) + let helpers = try #require(MacToolchainDiscovery.realpath(scratch.appendingPathComponent("\(root) tool/helpers").path)) + #expect(tool.environment["PATH"]?.split(separator: ":").contains(Substring(helpers)) == true) + #expect(tool.providerEnvironment["JAVA_HOME"] == tool.environment["JAVA_HOME"]) + #expect(tool.environment["FIXTURE_SECRET"] == "test-only-secret") + #expect(tool.providerEnvironment["FIXTURE_SECRET"] == nil) + #expect(!tool.executesProjectCode) + } + let beforeRunChoice = try Data(contentsOf: local) + var sameDirectory = try core.invocationPlan(target, root: "a", settings: [:]) + let alternateExecutable = scratch.appendingPathComponent("b tool/bin/worker") + sameDirectory.toolSelections = ["compiler": .executable(path: alternateExecutable.path), + "vm": .installation(home: scratch.appendingPathComponent("b vm").path)] + let alternate = try await resolver.resolve(sameDirectory, displayName: "Fixture", workspaceURL: workspace) { runtime, output in + try core.checkToolVersion(target, runtime: runtime, output: output) + } + #expect(alternate.executableURL == alternateExecutable) + #expect(alternate.environment["JAVA_HOME"] == MacToolchainDiscovery.realpath(scratch.appendingPathComponent("b vm").path)) + #expect(try Data(contentsOf: local) == beforeRunChoice) + pins["a"]?["compiler"] = ["home": "/fixture/missing-tool", "version": "1", "provider": "host:manual"] + try JSONSerialization.data(withJSONObject: ["version": 1, "toolchains": [:], "pins": pins]).write(to: local) + let invocation = try core.invocationPlan(target, root: "a", settings: [:]) + await #expect(throws: BuildToolResolutionError.self) { + try await resolver.resolve(invocation, displayName: "Fixture", workspaceURL: workspace) { runtime, output in + try core.checkToolVersion(target, runtime: runtime, output: output) + } + } + } + @Test func emptySettingSkipsToWrapperAndPassiveSelectionNeverProbes() async throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) @@ -66,7 +164,8 @@ struct MacBuildToolResolverTests { BuildVersionCheck(satisfied: output.contains("fixture 2.0"), version: "2.0") } #expect(resolved.executableURL == executable) - #expect(resolved.environment["PATH"]?.hasPrefix(executable.deletingLastPathComponent().path + ":") == true) + #expect(resolved.environment["PATH"]?.split(separator: ":").first.map(String.init) + == executable.deletingLastPathComponent().path) #expect(resolver.languageImportExecutable(invocation, workspaceURL: root) == executable) } @@ -276,3 +375,29 @@ private struct BuildProbeEmptyStore: KeyValueStore { func stringArray(forKey key: String) -> [String]? { nil } func set(_ value: Any?, forKey key: String) {} } + +struct CanonicalToolchainRuntimeLocator: RuntimeLocator { + func environment() -> [String: String] { ["PATH": "/usr/bin:/bin", "FIXTURE_SECRET": "test-only-secret"] } + func discover() -> RuntimeDiscoveryResult { + Issue.record("An explicit toolchain must not discover other JDKs") + return .init(javaRuntimes: [], mavenRuntimes: []) + } + func validJavaHome(path: String) -> URL? { + let url = URL(fileURLWithPath: path) + return isExecutable(at: url.appendingPathComponent("bin/java")) ? url : nil + } + func javaRuntime(at homeURL: URL) -> JavaRuntimeCandidate? { nil } + func isExecutable(at url: URL) -> Bool { FileManager.default.isExecutableFile(atPath: url.path) } + func systemMavenExecutable() -> URL? { nil } + func mavenExecutable(forHomePath path: String) -> URL? { nil } + func mavenRuntime(at executableURL: URL) -> MavenRuntimeCandidate? { nil } +} + +private final class ToolchainPreferenceStore: KeyValueStore { + private var values: [String: Any] = [:] + func data(forKey key: String) -> Data? { values[key] as? Data } + func object(forKey key: String) -> Any? { values[key] } + func string(forKey key: String) -> String? { values[key] as? String } + func stringArray(forKey key: String) -> [String]? { values[key] as? [String] } + func set(_ value: Any?, forKey key: String) { values[key] = value } +} diff --git a/macos/Tests/LitheTests/MacDebugPortAvailabilityCheckerTests.swift b/macos/Tests/LitheTests/MacDebugPortAvailabilityCheckerTests.swift index efde9398b6..23bd7ddd5c 100644 --- a/macos/Tests/LitheTests/MacDebugPortAvailabilityCheckerTests.swift +++ b/macos/Tests/LitheTests/MacDebugPortAvailabilityCheckerTests.swift @@ -6,7 +6,7 @@ import Testing @MainActor struct MacDebugPortAvailabilityCheckerTests { @Test - func reportsAListeningPortAsUnavailableAndAReleasedPortAsAvailable() throws { + func reportsAListeningPortAsUnavailableAndAReleasedPortAsAvailable() async throws { var descriptor = socket(AF_INET, SOCK_STREAM, 0) #expect(descriptor >= 0) guard descriptor >= 0 else { return } @@ -39,9 +39,21 @@ struct MacDebugPortAvailabilityCheckerTests { #expect(port > 0) let checker = MacDebugPortAvailabilityChecker() + let unused = try checker.unusedLoopbackPort() + #expect(unused > 0 && checker.isPortAvailable(Int(unused))) #expect(!checker.isPortAvailable(port)) + try await checker.waitForLoopbackListener(UInt16(port), processID: { getpid() }, isCurrent: { true }) + var checks = 0 + do { + try await checker.waitForLoopbackListener(UInt16(port), processID: { Int32.max }, isCurrent: { checks += 1; return checks == 1 }) + Issue.record("A listening port owned by another process was accepted") + } catch is CancellationError { } #expect(close(descriptor) == 0) descriptor = -1 #expect(checker.isPortAvailable(port)) + do { + try await checker.waitForLoopbackListener(UInt16(port), processID: { getpid() }, isCurrent: { false }) + Issue.record("A stopped debug operation waited for a listener") + } catch is CancellationError { } } } diff --git a/macos/Tests/LitheTests/MacLegacyMavenRunConfigurationMigrationTests.swift b/macos/Tests/LitheTests/MacLegacyMavenRunConfigurationMigrationTests.swift new file mode 100644 index 0000000000..b6334a32fe --- /dev/null +++ b/macos/Tests/LitheTests/MacLegacyMavenRunConfigurationMigrationTests.swift @@ -0,0 +1,476 @@ +import Foundation +import LitheCoreContracts +import Testing +@testable import Lithe + +@Suite("Legacy Maven Run migration") +@MainActor +struct MacLegacyMavenRunConfigurationMigrationTests { + @Test(arguments: [false, true]) + func moduleChoicesRemainDataAndAnEmptyGoalListIsLeftToTheProvider(runtimeChoice: Bool) throws { + let fixture = try Fixture() + defer { fixture.remove() } + let id = "maven.module:nested/app" + try fixture.write(["version": 2, "configurations": [["id": id, "name": "Module", "provider": "maven.module", "execution": "task", "args": [], + "extensions": ["maven": ["module": "app", "mainClass": runtimeChoice ? "opaque/entry" : "", "jvmArguments": runtimeChoice ? ["-Dsaved=value"] : []]]]]], to: fixture.team) + try fixture.write(["version": 2, "configurations": []], to: fixture.local) + let reference = BuildProviderRunReference(pluginID: "io.github.lithe-idea.maven-build", buildSystemID: "maven", root: "nested", run: "module", + projectID: "p", targetID: "opaque/module", execution: .task) + let runs = [RunProviderConfiguration(projectPath: "nested/app", name: "Module", reference: reference)] + let models = [try JSONDecoder().decode(BuildModel.self, from: Data(#"{"projects":[{"id":"p","path":"nested/app","name":"app"}],"targets":[{"id":"opaque/module","projectID":"p","name":"maven.module","path":"nested/app","tags":["maven-module"]}]}"#.utf8))] + let store = MacRunConfigurationStore(core: RustCoreBridge(), storage: MacFileStorage(), preferences: MigrationPreferences()) + _ = try store.generate(at: fixture.root, files: [], modulePaths: [], javaEntrypoints: nil, + projectOwnership: fixture.projects, providerRuns: runs, models: models, lease: RunConfigurationGenerationLease()) + let input = try store.providerLaunchInputs(at: fixture.root, configurationID: id, providerRoot: "nested") + #expect(input.reference == reference) + #expect(input.launchOptions.arguments == []) + #expect(input.launchOptions.runtimeArguments == (runtimeChoice ? ["-Dsaved=value"] : [])) + #expect(input.launchOptions.entrypoint == (runtimeChoice ? "opaque/entry" : nil)) + } + + @Test(arguments: [false, true]) + func explicitToolsKeepEachRunAndLocalClearWithoutChangingDirectoryPins(clearLocal: Bool) throws { + let fixture = try Fixture() + defer { fixture.remove() } + var team = try fixture.read(fixture.team) + var entries = try #require(team["configurations"] as? [[String: Any]]) + var first = entries[0], firstExtensions = try #require(first["extensions"] as? [String: Any]) + firstExtensions["java"] = ["mavenExecutablePath": "/fixture/team launcher", "mavenJavaHomePath": "/fixture/team vm"] + first["extensions"] = firstExtensions + var second = first, secondExtensions = firstExtensions + second["id"] = "user:second-service" + secondExtensions["java"] = ["mavenExecutablePath": "/fixture/second launcher", "mavenJavaHomePath": "/fixture/second vm"] + second["extensions"] = secondExtensions + entries = [first, second]; team["configurations"] = entries + try fixture.write(team, to: fixture.team) + var local = try fixture.read(fixture.local) + var overrides = try #require(local["configurations"] as? [[String: Any]]) + var localExtensions = try #require(overrides[0]["extensions"] as? [String: Any]) + localExtensions["java"] = ["mavenExecutablePath": clearLocal ? "" : "/fixture/local launcher"] + overrides[0]["extensions"] = localExtensions; local["configurations"] = overrides + try fixture.write(local, to: fixture.local) + let store = MacRunConfigurationStore(core: RustCoreBridge(), storage: MacFileStorage(), preferences: MigrationPreferences()) + _ = try store.generate(at: fixture.root, files: [], modulePaths: [], javaEntrypoints: nil, + projectOwnership: fixture.projects, providerRuns: fixture.runs, models: fixture.models, lease: RunConfigurationGenerationLease()) + let firstInput = try store.providerLaunchInputs(at: fixture.root, configurationID: fixture.id, providerRoot: "nested") + #expect(firstInput.tools["io.github.lithe-idea.maven-build:maven"] == (clearLocal ? nil : .executable(path: "/fixture/local launcher"))) + #expect(firstInput.tools["java"] == .installation(home: "/fixture/team vm")) + let secondInput = try store.providerLaunchInputs(at: fixture.root, configurationID: "user:second-service", providerRoot: "nested") + #expect(secondInput.tools["io.github.lithe-idea.maven-build:maven"] == .executable(path: "/fixture/second launcher")) + #expect(secondInput.tools["java"] == .installation(home: "/fixture/second vm")) + #expect(!FileManager.default.fileExists(atPath: fixture.root.appendingPathComponent(".lithe/toolchains/local.json").path)) + let before = try fixture.bytes() + _ = try store.generate(at: fixture.root, files: [], modulePaths: [], javaEntrypoints: nil, + projectOwnership: fixture.projects, providerRuns: fixture.runs, models: fixture.models, lease: RunConfigurationGenerationLease()) + #expect(try fixture.bytes() == before) + } + + @Test + func preferenceOnlyExplicitToolsTransferToTheGenericRunAndRetainTheSource() throws { + let fixture = try Fixture() + defer { fixture.remove() } + try FileManager.default.removeItem(at: fixture.local) + let preferences = MigrationPreferences() + let key = "lithe.java-run-options.\(fixture.root.path.replacingOccurrences(of: "/", with: "_")).\(fixture.id)" + let source = try JSONEncoder().encode(RunOptions(mavenExecutablePath: "/fixture/preference launcher", mavenJavaHomePath: "/fixture/preference vm")) + preferences.set(source, forKey: key) + let store = MacRunConfigurationStore(core: RustCoreBridge(), storage: MacFileStorage(), preferences: preferences) + _ = try store.generate(at: fixture.root, files: [], modulePaths: [], javaEntrypoints: nil, + projectOwnership: fixture.projects, providerRuns: fixture.runs, models: fixture.models, lease: RunConfigurationGenerationLease()) + let inputs = try store.providerLaunchInputs(at: fixture.root, configurationID: fixture.id, providerRoot: "nested") + #expect(inputs.tools == ["io.github.lithe-idea.maven-build:maven": .executable(path: "/fixture/preference launcher"), + "java": .installation(home: "/fixture/preference vm")]) + #expect(preferences.data(forKey: key) == source) + } + + @Test(arguments: ["project", "directory", "preferences", "relative", "control"]) + func unrepresentableDefaultsAndInvalidExplicitToolsKeepAllSources(kind: String) throws { + let fixture = try Fixture() + defer { fixture.remove() } + let preferences = MigrationPreferences() + var local = try fixture.read(fixture.local) + if kind == "project" { local["toolchain"] = ["maven": ["javaHomePath": "/fixture/project vm"]] } + if kind == "directory" { local["toolchain"] = ["pathOverrides": ["nested": ["mavenExecutablePath": "/fixture/project launcher"]]] } + if kind == "preferences" { + preferences.set(try JSONSerialization.data(withJSONObject: ["mavenJavaHomePath": "/fixture/inherited vm"]), + forKey: MacProjectRuntimeSettingsStore.preferencesKey(fixture.root)) + } + if kind == "relative" || kind == "control" { + var entries = try #require(local["configurations"] as? [[String: Any]]) + var ext = try #require(entries[0]["extensions"] as? [String: Any]) + ext["java"] = ["mavenExecutablePath": kind == "relative" ? "relative/launcher" : "/fixture/bad\nlauncher"] + entries[0]["extensions"] = ext; local["configurations"] = entries + } + try fixture.write(local, to: fixture.local) + let before = try fixture.bytes() + let store = MacRunConfigurationStore(core: RustCoreBridge(), storage: MacFileStorage(), preferences: preferences) + #expect(throws: (any Error).self) { + _ = try store.generate(at: fixture.root, files: [], modulePaths: [], javaEntrypoints: nil, + projectOwnership: fixture.projects, providerRuns: fixture.runs, models: fixture.models, lease: RunConfigurationGenerationLease()) + } + #expect(try fixture.bytes() == before) + } + + @Test(arguments: [false, true]) + func activePreferencesTransferOnceWithoutDroppingJavaOrReplacingLocalChoices(localMissing: Bool) throws { + let fixture = try Fixture() + defer { fixture.remove() } + let domain = "lithe.legacy-run-preferences.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: domain)) + defer { defaults.removePersistentDomain(forName: domain) } + let preferences = MacUserDefaultsStore(defaults: defaults) + let prefix = "lithe.java-run-options.\(fixture.root.path.replacingOccurrences(of: "/", with: "_"))." + let serviceBytes = try JSONEncoder().encode(RunOptions(workingDirectoryPath: fixture.root.appendingPathComponent("nested/app/work").path, + vmArguments: "'-Dvalue=one two' '\\literal'", programArguments: "'--value=two words' 雪", activeProfiles: ["release"], + mavenSkipTests: false, environment: ["FROM": "preference"])) + preferences.set(serviceBytes, forKey: prefix + fixture.id) + if localMissing { + try FileManager.default.removeItem(at: fixture.local) + try fixture.write(["version": 2, "configurations": [["id": "user:java", "name": "Unrelated Java", "provider": "java.current-file", "execution": "application"]]], to: fixture.team) + preferences.set(try JSONEncoder().encode(RunOptions(vmArguments: "-Djava=okay", programArguments: "before", environment: ["JAVA_KEEP": "value"])), forKey: prefix + "user:java") + } + let store = MacRunConfigurationStore(core: RustCoreBridge(), storage: MacFileStorage(), preferences: preferences) + _ = try store.generate(at: fixture.root, files: [], modulePaths: [], javaEntrypoints: nil, + projectOwnership: fixture.projects, providerRuns: fixture.runs, models: fixture.models, lease: RunConfigurationGenerationLease()) + let inputs = try store.providerLaunchInputs(at: fixture.root, configurationID: fixture.id, providerRoot: "nested") + #expect(inputs.launchOptions.runtimeArguments == ["-Dvalue=one two", "\\literal"]) + #expect(inputs.launchOptions.workingDirectory == "nested/app/work") + #expect(inputs.launchOptions.arguments == (localMissing ? ["--value=two words", "雪"] : ["--local=雪"])) + #expect(inputs.launchOptions.environment == (localMissing ? ["FROM": "preference"] : ["EXAMPLE": "local"])) + #expect(inputs.optionOverrides["profiles"] == (localMissing ? .array([.string("release")]) : nil)) + #expect(inputs.optionOverrides["skipTests"] == .bool(false)) + if localMissing { + let resolved = try store.resolve(at: fixture.root, toolchainCandidates: []) + let java = try #require(resolved.configurations.first { $0.configuration.id == "user:java" }) + #expect(java.options.vmArguments == "-Djava=okay") + #expect(java.options.environment == ["JAVA_KEEP": "value"]) + #expect((try FileManager.default.attributesOfItem(atPath: fixture.local.path)[.posixPermissions] as? NSNumber)?.intValue == 0o600) + } + let before = try fixture.bytes() + _ = try store.generate(at: fixture.root, files: [], modulePaths: [], javaEntrypoints: nil, + projectOwnership: fixture.projects, providerRuns: fixture.runs, models: fixture.models, lease: RunConfigurationGenerationLease()) + #expect(try fixture.bytes() == before) + #expect(preferences.data(forKey: prefix + fixture.id) == serviceBytes, "Retired source remains a rollback record; generic documents are authoritative") + } + + @Test + func preferenceOnlyCancellationRemovesOnlyOurNewFileAndEmptyDirectories() throws { + let fixture = try Fixture() + defer { fixture.remove() } + try FileManager.default.removeItem(at: fixture.root.appendingPathComponent(".lithe")) + let preferences = MigrationPreferences(), storage = MigrationFaultStorage(), lease = RunConfigurationGenerationLease() + let key = "lithe.java-run-options.\(fixture.root.path.replacingOccurrences(of: "/", with: "_")).\(fixture.id)" + let bytes = try JSONEncoder().encode(RunOptions(programArguments: "--saved")) + preferences.set(bytes, forKey: key) + let migration = MacLegacyMavenRunConfigurationMigration(storage: storage, preferences: preferences, immutableRoots: []) + let plan = try migration.prepare(workspace: fixture.root, projects: fixture.projects, runs: fixture.runs, models: fixture.models, lease: lease) + #expect(plan.writes.count == 1) + storage.afterReplace = { _ in lease.invalidate() } + #expect(throws: (any Error).self) { try migration.apply(plan, workspace: fixture.root, lease: lease) } + #expect(!FileManager.default.fileExists(atPath: fixture.root.appendingPathComponent(".lithe").path)) + #expect(preferences.data(forKey: key) == bytes) + } + + @Test + func aChangedActivePreferenceRollsBackOurFilesAndRetainsTheNewSource() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let preferences = MigrationPreferences(), storage = MigrationFaultStorage(), lease = RunConfigurationGenerationLease() + let key = "lithe.java-run-options.\(fixture.root.path.replacingOccurrences(of: "/", with: "_")).\(fixture.id)" + preferences.set(try JSONEncoder().encode(RunOptions(vmArguments: "-Dbefore=value")), forKey: key) + let migration = MacLegacyMavenRunConfigurationMigration(storage: storage, preferences: preferences, immutableRoots: []) + let plan = try migration.prepare(workspace: fixture.root, projects: fixture.projects, runs: fixture.runs, models: fixture.models, lease: lease) + let original = try fixture.bytes(), external = try JSONEncoder().encode(RunOptions(vmArguments: "-Dexternal=value")) + storage.afterReplace = { index in if index == 1 { preferences.set(external, forKey: key) } } + #expect(throws: (any Error).self) { try migration.apply(plan, workspace: fixture.root, lease: lease) } + #expect(try fixture.bytes() == original) + #expect(preferences.data(forKey: key) == external) + } + + @Test(arguments: ["both-v1", "team-v1", "local-v1"]) + func retiredV1LayersKeepStableIdentitiesAndUnrelatedJavaEntries(format: String) throws { + let fixture = try Fixture() + defer { fixture.remove() } + if format != "local-v1" { + try fixture.write(["version": 1, "annotation": "v1 team", "configurations": [ + ["id": fixture.id, "name": "Saved service", "type": "quarkus.maven", "module": "app", "workingDirectory": "nested", + "mainClass": "opaque/entry:1", "jvmArguments": ["-Dvalue=one two", "雪"], "programArguments": ["--team"], "mavenProfiles": ["team"]], + ["id": "user:java", "name": "Unrelated Java", "type": "java.current-file", "workingDirectory": "."] + ]], to: fixture.team) + } + if format != "team-v1" { + try fixture.write(["version": 1, "annotation": "v1 local", "configurations": [ + ["id": fixture.id, "workingDirectory": "nested/app/work", "programArguments": ["--local=雪"], "mavenProfiles": ["local"]] + ]], to: fixture.local) + } + let store = MacRunConfigurationStore(core: RustCoreBridge(), storage: MacFileStorage(), preferences: MigrationPreferences()) + _ = try store.generate(at: fixture.root, files: [], modulePaths: [], javaEntrypoints: nil, + projectOwnership: fixture.projects, providerRuns: fixture.runs, models: fixture.models, lease: RunConfigurationGenerationLease()) + let resolved = try store.resolve(at: fixture.root, toolchainCandidates: []) + #expect(resolved.configurations.filter { $0.configuration.buildProvider != nil }.map { $0.configuration.id } == [fixture.id]) + #expect(resolved.defaultConfigurationID == fixture.id) + let inputs = try store.providerLaunchInputs(at: fixture.root, configurationID: fixture.id, providerRoot: "nested") + #expect(inputs.launchOptions.entrypoint == "opaque/entry:1") + #expect(inputs.launchOptions.arguments == ["--local=雪"]) + #expect(inputs.launchOptions.workingDirectory == "nested/app/work") + if format != "team-v1" { #expect(inputs.optionOverrides["profiles"] == .array([.string("local")])) } + if format != "local-v1" { + #expect(resolved.configurations.contains { $0.configuration.id == "user:java" && $0.configuration.kind == .currentFile }) + #expect((try fixture.read(fixture.team)["version"] as? Int) == 2) + } + let before = try fixture.bytes() + _ = try store.generate(at: fixture.root, files: [], modulePaths: [], javaEntrypoints: nil, + projectOwnership: fixture.projects, providerRuns: fixture.runs, models: fixture.models, lease: RunConfigurationGenerationLease()) + #expect(try fixture.bytes() == before) + } + + @Test + func generationMigratesBothLayersWithStableIDsAndNoDuplicateService() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let preferences = MigrationPreferences() + let store = MacRunConfigurationStore(core: RustCoreBridge(), storage: MacFileStorage(), preferences: preferences) + let manifest = try Data(contentsOf: fixture.root.appendingPathComponent(".lithe/project.json")) + try FileManager.default.setAttributes([.posixPermissions: 0o640], ofItemAtPath: fixture.team.path) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: fixture.local.path) + _ = try store.generate(at: fixture.root, files: [], modulePaths: [], javaEntrypoints: nil, + projectOwnership: fixture.projects, providerRuns: fixture.runs, models: fixture.models, lease: RunConfigurationGenerationLease()) + let resolution = try store.resolve(at: fixture.root, toolchainCandidates: []) + let service = try #require(resolution.configurations.first) + #expect(resolution.configurations.count == 1) + #expect(service.configuration.id == fixture.id) + #expect(service.configuration.buildProvider == fixture.runs[0].reference) + #expect(resolution.defaultConfigurationID == fixture.id) + #expect(!resolution.diagnostics.contains { $0.code == "orphanedOverride" }) + let inputs = try store.providerLaunchInputs(at: fixture.root, configurationID: fixture.id, providerRoot: "nested") + #expect(inputs.launchOptions.runtimeArguments == ["--runtime=one two", "", "雪"]) + #expect(inputs.launchOptions.entrypoint == "opaque/entry:1") + #expect(inputs.launchOptions.arguments == ["--local=雪"]) + #expect(inputs.launchOptions.environment == ["EXAMPLE": "local"]) + #expect(inputs.launchOptions.workingDirectory == "nested/app/work") + #expect(inputs.optionOverrides == ["skipTests": .bool(false)]) + let team = try fixture.read(fixture.team), local = try fixture.read(fixture.local) + #expect((team["annotation"] as? String) == "keep document metadata") + #expect(((team["configurations"] as? [[String: Any]])?.first?["extensions"] as? [String: Any])?["maven"] == nil) + #expect((((team["configurations"] as? [[String: Any]])?.first?["extensions"] as? [String: Any])?["annotations"] as? [String: Bool]) == ["keep": true]) + #expect((local["annotation"] as? String) == "keep local metadata") + #expect(try Data(contentsOf: fixture.root.appendingPathComponent(".lithe/project.json")) == manifest) + #expect((try FileManager.default.attributesOfItem(atPath: fixture.team.path)[.posixPermissions] as? NSNumber)?.intValue == 0o640) + #expect((try FileManager.default.attributesOfItem(atPath: fixture.local.path)[.posixPermissions] as? NSNumber)?.intValue == 0o600) + let bytes = try fixture.bytes() + _ = try store.generate(at: fixture.root, files: [], modulePaths: [], javaEntrypoints: nil, + projectOwnership: fixture.projects, providerRuns: fixture.runs, models: fixture.models, lease: RunConfigurationGenerationLease()) + #expect(try fixture.bytes() == bytes, "A successful cutover is idempotent without another receipt file") + #expect(!FileManager.default.fileExists(atPath: fixture.root.appendingPathComponent(".lithe/run/generated.json").path)) + } + + @Test(arguments: ["tools", "version", "ambiguous", "preferences", "saved-preferences", "invalid-entrypoint", "changed-framework", "invalid-maven", "invalid-selector", "custom-debug"]) + func unsupportedSourcesAndFailedPreflightLeaveBothDocumentsUntouched(reason: String) throws { + let fixture = try Fixture() + defer { fixture.remove() } + let preferences = MigrationPreferences() + var team = try fixture.read(fixture.team) + var entries = try #require(team["configurations"] as? [[String: Any]]) + var entry = entries[0], extensions = try #require(entry["extensions"] as? [String: Any]) + if reason == "tools" { extensions["java"] = ["homePath": "/fixture/custom-jdk"]; entry["extensions"] = extensions } + if reason == "invalid-maven" { extensions["maven"] = "invalid legacy value"; entry["extensions"] = extensions } + if reason == "invalid-selector" { + var maven = try #require(extensions["maven"] as? [String: Any]); maven["module"] = 42 + extensions["maven"] = maven; entry["extensions"] = extensions + } + if reason == "custom-debug" { entry["debug"] = ["adapter": "dev.example.custom"] } + if reason == "saved-preferences" { + entry["id"] = "user:legacy-service" + var local = try fixture.read(fixture.local), localItems = try #require(fixture.read(fixture.local)["configurations"] as? [[String: Any]]) + localItems[0]["id"] = "user:legacy-service"; local["configurations"] = localItems + try fixture.write(local, to: fixture.local) + } + if reason == "version" { team["version"] = 1 } + if reason == "invalid-entrypoint" { + var maven = try #require(extensions["maven"] as? [String: Any]); maven["mainClass"] = String(repeating: "a", count: 4097) + extensions["maven"] = maven; entry["extensions"] = extensions + } + entries[0] = entry; team["configurations"] = entries + try fixture.write(team, to: fixture.team) + var runs = fixture.runs + if reason == "ambiguous" { + let other = BuildProviderRunReference(pluginID: "io.github.lithe-idea.maven-build", buildSystemID: "maven", root: "nested", + run: "framework", projectID: "p", targetID: "other/opaque", execution: .service) + runs.append(RunProviderConfiguration(projectPath: "nested/app", name: "Other", reference: other)) + } + if reason == "preferences" || reason == "saved-preferences" { + let key = fixture.root.path.replacingOccurrences(of: "/", with: "_") + let id = reason == "saved-preferences" ? "user:legacy-service" : fixture.id + preferences.set(Data("retired choices".utf8), forKey: "lithe.java-run-options.\(key).\(id)") + } + let before = try fixture.bytes() + let models: [BuildModel] + if reason == "changed-framework" { + models = [try JSONDecoder().decode(BuildModel.self, from: Data(#"{"projects":[{"id":"p","path":"nested/app","name":"app"}],"targets":[{"id":"opaque/service","projectID":"p","name":"micronaut.maven","path":"nested/app","tags":["maven-service"]}]}"#.utf8))] + } else { models = try fixture.models } + let store = MacRunConfigurationStore(core: RustCoreBridge(), storage: MacFileStorage(), preferences: preferences) + #expect(throws: (any Error).self) { + _ = try store.generate(at: fixture.root, files: [], modulePaths: [], javaEntrypoints: nil, + projectOwnership: fixture.projects, providerRuns: runs, models: models, lease: RunConfigurationGenerationLease()) + } + #expect(try fixture.bytes() == before) + } + + @Test(arguments: ["before", "after-first-write", "after-second-write", "second-file-conflict", "changed-source", "changed-first-during-rollback", "changed-local-after-write", "preferences-created-during-write", "marker-changed-during-write"]) + func cancellationAndConflictsUseConditionalRollbackWithoutOverwritingNewerBytes(mode: String) throws { + let fixture = try Fixture() + defer { fixture.remove() } + let lease = RunConfigurationGenerationLease(), storage = MigrationFaultStorage(), preferences = MigrationPreferences() + let migration = MacLegacyMavenRunConfigurationMigration(storage: storage, preferences: preferences, immutableRoots: []) + let plan = try migration.prepare(workspace: fixture.root, projects: fixture.projects, runs: fixture.runs, models: fixture.models, lease: lease) + #expect(plan.writes.count == 2) + let original = try fixture.bytes() + let external = Data(#"{"version":2,"configurations":[],"external":"preserve"}"#.utf8) + if mode == "before" { lease.invalidate() } + if mode == "changed-source" { try external.write(to: fixture.local) } + storage.beforeReplace = { index in + if index == 2 && mode == "second-file-conflict" { try external.write(to: fixture.local) } + if index == 2 && mode == "changed-first-during-rollback" { + try external.write(to: fixture.team); try external.write(to: fixture.local) + } + } + storage.afterReplace = { index in + if (index == 1 && mode == "after-first-write") || (index == 2 && mode == "after-second-write") { lease.invalidate() } + if index == 2 && mode == "changed-local-after-write" { try external.write(to: fixture.local) } + let projectKey = fixture.root.path.replacingOccurrences(of: "/", with: "_") + if index == 1 && mode == "preferences-created-during-write" { + preferences.set(Data("new legacy choices".utf8), forKey: "lithe.java-run-options.\(projectKey).\(fixture.id)") + } + if index == 1 && mode == "marker-changed-during-write" { + preferences.set(true, forKey: "lithe.run-configuration-migrated.\(projectKey)") + } + } + #expect(throws: (any Error).self) { try migration.apply(plan, workspace: fixture.root, lease: lease) } + if mode == "changed-first-during-rollback" { + #expect(try Data(contentsOf: fixture.team) == external) + } else { #expect(try Data(contentsOf: fixture.team) == original[0]) } + let expectedLocal = ["second-file-conflict", "changed-source", "changed-first-during-rollback", "changed-local-after-write"].contains(mode) ? external : original[1] + #expect(try Data(contentsOf: fixture.local) == expectedLocal) + } + + @Test + func changingAnUnwrittenSourceRollsBackOnlyOurDestination() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let originalTeam = try fixture.read(fixture.team) + try fixture.write(originalTeam, to: fixture.local) + try fixture.write(["version": 2, "configurations": []], to: fixture.team) + let beforeLocal = try Data(contentsOf: fixture.local) + let storage = MigrationFaultStorage(), lease = RunConfigurationGenerationLease() + let migration = MacLegacyMavenRunConfigurationMigration(storage: storage, preferences: MigrationPreferences(), immutableRoots: []) + let plan = try migration.prepare(workspace: fixture.root, projects: fixture.projects, runs: fixture.runs, models: fixture.models, lease: lease) + #expect(plan.writes.count == 1) + let external = Data(#"{"version":2,"configurations":[],"external":"preserve"}"#.utf8) + storage.afterReplace = { index in if index == 1 { try external.write(to: fixture.team) } } + #expect(throws: (any Error).self) { try migration.apply(plan, workspace: fixture.root, lease: lease) } + #expect(try Data(contentsOf: fixture.team) == external) + #expect(try Data(contentsOf: fixture.local) == beforeLocal) + } + + @Test + func aReadOnlyWorkspaceWithoutLegacyEntriesRemainsReadable() throws { + let fixture = try Fixture() + defer { fixture.remove() } + try fixture.write(["version": 2, "configurations": []], to: fixture.team) + try fixture.write(["version": 2, "configurations": []], to: fixture.local) + let before = try fixture.bytes() + let migration = MacLegacyMavenRunConfigurationMigration(storage: MacFileStorage(), preferences: MigrationPreferences(), immutableRoots: [fixture.root]) + let plan = try migration.prepare(workspace: fixture.root, projects: fixture.projects, runs: fixture.runs, models: fixture.models, lease: RunConfigurationGenerationLease()) + #expect(plan.writes.isEmpty) + #expect(try fixture.bytes() == before) + } + + @Test + func immutablePackagesAndEscapingSymlinksRejectBeforePublication() throws { + let fixture = try Fixture(), outside = try Fixture() + defer { fixture.remove(); outside.remove() } + let lease = RunConfigurationGenerationLease() + let migration = MacLegacyMavenRunConfigurationMigration(storage: MacFileStorage(), preferences: MigrationPreferences(), immutableRoots: [fixture.root]) + let before = try fixture.bytes() + #expect(throws: (any Error).self) { try migration.prepare(workspace: fixture.root, projects: fixture.projects, runs: fixture.runs, models: fixture.models, lease: lease) } + #expect(try fixture.bytes() == before) + try FileManager.default.removeItem(at: fixture.root.appendingPathComponent(".lithe/run")) + try FileManager.default.createSymbolicLink(at: fixture.root.appendingPathComponent(".lithe/run"), withDestinationURL: outside.root.appendingPathComponent(".lithe/run")) + let external = try outside.bytes() + let linked = MacLegacyMavenRunConfigurationMigration(storage: MacFileStorage(), preferences: MigrationPreferences(), immutableRoots: []) + #expect(throws: (any Error).self) { try linked.prepare(workspace: fixture.root, projects: fixture.projects, runs: fixture.runs, models: fixture.models, lease: lease) } + #expect(try outside.bytes() == external) + } + + private struct Fixture { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("legacy-maven-run-\(UUID().uuidString)") + let id = "quarkus.maven:nested/app" + var team: URL { root.appendingPathComponent(".lithe/run/configurations.json") } + var local: URL { root.appendingPathComponent(".lithe/run/local.json") } + let projects = [RunProjectOwnership(path: "nested/app", build: .init(pluginID: "io.github.lithe-idea.maven-build", buildSystemID: "maven", root: "nested", projectID: "p"), contentRoots: [], name: "app")] + var runs: [RunProviderConfiguration] { + [.init(projectPath: "nested/app", name: "Current service", reference: .init(pluginID: "io.github.lithe-idea.maven-build", + buildSystemID: "maven", root: "nested", run: "framework", projectID: "p", targetID: "opaque/service", execution: .service))] + } + var models: [BuildModel] { + get throws { + [try JSONDecoder().decode(BuildModel.self, from: Data(#"{"projects":[{"id":"p","path":"nested/app","name":"app"}],"targets":[{"id":"opaque/service","projectID":"p","name":"quarkus.maven","path":"nested/app","tags":["maven-service"]}]}"#.utf8))] + } + } + init() throws { + do { + try FileManager.default.createDirectory(at: root.appendingPathComponent("nested/app/work"), withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: team.deletingLastPathComponent(), withIntermediateDirectories: true) + try write(["version": 2, "annotation": "keep document metadata", "configurations": [["id": id, "name": "User service", "provider": "quarkus.maven", "execution": "service", "cwd": "nested", "toolchains": ["java": "project-jdk", "maven": "project-maven"], "debug": ["adapter": "jdwp"], "extensions": ["maven": ["module": "app", "jvmArguments": ["--runtime=one two", "", "雪"], "programArguments": ["--team=space value"], "mainClass": "opaque/entry:1", "profiles": ["team"], "skipTests": true], "annotations": ["keep": true]]]]], to: team) + try write(["version": 2, "annotation": "keep local metadata", "configurations": [["id": id, "cwd": "nested/app/work", "env": ["EXAMPLE": "local"], "extensions": ["maven": ["programArguments": ["--local=雪"], "profiles": [], "skipTests": false]]]]], to: local) + try write(["version": 1, "defaultRunConfiguration": id], to: root.appendingPathComponent(".lithe/project.json")) + } catch { remove(); throw error } + } + func read(_ url: URL) throws -> [String: Any] { try #require(JSONSerialization.jsonObject(with: Data(contentsOf: url)) as? [String: Any]) } + func write(_ document: [String: Any], to url: URL) throws { try JSONSerialization.data(withJSONObject: document, options: [.sortedKeys]).write(to: url) } + func bytes() throws -> [Data] { try [Data(contentsOf: team), Data(contentsOf: local)] } + func remove() { try? FileManager.default.removeItem(at: root) } + } +} + +/// Faults occur at explicit synchronous publication boundaries, without timing or background tasks. +private final class MigrationFaultStorage: FileStorage, @unchecked Sendable { + private let base = MacFileStorage() + private var replacements = 0 + var beforeReplace: (Int) throws -> Void = { _ in } + var afterReplace: (Int) throws -> Void = { _ in } + func replaceDataPreservingMetadata(_ data: Data, at url: URL, expecting expected: Data?) throws -> Bool { + replacements += 1; let index = replacements + try beforeReplace(index) + let result = try base.replaceDataPreservingMetadata(data, at: url, expecting: expected) + try afterReplace(index) + return result + } + func homeDirectory() -> URL { base.homeDirectory() } + func cacheDirectory() -> URL { base.cacheDirectory() } + func applicationSupportDirectory() -> URL { base.applicationSupportDirectory() } + func temporaryDirectory() -> URL { base.temporaryDirectory() } + func metadata(for url: URL) -> FileMetadata? { base.metadata(for: url) } + func fileExists(at url: URL) -> Bool { base.fileExists(at: url) } + func isExecutable(at url: URL) -> Bool { base.isExecutable(at: url) } + func listDirectory(at url: URL) -> [URL] { base.listDirectory(at: url) } + func readPrefix(from url: URL, byteCount: Int) throws -> Data { try base.readPrefix(from: url, byteCount: byteCount) } + func readData(from url: URL, options: Data.ReadingOptions) throws -> Data { try base.readData(from: url, options: options) } + func writeData(_ data: Data, to url: URL, options: Data.WritingOptions) throws { try base.writeData(data, to: url, options: options) } + func replaceData(_ data: Data, at url: URL, expecting expected: Data?) throws -> Bool { try base.replaceData(data, at: url, expecting: expected) } + func removeData(at url: URL, expecting expected: Data) throws -> Bool { try base.removeData(at: url, expecting: expected) } + func createDirectory(at url: URL, withIntermediateDirectories: Bool) throws { try base.createDirectory(at: url, withIntermediateDirectories: withIntermediateDirectories) } + func removeItem(at url: URL) throws { try base.removeItem(at: url) } + func moveItem(at sourceURL: URL, to destinationURL: URL) throws { try base.moveItem(at: sourceURL, to: destinationURL) } + func copyItem(at sourceURL: URL, to destinationURL: URL) throws { try base.copyItem(at: sourceURL, to: destinationURL) } +} + +private final class MigrationPreferences: KeyValueStore, @unchecked Sendable { + private var values: [String: Any] = [:] + func data(forKey key: String) -> Data? { values[key] as? Data } + func object(forKey key: String) -> Any? { values[key] } + func string(forKey key: String) -> String? { values[key] as? String } + func stringArray(forKey key: String) -> [String]? { values[key] as? [String] } + func set(_ value: Any?, forKey key: String) { values[key] = value } + func keys(withPrefix prefix: String) -> [String]? { values.keys.filter { $0.hasPrefix(prefix) }.sorted() } +} diff --git a/macos/Tests/LitheTests/MacNativeBuildProviderIntegrationTests.swift b/macos/Tests/LitheTests/MacNativeBuildProviderIntegrationTests.swift index 479d175ba6..76b46acfe2 100644 --- a/macos/Tests/LitheTests/MacNativeBuildProviderIntegrationTests.swift +++ b/macos/Tests/LitheTests/MacNativeBuildProviderIntegrationTests.swift @@ -1,6 +1,7 @@ import CryptoKit import Foundation import LitheCoreContracts +import LitheExecutionModule import LitheLanguageIntelligenceModule import Testing @testable import Lithe @@ -10,6 +11,560 @@ import Testing @Suite("Native macOS build Provider integration", .serialized) @MainActor struct MacNativeBuildProviderIntegrationTests { + @Test(.enabled(if: ProcessInfo.processInfo.environment["LITHE_STAGE_D_MAVEN_PACKAGE"] != nil, + "Requires the stage D Maven package and selected Java/Maven"), arguments: [false, true], [false, true]) + func installedDefinitionsResolveTheRealBuildAndRuntimeThroughSavedChoices(projectWrapper: Bool, consumerDefaults: Bool) async throws { + let environment = ProcessInfo.processInfo.environment + let package = URL(fileURLWithPath: try #require(environment["LITHE_STAGE_D_MAVEN_PACKAGE"])) + let javaPath = try #require(environment["LITHE_STAGE_C_JAVA"]) + let mavenPath = try #require(environment["LITHE_STAGE_C_MAVEN"]) + let java = try #require(MacToolchainDiscovery.realpath(javaPath)) + let maven = try #require(MacToolchainDiscovery.realpath(mavenPath)) + let javaHome = URL(fileURLWithPath: java).deletingLastPathComponent().deletingLastPathComponent().path + let mavenHome = URL(fileURLWithPath: maven).deletingLastPathComponent().deletingLastPathComponent().path + let files = FileManager.default + let workspace = files.temporaryDirectory.appendingPathComponent("lithe-native-defined-tools-\(UUID().uuidString)") + try files.createDirectory(at: workspace, withIntermediateDirectories: true) + defer { try? files.removeItem(at: workspace) } + let manifest = try Data(contentsOf: package.appendingPathComponent("plugin.json")) + let javaPackage = DeclarativePluginTestSupport.repositoryRoot.appendingPathComponent("Plugins/mac/Official/JavaSupport") + let sources: [ToolchainSettingsPluginSource] = [ + .init(pluginID: "io.github.lithe-idea.maven-build", name: "Maven", manifest: manifest, isEnabled: true), + .init(pluginID: "dev.lithe.plugin.java-support", name: "Java", manifest: try Data(contentsOf: javaPackage.appendingPathComponent("plugin.json")), + isEnabled: true, verifiedOfficial: true) + ] + let before = try Self.inventory(package), javaBefore = try Self.inventory(javaPackage) + var pins = ["java": ["home": javaHome, "version": "selected", "provider": "host:manual"]] + if projectWrapper { + // A fixture launcher delegates to the explicit real Maven without a + // download. Opening the workspace cannot execute this project code. + let wrapper = workspace.appendingPathComponent("mvnw") + let escapedMaven = maven.replacingOccurrences(of: "'", with: "'\"'\"'") + try Data("#!/bin/sh\nexec '\(escapedMaven)' \"$@\"\n".utf8).write(to: wrapper) + try files.setAttributes([.posixPermissions: 0o755], ofItemAtPath: wrapper.path) + try files.createDirectory(at: workspace.appendingPathComponent(".mvn/wrapper"), withIntermediateDirectories: true) + try Data("# fixture launcher needs no distribution\n".utf8) + .write(to: workspace.appendingPathComponent(".mvn/wrapper/maven-wrapper.properties")) + } else { + pins["io.github.lithe-idea.maven-build:maven"] = ["home": mavenHome, "version": "selected", "provider": "host:manual"] + } + if consumerDefaults { + let store = MacToolchainPreferencesStore(core: RustCoreBridge(), storage: MacFileStorage(), immutableRoots: { [package, javaPackage] }) + var baseline = try await store.load(workspace: workspace) + baseline = try await store.save(.object(["home": .string(javaHome)]), + at: .init(directory: ".", consumer: nil, tool: "java"), expecting: baseline) + baseline = try await store.save(.object(["inherit": .string("tool")]), + at: .init(directory: ".", consumer: "io.github.lithe-idea.maven-build/maven", tool: "java"), expecting: baseline) + if !projectWrapper { + _ = try await store.save(.object(["home": .string(mavenHome)]), + at: .init(directory: ".", consumer: "io.github.lithe-idea.maven-build/maven", tool: "io.github.lithe-idea.maven-build:maven"), expecting: baseline) + } + #expect(!files.fileExists(atPath: workspace.appendingPathComponent(".lithe/toolchains/local.json").path)) + } else { + try files.createDirectory(at: workspace.appendingPathComponent(".lithe/toolchains"), withIntermediateDirectories: true) + try JSONSerialization.data(withJSONObject: ["version": 1, "toolchains": [:], "pins": [".": pins]]) + .write(to: workspace.appendingPathComponent(".lithe/toolchains/local.json")) + } + let defined = MacDefinedToolchainResolver() + defined.sources = { sources } + let runtime = ProjectRuntimeService(runtimeLocator: MacRuntimeLocator(), store: NativeDefinedToolPreferences()) + runtime.openProject(at: workspace) + defer { runtime.closeProject() } + let resolver = MacBuildToolResolver(runtime: runtime, processRunner: MacProcessRunner(), fileStorage: MacFileStorage(), definedTools: defined) + let core = RustBuildSystemOperations(core: RustCoreBridge()) + let target = BuildSystemTarget(manifest: manifest, buildSystemID: "maven") + let invocation = try core.invocationPlan(target, root: ".", settings: [:]) + #expect(resolver.languageImportExecutable(invocation, workspaceURL: workspace) == nil) + let selected = try await resolver.resolve(invocation, displayName: "Maven", workspaceURL: workspace) { runtime, output in + try core.checkToolVersion(target, runtime: runtime, output: output) + } + let expected = projectWrapper ? try #require(MacToolchainDiscovery.realpath(workspace.appendingPathComponent("mvnw").path)) : maven + #expect(selected.executableURL.path == expected) + #expect(selected.environment["JAVA_HOME"] == javaHome) + #expect(selected.executesProjectCode == projectWrapper) + if !projectWrapper { #expect(selected.version != nil) } + #expect(selected.providerEnvironment["JAVA_HOME"] == javaHome) + #expect(try Self.inventory(package) == before) + #expect(try Self.inventory(javaPackage) == javaBefore) + } + + @Test(.enabled(if: ProcessInfo.processInfo.environment["LITHE_STAGE_D_MAVEN_PACKAGE"] != nil, + "Requires the stage D Maven package and selected Java/Maven"), + .enabled(if: ProcessInfo.processInfo.environment["LITHE_STAGE_D_ALTERNATE_JAVA"] != nil, + "Requires an explicitly selected second compatible JDK")) + func consumerDefaultChangesReachTheRealMavenProcessWhileExplicitRunsStayFixed() async throws { + let environment = ProcessInfo.processInfo.environment + let package = URL(fileURLWithPath: try #require(environment["LITHE_STAGE_D_MAVEN_PACKAGE"])) + let primaryPath = try #require(environment["LITHE_STAGE_C_JAVA"]) + let alternatePath = try #require(environment["LITHE_STAGE_D_ALTERNATE_JAVA"]) + let mavenPath = try #require(environment["LITHE_STAGE_C_MAVEN"]) + let primary = try #require(MacToolchainDiscovery.realpath(primaryPath)) + let alternate = try #require(MacToolchainDiscovery.realpath(alternatePath)) + let maven = try #require(MacToolchainDiscovery.realpath(mavenPath)) + let homes = [primary, alternate].map { URL(fileURLWithPath: $0).deletingLastPathComponent().deletingLastPathComponent().path } + try #require(homes[0] != homes[1]) + let mavenHome = URL(fileURLWithPath: maven).deletingLastPathComponent().deletingLastPathComponent().path + let javaPackage = DeclarativePluginTestSupport.repositoryRoot.appendingPathComponent("Plugins/mac/Official/JavaSupport") + let manifest = try Data(contentsOf: package.appendingPathComponent("plugin.json")) + let sources: [ToolchainSettingsPluginSource] = [ + .init(pluginID: "io.github.lithe-idea.maven-build", name: "Maven", manifest: manifest, isEnabled: true), + .init(pluginID: "dev.lithe.plugin.java-support", name: "Java", manifest: try Data(contentsOf: javaPackage.appendingPathComponent("plugin.json")), + isEnabled: true, verifiedOfficial: true) + ] + let workspace = FileManager.default.temporaryDirectory.appendingPathComponent("lithe-real-default-change-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: workspace, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: workspace) } + let before = try Self.inventory(package), javaBefore = try Self.inventory(javaPackage) + let store = MacToolchainPreferencesStore(core: RustCoreBridge(), storage: MacFileStorage(), immutableRoots: { [package, javaPackage] }) + var baseline = try await store.load(workspace: workspace) + baseline = try await store.save(.object(["home": .string(mavenHome)]), + at: .init(directory: ".", consumer: "io.github.lithe-idea.maven-build/maven", tool: "io.github.lithe-idea.maven-build:maven"), expecting: baseline) + _ = try await store.save(.object(["inherit": .string("tool")]), + at: .init(directory: ".", consumer: "io.github.lithe-idea.maven-build/maven", tool: "java"), expecting: baseline) + let defined = MacDefinedToolchainResolver(); defined.sources = { sources } + let runtime = ProjectRuntimeService(runtimeLocator: MacRuntimeLocator(), store: NativeDefinedToolPreferences()) + runtime.openProject(at: workspace) + defer { runtime.closeProject() } + let resolver = MacBuildToolResolver(runtime: runtime, processRunner: MacProcessRunner(), fileStorage: MacFileStorage(), definedTools: defined) + let core = RustBuildSystemOperations(core: RustCoreBridge()) + let target = BuildSystemTarget(manifest: manifest, buildSystemID: "maven") + let invocation = try core.invocationPlan(target, root: ".", settings: [:]) + for (index, home) in homes.enumerated() { + baseline = try await store.load(workspace: workspace) + _ = try await store.save(.object(["home": .string(home)]), at: .init(directory: ".", consumer: nil, tool: "java"), expecting: baseline) + let selected = try await resolver.resolve(invocation, displayName: "Maven", workspaceURL: workspace) { runtime, output in + try core.checkToolVersion(target, runtime: runtime, output: output) + } + #expect(selected.environment["JAVA_HOME"] == home) + let result = try await MacBuildCommandRunner().run(.init(executableURL: selected.executableURL, arguments: ["--version"], + workingDirectory: workspace, environment: selected.environment, capturesStandardOutput: true, timeout: .seconds(5))) { _ in } + #expect(result.termination == .exited(0)) + #expect(result.standardOutput?.contains(home) == true) + if index == homes.count - 1 { + var explicit = invocation; explicit.toolSelections["java"] = .installation(home: homes[0]) + let fixed = try await resolver.resolve(explicit, displayName: "Maven", workspaceURL: workspace) { runtime, output in + try core.checkToolVersion(target, runtime: runtime, output: output) + } + #expect(fixed.environment["JAVA_HOME"] == homes[0]) + } + } + #expect(try Self.inventory(package) == before) + #expect(try Self.inventory(javaPackage) == javaBefore) + } + + @Test(.enabled(if: ProcessInfo.processInfo.environment["LITHE_STAGE_D_MAVEN_PACKAGE"] != nil, + "Requires the stage D Maven package and selected Java/Maven")) + func runProfileUsesOneMavenModelAndSavedGenerationTracksDefaultProfileChanges() async throws { + try await verifyRunConfigurationPreparation(savedTaskOnly: false) + } + + @Test(.enabled(if: ProcessInfo.processInfo.environment["LITHE_STAGE_D_MAVEN_PACKAGE"] != nil, + "Requires the stage D Maven package and selected Java/Maven")) + func savedProviderTaskUsesCurrentTypedInputsWithoutChangingBuildDefaults() async throws { + try await verifyRunConfigurationPreparation(savedTaskOnly: true) + } + + @Test(.enabled(if: ProcessInfo.processInfo.environment["LITHE_STAGE_D_MAVEN_PACKAGE"] != nil, + "Requires the stage D Maven package and selected Java/Maven")) + func legacyRunTaskMigratesToTheCurrentMavenTargetWithoutLosingItsStableID() async throws { + try await verifyRunConfigurationPreparation(savedTaskOnly: true, migrateLegacy: true) + } + + private func verifyRunConfigurationPreparation(savedTaskOnly: Bool, migrateLegacy: Bool = false) async throws { + let environment = ProcessInfo.processInfo.environment + let package = URL(fileURLWithPath: try #require(environment["LITHE_STAGE_D_MAVEN_PACKAGE"])) + let java = URL(fileURLWithPath: try #require(environment["LITHE_STAGE_C_JAVA"])) + let maven = URL(fileURLWithPath: try #require(environment["LITHE_STAGE_C_MAVEN"])) + let files = FileManager.default + let scratch = files.temporaryDirectory.appendingPathComponent("lithe-native-run-profile-\(UUID().uuidString)") + try files.createDirectory(at: scratch, withIntermediateDirectories: true) + defer { try? files.removeItem(at: scratch) } + let workspace = scratch.appendingPathComponent("workspace") + for path in ["nested/checks", "nested/test inputs"] { + try files.createDirectory(at: workspace.appendingPathComponent(path), withIntermediateDirectories: true) + } + try Data("class Main { public static void main(String[] args) {} }".utf8).write(to: workspace.appendingPathComponent("nested/checks/Main.java")) + try Data("selected=${selected.value}".utf8).write(to: workspace.appendingPathComponent("nested/test inputs/runtime.properties")) + try Data(""" + 4.0.0smokeapp1 + checksinactive-checks + ${selected.sources}${selected.checks}test inputs + true + run-onlycurrent-profile + inactive-sourceschecks + additional + """.utf8).write(to: workspace.appendingPathComponent("nested/pom.xml")) + let before = try Self.inventory(package) + let core = RustCoreBridge(), storage = MacFileStorage() + let planner = RustBuildSystemOperations(core: core), declarations = RustDeclarativePluginOperations(core: core) + let descriptor = try declarations.validate(manifest: Data(contentsOf: package.appendingPathComponent("plugin.json"))) + let source = BuildSystemPluginSource(pluginID: descriptor.id.rawValue, manifest: descriptor.normalizedManifest, + packageURL: package, buildSystems: descriptor.buildSystems) + let runtime = ExplicitProviderRuntime(java: java, environment: environment), released = TestGate() + let directories = MacPluginStateDirectories(rootURL: scratch.appendingPathComponent("state")) + let sessions = MacBuildProviderSessions(launchResolver: MacPluginLanguageServerLaunchResolver(planner: declarations, + directories: directories, settings: InMemoryPluginSettings()), tools: runtime, versionChecker: declarations, + runtime: runtime, core: core, processRegistry: nil, endBackendProcesses: { token, _ in + MacPluginBackendProcesses.end(token: token, gracePeriod: 0); released.open() + }) + let domain = "lithe.native-run-profile.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: domain)) + defer { defaults.removePersistentDomain(forName: domain) } + let options = MacBuildOptionsStore(store: MacUserDefaultsStore(defaults: defaults)) + let build = BuildSystemService(planner: planner, tools: ExplicitNativeConfigurationTool(maven: maven), optionsStore: options, + stateLocator: MacBuildSystemStateLocator(directories: directories, fileStorage: storage), + runner: MacBuildCommandRunner(), providers: sessions, sources: { [source] }) + let outcome: Result + do { + build.reset(workspaceURL: workspace) + let baseline = try await build.prepareLanguageImport(workspaceURL: workspace) + if migrateLegacy { + try await build.prepareLanguageTestModels(allModels: true, workspace: workspace) + _ = try await build.prepareLanguageImport(workspaceURL: workspace) + let store = MacRunConfigurationStore(core: core, storage: storage, preferences: MacUserDefaultsStore(defaults: defaults)) + let legacyID = "user:legacy-validate" + let url = workspace.appendingPathComponent(".lithe/run/local.json") + try files.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try JSONSerialization.data(withJSONObject: ["version": 2, "configurations": [["id": legacyID, "name": "Saved validate", + "provider": "maven.module", "execution": "task", "cwd": "nested", "args": ["validate"], + "env": ["LITHE_RUN_VALUE": "雪"], "extensions": ["maven": ["module": ".", "profiles": ["run-only"]]]]]], options: [.sortedKeys]).write(to: url) + _ = try store.generate(at: workspace, files: [], modulePaths: [], javaEntrypoints: nil, + projectOwnership: build.runProjectOwnership(), providerRuns: build.providerRunConfigurations(), + models: build.entries.compactMap(\.model), lease: RunConfigurationGenerationLease()) + let resolution = try store.resolve(at: workspace, toolchainCandidates: []) + #expect(resolution.configurations.count == 1) + #expect(resolution.configurations.first?.configuration.id == legacyID) + let inputs = try store.providerLaunchInputs(at: workspace, configurationID: legacyID, providerRoot: "nested") + #expect(inputs.reference?.run == "module") + #expect(inputs.launchOptions.arguments == ["validate"]) + #expect(inputs.optionOverrides["profiles"] == .array([.string("run-only")])) + let prepared = try await build.prepareConfigurationRun(inputs: inputs, instanceRoot: "nested", workspace: workspace, + tool: BuildProviderHints(), isCurrent: { true }, progress: { _ in }) + #expect(prepared.arguments.last == "validate") + #expect(prepared.arguments.contains("-Prun-only")) + #expect(prepared.environment["LITHE_RUN_VALUE"] == "雪") + #expect(try build.languageImport(workspaceURL: workspace, root: nil) == baseline) + #expect(try options.instanceOptions(workspaceURL: workspace, buildSystemID: "maven")["nested"]?["profiles"] == nil) + } else if savedTaskOnly { + try await build.prepareLanguageTestModels(allModels: true, workspace: workspace) + let taskPreparation = try await build.prepareConfigurationRun(inputs: ProviderLaunchInputs( + buildSystemID: "maven", provider: "maven.module", projectPath: "nested", + launchOptions: BuildLaunchOptions(execution: .task, arguments: ["validate"], environment: ["LITHE_RUN_VALUE": "雪"], workingDirectory: "."), + optionOverrides: ["profiles": .array([.string("run-only")])]), instanceRoot: "nested", workspace: workspace, + tool: BuildProviderHints(toolExecutable: maven, toolEnvironment: ["JAVA_HOME": java.deletingLastPathComponent().deletingLastPathComponent().path]), + isCurrent: { true }, progress: { _ in }) + #expect(taskPreparation.executable.value == "io.github.lithe-idea.maven-build:maven") + #expect(taskPreparation.arguments.contains("validate")) + #expect(taskPreparation.arguments.contains("-Prun-only")) + #expect(taskPreparation.workingDirectory == ".") + #expect(taskPreparation.environment["LITHE_RUN_VALUE"] == "雪") + #expect(try build.languageImport(workspaceURL: workspace, root: nil) == baseline) + let store = MacRunConfigurationStore(core: core, storage: storage, preferences: MacUserDefaultsStore(defaults: defaults)) + let moduleTarget = try #require(build.entries.first?.model?.targets.first { $0.name == "maven.module" }) + let reference = BuildProviderRunReference(pluginID: source.pluginID, buildSystemID: "maven", root: "nested", + run: "module", projectID: moduleTarget.projectID, targetID: moduleTarget.id, userArguments: ["validate"], execution: .task) + let savedID = try store.saveBuildProviderConfiguration(reference, name: "Validate module", at: workspace) + try store.saveOptions(RunOptions(programArguments: "validate", providerRunOptions: BuildProviderRunOptions( + options: ["profiles": .array([.string("run-only"), .string("additional")])])), configurationID: savedID, scope: .local, at: workspace) + let savedInputs = try store.providerLaunchInputs(at: workspace, configurationID: savedID, providerRoot: "nested") + let savedPreparation = try await build.prepareConfigurationRun(inputs: savedInputs, instanceRoot: "nested", workspace: workspace, + tool: BuildProviderHints(), isCurrent: { true }, progress: { _ in }) + #expect(savedPreparation.arguments.last == "validate") + #expect(savedPreparation.arguments.contains("-Padditional,run-only")) + #expect(savedInputs.optionOverrides["profiles"] == .array([.string("run-only"), .string("additional")])) + #expect(try build.languageImport(workspaceURL: workspace, root: nil) == baseline) + #expect(try options.instanceOptions(workspaceURL: workspace, buildSystemID: "maven")["nested"]?["profiles"] == nil) + #expect(try store.resolve(at: workspace, toolchainCandidates: []).configurations.first?.configuration.execution == .task) + #expect(build.configurationRunSource(inputs: savedInputs, instanceRoot: "nested") == source) + } else { + let prepared = try await build.prepareLanguageRun(fileURL: workspace.appendingPathComponent("nested/checks/Main.java"), + workspace: workspace, purpose: "run", + optionOverrides: ["profiles": .array([.string("run-only")])], + tool: { _ in BuildProviderHints(toolExecutable: maven, + toolEnvironment: runtime.environment.filter { $0.key == "JAVA_HOME" || $0.key == "PATH" }) }, isCurrent: { true }) + guard case .maven(let context)? = prepared else { throw NativeProviderIntegrationError.unexpectedCommand } + #expect(context.profiles == ["run-only"]) + #expect(context.mavenExecutablePath == maven.path) + #expect(context.reactorPath == "nested") + #expect(try String(contentsOf: workspace.appendingPathComponent("nested/target/test-classes/runtime.properties"), encoding: .utf8) == "selected=current-profile") + #expect(build.entries.first?.model?.contentRoots.contains { $0.path == "nested/checks" && $0.kind == "source" } == true) + #expect(try build.languageImport(workspaceURL: workspace, root: nil) == baseline) + #expect(try options.instanceOptions(workspaceURL: workspace, buildSystemID: "maven")["nested"]?["profiles"] == nil) + let ownership = try build.runProjectOwnership() + #expect(ownership.first?.build?.root == "nested") + #expect(ownership.first?.contentRoots.contains { $0.path == "nested/checks" && $0.kind == "source" } == true) + let store = MacRunConfigurationStore(core: core, storage: storage, preferences: MacUserDefaultsStore(defaults: defaults)) + let runs = try build.providerRunConfigurations() + #expect(runs.contains { $0.reference.run == "module" && $0.reference.execution == .task }) + _ = try store.generate(at: workspace, files: [workspace.appendingPathComponent("nested/checks/Main.java")], modulePaths: [], + javaEntrypoints: JavaEntrypoints(entries: [JavaEntrypoint(sourcePath: "nested/checks/Main.java", mainClass: "Main")]), + projectOwnership: ownership, providerRuns: runs) + let resolvedRuns = try store.resolve(at: workspace, toolchainCandidates: []).configurations.compactMap { $0.configuration.buildProvider } + #expect(Set(resolvedRuns.map(\.targetID)) == Set(runs.map { $0.reference.targetID })) + #expect(!store.inspectProviderGeneration(at: workspace, projectOwnership: ownership, providerRuns: runs).diagnostics.contains { $0.code == "staleProviderModel" }) + let entryID = try #require(build.entries.first?.id) + func currentOwnership() async throws -> [LitheCoreContracts.RunProjectOwnership] { + _ = try await build.prepareLanguageImport(workspaceURL: workspace) + try await build.prepareLanguageTestModels(allModels: true, workspace: workspace) + _ = try await build.prepareLanguageImport(workspaceURL: workspace) + return try build.runProjectOwnership() + } + // These are explicit edits of Build defaults after the temporary Run + // has completed. POM bytes stay unchanged throughout A → B → A. + build.setOption(entryID, optionID: "profiles", value: .array([.string("run-only")])) + let changed = try await currentOwnership() + #expect(changed.first?.contentRoots.contains { $0.path == "nested/checks" && $0.kind == "testSource" } == true) + #expect(store.inspectProviderGeneration(at: workspace, projectOwnership: changed, providerRuns: try build.providerRunConfigurations()).diagnostics.contains { $0.code == "staleProviderModel" }) + build.setOption(entryID, optionID: "profiles", value: .array([])) + let restored = try await currentOwnership() + #expect(restored == ownership) + #expect(!store.inspectProviderGeneration(at: workspace, projectOwnership: restored, providerRuns: try build.providerRunConfigurations()).diagnostics.contains { $0.code == "staleProviderModel" }) + } + outcome = .success(()) + } catch { outcome = .failure(error) } + build.stop(); sessions.retainProviders { _ in false } + #expect(await released.waitUntilOpen(timeout: .seconds(5))) + #expect(try Self.inventory(package) == before) + try outcome.get() + } + + @Test(.enabled(if: ProcessInfo.processInfo.environment["LITHE_STAGE_D_MAVEN_PACKAGE"] != nil, + "Requires the stage D native-configuration development package")) + func savingNativeSettingsKeepsTheOtherReactorIndependent() async throws { + try await nativeSettingsStayIndependentAcrossReactorRoots(replaceSource: false) + } + + @Test(.enabled(if: ProcessInfo.processInfo.environment["LITHE_STAGE_D_MAVEN_PACKAGE"] != nil, + "Requires the stage D native-configuration development package")) + func replacingNativeSettingsSourceKeepsTheOtherReactorIndependent() async throws { + try await nativeSettingsStayIndependentAcrossReactorRoots(replaceSource: true) + } + + private func nativeSettingsStayIndependentAcrossReactorRoots(replaceSource: Bool) async throws { + let environment = ProcessInfo.processInfo.environment + let package = URL(fileURLWithPath: try #require(environment["LITHE_STAGE_D_MAVEN_PACKAGE"])) + let java = URL(fileURLWithPath: try #require(environment["LITHE_STAGE_C_JAVA"])) + let maven = URL(fileURLWithPath: try #require(environment["LITHE_STAGE_C_MAVEN"])) + let files = FileManager.default + let scratch = files.temporaryDirectory.appendingPathComponent("lithe-native-reactor-settings-\(UUID().uuidString)") + try files.createDirectory(at: scratch, withIntermediateDirectories: true) + defer { try? files.removeItem(at: scratch) } + let workspace = scratch.appendingPathComponent("workspace") + for root in ["a", "b"] { + let directory = workspace.appendingPathComponent(root) + try files.createDirectory(at: directory, withIntermediateDirectories: true) + try Data("".utf8).write(to: directory.appendingPathComponent("pom.xml")) + } + let a = scratch.appendingPathComponent("a settings.xml") + let b = scratch.appendingPathComponent("b settings.xml") + let replacement = scratch.appendingPathComponent("replacement.xml") + for (url, name) in [(a, "a"), (b, "b"), (replacement, "replacement")] { + try Data("\(scratch.appendingPathComponent(name + "-repository").path)".utf8).write(to: url) + } + // Each reactor has an independent pin. Maven receives its canonical + // selection as environment rather than inferring the backend's java.home. + let javaHome = java.resolvingSymlinksInPath().deletingLastPathComponent().deletingLastPathComponent() + var pins: [String: [String: [String: String]]] = [:] + let mavenPath = try #require(MacToolchainDiscovery.realpath(maven.path)) + let mavenHome = URL(fileURLWithPath: mavenPath).deletingLastPathComponent().deletingLastPathComponent().path + for root in ["a", "b"] { + let alias = scratch.appendingPathComponent("runtime-\(root)") + try files.createSymbolicLink(at: alias, withDestinationURL: javaHome) + pins[root] = ["java": ["home": alias.path, "version": "selected", "provider": "host:manual"], + "io.github.lithe-idea.maven-build:maven": ["home": mavenHome, "version": "selected", "provider": "host:manual"]] + } + try files.createDirectory(at: workspace.appendingPathComponent(".lithe/toolchains"), withIntermediateDirectories: true) + try JSONSerialization.data(withJSONObject: ["version": 1, "toolchains": [:], "pins": pins]) + .write(to: workspace.appendingPathComponent(".lithe/toolchains/local.json")) + let bSettings = "\(scratch.appendingPathComponent("b-repository").path)${env.JAVA_HOME}" + try Data(bSettings.utf8).write(to: b) + let before = try Self.inventory(package) + let core = RustCoreBridge() + let planner = RustBuildSystemOperations(core: core) + let declarations = RustDeclarativePluginOperations(core: core) + let descriptor = try declarations.validate(manifest: Data(contentsOf: package.appendingPathComponent("plugin.json"))) + let source = BuildSystemPluginSource(pluginID: descriptor.id.rawValue, manifest: descriptor.normalizedManifest, + packageURL: package, buildSystems: descriptor.buildSystems) + let runtime = ExplicitProviderRuntime(java: java, environment: environment) + let released = TestGate() + let directories = MacPluginStateDirectories(rootURL: scratch.appendingPathComponent("state")) + let sessions = MacBuildProviderSessions( + launchResolver: MacPluginLanguageServerLaunchResolver(planner: declarations, directories: directories, + settings: InMemoryPluginSettings()), tools: runtime, versionChecker: declarations, + runtime: runtime, core: core, processRegistry: nil, endBackendProcesses: { token, _ in + MacPluginBackendProcesses.end(token: token, gracePeriod: 0); released.open() + }) + let domain = "lithe.native-reactor-settings.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: domain)) + defer { defaults.removePersistentDomain(forName: domain) } + let options = MacBuildOptionsStore(store: MacUserDefaultsStore(defaults: defaults)) + try options.setInstanceOptions(["a": ["settingsFile": .string(a.path)], "b": ["settingsFile": .string(b.path)]], + workspaceURL: workspace, buildSystemID: "maven") + let storage = MacFileStorage() + let defined = MacDefinedToolchainResolver() + let javaManifest = try Data(contentsOf: DeclarativePluginTestSupport.repositoryRoot.appendingPathComponent("Plugins/mac/Official/JavaSupport/plugin.json")) + let toolSources: [ToolchainSettingsPluginSource] = [ + .init(pluginID: descriptor.id.rawValue, name: "Maven", manifest: descriptor.normalizedManifest, isEnabled: true), + .init(pluginID: "dev.lithe.plugin.java-support", name: "Java", manifest: javaManifest, isEnabled: true, verifiedOfficial: true) + ] + defined.sources = { toolSources } + let projectRuntime = ProjectRuntimeService(runtimeLocator: CanonicalToolchainRuntimeLocator(), store: NativeDefinedToolPreferences()) + projectRuntime.openProject(at: workspace) + defer { projectRuntime.closeProject() } + let build = BuildSystemService(planner: planner, + tools: MacBuildToolResolver(runtime: projectRuntime, processRunner: MacProcessRunner(), fileStorage: storage, definedTools: defined), optionsStore: options, + stateLocator: MacBuildSystemStateLocator(directories: directories, fileStorage: storage), + runner: MacBuildCommandRunner(), providers: sessions, + nativeConfigurationFiles: MacBuildNativeConfigurationStore(storage: storage, planner: planner, + immutableRoots: [package, Bundle.main.bundleURL]), sources: { [source] }) + let outcome: Result + do { + build.reset(workspaceURL: workspace) + _ = try await build.prepareLanguageImport(workspaceURL: workspace) + #expect(build.entries.map(\.id) == ["maven:a", "maven:b"]) + // Saving a source and selecting another source are independent + // workflows. Each keeps both reactors live but owns one mutation, + // without reading an unchanged fixture through Maven twice. + if replaceSource { + let originalA = try Data(contentsOf: a) + _ = try build.configureOption("maven:a", optionID: "settingsFile", value: .string(replacement.path)) + _ = try await build.prepareLanguageImport(workspaceURL: workspace) + let changedA = try await build.readNativeConfiguration("maven:a") + #expect(changedA.sources.first { $0.id == "user" }?.uri == replacement.absoluteString) + #expect(try Data(contentsOf: a) == originalA) + } else { + let firstA = try await build.readNativeConfiguration("maven:a") + let userA = try #require(firstA.sources.first { $0.id == "user" }) + #expect(userA.uri == a.absoluteString) + let savedPath = scratch.appendingPathComponent("saved-a-repository").path + // The production save already reads back from the provider. + let changedA = try await build.saveNativeConfiguration("maven:a", snapshot: firstA, + edits: BuildNativeConfigurationEdits(sourceID: userA.id, revision: userA.revision, + values: ["localRepository": .string(savedPath)])) + #expect(changedA.sources.first { $0.id == "user" }?.fields.first { $0.id == "localRepository" }?.effectiveValue == .string(savedPath)) + } + let unchangedB = try await build.readNativeConfiguration("maven:b") + let userB = try #require(unchangedB.sources.first { $0.id == "user" }) + #expect(userB.uri == b.absoluteString) + #expect(userB.fields.first { $0.id == "localRepository" }?.effectiveValue + == .string(scratch.appendingPathComponent("b-repository").path)) + #expect(userB.fields.first { $0.id == "activeProfiles" }?.effectiveValue + == .array([.string(try #require(MacToolchainDiscovery.realpath(javaHome.path)))])) + #expect(try String(contentsOf: b, encoding: .utf8) == bSettings) + #expect(try options.instanceOptions(workspaceURL: workspace, buildSystemID: "maven")["b"]?["settingsFile"] == .string(b.path)) + #expect(!files.fileExists(atPath: workspace.appendingPathComponent("a/target").path)) + #expect(!files.fileExists(atPath: workspace.appendingPathComponent("b/target").path)) + for path in ["a-repository", "b-repository", "saved-a-repository", "replacement-repository"] { + #expect(!files.fileExists(atPath: scratch.appendingPathComponent(path).path)) + } + outcome = .success(()) + } catch { outcome = .failure(error) } + build.stop(); sessions.retainProviders { _ in false } + #expect(await released.waitUntilOpen(timeout: .seconds(5))) + #expect(try Self.inventory(package) == before) + try outcome.get() + } + + @Test(.enabled(if: ProcessInfo.processInfo.environment["LITHE_STAGE_D_MAVEN_PACKAGE"] != nil, + "Requires the stage D native-configuration development package")) + func nativeSettingsUseProductionStorageReadBackAndRejectExternalChanges() async throws { + let environment = ProcessInfo.processInfo.environment + let package = URL(fileURLWithPath: try #require(environment["LITHE_STAGE_D_MAVEN_PACKAGE"])) + let java = URL(fileURLWithPath: try #require(environment["LITHE_STAGE_C_JAVA"])) + let maven = URL(fileURLWithPath: try #require(environment["LITHE_STAGE_C_MAVEN"])) + let files = FileManager.default + let scratch = files.temporaryDirectory.appendingPathComponent("lithe-native-settings-workflow-\(UUID().uuidString)") + try files.createDirectory(at: scratch, withIntermediateDirectories: true) + defer { try? files.removeItem(at: scratch) } + let workspace = scratch.appendingPathComponent("workspace") + try files.createDirectory(at: workspace.appendingPathComponent(".mvn"), withIntermediateDirectories: true) + try Data("".utf8).write(to: workspace.appendingPathComponent("pom.xml")) + let user = scratch.appendingPathComponent("user settings.xml") + let global = scratch.appendingPathComponent("global.xml") + let inheritedRepository = scratch.appendingPathComponent("inherited-repository").path + let selectedRepository = scratch.appendingPathComponent("saved-repository").path + let original = "test-secret-preservedci" + try Data(original.utf8).write(to: user) + try Data("\(inheritedRepository)".utf8).write(to: global) + try Data("--global-settings=\(global.path)\n".utf8).write(to: workspace.appendingPathComponent(".mvn/maven.config")) + let before = try Self.inventory(package) + let core = RustCoreBridge() + let planner = RustBuildSystemOperations(core: core) + let declarations = RustDeclarativePluginOperations(core: core) + let descriptor = try declarations.validate(manifest: Data(contentsOf: package.appendingPathComponent("plugin.json"))) + let source = BuildSystemPluginSource(pluginID: descriptor.id.rawValue, manifest: descriptor.normalizedManifest, + packageURL: package, buildSystems: descriptor.buildSystems) + let selectedRuntime = ExplicitProviderRuntime(java: java, environment: environment) + let released = TestGate() + let directories = MacPluginStateDirectories(rootURL: scratch.appendingPathComponent("state")) + let sessions = MacBuildProviderSessions( + launchResolver: MacPluginLanguageServerLaunchResolver(planner: declarations, directories: directories, + settings: InMemoryPluginSettings()), tools: selectedRuntime, versionChecker: declarations, + runtime: selectedRuntime, core: core, processRegistry: nil, endBackendProcesses: { token, _ in + MacPluginBackendProcesses.end(token: token, gracePeriod: 0); released.open() + }) + let preferenceDomain = "lithe.native-settings.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: preferenceDomain)) + defer { defaults.removePersistentDomain(forName: preferenceDomain) } + let options = MacBuildOptionsStore(store: MacUserDefaultsStore(defaults: defaults)) + try options.setOptions(["settingsFile": .string(user.path)], workspaceURL: workspace, buildSystemID: "maven") + let storage = MacFileStorage() + func service() -> BuildSystemService { + BuildSystemService(planner: planner, tools: ExplicitNativeConfigurationTool(maven: maven), optionsStore: options, + stateLocator: MacBuildSystemStateLocator(directories: directories, fileStorage: storage), + runner: MacBuildCommandRunner(), providers: sessions, + nativeConfigurationFiles: MacBuildNativeConfigurationStore(storage: storage, planner: planner, + immutableRoots: [package, Bundle.main.bundleURL]), sources: { [source] }) + } + let build = service() + var reopened: BuildSystemService? + let outcome: Result + do { + build.reset(workspaceURL: workspace) + _ = try await build.prepareLanguageImport(workspaceURL: workspace) + let entry = try #require(build.entries.first) + let first = try await build.readNativeConfiguration(entry.id) + let userSource = try #require(first.sources.first { $0.id == "user" }) + let repository = try #require(userSource.fields.first { $0.id == "localRepository" }) + #expect(repository.value == .null) + #expect(repository.effectiveValue == .string(inheritedRepository)) + #expect(try String(contentsOf: user, encoding: .utf8) == original) + var draft = BuildNativeConfigurationDraft(source: userSource) + #expect(!draft.isDirty) + draft.setOverride(true, fieldID: repository.id) + draft.setValue(.string(selectedRepository), fieldID: repository.id) + let saved = try await build.saveNativeConfiguration(entry.id, snapshot: first, edits: draft.edits) + #expect(saved.sources.first { $0.id == "user" }?.fields.first { $0.id == "localRepository" }?.effectiveValue == .string(selectedRepository)) + let savedText = try String(contentsOf: user, encoding: .utf8) + #expect(savedText.contains("")) + #expect(savedText.contains("test-secret-preserved")) + #expect(!String(decoding: try JSONEncoder().encode(saved), as: UTF8.self).contains("test-secret-preserved")) + let current = try #require(saved.sources.first { $0.id == "user" }) + let external = savedText + "\n" + try Data(external.utf8).write(to: user) + await #expect(throws: (any Error).self) { + try await build.saveNativeConfiguration(entry.id, snapshot: saved, edits: BuildNativeConfigurationEdits( + sourceID: current.id, revision: current.revision, values: ["localRepository": .string("/obsolete")])) + } + #expect(try String(contentsOf: user, encoding: .utf8) == external) + build.stop() + let next = service(); reopened = next + next.reset(workspaceURL: workspace) + _ = try await next.prepareLanguageImport(workspaceURL: workspace) + let nextEntry = try #require(next.entries.first) + let fresh = try await next.readNativeConfiguration(nextEntry.id) + #expect(fresh.sources.first { $0.id == "user" }?.fields.first { $0.id == "localRepository" }?.value == .string(selectedRepository)) + #expect(!files.fileExists(atPath: workspace.appendingPathComponent("target").path)) + #expect(!files.fileExists(atPath: inheritedRepository)) + #expect(!files.fileExists(atPath: selectedRepository)) + outcome = .success(()) + } catch { outcome = .failure(error) } + build.stop(); reopened?.stop(); sessions.retainProviders { _ in false } + #expect(await released.waitUntilOpen(timeout: .seconds(5))) + #expect(try Self.inventory(package) == before) + try outcome.get() + } + @Test(.enabled(if: ProcessInfo.processInfo.environment["LITHE_STAGE_C_MAVEN_PACKAGE"] != nil, "Requires an explicit development Maven package and tool paths")) func realMavenProviderBuildsAndTestsWithoutAPluginPage() async throws { @@ -55,6 +610,7 @@ struct MacNativeBuildProviderIntegrationTests { let model = try operations.decodeModel(target, workspaceURL: workspace, root: context.root, output: .providerResult(raw), options: context.options) #expect(Set(model.projects.map(\.name)) == ["reactor", "core", "app"]) + #expect(try #require(sessions.processID(for: provider)) > 0) let project = try #require(model.projects.first { $0.name == "core" }) let taskPlan = try operations.taskPlan(target, context: context, request: BuildTaskRequest(task: "compile", project: BuildSelection(project: project))) @@ -67,13 +623,13 @@ struct MacNativeBuildProviderIntegrationTests { #expect(built.output == progress.output) #expect(files.fileExists(atPath: workspace.appendingPathComponent("nested/core/target/classes/demo/core/Greeter.class").path)) let testPlan = try operations.testPlan(target, context: context, - request: BuildTestRequest(project: BuildSelection(project: project), suite: "demo.core.GreeterTest", testCase: "greetsByName")) + request: BuildTestRequest(project: BuildSelection(project: project), selectorKind: "java.test.fullName", selectors: ["demo.core.GreeterTest#greetsByName()"])) guard case .provider(let test) = testPlan.execution else { throw NativeProviderIntegrationError.unexpectedCommand } let tested = try await sessions.execute(Self.withTool(test, maven: maven), on: provider, timeout: .seconds(20)) { _ in } #expect(tested.exitCode == 0) let results = try operations.decodeTestResults(target, request: BuildTestResultsRequest(workspaceURL: workspace, root: context.root, project: project.path, projects: model.projects.map(\.path), output: tested.output, - notBefore: nil, suite: "demo.core.GreeterTest")) + notBefore: nil, suite: "demo.core.GreeterTest", result: tested.testResults)) #expect(results.summary.total == 1) #expect(results.summary.failed == 0) #expect(results.tests.first?.name == "greetsByName") @@ -109,12 +665,21 @@ struct MacNativeBuildProviderIntegrationTests { } private enum NativeProviderIntegrationError: Error { case unexpectedCommand } +@MainActor +private final class ExplicitNativeConfigurationTool: BuildToolResolving { + let maven: URL + init(maven: URL) { self.maven = maven } + func resolve(_ invocation: BuildInvocation, displayName: String, workspaceURL: URL, + checkVersion: @escaping @Sendable (String?, String) throws -> BuildVersionCheck) async throws -> ResolvedBuildTool { + ResolvedBuildTool(executableURL: maven, environment: [:], executesProjectCode: false, version: nil) + } +} @MainActor private final class NativeProviderProgress { var output = "" } /// Explicit test-selected executable paths. All process/RPC execution is real; /// this port avoids reading or modifying the user's runtime preferences. @MainActor -private final class ExplicitProviderRuntime: BuildToolResolving, LanguageToolRuntimePort { +final class ExplicitProviderRuntime: BuildToolResolving, LanguageToolRuntimePort { let java: URL let environment: [String: String] init(java: URL, environment: [String: String]) { @@ -133,3 +698,12 @@ private final class ExplicitProviderRuntime: BuildToolResolving, LanguageToolRun func languageToolProcessEnvironment(runtimeTools: [String]) -> [String: String] { environment } func missingLanguageToolMessage(_ name: String) -> String { name } } + +/// This integration never reads or writes the user's saved runtime preferences. +private struct NativeDefinedToolPreferences: KeyValueStore { + func data(forKey key: String) -> Data? { nil } + func object(forKey key: String) -> Any? { nil } + func string(forKey key: String) -> String? { nil } + func stringArray(forKey key: String) -> [String]? { nil } + func set(_ value: Any?, forKey key: String) {} +} diff --git a/macos/Tests/LitheTests/MacToolchainDiscoveryTests.swift b/macos/Tests/LitheTests/MacToolchainDiscoveryTests.swift index 7ac8ff6e8a..8a22da7e8c 100644 --- a/macos/Tests/LitheTests/MacToolchainDiscoveryTests.swift +++ b/macos/Tests/LitheTests/MacToolchainDiscoveryTests.swift @@ -11,6 +11,159 @@ import LitheModuleAPI struct MacToolchainDiscoveryTests { private static let pluginID = "dev.lithe.fixture.discovery" + @MainActor @Test func consumerDefaultsAreReadAgainAndRemainIndependentFromExplicitRuns() async throws { + let root = try DiscoveryFixtureRoot() + defer { root.remove() } + let first = try root.installation("selected-one"), second = try root.installation("selected-two") + let probes = DiscoveryProbes(outputs: [first + "/bin/compiler": "compiler 17", second + "/bin/compiler": "compiler 21"]) + let url = root.url("workspace/.lithe/run/local.json") + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + func save(_ home: String) throws { + let value: [String: Any] = ["version": 2, "configurations": [], "toolchain": ["preferences": ["version": 1, + "workspace": ["tools": ["compiler": ["home": home]], + "consumers": ["dev.example.build/engine": ["compiler": ["inherit": "workspace"]]]]]]] + try JSONSerialization.data(withJSONObject: value, options: [.sortedKeys]).write(to: url) + } + let resolver = MacDefinedToolchainResolver() + let source = Self.source() + resolver.sources = { [source] }; resolver.discovery = discovery(root: root, probes: probes) + try save(first) + let selected = try await resolver.resolve(tool: "compiler", directory: ".", workspace: root.url("workspace"), environment: [:], + minimumVersion: "11", projectCodeAllowed: true, fixed: nil, use: .installation, consumerID: "dev.example.build/engine") + #expect(selected?.executable.path == first + "/bin/compiler") + try save(second) + let inherited = try await resolver.resolve(tool: "compiler", directory: ".", workspace: root.url("workspace"), environment: [:], + minimumVersion: "11", projectCodeAllowed: true, fixed: nil, use: .installation, consumerID: "dev.example.build/engine") + #expect(inherited?.executable.path == second + "/bin/compiler") + let explicit = try await resolver.resolve(tool: "compiler", directory: ".", workspace: root.url("workspace"), environment: [:], + minimumVersion: "11", projectCodeAllowed: true, fixed: .installation(home: first), use: .installation, consumerID: "dev.example.build/engine") + #expect(explicit?.executable.path == first + "/bin/compiler") + let before = try Data(contentsOf: url) + await #expect(throws: BuildToolResolutionError.self) { + try await resolver.resolve(tool: "compiler", directory: ".", workspace: root.url("workspace"), environment: [:], + minimumVersion: "11", projectCodeAllowed: true, fixed: .installation(home: root.path + "/missing"), use: .installation, + consumerID: "dev.example.build/engine") + } + #expect(try Data(contentsOf: url) == before) + } + + @MainActor @Test func consumerToolReferenceReadsCurrentDirectoryDefaultsAndLeavesExplicitRunsAlone() async throws { + let root = try DiscoveryFixtureRoot() + defer { root.remove() } + let first = try root.installation("first-directory"), second = try root.installation("second-directory") + try FileManager.default.createDirectory(at: root.url("workspace/nested/child"), withIntermediateDirectories: true) + let url = root.url("workspace/.lithe/run/local.json") + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + let source = Self.source(), resolver = MacDefinedToolchainResolver() + resolver.sources = { [source] } + resolver.discovery = discovery(root: root, probes: DiscoveryProbes(outputs: [first + "/bin/compiler": "compiler 21", second + "/bin/compiler": "compiler 21"])) + for home in [first, second] { + let document: [String: Any] = ["version": 2, "configurations": [], "toolchain": ["preferences": ["version": 1, + "workspace": ["tools": ["compiler": ["home": first]], "consumers": ["dev.example.build/engine": ["compiler": ["inherit": "tool"]]]], + "directories": ["nested": ["tools": ["compiler": ["home": home]], "consumers": ["dev.example.build/engine": ["compiler": ["home": first]]]], + "nested/child": ["consumers": ["dev.example.build/engine": ["compiler": ["inherit": "consumer"]]]]]]]] + try JSONSerialization.data(withJSONObject: document, options: [.sortedKeys]).write(to: url) + let selected = try await resolver.resolve(tool: "compiler", directory: "nested/child", workspace: root.url("workspace"), environment: [:], + minimumVersion: "11", projectCodeAllowed: true, fixed: nil, use: .installation, consumerID: "dev.example.build/engine") + #expect(selected?.executable.path == home + "/bin/compiler") + } + let explicit = try await resolver.resolve(tool: "compiler", directory: "nested/child", workspace: root.url("workspace"), environment: [:], + minimumVersion: "11", projectCodeAllowed: true, fixed: .installation(home: first), use: .installation, consumerID: "dev.example.build/engine") + #expect(explicit?.executable.path == first + "/bin/compiler") + } + + @MainActor @Test func consumerDefaultsChangedDuringNativeProofCannotPublishASelection() async throws { + let root = try DiscoveryFixtureRoot() + defer { root.remove() } + let home = try root.installation("selected") + let url = root.url("workspace/.lithe/run/local.json") + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try JSONSerialization.data(withJSONObject: ["version": 2, "toolchain": ["preferences": ["version": 1, + "workspace": ["tools": ["compiler": ["home": home]]]]]]).write(to: url) + let resolver = MacDefinedToolchainResolver(), source = Self.source() + resolver.sources = { [source] } + var discovery = MacToolchainDiscovery() + discovery.consumerProbe = { _, _, _ in + do { try Data(#"{"version":2,"toolchain":{"preferences":{"version":1}}}"#.utf8).write(to: url) } + catch { return nil } + return "compiler 21" + } + resolver.discovery = discovery + await #expect(throws: CancellationError.self) { + try await resolver.resolve(tool: "compiler", directory: ".", workspace: root.url("workspace"), environment: [:], + minimumVersion: "11", projectCodeAllowed: true, fixed: nil, use: .installation, consumerID: "dev.example.build/engine") + } + } + + @MainActor @Test func invalidConsumerPreferenceDocumentsAndExternalAliasesNeverProbeTools() async throws { + let root = try DiscoveryFixtureRoot() + defer { root.remove() } + let source = Self.source(), resolver = MacDefinedToolchainResolver() + resolver.sources = { [source] } + let probes = DiscoveryProbes(outputs: [:]); resolver.discovery = discovery(root: root, probes: probes) + let url = root.url("workspace/.lithe/run/local.json") + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + for value in [#"{"version":99}"#, #"{"version":2,"toolchain":[]}"#, #"{"version":2,"toolchain":{"preferences":{"version":1,"unknown":true}}}"#] { + try Data(value.utf8).write(to: url) + await #expect(throws: BuildToolResolutionError.self) { + try await resolver.resolve(tool: "compiler", directory: ".", workspace: root.url("workspace"), environment: [:], + minimumVersion: nil, projectCodeAllowed: true, fixed: nil, use: .installation, consumerID: "dev.example.build/engine") + } + } + let external = root.url("external-run") + try FileManager.default.createDirectory(at: external, withIntermediateDirectories: true) + try Data(#"{"version":2}"#.utf8).write(to: external.appendingPathComponent("local.json")) + try FileManager.default.removeItem(at: url.deletingLastPathComponent()) + try FileManager.default.createSymbolicLink(at: url.deletingLastPathComponent(), withDestinationURL: external) + await #expect(throws: BuildToolResolutionError.self) { + try await resolver.resolve(tool: "compiler", directory: ".", workspace: root.url("workspace"), environment: [:], + minimumVersion: nil, projectCodeAllowed: true, fixed: nil, use: .installation, consumerID: "dev.example.build/engine") + } + #expect(probes.calls.isEmpty) + } + + @MainActor @Test(arguments: [".lithe", ".lithe/run"], [false, true]) + func missingPreferenceLeafBehindAParentAliasIsRejectedBeforeToolWork(parent: String, dangling: Bool) async throws { + let root = try DiscoveryFixtureRoot() + defer { root.remove() } + let home = try root.installation("selected") + let outside = root.url("outside") + try FileManager.default.createDirectory(at: outside, withIntermediateDirectories: true) + let alias = root.url("workspace/" + parent) + try FileManager.default.createDirectory(at: alias.deletingLastPathComponent(), withIntermediateDirectories: true) + try FileManager.default.createSymbolicLink(at: alias, withDestinationURL: dangling ? outside.appendingPathComponent("missing") : outside) + let source = Self.source(), resolver = MacDefinedToolchainResolver() + resolver.sources = { [source] } + let probes = DiscoveryProbes(outputs: [home + "/bin/compiler": "compiler 21"]) + resolver.discovery = discovery(root: root, probes: probes) + await #expect(throws: BuildToolResolutionError.self) { + try await resolver.resolve(tool: "compiler", directory: ".", workspace: root.url("workspace"), environment: [:], + minimumVersion: nil, projectCodeAllowed: true, fixed: .installation(home: home), use: .installation, + consumerID: "dev.example.build/engine") + } + #expect(probes.calls.isEmpty) + #expect(try FileManager.default.contentsOfDirectory(atPath: outside.path).isEmpty) + } + + @MainActor @Test func unrelatedRunMetadataKeepsTheExistingNativeDocumentBudget() async throws { + let root = try DiscoveryFixtureRoot() + defer { root.remove() } + let home = try root.installation("selected"), source = Self.source() + let probes = DiscoveryProbes(outputs: [home + "/bin/compiler": "compiler 21"]) + let resolver = MacDefinedToolchainResolver(); resolver.sources = { [source] } + resolver.discovery = discovery(root: root, probes: probes) + let url = root.url("workspace/.lithe/run/local.json") + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + let document: [String: Any] = ["version": 2, "metadata": String(repeating: "x", count: 1_100_000), + "toolchain": ["preferences": ["version": 1, "workspace": ["tools": ["compiler": ["home": home]]]]]] + let bytes = try JSONSerialization.data(withJSONObject: document) + try bytes.write(to: url) + let selected = try await resolver.resolve(tool: "compiler", directory: ".", workspace: root.url("workspace"), environment: [:], + minimumVersion: "11", projectCodeAllowed: true, fixed: nil, use: .installation, consumerID: "dev.example.build/engine") + #expect(selected?.executable.path == home + "/bin/compiler") + #expect(try Data(contentsOf: url) == bytes) + } + private static func manifest(complete: Bool = true) -> Data { let definition: [String: Any] = [ "id": "compiler", "displayName": "Fixture compiler", "icon": ["host": "plugin"], @@ -41,6 +194,55 @@ struct MacToolchainDiscoveryTests { verifiedOfficial: true) } + @Test func installedToolDeclarationsPassTheProductionHostAdmission() throws { + // Discovery accepts raw declarations; installation must also admit them + // through the capabilities of the host that actually resolves consumers. + let operations = RustDeclarativePluginOperations(core: RustCoreBridge()) + _ = try operations.validate(manifest: Self.manifest()) + _ = try operations.validate(manifest: Self.providerSource().manifest) + } + + @Test func consumerRefusesUnsupportedLaunchTemplatesInsteadOfDroppingTheirFlags() async throws { + let root = try DiscoveryFixtureRoot() + defer { root.remove() } + let home = try root.installation("home/Kits/tool.kit/Contents/Home") + let probes = DiscoveryProbes(outputs: [home + "/bin/compiler": "compiler 21"]) + var manifest = try #require(JSONSerialization.jsonObject(with: Self.manifest()) as? [String: Any]) + var definitions = try #require(manifest["toolDefinitions"] as? [[String: Any]]) + definitions[0]["launch"] = ["${executable}", "--fixture", "${arguments}"] + manifest["toolDefinitions"] = definitions + let source = ToolchainSettingsPluginSource(pluginID: Self.pluginID, name: "Fixture", + manifest: try JSONSerialization.data(withJSONObject: manifest), isEnabled: true, verifiedOfficial: true) + let local: ToolingJSONValue = .object(["version": .number(1), "toolchains": .object([:])]) + await #expect(throws: BuildToolResolutionError.self) { + try await discovery(root: root, probes: probes).resolve( + .init(workspace: root.url("workspace"), sources: [source], environment: [:]), + tool: "compiler", directory: ".", minimumVersion: nil, local: local, projectCodeAllowed: true) + } + #expect(probes.calls.isEmpty) + } + + @Test func installationDependenciesConsumeExportsWithoutInstantiatingAnEntryRecipe() async throws { + let root = try DiscoveryFixtureRoot() + defer { root.remove() } + let home = try root.installation("home/Kits/runtime.kit/Contents/Home") + let probes = DiscoveryProbes(outputs: [home + "/bin/compiler": "compiler 21"]) + var manifest = try #require(JSONSerialization.jsonObject(with: Self.manifest()) as? [String: Any]) + var definitions = try #require(manifest["toolDefinitions"] as? [[String: Any]]) + definitions[0]["launch"] = ["${executable}", "${runtimeArguments}", "-jar", "${entry}", "${arguments}"] + definitions[0]["exports"] = [["name": "COMPILER_HOME", "value": "${home}"]] + manifest["toolDefinitions"] = definitions + let source = ToolchainSettingsPluginSource(pluginID: Self.pluginID, name: "Runtime", + manifest: try JSONSerialization.data(withJSONObject: manifest), isEnabled: true, verifiedOfficial: true) + let selected = try await discovery(root: root, probes: probes).resolve( + .init(workspace: root.url("workspace"), sources: [source], environment: [:]), tool: "compiler", + directory: ".", minimumVersion: "17", local: .object(["version": .number(1), "toolchains": .object([:])]), + projectCodeAllowed: false, requiresDirectLaunch: false) + #expect(selected?.version == "21") + #expect(selected?.environment["COMPILER_HOME"] == home) + #expect(probes.calls == [.init(executable: home + "/bin/compiler", arguments: ["--version"])]) + } + @Test func scannedHomesAreCheckedProbedAndTheHighestVersionIsSelected() async throws { let root = try DiscoveryFixtureRoot() defer { root.remove() } @@ -327,6 +529,196 @@ struct MacToolchainDiscoveryTests { } #endif + @Test func consumerResolvesAnUnscannedFixedHomeBeforeNewerInstallations() async throws { + let root = try DiscoveryFixtureRoot() + defer { root.remove() } + let manual = try root.installation("manual") + let automatic = try root.installation("home/Kits/newer.kit/Contents/Home") + let probes = DiscoveryProbes(outputs: [manual + "/bin/compiler": "compiler 17", automatic + "/bin/compiler": "compiler 23"]) + let local = try #require(ToolingJSONValue.fromFoundation([ + "version": 1, "toolchains": [:], "pins": [".": ["compiler": ["home": manual, "version": "17", "provider": "host:manual"]]] + ])) + let selected = try await discovery(root: root, probes: probes).resolve( + .init(workspace: root.url("workspace"), sources: [Self.source()], environment: ["PATH": "/fixture/path"]), + tool: "compiler", directory: ".", minimumVersion: "11", local: local, projectCodeAllowed: true) + #expect(selected?.executable.path == manual + "/bin/compiler") + #expect(selected?.version == "17" && selected?.projectCode == false) + #expect(selected?.environment["PATH"] == manual + "/bin:/fixture/path") + } + + @Test func consumerChoicesKeepTwoRunsIndependentFromTheDirectoryPin() async throws { + let root = try DiscoveryFixtureRoot() + defer { root.remove() } + let pinned = try root.installation("pinned") + let selected = try root.installation("per run") + let probes = DiscoveryProbes(outputs: [pinned + "/bin/compiler": "compiler 21", selected + "/bin/compiler": "compiler 17"]) + let local = try #require(ToolingJSONValue.fromFoundation([ + "version": 1, "toolchains": [:], "pins": [".": ["compiler": ["home": pinned, "version": "21", "provider": "host:manual"]]] + ])) + let request = MacToolchainDiscovery.Request(workspace: root.url("workspace"), sources: [Self.source()], environment: [:]) + let resolver = discovery(root: root, probes: probes) + let first = try await resolver.resolve(request, tool: "compiler", directory: ".", minimumVersion: "11", local: local, + projectCodeAllowed: true, fixed: .installation(home: selected)) + let second = try await resolver.resolve(request, tool: "compiler", directory: ".", minimumVersion: "11", local: local, + projectCodeAllowed: true) + #expect(first?.executable.path == selected + "/bin/compiler") + #expect(second?.executable.path == pinned + "/bin/compiler") + #expect(probes.calls.map(\.executable) == [selected + "/bin/compiler", pinned + "/bin/compiler"]) + await #expect(throws: BuildToolResolutionError.self) { + try await resolver.resolve(request, tool: "compiler", directory: ".", minimumVersion: nil, local: local, + projectCodeAllowed: true, fixed: .installation(home: root.path + "/missing")) + } + #expect(probes.calls.count == 2) + } + + @Test func consumerWorkspaceLauncherIsOnlyProbedForTheCurrentExplicitAction() async throws { + let root = try DiscoveryFixtureRoot() + defer { root.remove() } + let executable = root.url("workspace/custom runner") + try Data("#!/bin/sh\n".utf8).write(to: executable) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path) + let probes = DiscoveryProbes(outputs: [executable.path: "compiler 21"]) + let resolver = discovery(root: root, probes: probes) + let request = MacToolchainDiscovery.Request(workspace: root.url("workspace"), sources: [Self.source()], environment: [:]) + let local: ToolingJSONValue = .object(["version": .number(1), "toolchains": .object([:])]) + await #expect(throws: BuildToolResolutionError.self) { + try await resolver.resolve(request, tool: "compiler", directory: ".", minimumVersion: "17", local: local, + projectCodeAllowed: false, fixed: .executable(path: executable.path)) + } + #expect(probes.calls.isEmpty) + let selected = try await resolver.resolve(request, tool: "compiler", directory: ".", minimumVersion: "17", local: local, + projectCodeAllowed: true, fixed: .executable(path: executable.path)) + #expect(selected?.executable == executable && selected?.projectCode == true) + #expect(selected?.environment.isEmpty == true) + #expect(probes.calls == [.init(executable: executable.path, arguments: ["--version"])]) + } + + @Test func consumerNeverFallsBackFromAnInvalidFixedHome() async throws { + let root = try DiscoveryFixtureRoot() + defer { root.remove() } + let automatic = try root.installation("home/Kits/newer.kit/Contents/Home") + let probes = DiscoveryProbes(outputs: [automatic + "/bin/compiler": "compiler 23"]) + let local = try #require(ToolingJSONValue.fromFoundation([ + "version": 1, "toolchains": [:], "pins": [".": ["compiler": ["home": root.path + "/missing", "version": "17", "provider": "host:manual"]]] + ])) + await #expect(throws: BuildToolResolutionError.self) { + try await discovery(root: root, probes: probes).resolve( + .init(workspace: root.url("workspace"), sources: [Self.source()], environment: [:]), + tool: "compiler", directory: ".", minimumVersion: nil, local: local, projectCodeAllowed: true) + } + } + + @Test func consumerWrapperRequiresAnExplicitActionAndIsNeverPassivelyProbed() async throws { + let root = try DiscoveryFixtureRoot() + defer { root.remove() } + let wrapper = root.url("workspace/compilerw") + try Data("#!/bin/sh\n".utf8).write(to: wrapper) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: wrapper.path) + var manifest = try #require(JSONSerialization.jsonObject(with: Self.manifest()) as? [String: Any]) + var definitions = try #require(manifest["toolDefinitions"] as? [[String: Any]]) + var candidates = try #require(definitions[0]["executables"] as? [[String: Any]]) + candidates.insert(["id": "wrapper", "kind": "projectFile", "path": "compilerw"], at: 0) + definitions[0]["executables"] = candidates + definitions[0]["wrappers"] = [["path": "compilerw"]] + manifest["toolDefinitions"] = definitions + let source = ToolchainSettingsPluginSource(pluginID: Self.pluginID, name: "Fixture", + manifest: try JSONSerialization.data(withJSONObject: manifest), isEnabled: true, verifiedOfficial: true) + let probes = DiscoveryProbes(outputs: [wrapper.path: "compiler 21"]) + let discovery = discovery(root: root, probes: probes) + let request = MacToolchainDiscovery.Request(workspace: root.url("workspace"), sources: [source], environment: [:]) + let local: ToolingJSONValue = .object(["version": .number(1), "toolchains": .object([:])]) + await #expect(throws: BuildToolResolutionError.self) { + try await discovery.resolve(request, tool: "compiler", directory: ".", minimumVersion: nil, + local: local, projectCodeAllowed: false) + } + let selected = try await discovery.resolve(request, tool: "compiler", directory: ".", minimumVersion: nil, + local: local, projectCodeAllowed: true) + #expect(selected?.executable == wrapper && selected?.projectCode == true) + #expect(probes.calls.isEmpty) + await #expect(throws: BuildToolResolutionError.self) { + try await discovery.resolve(request, tool: "compiler", directory: ".", minimumVersion: "17", + local: local, projectCodeAllowed: false) + } + #expect(probes.calls.isEmpty) + let checked = try await discovery.resolve(request, tool: "compiler", directory: ".", minimumVersion: "17", + local: local, projectCodeAllowed: true) + #expect(checked?.executable == wrapper && checked?.version == "21" && checked?.projectCode == true) + #expect(probes.calls == [.init(executable: wrapper.path, arguments: ["--version"])]) + await #expect(throws: BuildToolResolutionError.self) { + try await discovery.resolve(request, tool: "compiler", directory: ".", minimumVersion: "23", + local: local, projectCodeAllowed: true) + } + } + + @Test(arguments: [false, true]) func consumerQueriesCurrentWorkspaceProviderWithMatchingResolutionIdentity(stringIDs: Bool) async throws { + let root = try DiscoveryFixtureRoot() + defer { root.remove() } + let home = try root.installation("manager/current") + let source = root.url("workspace/.compiler-version") + try Data("21\n".utf8).write(to: source) + let queries = ProviderQueries { _ in [ + "installations": [["tool": "compiler", "version": "21", "home": home]], + "statements": [["tool": "compiler", "scope": "project", "source": source.path, "request": "21", + "installation": home, "kind": "declaration", "reader": "native"]], + "watch": [source.path] + ] } + var discovery = discovery(root: root, probes: DiscoveryProbes(outputs: [home + "/bin/compiler": "compiler 21"])) + discovery.queryProvider = { plugin, provider, params, _ in + let answer = try queries.answer(pluginID: plugin, providerID: provider, params: params) + if !stringIDs { return answer } + var wire = try #require(answer.foundationObject as? [String: Any]) + var response = try #require(wire["response"] as? [String: Any]) + wire["id"] = "rpc:雪-2"; response["id"] = "rpc:雪-2"; wire["response"] = response + return try #require(ToolingJSONValue.fromFoundation(wire)) + } + discovery.workspaceContext = { _, _ in .init(contextID: "context-1", generation: 3, rootID: "root-1") } + let result = try await discovery.resolve( + .init(workspace: root.url("workspace"), sources: [try Self.providerSource()], environment: [:]), + tool: "compiler", directory: ".", minimumVersion: nil, + local: .object(["version": .number(1), "toolchains": .object([:])]), projectCodeAllowed: true) + #expect(result?.executable.path == home + "/bin/compiler") + #expect(queries.sent.count == 1) + let workspace = try #require(queries.sent.first?["workspace"] as? [String: Any]) + #expect(workspace["directory"] as? String == "." && workspace["rootID"] as? String == "root-1") + } + + @Test func consumerRetainsShellFallbackForACheckedUnavailableProvider() async throws { + let root = try DiscoveryFixtureRoot() + defer { root.remove() } + let home = try root.installation("shell-home") + var discovery = discovery(root: root, probes: DiscoveryProbes(outputs: [home + "/bin/compiler": "compiler 21"])) + discovery.queryProvider = { _, _, params, _ in + let object = try #require(params.foundationObject as? [String: Any]) + return try #require(ToolingJSONValue.fromFoundation(["id": 1, "response": ["jsonrpc": "2.0", "id": 1, + "error": ["code": -32073, "message": "Fixture unavailable", "data": [ + "code": "plugin.toolchain.providerUnavailable", "operationID": object["operationID"]!, "providerID": object["providerID"]! + ]]]])) + } + discovery.workspaceContext = { _, _ in .init(contextID: "context-1", generation: 3, rootID: "root-1") } + let selected = try await discovery.resolve( + .init(workspace: root.url("workspace"), sources: [try Self.providerSource()], environment: ["FIXTURE_HOME": home]), + tool: "compiler", directory: ".", minimumVersion: nil, + local: .object(["version": .number(1), "toolchains": .object([:])]), projectCodeAllowed: true) + #expect(selected?.executable.path == home + "/bin/compiler") + } + + @Test func consumerRejectsNativeEvidenceChangedDuringProbe() async throws { + let root = try DiscoveryFixtureRoot() + defer { root.remove() } + let home = try root.installation("home/Kits/newer.kit/Contents/Home") + var discovery = discovery(root: root, probes: DiscoveryProbes(outputs: [:])) + discovery.consumerProbe = { executable, _, _ in + try? FileManager.default.removeItem(atPath: executable) + return "compiler 23" + } + await #expect(throws: CancellationError.self) { + try await discovery.resolve(.init(workspace: root.url("workspace"), sources: [Self.source()], environment: [:]), + tool: "compiler", directory: ".", minimumVersion: nil, + local: .object(["version": .number(1), "toolchains": .object([:])]), projectCodeAllowed: true) + } + #expect(!FileManager.default.fileExists(atPath: home + "/bin/compiler")) + } + /// A provider past its deadline and one whose backend could not answer both reach Core as /// unavailable: the scanned installation still decides, and each keeps its own reason. @Test func providersWithoutAnAnswerAreNamedWithTheirReasonWhileLowerSourcesDecide() async throws { @@ -364,6 +756,7 @@ struct MacToolchainDiscoveryTests { let home = root.url("home").path discovery.homeDirectory = { home } discovery.probe = { probes.run($0, $1) } + discovery.consumerProbe = { executable, arguments, _ in probes.run(executable, arguments) } return discovery } } diff --git a/macos/Tests/LitheTests/MavenDependencyTreeBridgeTests.swift b/macos/Tests/LitheTests/MavenDependencyTreeBridgeTests.swift deleted file mode 100644 index e92cd37288..0000000000 --- a/macos/Tests/LitheTests/MavenDependencyTreeBridgeTests.swift +++ /dev/null @@ -1,55 +0,0 @@ -import Foundation -import LitheCoreContracts -import Testing -@testable import Lithe - -/// Plans and reads a dependency-tree file through the linked Rust Core. -/// -/// The ordinary Swift unit lane does not link Core, so this suite runs in the -/// Core-linked lane of `scripts/rust-core/verify-rust-core.sh`. -@Suite("Maven dependency tree through Rust Core") -struct MavenDependencyTreeBridgeTests { - @Test(.enabled(if: RustCoreBridge().isAvailable, "Requires the linked Rust Core integration library")) - func mavenDependencyTreeFileIsPlannedAndReadThroughCore() throws { - try #require(RustCoreBridge().isAvailable) - let fixture = try MavenDependencyTreeFixture.load() - let testRoot = FileManager.default.temporaryDirectory - .appendingPathComponent("lithe-maven-tree-\(UUID().uuidString)", isDirectory: true) - defer { try? FileManager.default.removeItem(at: testRoot) } - try FileManager.default.createDirectory(at: testRoot, withIntermediateDirectories: true) - try Data("service".utf8) - .write(to: testRoot.appendingPathComponent("pom.xml")) - let outputFile = testRoot.appendingPathComponent("scratch/tree.txt") - let operations = RustJavaMavenOperations(core: RustCoreBridge()) - - let plan = try operations.mavenDependencyPlan( - at: testRoot, - context: MavenLaunchContext( - reactorPath: ".", - profiles: [], - settingsPath: nil, - skipTests: false, - mavenExecutablePath: nil, - javaHomePath: nil - ), - module: nil, - outputFile: outputFile - ) - #expect(plan.arguments.contains("-DoutputFile=" + outputFile.standardizedFileURL.path)) - #expect(plan.arguments.contains("-N")) - - try Data(fixture.treeFile.utf8).write(to: testRoot.appendingPathComponent("tree.txt")) - let tree = try operations.mavenDependencies( - modulePath: fixture.modulePath, - outputFile: testRoot.appendingPathComponent("tree.txt") - ) - #expect(tree == (try fixture.expected.makeModel())) - - #expect(throws: MavenOperationError.self) { - try operations.mavenDependencies( - modulePath: fixture.modulePath, - outputFile: testRoot.appendingPathComponent("never-written.txt") - ) - } - } -} diff --git a/macos/Tests/LitheTests/MavenRuntimeTests.swift b/macos/Tests/LitheTests/MavenRuntimeTests.swift index 2720c8d782..f23836f142 100644 --- a/macos/Tests/LitheTests/MavenRuntimeTests.swift +++ b/macos/Tests/LitheTests/MavenRuntimeTests.swift @@ -56,243 +56,13 @@ struct MavenRuntimeTests { let macCases = fixture.storageIdentityCases.filter { $0.platform == "macos" } #expect(macCases.count == 1) for item in macCases { - #expect(MacMavenConfigurationStore.storageIdentity( + #expect(MacLegacyMavenConfigurationMigration.storageIdentity( workspacePath: item.workspacePath, reactorPath: item.reactorPath ) == item.expectedIdentity) } } - @Test - func mavenScanPayloadDecodesRustCamelCaseIdentifiers() throws { - let json = #""" - { - "relativePath": "services/api", - "groupId": "com.example", - "artifactId": "root", - "version": "1.0", - "packaging": "pom", - "sourceRoots": [{"path": "src/main/java", "kind": "mainJava"}], - "modules": [{ - "relativePath": "module-a", - "groupId": "com.example", - "artifactId": "child", - "version": "1.0", - "packaging": "jar", - "sourceRoots": [{"path": "src/test/java", "kind": "testJava"}], - "modules": [] - }], - "profiles": [{"id": "dev", "isActiveByDefault": true}], - "hasWrapper": true - } - """# - let payload = try JSONDecoder().decode( - RustCoreBridge.MavenScanPayload.self, - from: Data(json.utf8) - ) - - let workspaceRoot = FileManager.default.temporaryDirectory - .appendingPathComponent("lithe-maven-payload", isDirectory: true) - let project = payload.makeProject(workspaceRootURL: workspaceRoot) - let expectedRoot = URL( - fileURLWithPath: workspaceRoot.path + "/services/api", - isDirectory: true - ) - #expect(project.rootURL == expectedRoot) - #expect(!project.rootURL.absoluteString.contains("%2F")) - #expect(project.pomURL == expectedRoot.appendingPathComponent("pom.xml")) - #expect(project.groupID == "com.example") - #expect(project.artifactID == "root") - #expect(project.sourceRoots == [ - MavenSourceRoot(path: "src/main/java", kind: .mainJava) - ]) - #expect(project.modules.count == 1) - #expect(project.modules[0].groupID == "com.example") - #expect(project.modules[0].artifactID == "child") - #expect(project.modules[0].sourceRoots == [ - MavenSourceRoot(path: "src/test/java", kind: .testJava) - ]) - #expect(project.modules[0].url == expectedRoot.appendingPathComponent("module-a")) - #expect(project.profiles == [MavenProfile(id: "dev", isActiveByDefault: true)]) - #expect(project.hasWrapper) - } - - @Test - func nestedMavenRunConfigurationsUseWorkspaceRelativeModulePaths() { - let workspaceRoot = FileManager.default.temporaryDirectory - .appendingPathComponent("lithe-nested-maven-run-\(UUID().uuidString)", isDirectory: true) - let mavenRoot = URL( - fileURLWithPath: workspaceRoot.path + "/projects/demo", - isDirectory: true - ) - let moduleRoot = mavenRoot.appendingPathComponent("service", isDirectory: true) - let module = MavenModule( - relativePath: "service", - url: moduleRoot, - groupID: "com.example", - artifactID: "service-api", - version: "1.0", - packaging: "jar", - modules: [] - ) - let project = MavenProject( - rootURL: mavenRoot, - pomURL: mavenRoot.appendingPathComponent("pom.xml"), - groupID: "com.example", - artifactID: "demo", - version: "1.0", - packaging: "pom", - modules: [module], - profiles: [], - hasWrapper: false - ) - - let modules = RustJavaMavenOperations(core: RustCoreBridge()).workspaceMavenModules( - in: project, - relativeTo: workspaceRoot - ) - #expect(modules.map { $0.path } == ["projects/demo/service"]) - #expect(modules.map { $0.module.relativePath } == ["service"]) - } - - @Test - func mavenLaunchPlanPayloadDecodesSharedCoreResponse() throws { - let json = #""" - { - "version": 1, - "executable": { "toolchain": "project-maven" }, - "arguments": ["-B", "-ntp", "verify"], - "workingDirectory": "projects/demo", - "configurationFingerprint": "sha256:fixture" - } - """# - - let payload = try JSONDecoder().decode( - RustCoreBridge.MavenLaunchPlanPayload.self, - from: Data(json.utf8) - ) - let plan = payload.makeModel() - - #expect(plan.version == 1) - #expect(plan.toolchain == "project-maven") - #expect(plan.arguments == ["-B", "-ntp", "verify"]) - #expect(plan.workingDirectory == "projects/demo") - #expect(plan.configurationFingerprint == "sha256:fixture") - } - - @Test - func mavenDependencyPayloadDecodesTheSharedFixture() throws { - let fixture = try Self.dependencyTreeFixture() - let tree = try fixture.expected.makeModel() - - #expect(fixture.version == 2) - #expect(tree.modulePath == fixture.modulePath) - #expect(fixture.treeFile.contains("omitted for conflict with 2.0")) - #expect(tree.dependencies.map(\.artifactID) == [ - "compile-lib", "managed-lib", "provided-lib", "runtime-lib", "test-lib" - ]) - let compileDependency = try #require(tree.dependencies.first) - let conflict = try #require(compileDependency.children.first) - #expect(conflict.resolution == .omittedConflict) - #expect(conflict.selectedVersion == "2.0") - #expect(conflict.premanagedVersion == "1.1") - let managed = try #require(tree.dependencies.first { $0.artifactID == "managed-lib" }) - #expect(managed.premanagedVersion == "0.9.0") - #expect(managed.ignoredScope == "compile") - let provided = try #require(tree.dependencies.first { $0.artifactID == "provided-lib" }) - #expect(provided.premanagedScope == "compile") - let runtime = try #require(tree.dependencies.first { $0.artifactID == "runtime-lib" }) - #expect(runtime.children.first?.originalScope == "test") - let testDependency = try #require(tree.dependencies.last) - #expect(testDependency.classifier == "tests") - #expect(testDependency.scope == "test") - } - - @Test - func dependencyOutputStoreKeepsFilesPerProcessAndRemovesAbandonedOnes() throws { - let root = FileManager.default.temporaryDirectory - .appendingPathComponent("lithe-maven-outputs-\(UUID().uuidString)", isDirectory: true) - defer { try? FileManager.default.removeItem(at: root) } - let running = root.appendingPathComponent("200", isDirectory: true) - let exited = root.appendingPathComponent("300", isDirectory: true) - let unrelated = root.appendingPathComponent("notes", isDirectory: true) - for directory in [running, exited, unrelated] { - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - } - - let store = MacMavenDependencyOutputStore( - rootURL: root, - processIdentifier: 100, - isProcessRunning: { $0 == 200 } - ) - - // Another running Lithe keeps its files; an exited one's are removed. - #expect(FileManager.default.fileExists(atPath: running.path)) - #expect(!FileManager.default.fileExists(atPath: exited.path)) - #expect(FileManager.default.fileExists(atPath: unrelated.path)) - - let operationID = UUID().uuidString - let file = try store.makeDependencyOutputFile(operationID: operationID) - #expect(file.deletingLastPathComponent().lastPathComponent == "100") - try Data("stale".utf8).write(to: file) - #expect(try store.makeDependencyOutputFile(operationID: operationID) == file) - #expect(!FileManager.default.fileExists(atPath: file.path)) - - try Data("tree".utf8).write(to: file) - store.removeDependencyOutputFile(file) - #expect(!FileManager.default.fileExists(atPath: file.path)) - store.removeDependencyOutputFile(file) - - for operationID in ["", "../escape", "a/b", "a b"] { - #expect(throws: MavenOperationError.self) { - try store.makeDependencyOutputFile(operationID: operationID) - } - } - } - - @Test - func mavenConfigurationSeparatesPortableAndLocalPaths() throws { - let testRoot = FileManager.default.temporaryDirectory - .appendingPathComponent("lithe-maven-store-\(UUID().uuidString)", isDirectory: true) - defer { try? FileManager.default.removeItem(at: testRoot) } - let workspace = testRoot.appendingPathComponent("workspace", isDirectory: true) - try FileManager.default.createDirectory(at: workspace, withIntermediateDirectories: true) - let storage = MavenTestFileStorage(root: testRoot) - let store = MacMavenConfigurationStore(storage: storage) - let configuration = MavenStoredConfiguration( - portable: MavenPortableConfiguration( - selectedProfiles: ["dev", "qa"], - customProfiles: ["qa"], - skipTests: true - ), - local: MavenLocalConfiguration( - settingsPath: "/private/settings.xml", - mavenExecutablePath: "/private/apache-maven", - javaHomePath: "/private/jdk" - ) - ) - - try store.saveMavenConfiguration( - configuration, - workspaceURL: workspace, - reactorPath: "." - ) - - let portableURL = workspace.appendingPathComponent(".lithe/maven/config.json") - let portableText = String(decoding: try Data(contentsOf: portableURL), as: UTF8.self) - #expect(portableText.contains("\"selectedProfiles\"")) - #expect(!portableText.contains("/private/")) - #expect(try store.loadMavenConfiguration( - workspaceURL: workspace, - reactorPath: "." - ) == configuration) - let localFiles = try FileManager.default.contentsOfDirectory( - at: storage.applicationSupportDirectory().appendingPathComponent("Lithe/Maven"), - includingPropertiesForKeys: nil - ) - #expect(localFiles.count == 1) - } - @Test @MainActor func projectRelativeJavaOverridesResolveAgainstProjectRoot() { @@ -431,41 +201,28 @@ struct MavenRuntimeTests { @Test @MainActor - func savedRunDefaultsReplaceMirroredRuntimeSettings() { - // `.lithe/run/local.json` owns the project defaults. Settings mirrored from an - // older editor must follow it, while unrelated Maven settings stay untouched. + func runtimeSelectionsReadCurrentCanonicalDefaults() throws { + let store = InMemoryProjectRuntimeSettingsStore() + let root = URL(fileURLWithPath: "/workspace", isDirectory: true) let service = ProjectRuntimeService( - runtimeLocator: MavenOverrideRuntimeLocator(resolutions: [:]), - store: EmptyKeyValueStore() + runtimeLocator: MavenOverrideRuntimeLocator(resolutions: [:]), settingsStore: store ) - service.openProject(at: URL(fileURLWithPath: "/workspace", isDirectory: true)) - var legacy = service.settings - legacy.javaHomePath = "/fixture/legacy-jdk" - legacy.mavenHomeSelection = .custom - legacy.mavenHomePath = "/fixture/legacy-maven" - legacy.mavenJavaHomePath = "/fixture/legacy-maven-jdk" - legacy.mavenSettingsPath = "/fixture/settings.xml" - service.updateSettings(legacy) - - service.adoptProjectToolchain(ProjectToolchainSelection( - javaHomePath: "/fixture/run-jdk", - mavenExecutablePath: "./mvnw", - mavenJavaHomePath: "" - )) + service.openProject(at: root) + let original = try store.load(at: root, migrateLegacy: false) + var current = original.settings + current.javaHomePath = "/fixture/run-jdk" + current.mavenHomeSelection = .wrapper + _ = try store.save(current, at: root, expecting: original) + service.reloadSettings() #expect(service.settings.javaHomePath == "/fixture/run-jdk") #expect(service.settings.mavenHomeSelection == .wrapper) - #expect(service.settings.mavenJavaHomePath.isEmpty) - #expect(service.settings.mavenSettingsPath == "/fixture/settings.xml") - - service.adoptProjectToolchain(ProjectToolchainSelection(mavenExecutablePath: "/fixture/other-maven")) - #expect(service.settings.mavenHomeSelection == .custom) - #expect(service.settings.mavenHomePath == "/fixture/other-maven") - - // Explicitly automatic defaults clear the choice but keep the remembered path. - service.adoptProjectToolchain(ProjectToolchainSelection()) - #expect(service.settings.mavenHomeSelection == .automatic) - #expect(service.settings.mavenHomePath == "/fixture/other-maven") + let saved = try store.load(at: root, migrateLegacy: false) + _ = try store.save(ProjectRuntimeSettings(), at: root, expecting: saved) + service.reloadSettings() #expect(service.settings.projectToolchainSelection() == ProjectToolchainSelection()) + store.failure = CocoaError(.fileReadCorruptFile) + #expect(service.javaHomeURL() == nil) + #expect(service.settingsError != nil) } @Test @@ -587,9 +344,7 @@ struct MavenRuntimeTests { ) } - private static func dependencyTreeFixture() throws -> MavenDependencyTreeFixture { - try MavenDependencyTreeFixture.load() - } + } private struct MavenPlatformContractFixture: Decodable { @@ -604,29 +359,6 @@ private struct MavenPlatformContractFixture: Decodable { let storageIdentityCases: [StorageIdentityCase] } -/// The shared `maven.dependencies` compatibility fixture. -struct MavenDependencyTreeFixture: Decodable { - let version: Int - let modulePath: String - let treeFile: String - let expected: RustCoreBridge.MavenDependenciesPayload - - static func load() throws -> MavenDependencyTreeFixture { - let repositoryRoot = URL(fileURLWithPath: #filePath) - .deletingLastPathComponent() - .deletingLastPathComponent() - .deletingLastPathComponent() - .deletingLastPathComponent() - let url = repositoryRoot.appendingPathComponent( - "shared/fixtures/maven/dependency-tree-v2.json" - ) - return try JSONDecoder().decode( - MavenDependencyTreeFixture.self, - from: Data(contentsOf: url) - ) - } -} - private struct MavenTestFileStorage: FileStorage { let root: URL diff --git a/macos/Tests/LitheTests/MybatisFeatureModelTests.swift b/macos/Tests/LitheTests/MybatisFeatureModelTests.swift index fa8b5a9cb0..3514ad47d7 100644 --- a/macos/Tests/LitheTests/MybatisFeatureModelTests.swift +++ b/macos/Tests/LitheTests/MybatisFeatureModelTests.swift @@ -214,8 +214,6 @@ private final class MybatisTestOperations: JavaMavenOperations, @unchecked Senda return makeResult(files) } - func scanMavenProject(at rootURL: URL, files: [URL]) -> MavenProject? { nil } - func mavenDiagnostics(output: String, projectRoot: URL) -> [MavenBuildIssue] { [] } func codeVision(at rootURL: URL, targetPath: String, paths: [String]) -> [JavaCodeVisionValue] { [] } func className(source: String, simpleName: String) -> String? { nil } func sourceDefinition(source: String, declarationName: String, memberName: String?) -> (line: Int, utf16Column: Int)? { nil } diff --git a/macos/Tests/LitheTests/PluginBuildOptionPersistenceTests.swift b/macos/Tests/LitheTests/PluginBuildOptionPersistenceTests.swift index d2ec248b62..85fe13df36 100644 --- a/macos/Tests/LitheTests/PluginBuildOptionPersistenceTests.swift +++ b/macos/Tests/LitheTests/PluginBuildOptionPersistenceTests.swift @@ -11,6 +11,263 @@ import Testing @Suite("Plugin build option persistence") @MainActor struct PluginBuildOptionPersistenceTests { + @Test + func nativeDocumentValidationConsumesTheSameCoreFixtureAsWindows() throws { + let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent() + .deletingLastPathComponent().deletingLastPathComponent() + let data = try Data(contentsOf: root.appendingPathComponent("shared/fixtures/build-project-options/validation.json")) + let fixture = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let cases = try #require(fixture["cases"] as? [[String: Any]]) + for item in cases { + let document = try #require(item["document"]), expected = try #require(item["valid"] as? Bool) + var valid = true + do { try BuildProjectOptionsDocument.validate(document) } catch { valid = false } + #expect(valid == expected, Comment(rawValue: String(describing: item["name"]))) + } + } + + @Test + func privateDefaultsReachPlansAndResetWithoutChangingTheTeamDocument() async throws { + let workspace = FileManager.default.temporaryDirectory.appendingPathComponent("lithe-private-build-defaults-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: workspace) } + let source = try Self.source(workspace: workspace, instanceScoped: true) + let backing = OptionPreferenceStore() + let store = MacBuildOptionsStore(store: backing, storage: MacFileStorage()) + var document = BuildProjectOptionsDocument() + var system = BuildProjectOptionsDocument.System() + system.instanceDefaults["settingsFile"] = .string("/fixture/team.xml") + system.instances["b"] = ["settingsFile": .string("/fixture/team-b.xml")] + document.systems["cargo"] = system + try store.saveProjectDocument(document, workspace: workspace, expecting: nil) + let bytes = try store.projectDocument(workspace).bytes + try store.setInstanceDefaults(["settingsFile": .string("/fixture/private.xml")], workspaceURL: workspace, buildSystemID: "cargo") + let runner = OptionCommandRecorder() + let service = Self.service(source: source, workspace: workspace, store: store, runner: runner) + defer { service.stop() } + _ = try await Self.refreshed(service) { service.reset(workspaceURL: workspace) } + #expect(service.entry("cargo:a")?.options.first { $0.id == "settingsFile" }?.value == .string("/fixture/private.xml")) + #expect(service.entry("cargo:b")?.options.first { $0.id == "settingsFile" }?.value == .string("/fixture/team-b.xml")) + #expect(Array(try await Self.run("cargo:a", service: service, runner: runner).suffix(2)) == ["--config", "/fixture/private.xml"]) + _ = try service.configureOption("cargo:a", optionID: "settingsFile", value: .string("")) + #expect(try service.configureOption("cargo:a", optionID: "settingsFile", value: nil).value == .string("/fixture/private.xml")) + #expect(backing.instanceData.isEmpty) + try store.setInstanceDefaults(["settingsFile": .string("/fixture/team.xml")], workspaceURL: workspace, buildSystemID: "cargo") + _ = try await Self.refreshed(service) { service.detect() } + #expect(service.entry("cargo:a")?.options.first { $0.id == "settingsFile" }?.value == .string("/fixture/team.xml")) + #expect(try store.projectDocument(workspace).bytes == bytes) + } + + @Test + func sharedDefaultsStayInTheProjectWhileLocalEditsOnlyStoreDifferences() async throws { + let workspace = FileManager.default.temporaryDirectory.appendingPathComponent("lithe-shared-build-options-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: workspace) } + let source = try Self.source(workspace: workspace, instanceScoped: true, allowCustom: true) + let backing = OptionPreferenceStore() + let store = MacBuildOptionsStore(store: backing, storage: MacFileStorage()) + var shared = BuildProjectOptionsDocument() + var system = BuildProjectOptionsDocument.System() + system.instanceDefaults = ["features": .array([.string("base")]), "settingsFile": .string("/fixture/shared.xml")] + system.options = ["offline": .bool(true)] + system.savedChoices = ["features": ["custom-unselected", "base"]] + shared.systems["cargo"] = system + try store.saveProjectDocument(shared, workspace: workspace, expecting: nil) + let original = try store.projectDocument(workspace).bytes + let runner = OptionCommandRecorder() + let service = Self.service(source: source, workspace: workspace, store: store, runner: runner) + defer { service.stop() } + _ = try await Self.refreshed(service) { service.reset(workspaceURL: workspace) } + #expect(service.entries.allSatisfy { $0.options.first { $0.id == "settingsFile" }?.value == .string("/fixture/shared.xml") }) + #expect(try store.projectDocument(workspace).bytes == original) + #expect(backing.instanceData.isEmpty) + #expect(service.entry("cargo:a")?.options.first { $0.id == "features" }?.choices.contains { $0.value == "custom-unselected" } == true) + #expect(service.entry("cargo:a")?.options.first { $0.id == "features" }?.value == .array([.string("base")])) + _ = try await Self.refreshed(service) { + service.setInstanceOption("cargo:a", optionID: "settingsFile", value: .string("/fixture/local-a.xml")) + } + #expect(service.entry("cargo:b")?.options.first { $0.id == "settingsFile" }?.value == .string("/fixture/shared.xml")) + #expect(try store.projectDocument(workspace).bytes == original) + let localData = try #require(backing.instanceData.first) + let local = try JSONDecoder().decode([String: [String: ToolingJSONValue]].self, from: localData) + #expect(local == ["a": ["settingsFile": .string("/fixture/local-a.xml")]]) + let reset = try service.configureOption("cargo:a", optionID: "settingsFile", value: nil) + #expect(reset.value == .string("/fixture/shared.xml")) + #expect(backing.instanceData.isEmpty) + #expect(try store.projectDocument(workspace).bytes == original) + var changed = shared + changed.systems["cargo"]?.instanceDefaults["settingsFile"] = .string("/fixture/new-shared.xml") + try store.saveProjectDocument(changed, workspace: workspace, expecting: original) + #expect(throws: (any Error).self) { try store.saveProjectDocument(shared, workspace: workspace, expecting: original) } + _ = try await Self.refreshed(service) { service.detect() } + #expect(service.entries.allSatisfy { $0.options.first { $0.id == "settingsFile" }?.value == .string("/fixture/new-shared.xml") }) + #expect(try await Self.run("cargo:a", service: service, runner: runner) == ["build", "--message-format=short", "--features", "base", "--offline", "--config", "/fixture/new-shared.xml"]) + } + + @Test + func malformedSharedAndLocalOptionsNeverBecomeEmptySuccessfulInput() async throws { + let workspace = FileManager.default.temporaryDirectory.appendingPathComponent("lithe-invalid-build-options-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: workspace) } + let source = try Self.source(workspace: workspace) + let backing = OptionPreferenceStore() + let store = MacBuildOptionsStore(store: backing, storage: MacFileStorage()) + var project = BuildProjectOptionsDocument() + project.systems["cargo"] = BuildProjectOptionsDocument.System() + try store.saveProjectDocument(project, workspace: workspace, expecting: nil) + let url = workspace.appendingPathComponent(".lithe/build/options.json") + for invalid in ["1", "null", "{}", "[true]"] { + let json = Data("{\"version\":1,\"systems\":{\"cargo\":{\"instanceDefaults\":{\"features\":\(invalid)}}}}".utf8) + try json.write(to: url) + #expect(throws: (any Error).self) { try store.projectDocument(workspace) } + #expect(try Data(contentsOf: url) == json) + } + try Data("{\"version\":true,\"systems\":{}}".utf8).write(to: url) + #expect(throws: (any Error).self) { try store.projectDocument(workspace) } + let broken = Data("{broken".utf8) + try broken.write(to: url) + let service = Self.service(source: source, workspace: workspace, store: store, runner: OptionCommandRecorder()) + defer { service.stop() } + service.reset(workspaceURL: workspace) + await #expect(throws: (any Error).self) { try await service.prepareLanguageImport(workspaceURL: workspace) } + #expect(service.entries.isEmpty) + #expect(!service.issues.isEmpty) + #expect(try Data(contentsOf: url) == broken) + try FileManager.default.removeItem(at: url) + try store.setOptions(["offline": .bool(true)], workspaceURL: workspace, buildSystemID: "cargo") + backing.corruptBuildOptions() + #expect(throws: (any Error).self) { try store.options(workspaceURL: workspace, buildSystemID: "cargo") } + } + + @Test(arguments: ["", ".Instances", ".InstanceDefaults", ".EmptyMultiSelect"]) + func nonDataLocalPreferencesBlockDetectionWithoutReplacingTheirSource(suffix: String) async throws { + let workspace = FileManager.default.temporaryDirectory.appendingPathComponent("lithe-invalid-option-type-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: workspace) } + let source = try Self.source(workspace: workspace) + let backing = OptionPreferenceStore() + let key = "BuildSystems.Options.cargo." + MacPluginStateDirectories.workspaceDigest(workspace) + suffix + backing.set("retain the malformed preference", forKey: key) + let store = MacBuildOptionsStore(store: backing) + let service = Self.service(source: source, workspace: workspace, store: store, runner: OptionCommandRecorder()) + defer { service.stop() } + service.reset(workspaceURL: workspace) + await #expect(throws: (any Error).self) { try await service.prepareLanguageImport(workspaceURL: workspace) } + #expect(service.entries.isEmpty) + #expect(!service.issues.isEmpty) + #expect(backing.object(forKey: key) as? String == "retain the malformed preference") + } + + @Test + func scopeUpgradeMigratesSharedValuesAndEmptyMarkersOnlyAfterReadback() async throws { + let workspace = FileManager.default.temporaryDirectory.appendingPathComponent("lithe-build-scope-migration-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: workspace) } + let source = try Self.source(workspace: workspace, instanceScoped: true) + let backing = OptionPreferenceStore() + let store = MacBuildOptionsStore(store: backing) + let previous: [String: ToolingJSONValue] = ["features": .array([.string("cli"), .string("gui")]), "settingsFile": .string("/fixture/shared.xml"), "offline": .bool(true)] + try store.setOptions(previous, workspaceURL: workspace, buildSystemID: "cargo") + try store.setEmptyMultiSelectOptions(["a": ["features"], "temporarily-absent": ["features"]], workspaceURL: workspace, buildSystemID: "cargo") + try store.setInstanceOptions(["b": ["settingsFile": .string("/fixture/explicit b.xml")]], workspaceURL: workspace, buildSystemID: "cargo") + backing.refuseInstanceWrites = true + let service = Self.service(source: source, workspace: workspace, store: store, runner: OptionCommandRecorder()) + defer { service.stop() } + service.reset(workspaceURL: workspace) + do { + _ = try await service.prepareLanguageImport(workspaceURL: workspace) + Issue.record("A failed migration must block publishing an unrelated default configuration") + } catch { + #expect(!service.issues.isEmpty) + } + #expect(service.entries.isEmpty) + #expect(try store.options(workspaceURL: workspace, buildSystemID: "cargo") == previous) + #expect(try store.emptyMultiSelectOptions(workspaceURL: workspace, buildSystemID: "cargo") == ["a": ["features"], "temporarily-absent": ["features"]]) + backing.refuseInstanceWrites = false + let imported = try await Self.refreshed(service) { service.detect() } + #expect(imported.mavenContext?.profiles == []) + #expect(imported.mavenContext?.settingsPath == "/fixture/shared.xml") + #expect(try store.options(workspaceURL: workspace, buildSystemID: "cargo") == ["offline": .bool(true)]) + #expect(try store.emptyMultiSelectOptions(workspaceURL: workspace, buildSystemID: "cargo").isEmpty) + #expect(try store.instanceOptions(workspaceURL: workspace, buildSystemID: "cargo")["a"]?["features"] == .array([])) + #expect(try store.instanceOptions(workspaceURL: workspace, buildSystemID: "cargo")["temporarily-absent"]?["features"] == .array([])) + #expect(try store.instanceOptions(workspaceURL: workspace, buildSystemID: "cargo")["b"]?["features"] == .array([.string("gui")])) + #expect(try store.instanceOptions(workspaceURL: workspace, buildSystemID: "cargo")["b"]?["settingsFile"] == .string("/fixture/explicit b.xml")) + _ = try await Self.refreshed(service) { service.detect() } + #expect(try store.instanceOptions(workspaceURL: workspace, buildSystemID: "cargo")["a"]?["features"] == .array([])) + } + + @Test + func independentInstanceOptionsSurviveSiblingEditsResetAndReopening() async throws { + let workspace = FileManager.default.temporaryDirectory.appendingPathComponent("lithe-instance-build-options-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: workspace) } + let source = try Self.source(workspace: workspace, instanceScoped: true) + let backing = OptionPreferenceStore() + let store = MacBuildOptionsStore(store: backing) + try store.setInstanceOptions([ + "a": ["features": .array([]), "settingsFile": .string("/fixture/a settings.xml"), "release": .bool(false), "label": .string("")], + "b": ["features": .array([.string("gui")]), "settingsFile": .string("/fixture/b.xml"), "cacheDirectory": .string("/fixture/cache b")] + ], workspaceURL: workspace, buildSystemID: "cargo") + let runner = OptionCommandRecorder() + let service = Self.service(source: source, workspace: workspace, store: store, runner: runner) + defer { service.stop() } + let imported = try await Self.refreshed(service) { service.reset(workspaceURL: workspace) } + #expect(imported.mavenContext?.profiles == []) + #expect(imported.mavenContext?.settingsPath == "/fixture/a settings.xml") + #expect(service.entry("cargo:a")?.options.first { $0.id == "features" }?.scope == .instance) + #expect(try await Self.run("cargo:a", service: service, runner: runner) == ["build", "--message-format=short", "--config", "/fixture/a settings.xml"]) + #expect(try await Self.run("cargo:b", service: service, runner: runner) == ["build", "--message-format=short", "--features", "gui", "--config", "/fixture/b.xml", "--cache", "/fixture/cache b"]) + let originalA = try store.instanceOptions(workspaceURL: workspace, buildSystemID: "cargo")["a"] + _ = try await Self.refreshed(service) { + service.setInstanceOption("cargo:b", optionID: "features", value: .array([])) + service.setInstanceOption("cargo:b", optionID: "settingsFile", value: .string("/fixture/changed b.xml")) + } + #expect(try store.instanceOptions(workspaceURL: workspace, buildSystemID: "cargo")["a"] == originalA) + #expect(try store.emptyMultiSelectOptions(workspaceURL: workspace, buildSystemID: "cargo").isEmpty) + #expect(try store.options(workspaceURL: workspace, buildSystemID: "cargo").isEmpty) + #expect(throws: (any Error).self) { try service.configureOption("cargo:a", optionID: "release", value: .string("bad")) } + service.stop() + + let reopenedStore = MacBuildOptionsStore(store: backing) + let reopened = Self.service(source: source, workspace: workspace, store: reopenedStore, runner: runner) + defer { reopened.stop() } + _ = try await Self.refreshed(reopened) { reopened.reset(workspaceURL: workspace) } + #expect(reopened.entry("cargo:a")?.options.first { $0.id == "features" }?.value == .array([])) + #expect(reopened.entry("cargo:a")?.options.first { $0.id == "label" }?.source == "user") + #expect(reopened.entry("cargo:a")?.options.first { $0.id == "release" }?.source == "user") + #expect(reopened.entry("cargo:b")?.options.first { $0.id == "features" }?.value == .array([])) + _ = try await Self.refreshed(reopened) { + reopened.setInstanceOption("cargo:a", optionID: "features", value: nil) + reopened.setOption("cargo:a", optionID: "settingsFile", value: nil) + reopened.setOption("cargo:a", optionID: "offline", value: .bool(true)) + } + #expect(reopened.entry("cargo:a")?.options.first { $0.id == "features" }?.value == .array([.string("base")])) + #expect(reopened.entry("cargo:b")?.options.first { $0.id == "features" }?.value == .array([])) + #expect(try reopenedStore.instanceOptions(workspaceURL: workspace, buildSystemID: "cargo")["b"]?["settingsFile"] == .string("/fixture/changed b.xml")) + #expect(reopened.entries.allSatisfy { $0.options.first { $0.id == "offline" }?.value == .bool(true) }) + #expect(try reopenedStore.instanceOptions(workspaceURL: workspace.appendingPathComponent("other"), buildSystemID: "cargo").isEmpty) + #expect(try reopenedStore.instanceOptions(workspaceURL: workspace, buildSystemID: "other").isEmpty) + } + + @Test + func checkedOptionEditsRejectInvalidValuesAndReadBackTheInstanceWithoutChangingSiblings() async throws { + let workspace = FileManager.default.temporaryDirectory.appendingPathComponent("lithe-checked-build-options-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: workspace) } + let source = try Self.source(workspace: workspace) + let store = MacBuildOptionsStore(store: OptionPreferenceStore()) + let service = Self.service(source: source, workspace: workspace, store: store, runner: OptionCommandRecorder()) + defer { service.stop() } + _ = try await Self.refreshed(service) { service.reset(workspaceURL: workspace) } + #expect(throws: (any Error).self) { try service.configureOption("cargo:a", optionID: "features", value: .bool(true)) } + #expect(throws: (any Error).self) { try service.configureOption("cargo:a", optionID: "features", value: .array([.string("absent")])) } + #expect(throws: (any Error).self) { try service.configureOption("cargo:a", optionID: "missing", value: .string("v")) } + #expect(try store.options(workspaceURL: workspace, buildSystemID: "cargo").isEmpty) + let changed = try service.configureOption("cargo:a", optionID: "features", value: .array([.string("cli")])) + #expect(changed.value == .array([.string("cli")])) + #expect(changed.source == "user") + let cleared = try service.configureOption("cargo:a", optionID: "features", value: .array([])) + #expect(cleared.value == .array([])) + #expect(try store.emptyMultiSelectOptions(workspaceURL: workspace, buildSystemID: "cargo")["a"] == ["features"]) + let reset = try service.configureOption("cargo:a", optionID: "features", value: nil) + #expect(reset.value == .array([.string("base")])) + #expect(reset.source != "user") + } + @Test func clearedInstanceSurvivesSiblingEditsRedetectionAndReopening() async throws { let workspace = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) @@ -18,7 +275,7 @@ struct PluginBuildOptionPersistenceTests { let source = try Self.source(workspace: workspace) let backing = OptionPreferenceStore() let store = MacBuildOptionsStore(store: backing) - store.setOptions( + try store.setOptions( ["features": .array([.string("cli"), .string("gui")])], workspaceURL: workspace, buildSystemID: "cargo" ) @@ -44,8 +301,8 @@ struct PluginBuildOptionPersistenceTests { service.setInstanceOption("cargo:a", optionID: "features", value: .array([])) } #expect(cleared.mavenContext?.profiles == []) - #expect(store.options(workspaceURL: workspace, buildSystemID: "cargo")["features"] == .array([.string("gui")])) - #expect(store.emptyMultiSelectOptions(workspaceURL: workspace, buildSystemID: "cargo") == ["a": ["features"]]) + #expect(try store.options(workspaceURL: workspace, buildSystemID: "cargo")["features"] == .array([.string("gui")])) + #expect(try store.emptyMultiSelectOptions(workspaceURL: workspace, buildSystemID: "cargo") == ["a": ["features"]]) #expect(service.entry("cargo:a")?.options.first { $0.id == "features" }?.source == "user") #expect(service.entry("cargo:a")?.options.first { $0.id == "features" }?.value == .array([])) #expect(service.entry("cargo:a")?.sync?.fingerprint != previousFingerprint) @@ -71,7 +328,7 @@ struct PluginBuildOptionPersistenceTests { _ = try await Self.refreshed(reopened) { reopened.setInstanceOption("cargo:a", optionID: "features", value: .array([.string("cli")])) } - #expect(reopenedStore.emptyMultiSelectOptions(workspaceURL: workspace, buildSystemID: "cargo").isEmpty) + #expect(try reopenedStore.emptyMultiSelectOptions(workspaceURL: workspace, buildSystemID: "cargo").isEmpty) #expect(try await Self.run("cargo:a", service: reopened, runner: runner) == ["build", "--message-format=short", "--features", "base,cli"]) _ = try await Self.refreshed(reopened) { reopened.setInstanceOption("cargo:a", optionID: "features", value: .array([])) @@ -79,30 +336,30 @@ struct PluginBuildOptionPersistenceTests { let reset = try await Self.refreshed(reopened) { reopened.setInstanceOption("cargo:a", optionID: "features", value: nil) } - #expect(reopenedStore.options(workspaceURL: workspace, buildSystemID: "cargo")["features"] == nil) - #expect(reopenedStore.emptyMultiSelectOptions(workspaceURL: workspace, buildSystemID: "cargo").isEmpty) + #expect(try reopenedStore.options(workspaceURL: workspace, buildSystemID: "cargo")["features"] == nil) + #expect(try reopenedStore.emptyMultiSelectOptions(workspaceURL: workspace, buildSystemID: "cargo").isEmpty) #expect(reset.mavenContext?.profiles == ["base"]) #expect(try await Self.run("cargo:a", service: reopened, runner: runner) == ["build", "--message-format=short", "--features", "base"]) } @Test - func emptySelectionsRoundTripWithoutChangingLegacyOptionsAndRemainScoped() { + func emptySelectionsRoundTripWithoutChangingLegacyOptionsAndRemainScoped() throws { let backing = OptionPreferenceStore() let workspace = URL(fileURLWithPath: "/fixture/workspace") let other = URL(fileURLWithPath: "/fixture/other") let store = MacBuildOptionsStore(store: backing) let legacy: [String: ToolingJSONValue] = ["features": .array([.string("gui")]), "text": .string("")] - store.setOptions(legacy, workspaceURL: workspace, buildSystemID: "cargo") - #expect(store.emptyMultiSelectOptions(workspaceURL: workspace, buildSystemID: "cargo").isEmpty) - store.setEmptyMultiSelectOptions(["a": ["features"], "unused": []], workspaceURL: workspace, buildSystemID: "cargo") + try store.setOptions(legacy, workspaceURL: workspace, buildSystemID: "cargo") + #expect(try store.emptyMultiSelectOptions(workspaceURL: workspace, buildSystemID: "cargo").isEmpty) + try store.setEmptyMultiSelectOptions(["a": ["features"], "unused": []], workspaceURL: workspace, buildSystemID: "cargo") let reopened = MacBuildOptionsStore(store: backing) - #expect(reopened.options(workspaceURL: workspace, buildSystemID: "cargo") == legacy) - #expect(reopened.emptyMultiSelectOptions(workspaceURL: workspace, buildSystemID: "cargo") == ["a": ["features"]]) - #expect(reopened.emptyMultiSelectOptions(workspaceURL: other, buildSystemID: "cargo").isEmpty) - #expect(reopened.emptyMultiSelectOptions(workspaceURL: workspace, buildSystemID: "other").isEmpty) - reopened.setEmptyMultiSelectOptions([:], workspaceURL: workspace, buildSystemID: "cargo") - #expect(store.emptyMultiSelectOptions(workspaceURL: workspace, buildSystemID: "cargo").isEmpty) - #expect(store.options(workspaceURL: workspace, buildSystemID: "cargo") == legacy) + #expect(try reopened.options(workspaceURL: workspace, buildSystemID: "cargo") == legacy) + #expect(try reopened.emptyMultiSelectOptions(workspaceURL: workspace, buildSystemID: "cargo") == ["a": ["features"]]) + #expect(try reopened.emptyMultiSelectOptions(workspaceURL: other, buildSystemID: "cargo").isEmpty) + #expect(try reopened.emptyMultiSelectOptions(workspaceURL: workspace, buildSystemID: "other").isEmpty) + try reopened.setEmptyMultiSelectOptions([:], workspaceURL: workspace, buildSystemID: "cargo") + #expect(try store.emptyMultiSelectOptions(workspaceURL: workspace, buildSystemID: "cargo").isEmpty) + #expect(try store.options(workspaceURL: workspace, buildSystemID: "cargo") == legacy) } private static func refreshed( @@ -139,23 +396,26 @@ struct PluginBuildOptionPersistenceTests { ) } - private static func source(workspace: URL) throws -> BuildSystemPluginSource { + private static func source(workspace: URL, instanceScoped: Bool = false, allowCustom: Bool = false) throws -> BuildSystemPluginSource { for (root, feature) in [("a", "cli"), ("b", "gui")] { let directory = workspace.appendingPathComponent(root) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) try Data("[package]\nname = \"\(root)\"\nversion = \"0.1.0\"\n[features]\n\(feature) = []\n".utf8) .write(to: directory.appendingPathComponent("Cargo.toml")) } - let fixture = DeclarativePluginTestSupport.repositoryRoot.appendingPathComponent("shared/contracts/plugins/v1/fixtures/build-static-file.plugin.json") + let fixture = DeclarativePluginTestSupport.repositoryRoot.appendingPathComponent("shared/contracts/plugins/v1/fixtures/\(instanceScoped ? "build-instance-options" : "build-static-file").plugin.json") var manifest = try #require(JSONSerialization.jsonObject(with: Data(contentsOf: fixture)) as? [String: Any]) var systems = try #require(manifest["buildSystems"] as? [[String: Any]]) var options = try #require(systems[0]["options"] as? [[String: Any]]) let index = try #require(options.firstIndex { $0["id"] as? String == "features" }) options[index]["choices"] = [["value": "base"]] options[index]["default"] = ["base"] - options[index]["allowCustom"] = false + options[index]["allowCustom"] = allowCustom systems[0]["options"] = options systems[0]["languageImport"] = ["kind": "maven", "options": ["profiles": "features"]] + if instanceScoped { + systems[0]["languageImport"] = ["kind": "maven", "options": ["profiles": "features", "settingsFile": "settingsFile"]] + } manifest["buildSystems"] = systems let descriptor = try DeclarativePluginTestSupport.operations.validate(manifest: JSONSerialization.data(withJSONObject: manifest)) return BuildSystemPluginSource( @@ -167,11 +427,19 @@ struct PluginBuildOptionPersistenceTests { private final class OptionPreferenceStore: KeyValueStore { private var values: [String: Any] = [:] + var refuseInstanceWrites = false + var instanceData: [Data] { values.filter { $0.key.hasSuffix(".Instances") }.values.compactMap { $0 as? Data } } + func corruptBuildOptions() { + for key in values.keys where key.hasPrefix("BuildSystems.Options.") { values[key] = Data("{broken".utf8) } + } func data(forKey key: String) -> Data? { values[key] as? Data } func object(forKey key: String) -> Any? { values[key] } func string(forKey key: String) -> String? { values[key] as? String } func stringArray(forKey key: String) -> [String]? { values[key] as? [String] } - func set(_ value: Any?, forKey key: String) { values[key] = value } + func set(_ value: Any?, forKey key: String) { + guard !refuseInstanceWrites || !key.hasSuffix(".Instances") else { return } + values[key] = value + } } private actor OptionCommandRecorder: BuildCommandRunning { diff --git a/macos/Tests/LitheTests/PluginDebugAdapterTests.swift b/macos/Tests/LitheTests/PluginDebugAdapterTests.swift index 87d73374ff..205052ba91 100644 --- a/macos/Tests/LitheTests/PluginDebugAdapterTests.swift +++ b/macos/Tests/LitheTests/PluginDebugAdapterTests.swift @@ -13,6 +13,49 @@ import Testing struct PluginDebugAdapterTests { private struct ResolveFailure: Error, Equatable {} + @Test(arguments: ["unchanged", "disabled", "replaced", "generation"]) + func providerConnectionUsesTheOwningRegistryEntryAndRevokesStaleBindings(change: String) throws { + let fixture = DeclarativePluginTestSupport.repositoryRoot.appendingPathComponent("Plugins/mac/Official/JavaSupport/plugin.json") + let descriptor = try DeclarativePluginTestSupport.operations.validate(manifest: Data(contentsOf: fixture)) + let package = InstalledPluginPackage(descriptor: descriptor, installation: PluginInstallationRecord( + pluginID: descriptor.id, activeVersion: descriptor.pluginManifest.version, origin: .marketplace, status: .installed), + packageURL: URL(fileURLWithPath: "/fixture/java-support")) + let entry = try #require(PluginDebugAdapterEntry.qualifiedIndex(of: [package]).values.first) + var entries = [entry.providerID: entry] + var generation = 7 + let resolver = MacBuildConnectionDebugResolver(planner: DeclarativePluginTestSupport.operations, + entries: { entries }, generation: { generation }) + let prepared = try resolver.prepare(configurationType: "java", preferredAdapterID: nil, port: 51235) + #expect(prepared.options.adapterID == entry.providerID) + #expect(prepared.options.target.kind == "connection") + #expect(prepared.configuration.adapterProviderID == entry.providerID) + #expect(prepared.configuration.arguments["hostName"] == .string("127.0.0.1")) + #expect(prepared.configuration.arguments["port"] == .integer(51235)) + #expect(prepared.isCurrent()) + if change == "disabled" { entries = [:] } + if change == "generation" { generation += 1 } + if change == "replaced" { + let replacement = InstalledPluginPackage(descriptor: descriptor, installation: package.installation, + packageURL: URL(fileURLWithPath: "/fixture/replacement")) + entries[entry.providerID] = PluginDebugAdapterEntry(plugin: replacement, adapter: entry.adapter) + } + #expect(prepared.isCurrent() == (change == "unchanged")) + // Explicit old language IDs and a missing alias never select built-in adapters. + #expect(throws: DebugLaunchConfigurationResolutionError.providerDebugUnavailable) { + try resolver.prepare(configurationType: "java", preferredAdapterID: "java", port: 51235) + } + #expect(throws: DebugLaunchConfigurationResolutionError.providerDebugUnavailable) { + try resolver.prepare(configurationType: "missing", preferredAdapterID: nil, port: 51235) + } + entries["another/adapter"] = entry + if change == "unchanged" { + #expect(throws: DebugLaunchConfigurationResolutionError.providerDebugUnavailable) { + try resolver.prepare(configurationType: "java", preferredAdapterID: nil, port: 51235) + } + #expect(try resolver.prepare(configurationType: "java", preferredAdapterID: entry.providerID, port: 51235).isCurrent()) + } + } + @Test(arguments: ["valid", "not-executable", "withdrawn", "replaced", "program-alias", "instance-alias"]) func providerDebugHandoffRechecksFilesAndCurrentPackage(change: String) throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent("provider-debug-\(UUID().uuidString)", isDirectory: true) diff --git a/macos/Tests/LitheTests/PluginPackageSignatureTests.swift b/macos/Tests/LitheTests/PluginPackageSignatureTests.swift index 1ce5faeea2..593ef604d8 100644 --- a/macos/Tests/LitheTests/PluginPackageSignatureTests.swift +++ b/macos/Tests/LitheTests/PluginPackageSignatureTests.swift @@ -4,6 +4,33 @@ import LithePluginPackageSigning import Testing struct PluginPackageSignatureTests { + @Test + func publisherSignatureMatchesTheSharedCrossPlatformVector() throws { + struct Fixture: Decodable { + let contents: [String: String] + let document: PluginPackageSignature.Document + let publicKey: String + let trustedPublisherKeys: [String: String] + } + let fixture = try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: + DeclarativePluginTestSupport.repositoryRoot.appendingPathComponent("shared/fixtures/plugins/publisher-signature.json"))) + #expect(PluginPackageSignature.trustedPublisherKeysBase64 == fixture.trustedPublisherKeys) + let root = FileManager.default.temporaryDirectory.appendingPathComponent("publisher-vector-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: root) } + for (path, contents) in fixture.contents { + let file = root.appendingPathComponent(path) + try FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data(contents.utf8).write(to: file) + } + let key = try Curve25519.Signing.PublicKey(rawRepresentation: #require(Data(base64Encoded: fixture.publicKey))) + try PluginPackageSignature.verify(packageAt: root, pluginID: "dev.example.signed", pluginVersion: "1.2.3", + document: fixture.document, publicKey: key) + #expect(throws: PluginPackageSignature.Error.untrustedKey("fixture-rfc8032")) { + try PluginPackageSignature.verify(packageAt: root, pluginID: "dev.example.signed", pluginVersion: "1.2.3", + document: fixture.document, trustedKeys: PluginPackageSignature.trustedPublisherKeys()) + } + } + @Test func signatureCoversEveryPackageFile() throws { let root = try makePackage() diff --git a/macos/Tests/LitheTests/PluginSettingsTests.swift b/macos/Tests/LitheTests/PluginSettingsTests.swift index e0e88d77e3..97cf6c7ac6 100644 --- a/macos/Tests/LitheTests/PluginSettingsTests.swift +++ b/macos/Tests/LitheTests/PluginSettingsTests.swift @@ -326,6 +326,27 @@ struct PluginSettingsTests { } } + @Test + func nativeConfigurationRegistersItsBuildPageWithoutShadowPreferenceControls() async throws { + try await withV2Manager { manager in + try await manager.setEnabled(true, for: PluginID("dev.lithe.fixture.settings-v2")) + var snapshot = try #require(manager.snapshots.first { $0.id == PluginID("dev.lithe.fixture.settings-v2") }) + snapshot.settings = [] + snapshot.contributions.actions = [] + snapshot.contributions.languageServerIDs = [] + snapshot.contributions.buildSystems = [.init(id: "native-tool", displayName: "Native tool", nativeConfiguration: true)] + let pages = PluginSettingsRegistry.pages(from: [snapshot]) + let page = try #require(pages.first) + #expect(pages.count == 1) + #expect(page.id == "buildSystem:native-tool") + #expect(page.group == .build) + #expect(page.contributions.first?.nativeBuildSystemIDs == ["native-tool"]) + #expect(page.contributions.first?.settings.isEmpty == true) + snapshot.contributions.buildSystems[0].nativeConfiguration = false + #expect(PluginSettingsRegistry.pages(from: [snapshot]).isEmpty) + } + } + @Test func aDisabledPluginRegistersNoPages() async throws { try await withV2Manager { manager in diff --git a/macos/Tests/LitheTests/ProjectGenerationTests.swift b/macos/Tests/LitheTests/ProjectGenerationTests.swift index e6a7a443e3..da9c43d3f2 100644 --- a/macos/Tests/LitheTests/ProjectGenerationTests.swift +++ b/macos/Tests/LitheTests/ProjectGenerationTests.swift @@ -345,11 +345,15 @@ struct ProjectGenerationTests { } var language = manifest language["projectGenerators"] = nil - language["permissions"] = ["languageServer.launch"] + // The language half keeps the debug adapter it declares, so it keeps + // the debug permissions that adapter needs. + language["permissions"] = ["languageServer.launch", "debug.launch", "debug.attach"] languageRequires["projectGenerators"] = nil language["requires"] = languageRequires var templates = manifest - for key in ["languages", "languageServers", "settings"] { templates[key] = nil } + // The template half requires only projectGenerators, so every other + // capability contribution leaves with its requirement. + for key in ["languages", "languageServers", "settings", "debugAdapters", "toolDefinitions"] { templates[key] = nil } templates["id"] = templatesPluginID.rawValue templates["displayName"] = "Java Templates" templates["permissions"] = ["project.generate"] diff --git a/macos/Tests/LitheTests/ProjectRuntimeLoadingTests.swift b/macos/Tests/LitheTests/ProjectRuntimeLoadingTests.swift index c74413b796..d91e2ca233 100644 --- a/macos/Tests/LitheTests/ProjectRuntimeLoadingTests.swift +++ b/macos/Tests/LitheTests/ProjectRuntimeLoadingTests.swift @@ -12,7 +12,7 @@ struct ProjectRuntimeLoadingTests { let service = ProjectRuntimeService(runtimeLocator: locator, store: LoadingEmptyStore()) let root = URL(fileURLWithPath: "/fixture/workspace") service.openProject(at: root) - let load = Task { try await service.loadRunConfigurationToolchainCandidates(for: nil, projectRoot: root) } + let load = Task { try await service.loadRunConfigurationToolchainCandidates( projectRoot: root) } defer { locator.release(); start.finish(); load.cancel(); service.closeProject() } let received = await withTaskGroup(of: Bool.self) { group in group.addTask { for await _ in started { return true }; return false } diff --git a/macos/Tests/LitheTests/ProjectRuntimePersistenceTests.swift b/macos/Tests/LitheTests/ProjectRuntimePersistenceTests.swift new file mode 100644 index 0000000000..fe05a0c313 --- /dev/null +++ b/macos/Tests/LitheTests/ProjectRuntimePersistenceTests.swift @@ -0,0 +1,233 @@ +import Foundation +import LitheCoreContracts +import Testing +@testable import Lithe + +@Suite("Project runtime canonical persistence", .serialized) +@MainActor +struct ProjectRuntimePersistenceTests { + @Test(arguments: [false, true]) + func migratesSubprojectPathsIntoExistingCanonicalDefaultsWithoutReplacingSavedChoices(existingPaths: Bool) throws { + let root = try workspace() + defer { try? FileManager.default.removeItem(at: root) } + let preferences = RuntimeMigrationPreferences() + let key = MacProjectRuntimeSettingsStore.preferencesKey(root) + var legacy = ProjectRuntimeSettings(javaHomePath: "/fixture/old-project-jdk") + legacy.setOverride(path: "services/a", javaHomePath: "/fixture/a-jdk", mavenExecutablePath: "/fixture/a-mvn") + legacy.setOverride(path: "services/b", mavenExecutablePath: "./mvnw", mavenJavaHomePath: "/fixture/b-maven-jdk") + preferences.set(try JSONEncoder().encode(legacy), forKey: key) + let local = root.appendingPathComponent(".lithe/run/local.json") + try FileManager.default.createDirectory(at: local.deletingLastPathComponent(), withIntermediateDirectories: true) + var toolchain: [String: Any] = ["java": ["homePath": "/fixture/saved-project-jdk"]] + if existingPaths { toolchain["pathOverrides"] = ["services/a": ["javaHomePath": "/fixture/saved-a-jdk"]] } + try JSONSerialization.data(withJSONObject: ["version": 2, "toolchain": toolchain, "configurations": []]).write(to: local) + let store = MacProjectRuntimeSettingsStore(core: RustCoreBridge(), storage: MacFileStorage(), preferences: preferences) + let migrated = try store.load(at: root, migrateLegacy: true) + #expect(migrated.settings.javaHomePath == "/fixture/saved-project-jdk") + #expect(migrated.settings.exactOverride(for: "services/a")?.javaHomePath == (existingPaths ? "/fixture/saved-a-jdk" : "/fixture/a-jdk")) + #expect(migrated.settings.exactOverride(for: "services/b")?.mavenExecutablePath == (existingPaths ? nil : "./mvnw")) + let preferenceData = try #require(preferences.data(forKey: key)) + let cleaned = try #require(JSONSerialization.jsonObject(with: preferenceData) as? [String: Any]) + #expect(cleaned["moduleOverrides"] == nil) + let bytes = try Data(contentsOf: local) + let reopened = try store.load(at: root, migrateLegacy: false) + #expect(reopened.settings == migrated.settings) + #expect(try Data(contentsOf: local) == bytes) + var edited = reopened.settings + edited.setOverride(path: "services/a", javaHomePath: "/fixture/edited-a-jdk") + let saved = try store.save(edited, at: root, expecting: reopened) + #expect(saved.settings.exactOverride(for: "services/a")?.javaHomePath == "/fixture/edited-a-jdk") + #expect(try store.load(at: root, migrateLegacy: false).settings == saved.settings) + #expect(saved.settings.overlay(onto: RunOptions(), workspaceRelativePath: "services/a/module").javaHomePath == "/fixture/edited-a-jdk") + #expect(saved.settings.overlay(onto: RunOptions(), workspaceRelativePath: "services/another").javaHomePath == "/fixture/saved-project-jdk") + } + + @Test(arguments: ["outside", "duplicate", "unknown"]) + func invalidSubprojectPathRetainsLegacyDataAndDoesNotCreateCanonicalDocument(kind: String) throws { + let root = try workspace() + defer { try? FileManager.default.removeItem(at: root) } + let preferences = RuntimeMigrationPreferences() + let key = MacProjectRuntimeSettingsStore.preferencesKey(root) + let path = kind == "outside" ? "../escape" : "services/a" + var overrides: [[String: Any]] = [["path": path, "javaHomePath": "/fixture/jdk"]] + if kind == "duplicate" { overrides.append(["path": path, "javaHomePath": "/fixture/another-jdk"]) } + if kind == "unknown" { overrides[0]["futureField"] = "preserve-this-value" } + let original = try JSONSerialization.data(withJSONObject: ["moduleOverrides": overrides]) + preferences.set(original, forKey: key) + let store = MacProjectRuntimeSettingsStore(core: RustCoreBridge(), storage: MacFileStorage(), preferences: preferences) + #expect(throws: (any Error).self) { try store.load(at: root, migrateLegacy: true) } + #expect(preferences.data(forKey: key) == original) + #expect(!FileManager.default.fileExists(atPath: root.appendingPathComponent(".lithe/run/local.json").path)) + } + + @Test(arguments: [false, true]) + func migratesLegacyOnlyAfterReadingTheCanonicalSelection(existingCanonical: Bool) throws { + let root = try workspace() + defer { try? FileManager.default.removeItem(at: root) } + let preferences = RuntimeMigrationPreferences() + let core = RustCoreBridge() + try #require(core.isAvailable) + let store = MacProjectRuntimeSettingsStore(core: core, storage: MacFileStorage(), preferences: preferences) + let key = MacProjectRuntimeSettingsStore.preferencesKey(root) + let legacy = ProjectRuntimeSettings(javaHomePath: "/fixture/old-jdk", mavenHomeSelection: .wrapper, + mavenJavaHomePath: "/fixture/old-maven-jdk") + var document = try #require(JSONSerialization.jsonObject(with: JSONEncoder().encode(legacy)) as? [String: Any]) + document["futurePreference"] = true + document["mavenSettingsPath"] = "/fixture/settings.xml" + let original = try JSONSerialization.data(withJSONObject: document) + preferences.set(original, forKey: key) + let expected = existingCanonical ? ProjectToolchainSelection() : legacy.projectToolchainSelection() + if existingCanonical { + // Explicit automatic is a saved choice and wins over old overrides. + try MacRunConfigurationStore(core: core, storage: MacFileStorage(), preferences: preferences) + .saveProjectToolchain(expected, at: root) + } + let readOnly = try store.load(at: root, migrateLegacy: false) + #expect(preferences.data(forKey: key) == original) + #expect(readOnly.settings.projectToolchainSelection() == ProjectToolchainSelection()) + let migrated = try store.load(at: root, migrateLegacy: true) + #expect(migrated.settings.projectToolchainSelection() == expected) + let savedPreferences = try #require(preferences.data(forKey: key)) + let cleaned = try #require(JSONSerialization.jsonObject(with: savedPreferences) as? [String: Any]) + for field in ["javaHomePath", "mavenHomePath", "mavenHomeSelection", "mavenJavaHomePath"] { + #expect(cleaned[field] == nil) + } + #expect(cleaned["futurePreference"] as? Bool == true) + #expect(cleaned["mavenSettingsPath"] as? String == "/fixture/settings.xml") + let local = root.appendingPathComponent(".lithe/run/local.json") + let bytes = try Data(contentsOf: local) + _ = try store.load(at: root, migrateLegacy: true) + #expect(try Data(contentsOf: local) == bytes) + #expect(preferences.data(forKey: key) == savedPreferences) + let ignore = try String(contentsOf: root.appendingPathComponent(".lithe/.gitignore"), encoding: .utf8) + #expect(ignore.contains("run/local.json")) + } + + @Test + func invalidCanonicalDocumentPreservesBothSourcesAndBlocksRuntimeSelection() throws { + let root = try workspace() + defer { try? FileManager.default.removeItem(at: root) } + let preferences = RuntimeMigrationPreferences() + let key = MacProjectRuntimeSettingsStore.preferencesKey(root) + let legacy = try JSONEncoder().encode(ProjectRuntimeSettings(javaHomePath: "/fixture/old-jdk")) + preferences.set(legacy, forKey: key) + let local = root.appendingPathComponent(".lithe/run/local.json") + try FileManager.default.createDirectory(at: local.deletingLastPathComponent(), withIntermediateDirectories: true) + let invalid = Data("{broken".utf8) + try invalid.write(to: local) + let store = MacProjectRuntimeSettingsStore(core: RustCoreBridge(), storage: MacFileStorage(), preferences: preferences) + #expect(throws: (any Error).self) { try store.load(at: root, migrateLegacy: true) } + let service = ProjectRuntimeService(runtimeLocator: ProjectRuntimeSettingsTestLocator(), settingsStore: store) + service.openProject(at: root) + #expect(service.javaHomeURL() == nil) + #expect(service.mavenExecutable(at: root) == nil) + #expect(service.settingsError != nil) + #expect(preferences.data(forKey: key) == legacy) + #expect(try Data(contentsOf: local) == invalid) + } + + @Test(arguments: [false, true]) + func nonDataRuntimePreferenceCannotBecomeAutomaticOrBeRemovedByMigration(migrate: Bool) throws { + let root = try workspace() + defer { try? FileManager.default.removeItem(at: root) } + let preferences = RuntimeMigrationPreferences() + let key = MacProjectRuntimeSettingsStore.preferencesKey(root) + preferences.set("retain the malformed preference", forKey: key) + let store = MacProjectRuntimeSettingsStore(core: RustCoreBridge(), storage: MacFileStorage(), preferences: preferences) + #expect(throws: (any Error).self) { try store.load(at: root, migrateLegacy: migrate) } + let service = ProjectRuntimeService(runtimeLocator: ProjectRuntimeSettingsTestLocator(), settingsStore: store) + service.openProject(at: root) + #expect(service.javaHomeURL() == nil) + #expect(service.mavenExecutable(at: root) == nil) + #expect(service.settingsError != nil) + #expect(preferences.object(forKey: key) as? String == "retain the malformed preference") + #expect(!FileManager.default.fileExists(atPath: root.appendingPathComponent(".lithe/run/local.json").path)) + } + + @Test + func draftCancelSaveReadBackAndExternalEditUseTheSameSource() throws { + let root = try workspace() + defer { try? FileManager.default.removeItem(at: root) } + let preferences = RuntimeMigrationPreferences() + let store = MacProjectRuntimeSettingsStore(core: RustCoreBridge(), storage: MacFileStorage(), preferences: preferences) + let service = ProjectRuntimeService(runtimeLocator: ProjectRuntimeSettingsTestLocator(), settingsStore: store) + let feature = RuntimeSettingsFeatureModel(service: service) + service.openProject(at: root) + prepare(feature, root: root) + let local = root.appendingPathComponent(".lithe/run/local.json") + #expect(!FileManager.default.fileExists(atPath: local.path)) + feature.updateSettings { $0.javaHomePath = "/fixture/draft-jdk" } + #expect(!FileManager.default.fileExists(atPath: local.path)) + #expect(service.settings.javaHomePath.isEmpty) + feature.cancelChanges() + #expect(!feature.hasChanges) + #expect(feature.saveSettings()) + feature.finishSaving() + #expect(!FileManager.default.fileExists(atPath: local.path)) + feature.updateSettings { $0.javaHomePath = "/fixture/saved-jdk" } + #expect(feature.saveSettings()) + #expect(feature.isSaving) + feature.updateSettings { $0.javaHomePath = "/fixture/blocked-edit" } + #expect(feature.settings.javaHomePath == "/fixture/saved-jdk") + #expect(!feature.saveSettings()) + feature.finishSaving() + #expect(service.javaHomeURL()?.path == "/fixture/saved-jdk") + #expect(!feature.hasChanges) + let baseline = try store.load(at: root, migrateLegacy: false) + var external = baseline.settings + external.javaHomePath = "/fixture/external-jdk" + _ = try store.save(external, at: root, expecting: baseline) + feature.updateSettings { $0.javaHomePath = "/fixture/kept-draft" } + // A launch refreshes its source while a settings draft is open. It must + // not replace that draft's baseline and make a stale save appear valid. + #expect(service.javaHomeURL()?.path == "/fixture/external-jdk") + #expect(!feature.saveSettings()) + #expect(feature.settings.javaHomePath == "/fixture/kept-draft") + #expect(feature.settingsError != nil) + #expect(try store.load(at: root, migrateLegacy: false).settings.javaHomePath == "/fixture/external-jdk") + feature.cancelChanges() + #expect(feature.settings.javaHomePath == "/fixture/external-jdk") + #expect(!feature.hasChanges) + let other = try workspace() + defer { try? FileManager.default.removeItem(at: other) } + let otherSnapshot = try store.load(at: other, migrateLegacy: false) + #expect(throws: (any Error).self) { try store.save(otherSnapshot.settings, at: root, expecting: otherSnapshot) } + service.closeProject() + service.openProject(at: root) + #expect(service.javaHomeURL()?.path == "/fixture/external-jdk") + } + + @Test + func failedMigrationRetainsLegacyPreferences() throws { + let root = try workspace() + defer { try? FileManager.default.removeItem(at: root) } + let preferences = RuntimeMigrationPreferences() + let key = MacProjectRuntimeSettingsStore.preferencesKey(root) + let original = try JSONEncoder().encode(ProjectRuntimeSettings(javaHomePath: "/fixture/jdk")) + preferences.set(original, forKey: key) + var storage = MacFileStorage() + storage.unsupportedExchangeForTesting = true + let store = MacProjectRuntimeSettingsStore(core: RustCoreBridge(), storage: storage, preferences: preferences) + #expect(throws: (any Error).self) { try store.load(at: root, migrateLegacy: true) } + #expect(preferences.data(forKey: key) == original) + #expect(!FileManager.default.fileExists(atPath: root.appendingPathComponent(".lithe/run/local.json").path)) + } + + private func workspace() throws -> URL { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("runtime-canonical-" + UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + return root + } + private func prepare(_ feature: RuntimeSettingsFeatureModel, root: URL) { + feature.prepare(workspaceName: "fixture", workspaceURL: root, files: []) + } +} + +private final class RuntimeMigrationPreferences: KeyValueStore { + private var values: [String: Any] = [:] + func data(forKey key: String) -> Data? { values[key] as? Data } + func object(forKey key: String) -> Any? { values[key] } + func string(forKey key: String) -> String? { values[key] as? String } + func stringArray(forKey key: String) -> [String]? { values[key] as? [String] } + func set(_ value: Any?, forKey key: String) { values[key] = value } +} diff --git a/macos/Tests/LitheTests/ProjectRuntimeSettingsTests.swift b/macos/Tests/LitheTests/ProjectRuntimeSettingsTests.swift index ba39508104..ede08cdce0 100644 --- a/macos/Tests/LitheTests/ProjectRuntimeSettingsTests.swift +++ b/macos/Tests/LitheTests/ProjectRuntimeSettingsTests.swift @@ -1,4 +1,5 @@ import Foundation +import LitheModuleAPI import LitheCoreContracts import Testing @testable import Lithe @@ -83,30 +84,53 @@ struct ProjectRuntimeSettingsTests { ) } + @Test + func inventoryUsesProviderHierarchyAndDeclarationsInsteadOfGuessingFromFileNames() { + let root = URL(fileURLWithPath: "/workspace/fixture") + let files = ["engine.marker", "ghost/engine.marker", "pom.xml", "package.json"].map { root.appendingPathComponent($0) } + let language = LanguageSupportDeclaration(id: "java", displayName: "Java", projectFileNames: ["engine.marker"]) + let model = BuildModel(projects: [ + .init(id: "root", path: ".", name: "Root"), + .init(id: "library", path: "active/library", name: "Engine library", parentID: "root"), + .init(id: "nested", path: "active/library/nested", name: "Engine child", parentID: "library") + ]) + let sources: [ProjectRuntimeInventory.BuildSource] = [.init(id: "generic:root", root: ".", displayName: "Generic", usesJava: true, model: model)] + let rows = ProjectRuntimeInventory.subprojects(workspaceName: "fixture", workspaceURL: root, files: files, + buildSources: sources, languageSupports: [language]) + #expect(rows.map(\.id) == ["project", "path:active/library", "path:active/library/nested"]) + #expect(rows.last?.parentID == "path:active/library") + #expect(rows.last?.title == "Engine child") + #expect(rows.allSatisfy { $0.usesJava }) + #expect(ProjectRuntimeInventory.subprojects(workspaceName: "fixture", workspaceURL: root, files: files, + buildSources: [], languageSupports: []).map(\.id) == ["project"]) + let passive = ProjectRuntimeInventory.subprojects(workspaceName: "fixture", workspaceURL: root, files: files, + buildSources: [], languageSupports: [language]) + #expect(passive.map(\.id) == ["project", "path:ghost"]) + #expect(passive.last?.kind == .other) + } + + @Test + func overlappingSourcesShareOneDirectoryAndDoNotExposeOutsideFiles() { + let root = URL(fileURLWithPath: "/workspace/fixture") + let model = BuildModel(projects: [.init(id: "library", path: "services/library", name: "Library")]) + let rows = ProjectRuntimeInventory.subprojects(workspaceName: "fixture", workspaceURL: root, + files: [URL(fileURLWithPath: "/outside/engine.marker")], + buildSources: [ + .init(id: "first", root: "services/library", displayName: "First", usesJava: false, model: model), + .init(id: "second", root: "services/library", displayName: "Second", usesJava: true, model: nil), + .init(id: "outside", root: "../outside", displayName: "Outside", usesJava: false, model: nil) + ], languageSupports: [.init(id: "java", displayName: "Java", projectFileNames: ["engine.marker"])]) + #expect(rows.map(\.id) == ["project", "path:services/library"]) + #expect(rows.last?.usesJava == true) + } + @Test func inventoryListsIndependentBackendsAndFrontendRoots() { let root = URL(fileURLWithPath: "/workspace/shop", isDirectory: true) - let alpha = MavenProject( - rootURL: root.appendingPathComponent("services/alpha", isDirectory: true), - pomURL: root.appendingPathComponent("services/alpha/pom.xml"), - groupID: "shop", - artifactID: "alpha", - version: "1.0", - packaging: "jar", - modules: [ - MavenModule( - relativePath: "api", - url: root.appendingPathComponent("services/alpha/api", isDirectory: true), - groupID: "shop", - artifactID: "alpha-api", - version: "1.0", - packaging: "jar", - modules: [] - ) - ], - profiles: [], - hasWrapper: true - ) + let alpha = BuildModel(projects: [ + .init(id: "alpha", path: "services/alpha", name: "alpha"), + .init(id: "api", path: "services/alpha/api", name: "alpha-api", parentID: "alpha") + ]) let files = [ root.appendingPathComponent("services/alpha/pom.xml"), root.appendingPathComponent("services/alpha/api/pom.xml"), @@ -118,18 +142,22 @@ struct ProjectRuntimeSettingsTests { workspaceName: "shop", workspaceURL: root, files: files, - mavenProject: alpha + buildSources: [.init(id: "sample:alpha", root: "services/alpha", displayName: "Build root", usesJava: true, model: alpha)], + languageSupports: [ + LanguageSupportDeclaration(id: "java", displayName: "Java", projectFileNames: ["pom.xml"]), + LanguageSupportDeclaration(id: "frontend", displayName: "Frontend", projectFileNames: ["package.json"]) + ] ) #expect(subprojects.map(\.id) == [ "project", - "maven:services/alpha", - "module:services/alpha/api", - "maven:services/beta", - "other:frontend" + "path:services/alpha", + "path:services/alpha/api", + "path:frontend", + "path:services/beta" ]) - #expect(subprojects.first { $0.id == "maven:services/beta" }?.usesJava == true) - #expect(subprojects.first { $0.id == "other:frontend" }?.usesJava == false) + #expect(subprojects.first { $0.id == "path:services/beta" }?.usesJava == true) + #expect(subprojects.first { $0.id == "path:frontend" }?.usesJava == false) #expect( ProjectRuntimeInventory.workspaceRelativePath( modulePath: ".", @@ -157,16 +185,16 @@ struct ProjectRuntimeSettingsTests { @Test @MainActor func persistedSettingsReloadForTheSameProject() { - let store = ProjectRuntimeSettingsTestStore() + let store = InMemoryProjectRuntimeSettingsStore() let root = URL(fileURLWithPath: "/workspace/shop", isDirectory: true) let locator = ProjectRuntimeSettingsTestLocator() - let service = ProjectRuntimeService(runtimeLocator: locator, store: store) + let service = ProjectRuntimeService(runtimeLocator: locator, settingsStore: store) service.openProject(at: root) var settings = ProjectRuntimeSettings(javaHomePath: "/Library/Java/jdk-21") settings.setOverride(path: "services/alpha", javaHomePath: "/Library/Java/jdk-17") service.updateSettings(settings) - let reloaded = ProjectRuntimeService(runtimeLocator: locator, store: store) + let reloaded = ProjectRuntimeService(runtimeLocator: locator, settingsStore: store) reloaded.openProject(at: root) #expect(reloaded.settings.javaHomePath == "/Library/Java/jdk-21") #expect(reloaded.settings.exactOverride(for: "services/alpha")?.javaHomePath == "/Library/Java/jdk-17") @@ -186,7 +214,7 @@ struct ProjectRuntimeSettingsTests { JavaRuntimeCandidate(homePath: "/jdk-21", version: "21", vendor: "Test"), JavaRuntimeCandidate(homePath: "/jdk-25", version: "25", vendor: "Test") ]) - let service = ProjectRuntimeService(runtimeLocator: locator, store: ProjectRuntimeSettingsTestStore()) + let service = ProjectRuntimeService(runtimeLocator: locator, settingsStore: InMemoryProjectRuntimeSettingsStore()) service.openProject(at: URL(fileURLWithPath: "/workspace/shop")) service.updateSettings(ProjectRuntimeSettings(javaHomePath: "/jdk-25")) let runtimes = service.javaLanguageServiceRuntimes() @@ -199,7 +227,7 @@ struct ProjectRuntimeSettingsTests { @Test @MainActor func invalidConfiguredProjectJDKIsNotMaskedByDiscovery() async throws { - let store = ProjectRuntimeSettingsTestStore() + let store = InMemoryProjectRuntimeSettingsStore() let root = URL(fileURLWithPath: "/workspace/shop", isDirectory: true) let discovered = JavaRuntimeCandidate( homePath: "/Library/Java/jdk-21", @@ -210,7 +238,7 @@ struct ProjectRuntimeSettingsTests { validJavaHomes: ["/Library/Java/jdk-21"], discoveredJavaRuntimes: [discovered] ) - let service = ProjectRuntimeService(runtimeLocator: locator, store: store) + let service = ProjectRuntimeService(runtimeLocator: locator, settingsStore: store) service.openProject(at: root) service.updateSettings(ProjectRuntimeSettings(javaHomePath: "/missing-jdk")) await service.refreshAvailableRuntimes() @@ -224,17 +252,7 @@ struct ProjectRuntimeSettingsTests { } } -private final class ProjectRuntimeSettingsTestStore: KeyValueStore, @unchecked Sendable { - private var values: [String: Any] = [:] - - func data(forKey key: String) -> Data? { values[key] as? Data } - func object(forKey key: String) -> Any? { values[key] } - func string(forKey key: String) -> String? { values[key] as? String } - func stringArray(forKey key: String) -> [String]? { values[key] as? [String] } - func set(_ value: Any?, forKey key: String) { values[key] = value } -} - -private struct ProjectRuntimeSettingsTestLocator: RuntimeLocator { +struct ProjectRuntimeSettingsTestLocator: RuntimeLocator { var validJavaHomes: Set? var discoveredJavaRuntimes: [JavaRuntimeCandidate] = [] diff --git a/macos/Tests/LitheTests/ProjectRuntimeTestSupport.swift b/macos/Tests/LitheTests/ProjectRuntimeTestSupport.swift new file mode 100644 index 0000000000..58a93b826d --- /dev/null +++ b/macos/Tests/LitheTests/ProjectRuntimeTestSupport.swift @@ -0,0 +1,35 @@ +import Foundation +import LitheCoreContracts +@testable import Lithe + +/// Runtime selection unit tests use an explicit, isolated source. Persistence +/// and migration are exercised separately against Core and temporary files. +@MainActor +final class InMemoryProjectRuntimeSettingsStore: ProjectRuntimeSettingsStoring { + var values: [URL: ProjectRuntimeSettingsSnapshot] = [:] + var failure: (any Error)? + private var revision = 0 + func load(at workspace: URL, migrateLegacy: Bool) throws -> ProjectRuntimeSettingsSnapshot { + if let failure { throw failure } + return values[workspace] ?? ProjectRuntimeSettingsSnapshot(workspaceURL: workspace, settings: ProjectRuntimeSettings(), revision: "missing") + } + func save(_ settings: ProjectRuntimeSettings, at workspace: URL, + expecting snapshot: ProjectRuntimeSettingsSnapshot) throws -> ProjectRuntimeSettingsSnapshot { + let current = try load(at: workspace, migrateLegacy: false) + guard snapshot.workspaceURL == workspace, current.revision == snapshot.revision else { throw ProjectRuntimeSettingsStoreError.conflict } + revision += 1 + let saved = ProjectRuntimeSettingsSnapshot(workspaceURL: workspace, settings: settings, revision: String(revision), hasSavedToolchain: true) + values[workspace] = saved + return saved + } +} + +@MainActor +extension ProjectRuntimeService { + convenience init(runtimeLocator: any RuntimeLocator, store _: any KeyValueStore, + toolDiscovery: (any RuntimeToolDiscovery)? = nil, + javaSelector: any JavaRuntimeSelecting = RustCoreBridge()) { + self.init(runtimeLocator: runtimeLocator, settingsStore: InMemoryProjectRuntimeSettingsStore(), + toolDiscovery: toolDiscovery, javaSelector: javaSelector) + } +} diff --git a/macos/Tests/LitheTests/RemoteRuntimeDiscoveryTests.swift b/macos/Tests/LitheTests/RemoteRuntimeDiscoveryTests.swift index 8e8ab7e7b3..1b2052f258 100644 --- a/macos/Tests/LitheTests/RemoteRuntimeDiscoveryTests.swift +++ b/macos/Tests/LitheTests/RemoteRuntimeDiscoveryTests.swift @@ -64,7 +64,7 @@ struct RemoteRuntimeDiscoveryTests { let host = FixtureRuntimeHost() let remote = MacRemoteRuntimeLocator(binding: Self.binding, host: host) let local = FixtureLocalRuntimeLocator() - let service = ProjectRuntimeService(runtimeLocator: local, store: FixtureRuntimeStore(), + let service = ProjectRuntimeService(runtimeLocator: local, settingsStore: InMemoryProjectRuntimeSettingsStore(), workspaceRuntime: { $0 == Self.binding.root ? (remote, remote) : nil }) defer { service.closeProject() } service.openProject(at: Self.binding.root) diff --git a/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift b/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift index 0742356b51..26547641d2 100644 --- a/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift +++ b/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift @@ -10,7 +10,153 @@ import Testing @MainActor struct RunConfigurationIntegrationTests { @Test - func savedBuildProviderReferenceWorksWithoutGeneratedConfiguration() throws { + func argumentEditorTextPreservesExactArgvThroughNativeStorageAndCore() throws { + struct Fixture: Decodable { struct Entry: Decodable { let values: [String]; let text: String }; let cases: [Entry] } + let fixture = try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: DeclarativePluginTestSupport.repositoryRoot.appendingPathComponent("shared/fixtures/run-configuration/argument-text.json"))) + let root = FileManager.default.temporaryDirectory.appendingPathComponent("run-argument-text-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let store = MacRunConfigurationStore(core: RustCoreBridge(), storage: MacFileStorage(), preferences: RunTestKeyValueStore()) + let reference = BuildProviderRunReference(pluginID: "dev.example.engine", buildSystemID: "engine", root: ".", run: "task", + projectID: "project", targetID: "target", execution: .task) + let id = try store.saveBuildProviderConfiguration(reference, name: "Arguments", at: root) + for entry in fixture.cases { + #expect(RunArgumentText.format(entry.values) == entry.text) + try store.saveOptions(RunOptions(programArguments: entry.text), configurationID: id, scope: .local, at: root) + let resolved = try #require(store.resolve(at: root, toolchainCandidates: []).configurations.first { $0.configuration.id == id }) + #expect(resolved.options.programArguments == entry.text) + #expect(try store.providerLaunchInputs(at: root, configurationID: id, providerRoot: ".").launchOptions.arguments == entry.values) + } + } + + @Test + func providerRuntimeArgumentRowsAndEntrypointSurviveEditingAndResetThroughNativeStorage() throws { + struct Fixture: Decodable { struct Entry: Decodable { let values: [String] }; let cases: [Entry] } + let fixture = try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: DeclarativePluginTestSupport.repositoryRoot.appendingPathComponent("shared/fixtures/run-configuration/argument-text.json"))) + let root = FileManager.default.temporaryDirectory.appendingPathComponent("provider-editor-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let store = MacRunConfigurationStore(core: RustCoreBridge(), storage: MacFileStorage(), preferences: RunTestKeyValueStore()) + let reference = BuildProviderRunReference(pluginID: "dev.example.engine", buildSystemID: "engine", root: ".", run: "task", + projectID: "project", targetID: "target", execution: .task) + let id = try store.saveBuildProviderConfiguration(reference, name: "Runtime choices", at: root) + let tools: [String: FixedToolchainSelection?] = ["dev.example.engine:compiler": .executable(path: "/fixture/compiler")] + var options = RunOptions(providerRunOptions: BuildProviderRunOptions(entrypoint: "opaque/entry 雪", options: ["offline": .bool(false)], tools: tools)) + for entry in fixture.cases { + options.providerRunOptions?.runtimeArguments = entry.values + try store.saveOptions(options, configurationID: id, scope: .local, at: root) + let read = try store.providerLaunchInputs(at: root, configurationID: id, providerRoot: ".") + #expect(read.launchOptions.runtimeArguments == entry.values) + #expect(read.launchOptions.entrypoint == "opaque/entry 雪") + #expect(read.optionOverrides == ["offline": .bool(false)]) + #expect(read.tools == ["dev.example.engine:compiler": .executable(path: "/fixture/compiler")]) + } + try store.saveOptions(options.resettingProviderRunOptions(), configurationID: id, scope: .local, at: root) + let cleared = try store.providerLaunchInputs(at: root, configurationID: id, providerRoot: ".") + #expect(cleared.launchOptions.runtimeArguments.isEmpty) + #expect(cleared.launchOptions.entrypoint == nil) + #expect(cleared.tools.isEmpty) + #expect(cleared.optionOverrides.isEmpty) + } + + @Test + func providerResetClearsTeamToolsFromTheResolvedEditorDraftWithoutChangingTheTeamDocument() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("provider-reset-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let store = MacRunConfigurationStore(core: RustCoreBridge(), storage: MacFileStorage(), preferences: RunTestKeyValueStore()) + let reference = BuildProviderRunReference(pluginID: "dev.example.engine", buildSystemID: "engine", root: ".", run: "task", + projectID: "project", targetID: "target", execution: .task) + let id = try store.saveBuildProviderConfiguration(reference, name: "Team task", at: root) + let localURL = root.appendingPathComponent(".lithe/run/local.json") + let teamURL = root.appendingPathComponent(".lithe/run/configurations.json") + try FileManager.default.moveItem(at: localURL, to: teamURL) + let teamTools: [String: FixedToolchainSelection?] = ["dev.example.engine:compiler": .executable(path: "/fixture/team compiler"), + "dev.example.engine:vm": .installation(home: "/fixture/team vm")] + try store.saveOptions(RunOptions(providerRunOptions: BuildProviderRunOptions(tools: teamTools)), configurationID: id, scope: .project, at: root) + let teamBytes = try Data(contentsOf: teamURL) + try store.saveOptions(RunOptions(providerRunOptions: BuildProviderRunOptions(runtimeArguments: ["local argument"], + tools: ["dev.example.engine:compiler": .executable(path: "/fixture/local compiler")])), configurationID: id, scope: .local, at: root) + let editor = try #require(store.resolve(at: root, toolchainCandidates: []).configurations.first { $0.configuration.id == id }).options + #expect(editor.providerRunOptions?.tools["dev.example.engine:vm"] == teamTools["dev.example.engine:vm"]) + try store.saveOptions(editor.resettingProviderRunOptions(), configurationID: id, scope: .local, at: root) + let reset = try store.providerLaunchInputs(at: root, configurationID: id, providerRoot: ".") + #expect(reset.tools.isEmpty) + #expect(reset.launchOptions.runtimeArguments.isEmpty) + #expect(try Data(contentsOf: teamURL) == teamBytes) + let reopened = try #require(store.resolve(at: root, toolchainCandidates: []).configurations.first { $0.configuration.id == id }).options + #expect(reopened.providerRunOptions?.tools.count == teamTools.count) + #expect(reopened.providerRunOptions?.tools.values.allSatisfy { $0 == nil } == true) + } + + @Test + func nativeGenerationUsesProviderOwnershipThroughTheRealBridgeWithoutReadingPomsForJavaMembership() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("provider-generation-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: root) } + let data = try Data(contentsOf: DeclarativePluginTestSupport.repositoryRoot.appendingPathComponent("shared/fixtures/run-configuration/provider-project-ownership.json")) + let fixture = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let files = try #require(fixture["files"] as? [String: String]) + for (path, contents) in files { + let file = root.appendingPathComponent(path) + try FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data(contents.utf8).write(to: file) + } + let decoder = JSONDecoder() + let ownershipObject = try #require(fixture["projectOwnership"]) + let ownershipData = try JSONSerialization.data(withJSONObject: ownershipObject) + let ownership = try decoder.decode([LitheCoreContracts.RunProjectOwnership].self, from: ownershipData) + let runsData = try JSONSerialization.data(withJSONObject: try #require(fixture["providerRuns"])) + let runs = try decoder.decode([RunProviderConfiguration].self, from: runsData) + let entryObject = try #require(fixture["javaEntrypoints"]) + let entryData = try JSONSerialization.data(withJSONObject: entryObject) + let entries = try decoder.decode(JavaEntrypoints.self, from: entryData) + let store = MacRunConfigurationStore(core: RustCoreBridge(), storage: MacFileStorage(), preferences: RunTestKeyValueStore()) + _ = try store.generate(at: root, files: files.keys.map { root.appendingPathComponent($0) }, modulePaths: ["wrong-legacy-path"], + javaEntrypoints: entries, projectOwnership: ownership, providerRuns: runs) + let inspected = try RustCoreBridge().inspectRunConfiguration(at: root, checkFingerprint: false, projectOwnership: ownership, providerRuns: runs).get() + let generated = try #require(inspected.generated) + let generatedData = try JSONEncoder().encode(generated) + let document = try #require(JSONSerialization.jsonObject(with: generatedData) as? [String: Any]) + #expect(!FileManager.default.fileExists(atPath: root.appendingPathComponent(".lithe/run/generated.json").path)) + let configurations = try #require(document["configurations"] as? [[String: Any]]) + let expected = try #require(fixture["expected"] as? [String: Any]) + for item in try #require(expected["configurations"] as? [[String: String]]) { + let actual = try #require(configurations.first { $0["id"] as? String == item["id"] }) + #expect(actual["cwd"] as? String == item["cwd"]) + let extensions = try #require(actual["extensions"] as? [String: [String: Any]]) + #expect(extensions["java"]?["module"] as? String == item["module"]) + #expect(extensions["maven"] == nil) + #expect(extensions["java"]?["sourceSet"] as? String == item["sourceSet"]) + } + for item in try #require(expected["services"] as? [[String: Any]]) { + let id = try #require(item["id"] as? String) + let actual = try #require(configurations.first { $0["id"] as? String == id }) + #expect(actual["provider"] as? String == "build.provider") + #expect(actual["execution"] as? String == "service") + let extensions = try #require(actual["extensions"] as? [String: Any]) + #expect(extensions["maven"] == nil) + } + let metadata = try #require(document["generator"] as? [String: Any]) + #expect((metadata["providerFingerprint"] as? String)?.hasPrefix("sha256:") == true) + #expect((metadata["providerRunFingerprint"] as? String)?.hasPrefix("sha256:") == true) + #expect(!store.inspectProviderGeneration(at: root, projectOwnership: ownership, providerRuns: runs.reversed()).diagnostics.contains { $0.code == "staleProviderModel" }) + #expect(store.inspectProviderGeneration(at: root, projectOwnership: ownership, providerRuns: []).diagnostics.contains { $0.code == "staleProviderModel" }) + #expect(!store.inspectProviderGeneration(at: root, projectOwnership: ownership).diagnostics.contains { $0.code == "staleProviderModel" }) + #expect(!store.inspectProviderGeneration(at: root, projectOwnership: ownership.reversed()).diagnostics.contains { $0.code == "staleProviderModel" }) + #expect(store.inspectProviderGeneration(at: root, projectOwnership: []).diagnostics.contains { $0.code == "staleProviderModel" }) + #expect(!store.inspectProviderGeneration(at: root, projectOwnership: ownership).diagnostics.contains { $0.code == "staleProviderModel" }) + let savedRequirements = try #require(inspected.toolchainRequirements) + let requirementsData = try JSONEncoder().encode(savedRequirements) + let requirements = try #require(JSONSerialization.jsonObject(with: requirementsData) as? [String: Any]) + let tools = try #require(requirements["toolchains"] as? [String: [String: Any]]) + #expect(tools["project-jdk"]?["minimumVersion"] as? String == expected["minimumJavaVersion"] as? String) + #expect(tools["project-maven"]?["minimumVersion"] as? String == expected["minimumMavenVersion"] as? String) + #expect(tools["project-maven"]?["wrapper"] == nil) + + } + + @Test(arguments: [BuildExecution.application, .service, .task]) + func savedBuildProviderReferenceWorksWithoutGeneratedConfiguration(execution: BuildExecution) throws { let core = RustCoreBridge() try #require(core.isAvailable) let root = FileManager.default.temporaryDirectory.appendingPathComponent("provider-config-" + UUID().uuidString) @@ -18,13 +164,18 @@ struct RunConfigurationIntegrationTests { try FileManager.default.createDirectory(at: root.appendingPathComponent("app"), withIntermediateDirectories: true) let store = MacRunConfigurationStore(core: core, storage: MacFileStorage(), preferences: RunTestKeyValueStore()) let reference = BuildProviderRunReference(pluginID: "dev.example.gradle", buildSystemID: "gradle", root: "app", - run: "application", projectID: "p", targetID: "t", userArguments: ["a b", "--x=one two"]) + run: "application", projectID: "p", targetID: "t", userArguments: ["a b", "--x=one two"], execution: execution == .application ? nil : execution) let id = try store.saveBuildProviderConfiguration(reference, name: "Main", at: root) #expect(store.inspect(at: root).status == .ready) #expect(store.inspect(at: root).requiresGeneration) #expect(!FileManager.default.fileExists(atPath: root.appendingPathComponent(".lithe/run/generated.json").path)) let resolved = try store.resolve(at: root, toolchainCandidates: []) #expect(resolved.configurations.first?.configuration.buildProvider == reference) + #expect(resolved.configurations.first?.configuration.execution.rawValue == execution.rawValue) + let inputs = try store.providerLaunchInputs(at: root, configurationID: id, providerRoot: "app") + #expect(inputs.reference == reference) + #expect(inputs.launchOptions.execution == execution) + #expect(inputs.launchOptions.arguments == reference.userArguments) #expect(resolved.configurations.first?.configuration.id == id) #expect(try store.saveBuildProviderConfiguration(reference, name: "Renamed", at: root) == id) #expect(try store.resolve(at: root, toolchainCandidates: []).configurations.count == 1) @@ -39,11 +190,56 @@ struct RunConfigurationIntegrationTests { #expect(!store.inspect(at: root).requiresGeneration) } + @Test + func portableProviderChoicesRoundTripThroughNativeStorageWithoutLosingArgumentBoundaries() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("provider-choices-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root.appendingPathComponent("app/work"), withIntermediateDirectories: true) + let fixtureData = try Data(contentsOf: DeclarativePluginTestSupport.repositoryRoot.appendingPathComponent("shared/fixtures/run-configuration/build-provider-options.json")) + let fixture = try #require(JSONSerialization.jsonObject(with: fixtureData) as? [String: Any]) + func decode(_ key: String, as _: T.Type) throws -> T { + try JSONDecoder().decode(T.self, from: JSONSerialization.data(withJSONObject: try #require(fixture[key]))) + } + let reference = try decode("reference", as: BuildProviderRunReference.self) + let choices = try decode("choices", as: BuildProviderRunOptions.self) + let store = MacRunConfigurationStore(core: RustCoreBridge(), storage: MacFileStorage(), preferences: RunTestKeyValueStore()) + let id = try store.saveBuildProviderConfiguration(reference, name: "Service", at: root) + var options = RunOptions(workingDirectoryPath: "app/work", programArguments: "--application=\"one two\" 雪", + environment: ["EXAMPLE": "value"], providerRunOptions: choices) + try store.saveOptions(options, configurationID: id, scope: .local, at: root) + let resolved = try store.resolve(at: root, toolchainCandidates: []) + #expect(resolved.configurations.first?.options.providerRunOptions == choices) + let inputs = try store.providerLaunchInputs(at: root, configurationID: id, providerRoot: "app") + #expect(inputs.reference == reference) + #expect(inputs.launchOptions.runtimeArguments == choices.runtimeArguments) + #expect(inputs.launchOptions.entrypoint == choices.entrypoint) + #expect(inputs.optionOverrides == choices.options) + #expect(inputs.tools == choices.tools.compactMapValues { $0 }) + #expect(inputs.launchOptions.arguments == ["--application=one two", "雪"]) + #expect(inputs.launchOptions.environment == options.environment) + #expect(inputs.launchOptions.workingDirectory == "app/work") + #expect(inputs.opaqueReferenceArguments == nil) + // Native preference copies also keep the exact arrays and option values. + let encoded = try JSONEncoder().encode(options) + #expect(try JSONDecoder().decode(RunOptions.self, from: encoded) == options) + #expect(try store.saveBuildProviderConfiguration(reference, name: "Renamed", at: root) == id) + #expect(try store.providerLaunchInputs(at: root, configurationID: id, providerRoot: "app") == inputs) + options.providerRunOptions = BuildProviderRunOptions(tools: Dictionary(uniqueKeysWithValues: + choices.tools.keys.map { ($0, Optional.none) })) + try store.saveOptions(options, configurationID: id, scope: .local, at: root) + let reset = try store.providerLaunchInputs(at: root, configurationID: id, providerRoot: "app") + #expect(reset.launchOptions.runtimeArguments.isEmpty) + #expect(reset.launchOptions.entrypoint == nil) + #expect(reset.optionOverrides.isEmpty) + #expect(reset.tools.isEmpty) + #expect(!FileManager.default.fileExists(atPath: root.appendingPathComponent(".lithe/run/generated.json").path)) + } + @Test func readySavedTasksStillRequestDetectionUntilGenerationSucceeds() async { let fixture = makeFixture(status: .ready) fixture.operations.requiresGeneration = true - await fixture.service.loadProject(at: fixture.root, files: [], mavenProject: nil, snapshotID: UUID()) + await fixture.service.loadProject(at: fixture.root, files: [], snapshotID: UUID()) #expect(fixture.service.configurationStatus == .ready) #expect(fixture.service.requiresConfigurationGeneration) await fixture.service.generateRunConfigurations(automatic: true) @@ -125,7 +321,7 @@ struct RunConfigurationIntegrationTests { let fixture = makeFixture(status: .missing) let saved = ProjectToolchainSelection(javaHomePath: "/fixture/project-jdk") fixture.operations.inspectionToolchain = saved - await fixture.service.loadProject(at: fixture.root, files: [], mavenProject: nil) + await fixture.service.loadProject(at: fixture.root, files: []) #expect(fixture.service.projectLoadState.hasLoadedDocuments(for: fixture.root)) // Settings mirror these defaults before any configuration is generated. #expect(fixture.service.savedProjectToolchain == saved) @@ -151,7 +347,7 @@ struct RunConfigurationIntegrationTests { func generationRefusesUnsupportedVersionsEvenWithoutTheConfirmationEntry() async { let fixture = makeFixture(status: .invalid("Unsupported configuration version")) fixture.operations.inspectionRecoveryAction = .upgradeApplication - await fixture.service.loadProject(at: fixture.root, files: [], mavenProject: nil, snapshotID: UUID()) + await fixture.service.loadProject(at: fixture.root, files: [], snapshotID: UUID()) let feature = RunFeatureModel(service: fixture.service) feature.requestRunConfigurationGeneration() #expect(!feature.isGenerationConfirmationPresented) @@ -166,15 +362,23 @@ struct RunConfigurationIntegrationTests { @Test func projectPreparationDoesNotBlockStandaloneOrMavenGoalLaunchers() { - func configuration(_ kind: RunConfigurationKind, reactor: String? = nil) -> RunConfiguration { + func configuration(_ kind: RunConfigurationKind, reactor: String? = nil, source: String? = nil) -> RunConfiguration { RunConfiguration(id: "sample", name: "Sample", kind: kind, modulePath: nil, - mainClass: "sample.Main", mavenReactorPath: reactor) + mainClass: "sample.Main", sourcePath: source, mavenReactorPath: reactor) } #expect(!configuration(.javaMain).usesJavaProjectPreparation) #expect(!configuration(.currentFile, reactor: ".").usesJavaProjectPreparation) #expect(!configuration(.mavenModule, reactor: ".").usesJavaProjectPreparation) #expect(configuration(.javaMain, reactor: ".").usesJavaProjectPreparation) - #expect(configuration(.springBoot, reactor: ".").usesJavaProjectPreparation) + #expect(!configuration(.springBoot, reactor: ".").usesJavaProjectPreparation) + #expect(configuration(.springBoot, reactor: ".", source: "src/Main.java").usesJavaProjectPreparation) + #expect(!configuration(.springBoot, reactor: ".").usesProviderConnectionDebug) + #expect(!configuration(.mavenFramework(.quarkus), reactor: ".").usesProviderConnectionDebug) + let service = RunConfiguration(id: "imported:service", name: "Service", kind: .process(provider: "build.provider"), + modulePath: nil, mainClass: nil, buildProvider: .init(pluginID: "dev.example.engine", buildSystemID: "engine", root: ".", + run: "serve", projectID: "project", targetID: "target", execution: .service)) + #expect(service.usesProviderConnectionDebug) + #expect(!RunConfigurationKind.mavenModule.capabilities.contains(.jdwpDebug)) } @Test @@ -721,6 +925,28 @@ struct RunConfigurationIntegrationTests { #expect(source == second) } + @Test + func providerJavaDebugSourceUsesTheGenericInstanceRootWithoutCrossingIntoAnotherProject() { + let root = URL(fileURLWithPath: "/workspace/providers", isDirectory: true) + let wrong = root.appendingPathComponent("app/src/main/java/com/acme/Main.java") + let current = root.appendingPathComponent("nested/engine/app/src/main/java/com/acme/Main.java") + let configuration = RunConfiguration(id: "java-main:engine", name: "Engine Main", kind: .javaMain, + modulePath: "app", mainClass: "com.acme.Main", mavenReactorPath: "obsolete-owner", + buildProject: .init(pluginID: "dev.example.engine", buildSystemID: "engine", root: "nested/engine", projectID: "opaque-project")) + let resolver = DebugLaunchSourceResolver() + #expect(resolver.resolve(configuration: configuration, activeDocumentURL: wrong, + projectFiles: [wrong, current], workspaceURL: root) == current) + // The current module has no source in the second snapshot. A matching + // class in another instance is not a replacement launch target. + #expect(resolver.resolve(configuration: configuration, activeDocumentURL: wrong, + projectFiles: [wrong], workspaceURL: root) == nil) + let workspaceOwner = RunConfiguration(id: "java-main:root", name: "Root Main", kind: .javaMain, + modulePath: "app", mainClass: "com.acme.Main", mavenReactorPath: "obsolete-owner", + buildProject: .init(pluginID: "dev.example.other", buildSystemID: "other", root: ".", projectID: "other-project")) + #expect(resolver.resolve(configuration: workspaceOwner, activeDocumentURL: current, + projectFiles: [wrong, current], workspaceURL: root) == wrong) + } + @Test func currentFileDebugStillUsesTheActiveEditorDocument() throws { let root = URL(fileURLWithPath: "/workspace/current-file", isDirectory: true) @@ -1070,9 +1296,9 @@ struct RunConfigurationIntegrationTests { #expect(pythonPlan.launchPlan.arguments == ["-m", "pytest", "tests/test_api.py::test_health"]) let javaProvider = try #require(registry.provider(id: "java")) - let javaPlan = try javaProvider.testPlan(scope: .file(files[0]), workspaceURL: root) - #expect(javaPlan.launchPlan.toolchainID == "project-maven") - #expect(javaPlan.launchPlan.arguments == ["-Dstyle.color=always", "-Dtest=UserServiceTest", "test"]) + #expect(throws: LanguageTestPlanError.unsupportedProvider(javaProvider.descriptor.displayName)) { + try javaProvider.testPlan(scope: .file(files[0]), workspaceURL: root) + } let gradleMethodPlan = try javaProvider.testPlan( scope: .testCase( @@ -1088,7 +1314,8 @@ struct RunConfigurationIntegrationTests { "test", "--no-daemon", "--tests", "example.UserServiceTest.logsIn", ]) - let mavenMethodPlan = try javaProvider.testPlan( + #expect(throws: LanguageTestPlanError.unsupportedProvider(javaProvider.descriptor.displayName)) { + try javaProvider.testPlan( scope: .testCase( identifier: "example.UserServiceTest#logsIn()", fileURL: files[0] @@ -1098,9 +1325,7 @@ struct RunConfigurationIntegrationTests { projectFiles: [root.appendingPathComponent("pom.xml"), files[0]] ) ) - #expect(mavenMethodPlan.launchPlan.arguments == [ - "-Dstyle.color=always", "-Dtest=example.UserServiceTest#logsIn", "test", - ]) + } let gradlePlan = try javaProvider.testPlan( scope: .workspace, @@ -1151,10 +1376,11 @@ struct RunConfigurationIntegrationTests { let javaProvider = try #require(registry.provider(id: "java")) let nodeProvider = try #require(registry.provider(id: "node")) + // Every Java source is only inventory for JDT semantic discovery; + // a plain workspace still cannot acquire a Maven command plan. #expect(javaProvider.discoverTests(context: LanguageTestContext( - workspaceURL: root, - projectFiles: [javaFile] - )).isEmpty) + workspaceURL: root, projectFiles: [javaFile] + )).last?.fileURL == javaFile) #expect(nodeProvider.discoverTests(context: LanguageTestContext( workspaceURL: root, projectFiles: [nodeFile] @@ -2644,11 +2870,11 @@ struct RunConfigurationIntegrationTests { plans: [configuration.id: plan] ) - await fixture.service.loadProject(at: fixture.root, files: [], mavenProject: nil) + await fixture.service.loadProject(at: fixture.root, files: []) fixture.service.run(configuration: configuration, currentFileURL: nil) #expect(fixture.process.isRunning) let stopCountAfterStart = fixture.process.stopCount - await fixture.service.loadProject(at: fixture.root, files: [], mavenProject: nil) + await fixture.service.loadProject(at: fixture.root, files: []) #expect(fixture.process.isRunning) #expect(fixture.process.stopCount == stopCountAfterStart) } @@ -2660,7 +2886,6 @@ struct RunConfigurationIntegrationTests { await fixture.service.loadProject( at: fixture.root, files: [], - mavenProject: fixture.mavenProject, snapshotID: UUID() ) @@ -2694,7 +2919,6 @@ struct RunConfigurationIntegrationTests { await fixture.service.loadProject( at: fixture.root, files: [], - mavenProject: fixture.mavenProject, snapshotID: UUID() ) fixture.service.run(configuration: configuration, currentFileURL: nil) @@ -2737,7 +2961,6 @@ struct RunConfigurationIntegrationTests { await fixture.service.loadProject( at: fixture.root, files: [], - mavenProject: fixture.mavenProject, snapshotID: UUID() ) fixture.service.runAllServices(javaLaunches: [configuration.id: target]) @@ -2772,7 +2995,7 @@ struct RunConfigurationIntegrationTests { plans: [configuration.id: plan] ) - await fixture.service.loadProject(at: fixture.root, files: [], mavenProject: nil) + await fixture.service.loadProject(at: fixture.root, files: []) fixture.service.run(configuration: configuration, currentFileURL: nil) let request = try #require(fixture.process.requests.last) @@ -2796,8 +3019,7 @@ struct RunConfigurationIntegrationTests { await fixture.service.loadProject( at: fixture.root, - files: [source], - mavenProject: nil + files: [source] ) #expect(fixture.service.configurations == [current]) fixture.service.run(configuration: current, currentFileURL: source) @@ -2848,8 +3070,7 @@ struct RunConfigurationIntegrationTests { await fixture.service.loadProject( at: fixture.root, - files: [], - mavenProject: fixture.mavenProject + files: [] ) fixture.service.runAllServices() @@ -2906,8 +3127,7 @@ struct RunConfigurationIntegrationTests { await fixture.service.loadProject( at: fixture.root, - files: [], - mavenProject: fixture.mavenProject + files: [] ) fixture.service.runAllServices() @@ -2964,8 +3184,7 @@ struct RunConfigurationIntegrationTests { await fixture.service.loadProject( at: fixture.root, - files: [], - mavenProject: fixture.mavenProject + files: [] ) fixture.service.runAllServices() @@ -3015,7 +3234,7 @@ struct RunConfigurationIntegrationTests { ] ) - await fixture.service.loadProject(at: fixture.root, files: [], mavenProject: fixture.mavenProject) + await fixture.service.loadProject(at: fixture.root, files: []) #expect(fixture.service.serviceURL(for: argumentService)?.absoluteString == "http://127.0.0.1:5173") #expect(fixture.service.serviceURL(for: environmentService)?.absoluteString == "http://127.0.0.1:8000") @@ -3062,7 +3281,7 @@ struct RunConfigurationIntegrationTests { ] ) - await fixture.service.loadProject(at: fixture.root, files: [], mavenProject: fixture.mavenProject) + await fixture.service.loadProject(at: fixture.root, files: []) #expect(fixture.service.serviceURL(for: unknown) == nil) #expect(fixture.service.serviceURL(for: invalid) == nil) @@ -3110,8 +3329,7 @@ struct RunConfigurationIntegrationTests { await fixture.service.loadProject( at: fixture.root, - files: [], - mavenProject: nil + files: [] ) fixture.service.runAllServices() @@ -3144,8 +3362,7 @@ struct RunConfigurationIntegrationTests { await fixture.service.loadProject( at: fixture.root, - files: [], - mavenProject: nil + files: [] ) fixture.service.startConfiguration(frontend) @@ -3162,20 +3379,9 @@ struct RunConfigurationIntegrationTests { store: RunTestKeyValueStore() ) runtime.openProject(at: root) - let project = MavenProject( - rootURL: root, - pomURL: root.appendingPathComponent("pom.xml"), - groupID: nil, - artifactID: "fixture", - version: nil, - packaging: "jar", - modules: [], - profiles: [], - hasWrapper: false - ) #expect(runtime.javaExecutableURL(overridePath: "/local/JDK 21")?.path == "/local/JDK 21/bin/java") - #expect(runtime.mavenExecutable(for: project, overridePath: "/local/Maven 3.9/bin/mvn")?.path == "/local/Maven 3.9/bin/mvn") + #expect(runtime.mavenExecutable(at: root, overridePath: "/local/Maven 3.9/bin/mvn")?.path == "/local/Maven 3.9/bin/mvn") #expect(runtime.environment(for: .maven, javaHomeOverride: "/local/Maven JDK")["JAVA_HOME"] == "/local/Maven JDK") } @@ -3223,7 +3429,6 @@ struct RunConfigurationIntegrationTests { await fixture.service.loadProject( at: fixture.root, files: [], - mavenProject: fixture.mavenProject, snapshotID: UUID() ) await fixture.service.generateRunConfigurations() @@ -3492,8 +3697,9 @@ struct RunConfigurationIntegrationTests { ) _ = try store.generate(at: root, files: [], modulePaths: [], javaEntrypoints: nil) - await service.loadProject(at: root, files: [], mavenProject: nil) + await service.loadProject(at: root, files: []) let configuration = try #require(service.configurations.first { $0.id == "user:go-api" }) + service.run(configuration: configuration, currentFileURL: nil) let request = try #require(process.requests.last) @@ -3681,8 +3887,7 @@ struct RunConfigurationIntegrationTests { await fixture.service.loadProject( at: fixture.root, - files: [], - mavenProject: fixture.mavenProject + files: [] ) #expect(fixture.service.selectedConfigurationID == second.id) @@ -3717,8 +3922,7 @@ struct RunConfigurationIntegrationTests { await fixture.service.loadProject( at: fixture.root, - files: [], - mavenProject: fixture.mavenProject + files: [] ) let projected = try #require( @@ -3735,7 +3939,7 @@ struct RunConfigurationIntegrationTests { status: .ready, effective: [EffectiveRunConfiguration(configuration: .currentFile, options: JavaRunOptions())] ) - await fixture.service.loadProject(at: fixture.root, files: [], mavenProject: fixture.mavenProject) + await fixture.service.loadProject(at: fixture.root, files: []) let created = fixture.service.createConfiguration( RunConfigurationDraft( @@ -3769,7 +3973,7 @@ struct RunConfigurationIntegrationTests { effective: [current, EffectiveRunConfiguration(configuration: backend, options: JavaRunOptions())], preferences: preferences ) - await first.service.loadProject(at: first.root, files: [], mavenProject: first.mavenProject) + await first.service.loadProject(at: first.root, files: []) first.service.select(backend) let reopened = makeFixture( @@ -3777,7 +3981,7 @@ struct RunConfigurationIntegrationTests { effective: [current, EffectiveRunConfiguration(configuration: backend, options: JavaRunOptions())], preferences: preferences ) - await reopened.service.loadProject(at: reopened.root, files: [], mavenProject: reopened.mavenProject) + await reopened.service.loadProject(at: reopened.root, files: []) #expect(reopened.service.selectedConfigurationID == backend.id) } @@ -3803,10 +4007,10 @@ struct RunConfigurationIntegrationTests { ) let oldLoad = Task { - await service.loadProject(at: oldRoot, files: [], mavenProject: nil) + await service.loadProject(at: oldRoot, files: []) } #expect(await operations.waitUntilBlocked()) - await service.loadProject(at: newRoot, files: [], mavenProject: nil) + await service.loadProject(at: newRoot, files: []) operations.releaseBlockedInspection() await oldLoad.value @@ -3835,10 +4039,10 @@ struct RunConfigurationIntegrationTests { ) let snapshotID = UUID() - await service.loadProject(at: root, files: [], mavenProject: nil, snapshotID: snapshotID) + await service.loadProject(at: root, files: [], snapshotID: snapshotID) let generation = Task { await service.generateRunConfigurations() } #expect(await operations.waitUntilBlocked()) - await service.loadProject(at: root, files: [], mavenProject: nil, snapshotID: snapshotID) + await service.loadProject(at: root, files: [], snapshotID: snapshotID) operations.releaseGeneration() await generation.value @@ -3877,8 +4081,7 @@ struct RunConfigurationIntegrationTests { ) await fixture.service.loadProject( at: fixture.root, - files: [], - mavenProject: fixture.mavenProject + files: [] ) let options = RunOptions( javaHomePath: "toolchains/jdk", @@ -3888,7 +4091,6 @@ struct RunConfigurationIntegrationTests { #expect(fixture.service.saveEditorChanges( options, - toolchain: ProjectToolchainSelection(), for: configuration, scope: .project )) @@ -3915,34 +4117,26 @@ struct RunConfigurationIntegrationTests { ) await fixture.service.loadProject( at: fixture.root, - files: [], - mavenProject: fixture.mavenProject + files: [] ) let options = RunOptions(programArguments: "--dev") - let toolchain = ProjectToolchainSelection( - javaHomePath: "/opt/jdk-21", - mavenExecutablePath: "/opt/apache-maven", - mavenJavaHomePath: "/opt/jdk-17" - ) + #expect(fixture.service.saveEditorChanges( options, - toolchain: toolchain, for: configuration, scope: .local )) #expect(fixture.operations.editorSaveCalls == 1) #expect(fixture.operations.savedOptions == [options]) #expect(fixture.operations.savedScopes == [.local]) - #expect(fixture.operations.savedToolchains == [toolchain]) + #expect(fixture.operations.savedToolchains == [ProjectToolchainSelection()]) } @Test func editorSaveKeepsTheEffectiveProjectDefaults() async { - // Core rewrites the project defaults with every editor save. Resolving a - // layer without saved defaults yields an empty selection; writing it would - // turn "never saved" into explicit automatic defaults and drop the JDK the - // runtime settings still hold. + // Never manufacture project defaults from a separate form. External + // updates made after loading must be read again before an editor save. let configuration = JavaRunConfiguration( id: "spring", name: "Spring", @@ -3950,26 +4144,26 @@ struct RunConfigurationIntegrationTests { modulePath: ".", mainClass: nil ) - let mirror = ProjectToolchainSelection(javaHomePath: "/fixture/mirrored-jdk") let unsaved = makeFixture( status: .ready, effective: [EffectiveRunConfiguration(configuration: configuration, options: RunOptions(), source: .generated)] ) - await unsaved.service.loadProject(at: unsaved.root, files: [], mavenProject: unsaved.mavenProject) + await unsaved.service.loadProject(at: unsaved.root, files: []) #expect(unsaved.service.savedProjectToolchain == nil) - #expect(unsaved.service.saveEditorChanges(RunOptions(), toolchain: mirror, for: configuration, scope: .local)) - #expect(unsaved.operations.savedToolchains == [mirror]) - #expect(unsaved.service.savedProjectToolchain == mirror) + #expect(unsaved.service.saveEditorChanges(RunOptions(), for: configuration, scope: .local)) + #expect(unsaved.operations.savedToolchains == [ProjectToolchainSelection()]) + #expect(unsaved.service.savedProjectToolchain == ProjectToolchainSelection()) - // Once the local layer holds defaults, they win over a stale mirror. + // Current on-disk defaults win over this service's loaded snapshot. let saved = ProjectToolchainSelection(javaHomePath: "/fixture/saved-jdk", mavenExecutablePath: "mvnw") let stored = makeFixture( status: .ready, effective: [EffectiveRunConfiguration(configuration: configuration, options: RunOptions(), source: .generated)] ) + await stored.service.loadProject(at: stored.root, files: []) + // A different editor saves after this service loaded its documents. stored.operations.inspectionToolchain = saved - await stored.service.loadProject(at: stored.root, files: [], mavenProject: stored.mavenProject) - #expect(stored.service.saveEditorChanges(RunOptions(), toolchain: mirror, for: configuration, scope: .local)) + #expect(stored.service.saveEditorChanges(RunOptions(), for: configuration, scope: .local)) #expect(stored.operations.savedToolchains == [saved]) } @@ -3993,14 +4187,12 @@ struct RunConfigurationIntegrationTests { ) await fixture.service.loadProject( at: fixture.root, - files: [], - mavenProject: fixture.mavenProject + files: [] ) fixture.operations.resolveFailureMessage = "Injected reload failure." #expect(!fixture.service.saveEditorChanges( RunOptions(programArguments: "--new"), - toolchain: ProjectToolchainSelection(), for: configuration, scope: .local )) @@ -4010,33 +4202,35 @@ struct RunConfigurationIntegrationTests { ) == true) } - @Test - func resettingOptionsUsesTheCombinedEditorSave() async { + @Test(arguments: [false, true]) + func resettingOptionsUsesTheCombinedEditorSave(provider: Bool) async { let configuration = JavaRunConfiguration( id: "spring", name: "Spring", - kind: .springBoot, + kind: provider ? .process(provider: "build.provider") : .springBoot, + execution: .service, modulePath: ".", - mainClass: nil + mainClass: nil, + buildProvider: provider ? BuildProviderRunReference(pluginID: "dev.example", buildSystemID: "engine", root: ".", + run: "serve", projectID: "p", targetID: "t", execution: .service) : nil ) let fixture = makeFixture( status: .ready, effective: [EffectiveRunConfiguration( configuration: configuration, - options: RunOptions(programArguments: "--dev"), + options: RunOptions(programArguments: "--dev", providerRunOptions: provider ? BuildProviderRunOptions(tools: ["dev.example:compiler": .executable(path: "/fixture/compiler")]) : nil), source: .local )] ) await fixture.service.loadProject( at: fixture.root, - files: [], - mavenProject: fixture.mavenProject + files: [] ) fixture.service.resetOptions(for: configuration) #expect(fixture.operations.editorSaveCalls == 1) - #expect(fixture.operations.savedOptions == [RunOptions()]) + #expect(fixture.operations.savedOptions == [RunOptions(providerRunOptions: provider ? BuildProviderRunOptions(tools: ["dev.example:compiler": nil]) : nil)]) #expect(fixture.operations.savedScopes == [.local]) } @@ -4130,31 +4324,8 @@ struct RunConfigurationIntegrationTests { javaMavenOperations: RunTestJavaMavenOperations(), runConfigurationOperations: operations ) - let modules = ["backend", "worker"].map { path in - MavenModule( - relativePath: path, - url: root.appendingPathComponent(path), - groupID: nil, - artifactID: path, - version: nil, - packaging: "jar", - modules: [] - ) - } - let project = MavenProject( - rootURL: root, - pomURL: root.appendingPathComponent("pom.xml"), - groupID: nil, - artifactID: "fixture", - version: nil, - packaging: "pom", - modules: modules, - profiles: [], - hasWrapper: false - ) return RunServiceFixture( root: root, - mavenProject: project, service: service, operations: operations, process: process, @@ -4245,7 +4416,6 @@ private struct LanguageServerReliabilityHarness { @MainActor private struct RunServiceFixture { let root: URL - let mavenProject: MavenProject let service: JavaRunService let operations: RecordingRunConfigurationOperations let process: RecordingStreamingProcess @@ -4305,7 +4475,8 @@ private final class RecordingRunConfigurationOperations: RunConfigurationOperati at projectURL: URL, files: [URL], modulePaths: [String], - javaEntrypoints: JavaEntrypoints? + javaEntrypoints: JavaEntrypoints?, + projectOwnership: [RunProjectOwnership]? = nil ) throws -> RunConfigurationGenerationResult { generateCalls += 1 return RunConfigurationGenerationResult(entryCount: generationEntryCount ?? effective.count) @@ -4422,7 +4593,8 @@ private final class BlockingInspectionOperations: RunConfigurationOperations, @u at projectURL: URL, files: [URL], modulePaths: [String], - javaEntrypoints: JavaEntrypoints? + javaEntrypoints: JavaEntrypoints?, + projectOwnership: [RunProjectOwnership]? = nil ) throws -> RunConfigurationGenerationResult { RunConfigurationGenerationResult(entryCount: 0) } @@ -4470,7 +4642,8 @@ private final class BlockingGenerationOperations: RunConfigurationOperations, @u at projectURL: URL, files: [URL], modulePaths: [String], - javaEntrypoints: JavaEntrypoints? + javaEntrypoints: JavaEntrypoints?, + projectOwnership: [RunProjectOwnership]? = nil ) throws -> RunConfigurationGenerationResult { didBlock.open() _ = release.waitSynchronously() @@ -5426,8 +5599,6 @@ private final class RunTestKeyValueStore: KeyValueStore, @unchecked Sendable { } private struct RunTestJavaMavenOperations: JavaMavenOperations { - func scanMavenProject(at rootURL: URL, files: [URL]) -> MavenProject? { nil } - func mavenDiagnostics(output: String, projectRoot: URL) -> [MavenBuildIssue] { [] } func codeVision(at rootURL: URL, targetPath: String, paths: [String]) -> [JavaCodeVisionValue] { [] } func className(source: String, simpleName: String) -> String? { nil } func sourceDefinition(source: String, declarationName: String, memberName: String?) -> (line: Int, utf16Column: Int)? { nil } @@ -5437,49 +5608,6 @@ private struct RunTestJavaMavenOperations: JavaMavenOperations { @Suite("Core payload decoding") struct CorePayloadDecodingTests { - /// The core spells Maven coordinates the way Maven does -- `groupId` and - /// `artifactId`. Swift spells the same properties with a capital D, and - /// `artifactId` is non-optional, so a missing key mapping made the whole - /// scan decode to nil and the Maven panel claimed the project had no pom. - /// The failure is silent: the bridge discards decoding errors. - @Test - func mavenScanDecodesTheCoordinateSpellingTheCoreEmits() throws { - let json = """ - { - "relativePath": ".", - "groupId": "com.lithe.demo", - "artifactId": "full-stack-demo", - "version": "1.0.0-SNAPSHOT", - "packaging": "pom", - "hasWrapper": true, - "profiles": [], - "modules": [ - { - "groupId": "com.lithe.demo", - "artifactId": "backend-api", - "relativePath": "backend-api", - "version": "1.0.0-SNAPSHOT", - "packaging": "jar", - "modules": [] - } - ] - } - """ - - let payload = try JSONDecoder().decode( - RustCoreBridge.MavenScanPayload.self, - from: Data(json.utf8) - ) - let project = payload.makeProject( - workspaceRootURL: FileManager.default.temporaryDirectory - .appendingPathComponent("lithe-maven-core-payload", isDirectory: true) - ) - - #expect(project.artifactID == "full-stack-demo") - #expect(project.modules.map(\.artifactID) == ["backend-api"]) - #expect(project.modules.map(\.relativePath) == ["backend-api"]) - } - /// Same root cause, quieter symptom: `hunkId` decoded as nil left every /// diff row unattached to its hunk instead of failing outright. @Test diff --git a/macos/Tests/LitheTests/RunEntryPointTests.swift b/macos/Tests/LitheTests/RunEntryPointTests.swift index c625603c23..6c1106bc66 100644 --- a/macos/Tests/LitheTests/RunEntryPointTests.swift +++ b/macos/Tests/LitheTests/RunEntryPointTests.swift @@ -1154,7 +1154,8 @@ private final class ReadyRunConfigurationOperations: RunConfigurationOperations, at projectURL: URL, files: [URL], modulePaths: [String], - javaEntrypoints: JavaEntrypoints? + javaEntrypoints: JavaEntrypoints?, + projectOwnership: [RunProjectOwnership]? = nil ) throws -> RunConfigurationGenerationResult { RunConfigurationGenerationResult(entryCount: 1) } @@ -1171,20 +1172,21 @@ private final class ReadyRunConfigurationOperations: RunConfigurationOperations, ) /// A service configuration so `runAllServiceConfigurations` has something to - /// launch after a deferred resume. + /// launch after a deferred resume. This fixture tests inventory deferral; + /// Maven preparation has its own model, Provider and lifecycle integration tests. static let serviceEntryPoint = RunConfiguration( - id: "spring-boot:demo.App", - name: "App (Spring Boot)", - kind: .mavenFramework(.springBoot), + id: "fixture-service:demo.App", + name: "App Service", + kind: .process(provider: "fixture.service"), execution: .service, modulePath: nil, mainClass: "demo.App" ) static let serviceEntryPointB = RunConfiguration( - id: "spring-boot:demo.OtherApp", + id: "fixture-service:demo.OtherApp", name: "Other Service", - kind: .mavenFramework(.springBoot), + kind: .process(provider: "fixture.service"), execution: .service, modulePath: nil, mainClass: "demo.OtherApp" @@ -1283,6 +1285,12 @@ private final class InspectionGatedRunConfigurationOperations: RunConfigurationO releases.forEach { $0.open() } } + // Generation identity checks have their own tests; this fake gates only + // workspace inventory inspections, never unrelated model validation. + func inspectProviderGeneration(at _: URL, projectOwnership _: [RunProjectOwnership]) -> ProjectRunConfigurationInspection { + ProjectRunConfigurationInspection(status: .ready, diagnostics: []) + } + func inspect(at projectURL: URL) -> ProjectRunConfigurationInspection { lock.lock() inspectCalls += 1 @@ -1300,7 +1308,8 @@ private final class InspectionGatedRunConfigurationOperations: RunConfigurationO at projectURL: URL, files: [URL], modulePaths: [String], - javaEntrypoints: JavaEntrypoints? + javaEntrypoints: JavaEntrypoints?, + projectOwnership: [RunProjectOwnership]? = nil ) throws -> RunConfigurationGenerationResult { RunConfigurationGenerationResult(entryCount: 1) } @@ -1416,6 +1425,12 @@ private final class InventoryRecordingGatedRunConfigurationOperations: RunConfig releases.forEach { $0.open() } } + // Generation identity checks have their own tests; this fake gates only + // workspace inventory inspections, never unrelated model validation. + func inspectProviderGeneration(at _: URL, projectOwnership _: [RunProjectOwnership]) -> ProjectRunConfigurationInspection { + ProjectRunConfigurationInspection(status: .ready, diagnostics: []) + } + func inspect(at projectURL: URL) -> ProjectRunConfigurationInspection { lock.lock() inspectCalls += 1 @@ -1430,7 +1445,8 @@ private final class InventoryRecordingGatedRunConfigurationOperations: RunConfig at projectURL: URL, files: [URL], modulePaths: [String], - javaEntrypoints: JavaEntrypoints? + javaEntrypoints: JavaEntrypoints?, + projectOwnership: [RunProjectOwnership]? = nil ) throws -> RunConfigurationGenerationResult { lock.lock() inventories.append(files) diff --git a/macos/Tests/LitheTests/RunLaunchEnvironmentTests.swift b/macos/Tests/LitheTests/RunLaunchEnvironmentTests.swift index b5f91a28f7..4a2d8cb305 100644 --- a/macos/Tests/LitheTests/RunLaunchEnvironmentTests.swift +++ b/macos/Tests/LitheTests/RunLaunchEnvironmentTests.swift @@ -189,6 +189,25 @@ struct RunLaunchEnvironmentTests { #expect(resolved.environment["PATH"] == "\(tools.path):/usr/bin:/bin:/fixture-node/bin") } + @Test @MainActor + func definedToolLaunchReturnsAnOperationValueWithoutAmbientLaunchPermission() async throws { + let runtime = ProjectRuntimeService(runtimeLocator: RunLaunchPathRuntimeLocator( + environmentValues: ["PATH": "/fixture/bin"], executablePath: "/unused"), store: RunLaunchPathStore()) + let defined = DefinedRunToolFixture() + let resolver = RunExecutableResolver(runtimeService: runtime, environmentResolver: RustCoreBridge(), definedTools: defined) + let plan = SharedLaunchPlan(executable: .toolchain("compiler"), arguments: [], workingDirectory: "apps/example") + let options = RunOptions() + #expect(throws: RunExecutableResolutionError.self) { try resolver.resolve(plan, projectURL: projectURL, options: options) } + let first = try #require(await resolver.prepare(plan, projectURL: projectURL, options: options)) + let second = try #require(await resolver.prepare(plan, projectURL: projectURL, options: options)) + #expect(defined.calls == ["compiler:apps/example", "compiler:apps/example"]) + #expect(first.executableURL.path == "/fixture/compiler/bin/worker") + #expect(first.environment["COMPILER_HOME"] == "/fixture/compiler") + #expect(first.executableURL == second.executableURL) + // No shared slot survives preparation; another Run cannot borrow its permission. + #expect(throws: RunExecutableResolutionError.self) { try resolver.resolve(plan, projectURL: projectURL, options: options) } + } + @Test(arguments: [false, true]) @MainActor func resolverAppliesTheInterpreterDirectoryToNodeToolchains(missingPath: Bool) throws { @@ -233,6 +252,19 @@ private struct RunLaunchPathRuntimeLocator: RuntimeLocator { func mavenRuntime(at executableURL: URL) -> MavenRuntimeCandidate? { nil } } +@MainActor +private final class DefinedRunToolFixture: DefinedToolchainResolving { + var calls: [String] = [] + func resolve(tool: String, directory: String, workspace: URL, environment: [String: String], + minimumVersion: String?, projectCodeAllowed: Bool) async throws -> ResolvedDefinedToolchain? { + #expect(projectCodeAllowed) + calls.append(tool + ":" + directory) + return ResolvedDefinedToolchain(executable: URL(fileURLWithPath: "/fixture/compiler/bin/worker"), + environment: environment.merging(["COMPILER_HOME": "/fixture/compiler"]) { _, new in new }, + projectCode: false, version: "1") + } +} + private final class RunLaunchPathStore: KeyValueStore { private var values: [String: Any] = [:] diff --git a/macos/Tests/LitheTests/RunWorkflowCoordinatorTests.swift b/macos/Tests/LitheTests/RunWorkflowCoordinatorTests.swift index e5270c2955..f395d105ea 100644 --- a/macos/Tests/LitheTests/RunWorkflowCoordinatorTests.swift +++ b/macos/Tests/LitheTests/RunWorkflowCoordinatorTests.swift @@ -7,6 +7,44 @@ import LitheModuleAPI @Suite("Run workflow coordinator") @MainActor struct RunWorkflowCoordinatorTests { + @Test(arguments: ["stop", "replace", "workspace"]) + func runActionCancellationOwnsSavingAndCannotClearItsReplacement(mode: String) async { + let coordinator = makeRunWorkflowCoordinator() + let entered = TestGate(), release = TestGate(), finished = TestGate() + let nextEntered = TestGate(), nextRelease = TestGate(), nextFinished = TestGate() + defer { coordinator.cancelModuleOperation(); release.open(); nextRelease.open() } + var oldLaunches = 0, nextLaunches = 0 + coordinator.startRunAction { + defer { finished.open() } + entered.open() + _ = await release.waitUntilOpen() + if !Task.isCancelled { oldLaunches += 1 } + } + #expect(await entered.waitUntilOpen()) + #expect(coordinator.isRunActionStarting) + if mode == "replace" { + coordinator.startRunAction { + defer { nextFinished.open() } + nextEntered.open() + _ = await nextRelease.waitUntilOpen() + if !Task.isCancelled { nextLaunches += 1 } + } + #expect(await nextEntered.waitUntilOpen()) + } else if mode == "workspace" { coordinator.resetPendingAction() } + else { coordinator.cancelModuleOperation() } + release.open() + #expect(await finished.waitUntilOpen()) + #expect(oldLaunches == 0) + #expect(coordinator.isRunActionStarting == (mode == "replace")) + if mode == "replace" { + nextRelease.open() + #expect(await nextFinished.waitUntilOpen()) + #expect(nextLaunches == 1) + } + coordinator.cancelModuleOperation() + #expect(!coordinator.isRunActionStarting) + } + @Test func moduleLaunchCoalescesAndWorkspaceResetCancelsPreparation() async { let coordinator = makeRunWorkflowCoordinator() diff --git a/macos/Tests/LitheTests/RustBuildSystemOperationsTests.swift b/macos/Tests/LitheTests/RustBuildSystemOperationsTests.swift index 97137c4024..61295ec4e5 100644 --- a/macos/Tests/LitheTests/RustBuildSystemOperationsTests.swift +++ b/macos/Tests/LitheTests/RustBuildSystemOperationsTests.swift @@ -10,6 +10,39 @@ import Testing struct RustBuildSystemOperationsTests { private let operations = RustBuildSystemOperations(core: RustCoreBridge()) + @Test + func descriptorPreservesGenericPreparationWithoutPrescribingTaskIDs() throws { + let source = try Self.source("build-provider-preparation") + let tasks = try #require(source.buildSystems.first?.tasks) + #expect(tasks.map(\.id) == ["prepare-assets", "prepare-specs"]) + #expect(tasks.map(\.preparation) == [ + .init(purpose: "run", sourceRoles: ["generatedSource", "source"]), + .init(purpose: "run", sourceRoles: ["generatedTestSource", "testSource"]) + ]) + } + + @Test + func providerReportsTravelInlineAndKeepRepeatedCasesInTheirProject() throws { + let source = try Self.source("build-provider-reports") + let target = BuildSystemTarget(manifest: source.manifest, buildSystemID: "gradle") + #expect(source.buildSystems.first?.providerTestReports == true) + let workspace = FileManager.default.temporaryDirectory.appendingPathComponent("provider-reports-" + UUID().uuidString) + try FileManager.default.createDirectory(at: workspace, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: workspace) } + let report = try JSONDecoder().decode(ToolingJSONValue.self, from: Data(#"{"format":"litheTestResults","version":1,"tests":[{"project":"a","suite":"Same","case":"checks","status":"failed"},{"project":"b","suite":"Same","case":"checks","status":"passed"}]}"#.utf8)) + let request = BuildTestResultsRequest(workspaceURL: workspace, root: ".", project: nil, projects: [], + output: "", notBefore: nil, suite: nil, result: report) + let result = try operations.decodeTestResults(target, request: request) + #expect(result.summary.total == 2) + #expect(result.summary.failed == 1) + #expect(Set(result.tests.map(\.id)).count == 2) + #expect(result.tests.map(\.project) == ["a", "b"]) + #expect(throws: (any Error).self) { + try operations.decodeTestResults(target, request: BuildTestResultsRequest(workspaceURL: workspace, + root: ".", project: nil, projects: [], output: "", notBefore: nil, suite: nil)) + } + } + @Test func descriptorDeclaresBuildSystemsAndTheirModule() throws { let descriptor = try Self.descriptor("build-static-file") @@ -120,6 +153,39 @@ struct RustBuildSystemOperationsTests { #expect(request.mayUseNetwork) } + @Test + func providerServiceOptionsRoundTripThroughRealCoreAndRejectMissingChoices() throws { + let descriptor = try Self.descriptor("build-provider-run-options") + #expect(descriptor.buildSystems.first?.providerRuns.first?.execution == .service) + let target = try Self.target("build-provider-run-options", "gradle") + let workspace = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: workspace) } + for path in ["app", "tools"] { try FileManager.default.createDirectory(at: workspace.appendingPathComponent(path), withIntermediateDirectories: true) } + struct Fixture: Decodable { let launchOptions: BuildLaunchOptions; let result: ToolingJSONValue } + let fixture = try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: DeclarativePluginTestSupport.repositoryRoot + .appendingPathComponent("shared/contracts/plugins/v1/fixtures/build-run-options.json"))) + let planner: any BuildSystemPlanning = operations + let project = BuildSelection(name: "app", path: "app") + let selected = BuildSelection(name: "Main", path: "app", tags: ["application"]) + let plan = try planner.prepareRunPlan(target, context: BuildPlanContext(root: "app", options: [:]), run: "service", + project: project, selectedTarget: selected, userArguments: [], workspaceURL: workspace, launchOptions: fixture.launchOptions) + guard case .object(let params) = plan.params else { Issue.record("Missing provider params"); return } + let optionsJSON = try JSONDecoder().decode(ToolingJSONValue.self, from: JSONEncoder().encode(fixture.launchOptions)) + #expect(params["launchOptions"] == optionsJSON) + let launch = try planner.decodeRunPreparation(target, workspaceURL: workspace, root: "app", result: fixture.result, launchOptions: fixture.launchOptions) + #expect(launch.workingDirectory == "tools") + #expect(launch.arguments == ["--file", "app/pom.xml", "framework:run"]) + #expect(throws: (any Error).self) { + try planner.prepareRunPlan(target, context: BuildPlanContext(root: "app", options: [:]), run: "service", + project: project, selectedTarget: selected, userArguments: []) + } + #expect(throws: (any Error).self) { try planner.decodeRunPreparation(target, workspaceURL: workspace, root: "app", result: fixture.result) } + try FileManager.default.removeItem(at: workspace.appendingPathComponent("tools")) + #expect(throws: (any Error).self) { + try planner.decodeRunPreparation(target, workspaceURL: workspace, root: "app", result: fixture.result, launchOptions: fixture.launchOptions) + } + } + @Test func providerRunPreparationRoundTripsThroughRealCore() throws { let descriptor = try Self.descriptor("build-provider-run-preparation") @@ -143,6 +209,49 @@ struct RustBuildSystemOperationsTests { #expect(launch.workingDirectory == "app") } + @Test + func savedApplicationArgumentsKeepTheProtocolFourChannelThroughRealCore() throws { + struct Fixture: Decodable { + struct Expected: Decodable { let applicationReferenceUsesLaunchOptions: Bool } + let reference: BuildProviderRunReference + let expected: Expected + } + let fixture = try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: DeclarativePluginTestSupport.repositoryRoot + .appendingPathComponent("shared/fixtures/run-configuration/build-provider.json"))) + let inputs = ProviderLaunchInputs(buildSystemID: fixture.reference.buildSystemID, provider: fixture.reference.run, + projectPath: fixture.reference.root, launchOptions: BuildLaunchOptions(execution: .application, arguments: fixture.reference.userArguments), + reference: fixture.reference) + let arguments = try #require(inputs.opaqueReferenceArguments) + let options = inputs.opaqueReferenceArguments == nil ? inputs.launchOptions : nil + #expect((options != nil) == fixture.expected.applicationReferenceUsesLaunchOptions) + let target = try Self.target("build-provider-run-preparation", "gradle") + let workspace = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: workspace) } + try FileManager.default.createDirectory(at: workspace.appendingPathComponent("app"), withIntermediateDirectories: true) + let plan = try operations.prepareRunPlan(target, context: BuildPlanContext(root: "app", options: [:]), + run: "application", project: BuildSelection(name: "app", path: "app"), + selectedTarget: BuildSelection(name: "Main", path: "app", tags: ["application"]), + userArguments: arguments, workspaceURL: workspace, launchOptions: options) + guard case .object(let params) = plan.params else { Issue.record("Missing provider params"); return } + #expect(params["userArguments"] == .array(fixture.reference.userArguments.map(ToolingJSONValue.string))) + #expect(params["launchOptions"] == nil) + } + + @Test + func typedSavedApplicationOverridesRemainVersionGatedByRealCore() throws { + let reference = BuildProviderRunReference(pluginID: "dev.example.gradle", buildSystemID: "gradle", root: ".", + run: "application", projectID: "app", targetID: "main", userArguments: ["a b"]) + let inputs = ProviderLaunchInputs(buildSystemID: "gradle", provider: "application", projectPath: "app", + launchOptions: BuildLaunchOptions(execution: .application, runtimeArguments: ["-Xmx64m"], arguments: reference.userArguments), reference: reference) + #expect(inputs.opaqueReferenceArguments == nil) + let target = try Self.target("build-provider-run-preparation", "gradle") + #expect(throws: (any Error).self) { + try operations.prepareRunPlan(target, context: BuildPlanContext(root: ".", options: [:]), run: "application", + project: BuildSelection(name: "app", path: "app"), selectedTarget: BuildSelection(name: "Main", path: "app", tags: ["application"]), + userArguments: inputs.opaqueReferenceArguments ?? [], launchOptions: inputs.launchOptions) + } + } + @Test func providerDebugPreparationRoundTripsThroughRealCore() throws { let descriptor = try Self.descriptor("build-provider-debug-preparation") diff --git a/macos/Tests/LitheTests/SpringFeatureModelTests.swift b/macos/Tests/LitheTests/SpringFeatureModelTests.swift index b672a32800..7cbdc32b15 100644 --- a/macos/Tests/LitheTests/SpringFeatureModelTests.swift +++ b/macos/Tests/LitheTests/SpringFeatureModelTests.swift @@ -211,8 +211,6 @@ private final class SpringTestOperations: JavaMavenOperations, @unchecked Sendab lock.unlock() return makeResult(files) } - func scanMavenProject(at rootURL: URL, files: [URL]) -> MavenProject? { nil } - func mavenDiagnostics(output: String, projectRoot: URL) -> [MavenBuildIssue] { [] } func codeVision(at rootURL: URL, targetPath: String, paths: [String]) -> [JavaCodeVisionValue] { [] } func className(source: String, simpleName: String) -> String? { nil } func sourceDefinition(source: String, declarationName: String, memberName: String?) -> (line: Int, utf16Column: Int)? { nil } diff --git a/macos/Tests/LitheTests/Support/NoProjectJavaOperations.swift b/macos/Tests/LitheTests/Support/NoProjectJavaOperations.swift index 63527d4e74..b14a856df2 100644 --- a/macos/Tests/LitheTests/Support/NoProjectJavaOperations.swift +++ b/macos/Tests/LitheTests/Support/NoProjectJavaOperations.swift @@ -5,8 +5,6 @@ import Foundation /// artifacts. Keep their unrelated background work independent of the host, /// including when the test executable links the real Rust Core. struct NoProjectJavaOperations: JavaMavenOperations { - func scanMavenProject(at rootURL: URL, files: [URL]) -> MavenProject? { nil } - func mavenDiagnostics(output: String, projectRoot: URL) -> [MavenBuildIssue] { [] } func codeVision(at rootURL: URL, targetPath: String, paths: [String]) -> [JavaCodeVisionValue] { [] } func className(source: String, simpleName: String) -> String? { nil } func sourceDefinition(source: String, declarationName: String, memberName: String?) -> (line: Int, utf16Column: Int)? { nil } diff --git a/macos/Tests/LitheTests/ToolchainPreferencesTests.swift b/macos/Tests/LitheTests/ToolchainPreferencesTests.swift new file mode 100644 index 0000000000..cb1a228346 --- /dev/null +++ b/macos/Tests/LitheTests/ToolchainPreferencesTests.swift @@ -0,0 +1,342 @@ +import Foundation +import AppKit +import SwiftUI +import LitheCoreContracts +import Testing +@testable import Lithe + +@Suite("Toolchain preference editing", .serialized) +@MainActor +struct ToolchainPreferencesTests { + private let address = ToolchainPreferenceAddress(directory: ".", consumer: nil, tool: "dev.example.runtime:sdk") + + @Test + func openDraftCancelAndEmptyResetDoNotCreateFiles() async throws { + let root = try workspace() + defer { try? FileManager.default.removeItem(at: root) } + let store = store() + let model = ToolchainPreferencesFeatureModel(store: store) + defer { model.close() } + await model.open(workspace: root, address: address) + #expect(model.error == nil && model.draft?.mode == .inherited) + model.edit(mode: .installation, path: "/fixture/draft-sdk") + #expect(model.draft?.isChanged == true) + #expect(!FileManager.default.fileExists(atPath: root.appendingPathComponent(".lithe").path)) + model.hide(address) + await model.open(workspace: root, address: address) + #expect(model.draft?.path == "/fixture/draft-sdk" && model.draft?.isChanged == true) + await model.cancel() + await model.save(reset: true) + #expect(model.error == nil && model.draft?.isChanged == false) + #expect(!FileManager.default.fileExists(atPath: root.appendingPathComponent(".lithe").path)) + } + + @Test(arguments: [false, true]) + func nativeSaveAndResetButtonsUseTheGuardedStore(dark: Bool) async throws { + let root = try workspace() + defer { try? FileManager.default.removeItem(at: root) } + let native = store() + let model = ToolchainPreferencesFeatureModel(store: native) + let tool = ToolchainSettingsSnapshot.Tool(id: address.tool, displayName: "Fixture SDK", pluginID: "dev.example.runtime", + pluginName: "Fixture runtime", consumers: [.init(id: "dev.example.build/engine", title: "Fixture Build", pluginName: "Fixture provider")]) + let application = NSApplication.shared + let attribute = NSAccessibility.Attribute(rawValue: "AXEnhancedUserInterface") + let previous = application.accessibilityAttributeValue(attribute) + application.accessibilitySetValue(true, forAttribute: attribute) + let host = NSHostingView(rootView: ToolchainPreferenceEditorView(editor: model, workspace: root, tool: tool) + .padding(16).environment(\.colorScheme, dark ? .dark : .light)) + let window = NSWindow(contentRect: .init(x: 0, y: 0, width: 640, height: 420), styleMask: [.borderless], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false; window.contentView = host; window.orderFront(nil) + defer { + model.close(); window.contentView = nil; window.orderOut(nil); window.close() + application.accessibilitySetValue(previous, forAttribute: attribute) + } + let open = try await control("toolchain-preference-open:" + tool.id, in: host) + #expect(open.accessibilityPerformPress?() == true) + try await settled(in: host, stage: "opening draft") { model.draft != nil && !model.isLoading } + model.edit(mode: .installation, path: "/fixture/saved-sdk") + let save = try await control("toolchain-preference-save:" + tool.id, in: host) + try await settled(in: host, stage: "enabling Save") { save.isAccessibilityEnabled?() == true } + #expect(save.accessibilityPerformPress?() == true) + try await settled(in: host, stage: "saving draft") { model.draft?.isChanged == false && !model.isSaving } + #expect(model.error == nil) + let saved = try await native.load(workspace: root) + #expect(try native.inspect(address, snapshot: saved).fixed == .installation(home: "/fixture/saved-sdk")) + let reset = try await control("toolchain-preference-reset:" + tool.id, in: host) + #expect(reset.accessibilityPerformPress?() == true) + try await settled(in: host, stage: "resetting selection") { model.draft?.mode == .inherited && !model.isSaving } + #expect(model.error == nil) + let resetSnapshot = try await native.load(workspace: root) + #expect(try native.inspect(address, snapshot: resetSnapshot).stored == nil) + } + + @Test + func explicitSaveReopensPreservesOtherRunFieldsAndFilePermissions() async throws { + let root = try workspace() + defer { try? FileManager.default.removeItem(at: root) } + let local = root.appendingPathComponent(".lithe/run/local.json") + try FileManager.default.createDirectory(at: local.deletingLastPathComponent(), withIntermediateDirectories: true) + let original: [String: Any] = ["version": 2, "configurations": [], "future": ["keep": true], + "toolchain": ["java": ["homePath": "/fixture/legacy-jdk"], "future": "keep"]] + try JSONSerialization.data(withJSONObject: original).write(to: local) + try FileManager.default.setAttributes([.posixPermissions: 0o640], ofItemAtPath: local.path) + let store = store() + let initial = try await store.load(workspace: root) + let saved = try await store.save(.object(["home": .string("/fixture/chosen-sdk")]), at: address, expecting: initial) + #expect(try store.inspect(address, snapshot: saved).fixed == .installation(home: "/fixture/chosen-sdk")) + #expect(try await store.load(workspace: root) == saved) + let data = try Data(contentsOf: local) + var document = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + var toolchain = try #require(document["toolchain"] as? [String: Any]) + toolchain.removeValue(forKey: "preferences"); document["toolchain"] = toolchain + #expect(NSDictionary(dictionary: document).isEqual(to: original)) + #expect((try FileManager.default.attributesOfItem(atPath: local.path)[.posixPermissions] as? NSNumber)?.intValue == 0o640) + #expect(try String(contentsOf: root.appendingPathComponent(".lithe/.gitignore"), encoding: .utf8).contains("run/local.json")) + } + + @Test + func consumerToolReferenceSurvivesNativeSaveReopenAndDirectoryDefaultChanges() async throws { + let root = try workspace() + defer { try? FileManager.default.removeItem(at: root) } + let native = store() + let directory = ToolchainPreferenceAddress(directory: "nested", consumer: nil, tool: address.tool) + let consumer = ToolchainPreferenceAddress(directory: ".", consumer: "dev.example.build/engine", tool: address.tool) + let child = ToolchainPreferenceAddress(directory: "nested/child", consumer: consumer.consumer, tool: address.tool) + var baseline = try await native.load(workspace: root) + baseline = try await native.save(.object(["home": .string("/fixture/workspace-sdk")]), at: address, expecting: baseline) + baseline = try await native.save(.object(["home": .string("/fixture/directory-sdk")]), at: directory, expecting: baseline) + _ = try await native.save(.object(["inherit": .string("tool")]), at: consumer, expecting: baseline) + let model = ToolchainPreferencesFeatureModel(store: native) + defer { model.close() } + await model.open(workspace: root, address: child) + #expect(model.reading?.fixed == .installation(home: "/fixture/directory-sdk")) + model.edit(mode: .directoryTool) + await model.save() + #expect(model.error == nil && model.reading?.stored == .object(["inherit": .string("tool")])) + model.close() + await model.open(workspace: root, address: child) + #expect(model.draft?.mode == .directoryTool && model.reading?.fixed == .installation(home: "/fixture/directory-sdk")) + baseline = try await native.load(workspace: root) + _ = try await native.save(.object(["home": .string("/fixture/changed-sdk")]), at: directory, expecting: baseline) + await model.open(workspace: root, address: child) + #expect(model.error == nil && model.draft?.mode == .directoryTool) + #expect(model.reading?.fixed == .installation(home: "/fixture/changed-sdk")) + #expect(model.reading?.stored == .object(["inherit": .string("tool")])) + } + + @Test + func backgroundReloadRefreshesCleanInputsAndRetainsDirtyDraftBaselines() async throws { + let root = try workspace() + defer { try? FileManager.default.removeItem(at: root) } + let native = store() + let settings = ToolchainSettingsFeatureModel(loader: editorLoader(workspace: root), preferences: native) + defer { settings.close() } + await settings.load(workspace: root) + await settings.preferences.open(workspace: root, address: address) + var baseline = try await native.load(workspace: root) + _ = try await native.save(.object(["home": .string("/fixture/external-sdk")]), at: address, expecting: baseline) + await settings.load(workspace: root) + #expect(settings.preferences.reading?.fixed == .installation(home: "/fixture/external-sdk")) + #expect(settings.preferences.draft?.isChanged == false) + settings.preferences.edit(path: "/fixture/retained-draft") + baseline = try await native.load(workspace: root) + _ = try await native.save(.object(["home": .string("/fixture/newer-sdk")]), at: address, expecting: baseline) + await settings.load(workspace: root) + #expect(settings.preferences.draft?.path == "/fixture/retained-draft") + #expect(settings.preferences.draft?.isChanged == true && settings.preferences.error != nil) + } + + @Test + func aBackgroundReloadCannotWaitOnOrRevokeItsOwnPendingSave() async { + let root = URL(fileURLWithPath: "/fixture/preference-save-owner") + let snapshot = ToolchainPreferencesSnapshot(workspace: root, document: .object(["version": .integer(1)]), revision: "initial") + let controlled = PreferenceEditorStore(snapshot: snapshot) + let settings = ToolchainSettingsFeatureModel(loader: editorLoader(workspace: root), preferences: controlled) + let started = TestGate(), release = TestGate(), refreshed = TestGate() + defer { release.open(); controlled.beforeSave = nil; settings.close() } + await settings.load(workspace: root) + await settings.preferences.open(workspace: root, address: address) + settings.preferences.edit(mode: .installation, path: "/fixture/sdk") + controlled.beforeSave = { + started.open() + #expect(await release.waitUntilOpen(timeout: .seconds(5))) + } + let saving = Task { await settings.preferences.save() } + defer { saving.cancel() } + #expect(await started.waitUntilOpen()) + let reloading = Task { await settings.load(workspace: root); refreshed.open() } + defer { reloading.cancel() } + // A regression can wait on the save here, but this local deadline + // releases the native owner afterwards and both tasks still drain. + #expect(await refreshed.waitUntilOpen()) + release.open() + await saving.value; await reloading.value + #expect(controlled.completedWrites == 1) + #expect(settings.preferences.error == nil && settings.preferences.draft?.isChanged == false) + #expect(!settings.preferences.isSaving) + } + + @Test + func externalEditKeepsTheOriginalDraftBaselineUntilExplicitCancel() async throws { + let root = try workspace() + defer { try? FileManager.default.removeItem(at: root) } + let store = store() + let model = ToolchainPreferencesFeatureModel(store: store) + defer { model.close() } + await model.open(workspace: root, address: address) + model.edit(mode: .executable, path: "/fixture/draft/compiler") + let initial = try await store.load(workspace: root) + _ = try await store.save(.object(["home": .string("/fixture/external-sdk")]), at: address, expecting: initial) + let local = root.appendingPathComponent(".lithe/run/local.json") + let external = try Data(contentsOf: local) + await model.open(workspace: root, address: address) + #expect(model.error != nil && model.draft?.path == "/fixture/draft/compiler") + await model.save() + #expect(model.error != nil && model.draft?.isChanged == true) + #expect(try Data(contentsOf: local) == external) + await model.cancel() + #expect(model.error == nil && model.draft?.path == "/fixture/external-sdk") + model.edit(mode: .installation, path: "/fixture/retained-draft") + try Data("{broken".utf8).write(to: local) + await model.open(workspace: root, address: address) + #expect(model.error != nil && model.draft?.path == "/fixture/retained-draft") + } + + @Test + func preferencesOnlyDocumentDoesNotSuppressLegacyDefaultMigration() async throws { + let root = try workspace() + defer { try? FileManager.default.removeItem(at: root) } + let store = store() + let initial = try await store.load(workspace: root) + let saved = try await store.save(.object(["home": .string("/fixture/generic-sdk")]), at: address, expecting: initial) + let preferences = PreferenceMigrationValues() + preferences.set(try JSONEncoder().encode(ProjectRuntimeSettings(javaHomePath: "/fixture/old-jdk")), + forKey: MacProjectRuntimeSettingsStore.preferencesKey(root)) + let runtime = MacProjectRuntimeSettingsStore(core: RustCoreBridge(), storage: MacFileStorage(), preferences: preferences) + let migrated = try runtime.load(at: root, migrateLegacy: true) + #expect(migrated.settings.javaHomePath == "/fixture/old-jdk") + let after = try await store.load(workspace: root) + #expect(after.document == saved.document) + #expect(try store.inspect(address, snapshot: after).fixed == .installation(home: "/fixture/generic-sdk")) + } + + @Test(arguments: ["immutable", "parent-alias", "file-alias", "invalid"]) + func invalidOrReadOnlyTargetsRemainUnchanged(kind: String) async throws { + let root = try workspace(), outside = try workspace() + defer { try? FileManager.default.removeItem(at: root); try? FileManager.default.removeItem(at: outside) } + let local = root.appendingPathComponent(".lithe/run/local.json") + let source = outside.appendingPathComponent("local.json") + let bytes = Data("{\"version\":2,\"configurations\":[]}".utf8) + try bytes.write(to: source) + if kind == "parent-alias" { + try FileManager.default.createSymbolicLink(at: root.appendingPathComponent(".lithe"), withDestinationURL: outside) + } else if kind == "file-alias" || kind == "invalid" { + try FileManager.default.createDirectory(at: local.deletingLastPathComponent(), withIntermediateDirectories: true) + if kind == "file-alias" { try FileManager.default.createSymbolicLink(at: local, withDestinationURL: source) } + else { try Data("{broken".utf8).write(to: local) } + } + let store = store(immutable: kind == "immutable" ? [root] : []) + await #expect(throws: (any Error).self) { try await store.load(workspace: root) } + #expect(try Data(contentsOf: source) == bytes) + if kind == "invalid" { #expect(try Data(contentsOf: local) == Data("{broken".utf8)) } + } + + @Test + func cancelledSaveAndWorkspaceCloseDrainWithoutPublishingOrWriting() async throws { + let root = try workspace() + defer { try? FileManager.default.removeItem(at: root) } + let native = store() + let initial = try await native.load(workspace: root) + let cancelled = Task { try await native.save(.object(["home": .string("/fixture/sdk")]), at: address, expecting: initial) } + cancelled.cancel() + await #expect(throws: CancellationError.self) { try await cancelled.value } + #expect(!FileManager.default.fileExists(atPath: root.appendingPathComponent(".lithe").path)) + + let controlled = PreferenceEditorStore(snapshot: initial) + let model = ToolchainPreferencesFeatureModel(store: controlled) + defer { controlled.beforeSave = nil; model.close() } + await model.open(workspace: root, address: address) + model.edit(mode: .installation, path: "/fixture/sdk") + controlled.beforeSave = { model.close() } + await model.save() + await model.drain() + #expect(controlled.saveAttempts == 1 && controlled.completedWrites == 0) + #expect(model.workspace == nil && model.draft == nil && !model.isSaving) + controlled.beforeSave = nil + await model.open(workspace: root, address: address) + #expect(model.draft?.mode == .inherited && model.error == nil) + } + + private func store(immutable: [URL] = []) -> MacToolchainPreferencesStore { + .init(core: RustCoreBridge(), storage: MacFileStorage(), immutableRoots: { immutable }) + } + private func editorLoader(workspace: URL) -> PreferenceSettingsSnapshotLoader { + .init(snapshot: .init(workspaceURL: workspace, directories: [.init(id: ".", title: "Fixture")], tools: [ + .init(id: address.tool, displayName: "Fixture SDK", pluginID: "dev.example.runtime", pluginName: "Fixture support")])) + } + private func workspace() throws -> URL { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("lithe-preference-editor-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + return root + } + + private func control(_ id: String, in view: NSView) async throws -> AnyObject { + func elements(_ item: Any, depth: Int = 0) -> [AnyObject] { + guard depth < 48 else { return [] } + let object = item as AnyObject + let children = object.accessibilityChildren?() ?? [] + let descendants: [Any] = children.isEmpty ? ((item as? NSView)?.subviews ?? []) : children + return [object] + descendants.flatMap { elements($0, depth: depth + 1) } + } + var found: AnyObject? + try await settled(in: view, stage: "finding " + id) { found = elements(view).first { $0.accessibilityIdentifier?() == id }; return found != nil } + return try #require(found) + } + + /// SwiftUI has no accessibility publication callback; observe the product + /// control/state with a local monotonic deadline and close the host in defer. + private func settled(in view: NSView, stage: String, until predicate: () -> Bool) async throws { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(2)) + repeat { + view.layoutSubtreeIfNeeded() + if predicate() { return } + await Task.yield() + } while clock.now < deadline + Issue.record("Toolchain preference control did not settle within two seconds: \(stage)") + throw ToolchainPreferenceError.invalid + } +} + +@MainActor +private final class PreferenceEditorStore: ToolchainPreferencesStoring { + let snapshot: ToolchainPreferencesSnapshot + var beforeSave: (() async -> Void)? + var saveAttempts = 0, completedWrites = 0 + init(snapshot: ToolchainPreferencesSnapshot) { self.snapshot = snapshot } + func load(workspace: URL) async throws -> ToolchainPreferencesSnapshot { snapshot } + func inspect(_ address: ToolchainPreferenceAddress, snapshot: ToolchainPreferencesSnapshot) throws -> ToolchainPreferenceReading { + .init(stored: nil, fixed: nil, source: "automatic") + } + func save(_ value: ToolingJSONValue?, at address: ToolchainPreferenceAddress, + expecting snapshot: ToolchainPreferencesSnapshot) async throws -> ToolchainPreferencesSnapshot { + saveAttempts += 1; await beforeSave?() + try Task.checkCancellation() + completedWrites += 1 + return snapshot + } +} + +private struct PreferenceSettingsSnapshotLoader: ToolchainSettingsLoading { + let snapshot: ToolchainSettingsSnapshot + func load(workspace: URL?) async throws -> ToolchainSettingsSnapshot { snapshot } +} + +private final class PreferenceMigrationValues: KeyValueStore { + private var values: [String: Any] = [:] + func data(forKey key: String) -> Data? { values[key] as? Data } + func object(forKey key: String) -> Any? { values[key] } + func string(forKey key: String) -> String? { values[key] as? String } + func stringArray(forKey key: String) -> [String]? { values[key] as? [String] } + func set(_ value: Any?, forKey key: String) { values[key] = value } +} diff --git a/macos/Tests/LitheTests/ToolchainSettingsLoaderTests.swift b/macos/Tests/LitheTests/ToolchainSettingsLoaderTests.swift index 1875fe99b1..255d3ffaa7 100644 --- a/macos/Tests/LitheTests/ToolchainSettingsLoaderTests.swift +++ b/macos/Tests/LitheTests/ToolchainSettingsLoaderTests.swift @@ -68,6 +68,118 @@ struct ToolchainSettingsLoaderTests { #expect(files.readPaths == [localPath]) } + @Test func buildConsumersComeFromTheCurrentCoreInvocationAndDisappearWhenDisabled() async throws { + let definition = try source() + let fixture = DeclarativePluginTestSupport.repositoryRoot.appendingPathComponent("shared/contracts/plugins/v1/fixtures/build-toolchains.plugin.json") + let data = Data(try String(contentsOf: fixture, encoding: .utf8) + .replacingOccurrences(of: "dev.lithe.fixture.build-toolchains:compiler", with: "compiler").utf8) + let build = ToolchainSettingsPluginSource(pluginID: "dev.lithe.fixture.build-toolchains", name: "Build fixture", + manifest: data, isEnabled: true) + let snapshot = try await loader(sources: { [definition, build] }).load(workspace: workspace) + #expect(snapshot.tools.first?.consumers == [.init(id: build.pluginID + "/cargo", title: "Cargo", pluginName: "Build fixture")]) + let disabled = ToolchainSettingsPluginSource(pluginID: build.pluginID, name: build.name, manifest: data, isEnabled: false) + let withdrawn = try await loader(sources: { [definition, disabled] }).load(workspace: workspace) + #expect(withdrawn.tools.first?.consumers.isEmpty == true) + } + + @Test func savedPreferenceScopesStayEditableWithoutVersionFilesOrPins() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("lithe-preference-directories-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: root) } + let url = root.appendingPathComponent(".lithe/run/local.json") + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + let bytes = Data(#"{"version":2,"configurations":[],"toolchain":{"preferences":{"version":1,"directories":{"modules/service":{"tools":{"compiler":{"home":"/fixture/sdk"}}},"modules/worker":{"consumers":{"dev.example.build/engine":{"compiler":{"inherit":"consumer"}}}}}}}}"#.utf8) + try bytes.write(to: url) + let source = try source() + let reading = loader(sources: { [source] }) + let saved = try await reading.load(workspace: root) + #expect(saved.directories.map(\.id) == [".", "modules", "modules/service", "modules/worker"]) + #expect(saved.directories.filter(\.hasToolPreferences).map(\.id) == ["modules/service", "modules/worker"]) + #expect(saved.tools.map(\.directoryID) == saved.directories.map(\.id)) + #expect(saved.watch.contains(url.path)) + #expect(try Data(contentsOf: url) == bytes) + #expect(!FileManager.default.fileExists(atPath: root.appendingPathComponent(".lithe/.gitignore").path)) + #expect(!FileManager.default.fileExists(atPath: root.appendingPathComponent(".lithe/toolchains").path)) + try Data(#"{"version":2,"configurations":[]}"#.utf8).write(to: url) + let reset = try await reading.load(workspace: root) + #expect(reset.directories.map(\.id) == ["."]) + #expect(reset.directories.first?.hasToolPreferences == false) + } + + @Test func changingSavedScopesDuringDiscoveryDiscardsOldRowsAndRetriesOnce() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("lithe-preference-scope-change-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: root) } + let url = root.appendingPathComponent(".lithe/run/local.json") + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + let original = Data(#"{"version":2,"configurations":[]}"#.utf8) + let changed = Data(#"{"version":2,"configurations":[],"toolchain":{"preferences":{"version":1,"directories":{"nested":{"tools":{"compiler":{"home":"/fixture/sdk"}}}}}}}"#.utf8) + try original.write(to: url) + let source = try source() + let reading = loader(sources: { [source] }, discover: { _ in + try? changed.write(to: url) + return .init() + }) + let snapshot = try await reading.load(workspace: root) + #expect(snapshot.directories.map(\.id) == [".", "nested"]) + #expect(snapshot.directories.last?.hasToolPreferences == true) + #expect(try Data(contentsOf: url) == changed) + } + + @Test func continuouslyChangingScopesStopAfterOneRetryWithARefreshableFailure() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("lithe-continuous-preference-change-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: root) } + let url = root.appendingPathComponent(".lithe/run/local.json") + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + let original = Data(#"{"version":2,"configurations":[]}"#.utf8) + try original.write(to: url) + let source = try source() + let reading = loader(sources: { [source] }, discover: { _ in + if var current = try? Data(contentsOf: url) { current.append(32); try? current.write(to: url) } + return .init() + }) + await #expect(throws: ToolchainSettingsLoadError.sourceChanged) { try await reading.load(workspace: root) } + #expect(try Data(contentsOf: url).count == original.count + 2) + } + + @Test(arguments: ["traversal", "too-deep"]) + func invalidPreferenceDirectoriesAreRejectedBeforeNativeDiscovery(kind: String) async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("lithe-invalid-preference-scopes-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: root) } + let url = root.appendingPathComponent(".lithe/run/local.json") + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + let directory = kind == "traversal" ? "../outside" : Array(repeating: "nested", count: 65).joined(separator: "/") + let bytes = try JSONSerialization.data(withJSONObject: ["version": 2, "configurations": [], "toolchain": ["preferences": ["version": 1, + "directories": [directory: ["tools": ["compiler": ["home": "/fixture/sdk"]]]]]]]) + try bytes.write(to: url) + let source = try source() + let reading = loader(sources: { [source] }, discover: { _ in + Issue.record("An invalid preference scope must not query or probe tools") + return .init() + }) + await #expect(throws: (any Error).self) { try await reading.load(workspace: root) } + #expect(try Data(contentsOf: url) == bytes) + } + + @Test(arguments: ["parent-alias", "file-alias"]) + func preferenceScopeReaderRefusesExternalAliasesBeforeReading(kind: String) throws { + let scratch = FileManager.default.temporaryDirectory.appendingPathComponent("lithe-preference-source-alias-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: scratch) } + let root = scratch.appendingPathComponent("workspace"), outside = scratch.appendingPathComponent("outside") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: outside.appendingPathComponent("run"), withIntermediateDirectories: true) + let source = outside.appendingPathComponent("run/local.json") + let bytes = Data(#"{"version":2,"configurations":[]}"#.utf8) + try bytes.write(to: source) + let target = root.appendingPathComponent(".lithe/run/local.json") + if kind == "parent-alias" { + try FileManager.default.createSymbolicLink(at: root.appendingPathComponent(".lithe"), withDestinationURL: outside) + } else { + try FileManager.default.createDirectory(at: target.deletingLastPathComponent(), withIntermediateDirectories: true) + try FileManager.default.createSymbolicLink(at: target, withDestinationURL: source) + } + #expect(throws: ToolchainSettingsLoadError.readFailed) { try MacToolchainSettingsLoader.safeNativePreferenceRead(target) } + #expect(try Data(contentsOf: source) == bytes) + } + @Test func versionFilesListTheirDirectoriesAndRowsInheritTheNearestFirst() async throws { let source = try source(watchFiles: [".fixture-version", "*.fixture"]) let files = ToolchainLoaderFiles(existing: [ diff --git a/macos/Tests/LitheTests/WorkspaceLanguageServerPreferencesTests.swift b/macos/Tests/LitheTests/WorkspaceLanguageServerPreferencesTests.swift index f072506963..56769f2e6b 100644 --- a/macos/Tests/LitheTests/WorkspaceLanguageServerPreferencesTests.swift +++ b/macos/Tests/LitheTests/WorkspaceLanguageServerPreferencesTests.swift @@ -330,8 +330,6 @@ private struct LanguagePreferencesJavaOperations: JavaMavenOperations { ) } - func scanMavenProject(at rootURL: URL, files: [URL]) -> MavenProject? { nil } - func mavenDiagnostics(output: String, projectRoot: URL) -> [MavenBuildIssue] { [] } func codeVision(at rootURL: URL, targetPath: String, paths: [String]) -> [JavaCodeVisionValue] { [] } func className(source: String, simpleName: String) -> String? { nil } func sourceDefinition(source: String, declarationName: String, memberName: String?) -> (line: Int, utf16Column: Int)? { nil } diff --git a/rust/lithe-core/src/build/commands.rs b/rust/lithe-core/src/build/commands.rs index 2c0e2dcee3..19e0f81b78 100644 --- a/rust/lithe-core/src/build/commands.rs +++ b/rust/lithe-core/src/build/commands.rs @@ -1,6 +1,6 @@ //! The `build.*` commands: JSON in, outcome envelope out. //! -//! Every command except `build.detect` takes `{manifest, buildSystemID, +//! Every command except `build.detect` and `build.projectOptions` takes `{manifest, buildSystemID, //! request}` and validates the manifest again, as the `plugins.declarative.*` //! commands do, so a host never has to trust its own cache of a manifest. //! Malformed payloads fail the call; rejected declarations and requests come @@ -144,6 +144,16 @@ pub(crate) fn build_resolve_options_command(payload: Value) -> Result Result { + with_build_system( + payload, + "build.editStoredOption", + "edit", + |system, request| super::stored_options::edit(system, request), + ) +} + /// `build.modelPlan`: the command that prints a command-sourced model. pub(crate) fn build_model_plan_command(payload: Value) -> Result { with_build_system( @@ -200,24 +210,40 @@ pub(crate) fn build_decode_test_discovery_command(payload: Value) -> Result Result { - with_build_system( + with_manifest( payload, "build.prepareRunPlan", "plan", - |build_system, request: &super::run_preparation::PrepareRunRequest| { - super::run_preparation::plan(build_system, request) + |build_system, request: &super::run_preparation::PrepareRunRequest, manifest| { + super::run_preparation::plan( + build_system, + request, + manifest + .requires() + .get("buildProviders") + .copied() + .unwrap_or(1), + ) }, ) } /// `build.decodeRunPreparation`: validates portable process launch data. pub(crate) fn build_decode_run_preparation_command(payload: Value) -> Result { - with_build_system( + with_manifest( payload, "build.decodeRunPreparation", "launch", - |build_system, request: &super::run_preparation::DecodeRunPreparationRequest| { - super::run_preparation::decode(build_system, request) + |build_system, request: &super::run_preparation::DecodeRunPreparationRequest, manifest| { + super::run_preparation::decode( + build_system, + request, + manifest + .requires() + .get("buildProviders") + .copied() + .unwrap_or(1), + ) }, ) } @@ -338,3 +364,49 @@ pub(crate) fn build_resolve_model_inputs_command(payload: Value) -> Result Result { + with_build_system( + payload, + "build.configurationPlan", + "plan", + |declaration, request: &OutputPlanRequest| super::configuration::plan(declaration, request), + ) +} + +/// `build.decodeConfiguration`: non-sensitive facts and bounded write bindings. +pub(crate) fn build_decode_configuration_command(payload: Value) -> Result { + with_build_system( + payload, + "build.decodeConfiguration", + "configuration", + |declaration, request: &super::configuration::DecodeConfigurationRequest| { + super::configuration::decode(declaration, request) + }, + ) +} + +/// `build.configurationEditPlan`: format-independent provider-owned editing. +pub(crate) fn build_configuration_edit_plan_command(payload: Value) -> Result { + with_build_system( + payload, + "build.configurationEditPlan", + "plan", + |declaration, request: &super::configuration::EditPlanRequest| { + super::configuration::edit_plan(declaration, request) + }, + ) +} + +/// `build.decodeConfigurationEdit`: validates a receipt without document bytes. +pub(crate) fn build_decode_configuration_edit_command(payload: Value) -> Result { + with_build_system( + payload, + "build.decodeConfigurationEdit", + "edit", + |declaration, request: &super::configuration::DecodeEditRequest| { + super::configuration::decode_edit(declaration, request) + }, + ) +} diff --git a/rust/lithe-core/src/build/configuration.rs b/rust/lithe-core/src/build/configuration.rs new file mode 100644 index 0000000000..e0806ff875 --- /dev/null +++ b/rust/lithe-core/src/build/configuration.rs @@ -0,0 +1,419 @@ +//! Non-sensitive native configuration facts supplied by the owning build engine. +//! Core validates a bounded snapshot and user edits; platform adapters own source +//! access, confirmation, conditional publication, and read-back. No build-tool +//! precedence or native settings discovery is inferred here. + +use super::manifest::BuildSystemDeclaration; +use super::plans::{self, OutputPlanRequest}; +use crate::plugins::declarative::Validation; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, BTreeSet}; + +const MAX_SOURCES: usize = 8; +const MAX_FIELDS: usize = 64; +const MAX_TEXT: usize = 8192; + +/// Opt-in declaration; older providers cannot silently ignore native settings. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ConfigurationDeclaration { + pub(crate) source: ConfigurationSource, +} + +/// Configuration facts currently require the workspace's own provider process. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum ConfigurationSource { + Provider, +} + +/// A native source is addressed by an opaque provider ID, never by an edit's path. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct ConfigurationSnapshot { + pub(crate) format: String, + pub(crate) version: u32, + pub(crate) sources: Vec, +} + +/// Only allowlisted, non-sensitive fields cross the provider wire. Unrelated +/// native content, including credentials, stays in the file on the host. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct NativeSource { + pub(crate) id: String, + pub(crate) scope: SourceScope, + /// Display and platform I/O metadata; not a workspace-relative model identity. + pub(crate) uri: String, + /// `missing`, or `sha256:` followed by the exact source bytes' digest. + pub(crate) revision: String, + pub(crate) writable: bool, + pub(crate) fields: Vec, +} + +/// User-visible scope; an installation is always read-only at runtime. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum SourceScope { + Installation, + User, + Workspace, +} + +/// A non-secret value and its upstream effective value are distinct. `null` +/// means no native override; rendering an inherited value must not save it. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct NativeField { + pub(crate) id: String, + pub(crate) kind: NativeFieldKind, + pub(crate) value: Value, + pub(crate) effective_value: Value, + /// Technical origin identifier supplied by the engine, such as `commandLine`. + pub(crate) effective_source: String, +} + +/// Native UI controls; no secret/password control is part of this projection. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum NativeFieldKind { + Text, + File, + Directory, + Boolean, + StringList, +} + +/// A user-confirmed draft is bound to the exact source and snapshot revision. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct NativeEdits { + #[serde(rename = "sourceID")] + pub(crate) source_id: String, + pub(crate) revision: String, + pub(crate) values: BTreeMap, +} + +/// Provider reply normalization; the host retains its operation/configuration +/// identity while decoding and rejects a late snapshot before presenting it. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct DecodeConfigurationRequest { + pub(crate) result: Value, +} + +/// Uses the same selected tool, options, environment, and workspace session as +/// the model and execution paths. Reading native facts never applies an edit. +pub(crate) fn plan( + declaration: &BuildSystemDeclaration, + request: &OutputPlanRequest, +) -> Validation { + if declaration.configuration.is_none() { + return Err(plans::unavailable(declaration, "native configuration")); + } + if request.output_file.is_some() || request.project.is_some() { + return Err(plans::invalid_selection( + "Native configuration uses an instance and no output file or model project.", + )); + } + let context = plans::PlanContext::new(declaration, &request.base)?; + let provider = declaration + .provider + .as_ref() + .ok_or_else(|| plans::unavailable(declaration, "a provider"))?; + Ok(context.provider_plan( + provider, + "lithe/build/configuration", + "litheBuildConfiguration", + plans::provider_hints(request)?, + )) +} + +/// Validates all fields before they become a settings form or write binding. +pub(crate) fn decode( + declaration: &BuildSystemDeclaration, + request: &DecodeConfigurationRequest, +) -> Validation { + if declaration.configuration.is_none() { + return Err(plans::unavailable(declaration, "native configuration")); + } + let snapshot: ConfigurationSnapshot = serde_json::from_value(request.result.clone()) + .map_err(|_| invalid("The provider returned an invalid configuration document."))?; + validate(&snapshot)?; + Ok(json!(snapshot)) +} + +fn invalid(message: &str) -> crate::plugins::declarative::PluginRejection { + crate::plugins::declarative::PluginRejection::new("plugin.build.invalidConfiguration", message) +} + +/// Shared by snapshot decoding and format-independent edit planning. +pub(crate) fn validate(snapshot: &ConfigurationSnapshot) -> Validation<()> { + if snapshot.format != "litheBuildConfiguration" + || snapshot.version != 1 + || snapshot.sources.len() > MAX_SOURCES + { + return Err(invalid("Unsupported or oversized native configuration.")); + } + let mut sources = BTreeSet::new(); + let mut uris: BTreeMap<&str, &NativeSource> = BTreeMap::new(); + for source in &snapshot.sources { + // Upstream may select one file in two scopes. Such an alias is only + // presentable when both scopes are read-only and describe exact facts. + let invalid_alias = uris.get(source.uri.as_str()).is_some_and(|previous| { + previous.writable + || source.writable + || previous.revision != source.revision + || previous.fields != source.fields + }); + uris.insert(source.uri.as_str(), source); + let uri = url::Url::parse(&source.uri) + .map_err(|_| invalid("A native configuration source needs a file URI."))?; + if !identifier(&source.id) + || !sources.insert(&source.id) + || invalid_alias + || source.uri.len() > MAX_TEXT + || uri.scheme() != "file" + || !uri.username().is_empty() + || uri.password().is_some() + || uri.query().is_some() + || uri.fragment().is_some() + || uri.path().is_empty() + || !revision(&source.revision) + || source.scope == SourceScope::Installation && source.writable + || source.fields.len() > MAX_FIELDS + { + return Err(invalid( + "Invalid native configuration source or write scope.", + )); + } + let mut ids = BTreeSet::new(); + for field in &source.fields { + if !identifier(&field.id) + || !ids.insert(&field.id) + || !value_matches(field.kind, &field.value) + || !value_matches(field.kind, &field.effective_value) + || !identifier(&field.effective_source) + || sensitive(&field.id) + { + return Err(invalid("Invalid, sensitive, or duplicate native field.")); + } + } + } + Ok(()) +} + +/// Returns only fields the user actually changed. Unchanged inherited display +/// values never manufacture a native override. +pub(crate) fn changed_fields<'a>( + snapshot: &'a ConfigurationSnapshot, + edits: &NativeEdits, +) -> Validation<(&'a NativeSource, Vec<&'a NativeField>)> { + // Callers retrieve validated draft values by the returned field IDs. + validate(snapshot)?; + let source = snapshot + .sources + .iter() + .find(|source| source.id == edits.source_id) + .ok_or_else(|| invalid("The edited native source is no longer available."))?; + if !source.writable || source.revision != edits.revision || edits.values.len() > MAX_FIELDS { + return Err(invalid( + "The native source is read-only or its revision changed.", + )); + } + let mut changed = Vec::new(); + for (id, value) in &edits.values { + let field = source + .fields + .iter() + .find(|field| field.id == *id) + .ok_or_else(|| invalid("The native field is no longer available."))?; + if !value_matches(field.kind, value) { + return Err(invalid( + "The native field value has an invalid type or size.", + )); + } + if *value != field.value { + changed.push(field); + } + } + Ok((source, changed)) +} + +fn revision(value: &str) -> bool { + value == "missing" + || value.strip_prefix("sha256:").is_some_and(|hex| { + hex.len() == 64 + && hex + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + }) +} + +fn field_name(value: &str) -> bool { + !value.is_empty() + && value.len() <= 128 + && value.bytes().enumerate().all(|(index, byte)| { + byte.is_ascii_alphabetic() + || byte == b'_' + || index > 0 && (byte.is_ascii_digit() || byte == b'-' || byte == b'.') + }) +} + +fn sensitive(value: &str) -> bool { + let lower = value.to_ascii_lowercase(); + [ + "password", + "passphrase", + "token", + "secret", + "credential", + "privatekey", + ] + .iter() + .any(|word| lower.contains(word)) +} + +fn value_matches(kind: NativeFieldKind, value: &Value) -> bool { + if value.is_null() { + return true; + } + let text = |value: &Value| { + value.as_str().is_some_and(|value| { + value.len() <= MAX_TEXT + && !value + .chars() + .any(|character| character == '\0' || character == '\r' || character == '\n') + }) + }; + match kind { + NativeFieldKind::Text | NativeFieldKind::File | NativeFieldKind::Directory => text(value), + NativeFieldKind::Boolean => value.is_boolean(), + NativeFieldKind::StringList => value + .as_array() + .is_some_and(|values| values.len() <= 64 && values.iter().all(text)), + } +} + +fn identifier(value: &str) -> bool { + value.len() <= 64 && field_name(value) +} + +/// A provider prepares replacement bytes in an already-existing host-owned file. +/// Neither the original document nor its replacement crosses the JSON boundary. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct EditPlanRequest { + #[serde(flatten)] + pub(crate) plan: OutputPlanRequest, + pub(crate) snapshot: ConfigurationSnapshot, + pub(crate) edits: NativeEdits, +} + +/// The host allocates the output inside its isolated workspace state directory. +/// Providers open it without creation, so late cancelled work cannot recreate it. +pub(crate) fn edit_plan( + declaration: &BuildSystemDeclaration, + request: &EditPlanRequest, +) -> Validation { + if declaration.configuration.is_none() || request.plan.project.is_some() { + return Err(plans::unavailable( + declaration, + "native configuration editing", + )); + } + changed_fields(&request.snapshot, &request.edits)?; + let output = plans::output_file( + request.plan.output_file.as_deref(), + super::manifest::OutputChannel::OutputFile, + )? + .ok_or_else(|| invalid("A host-owned configuration output file is required."))?; + let state = request + .plan + .base + .workspace_state_directory + .as_deref() + .ok_or_else(|| invalid("Configuration editing requires isolated workspace state."))?; + let output_path = std::path::Path::new(&output); + let state_path = std::path::Path::new(state); + if !state_path.is_absolute() + || output_path == state_path + || !output_path.starts_with(state_path) + || output_path + .components() + .any(|part| matches!(part, std::path::Component::ParentDir)) + || request.snapshot.sources.iter().any(|source| { + url::Url::parse(&source.uri) + .ok() + .and_then(|uri| uri.to_file_path().ok()) + .is_some_and(|path| path == output_path) + }) + { + return Err(invalid( + "Configuration output must be a private host-owned staging file.", + )); + } + let context = plans::PlanContext::new(declaration, &request.plan.base)?; + let provider = declaration + .provider + .as_ref() + .ok_or_else(|| plans::unavailable(declaration, "a provider"))?; + let mut params = plans::provider_hints(&request.plan)?; + params.insert("snapshot".into(), json!(request.snapshot)); + params.insert("edits".into(), json!({"sourceID":request.edits.source_id,"revision":request.edits.revision,"values":request.edits.values})); + params.insert("outputFile".into(), json!(output)); + Ok(context.provider_plan( + provider, + "lithe/build/editConfiguration", + "litheConfigurationEdit", + params, + )) +} + +/// Bounded receipt only; private native document bytes stay in staging storage. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct EditReceipt { + format: String, + version: u32, + #[serde(rename = "sourceID")] + source_id: String, + revision: String, + changed: bool, +} + +/// Receipt validation uses the same confirmed draft as planning. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct DecodeEditRequest { + snapshot: ConfigurationSnapshot, + edits: NativeEdits, + result: Value, +} + +/// Rejects unexpected result fields, source substitution and invalid revisions. +pub(crate) fn decode_edit( + declaration: &BuildSystemDeclaration, + request: &DecodeEditRequest, +) -> Validation { + if declaration.configuration.is_none() { + return Err(plans::unavailable( + declaration, + "native configuration editing", + )); + } + let (source, changed) = changed_fields(&request.snapshot, &request.edits)?; + let receipt: EditReceipt = serde_json::from_value(request.result.clone()) + .map_err(|_| invalid("Invalid configuration edit receipt."))?; + if receipt.format != "litheConfigurationEdit" + || receipt.version != 1 + || receipt.source_id != source.id + || !revision(&receipt.revision) + || receipt.changed && (receipt.revision == "missing" || changed.is_empty()) + || !receipt.changed && receipt.revision != source.revision + { + return Err(invalid("Invalid configuration edit receipt.")); + } + Ok(json!({"sourceID":receipt.source_id,"revision":receipt.revision,"changed":receipt.changed})) +} diff --git a/rust/lithe-core/src/build/debug_preparation.rs b/rust/lithe-core/src/build/debug_preparation.rs index 1fa36873cc..4c3de32e6b 100644 --- a/rust/lithe-core/src/build/debug_preparation.rs +++ b/rust/lithe-core/src/build/debug_preparation.rs @@ -18,7 +18,11 @@ pub(crate) fn adapter<'a>( let invalid = || { plans::invalid_selection("Native debug preparation needs a same-plugin launch adapter with program, cwd, args and env target bindings, without overriding defaults.") }; - let id = &run.debug.as_ref().ok_or_else(invalid)?.adapter; + let id = run + .debug + .as_ref() + .and_then(|debug| debug.adapter.as_ref()) + .ok_or_else(invalid)?; let adapter = adapters .iter() .find(|adapter| &adapter.id == id) @@ -30,6 +34,9 @@ pub(crate) fn adapter<'a>( .ok_or_else(invalid)?; let bindings = configuration.target_bindings.as_ref().ok_or_else(invalid)?; if run.source != TaskSource::Provider + || run + .execution + .is_some_and(|kind| kind != super::manifest::BuildExecution::Application) || !adapter.requests.contains(&DebugRequest::Launch) || bindings.program.as_deref() != Some("target.path") || bindings.cwd.as_deref() != Some("target.workingDirectory") @@ -56,12 +63,14 @@ pub(crate) fn plan( build: &BuildSystemDeclaration, request: &PrepareRunRequest, ) -> Validation { - let mut value = run_preparation::plan(build, request)?; - if !build - .run - .iter() - .any(|run| run.id == request.run && run.debug.is_some()) - { + let mut value = run_preparation::plan(build, request, 5)?; + if !build.run.iter().any(|run| { + run.id == request.run + && run + .debug + .as_ref() + .is_some_and(|debug| debug.adapter.is_some()) + }) { return Err(plans::unavailable(build, "debug preparation")); } value["provider"]["method"] = json!("lithe/build/prepareDebug"); @@ -86,7 +95,13 @@ pub(crate) fn decode( let run = build .run .iter() - .find(|run| run.id == request.run && run.debug.is_some()) + .find(|run| { + run.id == request.run + && run + .debug + .as_ref() + .is_some_and(|debug| debug.adapter.is_some()) + }) .ok_or_else(|| plans::unavailable(build, "debug preparation"))?; let invalid = || decode::invalid_output("Invalid provider native debug preparation result."); let object = request.base.result.as_object().ok_or_else(invalid)?; @@ -112,6 +127,9 @@ pub(crate) fn decode( { return Err(invalid()); } + if request.base.launch_options.is_some() { + return Err(invalid()); + } let validated = run_preparation::process_fields(&request.base)?; Ok( json!({"adapter":run.debug.as_ref().expect("selected debug declaration").adapter, diff --git a/rust/lithe-core/src/build/detect.rs b/rust/lithe-core/src/build/detect.rs index 5246966243..24f6cf6e8f 100644 --- a/rust/lithe-core/src/build/detect.rs +++ b/rust/lithe-core/src/build/detect.rs @@ -7,7 +7,7 @@ //! projects without running a build tool. use super::decode::Workspace; -use super::manifest::{BuildSystemDeclaration, ModelSource, Nesting}; +use super::manifest::{BuildSystemDeclaration, ModelSource, Nesting, OptionKind, OptionScope}; use super::options; use super::outputs::{model_value, read_model, source_marker}; use crate::plugins::declarative::{validate_manifest_value, PluginRejection, Validation}; @@ -25,6 +25,17 @@ pub(crate) struct DetectRequest { /// Stored option values by build-system ID, for run entries. #[serde(default)] pub(crate) options: BTreeMap>, + /// Values by build-system ID and normalized instance root. Only options + /// declared with instance scope may read this map. + #[serde(default)] + pub(crate) instance_options: BTreeMap>>, + /// Team-owned defaults for instance-scoped options, before root overrides. + #[serde(default)] + pub(crate) instance_defaults: BTreeMap>, + /// Explicit per-root empty workspace multi-selections, preserving values + /// needed by sibling models. Unknown or changed option kinds ignore markers. + #[serde(default)] + pub(crate) empty_selections: BTreeMap>>, } #[derive(Debug, Deserialize)] @@ -117,6 +128,10 @@ fn instance( } } let resolved = options::resolve(declaration, stored, model_values.as_ref()); + // Hosts must not recreate scope/default/root merging when handing this + // exact instance to a provider. Explicit empty selections remain chosen. + instance["options"] = json!(resolved.chosen_values); + instance["droppedOptions"] = json!(resolved.dropped); instance["optionFingerprint"] = json!(options::fingerprint(declaration, &resolved)); instance } @@ -186,7 +201,38 @@ pub(crate) fn detect(request: DetectRequest) -> Validation { let stored = request.options.get(&owner.declaration.id).unwrap_or(&empty); for (root, markers) in roots(owner.declaration, &directories) { if seen.insert((owner.declaration.id.clone(), root.clone())) { - instances.push(instance(owner, &workspace, &root, markers, stored)); + let local = request + .instance_options + .get(&owner.declaration.id) + .and_then(|roots| roots.get(&root)); + let mut values = BTreeMap::new(); + for option in &owner.declaration.options { + let value = match option.scope { + OptionScope::Workspace => stored.get(&option.id), + OptionScope::Instance => { + local.and_then(|values| values.get(&option.id)).or_else(|| { + request + .instance_defaults + .get(&owner.declaration.id) + .and_then(|values| values.get(&option.id)) + }) + } + }; + if let Some(value) = value { + values.insert(option.id.clone(), value.clone()); + } + if option.scope == OptionScope::Workspace + && option.kind == OptionKind::MultiSelect + && request + .empty_selections + .get(&owner.declaration.id) + .and_then(|roots| roots.get(&root)) + .is_some_and(|ids| ids.contains(&option.id)) + { + values.insert(option.id.clone(), json!([])); + } + } + instances.push(instance(owner, &workspace, &root, markers, &values)); } } } diff --git a/rust/lithe-core/src/build/integrations/maven.rs b/rust/lithe-core/src/build/integrations/maven.rs index 5128469ee3..6e772cd3d3 100644 --- a/rust/lithe-core/src/build/integrations/maven.rs +++ b/rust/lithe-core/src/build/integrations/maven.rs @@ -282,6 +282,7 @@ pub(super) fn test_results( let mut cases: Vec = reported_cases .into_iter() .map(|case| TestCaseResult { + project: None, suite: case.class_name, case: case.method, status: status(&case.status), @@ -310,6 +311,7 @@ pub(super) fn test_results( existing.line = existing.line.or(detail.line); } None => cases.push(TestCaseResult { + project: None, suite, case, status, diff --git a/rust/lithe-core/src/build/invocation.rs b/rust/lithe-core/src/build/invocation.rs index 5e42cc7b69..1e097de24d 100644 --- a/rust/lithe-core/src/build/invocation.rs +++ b/rust/lithe-core/src/build/invocation.rs @@ -60,6 +60,7 @@ fn candidates( ExecutableSource::Environment { variable, path } => json!({ "kind": "environment", "variable": variable, "path": path }), ExecutableSource::Command { command } => json!({ "kind": "command", "command": command }), ExecutableSource::Runtime { runtime } => json!({ "kind": "runtime", "runtime": runtime }), + ExecutableSource::Toolchain { toolchain_id } => json!({ "kind": "toolchain", "toolchainID": toolchain_id, "root": root }), ExecutableSource::Setting { setting_id } => json!({ "kind": "setting", "settingID": setting_id, "path": manifest.executable_setting_value(setting_id, settings)? }), }) diff --git a/rust/lithe-core/src/build/manifest.rs b/rust/lithe-core/src/build/manifest.rs index ae32e7ba47..e0a5e12724 100644 --- a/rust/lithe-core/src/build/manifest.rs +++ b/rust/lithe-core/src/build/manifest.rs @@ -67,6 +67,9 @@ pub(crate) struct BuildSystemDeclaration { pub(crate) provider: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub(crate) options: Vec, + /// Native non-sensitive settings and generic write bindings from the engine. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) configuration: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) model: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -253,13 +256,16 @@ pub(crate) struct VersionProbe { pub(crate) pattern: String, } -/// A build dimension the user chooses, stored per workspace by the host. +/// A build dimension the user chooses, stored in its declared scope by the host. #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub(crate) struct OptionDeclaration { pub(crate) id: String, pub(crate) display_name: String, pub(crate) kind: OptionKind, + /// Instance values belong to one detected root, never to its siblings. + #[serde(default, skip_serializing_if = "OptionScope::is_workspace")] + pub(crate) scope: OptionScope, #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) default: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -285,6 +291,21 @@ pub(crate) struct OptionDeclaration { pub(crate) icon: Option, } +/// Persistence boundary of an option. Existing declarations remain workspace-wide. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum OptionScope { + #[default] + Workspace, + Instance, +} + +impl OptionScope { + fn is_workspace(&self) -> bool { + *self == Self::Workspace + } +} + /// Where a host offers an option besides its option list. Neither placement /// changes how the option is resolved or emitted. #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] @@ -587,6 +608,9 @@ pub(crate) enum Scope { #[serde(rename_all = "camelCase", deny_unknown_fields)] pub(crate) struct TaskDeclaration { pub(crate) id: String, + /// Optional automatic preparation selected by purpose and model source role. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) preparation: Option, pub(crate) display_name: String, pub(crate) group: TaskGroup, #[serde(default = "task_execution")] @@ -613,6 +637,16 @@ pub(crate) struct TaskDeclaration { pub(crate) arguments: Vec, } +/// Tool-independent association between a project task and a language workflow. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct TaskPreparation { + /// Opaque workflow purpose, such as `run`; never a task ID or tool name. + pub(crate) purpose: String, + /// Canonical content-root roles for which this task prepares its project. + pub(crate) source_roles: Vec, +} + /// Whether Core expands a command or a workspace provider executes the task. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] @@ -663,11 +697,20 @@ pub(crate) struct RunDeclaration { pub(crate) extension: BTreeMap, } -/// Explicit adapter reference; it never grants cross-package access. +/// Native launch binds a local adapter; connection attach names a public registry type. #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub(crate) struct RunDebug { - pub(crate) adapter: String, + /// Local adapter ID for v5 native executable launch. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) adapter: Option, + /// Opaque public registry alias for v11 service connection attach. + #[serde( + default, + rename = "configurationType", + skip_serializing_if = "Option::is_none" + )] + pub(crate) configuration_type: Option, } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -693,6 +736,18 @@ pub(crate) enum WorkingDirectory { Root, } +fn is_test_selector_kind(value: &str) -> bool { + !value.is_empty() + && value.len() <= 120 + && value + .bytes() + .next() + .is_some_and(|byte| byte.is_ascii_alphanumeric()) + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte)) +} + /// How tests run and how their results are read. #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -703,6 +758,9 @@ pub(crate) struct TestDeclaration { pub(crate) target_tags: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub(crate) filters: Vec, + /// Public opaque selector namespaces interpreted only by the owning backend. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) selector_kinds: Vec, /// Omitted for argv templates; provider sends a typed test request. #[serde(default, skip_serializing_if = "TaskSource::is_command")] pub(crate) source: TaskSource, @@ -729,6 +787,8 @@ pub(crate) enum TestFilter { deny_unknown_fields )] pub(crate) enum TestResultsSource { + /// A canonical document returned by the same provider test operation. + Provider, /// JUnit XML report files, globbed relative to the project directory. JunitXml { reports: Vec, @@ -947,6 +1007,8 @@ pub(crate) struct DeclarationContext<'a> { pub(crate) debug_adapters: &'a [crate::debug::adapters::DebugAdapterDeclaration], pub(crate) plugin_api_version: u32, pub(crate) build_systems_version: u32, + /// Version 2 binds candidates to the host's canonical project toolchains. + pub(crate) executable_resolution_version: u32, /// Admission version for provider execution; independent of wire negotiation. pub(crate) build_providers_version: u32, pub(crate) model_inputs_version: u32, @@ -1039,6 +1101,11 @@ fn program(build_system: &BuildSystemDeclaration) -> Value { if task.source == TaskSource::Provider { review["source"] = json!("provider"); } + // Automatic preparation changes when a task can execute, not merely + // its display. Keep that behavior in the existing install review. + if let Some(preparation) = &task.preparation { + review["preparation"] = json!(preparation); + } review }) .collect(); @@ -1239,14 +1306,15 @@ impl BuildSystemDeclaration { self.validate_integration(field, context.official)?; self.validate_provider(field, context)?; self.validate_detection(field)?; - let option_ids = self.validate_options(field)?; + let option_ids = self.validate_options(field, context.build_systems_version)?; super::model_inputs::validate_declaration( self, context.model_inputs_version, context.build_providers_version, )?; self.validate_invocation(field, &option_ids, context)?; - let model_templates = self.validate_tasks(field, &option_ids)?; + let model_templates = + self.validate_tasks(field, &option_ids, context.build_systems_version)?; self.validate_model(field, &option_ids)?; self.validate_run(field, &option_ids, &model_templates)?; self.validate_test(field, &option_ids)?; @@ -1344,7 +1412,8 @@ impl BuildSystemDeclaration { .test .as_ref() .is_some_and(|test| test.source == TaskSource::Provider); - let uses = matches!(self.model, Some(ModelSource::Provider)) + let uses = self.configuration.is_some() + || matches!(self.model, Some(ModelSource::Provider)) || provider_runs || matches!(self.dependencies, Some(DependenciesSource::Provider { .. })) || provider_tests @@ -1359,6 +1428,15 @@ impl BuildSystemDeclaration { } return Ok(()); }; + if self.configuration.is_some() + && (context.build_providers_version < 7 + || !matches!(provider, ProviderDeclaration::Process(_))) + { + return Err(invalid( + &field, + "Native configuration requires buildProviders 7 and an owned provider process.", + )); + } if !uses { return Err(invalid( field, @@ -1366,19 +1444,56 @@ impl BuildSystemDeclaration { )); } for run in &self.run { - if run.debug.is_some() { - if context.build_providers_version < 5 { - return Err(invalid( - &field, - "Provider debug preparation requires buildProviders 5.", - )); + if let Some(debug) = &run.debug { + match (&debug.adapter, &debug.configuration_type) { + (Some(_), None) if context.build_providers_version >= 5 => { + super::debug_preparation::adapter(run, context.debug_adapters)?; + } + (None, Some(kind)) if context.build_providers_version >= 11 + && super::run_preparation::valid_configuration_type(kind) + && run.execution == Some(BuildExecution::Service) => {} + _ => return Err(invalid(&field, + "Debug requires a same-package native adapter (buildProviders 5), or one registry configurationType for a service (buildProviders 11).")), } - super::debug_preparation::adapter(run, context.debug_adapters)?; } } + if matches!( + self.test.as_ref().and_then(|test| test.results.as_ref()), + Some(TestResultsSource::Provider) + ) && context.build_providers_version < 8 + { + return Err(invalid( + &field, + "Provider-owned test reports require buildProviders 8.", + )); + } + if self + .test + .as_ref() + .is_some_and(|test| !test.selector_kinds.is_empty()) + && context.build_providers_version < 9 + { + return Err(invalid( + &field, + "Opaque test selectors require buildProviders 9.", + )); + } if provider_tests && context.build_providers_version < 3 { return Err(invalid(&field, "Provider tests require buildProviders 3.")); } + if context.build_providers_version < 10 + && self.run.iter().any(|run| { + run.source == TaskSource::Provider + && run + .execution + .is_some_and(|execution| execution != BuildExecution::Application) + }) + { + return Err(invalid( + &field, + "Provider service/task preparation requires buildProviders 10.", + )); + } if provider_runs && context.build_providers_version < 4 { return Err(invalid( &field, @@ -1447,7 +1562,7 @@ impl BuildSystemDeclaration { Ok(()) } - fn validate_options(&mut self, field: &str) -> Validation> { + fn validate_options(&mut self, field: &str, version: u32) -> Validation> { if self.options.len() > MAX_LIST { return Err(invalid(format!("{field}.options"), "Too many options.")); } @@ -1455,6 +1570,12 @@ impl BuildSystemDeclaration { for (index, option) in self.options.iter_mut().enumerate() { let field = format!("{field}.options[{index}]"); option.validate(&field)?; + if option.scope == OptionScope::Instance && version < 3 { + return Err(invalid( + format!("{field}.scope"), + "Instance options require buildSystems 3.", + )); + } if !ids.insert(option.id.clone()) { return Err(invalid( format!("{field}.id"), @@ -1526,7 +1647,13 @@ impl BuildSystemDeclaration { // The host finds these with the user's own runtime settings // (the JDK chosen for the project, for example); a plugin // must not route around that choice. - if !runtime.executables.is_empty() || runtime.version.is_some() { + let toolchain = runtime.executables.len() == 1 + && matches!( + runtime.executables[0].source, + ExecutableSource::Toolchain { .. } + ) + && context.executable_resolution_version >= 2; + if (!runtime.executables.is_empty() && !toolchain) || runtime.version.is_some() { return Err(invalid( field, format!( @@ -1535,6 +1662,14 @@ impl BuildSystemDeclaration { ), )); } + if toolchain { + check_candidate( + &mut runtime.executables[0], + &format!("{field}.executables[0]"), + &invocation.environment_inputs, + &BTreeSet::new(), + )?; + } continue; } if runtime.executables.is_empty() || runtime.executables.len() > MAX_LIST { @@ -1596,6 +1731,14 @@ impl BuildSystemDeclaration { { crate::plugins::executables::ordered_indices(list, &location)?; for candidate in list { + if matches!(candidate.source, ExecutableSource::Toolchain { .. }) + && context.executable_resolution_version < 2 + { + return Err(invalid( + &location, + "Toolchain candidates require executableResolution 2.", + )); + } if let ExecutableSource::Setting { setting_id } = &candidate.source { if !context.declared_settings.is_executable(setting_id) { return Err(invalid(&location, "A setting candidate needs a global file setting with effect reload or nextRun.")); @@ -1635,14 +1778,42 @@ impl BuildSystemDeclaration { &mut self, field: &str, options: &BTreeSet, + capability_version: u32, ) -> Validation> { if self.tasks.len() > MAX_TASKS { return Err(invalid(format!("{field}.tasks"), "Too many tasks.")); } let mut ids = BTreeSet::new(); let mut templates = BTreeSet::new(); + let mut preparations = BTreeSet::new(); for (index, task) in self.tasks.iter_mut().enumerate() { let field = format!("{field}.tasks[{index}]"); + if let Some(preparation) = &mut task.preparation { + if capability_version < 4 + || !is_entry_identifier(&preparation.purpose) + || preparation.source_roles.is_empty() + || preparation.source_roles.len() > 6 + || task.from_model + || task.execution != BuildExecution::Task + || task.requires_user_arguments + || !task.scopes.contains(&Scope::Project) + || preparation.source_roles.iter().any(|role| { + !matches!( + role.as_str(), + "source" + | "generatedSource" + | "resource" + | "testSource" + | "generatedTestSource" + | "testResource" + ) || !preparations.insert((preparation.purpose.clone(), role.clone())) + }) + { + return Err(invalid(format!("{field}.preparation"), + "Preparation requires buildSystems 4, a project task without prompts, and unique purpose/source-role bindings.")); + } + preparation.source_roles.sort(); + } if !is_entry_identifier(&task.id) || !ids.insert(task.id.clone()) { return Err(invalid( format!("{field}.id"), @@ -1840,14 +2011,14 @@ impl BuildSystemDeclaration { || run.from_tasks.is_some() || !run.arguments.is_empty() || !run.extension.is_empty() - || run - .execution - .is_some_and(|execution| execution != BuildExecution::Application) || run.name.as_ref().is_some_and(|name| { name.trim().is_empty() || name.len() > MAX_TEXT || name.contains("${") }) { - return Err(invalid(&field, "Provider runs require target tags, application execution, and no host command templates.")); + return Err(invalid( + &field, + "Provider runs require target tags and no host command templates.", + )); } let from = run.from_targets.as_mut().expect("validated target source"); from.tags = sorted_unique(std::mem::take(&mut from.tags)); @@ -1964,6 +2135,22 @@ impl BuildSystemDeclaration { &[Scope::Workspace, Scope::Project, Scope::Target], )?; check_target_tags(&mut test.target_tags, &test.scopes, &field)?; + let distinct_selector_kinds: BTreeSet<_> = test.selector_kinds.iter().collect(); + if !test.selector_kinds.is_empty() + && (test.source != TaskSource::Provider + || test.selector_kinds.len() > 16 + || distinct_selector_kinds.len() != test.selector_kinds.len() + || test + .selector_kinds + .iter() + .any(|kind| !is_test_selector_kind(kind))) + { + return Err(invalid( + &field, + "Test selectorKinds require a provider and bounded identifiers.", + )); + } + test.selector_kinds.sort(); test.filters.sort(); test.filters.dedup(); if test.discovery @@ -1986,6 +2173,12 @@ impl BuildSystemDeclaration { )); } } else { + if matches!(test.results, Some(TestResultsSource::Provider)) { + return Err(invalid( + &field, + "Provider-owned reports require provider test execution.", + )); + } check_argument_count(&test.arguments, &format!("{field}.arguments"))?; template::validate_arguments( &test.arguments, @@ -2213,6 +2406,14 @@ fn check_candidate( return Err(invalid(format!("{field}.settingID"), "Invalid setting ID.")); } } + ExecutableSource::Toolchain { toolchain_id } => { + if !crate::toolchain::definitions::tool_id(toolchain_id) { + return Err(invalid( + format!("{field}.toolchainID"), + "Invalid toolchain ID.", + )); + } + } ExecutableSource::Runtime { runtime, .. } => { if !runtimes.contains(runtime) { return Err(invalid( diff --git a/rust/lithe-core/src/build/mod.rs b/rust/lithe-core/src/build/mod.rs index 7981621b8e..511051affc 100644 --- a/rust/lithe-core/src/build/mod.rs +++ b/rust/lithe-core/src/build/mod.rs @@ -16,6 +16,7 @@ //! Note: the contract is recorded in .agents/notes/implemented/architecture/2026-10-17-build-system-plugin-contract.md mod commands; +mod configuration; mod debug_preparation; mod decode; mod detect; @@ -28,8 +29,10 @@ mod model_inputs; mod options; mod outputs; mod plans; +mod project_options; mod run_preparation; mod select; +mod stored_options; mod template; mod test_discovery; @@ -39,3 +42,5 @@ pub(crate) use manifest::{ activation_event, programs, validate_declarations, BuildSystemDeclaration, DeclarationContext, ProviderProcess, }; +pub(crate) use project_options::project_options_command; +pub(crate) use stored_options::resolve_stored_options_command; diff --git a/rust/lithe-core/src/build/options.rs b/rust/lithe-core/src/build/options.rs index 8b109119d4..16289c9505 100644 --- a/rust/lithe-core/src/build/options.rs +++ b/rust/lithe-core/src/build/options.rs @@ -256,7 +256,7 @@ pub(crate) fn describe( _ => Value::Null, }) }; - json!({ + let mut value = json!({ "id": option.id, "displayName": option.display_name, "kind": option.kind, @@ -264,7 +264,11 @@ pub(crate) fn describe( "source": source, "choices": choices, "allowCustom": option.allow_custom, - }) + }); + if option.scope == super::manifest::OptionScope::Instance { + value["scope"] = json!("instance"); + } + value }) .collect(); json!({ diff --git a/rust/lithe-core/src/build/outputs.rs b/rust/lithe-core/src/build/outputs.rs index 085f224498..3087c7ebd2 100644 --- a/rust/lithe-core/src/build/outputs.rs +++ b/rust/lithe-core/src/build/outputs.rs @@ -28,6 +28,8 @@ use std::collections::{BTreeMap, BTreeSet}; use std::path::Path; pub(super) const MAX_TEST_CASES: usize = 10_000; +// Durations cross JSON into JavaScript and must retain exact integer precision. +const MAX_DURATION_MILLIS: u64 = 9_007_199_254_740_991; const MAX_DIAGNOSTICS: usize = 10_000; const MAX_DEPENDENCIES: usize = 10_000; const MAX_DEPENDENCY_DEPTH: usize = 64; @@ -234,6 +236,9 @@ pub(crate) enum TestStatus { #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct TestCaseResult { + /// Provider-reported project identity; repeated class names in distinct projects remain distinct. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) project: Option, pub(crate) suite: String, pub(crate) case: String, pub(crate) status: TestStatus, @@ -262,6 +267,19 @@ struct CanonicalTestResults { version: u32, #[serde(default)] tests: Vec, + #[serde(default)] + coverage: Vec, +} + +/// Provider-owned completed scope; a missing suite covers its project, and a +/// missing case covers its suite. Hosts never infer coverage from selectors. +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)] +struct TestCoverage { + project: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + suite: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + case: Option, } #[derive(Debug, Deserialize)] @@ -281,6 +299,9 @@ pub(crate) struct DecodeTestResultsRequest { /// The run's combined output. #[serde(default)] pub(crate) output: String, + /// Canonical result of this provider operation; never a filesystem fallback. + #[serde(default)] + pub(crate) result: Option, /// The run's start; earlier report files are ignored. #[serde(default)] pub(crate) not_before_millis: Option, @@ -309,9 +330,9 @@ pub(crate) struct TestSummary { /// Merges invocations of the same case, keeping the most severe outcome and /// its first message. pub(crate) fn aggregate(cases: Vec) -> Vec { - let mut merged: BTreeMap<(String, String), TestCaseResult> = BTreeMap::new(); + let mut merged: BTreeMap<(Option, String, String), TestCaseResult> = BTreeMap::new(); for case in cases { - let key = (case.suite.clone(), case.case.clone()); + let key = (case.project.clone(), case.suite.clone(), case.case.clone()); if !merged.contains_key(&key) && merged.len() >= MAX_TEST_CASES { continue; } @@ -321,6 +342,12 @@ pub(crate) fn aggregate(cases: Vec) -> Vec { } Some(existing) => { existing.invocations += case.invocations.max(1); + existing.duration_millis = existing + .duration_millis + .zip(case.duration_millis) + .and_then(|(left, right)| left.checked_add(right)) + .filter(|total| *total <= MAX_DURATION_MILLIS); + if case.status > existing.status { existing.status = case.status; existing.message = case.message.or(existing.message.take()); @@ -368,7 +395,21 @@ pub(crate) fn decode_test_results( None => root.clone(), }; decode::bounded_text(&request.output)?; + let mut coverage = Vec::new(); let results = match source { + TestResultsSource::Provider => { + let (cases, scopes) = provider_test_cases( + request.result.as_ref().ok_or_else(|| { + decode::invalid_output("The provider returned no test report.") + })?, + &root, + )?; + coverage = scopes; + TestResults { + cases, + summary: None, + } + } TestResultsSource::JunitXml { reports } => TestResults { cases: report_cases( &workspace, @@ -395,11 +436,85 @@ pub(crate) fn decode_test_results( }; let cases = aggregate(results.cases); let summary = results.summary.unwrap_or_else(|| summary(&cases)); - Ok(json!({ + let mut result = json!({ "tests": cases, "summary": summary, "success": summary.failed == 0 && summary.errors == 0, - })) + }); + if !coverage.is_empty() { + result["coverage"] = json!(coverage); + } + Ok(result) +} + +/// Validates an inline report without opening report files or inferring tool semantics. +fn provider_test_cases( + value: &Value, + root: &str, +) -> Validation<(Vec, Vec)> { + let invalid = || decode::invalid_output("Invalid provider test report."); + if serde_json::to_vec(value).map_or(usize::MAX, |bytes| bytes.len()) > decode::MAX_OUTPUT_BYTES + { + return Err(invalid()); + } + let mut document: CanonicalTestResults = + serde_json::from_value(value.clone()).map_err(|_| invalid())?; + if document.format != "litheTestResults" + || document.version != CANONICAL_VERSION + || document.tests.len() > MAX_TEST_CASES + || document.coverage.len() > MAX_TEST_CASES + { + return Err(invalid()); + } + for case in &document.tests { + if case.suite.is_empty() + || case.case.is_empty() + || case.suite.len() > 4096 + || case.case.len() > 4096 + || case.suite.chars().any(char::is_control) + || case.case.chars().any(char::is_control) + || case + .message + .as_ref() + .is_some_and(|value| value.len() > 65536 || value.contains('\0')) + || case.invocations == 0 + || case.invocations > 1_000_000 + || case + .duration_millis + .is_some_and(|value| value > MAX_DURATION_MILLIS) + || case + .line + .is_some_and(|line| line == 0 || line > i32::MAX as usize) + { + return Err(invalid()); + } + for path in [&case.project, &case.path].into_iter().flatten() { + let normalized = + decode::request_path(path, "test report path").map_err(|_| invalid())?; + if normalized != *path || !decode::contains(root, &normalized) { + return Err(invalid()); + } + } + } + for scope in &document.coverage { + let normalized = + decode::request_path(&scope.project, "test coverage project").map_err(|_| invalid())?; + if normalized != scope.project + || !decode::contains(root, &normalized) + || (scope.case.is_some() && scope.suite.is_none()) + || [&scope.suite, &scope.case] + .into_iter() + .flatten() + .any(|value| { + value.is_empty() || value.len() > 4096 || value.chars().any(char::is_control) + }) + { + return Err(invalid()); + } + } + document.coverage.sort(); + document.coverage.dedup(); + Ok((document.tests, document.coverage)) } /// Directories whose reports belong to the run: the selected project, or the @@ -479,6 +594,10 @@ fn canonical_cases(text: &str, workspace: &Workspace, base: &str) -> Option= 0.0) .map(|seconds| (seconds * 1000.0).round() as u64); TestCaseResult { + project: None, suite, case: attribute(element, b"name").unwrap_or_default(), status: TestStatus::Passed, diff --git a/rust/lithe-core/src/build/plans.rs b/rust/lithe-core/src/build/plans.rs index 8f8c4a4edd..e3eca5bbda 100644 --- a/rust/lithe-core/src/build/plans.rs +++ b/rust/lithe-core/src/build/plans.rs @@ -121,6 +121,11 @@ pub(crate) struct TestPlanRequest { /// workspace run collects reports from each of them. #[serde(default)] pub(crate) projects: Vec, + /// One declared public namespace; values remain opaque to Core. + #[serde(default)] + pub(crate) selector_kind: Option, + #[serde(default)] + pub(crate) selectors: Vec, } #[derive(Debug, Deserialize)] @@ -152,6 +157,9 @@ pub(crate) struct ProviderTool { /// Absolute path of the executable, after the host applied wrappers, /// environment variables, `PATH`, and the user's override. pub(crate) executable: String, + /// Selected runtime inputs, independent of the backend process's own JDK. + #[serde(default)] + pub(crate) environment: BTreeMap, } /// Validates what the host adds to a provider request and returns the @@ -177,6 +185,20 @@ pub(crate) fn provider_hints(request: &OutputPlanRequest) -> Validation 64 + || tool.environment.iter().any(|(key, value)| { + !super::manifest::is_environment_name(key) + || value.len() > 32768 + || value.contains('\0') + }) + { + return Err(invalid_selection( + "The resolved tool environment is invalid or too large.", + )); + } + if !tool.environment.is_empty() { + params.insert("environment".to_string(), json!(tool.environment)); + } } Ok(params) } @@ -380,10 +402,17 @@ impl<'a> PlanContext<'a> { params.insert("root".to_string(), json!(self.root)); params.insert("options".to_string(), json!(self.option_values)); params.insert("optionArguments".to_string(), json!(option_arguments)); - params.insert( - "environment".to_string(), - json!(self.contribution.environment), + let mut environment = params + .remove("environment") + .and_then(|value| value.as_object().cloned()) + .unwrap_or_default(); + environment.extend( + self.contribution + .environment + .iter() + .map(|(key, value)| (key.clone(), json!(value))), ); + params.insert("environment".to_string(), json!(environment)); if let Some(inputs) = &self.model_inputs { params.insert("modelInputs".into(), inputs.clone()); } @@ -639,6 +668,24 @@ pub(crate) fn test_plan( } else { check_user_arguments(&request.user_arguments, &test.arguments)?; } + if let Some(kind) = &request.selector_kind { + if !test.selector_kinds.contains(kind) + || request.suite.is_some() + || request.case.is_some() + || request.selectors.len() > 64 + || request.selectors.iter().any(|value| { + value.is_empty() || value.len() > 4096 || value.chars().any(char::is_control) + }) + { + return Err(invalid_selection( + "Unsupported, conflicting or invalid opaque test selectors.", + )); + } + } else if !request.selectors.is_empty() { + return Err(invalid_selection( + "Opaque test selectors need their declared namespace.", + )); + } check_test_text(&request.suite, "test suite")?; check_test_text(&request.case, "test case")?; for (requested, filter) in [ @@ -741,12 +788,21 @@ pub(crate) fn test_plan( "reports": reports(globs), }), Some(TestResultsSource::HostIntegration) => json!({ "decoder": "hostIntegration" }), + Some(TestResultsSource::Provider) => json!({ "decoder": "provider" }), }; let mut extra = Map::new(); extra.insert("results".to_string(), results); if test.source == TaskSource::Provider { let mut params = Map::new(); params.insert("userArguments".to_string(), json!(request.user_arguments)); + if let Some(kind) = &request.selector_kind { + params.insert("selectorKind".to_string(), json!(kind)); + params.insert("selectors".to_string(), json!(request.selectors)); + } + + if matches!(test.results, Some(TestResultsSource::Provider)) { + params.insert("testResults".to_string(), json!(true)); + } // Filters stay separate values; the owning backend interprets them // using its upstream test engine, never a host-generated CLI string. for (name, value) in [("suite", &request.suite), ("case", &request.case)] { diff --git a/rust/lithe-core/src/build/project_options.rs b/rust/lithe-core/src/build/project_options.rs new file mode 100644 index 0000000000..ebfd14de86 --- /dev/null +++ b/rust/lithe-core/src/build/project_options.rs @@ -0,0 +1,112 @@ +//! Validates the existing team Build options document for native stores. +//! This is storage syntax only: declarations still own option types, scope, +//! defaults and contributions, and platforms own file access and publication. + +use crate::plugins::declarative::{decode_payload, outcome, PluginRejection, Validation}; +use crate::protocol::CoreError; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::BTreeMap; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Request { + document: Value, +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct Document { + // JSON Schema's numeric const 1 also accepts 1.0, as the existing native + // document validator does. Booleans must never become the version number. + version: f64, + systems: BTreeMap, +} + +#[derive(Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct System { + #[serde(default)] + options: BTreeMap, + #[serde(default)] + instance_defaults: BTreeMap, + #[serde(default)] + instances: BTreeMap>, + #[serde(default)] + saved_choices: BTreeMap>, +} + +/// No null or numeric option is part of the persisted v1 document. +#[derive(Deserialize, Serialize)] +#[serde(untagged)] +enum StoredValue { + Boolean(bool), + Text(String), + List(Vec), +} + +fn invalid() -> PluginRejection { + PluginRejection::new( + "plugin.build.invalidOptionsDocument", + "The saved build options are invalid. The original configuration has been kept.", + ) +} + +fn values_valid(values: &BTreeMap) -> bool { + values.len() <= 256 + && values.keys().all(|id| { + !id.is_empty() + && id.len() <= 128 + && id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"_.-".contains(&byte)) + }) +} + +pub(super) fn root_valid(root: &str) -> bool { + root == "." + || !root.is_empty() + && root.chars().count() <= 1024 + && !root.contains(['\\', ':']) + && !root.chars().any(char::is_control) + && root + .split('/') + .all(|part| !part.is_empty() && part != "." && part != "..") +} + +pub(super) fn validate(value: Value) -> Validation { + if serde_json::to_vec(&value).map_or(true, |bytes| bytes.len() > 2 * 1024 * 1024) { + return Err(invalid()); + } + let document: Document = serde_json::from_value(value).map_err(|_| invalid())?; + if document.version != 1.0 || document.systems.len() > 32 { + return Err(invalid()); + } + for (id, system) in &document.systems { + if id.is_empty() + || id.len() > 64 + || !id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + || !values_valid(&system.options) + || !values_valid(&system.instance_defaults) + || system.instances.len() > 256 + || system + .instances + .iter() + .any(|(root, values)| !root_valid(root) || !values_valid(values)) + { + return Err(invalid()); + } + } + Ok(json!({"version":1,"systems":document.systems})) +} + +/// Validates storage without dropping values belonging to inactive plugins or +/// treating malformed documents as empty defaults. Never reads or writes a file. +pub(crate) fn project_options_command(payload: Value) -> Result { + let request: Request = decode_payload(payload, "build.projectOptions")?; + Ok(outcome( + validate(request.document).map(|document| ("document", document)), + )) +} diff --git a/rust/lithe-core/src/build/run_preparation.rs b/rust/lithe-core/src/build/run_preparation.rs index c51158f3b4..d11af23741 100644 --- a/rust/lithe-core/src/build/run_preparation.rs +++ b/rust/lithe-core/src/build/run_preparation.rs @@ -3,16 +3,155 @@ //! bounded arguments, executable references, and workspace path containment. use super::decode::{self, Workspace}; -use super::manifest::{BuildSystemDeclaration, TaskSource}; +use super::manifest::{BuildExecution, BuildSystemDeclaration, TaskSource}; use super::plans::{self, OutputPlanRequest, SelectedEntity}; -use crate::plugins::declarative::{is_bare_command, is_identifier, Validation}; -use serde::Deserialize; +use crate::plugins::declarative::{is_bare_command, is_identifier, is_plugin_id, Validation}; +use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; +use std::collections::BTreeMap; const MAX_ARGUMENTS: usize = 1024; const MAX_ARGUMENT_BYTES: usize = 65536; const MAX_ENVIRONMENT: usize = 64; +/// Resolved user choices; only the owning provider interprets runtime semantics. +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct LaunchOptions { + execution: BuildExecution, + runtime_arguments: Vec, + arguments: Vec, + environment: BTreeMap, + #[serde( + default, + deserialize_with = "optional_launch_string", + skip_serializing_if = "Option::is_none" + )] + working_directory: Option, + #[serde( + default, + deserialize_with = "optional_launch_string", + skip_serializing_if = "Option::is_none" + )] + entrypoint: Option, + #[serde( + default, + deserialize_with = "optional_runtime_debug", + skip_serializing_if = "Option::is_none" + )] + debug: Option, +} + +/// The host selects a current DebugAdapter Registry entry and a local debuggee endpoint. +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct RuntimeDebug { + /// Qualified registry identity selected and revalidated by the native host. + #[serde(rename = "adapterID")] + adapter_id: String, + /// Public alias matching the selected run's declaration. + configuration_type: String, + /// Host-owned endpoint of the suspended service, not the adapter transport. + target: ConnectionTarget, +} + +/// A debuggee connection, independent of language and adapter transport. +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct ConnectionTarget { + kind: String, + host: String, + port: u16, +} + +/// Registry aliases remain opaque names, with bounded non-control text. +pub(crate) fn valid_configuration_type(value: &str) -> bool { + !value.trim().is_empty() && value.len() <= 256 && !value.chars().any(char::is_control) +} + +/// Omitted debug means Run; explicit null must never silently discard Debug. +fn optional_runtime_debug<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> Result, D::Error> { + RuntimeDebug::deserialize(deserializer).map(Some) +} + +/// Optional means absent; an explicitly null launch choice is malformed. +fn optional_launch_string<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> Result, D::Error> { + String::deserialize(deserializer).map(Some) +} + +impl LaunchOptions { + /// Bounds wire values and verifies an explicit directory in the host workspace. + fn validate(&self, workspace_root: Option<&str>) -> Validation<()> { + let invalid = || plans::invalid_selection("Invalid provider launch options."); + if self.debug.as_ref().is_some_and(|debug| { + debug.target.kind != "connection" + || debug.target.host != "127.0.0.1" + || debug.target.port == 0 + || self.execution != BuildExecution::Service + || !valid_configuration_type(&debug.configuration_type) + || !debug + .adapter_id + .split_once('/') + .is_some_and(|(plugin, adapter)| { + is_plugin_id(plugin) + && plugin.len() <= 128 + && is_identifier(adapter) + && adapter.len() <= 64 + }) + }) { + return Err(invalid()); + } + for arguments in [&self.runtime_arguments, &self.arguments] { + if arguments.len() > MAX_ARGUMENTS + || arguments + .iter() + .any(|arg| arg.len() > MAX_ARGUMENT_BYTES || arg.contains('\0')) + { + return Err(invalid()); + } + } + if self.environment.len() > MAX_ENVIRONMENT + || self.environment.iter().any(|(key, value)| { + !valid_environment_key(key) + || value.len() > MAX_ARGUMENT_BYTES + || value.contains('\0') + }) + || self + .entrypoint + .as_ref() + .is_some_and(|value| value.is_empty() || value.len() > 4096 || value.contains('\0')) + { + return Err(invalid()); + } + if let Some(cwd) = &self.working_directory { + if cwd.is_empty() + || cwd.len() > 4096 + || decode::request_path(cwd, "workingDirectory")? != *cwd + { + return Err(invalid()); + } + let workspace = Workspace::new(workspace_root.ok_or_else(invalid)?)?; + if !workspace.read_path(cwd).is_some_and(|path| path.is_dir()) { + return Err(invalid()); + } + } + Ok(()) + } +} + +/// Portable environment names shared by user inputs and returned launch plans. +fn valid_environment_key(key: &str) -> bool { + !key.is_empty() + && key.len() <= 128 + && key.bytes().enumerate().all(|(index, byte)| { + byte == b'_' || byte.is_ascii_alphabetic() || index > 0 && byte.is_ascii_digit() + }) +} + /// Host-selected model target and the run declaration that can prepare it. #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -23,18 +162,57 @@ pub(crate) struct PrepareRunRequest { pub(crate) target: SelectedEntity, #[serde(default)] pub(crate) user_arguments: Vec, + #[serde(default)] + pub(crate) workspace_root: Option, + #[serde(default)] + pub(crate) launch_options: Option, } /// Builds the public request without inferring language-specific launch facts. pub(crate) fn plan( build_system: &BuildSystemDeclaration, request: &PrepareRunRequest, + protocol_version: u32, ) -> Validation { let declaration = build_system .run .iter() .find(|run| run.id == request.run && run.source == TaskSource::Provider) .ok_or_else(|| plans::unavailable(build_system, "this provider run"))?; + let execution = declaration.execution.unwrap_or(BuildExecution::Application); + match &request.launch_options { + Some(options) => { + if options.debug.is_some() && protocol_version < 11 { + return Err(plans::invalid_selection( + "Runtime debug options require buildProviders 11.", + )); + } + if protocol_version < 10 || options.execution != execution { + return Err(plans::invalid_selection( + "Launch options require buildProviders 10 and the declared execution kind.", + )); + } + options.validate(request.workspace_root.as_deref())?; + if let Some(debug) = &options.debug { + if declaration + .debug + .as_ref() + .and_then(|binding| binding.configuration_type.as_ref()) + != Some(&debug.configuration_type) + { + return Err(plans::invalid_selection( + "The selected registry type must match this run's debug declaration.", + )); + } + } + } + None if execution != BuildExecution::Application => { + return Err(plans::invalid_selection( + "Service and task runs require launch options.", + )); + } + None => {} + } if request.base.output_file.is_some() { return Err(plans::invalid_selection( "Provider run preparation does not use an output file.", @@ -66,6 +244,9 @@ pub(crate) fn plan( let mut params = plans::provider_hints(&request.base)?; params.insert("run".to_string(), json!(declaration.id)); params.insert("userArguments".to_string(), json!(request.user_arguments)); + if let Some(options) = &request.launch_options { + params.insert("launchOptions".to_string(), json!(options)); + } for (name, selected) in [("project", project), ("target", target)] { params.insert( name.to_string(), @@ -92,12 +273,15 @@ pub(crate) struct DecodeRunPreparationRequest { pub(crate) workspace_root: String, pub(crate) root: String, pub(crate) result: Value, + #[serde(default)] + pub(crate) launch_options: Option, } /// Validates all launch data before a platform executable resolver sees it. pub(crate) fn decode( build_system: &BuildSystemDeclaration, request: &DecodeRunPreparationRequest, + protocol_version: u32, ) -> Validation { if !build_system .run @@ -107,6 +291,17 @@ pub(crate) fn decode( return Err(plans::unavailable(build_system, "run preparation")); } let invalid = || decode::invalid_output("Invalid provider run preparation result."); + if request.launch_options.is_some() && protocol_version < 10 { + return Err(invalid()); + } + if request + .launch_options + .as_ref() + .is_some_and(|options| options.debug.is_some()) + && protocol_version < 11 + { + return Err(invalid()); + } let value = &request.result; if serde_json::to_vec(value).map_or(usize::MAX, |bytes| bytes.len()) > decode::MAX_OUTPUT_BYTES { @@ -115,9 +310,8 @@ pub(crate) fn decode( let executable = value.get("executable").ok_or_else(invalid)?; let kind = executable["kind"].as_str().ok_or_else(invalid)?; let name = executable["value"].as_str().ok_or_else(invalid)?; - if name.len() > 120 - || !(kind == "command" && is_bare_command(name) - || kind == "toolchain" && is_identifier(name)) + if !(kind == "command" && name.len() <= 120 && is_bare_command(name) + || kind == "toolchain" && crate::toolchain::definitions::tool_id(name)) { return Err(invalid()); } @@ -152,11 +346,24 @@ pub(super) fn process_fields(request: &DecodeRunPreparationRequest) -> Validatio .read_path(&root) .filter(|path| path.is_dir()) .ok_or_else(invalid)?; - if !decode::contains(&root, &cwd) - || !workspace - .read_path(&cwd) - .is_some_and(|path| path.is_dir() && path.starts_with(&instance)) - { + let override_cwd = request + .launch_options + .as_ref() + .and_then(|options| options.working_directory.as_deref()); + if let Some(options) = &request.launch_options { + options + .validate(Some(&request.workspace_root)) + .map_err(|_| invalid())?; + } + let permitted = if let Some(expected) = override_cwd { + cwd == expected && workspace.read_path(&cwd).is_some_and(|path| path.is_dir()) + } else { + decode::contains(&root, &cwd) + && workspace + .read_path(&cwd) + .is_some_and(|path| path.is_dir() && path.starts_with(&instance)) + }; + if !permitted { return Err(invalid()); } let environment = value["environment"] @@ -164,12 +371,7 @@ pub(super) fn process_fields(request: &DecodeRunPreparationRequest) -> Validatio .filter(|env| env.len() <= MAX_ENVIRONMENT) .ok_or_else(invalid)?; for (key, value) in environment { - let valid_key = !key.is_empty() - && key.len() <= 128 - && key.bytes().enumerate().all(|(index, byte)| { - byte == b'_' || byte.is_ascii_alphabetic() || index > 0 && byte.is_ascii_digit() - }); - if !valid_key + if !valid_environment_key(key) || !value .as_str() .is_some_and(|text| text.len() <= MAX_ARGUMENT_BYTES && !text.contains('\0')) diff --git a/rust/lithe-core/src/build/stored_options.rs b/rust/lithe-core/src/build/stored_options.rs new file mode 100644 index 0000000000..935f2f931a --- /dev/null +++ b/rust/lithe-core/src/build/stored_options.rs @@ -0,0 +1,300 @@ +//! Pure team/local Build preference merging and declaration-checked edits. +//! Platforms own reads, conflict checks and atomic publication; no tool is run. + +use super::manifest::{BuildSystemDeclaration, OptionKind, OptionScope}; +use super::{options, project_options}; +use crate::plugins::declarative::{decode_payload, outcome, PluginRejection, Validation}; +use crate::protocol::CoreError; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, BTreeSet}; + +type EmptySelections = BTreeMap>>; + +/// Machine-local overrides use the existing document syntax, plus explicit +/// per-instance empty workspace selections needed to preserve sibling values. +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Local { + version: u32, + document: Value, + #[serde(default)] + empty_selections: EmptySelections, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ResolveRequest { + document: Value, + local: Value, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(super) struct EditRequest { + document: Value, + local: Value, + root: String, + #[serde(rename = "optionID")] + option_id: String, + value: Value, + #[serde(default)] + model_values: Option>>, +} + +fn invalid() -> PluginRejection { + PluginRejection::new( + "plugin.build.invalidOptionsDocument", + "Invalid Build preferences", + ) +} + +fn validate_local(value: Value) -> Validation { + let mut local: Local = serde_json::from_value(value).map_err(|_| invalid())?; + if local.version != 1 || local.empty_selections.len() > 32 { + return Err(invalid()); + } + local.document = project_options::validate(local.document)?; + for (system, roots) in &local.empty_selections { + if !local.document["systems"] + .get(system) + .is_some_and(Value::is_object) + || roots.len() > 256 + || roots.iter().any(|(root, ids)| { + !project_options::root_valid(root) + || ids.len() > 256 + || ids.iter().any(|id| { + id.is_empty() + || id.len() > 128 + || !id + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"_.-".contains(&b)) + }) + }) + { + return Err(invalid()); + } + } + if serde_json::to_vec(&local).map_or(true, |bytes| bytes.len() > 2 * 1024 * 1024) { + return Err(invalid()); + } + Ok(local) +} + +fn merge(team: &Value, local: &Local) -> Value { + let mut document = team.clone(); + let systems = document["systems"] + .as_object_mut() + .expect("validated document"); + for (id, patch) in local.document["systems"] + .as_object() + .expect("validated local document") + { + let target = systems.entry(id.clone()).or_insert_with(|| { + json!({ + "options":{},"instanceDefaults":{},"instances":{},"savedChoices":{} + }) + }); + for key in ["options", "instanceDefaults", "savedChoices"] { + target[key] + .as_object_mut() + .expect("normalized map") + .extend(patch[key].as_object().expect("normalized map").clone()); + } + for (root, values) in patch["instances"].as_object().expect("normalized roots") { + let roots = target["instances"] + .as_object_mut() + .expect("normalized roots"); + roots + .entry(root.clone()) + .or_insert_with(|| json!({})) + .as_object_mut() + .expect("normalized values") + .extend(values.as_object().expect("normalized values").clone()); + } + } + document +} + +fn effective( + system: &BuildSystemDeclaration, + document: &Value, + local: &Local, + root: &str, +) -> BTreeMap { + let stored = &document["systems"][&system.id]; + system + .options + .iter() + .filter_map(|option| { + let value = if option.scope == OptionScope::Instance { + stored["instances"][root] + .get(&option.id) + .or_else(|| stored["instanceDefaults"].get(&option.id)) + } else if option.kind == OptionKind::MultiSelect + && local + .empty_selections + .get(&system.id) + .and_then(|roots| roots.get(root)) + .is_some_and(|ids| ids.contains(&option.id)) + { + return Some((option.id.clone(), json!([]))); + } else { + stored["options"].get(&option.id) + }; + value.map(|value| (option.id.clone(), value.clone())) + }) + .collect() +} + +/// Resolves storage only, retaining inactive systems and custom choices. +pub(crate) fn resolve_stored_options_command(payload: Value) -> Result { + let request: ResolveRequest = decode_payload(payload, "build.resolveStoredOptions")?; + Ok(outcome((|| { + let team = project_options::validate(request.document)?; + let local = validate_local(request.local)?; + Ok(( + "storage", + json!({"document":merge(&team,&local),"local":local,"emptySelections":local.empty_selections}), + )) + })())) +} + +/// Produces one local document; the supplied team document is never edited. +pub(super) fn edit(system: &BuildSystemDeclaration, request: &EditRequest) -> Validation { + if !project_options::root_valid(&request.root) { + return Err(invalid()); + } + let team = project_options::validate(request.document.clone())?; + let mut local = validate_local(request.local.clone())?; + let option = system + .options + .iter() + .find(|option| option.id == request.option_id) + .ok_or_else(|| { + PluginRejection::new("plugin.build.invalidOption", "Unknown declared option") + })?; + let before = merge(&team, &local); + let current = effective(system, &before, &local, &request.root); + let mut next = request.value.clone(); + if !next.is_null() { + let resolved = options::resolve( + system, + &BTreeMap::from([(option.id.clone(), next)]), + request.model_values.as_ref(), + ); + next = resolved + .chosen_values + .get(&option.id) + .filter(|_| resolved.dropped.is_empty()) + .cloned() + .ok_or_else(|| { + PluginRejection::new( + "plugin.build.invalidOption", + "The option value is not accepted by its declaration", + ) + })?; + } + let systems = local.document["systems"] + .as_object_mut() + .expect("normalized systems"); + let target = systems.entry(system.id.clone()).or_insert_with(|| { + json!({ + "options":{},"instanceDefaults":{},"instances":{},"savedChoices":{} + }) + }); + let values = if option.scope == OptionScope::Instance { + target["instances"] + .as_object_mut() + .expect("normalized roots") + .entry(request.root.clone()) + .or_insert_with(|| json!({})) + .as_object_mut() + .expect("normalized values") + } else { + target["options"] + .as_object_mut() + .expect("normalized options") + }; + if next.is_null() { + values.remove(&option.id); + if let Some(roots) = local.empty_selections.get_mut(&system.id) { + for ids in roots.values_mut() { + ids.remove(&option.id); + } + } + } else { + if option.scope == OptionScope::Workspace && option.kind == OptionKind::MultiSelect { + let visible = options::resolve(system, ¤t, request.model_values.as_ref()) + .chosen_values + .get(&option.id) + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let raw = before["systems"][&system.id]["options"][&option.id] + .as_array() + .cloned() + .unwrap_or_default(); + let mut merged = raw + .into_iter() + .filter(|value| !visible.contains(value)) + .collect::>(); + for value in next.as_array().expect("checked multi-select") { + if !merged.contains(value) { + merged.push(value.clone()); + } + } + let ids = local + .empty_selections + .entry(system.id.clone()) + .or_default() + .entry(request.root.clone()) + .or_default(); + if next.as_array().is_some_and(Vec::is_empty) { + ids.insert(option.id.clone()); + } else { + ids.remove(&option.id); + } + next = json!(merged); + } + values.insert(option.id.clone(), next); + } + for roots in local.empty_selections.values_mut() { + roots.retain(|_, ids| !ids.is_empty()); + } + local.empty_selections.retain(|_, roots| !roots.is_empty()); + // A no-op reset must not materialize an empty machine override. Limit + // pruning to the edited system/root; dormant user data stays untouched. + let target = &mut local.document["systems"][&system.id]; + if target["instances"] + .get(&request.root) + .is_some_and(|values| values.as_object().is_some_and(serde_json::Map::is_empty)) + { + target["instances"] + .as_object_mut() + .expect("normalized roots") + .remove(&request.root); + } + if ["options", "instanceDefaults", "instances", "savedChoices"] + .iter() + .all(|key| { + target[*key] + .as_object() + .is_some_and(serde_json::Map::is_empty) + }) + && !local.empty_selections.contains_key(&system.id) + { + local.document["systems"] + .as_object_mut() + .expect("normalized systems") + .remove(&system.id); + } + // Revalidate aggregate bounds after insertion, without pruning unrelated data. + let local = validate_local(serde_json::to_value(local).map_err(|_| invalid())?)?; + let document = merge(&team, &local); + let stored = effective(system, &document, &local, &request.root); + let resolved = options::resolve(system, &stored, request.model_values.as_ref()); + Ok( + json!({"local":local,"options":options::describe(system,&resolved,request.model_values.as_ref())}), + ) +} diff --git a/rust/lithe-core/src/build/test_discovery.rs b/rust/lithe-core/src/build/test_discovery.rs index d3b9d19f28..385e7e6928 100644 --- a/rust/lithe-core/src/build/test_discovery.rs +++ b/rust/lithe-core/src/build/test_discovery.rs @@ -64,6 +64,8 @@ pub(crate) fn decode( let plan = plans::test_plan( build_system, &TestPlanRequest { + selector_kind: None, + selectors: Vec::new(), base: request.base.clone(), project: selector.project, target: selector.target, diff --git a/rust/lithe-core/src/execution/configuration.rs b/rust/lithe-core/src/execution/configuration.rs index cc2c36dd7b..dedceb0056 100644 --- a/rust/lithe-core/src/execution/configuration.rs +++ b/rust/lithe-core/src/execution/configuration.rs @@ -1,5 +1,8 @@ //! Run-configuration schemas, layered overrides, and deterministic generation. +mod project_toolchain; +pub(crate) use project_toolchain::{project_toolchain, ProjectToolchainRequest}; + use super::generated_layer::{self, GeneratedLayer}; use super::types::{Confidence, Execution, RunCategory}; use crate::languages::{JavaEntrypointFact, JavaEntrypointFacts}; @@ -13,9 +16,9 @@ use std::path::{Component, Path, PathBuf}; const VERSION: u32 = 2; const LEGACY_VERSION: u32 = 1; -// Bumped when generation changes what a workspace should contain: a detected -// layer from an older generator is reported stale instead of trusted. -const GENERATOR_REVISION: &str = "12"; +// Bumped when the detected layer changes; stale cached facts must regenerate. +const GENERATOR_REVISION: &str = "19"; + /// Toolchain requirements and `project.json` are separate documents that happen /// to live under `.lithe`. Their schema did not change with run-config v2, so /// they keep their own version and must not be validated against `VERSION`. @@ -42,6 +45,12 @@ pub struct InspectRequest { /// Java entries still match them; omitted while JDT is not ready. #[serde(default)] pub java_entrypoints: Option, + /// Current enabled Provider facts; omission leaves legacy clients unchanged. + #[serde(default)] + pub project_ownership: Option>, + /// Complete generic run selectors; omission preserves older callers. + #[serde(default)] + pub provider_runs: Option>, } #[derive(Debug, Deserialize)] @@ -53,6 +62,13 @@ pub struct GenerateRequest { pub paths: Vec, #[serde(default)] pub module_paths: Vec, + /// Current provider ownership, including an authoritative empty snapshot. + /// Omitted retains compatibility for the existing Windows generator. + #[serde(default)] + pub project_ownership: Option>, + /// Complete generic run selectors; omission preserves older callers. + #[serde(default)] + pub provider_runs: Option>, /// Launchable classes from the Java language service (`lsp.request` /// operation `javaEntrypoints`). Absent while that service is not ready: /// the previous generation's Java entries are carried forward instead of @@ -76,6 +92,9 @@ pub struct ResolveRequest { /// Host-owned local layer. When present, Core uses it instead of `.lithe/run/local.json`. #[serde(default)] pub local_document: Option, + /// Optional prepared team layer for a host transaction preflight; never persisted by Core. + #[serde(default)] + pub team_document: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -116,10 +135,28 @@ pub struct LaunchPlanRequest { pub maven_context: Option, } +/// Resolves saved Run choices without assembling a build tool's command line. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ProviderLaunchInputsRequest { + /// Canonical workspace owned by the native host. + pub root: String, + /// Stable identity resolved through the generated/team/local document layers. + pub configuration_id: String, + /// Current provider instance, supplied by the owning build model. + pub provider_root: String, + /// Optional host-owned replacement for the machine-local document. + #[serde(default)] + pub local_document: Option, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] /// Runtime path groups resolved by the Java project model before launch. pub struct JavaLaunchRequest { + /// Host receipt: the current provider task already prepared build resources. + #[serde(default)] + pub build_prepared: bool, pub main_class: String, #[serde(default)] pub class_paths: Vec, @@ -140,6 +177,66 @@ pub struct ToolchainPaths { /// Explicit executables for generic runtime toolchain IDs such as `project-node`. #[serde(default)] pub runtime_executable_paths: BTreeMap, + /// Machine-local selections by subproject root. Absent preserves saved + /// selections; an explicit empty map clears them. + #[serde(default)] + pub path_overrides: Option>, + /// Generic consumer preferences in the same machine-local document. + /// Absent preserves saved choices; an empty v1 document resets them. + #[serde(default)] + pub preferences: Option, +} + +/// Replaces only generic preferences in a captured machine-local Run document. +/// Legacy fields remain owned by their existing consumers until cutover. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct UpdateToolPreferencesRequest { + /// Required captured local layer; this command never reads native files. + pub local_document: Value, + /// Inert v1 preferences already edited by the shared preference command. + pub preferences: Value, +} + +/// Produces a Run-document replacement without materializing legacy defaults. +pub fn update_tool_preferences(request: UpdateToolPreferencesRequest) -> Result { + let mut document = request.local_document; + // A preference edit must not trigger legacy Run migration or silently + // narrow an unknown 64-bit version into a supported document version. + if !matches!(document["version"].as_u64(), Some(1 | 2)) { + return Err(CoreError::new( + ErrorCode::NotSupported, + "Unsupported run configuration version", + )); + } + configuration_ids(&document)?; + validate_toolchain_path_overrides(&document)?; + crate::toolchain::preferences::validate(&request.preferences) + .map_err(|error| CoreError::new(ErrorCode::InvalidRequest, error.message))?; + let object = document + .as_object_mut() + .ok_or_else(|| CoreError::new(ErrorCode::InvalidRequest, "Invalid local Run document"))?; + let toolchain = object.entry("toolchain").or_insert_with(|| json!({})); + let toolchain = toolchain.as_object_mut().ok_or_else(|| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid local tool preferences") + })?; + toolchain.insert("preferences".into(), request.preferences); + Ok(json!({"document":serde_json::to_string_pretty(&document).expect("validated Run document")})) +} + +/// Tool paths owned by a subproject; empty fields inherit the project defaults. +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PathToolchainOverride { + /// JDK home; empty inherits the project JDK. + #[serde(default)] + pub java_home_path: String, + /// Maven executable or wrapper selection; empty inherits the project choice. + #[serde(default)] + pub maven_executable_path: String, + /// Maven's JDK home; empty inherits the project Maven JDK. + #[serde(default)] + pub maven_java_home_path: String, } #[derive(Debug, Deserialize)] @@ -173,6 +270,12 @@ pub struct UpdateOptionsRequest { pub maven_executable_path: String, #[serde(default)] pub maven_java_home_path: String, + /// Portable provider choices. Omission preserves the existing namespace. + #[serde( + default, + deserialize_with = "super::provider_run_options::optional_request_options" + )] + pub provider_run_options: Option, /// When present, `updateOptions` writes the document-level global toolchain /// into the local layer instead of patching one configuration. #[serde(default)] @@ -216,13 +319,31 @@ pub struct BuildProviderRunReference { pub project_id: String, #[serde(rename = "targetID")] pub target_id: String, + /// Omitted older references are applications; the current declaration must match at launch. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub execution: Option, #[serde(default)] pub user_arguments: Vec, } impl BuildProviderRunReference { + /// Stable generated identity excludes mutable display names, execution kind and arguments. + pub(super) fn generated_id(&self) -> String { + use sha2::{Digest, Sha256}; + let selector = json!([ + self.plugin_id, + self.build_system_id, + self.root, + self.run, + self.project_id, + self.target_id + ]); + let bytes = serde_json::to_vec(&selector).expect("selector contains only strings"); + format!("build.provider:{:x}", Sha256::digest(bytes)) + } + /// Persistence validates identities only; current model membership is checked at launch. - fn validate(&self) -> Result<(), CoreError> { + pub(super) fn validate(&self) -> Result<(), CoreError> { let identifier = |value: &str| { !value.is_empty() && value.len() <= 200 @@ -230,7 +351,8 @@ impl BuildProviderRunReference { .bytes() .all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte)) }; - if !identifier(&self.plugin_id) + if self.execution == Some(Execution::Group) + || !identifier(&self.plugin_id) || !identifier(&self.build_system_id) || !identifier(&self.run) || [&self.project_id, &self.target_id] @@ -346,6 +468,12 @@ fn migrate_configuration_value(item: &mut Value) { /// or to `path` for a Java source that only counts by presence. pub struct GeneratorMetadata { pub fingerprint: String, + /// Identity of the Provider facts that generated this document. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_fingerprint: Option, + /// Identity of generic run declarations and opaque targets, separate from Java facts. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_run_fingerprint: Option, /// Unreadable inputs retained as unknown; a partial baseline cannot prove freshness. #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] pub unreadable_paths: BTreeSet, @@ -419,10 +547,12 @@ impl RunConfiguration { pub fn main_class(&self) -> Option { self.extension_string("maven", "mainClass") + .or_else(|| self.extension_string("java", "mainClass")) } pub fn module(&self) -> Option { self.extension_string("maven", "module") + .or_else(|| self.extension_string("java", "module")) } } @@ -505,6 +635,8 @@ pub fn inspect(request: InspectRequest) -> Result { } } let local = local_layer_document(&root, request.local_document.clone())?; + let team = read_document_value(&root, "run/configurations.json")? + .unwrap_or_else(|| json!({"version": VERSION, "configurations": []})); if let Some((document, metadata)) = generated .and_then(|document| Some((document, document.generator.as_ref()?))) .filter(|_| request.check_fingerprint != Some(false)) @@ -536,6 +668,37 @@ pub fn inspect(request: InspectRequest) -> Result { })); } } + if let Some(projects) = &request.project_ownership { + super::project_ownership::validate(projects)?; + if let Some(document) = generated { + let expected = super::project_ownership::fingerprint(projects); + if document + .generator + .as_ref() + .and_then(|metadata| metadata.provider_fingerprint.as_deref()) + != Some(expected.as_str()) + { + diagnostics.push(json!({"code":"staleProviderModel", "message":"Build provider model changed; regenerate Run configurations"})); + } + } + } + if let Some(runs) = &request.provider_runs { + super::provider_runs::validate(runs)?; + if let Some(document) = generated { + let expected = super::provider_runs::fingerprint(runs); + if !diagnostics + .iter() + .any(|item| item["code"] == "staleProviderModel") + && document + .generator + .as_ref() + .and_then(|metadata| metadata.provider_run_fingerprint.as_deref()) + != Some(expected.as_str()) + { + diagnostics.push(json!({"code":"staleProviderModel", "message":"Build provider model changed; regenerate Run configurations"})); + } + } + } if let (Some(document), Some(current)) = (generated, request.java_entrypoints.as_ref()) { // Source hashing no longer notices a main method added to an existing // class; JDT's answer does, and is cheaper than reading every source. @@ -579,7 +742,7 @@ pub fn inspect(request: InspectRequest) -> Result { } } Ok(json!({ - "status": if generated.is_some() || has_saved_build_run(&local) { "ready" } else { "missing" }, + "status": if generated.is_some() || has_saved_build_run(&local) || has_saved_build_run(&team) { "ready" } else { "missing" }, // Saved provider tasks remain runnable before detection, but must not // suppress discovery of the other entries after a process restart. "requiresGeneration": generated.is_none(), @@ -607,7 +770,14 @@ pub fn generate(request: GenerateRequest) -> Result { .collect::>(); paths.sort(); paths.dedup(); - let maven_root = crate::project::maven_root(&root, &paths)?; + if let Some(projects) = &request.project_ownership { + super::project_ownership::validate(projects)?; + } + let maven_root = if request.project_ownership.is_none() { + crate::project::maven_root(&root, &paths)? + } else { + None + }; let maven_relative_path = maven_root .as_ref() .map(|(_, relative_path)| relative_path.as_str()); @@ -615,8 +785,8 @@ pub fn generate(request: GenerateRequest) -> Result { .iter() .any(|path| path.to_lowercase().ends_with(".java")); let has_maven_project = maven_relative_path.is_some() - || root.join("mvnw").is_file() - || root.join("mvnw.cmd").is_file(); + || (request.project_ownership.is_none() + && (root.join("mvnw").is_file() || root.join("mvnw.cmd").is_file())); // A Gradle build needs the same JDK requirement as Maven, and its sources may // be Kotlin or Groovy rather than Java, so the build files count on their own. let has_gradle_project = ["build.gradle", "build.gradle.kts", "gradlew"] @@ -626,7 +796,7 @@ pub fn generate(request: GenerateRequest) -> Result { let configured_module_paths = request .module_paths .into_iter() - .map(|path| workspace_maven_path(maven_relative_path, &path)) + .map(|path| workspace_build_path(maven_relative_path, &path)) .collect(); // Note: entry-point ownership is recorded in .agents/notes/implemented/architecture/2026-09-21-java-entrypoints-owned-by-jdt.md let (mut java_entrypoints, java_entrypoints_origin) = match request.java_entrypoints { @@ -651,7 +821,13 @@ pub fn generate(request: GenerateRequest) -> Result { .iter() .map(|entry| entry.source_path.clone()) .collect::>(); - let module_paths = inferred_maven_module_paths(&root, &entry_sources, configured_module_paths); + let module_paths = match &request.project_ownership { + Some(projects) => projects + .iter() + .map(|project| project.path.clone()) + .collect(), + None => inferred_relative_build_projects(&root, &entry_sources, configured_module_paths), + }; let scanned = crate::languages::run_configurations_from_entrypoints( &root, &java_entrypoints, @@ -667,7 +843,20 @@ pub fn generate(request: GenerateRequest) -> Result { let configurations = scanned .configurations .into_iter() - .map(|value| -> Result { + .map(|mut value| -> Result { + let model_owner = request + .project_ownership + .as_ref() + .and_then(|projects| super::project_ownership::owner(projects, &value.source_path)); + if request.project_ownership.is_some() { + value.module_path = model_owner + .filter(|owner| owner.path != ".") + .map(|owner| owner.path.clone()); + value.source_set = model_owner + .map_or(crate::protocol::JavaSourceSetResponse::Other, |owner| { + owner.source_set(&value.source_path) + }); + } let provider = match value.kind.as_str() { "javaMain" | "springBoot" => "java.main", "mavenModule" => "maven.module", @@ -675,7 +864,10 @@ pub fn generate(request: GenerateRequest) -> Result { }; let id = java_configuration_id(&value); let owner_key = value.module_path.clone(); - let maven_owner = if let Some(owner) = maven_owners.get(&owner_key) { + let build_owner = model_owner.and_then(|owner| owner.build.as_ref()); + let maven_owner = if request.project_ownership.is_some() { + None + } else if let Some(owner) = maven_owners.get(&owner_key) { owner.clone() } else { let owner = maven_owner_for_module( @@ -686,19 +878,16 @@ pub fn generate(request: GenerateRequest) -> Result { maven_owners.insert(owner_key, owner.clone()); owner }; - let owner_relative_path = maven_owner - .as_ref() - .map(|(_, relative_path)| relative_path.as_str()); - let mut maven = serde_json::Map::new(); + let owner_relative_path = build_owner.map(|owner| owner.root.as_str()).or_else(|| { + maven_owner + .as_ref() + .map(|(_, relative_path)| relative_path.as_str()) + }); let module_path = value .module_path .as_deref() - .map(|path| maven_module_path(owner_relative_path, path)) - .unwrap_or_else(|| ".".to_string()); - maven.insert("module".to_string(), json!(module_path)); - if let Some(main_class) = value.main_class.as_ref() { - maven.insert("mainClass".to_string(), json!(main_class)); - } + .map(|path| relative_build_project(owner_relative_path, path)) + .unwrap_or_else(dot); let source_path = value.source_path; // Maven ownership is per entry: one workspace can contain standalone // Java files or multiple independent reactors in the same request. @@ -709,11 +898,22 @@ pub fn generate(request: GenerateRequest) -> Result { toolchains.insert("maven".to_string(), "project-maven".to_string()); } let mut extensions = BTreeMap::new(); - extensions.insert("maven".to_string(), Value::Object(maven)); - extensions.insert( - "java".to_string(), - json!({ "source": source_path, "sourceSet": value.source_set }), - ); + let mut java = json!({ "source": source_path, "sourceSet": value.source_set }); + if request.project_ownership.is_some() { + java["module"] = json!(module_path); + java["mainClass"] = json!(value.main_class); + if let Some(build) = build_owner { + java["build"] = json!(build); + } + } else { + let mut maven = serde_json::Map::new(); + maven.insert("module".to_string(), json!(module_path)); + if let Some(main_class) = value.main_class.as_ref() { + maven.insert("mainClass".to_string(), json!(main_class)); + } + extensions.insert("maven".to_string(), Value::Object(maven)); + } + extensions.insert("java".to_string(), java); Ok(RunConfiguration { id, name: value.name, @@ -779,21 +979,45 @@ pub fn generate(request: GenerateRequest) -> Result { .collect::>(); let mut detected = detected_configurations( &root, - maven_root.as_ref().map(|(path, _)| path.as_path()), + (request.project_ownership.is_none() && request.provider_runs.is_none()) + .then(|| maven_root.as_ref().map(|(path, _)| path.as_path())) + .flatten(), &claimed, )?; - retain_confirmed_spring_services(&mut detected, &spring_entrypoints, maven_relative_path); + if let Some(runs) = &request.provider_runs { + super::provider_runs::validate(runs)?; + detected.extend( + super::provider_runs::configurations(runs) + .into_iter() + .filter(|item| !claimed.contains(&item.id)), + ); + } + if request.project_ownership.is_none() && request.provider_runs.is_none() { + retain_confirmed_spring_services(&mut detected, &spring_entrypoints, maven_relative_path); + } // Counts real entry points, not documents: the always-present "Current File" // fallback is excluded so an empty project still reports zero and the UI can // say so, while a detected npm service correctly reports one. configurations.extend(detected); let mut snapshot = project_inputs(&root, &java_entry_sources(&configurations))?; // Unknown subtrees cannot delete known configurations or baseline inputs. + // Invalid required documents still fail instead of being silently overwritten. + let authoritative_service_ids = request.provider_runs.as_ref().map(|_| { + configurations + .iter() + .filter(|item| item.provider == "build.provider") + .map(|item| item.id.clone()) + .collect::>() + }); + if !snapshot.unreadable_paths.is_empty() { if let Some(previous) = previous { retain_unknown_inputs(&mut configurations, &mut snapshot, previous); } } + if let Some(ids) = authoritative_service_ids { + configurations.retain(|item| item.provider != "build.provider" || ids.contains(&item.id)); + } disambiguate_configuration_names(&mut configurations); let entry_count = configurations .iter() @@ -804,10 +1028,19 @@ pub fn generate(request: GenerateRequest) -> Result { maven_root.as_ref().map(|(path, _)| path.as_path()), has_java_ecosystem, &configurations, + request.project_ownership.as_deref(), )?; let generated = RunConfigurationDocument { version: VERSION, generator: Some(GeneratorMetadata { + provider_fingerprint: request + .project_ownership + .as_deref() + .map(super::project_ownership::fingerprint), + provider_run_fingerprint: request + .provider_runs + .as_deref() + .map(super::provider_runs::fingerprint), fingerprint: fingerprint_from_inputs(&snapshot.inputs), inputs: snapshot.inputs, unreadable_paths: snapshot.unreadable_paths, @@ -983,16 +1216,16 @@ fn disambiguate_configuration_names(configurations: &mut [RunConfiguration]) { } } -fn workspace_maven_path(maven_root: Option<&str>, path: &str) -> String { - match (maven_root, path) { +fn workspace_build_path(build_root: Option<&str>, path: &str) -> String { + match (build_root, path) { (Some(root), ".") => root.to_string(), (Some(root), path) if root != "." => format!("{root}/{path}"), _ => path.to_string(), } } -fn maven_module_path(maven_root: Option<&str>, path: &str) -> String { - let Some(root) = maven_root.filter(|root| *root != ".") else { +fn relative_build_project(build_root: Option<&str>, path: &str) -> String { + let Some(root) = build_root.filter(|root| *root != ".") else { return path.to_string(); }; if path == root { @@ -1045,13 +1278,13 @@ fn java_configuration_id(value: &crate::protocol::JavaRunConfigurationResponse) /// main classes that belong to different modules. Repeated source paths within /// one module are genuine duplicates and still collapse to one configuration. fn deduplicate_java_configurations(configurations: Vec) -> Vec { - let mut grouped = BTreeMap::>::new(); + let mut grouped = BTreeMap::>::new(); for configuration in configurations { let module = java_module_identity(configuration.module().as_deref()); grouped .entry(configuration.id.clone()) .or_default() - .entry(module) + .entry((configuration.cwd.clone(), module)) .or_insert(configuration); } @@ -1059,12 +1292,27 @@ fn deduplicate_java_configurations(configurations: Vec) -> Vec .into_iter() .flat_map(|(base_id, modules)| { let has_module_collision = modules.len() > 1; - modules.into_iter().map(move |(module, mut configuration)| { - if has_module_collision { - configuration.id = format!("{base_id}:{module}"); - } - configuration - }) + let separate_reactors = modules + .keys() + .map(|(root, _)| root) + .collect::>() + .len() + > 1; + modules + .into_iter() + .map(move |((root, module), mut configuration)| { + if has_module_collision { + // Preserve existing single-reactor IDs, but two independent + // reactors can both have a module named `app` and main `Main`. + let module = if separate_reactors { + workspace_build_path(Some(&root), &module) + } else { + module + }; + configuration.id = format!("{base_id}:{module}"); + } + configuration + }) }) .collect() } @@ -1100,10 +1348,8 @@ fn retain_confirmed_spring_services( let Some(module) = configuration.module() else { return false; }; - let workspace_module = workspace_maven_path(maven_root, &module); + let workspace_module = workspace_build_path(maven_root, &module); let mut matches = entries.iter().filter(|entry| { - // A nested module's main cannot make its parent launchable. Test - // mains remain Java entries and do not promote a library to a service. java_module_identity(entry.module_path.as_deref()) == workspace_module && entry.source_set != crate::protocol::JavaSourceSetResponse::Test }); @@ -1120,9 +1366,10 @@ fn retain_confirmed_spring_services( if let Some(object) = maven.as_object_mut() { object.insert("mainClass".to_string(), json!(entry.main_class)); } + let source_set = entry.source_set; configuration.extensions.insert( "java".to_string(), - json!({ "source": entry.source_path, "sourceSet": entry.source_set }), + json!({ "source": entry.source_path, "sourceSet": source_set }), ); true }); @@ -1164,7 +1411,7 @@ fn detected_configurations( .collect()) } -fn inferred_maven_module_paths( +fn inferred_relative_build_projects( root: &Path, paths: &[String], configured: Vec, @@ -1218,13 +1465,21 @@ fn is_nested_checkout_path(value: &str) -> bool { /// Merges generated, team, and local layers and selects compatible toolchains. pub fn resolve(request: ResolveRequest) -> Result { let root = existing_root(&request.root)?; - let team = read_document_value(&root, "run/configurations.json")? - .unwrap_or_else(|| json!({"version": VERSION, "configurations": []})); + let team = match request.team_document { + Some(mut document) => { + migrate_document_value(&mut document); + document + } + None => read_document_value(&root, "run/configurations.json")? + .unwrap_or_else(|| json!({"version": VERSION, "configurations": []})), + }; let local = local_layer_document(&root, request.local_document)?; let layer = generated_layer::get(&root); let generated = match layer.as_ref() { Some(layer) => serde_json::to_value(&layer.document).expect("run configurations encode"), - None if has_saved_build_run(&local) => json!({"version": VERSION, "configurations": []}), + None if has_saved_build_run(&local) || has_saved_build_run(&team) => { + json!({"version": VERSION, "configurations": []}) + } None => { return Err(CoreError::new( ErrorCode::WorkspaceNotFound, @@ -1240,11 +1495,16 @@ pub fn resolve(request: ResolveRequest) -> Result { if let Some(manifest) = manifest.as_ref() { validate_sidecar_version_value(manifest)?; } - let generated_ids = configuration_ids(&generated)?; + configuration_ids(&generated)?; + let mut configurations = merge_values(&generated, &team, &local)?; + let resolved_ids = configurations + .iter() + .map(|item| item.id.as_str()) + .collect::>(); let mut diagnostics = Vec::new(); for source in [&team, &local] { for id in configuration_ids(source)?.keys() { - if !generated_ids.contains_key(id) && !id.starts_with("user:") { + if !resolved_ids.contains(id.as_str()) && !id.starts_with("user:") { diagnostics.push(json!({ "id": id, "code": "orphanedOverride", @@ -1253,8 +1513,8 @@ pub fn resolve(request: ResolveRequest) -> Result { } } } - let mut configurations = merge_values(&generated, &team, &local)?; apply_maven_reactor_ownership(&mut configurations, &generated); + apply_build_project_ownership(&mut configurations, &generated); normalize_runtime_consumption(&mut configurations); let global_toolchain = local.get("toolchain").cloned(); if let Some(toolchain) = global_toolchain.as_ref() { @@ -1298,9 +1558,15 @@ pub fn resolve(request: ResolveRequest) -> Result { // the module itself must not turn `web` into `web/web`. Entries // without detected ownership were validated against the root. let reactor = configuration - .extension_string("maven", "reactorPath") + .extensions + .get("java") + .and_then(|java| java.get("build")) + .and_then(|build| build.get("root")) + .and_then(Value::as_str) + .map(str::to_owned) + .or_else(|| configuration.extension_string("maven", "reactorPath")) .unwrap_or_else(dot); - let workspace_module = workspace_maven_path(Some(&reactor), &module); + let workspace_module = workspace_build_path(Some(&reactor), &module); if !project_directory_exists(&root, &workspace_module) { configuration.disabled = true; diagnostics.push(json!({ @@ -1371,23 +1637,39 @@ pub fn resolve(request: ResolveRequest) -> Result { })) } -/// Configuration to select when the project names no default: a framework -/// service is what the user most likely starts first, whichever framework the -/// project uses; otherwise the first detected entry that is not Current File. +/// Prefer a detected service when the project names no default, otherwise +/// select the first available entry other than Current File. fn detected_default( generated: &RunConfigurationDocument, available: &[RunConfiguration], ) -> Option { - const FRAMEWORK_PROVIDERS: [&str; 3] = - ["spring-boot.maven", "quarkus.maven", "micronaut.maven"]; + let by_id = available + .iter() + .map(|item| (item.id.as_str(), item)) + .collect::>(); + let mut by_selector = BTreeMap::new(); + for configuration in available { + if let Some(selector) = super::provider_runs::selector( + &serde_json::to_value(configuration).expect("configuration encodes"), + ) { + by_selector.entry(selector).or_insert(configuration); + } + } let detected = generated .configurations .iter() .filter(|configuration| configuration.id != "current-file") - .filter(|configuration| available.iter().any(|item| item.id == configuration.id)); + .filter_map(|configuration| { + by_id.get(configuration.id.as_str()).copied().or_else(|| { + let selector = super::provider_runs::selector( + &serde_json::to_value(configuration).expect("configuration encodes"), + )?; + by_selector.get(&selector).copied() + }) + }); detected .clone() - .find(|configuration| FRAMEWORK_PROVIDERS.contains(&configuration.provider.as_str())) + .find(|configuration| configuration.execution == Execution::Service) .or_else(|| detected.clone().next()) .map(|configuration| configuration.id.clone()) } @@ -1426,6 +1708,43 @@ fn apply_maven_reactor_ownership(configurations: &mut [RunConfiguration], genera } } +/// Build identity is a current model fact, never a user override or inferred cwd. +fn apply_build_project_ownership(configurations: &mut [RunConfiguration], generated: &Value) { + let owners = generated["configurations"] + .as_array() + .into_iter() + .flatten() + .filter_map(|value| { + Some(( + value["id"].as_str()?, + value["extensions"]["java"].get("build")?.clone(), + )) + }) + .collect::>(); + for configuration in configurations { + if let Some(java) = configuration + .extensions + .get_mut("java") + .and_then(Value::as_object_mut) + { + java.remove("build"); + } + if let Some(owner) = owners.get(configuration.id.as_str()) { + // A null or scalar namespace must not revoke a current model owner. + let java = configuration + .extensions + .entry("java".into()) + .or_insert_with(|| json!({})); + if !java.is_object() { + *java = json!({}); + } + java.as_object_mut() + .expect("normalized namespace") + .insert("build".into(), owner.clone()); + } + } +} + fn apply_global_toolchain(configurations: &mut [RunConfiguration], toolchain: &Value) { let java_home = toolchain["java"]["homePath"].as_str().unwrap_or(""); let maven_executable = toolchain["maven"]["executablePath"].as_str().unwrap_or(""); @@ -1517,6 +1836,18 @@ fn update_toolchain_document( ) -> Result { let mut document = local_layer_document(root, local_document)?; validate_version_value(&document)?; + let preferences = toolchain + .preferences + .or_else(|| document["toolchain"].get("preferences").cloned()); + let overrides = toolchain + .path_overrides + .map(|value| json!(value)) + .or_else(|| { + document + .get("toolchain") + .and_then(|value| value.get("pathOverrides")) + .cloned() + }); document["toolchain"] = json!({ "java": { "homePath": toolchain.java_home_path }, "maven": { @@ -1524,6 +1855,13 @@ fn update_toolchain_document( "javaHomePath": toolchain.maven_java_home_path } }); + if let Some(overrides) = overrides { + document["toolchain"]["pathOverrides"] = overrides; + } + if let Some(preferences) = preferences { + document["toolchain"]["preferences"] = preferences; + } + validate_toolchain_path_overrides(&document)?; Ok(document) } @@ -1562,6 +1900,7 @@ fn update_configuration_options( ) -> Result { let relative = scope_document(&request.scope)?; let resolved = resolve(ResolveRequest { + team_document: None, root: request.root.clone(), toolchain_candidates: Vec::new(), local_document: request.local_document.clone(), @@ -1578,6 +1917,15 @@ fn update_configuration_options( CoreError::new(ErrorCode::InvalidRequest, "Run configuration was not found") })?; let uses_maven_capability = is_maven_backed(provider); + if let Some(options) = &request.provider_run_options { + if provider != "build.provider" { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Provider run options require a build provider reference", + )); + } + options.validate()?; + } let java_home_path = normalize_scoped_toolchain_path(&root, &request.scope, &request.java_home_path)?; let maven_executable_path = @@ -1622,7 +1970,12 @@ fn update_configuration_options( if !maven_java_home_path.is_empty() { java_extension.insert("mavenJavaHomePath".to_string(), json!(maven_java_home_path)); } - if uses_maven_capability { + if provider == "build.provider" { + patch["args"] = json!(split_arguments(&request.arguments)); + if let Some(options) = &request.provider_run_options { + patch["extensions"] = json!({"build-run":options}); + } + } else if uses_maven_capability { let mut maven_extension = serde_json::Map::from_iter([ ( "jvmArguments".to_string(), @@ -1733,14 +2086,9 @@ fn remove_maven_skip_tests_override(configuration: &mut serde_json::Map bool { - document["configurations"].as_array().is_some_and(|items| { - items.iter().any(|item| { - item["provider"] == "build.provider" - && item["id"] - .as_str() - .is_some_and(|id| id.starts_with("user:")) - }) - }) + document["configurations"] + .as_array() + .is_some_and(|items| items.iter().any(super::provider_runs::is_explicit)) } /// Upserts a local reference while preserving unrelated settings and stable IDs. @@ -1782,6 +2130,7 @@ fn create_build_provider_configuration( let encoded = serde_json::to_value(reference).expect("reference encodes"); let mut identity = encoded.clone(); identity.as_object_mut().unwrap().remove("userArguments"); + identity.as_object_mut().unwrap().remove("execution"); let items = document["configurations"] .as_array_mut() .ok_or_else(|| CoreError::new(ErrorCode::ParseFailed, "configurations must be an array"))?; @@ -1792,6 +2141,7 @@ fn create_build_provider_configuration( let mut saved = item["extensions"]["build"].clone(); if let Some(object) = saved.as_object_mut() { object.remove("userArguments"); + object.remove("execution"); } saved == identity }); @@ -1808,8 +2158,9 @@ fn create_build_provider_configuration( "Run configuration must be an object", ) })?; - let managed = json!({"id":id,"name":name,"provider":"build.provider","execution":"application", - "cwd":reference.root,"confidence":"declared"}); + let managed = json!({"id":id,"name":name,"provider":"build.provider","execution":reference.execution.unwrap_or_default(), + "confidence":"declared"}); + object.entry("cwd").or_insert_with(|| json!(reference.root)); object.extend( managed .as_object() @@ -1995,6 +2346,97 @@ fn javac_compile_step( step } +/// Extracts existing Run document values; framework goals and runtime semantics stay in providers. +pub fn provider_launch_inputs(request: ProviderLaunchInputsRequest) -> Result { + let root = existing_root(&request.root)?; + if invalid_relative_path(&request.provider_root) + || !project_directory_exists(&root, &request.provider_root) + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "The provider instance leaves the workspace.", + )); + } + let explicit_cwd = configuration_override_has_key( + &root, + request.local_document.as_ref(), + &request.configuration_id, + "cwd", + )?; + let explicit_arguments = configuration_override_has_key( + &root, + request.local_document.as_ref(), + &request.configuration_id, + "args", + )?; + let resolved = resolve(ResolveRequest { + team_document: None, + root: request.root, + toolchain_candidates: Vec::new(), + local_document: request.local_document, + })?; + let config = resolved["configurations"] + .as_array() + .and_then(|items| { + items + .iter() + .find(|value| value["id"] == request.configuration_id) + }) + .ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "Run configuration is unavailable.", + ) + })?; + if config["provider"] == "build.provider" { + let reference: BuildProviderRunReference = + serde_json::from_value(config["extensions"]["build"].clone()).map_err(|_| { + CoreError::new( + ErrorCode::ParseFailed, + "Invalid build provider run reference", + ) + })?; + reference.validate()?; + if reference.root != request.provider_root || config["disabled"] == true { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Run configuration belongs to a different provider instance or is disabled.", + )); + } + let arguments = if explicit_arguments { + config["args"].clone() + } else { + json!(reference.user_arguments) + }; + let choices = super::provider_run_options::BuildProviderRunOptions::from_extensions( + &serde_json::from_value(config["extensions"].clone()).map_err(|_| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid provider run options") + })?, + )?; + let mut options = json!({"execution":reference.execution.unwrap_or_default(),"runtimeArguments":choices.runtime_arguments, + "arguments":arguments,"environment":config["env"]}); + if !choices.entrypoint.is_empty() { + options["entrypoint"] = json!(choices.entrypoint); + } + // Saving an older selector materializes its root as cwd. Keep that + // default opaque for v4 applications; only an actual override is v10. + if explicit_cwd + && (config["cwd"] != reference.root + || request.configuration_id == reference.generated_id()) + { + options["workingDirectory"] = config["cwd"].clone(); + } + return Ok( + json!({"buildSystemID":reference.build_system_id,"provider":reference.run,"projectPath":reference.root, + "launchOptions":options,"optionOverrides":choices.options,"tools":choices.active_tools(),"reference":reference}), + ); + } + Err(CoreError::new( + ErrorCode::InvalidRequest, + "Run configuration has no provider-owned launch inputs.", + )) +} + /// Resolves one configuration into the exact executable, arguments, and environment. /// /// Standalone Java (`java.current-file` and non-Maven `java.main`) emits a @@ -2011,6 +2453,7 @@ pub fn create_launch_plan(request: LaunchPlanRequest) -> Result Result Result String { fn validate_version_value(document: &Value) -> Result<(), CoreError> { let version = document.get("version").and_then(Value::as_u64).unwrap_or(0) as u32; - validate_version(version) + validate_version(version)?; + validate_toolchain_path_overrides(document) +} + +fn validate_toolchain_path_overrides(document: &Value) -> Result<(), CoreError> { + if let Some(preferences) = document + .get("toolchain") + .and_then(|value| value.get("preferences")) + { + crate::toolchain::preferences::validate(preferences) + .map_err(|error| CoreError::new(ErrorCode::InvalidRequest, error.message))?; + } + let Some(value) = document + .get("toolchain") + .and_then(|value| value.get("pathOverrides")) + else { + return Ok(()); + }; + let invalid = || { + CoreError::new(ErrorCode::InvalidRequest, "Subproject toolchain paths must use bounded workspace-relative roots and valid tool paths") + }; + let overrides = value.as_object().ok_or_else(invalid)?; + if overrides.len() > 256 { + return Err(invalid()); + } + for (path, value) in overrides { + if path.is_empty() + || path.len() > 1024 + || path.contains(['\\', ':']) + || path.chars().any(char::is_control) + || path + .split('/') + .any(|part| part.is_empty() || matches!(part, "." | "..")) + { + return Err(invalid()); + } + let paths: PathToolchainOverride = + serde_json::from_value(value.clone()).map_err(|_| invalid())?; + for value in [ + &paths.java_home_path, + &paths.maven_executable_path, + &paths.maven_java_home_path, + ] { + if value.len() > 4096 || value.trim() != value || value.chars().any(char::is_control) { + return Err(invalid()); + } + } + } + Ok(()) } fn validate_version(version: u32) -> Result<(), CoreError> { @@ -2652,6 +3143,33 @@ fn merge_values( local: &Value, ) -> Result, CoreError> { let mut result: BTreeMap = BTreeMap::new(); + let mut saved = BTreeMap::::new(); + let mut complete_ids = BTreeSet::new(); + for source in [team, local] { + for item in source["configurations"].as_array().into_iter().flatten() { + let Some(id) = item["id"].as_str() else { + continue; + }; + if super::provider_runs::is_explicit(item) { + complete_ids.insert(id.to_string()); + } + if let Some(previous) = saved.get_mut(id) { + merge_configuration_value(previous, item)?; + } else { + saved.insert(id.to_string(), item.clone()); + } + } + } + let explicit_selectors = saved + .values() + .filter(|item| { + super::provider_runs::is_explicit(item) + && item["id"] + .as_str() + .is_some_and(|id| complete_ids.contains(id)) + }) + .filter_map(super::provider_runs::selector) + .collect::>(); for (source_index, source) in [base, team, local].into_iter().enumerate() { let Some(items) = source.get("configurations").and_then(Value::as_array) else { return Err(CoreError::new( @@ -2666,49 +3184,26 @@ fn merge_values( "Run configuration id is required", )); }; - if source_index > 0 && !result.contains_key(id) && !id.starts_with("user:") { + // A complete saved selector supersedes only its automatically generated + // counterpart. Distinct user configurations for that target remain distinct. + if source_index == 0 + && super::provider_runs::selector(item) + .is_some_and(|selector| explicit_selectors.contains(&selector)) + { + continue; + } + if source_index > 0 + && !result.contains_key(id) + && !id.starts_with("user:") + && !super::provider_runs::is_explicit(item) + { continue; } if let Some(existing) = result.get_mut(id) { - let (Some(target), Some(patch)) = (existing.as_object_mut(), item.as_object()) - else { - return Err(CoreError::new( - ErrorCode::ParseFailed, - "Run configuration must be an object", - )); - }; - for (key, value) in patch { - if key == "source" { - continue; - } - if key == "extensions" { - merge_extensions(target, &mut value.clone())?; - continue; - } - if key == "toolchains" { - let target_map = target - .entry(key.clone()) - .or_insert_with(|| json!({})) - .as_object_mut() - .ok_or_else(|| { - CoreError::new( - ErrorCode::ParseFailed, - "Run configuration toolchains must be an object", - ) - })?; - let patch_map = value.as_object().ok_or_else(|| { - CoreError::new( - ErrorCode::ParseFailed, - "Run configuration toolchains must be an object", - ) - })?; - for (toolchain_kind, toolchain_id) in patch_map { - target_map.insert(toolchain_kind.clone(), toolchain_id.clone()); - } - } else { - target.insert(key.clone(), value.clone()); - } - } + merge_configuration_value(existing, item)?; + let target = existing + .as_object_mut() + .expect("merged configuration is an object"); target.insert( "source".to_string(), json!(["generated", "project", "local"][source_index]), @@ -2736,6 +3231,49 @@ fn merge_values( .collect() } +/// Applies one sparse configuration patch using the same rules for claim discovery and resolution. +fn merge_configuration_value(existing: &mut Value, item: &Value) -> Result<(), CoreError> { + let (Some(target), Some(patch)) = (existing.as_object_mut(), item.as_object()) else { + return Err(CoreError::new( + ErrorCode::ParseFailed, + "Run configuration must be an object", + )); + }; + for (key, value) in patch { + if key == "source" { + continue; + } + if key == "extensions" { + merge_extensions(target, &mut value.clone())?; + continue; + } + if key == "toolchains" { + let target_map = target + .entry(key.clone()) + .or_insert_with(|| json!({})) + .as_object_mut() + .ok_or_else(|| { + CoreError::new( + ErrorCode::ParseFailed, + "Run configuration toolchains must be an object", + ) + })?; + let patch_map = value.as_object().ok_or_else(|| { + CoreError::new( + ErrorCode::ParseFailed, + "Run configuration toolchains must be an object", + ) + })?; + for (toolchain_kind, toolchain_id) in patch_map { + target_map.insert(toolchain_kind.clone(), toolchain_id.clone()); + } + } else { + target.insert(key.clone(), value.clone()); + } + } + Ok(()) +} + /// Reconciles runtime bindings from the effective command for generated v2 /// documents written before detectors declared their consumption explicitly. fn normalize_runtime_consumption(configurations: &mut [RunConfiguration]) { @@ -2792,6 +3330,23 @@ fn merge_extensions( match (slot.as_object_mut(), value.as_object()) { (Some(existing), Some(incoming)) => { for (key, item) in incoming { + if namespace == "build-run" && key == "tools" { + let tools = item.as_object().ok_or_else(|| { + CoreError::new( + ErrorCode::ParseFailed, + "Run tool selections must be an object", + ) + })?; + let slot = existing.entry(key.clone()).or_insert_with(|| json!({})); + let destination = slot.as_object_mut().ok_or_else(|| { + CoreError::new( + ErrorCode::ParseFailed, + "Run tool selections must be an object", + ) + })?; + destination.extend(tools.clone()); + continue; + } existing.insert(key.clone(), item.clone()); } } @@ -2852,16 +3407,17 @@ fn validate_configuration(configuration: &RunConfiguration) -> Result<(), CoreEr ) })?; reference.validate()?; + super::provider_run_options::BuildProviderRunOptions::from_extensions( + &configuration.extensions, + )?; if configuration.command.is_some() - || !configuration.args.is_empty() - || !configuration.env.is_empty() || !configuration.toolchains.is_empty() || configuration.debug.is_some() - || configuration.execution != Execution::Application + || configuration.execution != reference.execution.unwrap_or_default() { return Err(CoreError::new( ErrorCode::InvalidRequest, - "Build provider configurations store selectors, not process launch data", + "Build provider configurations cannot store commands, tool bindings or debug adapters", )); } } @@ -3037,9 +3593,14 @@ fn unique_user_configuration_id( fn split_arguments(input: &str) -> Vec { let mut result = Vec::new(); let mut current = String::new(); + let mut started = false; let mut quote = None; let mut escaped = false; for character in input.chars() { + // Quoting can deliberately start an empty argv item. + if !character.is_whitespace() || quote.is_some() || escaped { + started = true; + } if escaped { current.push(character); escaped = false; @@ -3054,8 +3615,9 @@ fn split_arguments(input: &str) -> Vec { current.push(character); } } else if character.is_whitespace() && quote.is_none() { - if !current.is_empty() { + if started { result.push(std::mem::take(&mut current)); + started = false; } } else { current.push(character); @@ -3064,7 +3626,7 @@ fn split_arguments(input: &str) -> Vec { if escaped { current.push('\\'); } - if !current.is_empty() { + if started { result.push(current); } result @@ -3131,7 +3693,7 @@ pub(super) fn read_requirements( || ["build.gradle", "build.gradle.kts", "gradlew", "mvnw"] .iter() .any(|name| root.join(name).is_file()); - detect_requirements(root, maven_root.as_deref(), has_java_ecosystem, &[]).map(Some) + detect_requirements(root, maven_root.as_deref(), has_java_ecosystem, &[], None).map(Some) } fn read_local_toolchains(root: &Path) -> Result, CoreError> { @@ -3157,6 +3719,7 @@ fn detect_requirements( maven_root: Option<&Path>, has_java_ecosystem: bool, configurations: &[RunConfiguration], + projects: Option<&[super::project_ownership::RunProjectOwnership]>, ) -> Result { let mut jdk = ToolchainRequirement { kind: "java".to_string(), @@ -3176,7 +3739,14 @@ fn detect_requirements( }; let maven_root = maven_root.unwrap_or(root); let pom = maven_root.join("pom.xml"); - if let Ok(text) = fs::read_to_string(pom) { + if let Some(projects) = projects { + jdk.minimum_version = projects + .iter() + .filter_map(|project| project.minimum_java_version.as_deref()) + .filter_map(|value| value.parse::().ok()) + .max() + .map(|value| value.to_string()); + } else if let Ok(text) = fs::read_to_string(pom) { let re = regex::Regex::new(r"(?:maven.compiler.release|maven.compiler.source|maven.compiler.target|java.version)\s*>?\s*[:=]?\s*([0-9]+(?:\.[0-9]+)?)").unwrap(); // Java 8 projects commonly declare `1.8`; keep the feature version // instead of the legacy `1` prefix. @@ -3188,16 +3758,20 @@ fn detect_requirements( if let Some((version, vendor)) = declared_java_version(maven_root).or_else(|| declared_java_version(root)) { - jdk.minimum_version = Some(version); + jdk.minimum_version = if projects.is_some() { + jdk.minimum_version + .as_deref() + .and_then(major_version) + .filter(|floor| version_parts(floor) > version_parts(&version)) + .or(Some(version)) + } else { + Some(version) + }; jdk.preferred_vendor = vendor; } - if maven_root.join("mvnw").exists() { + if projects.is_none() && maven_root.join("mvnw").exists() { maven.wrapper = Some("./mvnw".to_string()); } - // Wrapper distribution is a floor for system Maven, not an exact pin. A - // newer installed Maven (for example 3.9.x against a 3.6.x wrapper URL) - // remains valid for launch planning and run diagnostics. - maven.minimum_version = maven_wrapper_version(maven_root); let mut toolchains = BTreeMap::new(); let consumes = |toolchain: &str| { configurations.iter().any(|configuration| { @@ -3207,10 +3781,21 @@ fn detect_requirements( .any(|candidate| candidate == toolchain) }) }; - if has_java_ecosystem || consumes("project-jdk") { + if has_java_ecosystem + || consumes("project-jdk") + || projects.is_some_and(|projects| { + projects + .iter() + .any(|project| project.minimum_java_version.is_some()) + }) + { toolchains.insert("project-jdk".to_string(), jdk); } - if maven_root.join("pom.xml").is_file() || maven_root.join("mvnw").is_file() { + if projects.is_none() + && (maven_root.join("pom.xml").is_file() + || maven_root.join("mvnw").is_file() + || consumes("project-maven")) + { toolchains.insert("project-maven".to_string(), maven); } if consumes("project-node") { @@ -3510,6 +4095,7 @@ fn fingerprint_input(path: &Path) -> bool { path.file_name().and_then(|name| name.to_str()), Some( "pom.xml" + | "mvnw.cmd" | "mvnw" | "maven-wrapper.properties" | ".java-version" @@ -3641,20 +4227,6 @@ fn vendor_from_version(value: &str) -> Option { } } -fn maven_wrapper_version(root: &Path) -> Option { - let text = fs::read_to_string( - root.join(".mvn") - .join("wrapper") - .join("maven-wrapper.properties"), - ) - .ok()?; - regex::Regex::new(r"apache-maven-([0-9]+(?:\.[0-9]+)+)-bin\.(?:zip|tar\.gz)") - .ok()? - .captures(&text) - .and_then(|capture| capture.get(1)) - .map(|value| value.as_str().to_string()) -} - fn existing_root(value: &str) -> Result { let path = PathBuf::from(value); if !path.is_dir() { diff --git a/rust/lithe-core/src/execution/configuration/project_toolchain.rs b/rust/lithe-core/src/execution/configuration/project_toolchain.rs new file mode 100644 index 0000000000..536af84c20 --- /dev/null +++ b/rust/lithe-core/src/execution/configuration/project_toolchain.rs @@ -0,0 +1,187 @@ +//! Read-only project tool paths for an authoritative workspace-relative owner. +//! Platforms discover/probe executables; Core resolves only saved Run defaults +//! the nearest path override, and explicit per-Run paths. It never inspects +//! a POM or runs a tool. + +use super::{existing_root, local_layer_document, read_local_toolchains, validate_version_value}; +use crate::protocol::{invalid_relative_path, CoreError, ErrorCode}; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::collections::BTreeMap; + +/// Reads current tool selections even when no Run configuration was generated. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct ProjectToolchainRequest { + /// Absolute native workspace root, owned by the platform. + pub(crate) root: String, + /// Current Provider project/reactor root, relative to that workspace. + pub(crate) path: String, + /// Optional host-owned replacement for the local Run document. + #[serde(default)] + pub(crate) local_document: Option, + /// Optional Run whose explicit path overrides take precedence over the + /// canonical project/reactor selection. Empty fields keep that selection. + #[serde(default)] + pub(crate) configuration_id: Option, +} + +/// Returns paths, never resolved executables. Empty override fields inherit the +/// workspace defaults, rather than falling through a second ancestor override. +pub(crate) fn project_toolchain(request: ProjectToolchainRequest) -> Result { + if request.path.len() > 1024 + || invalid_relative_path(&request.path) + || request.path.contains('\\') + || request.path.chars().any(char::is_control) + || request.path != "." + && request + .path + .split('/') + .any(|part| part.is_empty() || part == ".") + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Toolchain owner must be a workspace-relative path", + )); + } + let root = existing_root(&request.root)?; + let document = local_layer_document(&root, request.local_document)?; + validate_version_value(&document)?; + let toolchain = &document["toolchain"]; + for group in [toolchain, &toolchain["java"], &toolchain["maven"]] { + if !group.is_null() && !group.is_object() { + return Err(invalid_paths()); + } + } + let java = saved_path(&toolchain["java"]["homePath"])?; + let maven = saved_path(&toolchain["maven"]["executablePath"])?; + let maven_java = saved_path(&toolchain["maven"]["javaHomePath"])?; + let mut runtimes = BTreeMap::new(); + if let Some(document) = read_local_toolchains(&root)? { + let entries = document["toolchains"] + .as_object() + .ok_or_else(invalid_paths)?; + if entries.len() > 256 { + return Err(invalid_paths()); + } + for (id, value) in entries { + if !crate::plugins::declarative::is_identifier(id) || !value.is_object() { + return Err(invalid_paths()); + } + let executable = saved_path(&value["executable"])?; + if !executable.is_empty() { + runtimes.insert(id.clone(), executable); + } + } + } + let selected = toolchain["pathOverrides"] + .as_object() + .and_then(|overrides| { + overrides + .iter() + .filter(|(path, _)| { + request.path == **path || request.path.starts_with(&format!("{path}/")) + }) + .max_by_key(|(path, _)| path.len()) + .map(|(_, value)| value) + }); + let path = |key: &str, default: &str| { + selected + .and_then(|value| value[key].as_str()) + .filter(|value| !value.trim().is_empty()) + .unwrap_or(default) + .trim() + .to_string() + }; + let mut result = json!({ "toolchain": { + "javaHomePath": path("javaHomePath", &java), + "mavenExecutablePath": path("mavenExecutablePath", &maven), + "mavenJavaHomePath": path("mavenJavaHomePath", &maven_java), + "runtimeExecutablePaths": runtimes, + }}); + if let Some(preferences) = toolchain.get("preferences") { + result["toolPreferences"] = preferences.clone(); + } + if let Some(id) = request.configuration_id.as_deref() { + let mut explicit = document.clone(); + // Reuse the existing layer merge, without materializing global + // defaults as if they were per-configuration choices. The caller's + // canonical reactor defaults have already been selected above. + explicit + .as_object_mut() + .ok_or_else(invalid_paths)? + .remove("toolchain"); + let resolved = super::resolve(super::ResolveRequest { + team_document: None, + root: root.to_string_lossy().into_owned(), + toolchain_candidates: Vec::new(), + local_document: Some(explicit), + })?; + let config = resolved["configurations"] + .as_array() + .and_then(|items| { + items + .iter() + .find(|item| item["id"] == id && item["disabled"] != true) + }) + .ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "Run configuration is unavailable", + ) + })?; + for owner in [ + &config["extensions"]["build"]["root"], + &config["extensions"]["java"]["build"]["root"], + &config["extensions"]["maven"]["reactorPath"], + ] { + if owner.as_str().is_some_and(|owner| owner != request.path) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Run configuration belongs to a different provider instance", + )); + } + } + if config["provider"] == "build.provider" { + let extensions = serde_json::from_value(config["extensions"].clone()) + .map_err(|_| invalid_paths())?; + let choices = + crate::execution::provider_run_options::BuildProviderRunOptions::from_extensions( + &extensions, + )?; + let tools = choices.active_tools(); + if !tools.is_empty() { + result["definedTools"] = json!(tools); + } + } + for (key, field) in [ + ("javaHomePath", "homePath"), + ("mavenExecutablePath", "mavenExecutablePath"), + ("mavenJavaHomePath", "mavenJavaHomePath"), + ] { + let value = saved_path(&config["extensions"]["java"][field])?; + if !value.is_empty() { + result["toolchain"][key] = json!(value); + } + } + } + Ok(result) +} + +fn invalid_paths() -> CoreError { + CoreError::new( + ErrorCode::ParseFailed, + "Project toolchain paths must be bounded strings", + ) +} + +fn saved_path(value: &Value) -> Result { + if value.is_null() { + return Ok(String::new()); + } + let value = value.as_str().ok_or_else(invalid_paths)?; + if value.len() > 4096 || value.chars().any(char::is_control) { + return Err(invalid_paths()); + } + Ok(value.trim().to_string()) +} diff --git a/rust/lithe-core/src/execution/launch_command.rs b/rust/lithe-core/src/execution/launch_command.rs index 24eb39c136..6c9b98b740 100644 --- a/rust/lithe-core/src/execution/launch_command.rs +++ b/rust/lithe-core/src/execution/launch_command.rs @@ -116,11 +116,24 @@ const MINIMUM_ARGFILE_JAVA_VERSION: u32 = 9; /// Hosts pass the result to [`plan_launch_command`], which needs it to know /// whether an argument file is supported. Encoding is the host's responsibility. pub fn java_feature_version_from_release(contents: &str) -> Option { + let value = java_version_from_release(contents)?; + release_feature_version(value) +} + +/// Borrows the full version from JDK-owned release metadata without running Java. +/// Native discovery uses this value with the shared runtime candidate ordering; +/// absent or unrecognized metadata must not trigger an implicit process probe. +pub fn java_version_from_release(contents: &str) -> Option<&str> { let value = contents .lines() .find_map(|line| line.trim().strip_prefix("JAVA_VERSION="))? .trim() .trim_matches('"'); + release_feature_version(value)?; + Some(value) +} + +fn release_feature_version(value: &str) -> Option { let mut parts = value.split(['.', '_', '-', '+']); let first = parts.next()?.parse::().ok()?; if first == 1 { @@ -512,6 +525,14 @@ mod tests { #[test] fn the_release_file_reports_the_feature_version() { + for version in ["21.0.2", "1.8.0_392", "25-ea+35", "17.0.12+7"] { + let release = format!("IMPLEMENTOR=\"fixture\"\nJAVA_VERSION=\"{version}\"\n"); + assert_eq!(java_version_from_release(&release), Some(version)); + } + assert_eq!( + java_version_from_release("JAVA_VERSION=\"unknown\"\n"), + None + ); assert_eq!( java_feature_version_from_release("IMPLEMENTOR=\"x\"\nJAVA_VERSION=\"21.0.2\"\n"), Some(21) diff --git a/rust/lithe-core/src/execution/mod.rs b/rust/lithe-core/src/execution/mod.rs index ae77428420..b0de5f125e 100644 --- a/rust/lithe-core/src/execution/mod.rs +++ b/rust/lithe-core/src/execution/mod.rs @@ -6,6 +6,9 @@ mod detectors; pub(crate) mod generated_layer; mod java_selection; mod launch_command; +mod project_ownership; +mod provider_run_options; +mod provider_runs; pub use java_selection::{ compare_java_candidates, select_java, select_java_candidates, JavaSelection, JavaSelectionCandidate, JavaSelectionRequest, @@ -18,9 +21,9 @@ pub use classpath_jar::{ pub(crate) use configuration::*; pub(crate) use detectors::{scan, DirectoryContext}; pub use launch_command::{ - java_feature_version_from_release, plan_launch_command, plan_launch_command_request, - LaunchCommandPlan, LaunchCommandPlanRequest, LaunchCommandPlanResponse, - WINDOWS_COMMAND_LINE_LIMIT, + java_feature_version_from_release, java_version_from_release, plan_launch_command, + plan_launch_command_request, LaunchCommandPlan, LaunchCommandPlanRequest, + LaunchCommandPlanResponse, WINDOWS_COMMAND_LINE_LIMIT, }; pub(crate) mod workspace_scan; diff --git a/rust/lithe-core/src/execution/project_ownership.rs b/rust/lithe-core/src/execution/project_ownership.rs new file mode 100644 index 0000000000..c70079dc26 --- /dev/null +++ b/rust/lithe-core/src/execution/project_ownership.rs @@ -0,0 +1,200 @@ +//! Validates current provider project/source ownership for Run generation. +//! This is a projection of upstream models, never a descriptor scanner. + +use crate::protocol::{CoreError, ErrorCode, JavaSourceSetResponse}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; + +/// One current build-model project and its own Java content roots. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RunProjectOwnership { + /// Workspace-relative project directory, including `.` for the root. + pub path: String, + /// Upstream project label; never interpreted as a framework or selector. + #[serde(default)] + pub name: Option, + /// Compiler language floor published by the upstream execution model. + #[serde(default)] + pub minimum_java_version: Option, + /// Exact current build-model owner; no build-system semantics are interpreted. + #[serde(default)] + pub build: Option, + /// Source roles published for this project by the build model. + #[serde(default)] + pub content_roots: Vec, +} + +/// Opaque build-model identity used to route native language preparation. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RunBuildProjectReference { + /// Enabled package contributing this model. + #[serde(rename = "pluginID")] + pub plugin_id: String, + /// Declaration within the package. + #[serde(rename = "buildSystemID")] + pub build_system_id: String, + /// Workspace-relative instance directory. + pub root: String, + /// Provider-owned project identity, not a filesystem path. + #[serde(rename = "projectID")] + pub project_id: String, +} + +/// A source directory and upstream-owned role; paths are workspace-relative. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RunSourceRoot { + pub path: String, + /// `source`, `testSource`, `generatedSource` or `generatedTestSource`. + pub kind: String, +} + +fn contains(root: &str, path: &str) -> bool { + root == "." || root == path || path.starts_with(&(root.to_string() + "/")) +} + +fn portable(path: &str) -> bool { + !path.is_empty() + && path.len() <= 4096 + && !path.contains(['\\', '\0', ':']) + && (path == "." + || path + .split('/') + .all(|part| !part.is_empty() && part != "." && part != "..")) +} + +/// Rejects incomplete, duplicate or escaping host projections before generation. +pub(super) fn validate(projects: &[RunProjectOwnership]) -> Result<(), CoreError> { + let invalid = || { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid provider project ownership for Run generation", + ) + }; + if projects.len() > 2000 + || projects + .iter() + .map(|p| p.content_roots.len()) + .sum::() + > 10000 + { + return Err(invalid()); + } + let mut paths = BTreeSet::new(); + for project in projects { + if !portable(&project.path) + || !paths.insert(&project.path) + || project.build.as_ref().is_some_and(|build| { + !crate::plugins::declarative::is_plugin_id(&build.plugin_id) + || !crate::plugins::declarative::is_identifier(&build.build_system_id) + || build.build_system_id.len() > 120 + || build.project_id.is_empty() + || build.project_id.len() > 256 + || build.project_id.chars().any(char::is_control) + || !portable(&build.root) + || !contains(&build.root, &project.path) + }) + { + return Err(invalid()); + } + if project + .minimum_java_version + .as_deref() + .is_some_and(|value| { + value.parse::().ok().is_none_or(|feature| { + feature == 0 || feature > 999 || feature.to_string() != value + }) + }) + { + return Err(invalid()); + } + let mut roots = BTreeSet::new(); + for root in &project.content_roots { + if !portable(&root.path) + || project + .build + .as_ref() + .is_some_and(|build| !contains(&build.root, &root.path)) + || !roots.insert((&root.path, &root.kind)) + || !matches!( + root.kind.as_str(), + "source" | "testSource" | "generatedSource" | "generatedTestSource" + ) + { + return Err(invalid()); + } + } + } + Ok(()) +} + +/// Prefer exact upstream source roots, then the deepest project boundary. +pub(super) fn owner<'a>( + projects: &'a [RunProjectOwnership], + source: &str, +) -> Option<&'a RunProjectOwnership> { + projects + .iter() + .filter_map(|project| { + let source_depth = project + .content_roots + .iter() + .filter(|root| contains(&root.path, source)) + .map(|root| root.path.len()) + .max(); + (source_depth.is_some() || contains(&project.path, source)) + .then_some((source_depth, project)) + }) + .max_by(|(left_depth, left), (right_depth, right)| { + (left_depth, left.path.len(), &left.path).cmp(&( + right_depth, + right.path.len(), + &right.path, + )) + }) + .map(|(_, project)| project) +} + +impl RunProjectOwnership { + /// Source set comes from the selected project's roots, not a directory name. + pub(super) fn source_set(&self, source: &str) -> JavaSourceSetResponse { + match self + .content_roots + .iter() + .filter(|root| contains(&root.path, source)) + .max_by(|left, right| { + (left.path.len(), &left.kind).cmp(&(right.path.len(), &right.kind)) + }) + .map(|root| root.kind.as_str()) + { + Some("testSource" | "generatedTestSource") => JavaSourceSetResponse::Test, + Some("source" | "generatedSource") => JavaSourceSetResponse::Main, + _ => JavaSourceSetResponse::Other, + } + } +} + +/// Stable identity for all facts consumed by generation, independent of list order. +pub(super) fn fingerprint(projects: &[RunProjectOwnership]) -> String { + use sha2::{Digest, Sha256}; + let mut projects = projects.iter().collect::>(); + projects.sort_by(|left, right| left.path.cmp(&right.path)); + let canonical = projects + .into_iter() + .map(|project| { + let roots = project + .content_roots + .iter() + .map(|root| (&root.path, &root.kind)) + .collect::>(); + serde_json::json!({"path":project.path,"name":project.name,"build":project.build, + "minimumJavaVersion":project.minimum_java_version, + "contentRoots":roots}) + }) + .collect::>(); + let bytes = + serde_json::to_vec(&canonical).expect("Provider ownership contains only JSON-safe values"); + format!("sha256:{:x}", Sha256::digest(bytes)) +} diff --git a/rust/lithe-core/src/execution/provider_run_options.rs b/rust/lithe-core/src/execution/provider_run_options.rs new file mode 100644 index 0000000000..3290cc6ba5 --- /dev/null +++ b/rust/lithe-core/src/execution/provider_run_options.rs @@ -0,0 +1,91 @@ +//! Persists portable user choices separately from opaque provider target identities. +//! Only the selected provider interprets runtime arguments, entrypoints and build options. + +use crate::protocol::{CoreError, ErrorCode}; +pub use crate::toolchain::fixed::FixedToolchainSelection; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::BTreeMap; + +/// User-owned Run v2 `extensions.build-run`; never a cached launch plan. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct BuildProviderRunOptions { + /// Exact argv elements, including empty arguments and whitespace. + #[serde(default)] + pub runtime_arguments: Vec, + /// Opaque provider entrypoint; empty explicitly clears an inherited choice. + #[serde(default)] + pub entrypoint: String, + /// Declared build-option values; current declaration/model validation occurs at preparation. + #[serde(default)] + pub options: BTreeMap, + /// Explicit tool choices for this Run's build preparation and process only. + /// They never change another Run or a workspace's directory pins. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub tools: BTreeMap>, +} + +impl BuildProviderRunOptions { + /// Bound persistence without interpreting a runtime or tool's option syntax. + pub(super) fn validate(&self) -> Result<(), CoreError> { + let invalid = || CoreError::new(ErrorCode::InvalidRequest, "Invalid provider run options"); + if self.runtime_arguments.len() > 1024 + || self + .runtime_arguments + .iter() + .any(|arg| arg.len() > 65536 || arg.contains('\0')) + || self.entrypoint.len() > 4096 + || self.entrypoint.contains('\0') + || self.options.len() > 64 + || self.options.keys().any(|key| { + key.is_empty() + || key.len() > 200 + || !key + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte)) + }) + || serde_json::to_vec(&self.options).map_or(true, |bytes| bytes.len() > 65536) + || self.tools.len() > 64 + || self.tools.iter().any(|(id, value)| { + !crate::toolchain::definitions::tool_id(id) + || value.as_ref().is_some_and(|value| { + serde_json::to_value(value) + .ok() + .is_none_or(|value| FixedToolchainSelection::parse(&value).is_err()) + }) + }) + || serde_json::to_vec(&self.tools).map_or(true, |bytes| bytes.len() > 65536) + { + return Err(invalid()); + } + Ok(()) + } + + /// Null clears an inherited per-Run choice, leaving directory policy intact. + pub(super) fn active_tools(&self) -> BTreeMap { + self.tools + .iter() + .filter_map(|(id, value)| value.clone().map(|value| (id.clone(), value))) + .collect() + } + + /// Missing options preserve older saved selectors; explicit malformed values fail closed. + pub(super) fn from_extensions(extensions: &BTreeMap) -> Result { + let value = match extensions.get("build-run") { + Some(value) => serde_json::from_value(value.clone()).map_err(|_| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid provider run options") + })?, + None => Self::default(), + }; + value.validate()?; + Ok(value) + } +} + +/// Omission inherits persisted choices; explicit null is not an instruction to discard them. +pub(super) fn optional_request_options<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> Result, D::Error> { + BuildProviderRunOptions::deserialize(deserializer).map(Some) +} diff --git a/rust/lithe-core/src/execution/provider_runs.rs b/rust/lithe-core/src/execution/provider_runs.rs new file mode 100644 index 0000000000..3a87874aaf --- /dev/null +++ b/rust/lithe-core/src/execution/provider_runs.rs @@ -0,0 +1,104 @@ +//! Validates and materializes opaque provider run selectors from current model snapshots. +//! Tool, framework, runtime and configuration semantics remain with the selected provider. + +use super::configuration::{BuildProviderRunReference, RunConfiguration}; +use super::types::{Confidence, RunCategory}; +use crate::protocol::{CoreError, ErrorCode}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::{BTreeMap, BTreeSet}; + +/// One declared provider run applied to an upstream target, independent of language imports. +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ProviderRunConfiguration { + /// Workspace-relative current model project; never inferred from a descriptor. + pub project_path: String, + pub name: String, + pub reference: BuildProviderRunReference, +} + +fn contains(root: &str, path: &str) -> bool { + root == "." || root == path || path.starts_with(&(root.to_string() + "/")) +} + +/// Validates a complete run snapshot without reading tool configuration or starting processes. +pub(super) fn validate(runs: &[ProviderRunConfiguration]) -> Result<(), CoreError> { + let invalid = || CoreError::new(ErrorCode::InvalidRequest, "Invalid provider run snapshot"); + if runs.len() > 10000 { + return Err(invalid()); + } + let mut identities = BTreeSet::new(); + for run in runs { + run.reference.validate()?; + if run.name.trim().is_empty() + || run.name.len() > 4096 + || run.name.chars().any(char::is_control) + || crate::protocol::invalid_relative_path(&run.project_path) + || run.project_path.is_empty() + || run.project_path.len() > 4096 + || run.project_path.contains(['\\', '\0']) + || !contains(&run.reference.root, &run.project_path) + || !run.reference.user_arguments.is_empty() + || !identities.insert(run.reference.generated_id()) + { + return Err(invalid()); + } + } + Ok(()) +} + +/// Materializes selectors without inventing commands, toolchains or adapter identities. +pub(super) fn configurations(runs: &[ProviderRunConfiguration]) -> Vec { + let mut ordered = runs.iter().collect::>(); + ordered.sort_by_key(|run| run.reference.generated_id()); + ordered + .into_iter() + .map(|run| RunConfiguration { + id: run.reference.generated_id(), + name: run.name.clone(), + provider: "build.provider".to_string(), + execution: run.reference.execution.unwrap_or_default(), + category: RunCategory::Project, + command: None, + args: Vec::new(), + cwd: run.reference.root.clone(), + env: BTreeMap::new(), + confidence: Confidence::Declared, + toolchains: BTreeMap::new(), + debug: None, + members: Vec::new(), + extensions: [( + "build".to_string(), + serde_json::to_value(&run.reference).expect("validated reference encodes"), + )] + .into_iter() + .collect(), + disabled: false, + source: None, + }) + .collect() +} + +/// Identity of the complete declaration/target snapshot, independent of input ordering. +pub(super) fn fingerprint(runs: &[ProviderRunConfiguration]) -> String { + use sha2::{Digest, Sha256}; + let mut ordered = runs.iter().collect::>(); + ordered.sort_by_key(|run| run.reference.generated_id()); + let bytes = serde_json::to_vec(&ordered).expect("validated run snapshot encodes"); + format!("sha256:{:x}", Sha256::digest(bytes)) +} + +/// Complete saved references own their identity; an ID prefix is not an execution capability. +/// Validation still rejects malformed selectors after layer merging. +pub(super) fn is_explicit(item: &Value) -> bool { + item["provider"] == "build.provider" && item["id"].is_string() && item["name"].is_string() +} + +/// Opaque selector identity for deduplication, independent of user choices or display names. +pub(super) fn selector(item: &Value) -> Option { + (item["provider"] == "build.provider").then_some(())?; + let reference: BuildProviderRunReference = + serde_json::from_value(item["extensions"]["build"].clone()).ok()?; + Some(reference.generated_id()) +} diff --git a/rust/lithe-core/src/git/mod.rs b/rust/lithe-core/src/git/mod.rs index 0ed43b6013..d57aa5e0cf 100644 --- a/rust/lithe-core/src/git/mod.rs +++ b/rust/lithe-core/src/git/mod.rs @@ -16,9 +16,9 @@ pub(crate) mod configuration; pub(crate) mod console; pub(crate) mod execution_events; pub(crate) mod execution_policy; -pub(crate) mod file_context; mod fetch; mod fetch_execution; +pub(crate) mod file_context; #[allow(dead_code)] pub(crate) mod graph; mod history; diff --git a/rust/lithe-core/src/lsp/interface/build_provider.rs b/rust/lithe-core/src/lsp/interface/build_provider.rs index 4da8ba7805..d3e08af099 100644 --- a/rust/lithe-core/src/lsp/interface/build_provider.rs +++ b/rust/lithe-core/src/lsp/interface/build_provider.rs @@ -15,7 +15,7 @@ use serde_json::{json, Value}; const EXPERIMENTAL_KEY: &str = "litheBuild"; /// Build-provider protocol versions this client speaks, in preference order. -const SUPPORTED_VERSIONS: [u64; 6] = [6, 5, 4, 3, 2, 1]; +const SUPPORTED_VERSIONS: [u64; 11] = [11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1]; /// Feature name recorded in the session's server capabilities once a /// provider answered a supported version. @@ -32,7 +32,61 @@ pub(crate) fn model_inputs_negotiated(capabilities: &Value) -> bool { capabilities .pointer("/experimental/litheBuild/version") .and_then(Value::as_u64) - == Some(6) + .is_some_and(|version| matches!(version, 6 | 7 | 8 | 9 | 10 | 11)) +} + +/// Version 7 returns non-sensitive native settings and source/write metadata. +pub(crate) const CONFIGURATION_METHOD: &str = "lithe/build/configuration"; +/// Provider-owned native editing; only a receipt crosses the wire. +pub(crate) const EDIT_CONFIGURATION_METHOD: &str = "lithe/build/editConfiguration"; +/// Admission feature keeps native source metadata away from older protocols. +pub(crate) const CONFIGURATION_FEATURE: &str = "litheBuildConfiguration"; +/// Only version 7 can accept source-backed configuration reads. +pub(crate) fn configuration_negotiated(capabilities: &Value) -> bool { + capabilities + .pointer("/experimental/litheBuild/version") + .and_then(Value::as_u64) + .is_some_and(|version| matches!(version, 7 | 8 | 9 | 10 | 11)) +} + +/// Version 8 returns canonical reports with the completing test operation. +pub(crate) const TEST_REPORTS_FEATURE: &str = "litheBuildTestReports"; +/// Older providers cannot silently fall back to host-owned report discovery. +pub(crate) fn test_reports_negotiated(capabilities: &Value) -> bool { + capabilities + .pointer("/experimental/litheBuild/version") + .and_then(Value::as_u64) + .is_some_and(|version| matches!(version, 8 | 9 | 10 | 11)) +} + +/// Version 9 passes public semantic selectors unchanged to the owning backend. +pub(crate) const TEST_SELECTORS_FEATURE: &str = "litheBuildTestSelectors"; +/// An old backend must never widen a filtered request to all tests. +pub(crate) fn test_selectors_negotiated(capabilities: &Value) -> bool { + capabilities + .pointer("/experimental/litheBuild/version") + .and_then(Value::as_u64) + .is_some_and(|version| matches!(version, 9 | 10 | 11)) +} + +/// Admission feature for typed user options and host-owned service/task launches. +pub(crate) const RUN_OPTIONS_FEATURE: &str = "litheBuildRunOptions"; +/// Older sessions cannot silently discard user arguments or launch ownership. +pub(crate) fn run_options_negotiated(capabilities: &Value) -> bool { + capabilities + .pointer("/experimental/litheBuild/version") + .and_then(Value::as_u64) + .is_some_and(|version| matches!(version, 10 | 11)) +} + +/// Version 11 accepts a registry-selected adapter and host-owned connection target. +pub(crate) const RUNTIME_DEBUG_FEATURE: &str = "litheBuildRuntimeDebug"; +/// The process agreement, rather than just the manifest, gates debug choices. +pub(crate) fn runtime_debug_negotiated(capabilities: &Value) -> bool { + capabilities + .pointer("/experimental/litheBuild/version") + .and_then(Value::as_u64) + == Some(11) } /// Version 5 prepares a native debug target, never an adapter process. @@ -44,7 +98,7 @@ pub(crate) fn debug_preparation_negotiated(capabilities: &Value) -> bool { capabilities .pointer("/experimental/litheBuild/version") .and_then(Value::as_u64) - .is_some_and(|version| matches!(version, 5 | 6)) + .is_some_and(|version| matches!(version, 5 | 6 | 7 | 8 | 9 | 10 | 11)) } /// Admission feature for the version 4 method. @@ -85,6 +139,8 @@ pub(crate) fn is_build_method(method: &str) -> bool { || method == DISCOVER_TESTS_METHOD || method == PREPARE_RUN_METHOD || method == PREPARE_DEBUG_METHOD + || method == CONFIGURATION_METHOD + || method == EDIT_CONFIGURATION_METHOD } /// Whether the method shares bounded output, progress, and final result handling. @@ -97,7 +153,7 @@ pub(crate) fn execution_negotiated(capabilities: &Value) -> bool { capabilities .pointer("/experimental/litheBuild/version") .and_then(Value::as_u64) - .is_some_and(|version| matches!(version, 2 | 3 | 4 | 5 | 6)) + .is_some_and(|version| matches!(version, 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11)) } /// Tests require an explicit version 3 agreement, independent of task support. @@ -105,7 +161,7 @@ pub(crate) fn tests_negotiated(capabilities: &Value) -> bool { capabilities .pointer("/experimental/litheBuild/version") .and_then(Value::as_u64) - .is_some_and(|version| matches!(version, 3 | 4 | 5 | 6)) + .is_some_and(|version| matches!(version, 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11)) } /// A preparation has progress and cancellation, but its reply is a launch plan. @@ -118,7 +174,7 @@ pub(crate) fn run_preparation_negotiated(capabilities: &Value) -> bool { capabilities .pointer("/experimental/litheBuild/version") .and_then(Value::as_u64) - .is_some_and(|version| matches!(version, 4 | 5 | 6)) + .is_some_and(|version| matches!(version, 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11)) } /// Normalizes bounded progress; the engine checks the pending request identity. @@ -143,7 +199,10 @@ pub(crate) fn progress(params: Option<&Value>) -> Option { /// Stable outcome for a provider-owned task. Tool failure is an exit code, /// while malformed replies and JSON-RPC errors are protocol failures. -pub(crate) fn execution_outcome(reply: &Value) -> Result { +pub(crate) fn execution_outcome( + reply: &Value, + reports_negotiated: bool, +) -> Result { if reply.get("error").is_some() { return Err(( "plugin.build.failed", @@ -171,7 +230,17 @@ pub(crate) fn execution_outcome(reply: &Value) -> Result 8 * 1024 * 1024 + { + return Err(invalid); + } + result["testResults"] = reports.clone(); + } + Ok(json!({ "value": result })) } #[cfg(test)] @@ -190,7 +259,7 @@ mod tests { &json!({ "experimental": { "litheBuild": {} } }) )); assert!(!negotiated( - &json!({ "experimental": { "litheBuild": { "version": 7 } } }) + &json!({ "experimental": { "litheBuild": { "version": 99 } } }) )); assert!(!negotiated( &json!({ "experimental": { "litheBuild": { "version": "1" } } }) @@ -219,6 +288,27 @@ mod tests { )); } + #[test] + fn inline_reports_require_their_protocol_and_are_retained_without_host_parsing() { + let result = json!({"result":{"exitCode":1,"output":"failed","testResults":{"format":"litheTestResults","version":1,"tests":[]}}}); + assert!(execution_outcome(&result, false).is_err()); + assert_eq!( + execution_outcome(&result, true).unwrap()["value"], + result["result"] + ); + for invalid in [json!(null), json!([]), json!("x")] { + let mut reply = result.clone(); + reply["result"]["testResults"] = invalid; + assert!(execution_outcome(&reply, true).is_err()); + } + assert!(!test_reports_negotiated( + &json!({"experimental":{"litheBuild":{"version":7}}}) + )); + assert!(test_reports_negotiated( + &json!({"experimental":{"litheBuild":{"version":8}}}) + )); + } + #[test] fn execution_replies_and_progress_match_the_shared_contract() { let fixture: Value = serde_json::from_str(include_str!( @@ -226,17 +316,19 @@ mod tests { )) .unwrap(); assert_eq!( - execution_outcome(&json!({"result": fixture["result"]})).unwrap(), + execution_outcome(&json!({"result": fixture["result"]}), false).unwrap(), json!({"value": fixture["result"]}) ); for result in fixture["invalidResults"].as_array().unwrap() { assert_eq!( - execution_outcome(&json!({"result": result})).unwrap_err().0, + execution_outcome(&json!({"result": result}), false) + .unwrap_err() + .0, "plugin.build.invalidResult" ); } assert_eq!( - execution_outcome(&json!({"error": {"message": "private"}})) + execution_outcome(&json!({"error": {"message": "private"}}), false) .unwrap_err() .0, "plugin.build.failed" @@ -250,8 +342,27 @@ mod tests { } assert!(progress(Some(&json!({"operationID":"1","output":"x".repeat(65537)}))).is_none()); assert!(execution_outcome( - &json!({"result": {"exitCode": 0,"output":"x".repeat(32*1024*1024+1)}}) + &json!({"result": {"exitCode": 0,"output":"x".repeat(32*1024*1024+1)}}), + false ) .is_err()); } } + +#[cfg(test)] +mod configuration_tests { + use super::*; + #[test] + fn native_configuration_requires_its_own_wire_version_without_losing_old_methods() { + let old = json!({"experimental":{"litheBuild":{"version":6}}}); + let current = json!({"experimental":{"litheBuild":{"version":7}}}); + assert!(!configuration_negotiated(&old)); + assert!(configuration_negotiated(¤t)); + assert!(is_build_method(CONFIGURATION_METHOD)); + assert!(model_inputs_negotiated(¤t)); + assert!(execution_negotiated(¤t)); + assert!(tests_negotiated(¤t)); + assert!(run_preparation_negotiated(¤t)); + assert!(debug_preparation_negotiated(¤t)); + } +} diff --git a/rust/lithe-core/src/lsp/interface/client.rs b/rust/lithe-core/src/lsp/interface/client.rs index c2d65070ad..f67247f15a 100644 --- a/rust/lithe-core/src/lsp/interface/client.rs +++ b/rust/lithe-core/src/lsp/interface/client.rs @@ -1871,6 +1871,15 @@ fn feature_names_from_capabilities(capabilities: &Value) -> Vec { if super::build_provider::tests_negotiated(capabilities) { insert_unique(&mut values, super::build_provider::TEST_EXECUTION_FEATURE); } + if super::build_provider::test_selectors_negotiated(capabilities) { + insert_unique(&mut values, super::build_provider::TEST_SELECTORS_FEATURE); + } + if super::build_provider::test_reports_negotiated(capabilities) { + insert_unique(&mut values, super::build_provider::TEST_REPORTS_FEATURE); + } + if super::build_provider::configuration_negotiated(capabilities) { + insert_unique(&mut values, super::build_provider::CONFIGURATION_FEATURE); + } if super::build_provider::model_inputs_negotiated(capabilities) { insert_unique(&mut values, super::build_provider::MODEL_INPUTS_FEATURE); } @@ -1880,6 +1889,12 @@ fn feature_names_from_capabilities(capabilities: &Value) -> Vec { super::build_provider::DEBUG_PREPARATION_FEATURE, ); } + if super::build_provider::runtime_debug_negotiated(capabilities) { + insert_unique(&mut values, super::build_provider::RUNTIME_DEBUG_FEATURE); + } + if super::build_provider::run_options_negotiated(capabilities) { + insert_unique(&mut values, super::build_provider::RUN_OPTIONS_FEATURE); + } if super::build_provider::run_preparation_negotiated(capabilities) { insert_unique(&mut values, super::build_provider::RUN_PREPARATION_FEATURE); } diff --git a/rust/lithe-core/src/lsp/interface/engine.rs b/rust/lithe-core/src/lsp/interface/engine.rs index a98d089a3c..e78d7ff849 100644 --- a/rust/lithe-core/src/lsp/interface/engine.rs +++ b/rust/lithe-core/src/lsp/interface/engine.rs @@ -815,6 +815,14 @@ struct PendingRequest { deadline: Instant, } +/// Launch discovery delayed until the same project-configuration gate as builds. +/// No JSON-RPC request exists until dispatch, so cancellation writes nothing. +struct JavaEntrypointWaiter { + operation_id: String, + created_at: Instant, + deadline: Instant, +} + /// Latest completed marker projection for one synchronized document. /// /// Only one version per URI is retained. This bounds memory while preventing @@ -877,6 +885,7 @@ struct SessionState { maven_profile_request_generations: BTreeMap, /// Serialized, configuration-gated `vscode.java.buildWorkspace` calls. java_builds: JavaBuildCoordinator, + java_entrypoint_waiters: VecDeque, /// Launch attempts share their operation ID across discovery, build and classpath. java_launches: BTreeMap, /// Already allocated classpath requests; the bounded writer sends only live ones. @@ -2177,6 +2186,7 @@ impl LspEngine { maven_profile_generation: 0, maven_profile_request_generations: BTreeMap::new(), java_builds: JavaBuildCoordinator::default(), + java_entrypoint_waiters: VecDeque::new(), java_launches: BTreeMap::new(), java_launch_messages: Vec::new(), java_build_timeout, @@ -2647,12 +2657,36 @@ impl RuntimeSession { } if state.request_by_operation.contains_key(&operation_id) || state.java_builds.contains(&operation_id) + || state.java_launches.contains_key(&operation_id) + || state + .java_entrypoint_waiters + .iter() + .any(|waiter| waiter.operation_id == operation_id) { return Err(CoreError::new( ErrorCode::InvalidRequest, "The language-server operation ID is already pending.", )); } + let now = Instant::now(); + if java_configuration_running(&mut state, now) { + if state.java_entrypoint_waiters.len() >= 128 { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Java entry-point discovery queue is full.", + )); + } + let deadline = now + state.java_build_timeout; + state + .java_entrypoint_waiters + .push_back(JavaEntrypointWaiter { + operation_id: operation_id.clone(), + created_at: now, + deadline, + }); + state.java_launches.insert(operation_id, preparation); + return Ok(()); + } let response = allocate_raw_request( state.client.clone(), "workspace/executeCommand", @@ -2729,6 +2763,10 @@ impl RuntimeSession { } if state.request_by_operation.contains_key(&operation_id) || state.java_builds.contains(&operation_id) + || state + .java_entrypoint_waiters + .iter() + .any(|waiter| waiter.operation_id == operation_id) { return Err(CoreError::new( ErrorCode::InvalidRequest, @@ -2775,6 +2813,99 @@ impl RuntimeSession { ) .with_details("plugin.build.unavailable")); } + if request.params.pointer("/launchOptions/debug").is_some() + && !state + .client + .server_capabilities + .iter() + .any(|feature| feature == super::build_provider::RUNTIME_DEBUG_FEATURE) + { + return Err(CoreError::new( + ErrorCode::NotSupported, + "The provider did not negotiate runtime debug options.", + ) + .with_details("plugin.build.unavailable")); + } + if request.params.get("launchOptions").is_some() { + if request.method != super::build_provider::PREPARE_RUN_METHOD { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Launch options apply only to run preparation.", + )); + } + if !state + .client + .server_capabilities + .iter() + .any(|feature| feature == super::build_provider::RUN_OPTIONS_FEATURE) + { + return Err(CoreError::new( + ErrorCode::NotSupported, + "The provider did not negotiate typed run options.", + ) + .with_details("plugin.build.unavailable")); + } + } + if request.params.get("selectorKind").is_some() + || request.params.get("selectors").is_some() + { + if request.method != super::build_provider::TEST_METHOD { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Opaque selectors require a provider test request.", + )); + } + if !state + .client + .server_capabilities + .iter() + .any(|feature| feature == super::build_provider::TEST_SELECTORS_FEATURE) + { + return Err(CoreError::new( + ErrorCode::NotSupported, + "The provider did not negotiate opaque test selectors.", + ) + .with_details("plugin.build.unavailable")); + } + } + if request.params.get("testResults").is_some() { + if request.method != super::build_provider::TEST_METHOD + || request.params["testResults"] != json!(true) + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Inline test reports require a provider test request.", + )); + } + if !state + .client + .server_capabilities + .iter() + .any(|feature| feature == super::build_provider::TEST_REPORTS_FEATURE) + { + return Err(CoreError::new( + ErrorCode::NotSupported, + "The provider did not negotiate inline test reports.", + ) + .with_details("plugin.build.unavailable")); + } + } + if matches!( + request.method.as_str(), + super::build_provider::CONFIGURATION_METHOD + | super::build_provider::EDIT_CONFIGURATION_METHOD + ) && !state + .client + .server_capabilities + .iter() + .any(|feature| feature == super::build_provider::CONFIGURATION_FEATURE) + { + return Err(CoreError::new( + ErrorCode::NotSupported, + "The provider did not negotiate native configuration.", + ) + .with_details("plugin.build.unavailable")); + } if request.method == super::build_provider::PREPARE_DEBUG_METHOD && !state .client @@ -3033,6 +3164,10 @@ impl RuntimeSession { } if state.request_by_operation.contains_key(&operation_id) || state.java_builds.contains(&operation_id) + || state + .java_entrypoint_waiters + .iter() + .any(|waiter| waiter.operation_id == operation_id) { return Err(CoreError::new( ErrorCode::InvalidRequest, @@ -3108,6 +3243,10 @@ impl RuntimeSession { } if state.request_by_operation.contains_key(&operation_id) || state.java_builds.contains(&operation_id) + || state + .java_entrypoint_waiters + .iter() + .any(|waiter| waiter.operation_id == operation_id) { return Err(CoreError::new( ErrorCode::InvalidRequest, @@ -3293,6 +3432,10 @@ impl RuntimeSession { } if state.request_by_operation.contains_key(&operation_id) || state.java_builds.contains(&operation_id) + || state + .java_entrypoint_waiters + .iter() + .any(|waiter| waiter.operation_id == operation_id) { return Err(CoreError::new( ErrorCode::InvalidRequest, @@ -3360,6 +3503,31 @@ impl RuntimeSession { } } + // Profile updates return before m2e has changed the Java projects. + // Discovering first and only gating the later build can select an + // old entry point or miss a source root introduced by the profile. + if self.provider_id == "java" + && request.operation == LspSemanticOperation::JavaEntrypoints + && java_configuration_running(&mut state, Instant::now()) + { + if state.java_entrypoint_waiters.len() >= 128 { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Too many Java entry-point requests are waiting for project configuration.", + )); + } + let now = Instant::now(); + let timeout = state.java_build_timeout; + state + .java_entrypoint_waiters + .push_back(JavaEntrypointWaiter { + operation_id, + created_at: now, + deadline: now + timeout, + }); + return Ok(()); + } + let pending_kind = requested_kind; let response = match request.operation { LspSemanticOperation::JavaSuperImplementation => { @@ -3572,6 +3740,32 @@ impl RuntimeSession { let outbound_order = self.lock_outbound_order()?; let request_id = { let mut state = self.lock_state()?; + if let Some(index) = state + .java_entrypoint_waiters + .iter() + .position(|waiter| waiter.operation_id == operation_id) + { + state.java_entrypoint_waiters.remove(index); + let error = runtime_error( + self, + "requestCancelled", + "request", + Some(JAVA_BUILD_METHOD), + None, + "Language-server request was cancelled.", + None, + None, + ); + push_request_event( + self, + &mut state, + operation_id, + JAVA_BUILD_METHOD, + None, + Some(error), + ); + return Ok(()); + } if let Some(departure) = state.java_builds.cancel(operation_id) { let error = runtime_error( self, @@ -4145,23 +4339,27 @@ impl RuntimeSession { { if let Some(pending) = pending_before.as_ref() { if let Some(operation_id) = &pending.operation_id { - let (result, error) = - match super::build_provider::execution_outcome(&value) { - Ok(result) => (Some(result), None), - Err((code, message)) => ( + let (result, error) = match super::build_provider::execution_outcome( + &value, + state.client.server_capabilities.iter().any(|feature| { + feature == super::build_provider::TEST_REPORTS_FEATURE + }), + ) { + Ok(result) => (Some(result), None), + Err((code, message)) => ( + None, + Some(runtime_error( + self, + code, + "request", + Some(&pending.method), None, - Some(runtime_error( - self, - code, - "request", - Some(&pending.method), - None, - &message, - None, - None, - )), - ), - }; + &message, + None, + None, + )), + ), + }; push_request_event( self, &mut state, @@ -5010,6 +5208,7 @@ impl RuntimeSession { // A build response, a finished project job, or finished Maven // profiles can each unblock the next queued build. outbound.extend(self.dispatch_java_build_locked(&mut state, now)); + outbound.extend(self.dispatch_java_entrypoints_locked(&mut state, now)); } if let Some((code, detail)) = fail_initialize { @@ -5361,6 +5560,91 @@ impl RuntimeSession { flush_queued_documents_locked(&mut state) } + /// Writes queued launch discovery only after m2e and JDT configuration settle. + fn dispatch_java_entrypoints_locked( + &self, + state: &mut SessionState, + now: Instant, + ) -> Vec { + if state.lifecycle != LspLifecycleState::Ready + || !state.client.initialized + || java_configuration_running(state, now) + { + return Vec::new(); + } + let mut messages = Vec::new(); + for waiter in std::mem::take(&mut state.java_entrypoint_waiters) { + if now >= waiter.deadline { + let error = runtime_error( + self, + "requestTimeout", + "javaProjectConfiguration", + Some(JAVA_BUILD_METHOD), + None, + "Java entry-point discovery timed out waiting for project configuration.", + None, + None, + ); + push_request_event( + self, + state, + &waiter.operation_id, + JAVA_BUILD_METHOD, + None, + Some(error), + ); + continue; + } + let response = match allocate_raw_request( + state.client.clone(), + JAVA_BUILD_METHOD, + java_entrypoints_command(&java_entrypoint_scope_uri(&self.root_uri)), + ) { + Ok(response) => response, + Err(error) => { + let error = runtime_error( + self, + "invalidRequest", + "javaProjectConfiguration", + Some(JAVA_BUILD_METHOD), + None, + "Could not encode the Java entry-point request.", + Some(&error.message), + None, + ); + push_request_event( + self, + state, + &waiter.operation_id, + JAVA_BUILD_METHOD, + None, + Some(error), + ); + continue; + } + }; + let request_id = (response.state.next_request_id - 1).to_string(); + state.client = response.state; + state + .request_by_operation + .insert(waiter.operation_id.clone(), request_id.clone()); + state.pending.insert( + request_id, + PendingRequest { + kind: PendingKind::JavaEntrypoints, + operation_id: Some(waiter.operation_id), + method: JAVA_BUILD_METHOD.to_string(), + document_uri: None, + document_version: None, + created_at: waiter.created_at, + deadline: waiter.deadline, + }, + ); + messages.extend(response.messages); + } + messages + } + /// Starts the next queued Java build when nothing blocks it. The caller /// holds `outbound_order` and must write the returned messages before /// releasing it, so request IDs reach the server in allocation order. @@ -5368,7 +5652,8 @@ impl RuntimeSession { if state.lifecycle != LspLifecycleState::Ready || !state.client.initialized { return Vec::new(); } - let maven_profiles_running = state.maven_profile_status == MavenProfileTaskStatus::Running; + let maven_profiles_running = state.maven_profile_update_pending + || state.maven_profile_status == MavenProfileTaskStatus::Running; let mut allocated = None; let dispatch = state .java_builds @@ -5568,6 +5853,7 @@ impl RuntimeSession { fn pump_outbound_maintenance(&self) { if !self.lock_state().is_ok_and(|state| { state.java_builds.has_queued() + || !state.java_entrypoint_waiters.is_empty() || !state.java_launch_messages.is_empty() || !state.deadline_cancellations.is_empty() || (state.maven_profile_update_pending @@ -5624,6 +5910,7 @@ impl RuntimeSession { } } messages.extend(self.dispatch_java_build_locked(&mut state, Instant::now())); + messages.extend(self.dispatch_java_entrypoints_locked(&mut state, Instant::now())); if messages.is_empty() { return; } @@ -5756,6 +6043,34 @@ impl RuntimeSession { let maven_profiles_running = state.maven_profile_status == MavenProfileTaskStatus::Running; let departure = state.java_builds.expire(maven_profiles_running, now); + let mut waiting = std::mem::take(&mut state.java_entrypoint_waiters); + while let Some(waiter) = waiting.pop_front() { + if now < waiter.deadline { + state.java_entrypoint_waiters.push_back(waiter); + continue; + } + let error = runtime_error( + self, + "requestTimeout", + "javaProjectConfiguration", + Some(JAVA_BUILD_METHOD), + None, + "Java entry-point discovery timed out waiting for project configuration.", + Some(&format!( + "elapsedMilliseconds={}", + now.saturating_duration_since(waiter.created_at).as_millis() + )), + None, + ); + push_request_event( + self, + &mut state, + &waiter.operation_id, + JAVA_BUILD_METHOD, + None, + Some(error), + ); + } for waiter in &departure.expired { let error = runtime_error( self, @@ -6717,6 +7032,13 @@ fn enqueue_runtime_event( } // Reuse the build coordinator's gate; never infer readiness from a log message. +/// The configuration barrier is shared by discovery and the following build. +fn java_configuration_running(state: &mut SessionState, now: Instant) -> bool { + state.maven_profile_update_pending + || state.maven_profile_status == MavenProfileTaskStatus::Running + || state.java_builds.project_configuration_running(now) +} + fn refresh_project_preparation(session: &RuntimeSession, state: &mut SessionState) { if session.provider_id != "java" { return; @@ -8028,6 +8350,26 @@ fn fail_feature_requests( message: &str, exit_code: Option, ) { + for waiter in std::mem::take(&mut state.java_entrypoint_waiters) { + let error = runtime_error( + session, + code, + stage, + Some(JAVA_BUILD_METHOD), + None, + message, + None, + exit_code, + ); + push_request_event( + session, + state, + &waiter.operation_id, + JAVA_BUILD_METHOD, + None, + Some(error), + ); + } let pending: Vec<_> = state .pending .iter() @@ -8159,6 +8501,7 @@ fn clear_runtime_state(session: &RuntimeSession, state: &mut SessionState) { state.java_navigation_marker_batches.clear(); state.java_navigation_marker_cache.clear(); state.java_builds = JavaBuildCoordinator::default(); + state.java_entrypoint_waiters.clear(); state.java_launches.clear(); state.java_launch_messages.clear(); state.deadline_cancellations.clear(); @@ -12355,6 +12698,117 @@ mod tests { harness } + fn queue_java_entrypoints(harness: &Harness, operation: &str) -> Result<(), CoreError> { + harness.session().request( + SemanticRequest { + session_id: harness.session_id.clone(), + operation_id: Some(operation.into()), + operation: LspSemanticOperation::JavaEntrypoints, + uri: None, + virtual_uri: None, + position: None, + new_name: None, + range: None, + diagnostics: Vec::new(), + completion_context: None, + completion_item: None, + code_action: None, + command: None, + }, + operation.into(), + ) + } + + #[test] + fn java_entrypoints_wait_for_profiles_and_project_jobs_before_querying_the_server() { + let mut harness = java_entrypoints_harness(); + let session = harness.session(); + session.lock_state().unwrap().maven_profile_status = MavenProfileTaskStatus::Running; + queue_java_entrypoints(&harness, "launch-discovery").unwrap(); + assert!(queue_java_entrypoints(&harness, "launch-discovery").is_err()); + session.pump_outbound_maintenance(); + assert!(!harness + .server + .messages() + .iter() + .any(|m| m["method"] == "workspace/executeCommand")); + { + let mut state = session.lock_state().unwrap(); + state.java_builds.observe_progress( + crate::lsp::languages::jdt_build::ProjectJobProgress::Begin { + token: "project-update".into(), + label: "Update project app".into(), + }, + Instant::now(), + ); + state.maven_profile_status = MavenProfileTaskStatus::Succeeded; + } + session.pump_outbound_maintenance(); + assert!(!harness + .server + .messages() + .iter() + .any(|m| m["method"] == "workspace/executeCommand")); + session.lock_state().unwrap().java_builds.observe_progress( + crate::lsp::languages::jdt_build::ProjectJobProgress::End { + token: "project-update".into(), + }, + Instant::now(), + ); + session.pump_outbound_maintenance(); + let id = harness + .server + .await_request("workspace/executeCommand") + .unwrap(); + harness + .server + .send(json!({"jsonrpc":"2.0","id":id,"result":[]})); + let event = harness.await_event(|e| { + e.operation_id.as_deref() == Some("launch-discovery") && e.kind == "requestCompleted" + }); + assert!(event.error.is_none(), "{event:?}"); + assert_eq!(event.result.as_ref().unwrap()["entries"], json!([])); + } + + #[test] + fn java_entrypoints_waiters_cancel_timeout_and_stop_without_a_discovery_or_wire_cancellation() { + for mode in ["cancel", "timeout", "stop"] { + let mut harness = java_entrypoints_harness(); + let session = harness.session(); + session.lock_state().unwrap().maven_profile_status = MavenProfileTaskStatus::Running; + queue_java_entrypoints(&harness, mode).unwrap(); + match mode { + "cancel" => session.cancel_operation(mode).unwrap(), + "timeout" => session.expire_deadlines_at(Instant::now() + Duration::from_secs(601)), + _ => session.stop().unwrap(), + } + let event = harness.await_event(|e| { + e.operation_id.as_deref() == Some(mode) && e.kind == "requestCompleted" + }); + assert!(event.error.is_some()); + assert!(session + .lock_state() + .unwrap() + .java_entrypoint_waiters + .is_empty()); + assert!(!harness.server.messages().iter().any(|m| { + m["params"]["command"] == "vscode.java.resolveMainClass" + || m["method"] == "$/cancelRequest" + })); + } + } + + #[test] + fn java_entrypoints_configuration_wait_queue_is_bounded() { + let harness = java_entrypoints_harness(); + harness.session().lock_state().unwrap().maven_profile_status = + MavenProfileTaskStatus::Running; + for index in 0..128 { + queue_java_entrypoints(&harness, &format!("queued-{index}")).unwrap(); + } + assert!(queue_java_entrypoints(&harness, "overflow").is_err()); + } + fn begin_java_launch(harness: &Harness) -> (String, String) { let operation_id = harness.engine.next_operation_id(); harness @@ -12384,6 +12838,60 @@ mod tests { ]})); } + /// The composed operation must wait for the same import barrier as discovery. + #[test] + fn java_launch_preparation_waits_for_configuration_and_reclaims_queued_attempts() { + for outcome in ["launch", "cancel", "timeout", "stop"] { + let mut harness = java_entrypoints_harness(); + let session = harness.session(); + session.lock_state().unwrap().maven_profile_status = MavenProfileTaskStatus::Running; + session + .prepare_java_launch( + JavaPrepareLaunchRequest { + session_id: harness.session_id.clone(), + operation_id: Some(outcome.into()), + source_path: "/workspace/app/src/TestMain.java".into(), + main_class: Some("demo.TestMain".into()), + }, + outcome.into(), + ) + .unwrap(); + session.pump_outbound_maintenance(); + assert!(!harness + .server + .messages() + .iter() + .any(|message| message["method"] == "workspace/executeCommand")); + match outcome { + "launch" => { + session.lock_state().unwrap().maven_profile_status = + MavenProfileTaskStatus::Succeeded; + session.pump_outbound_maintenance(); + let request = harness + .server + .await_request("workspace/executeCommand") + .unwrap(); + answer_java_launch_entries(&harness, &request); + harness + .server + .await_request_at("workspace/executeCommand", 1) + .unwrap(); + session.cancel_operation(outcome).unwrap(); + } + "cancel" => session.cancel_operation(outcome).unwrap(), + "timeout" => session.expire_deadlines_at(Instant::now() + Duration::from_secs(601)), + _ => session.stop().unwrap(), + } + let event = harness.await_event(|event| { + event.operation_id.as_deref() == Some(outcome) && event.kind == "requestCompleted" + }); + assert!(event.error.is_some(), "{outcome}: {event:?}"); + let state = session.lock_state().unwrap(); + assert!(state.java_entrypoint_waiters.is_empty()); + assert!(!state.java_launches.contains_key(outcome)); + } + } + #[test] fn java_launch_preparation_owns_selection_build_and_classpath_under_one_operation() { let mut harness = java_entrypoints_harness(); @@ -14722,6 +15230,145 @@ public class Main { ); } + #[test] + fn inline_test_reports_cannot_reach_a_provider_without_version_eight() { + for version in [7, 8] { + let mut harness = plugin_backend(json!({"litheBuild":{"version":version}})); + let result = build_request( + &harness, + "lithe/build/test", + json!({"testResults":true}), + None, + ); + if version == 7 { + assert!(matches!(result.unwrap_err().code, ErrorCode::NotSupported)); + assert!(harness + .server + .messages() + .iter() + .all(|message| message["method"] != "lithe/build/test")); + } else { + let operation = result.unwrap(); + let id = harness.server.await_request("lithe/build/test").unwrap(); + let value = json!({"exitCode":1,"output":"failure","testResults":{"format":"litheTestResults","version":1,"tests":[]}}); + harness + .server + .send(json!({"jsonrpc":"2.0","id":id,"result":value})); + let event = harness.await_event(|event| { + event.kind == "requestCompleted" + && event.operation_id.as_deref() == Some(operation.as_str()) + }); + assert_eq!(event.result.as_ref().unwrap()["value"], value); + } + } + } + + #[test] + fn provider_native_configuration_requires_version_seven_and_returns_only_the_projection() { + for version in 1..7 { + let harness = plugin_backend(json!({"litheBuild":{"version":version}})); + let error = + build_request(&harness, "lithe/build/configuration", json!({}), None).unwrap_err(); + assert!(matches!(error.code, ErrorCode::NotSupported)); + assert!(harness + .server + .messages() + .iter() + .all(|message| message["method"] != "lithe/build/configuration")); + } + let mut harness = plugin_backend(json!({"litheBuild":{"version":7}})); + let operation = + build_request(&harness, "lithe/build/configuration", json!({}), None).unwrap(); + let id = harness + .server + .await_request("lithe/build/configuration") + .unwrap(); + let result = json!({"format":"litheBuildConfiguration","version":1,"sources":[]}); + harness + .server + .send(json!({"jsonrpc":"2.0","id":id,"result":result})); + let event = harness.await_event(|event| { + event.kind == "requestCompleted" + && event.operation_id.as_deref() == Some(operation.as_str()) + }); + assert_eq!(event.result.as_ref().unwrap()["value"], result); + } + + /// User options must never be silently downgraded before a provider request. + #[test] + fn provider_run_options_require_version_ten_before_wire_send() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../../shared/contracts/plugins/v1/fixtures/build-run-options.json" + )) + .unwrap(); + let params = json!({"launchOptions":fixture["launchOptions"]}); + let old = plugin_backend(json!({"litheBuild":{"version":9}})); + let error = + build_request(&old, "lithe/build/prepareRun", params.clone(), None).unwrap_err(); + assert!(matches!(error.code, ErrorCode::NotSupported)); + assert!(old + .server + .messages() + .iter() + .all(|message| message["method"] != "lithe/build/prepareRun")); + let mut current = plugin_backend(json!({"litheBuild":{"version":10}})); + let error = build_request(¤t, "lithe/build/model", params.clone(), None).unwrap_err(); + assert!(matches!(error.code, ErrorCode::InvalidRequest)); + let operation = build_request(¤t, "lithe/build/prepareRun", params, None).unwrap(); + let id = current + .server + .await_request("lithe/build/prepareRun") + .unwrap(); + let message = current + .server + .messages() + .into_iter() + .find(|message| message["method"] == "lithe/build/prepareRun") + .unwrap(); + assert_eq!(message["params"]["launchOptions"], fixture["launchOptions"]); + current + .server + .send(json!({"jsonrpc":"2.0","id":id,"result":fixture["result"]})); + let event = current.await_event(|event| { + event.kind == "requestCompleted" + && event.operation_id.as_deref() == Some(operation.as_str()) + }); + assert_eq!(event.result.as_ref().unwrap()["value"], fixture["result"]); + } + + /// Port ownership cannot be silently discarded by a version-10 process. + #[test] + fn provider_runtime_debug_requires_version_eleven_before_wire_send() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../../shared/contracts/plugins/v1/fixtures/build-connection-debug-options.json" + )) + .unwrap(); + let params = json!({"launchOptions":fixture["launchOptions"]}); + let old = plugin_backend(json!({"litheBuild":{"version":10}})); + let error = + build_request(&old, "lithe/build/prepareRun", params.clone(), None).unwrap_err(); + assert!(matches!(error.code, ErrorCode::NotSupported)); + assert!(old + .server + .messages() + .iter() + .all(|message| message["method"] != "lithe/build/prepareRun")); + let mut current = plugin_backend(json!({"litheBuild":{"version":11}})); + let operation = build_request(¤t, "lithe/build/prepareRun", params, None).unwrap(); + let id = current + .server + .await_request("lithe/build/prepareRun") + .unwrap(); + current + .server + .send(json!({"jsonrpc":"2.0","id":id,"result":fixture["result"]})); + let event = current.await_event(|event| { + event.kind == "requestCompleted" + && event.operation_id.as_deref() == Some(operation.as_str()) + }); + assert_eq!(event.result.as_ref().unwrap()["value"], fixture["result"]); + } + #[test] fn provider_run_preparation_requires_version_four_and_returns_raw_plan() { for version in [1, 2, 3] { @@ -15015,7 +15662,7 @@ public class Main { .expect("initialize should have been written"); assert_eq!( initialize["params"]["capabilities"]["experimental"]["litheBuild"], - json!({ "versions": [6, 5, 4, 3, 2, 1] }) + json!({ "versions": [11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1] }) ); let error = build_request(&harness, "lithe/build/model", json!({}), None) @@ -15862,7 +16509,7 @@ public class Main { assert_eq!( initialize["params"]["capabilities"]["experimental"], json!({ - "litheBuild": { "versions": [6, 5, 4, 3, 2, 1] }, + "litheBuild": { "versions": [11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1] }, "litheFunctions": { "versions": [1, 2, 3] }, "litheLogging": { "versions": [1] }, "litheVcs": { "versions": [1] } diff --git a/rust/lithe-core/src/lsp/interface/engine_real_jdt_maven_provider_tests.rs b/rust/lithe-core/src/lsp/interface/engine_real_jdt_maven_provider_tests.rs new file mode 100644 index 0000000000..8ba9c5af1c --- /dev/null +++ b/rust/lithe-core/src/lsp/interface/engine_real_jdt_maven_provider_tests.rs @@ -0,0 +1,370 @@ +//! Opt-in Maven Provider -> JDT -> project JVM integration, with installed +//! resources kept read-only and both workspace sessions owned by the test. + +use super::*; +use sha2::{Digest, Sha256}; + +fn command(name: &str, payload: Value) -> Value { + let response: Value = serde_json::from_str(&crate::execute_json( + &json!({"id":"java-provider-integration", "command":name, "payload":payload}).to_string(), + )) + .expect("Core JSON response"); + assert_eq!(response["ok"], true, "{response}"); + assert_eq!(response["data"]["status"], "ok", "{response}"); + response["data"].clone() +} + +fn build_command(manifest: &Value, name: &str, request: Value) -> Value { + command( + name, + json!({"manifest":manifest,"buildSystemID":"maven","request":request}), + ) +} + +fn provider_request(engine: &LspEngine, session: &Arc, plan: &Value) -> Value { + let operation = engine.next_operation_id(); + session + .build_request( + BuildProviderRequest { + session_id: session.id.clone(), + operation_id: Some(operation.clone()), + method: plan["provider"]["method"] + .as_str() + .expect("provider method") + .into(), + params: plan["provider"]["params"].clone(), + timeout_milliseconds: Some(120_000), + }, + operation.clone(), + ) + .expect("Core should admit the selected Provider request"); + await_operation(session, &operation, Duration::from_secs(120)) + .unwrap_or_else(|error| panic!("Provider request failed: {error}"))["value"] + .clone() +} + +fn write(root: &Path, path: &str, content: &str) { + let file = root.join(path); + std::fs::create_dir_all(file.parent().expect("fixture parent")).expect("fixture directory"); + std::fs::write(file, content).expect("fixture contents"); +} + +/// A byte-sensitive inventory, unlike a size-only listing, also detects an +/// upstream process rewriting an installed file without changing its length. +fn inventory(root: &Path) -> BTreeMap { + installation_listing(root) + .into_keys() + .map(|relative| { + let path = root.join(&relative); + let digest = if path.is_symlink() { + format!( + "link:{:?}", + std::fs::read_link(path).expect("installed symlink") + ) + } else { + format!( + "{:x}", + Sha256::digest(std::fs::read(path).expect("installed file")) + ) + }; + (relative, digest) + }) + .collect() +} + +#[test] +fn real_provider_resources_survive_jdt_build_and_reach_the_project_jvm() { + // This is an explicit tool integration, never an installed-tools dependency + // of the ordinary unit lane. A missing requested tool fails, not skips. + let Some(package) = std::env::var_os("LITHE_JDTLS_SMOKE_MAVEN_PACKAGE") else { + return; + }; + let toolchain = real_jdt_toolchain().expect("select the packaged JDT and both JDKs"); + let package = PathBuf::from(package); + let maven = toolchain + .maven + .as_ref() + .expect("select Maven for the integration"); + let manifest: Value = serde_json::from_slice( + &std::fs::read(package.join("plugin.json")).expect("Maven manifest"), + ) + .expect("Maven manifest JSON"); + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("Unix time") + .as_nanos(); + let scratch = std::env::temp_dir().join(format!( + "lithe-java-provider-{}-{stamp}", + std::process::id() + )); + let engine = LspEngine::new(); + // Own the tree before the first fixture write, including partial setup. + let _scratch_cleanup = RealSmokeCleanup { + engine: &engine, + session_id: "not-started".into(), + root: scratch.clone(), + }; + let workspace = scratch.join("workspace"); + // Keep a nested reactor and a nonstandard source/resource layout. No host + // filename convention can manufacture these project/source-role facts. + write( + &workspace, + "nested/pom.xml", + r#"4.0.0 + smokereactor1pom + libraryapp + 88 + UTF-8"#, + ); + write( + &workspace, + "nested/library/pom.xml", + r#"4.0.0 + smokereactor1 + library"#, + ); + write(&workspace, "nested/library/src/main/java/demo/Library.java", + "package demo; public class Library { public static String value() { return \"library\"; } }"); + write( + &workspace, + "nested/app/pom.xml", + r#"4.0.0 + smokereactor1app + smokelibrary1 + inactive-sourcesinactive-checks + ${selected.sources}${selected.checks} + runtime inputstrue + test inputstrue + launchprovidersourceschecks"#, + ); + write( + &workspace, + "nested/app/sources/demo/Main.java", + r#"package demo; + public class Main { public static void main(String[] args) throws Exception { + java.util.Properties p = new java.util.Properties(); + try (java.io.InputStream input = Main.class.getResourceAsStream("/runtime.properties")) { p.load(input); } + System.out.println(p.getProperty("selected") + ":" + Library.value()); + } }"#, + ); + write( + &workspace, + "nested/app/checks/demo/TestMain.java", + r#"package demo; + public class TestMain { public static void main(String[] args) throws Exception { + java.util.Properties p = new java.util.Properties(); + try (java.io.InputStream input = TestMain.class.getResourceAsStream("/test.properties")) { p.load(input); } + System.out.println(p.getProperty("selected") + ":" + Library.value()); + } }"#, + ); + write( + &workspace, + "nested/app/runtime inputs/runtime.properties", + "selected=${selected.value}:initial", + ); + write( + &workspace, + "nested/app/test inputs/test.properties", + "selected=${selected.value}:test-initial", + ); + let workspace = workspace.canonicalize().expect("canonical workspace"); + let root_uri = url::Url::from_directory_path(&workspace) + .expect("workspace URI") + .to_string(); + let before_package = inventory(&package); + let before_jdt = inventory(&toolchain.root); + let mut provider_start = real_start_request(&toolchain, &scratch, &workspace, &root_uri); + provider_start.provider_id = "test.maven".into(); + provider_start.executable_path = toolchain + .project_jdk + .join("bin") + .join(if cfg!(windows) { "java.exe" } else { "java" }) + .to_string_lossy() + .into(); + provider_start.arguments = vec![ + "-jar".into(), + package + .join("lib/lithe-maven-backend.jar") + .to_string_lossy() + .into(), + "--state".into(), + scratch.join("provider-state").to_string_lossy().into(), + ]; + provider_start.environment.insert( + "JAVA_HOME".into(), + toolchain.project_jdk.to_string_lossy().into(), + ); + provider_start.runtime_executable_path = None; + provider_start.jdtls_launch_resources = None; + provider_start.java_runtimes.clear(); + let started = engine + .start_server(provider_start) + .expect("start the real Maven Provider"); + let _provider_cleanup = RealSmokeCleanup { + engine: &engine, + session_id: started.session_id.clone(), + root: scratch.join("provider-state"), + }; + let provider = engine + .session(&started.session_id) + .expect("Provider session"); + await_real_smoke_ready(&provider).unwrap_or_else(|error| panic!("{error}")); + let options = json!({"profiles":["launch"]}); + let base = json!({"root":"nested","options":options,"pluginDirectory":package, + "workspaceStateDirectory":scratch.join("provider-state"),"tool":{"executable":maven, + "environment":{"JAVA_HOME":toolchain.project_jdk}}}); + let model_plan = build_command(&manifest, "build.modelPlan", base.clone()); + let raw = provider_request(&engine, &provider, &model_plan["plan"]); + let model = build_command( + &manifest, + "build.decodeModel", + json!({"workspaceRoot":workspace,"root":"nested","options":options,"result":raw}), + )["model"] + .clone(); + let project = model["projects"] + .as_array() + .expect("projects") + .iter() + .find(|p| p["name"] == "app") + .expect("app project"); + assert!( + model["contentRoots"] + .as_array() + .expect("source roles") + .iter() + .any(|r| r["path"] == "nested/app/checks" && r["kind"] == "testSource"), + "{model}" + ); + let import = build_command( + &manifest, + "build.languageImport", + json!({"workspaceRoot":workspace,"root":"nested","options":options,"executablePath":maven}), + ); + let mut java_start = real_start_request(&toolchain, &scratch, &workspace, &root_uri); + let requested_import: crate::project::MavenLaunchContextRequest = + serde_json::from_value(import["languageImport"]["mavenContext"].clone()) + .expect("public language import"); + let mut initial_import = requested_import.clone(); + initial_import.profiles.clear(); + java_start.maven_context = Some(initial_import); + let release = std::fs::read_to_string(toolchain.project_jdk.join("release")) + .expect("project JDK release"); + java_start.java_runtimes[0].version = + crate::execution::java_feature_version_from_release(&release) + .expect("JDK feature") + .to_string(); + let started = engine.start_server(java_start).expect("start packaged JDT"); + let _jdt_cleanup = RealSmokeCleanup { + engine: &engine, + session_id: started.session_id.clone(), + root: scratch.join("cache"), + }; + let jdt = engine.session(&started.session_id).expect("JDT session"); + await_real_smoke_ready(&jdt).unwrap_or_else(|error| panic!("{error}")); + // Start with a different profile whose source roots contain no entry points. + // A single immediate query after the hot update must await the new model; + // retrying an empty answer would conceal discovery/configuration races. + jdt.update_maven_configuration(requested_import, false) + .expect("apply the prepared Provider import"); + let entries = + request_real_entrypoints(&engine, &jdt).expect("discover after the profile update"); + assert_eq!( + entries["entries"].as_array().map(Vec::len), + Some(2), + "{entries}" + ); + // Change resources after import: startup copying cannot make the test pass. + write( + &workspace, + "nested/app/runtime inputs/runtime.properties", + "selected=${selected.value}:changed", + ); + write( + &workspace, + "nested/app/test inputs/test.properties", + "selected=${selected.value}:test-changed", + ); + for (class, role, expected) in [ + ("demo.Main", "source", "provider:changed:library"), + ( + "demo.TestMain", + "testSource", + "provider:test-changed:library", + ), + ] { + let tasks = manifest["buildSystems"][0]["tasks"] + .as_array() + .expect("tasks"); + let declared: Vec<_> = tasks + .iter() + .filter(|task| { + task["preparation"]["purpose"] == "run" + && task["preparation"]["sourceRoles"] + .as_array() + .is_some_and(|roles| roles.iter().any(|value| value == role)) + }) + .collect(); + assert_eq!(declared.len(), 1, "one declared preparation for {role}"); + let mut request = base.clone(); + request["task"] = declared[0]["id"].clone(); + request["project"] = project.clone(); + let plan = build_command(&manifest, "build.taskPlan", request); + let result = provider_request(&engine, &provider, &plan["plan"]); + assert_eq!(result["exitCode"], 0, "{result}"); + let entry = entries["entries"] + .as_array() + .expect("entry points") + .iter() + .find(|e| e["mainClass"] == class) + .expect("exact JDT entry"); + let built = execute_real_command(&engine, &jdt, json!({"command":"vscode.java.buildWorkspace","arguments":[json!({ + "mainClass":class,"projectName":entry["projectName"],"filePath":workspace.join(entry["sourcePath"].as_str().expect("source")),"isFullBuild":false}).to_string()]}), Duration::from_secs(120)) + .unwrap_or_else(|error| panic!("JDT build failed: {error}")); + assert_eq!(built["value"], 1, "{built}"); + let paths = execute_real_command(&engine, &jdt, json!({"command":"vscode.java.resolveClasspath","arguments":[class,entry["projectName"]]}), Duration::from_secs(60)) + .unwrap_or_else(|error| panic!("JDT paths failed: {error}")); + let classpath = paths["value"][1] + .as_array() + .expect("JDT classpaths") + .iter() + .map(|p| p.as_str().expect("classpath")) + .collect::>() + .join(if cfg!(windows) { ";" } else { ":" }); + let mut launch = + std::process::Command::new(toolchain.project_jdk.join("bin").join(if cfg!(windows) { + "java.exe" + } else { + "java" + })); + launch + .args(["-cp", &classpath, class]) + .current_dir(&workspace); + let deadline = Instant::now() + Duration::from_secs(15); + let output = lithe_git_host::run( + &mut launch, + None, + || Instant::now() >= deadline, + || {}, + |_, _| {}, + ); + assert!( + output.failure.is_none() && output.status.is_some_and(|s| s.success()), + "JVM failed: {:?} {}", + output.failure, + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), expected); + } + drop(_jdt_cleanup); + drop(_provider_cleanup); + assert_eq!( + inventory(&package), + before_package, + "Maven plugin package changed" + ); + assert_eq!( + inventory(&toolchain.root), + before_jdt, + "JDT installation changed" + ); +} diff --git a/rust/lithe-core/src/lsp/interface/engine_real_jdt_tests.rs b/rust/lithe-core/src/lsp/interface/engine_real_jdt_tests.rs index 1dea544533..daab2c2c1c 100644 --- a/rust/lithe-core/src/lsp/interface/engine_real_jdt_tests.rs +++ b/rust/lithe-core/src/lsp/interface/engine_real_jdt_tests.rs @@ -13,6 +13,9 @@ use super::tests::{await_real_smoke_ready, RealSmokeCleanup}; use super::*; +#[path = "engine_real_jdt_maven_provider_tests.rs"] +mod maven_provider_tests; + /// Paths an opt-in real JDT LS check needs, or `None` when not configured. struct RealJdtToolchain { root: PathBuf, @@ -255,7 +258,9 @@ fn await_operation( .wait_events(remaining) .map_err(|error| error.message)? { - if event.operation_id.as_deref() != Some(operation_id) { + if event.operation_id.as_deref() != Some(operation_id) + || event.kind != "requestCompleted" + { continue; } if let Some(error) = event.error { diff --git a/rust/lithe-core/src/lsp/languages/java_run_markers.rs b/rust/lithe-core/src/lsp/languages/java_run_markers.rs index ccbae76e24..a359bda1d2 100644 --- a/rust/lithe-core/src/lsp/languages/java_run_markers.rs +++ b/rust/lithe-core/src/lsp/languages/java_run_markers.rs @@ -5,7 +5,7 @@ //! Run marker beside each test method, the latter two switching to a passed or //! failed icon once a run recorded an outcome. Which declarations qualify is //! decided upstream by JDT (`javaMainMethods`) and the Java Test extension -//! (`javaTestItems`); outcomes come from `maven.testResults` reports. This +//! (`javaTestItems`); outcomes come from canonical build-provider reports. This //! module only combines those answers, so the two platforms cannot drift on //! placement, labels, or how method outcomes roll up into a class outcome. //! @@ -33,8 +33,8 @@ pub struct JavaRunMarkersRequest { /// list when they cannot run tests for this file. #[serde(default)] pub test_items: Vec, - /// Recorded `maven.testResults` `testCases`; entries for other classes - /// are ignored. + /// Canonical report cases (`suite`, `case`, `status`); entries for other + /// classes are ignored. Legacy `className`/`method` remain accepted. #[serde(default)] pub test_cases: Vec, } @@ -84,9 +84,11 @@ impl RangeInput { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] -/// One `maven.testResults` test case. +/// One canonical test case, with legacy field aliases for older consumers. pub struct TestCaseInput { + #[serde(alias = "suite")] pub class_name: String, + #[serde(alias = "case")] pub method: String, pub status: String, } diff --git a/rust/lithe-core/src/plugins/capabilities.rs b/rust/lithe-core/src/plugins/capabilities.rs index bb180293f2..47f366d4d1 100644 --- a/rust/lithe-core/src/plugins/capabilities.rs +++ b/rust/lithe-core/src/plugins/capabilities.rs @@ -81,8 +81,8 @@ pub(crate) const TOOLCHAIN_PROVIDERS: &str = "toolchainProviders"; pub const SUPPORTED_CAPABILITIES: &[(&str, &[u32])] = &[ (BACKEND, &[1, 2]), (BUILD_MODEL_INPUTS, &[1]), - (BUILD_PROVIDERS, &[1, 2, 3, 4, 5, 6]), - (BUILD_SYSTEMS, &[1, 2]), + (BUILD_PROVIDERS, &[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]), + (BUILD_SYSTEMS, &[1, 2, 3, 4]), (DEBUG_ADAPTERS, &[1, 2]), (EXECUTION_PROVIDERS, &[1]), (EXECUTABLE_RESOLUTION, &[1, 2]), diff --git a/rust/lithe-core/src/plugins/declarative.rs b/rust/lithe-core/src/plugins/declarative.rs index 52224e91ac..97f5331933 100644 --- a/rust/lithe-core/src/plugins/declarative.rs +++ b/rust/lithe-core/src/plugins/declarative.rs @@ -1072,6 +1072,11 @@ impl DeclarativePluginManifest { debug_adapters: &self.debug_adapters, plugin_api_version: self.plugin_api_version, build_systems_version: self.requires.get("buildSystems").copied().unwrap_or(1), + executable_resolution_version: self + .requires + .get("executableResolution") + .copied() + .unwrap_or(0), build_providers_version: self.requires.get("buildProviders").copied().unwrap_or(1), model_inputs_version: self.requires.get("buildModelInputs").copied().unwrap_or(0), official: is_official_namespace(&self.id), @@ -1264,12 +1269,32 @@ impl DeclarativePluginManifest { } fn validate_toolchains(&self) -> Validation<()> { + let build_toolchains = self.build_systems.iter().any(|system| { + system + .invocation + .executables + .iter() + .chain( + system + .invocation + .runtime_dependencies + .iter() + .flat_map(|runtime| &runtime.executables), + ) + .any(|candidate| { + matches!( + candidate.source, + super::executables::ExecutableSource::Toolchain { .. } + ) + }) + }); if self.tool_definitions.is_empty() + && !build_toolchains && self.requires.get(capabilities::EXECUTABLE_RESOLUTION) == Some(&2) { return Err(invalid( "requires.executableResolution", - "Executable resolution 2 is reserved to tool definitions.", + "Executable resolution 2 requires tool definitions or build toolchain candidates.", )); } if self.tool_definitions.is_empty() && self.toolchain_providers.is_empty() { diff --git a/rust/lithe-core/src/plugins/executables.rs b/rust/lithe-core/src/plugins/executables.rs index 57fddb5a7d..f604858e45 100644 --- a/rust/lithe-core/src/plugins/executables.rs +++ b/rust/lithe-core/src/plugins/executables.rs @@ -100,6 +100,11 @@ pub(crate) enum ExecutableSource { #[serde(rename = "settingID")] setting_id: String, }, + /// Existing host toolchain registry, using the instance's canonical local selection. + Toolchain { + #[serde(rename = "toolchainID")] + toolchain_id: String, + }, } /// Operating system filter, applied after validating the complete candidate set. @@ -113,8 +118,10 @@ pub(crate) enum Platform { impl ExecutableCandidate { pub(crate) fn uses_resolution(&self) -> bool { - matches!(self.source, ExecutableSource::Setting { .. }) - || self.id.is_some() + matches!( + self.source, + ExecutableSource::Setting { .. } | ExecutableSource::Toolchain { .. } + ) || self.id.is_some() || self.priority.is_some() || self.insert_before.is_some() || self.insert_after.is_some() diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index 6d12924c60..ce597ec138 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -148,6 +148,10 @@ pub enum CoreCommand { ToolchainsFailureReply, /// Resolves the nearest machine-local directory pin. ToolchainsResolvePins, + /// Resolves consumer defaults while keeping workspace inheritance symbolic. + ToolchainsResolveConsumerPreferences, + /// Edits one machine-local consumer preference and returns its effective input. + ToolchainsEditConsumerPreferences, /// Resolves the four toolchain layers from a captured value snapshot. ToolchainsResolve, /// Checks a plugin-defined version probe without per-tool parsing. @@ -158,6 +162,12 @@ pub enum CoreCommand { ToolchainsCandidates, /// Finds the build-system instances of a workspace (`build.detect`). BuildDetect, + /// Validates a native team's persisted Build options (`build.projectOptions`). + BuildProjectOptions, + /// Merges checked team and machine-local Build preferences without I/O. + BuildResolveStoredOptions, + /// Edits one declared option in a machine-local Build document without I/O. + BuildEditStoredOption, /// Lists a build system's executable candidates and version probes (`build.invocationPlan`). BuildInvocationPlan, /// Compares a build tool's or runtime's version output with its minimum (`build.checkToolVersion`). @@ -172,6 +182,14 @@ pub enum CoreCommand { BuildTestPlan, /// Plans typed provider run preparation (`build.prepareRunPlan`). BuildPrepareRunPlan, + /// Plans source-backed configuration reads (`build.configurationPlan`). + BuildConfigurationPlan, + /// Validates non-sensitive native facts (`build.decodeConfiguration`). + BuildDecodeConfiguration, + /// Plans provider-owned native configuration editing (`build.configurationEditPlan`). + BuildConfigurationEditPlan, + /// Validates a public edit receipt (`build.decodeConfigurationEdit`). + BuildDecodeConfigurationEdit, /// Resolves optional public build inputs (`build.resolveModelInputs`). BuildResolveModelInputs, /// Plans native debug preparation (`build.prepareDebugPlan`). @@ -437,6 +455,8 @@ pub enum CoreCommand { LspDestroyServer, /// Validates layered run-configuration documents (`runConfig.inspect`). RunConfigInspect, + /// Reads effective saved tool paths for one Provider root (`runConfig.projectToolchain`). + RunConfigProjectToolchain, /// Selects an automatic project JDK (`runConfig.selectJava`). RunConfigSelectJava, /// Regenerates detected run configurations (`runConfig.generate`). @@ -445,12 +465,16 @@ pub enum CoreCommand { RunConfigResolve, /// Persists editable run options (`runConfig.updateOptions`). RunConfigUpdateOptions, + /// Replaces generic local tool preferences while preserving existing Run data. + RunConfigUpdateToolPreferences, /// Prepares one complete run-configuration editor save (`runConfig.saveEditorChanges`). RunConfigSaveEditorChanges, /// Adds a user-authored run configuration (`runConfig.createUserConfiguration`). RunConfigCreateUserConfiguration, /// Produces the process plan for one configuration (`runConfig.createLaunchPlan`). RunConfigCreateLaunchPlan, + /// Resolves user choices for a provider-owned launch (`runConfig.providerLaunchInputs`). + RunConfigProviderLaunchInputs, /// Counts workspace uses of Java declarations (`java.codeVision`). JavaCodeVision, /// Resolves a package-qualified Java class name (`java.className`). @@ -711,11 +735,18 @@ impl CoreCommand { "toolchains.decodeReply" => Some(Self::ToolchainsDecodeReply), "toolchains.failureReply" => Some(Self::ToolchainsFailureReply), "toolchains.resolvePins" => Some(Self::ToolchainsResolvePins), + "toolchains.resolveConsumerPreferences" => { + Some(Self::ToolchainsResolveConsumerPreferences) + } + "toolchains.editConsumerPreferences" => Some(Self::ToolchainsEditConsumerPreferences), "toolchains.resolve" => Some(Self::ToolchainsResolve), "toolchains.checkVersion" => Some(Self::ToolchainsCheckVersion), "toolchains.discoveryPlan" => Some(Self::ToolchainsDiscoveryPlan), "toolchains.candidates" => Some(Self::ToolchainsCandidates), "build.detect" => Some(Self::BuildDetect), + "build.projectOptions" => Some(Self::BuildProjectOptions), + "build.resolveStoredOptions" => Some(Self::BuildResolveStoredOptions), + "build.editStoredOption" => Some(Self::BuildEditStoredOption), "build.invocationPlan" => Some(Self::BuildInvocationPlan), "build.checkToolVersion" => Some(Self::BuildCheckToolVersion), "build.resolveOptions" => Some(Self::BuildResolveOptions), @@ -725,6 +756,10 @@ impl CoreCommand { "build.prepareDebugPlan" => Some(Self::BuildPrepareDebugPlan), "build.decodeDebugPreparation" => Some(Self::BuildDecodeDebugPreparation), "build.resolveModelInputs" => Some(Self::BuildResolveModelInputs), + "build.configurationPlan" => Some(Self::BuildConfigurationPlan), + "build.configurationEditPlan" => Some(Self::BuildConfigurationEditPlan), + "build.decodeConfigurationEdit" => Some(Self::BuildDecodeConfigurationEdit), + "build.decodeConfiguration" => Some(Self::BuildDecodeConfiguration), "build.prepareRunPlan" => Some(Self::BuildPrepareRunPlan), "build.decodeRunPreparation" => Some(Self::BuildDecodeRunPreparation), "build.testDiscoveryPlan" => Some(Self::BuildTestDiscoveryPlan), @@ -853,13 +888,16 @@ impl CoreCommand { "lsp.waitEvents" => Some(Self::LspWaitEvents), "lsp.destroyServer" => Some(Self::LspDestroyServer), "runConfig.inspect" => Some(Self::RunConfigInspect), + "runConfig.projectToolchain" => Some(Self::RunConfigProjectToolchain), "runConfig.selectJava" => Some(Self::RunConfigSelectJava), "runConfig.generate" => Some(Self::RunConfigGenerate), "runConfig.resolve" => Some(Self::RunConfigResolve), "runConfig.updateOptions" => Some(Self::RunConfigUpdateOptions), + "runConfig.updateToolPreferences" => Some(Self::RunConfigUpdateToolPreferences), "runConfig.saveEditorChanges" => Some(Self::RunConfigSaveEditorChanges), "runConfig.createUserConfiguration" => Some(Self::RunConfigCreateUserConfiguration), "runConfig.createLaunchPlan" => Some(Self::RunConfigCreateLaunchPlan), + "runConfig.providerLaunchInputs" => Some(Self::RunConfigProviderLaunchInputs), "java.codeVision" => Some(Self::JavaCodeVision), "java.className" => Some(Self::JavaClassName), "java.sourceDefinition" => Some(Self::JavaSourceDefinition), diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index 61b365f6d8..3f5c3095e2 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -345,6 +345,16 @@ fn execute_parsed(parsed: CoreRequest) -> CoreResponse { CoreCommand::ToolchainsResolvePins => { plugin_response(id, parsed.payload, crate::toolchain::resolve_pins_command) } + CoreCommand::ToolchainsResolveConsumerPreferences => plugin_response( + id, + parsed.payload, + crate::toolchain::resolve_consumer_preferences_command, + ), + CoreCommand::ToolchainsEditConsumerPreferences => plugin_response( + id, + parsed.payload, + crate::toolchain::edit_consumer_preferences_command, + ), CoreCommand::ToolchainsResolve => { plugin_response(id, parsed.payload, crate::toolchain::resolve_command) } @@ -360,6 +370,19 @@ fn execute_parsed(parsed: CoreRequest) -> CoreResponse { CoreCommand::BuildDetect => { plugin_response(id, parsed.payload, crate::build::build_detect_command) } + CoreCommand::BuildProjectOptions => { + plugin_response(id, parsed.payload, crate::build::project_options_command) + } + CoreCommand::BuildResolveStoredOptions => plugin_response( + id, + parsed.payload, + crate::build::resolve_stored_options_command, + ), + CoreCommand::BuildEditStoredOption => plugin_response( + id, + parsed.payload, + crate::build::build_edit_stored_option_command, + ), CoreCommand::BuildInvocationPlan => plugin_response( id, parsed.payload, @@ -396,6 +419,26 @@ fn execute_parsed(parsed: CoreRequest) -> CoreResponse { parsed.payload, crate::build::build_resolve_model_inputs_command, ), + CoreCommand::BuildConfigurationEditPlan => plugin_response( + id, + parsed.payload, + crate::build::build_configuration_edit_plan_command, + ), + CoreCommand::BuildDecodeConfigurationEdit => plugin_response( + id, + parsed.payload, + crate::build::build_decode_configuration_edit_command, + ), + CoreCommand::BuildConfigurationPlan => plugin_response( + id, + parsed.payload, + crate::build::build_configuration_plan_command, + ), + CoreCommand::BuildDecodeConfiguration => plugin_response( + id, + parsed.payload, + crate::build::build_decode_configuration_command, + ), CoreCommand::BuildPrepareRunPlan => plugin_response( id, parsed.payload, @@ -2320,6 +2363,23 @@ fn execute_parsed(parsed: CoreRequest) -> CoreResponse { Err(error) => CoreResponse::failure(id, error), } } + CoreCommand::RunConfigProjectToolchain => { + match serde_json::from_value::( + parsed.payload, + ) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid project toolchain request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::execution::project_toolchain) + { + Ok(data) => CoreResponse::success(id, data), + Err(error) => CoreResponse::failure(id, error), + } + } CoreCommand::RunConfigGenerate => { match serde_json::from_value::(parsed.payload) .map_err(|error| { @@ -2362,6 +2422,23 @@ fn execute_parsed(parsed: CoreRequest) -> CoreResponse { Err(error) => CoreResponse::failure(id, error), } } + CoreCommand::RunConfigUpdateToolPreferences => { + match serde_json::from_value::( + parsed.payload, + ) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid tool preferences request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::execution::update_tool_preferences) + { + Ok(data) => CoreResponse::success(id, data), + Err(error) => CoreResponse::failure(id, error), + } + } CoreCommand::RunConfigSaveEditorChanges => { match serde_json::from_value::(parsed.payload) .map_err(|error| { @@ -2394,6 +2471,23 @@ fn execute_parsed(parsed: CoreRequest) -> CoreResponse { Err(error) => CoreResponse::failure(id, error), } } + CoreCommand::RunConfigProviderLaunchInputs => { + match serde_json::from_value::( + parsed.payload, + ) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid provider launch input request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::execution::provider_launch_inputs) + { + Ok(data) => CoreResponse::success(id, data), + Err(error) => CoreResponse::failure(id, error), + } + } CoreCommand::RunConfigCreateLaunchPlan => { match serde_json::from_value::(parsed.payload) .map_err(|error| { @@ -2693,19 +2787,37 @@ fn execute_parsed(parsed: CoreRequest) -> CoreResponse { } CoreCommand::GitWorkspaceStatusMerge => { match serde_json::from_value::(parsed.payload) - .map_err(|error| CoreError::new(ErrorCode::InvalidRequest, "Invalid workspace Git status request").with_details(error.to_string())) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid workspace Git status request", + ) + .with_details(error.to_string()) + }) .and_then(git::workspace_status::plan) { - Ok(data) => CoreResponse::success(id, serde_json::to_value(data).expect("Git workspace status plan should encode")), + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Git workspace status plan should encode"), + ), Err(error) => CoreResponse::failure(id, error), } } CoreCommand::GitResolveFileContext => { match serde_json::from_value::(parsed.payload) - .map_err(|error| CoreError::new(ErrorCode::InvalidRequest, "Invalid Git file context request").with_details(error.to_string())) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid Git file context request", + ) + .with_details(error.to_string()) + }) .and_then(git::file_context::resolve) { - Ok(data) => CoreResponse::success(id, serde_json::to_value(data).expect("Git file context should encode")), + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Git file context should encode"), + ), Err(error) => CoreResponse::failure(id, error), } } diff --git a/rust/lithe-core/src/tests/build_configuration.rs b/rust/lithe-core/src/tests/build_configuration.rs new file mode 100644 index 0000000000..3fd8dc0220 --- /dev/null +++ b/rust/lithe-core/src/tests/build_configuration.rs @@ -0,0 +1,238 @@ +//! Format-independent native facts, edit plans and bounded receipt validation. + +use crate::build::{ + build_configuration_edit_plan_command, build_configuration_plan_command, + build_decode_configuration_command, build_decode_configuration_edit_command, +}; +use crate::plugins::declarative::validate_command; +use serde_json::{json, Value}; + +fn manifest() -> Value { + serde_json::from_str(include_str!( + "../../../../shared/contracts/plugins/v1/fixtures/build-provider-configuration.plugin.json" + )) + .unwrap() +} +fn snapshot() -> Value { + let mut value: Value = serde_json::from_str(include_str!( + "../../../../shared/contracts/plugins/v1/fixtures/build-configuration.json" + )) + .unwrap(); + if cfg!(windows) { + value["sources"][0]["uri"] = json!("file:///C:/fixture/user/settings.xml"); + } + value +} +fn native_fixture_path(path: &str) -> String { + if cfg!(windows) { + format!("C:{path}") + } else { + path.into() + } +} +fn call(command: fn(Value) -> Result, request: Value) -> Value { + let manifest = manifest(); + let id = manifest["buildSystems"][0]["id"].clone(); + command(json!({"manifest":manifest,"buildSystemID":id,"request":request})).unwrap() +} +fn edit_request() -> Value { + json!({"root":".", "workspaceStateDirectory":native_fixture_path("/fixture/state"), "outputFile":native_fixture_path("/fixture/state/outputs/edit.json"), + "snapshot":snapshot(), "edits":{"sourceID":"user", "revision":snapshot()["sources"][0]["revision"], + "values":{"localRepository":"/new"}}}) +} + +#[test] +fn native_configuration_requires_capability_seven_and_preserves_option_arguments() { + let executable = if cfg!(windows) { + "C:/fixture/mvn.cmd" + } else { + "/fixture/mvn" + }; + let plan = call( + build_configuration_plan_command, + json!({"root":".","options":{"offline":true},"tool":{"executable":executable}}), + ); + assert_eq!(plan["status"], "ok", "{plan}"); + assert_eq!( + plan["plan"]["provider"]["method"], + "lithe/build/configuration" + ); + assert_eq!( + plan["plan"]["provider"]["params"]["tool"]["executable"], + executable + ); + assert_eq!( + call( + build_configuration_plan_command, + json!({"root":".","tool":{"executable":"relative/mvn"}}) + )["status"], + "rejected" + ); + for version in 1..7 { + let mut old = manifest(); + old["requires"]["buildProviders"] = json!(version); + assert_eq!( + validate_command(json!({"manifest":old})).unwrap()["status"], + "rejected" + ); + } +} + +#[test] +fn native_configuration_rejects_sensitive_duplicate_and_unsafe_sources() { + assert_eq!( + call( + build_decode_configuration_command, + json!({"result":snapshot()}) + )["status"], + "ok" + ); + for variant in [ + "credentials", + "duplicate", + "uri", + "revision", + "installation", + "kind", + "xmlBinding", + ] { + let mut value = snapshot(); + let source = &mut value["sources"][0]; + match variant { + "credentials" => source["fields"][0]["id"] = json!("password"), + "duplicate" => { + let field = source["fields"][0].clone(); + source["fields"].as_array_mut().unwrap().push(field); + } + "uri" => source["uri"] = json!("https://example.invalid/settings.json"), + "revision" => source["revision"] = json!("unknown"), + "installation" => source["scope"] = json!("installation"), + "kind" => source["fields"][0]["kind"] = json!("password"), + "xmlBinding" => source["rootElement"] = json!({"name":"settings","namespace":""}), + _ => unreachable!(), + } + assert_eq!( + call(build_decode_configuration_command, json!({"result":value}))["status"], + "rejected", + "{variant}" + ); + } +} + +#[test] +fn native_configuration_allows_exact_read_only_aliases_but_no_editable_alias() { + let mut value = snapshot(); + value["sources"][0]["writable"] = json!(false); + let mut second = value["sources"][0].clone(); + second["id"] = json!("installation"); + second["scope"] = json!("installation"); + value["sources"].as_array_mut().unwrap().push(second); + assert_eq!( + call(build_decode_configuration_command, json!({"result":value}))["status"], + "ok" + ); + value["sources"][0]["writable"] = json!(true); + assert_eq!( + call(build_decode_configuration_command, json!({"result":value}))["status"], + "rejected" + ); +} + +#[test] +fn native_editing_passes_opaque_fields_and_private_staging_without_document_bytes() { + let mut request = edit_request(); + request["snapshot"]["sources"][0]["uri"] = json!("file:///fixture/config.json"); + request["snapshot"]["sources"][0]["fields"][0]["id"] = json!("cacheLocation"); + request["edits"]["values"] = json!({"cacheLocation":"/new"}); + let result = call(build_configuration_edit_plan_command, request); + assert_eq!(result["status"], "ok", "{result}"); + assert_eq!( + result["plan"]["provider"]["method"], + "lithe/build/editConfiguration" + ); + let params = &result["plan"]["provider"]["params"]; + assert_eq!( + params["outputFile"], + native_fixture_path("/fixture/state/outputs/edit.json") + ); + assert_eq!(params["edits"]["values"], json!({"cacheLocation":"/new"})); + for key in ["document", "rootElement", "path", "itemElement"] { + assert!(!params.to_string().contains(&format!("\"{key}\":"))); + } +} + +#[test] +fn native_editing_rejects_stale_unknown_read_only_and_escaping_requests() { + for variant in [ + "stale", + "unknown", + "readonly", + "type", + "escape", + "sameSource", + "noState", + "relativeOutput", + ] { + let mut request = edit_request(); + match variant { + "stale" => request["edits"]["revision"] = json!("missing"), + "unknown" => request["edits"]["values"] = json!({"unavailable":true}), + "readonly" => request["snapshot"]["sources"][0]["writable"] = json!(false), + "type" => request["edits"]["values"]["localRepository"] = json!(true), + "escape" => { + request["outputFile"] = + json!(native_fixture_path("/fixture/state/../settings.json")) + } + "sameSource" => { + request["workspaceStateDirectory"] = json!(native_fixture_path("/fixture")); + request["outputFile"] = json!(native_fixture_path("/fixture/user/settings.xml")); + } + "noState" => { + request + .as_object_mut() + .unwrap() + .remove("workspaceStateDirectory"); + } + "relativeOutput" => request["outputFile"] = json!("state/outputs/edit.json"), + _ => unreachable!(), + } + assert_eq!( + call(build_configuration_edit_plan_command, request)["status"], + "rejected", + "{variant}" + ); + } +} + +#[test] +fn native_edit_receipt_is_bound_to_confirmed_source_and_contains_no_document() { + let request = edit_request(); + let receipt = json!({"format":"litheConfigurationEdit","version":1,"sourceID":"user", "revision":format!("sha256:{}", "b".repeat(64)),"changed":true}); + let payload = |result: Value| json!({"snapshot":request["snapshot"],"edits":request["edits"],"result":result}); + assert_eq!( + call( + build_decode_configuration_edit_command, + payload(receipt.clone()) + )["status"], + "ok" + ); + for (field, value) in [ + ("sourceID", json!("installation")), + ("revision", json!("missing")), + ("document", json!("private")), + ("format", json!("xml")), + ("changed", json!(false)), + ] { + let mut invalid = receipt.clone(); + invalid[field] = value; + assert_eq!( + call(build_decode_configuration_edit_command, payload(invalid))["status"], + "rejected" + ); + } + let unchanged = json!({"format":"litheConfigurationEdit","version":1,"sourceID":"user","revision":request["edits"]["revision"],"changed":false}); + assert_eq!( + call(build_decode_configuration_edit_command, payload(unchanged))["status"], + "ok" + ); +} diff --git a/rust/lithe-core/src/tests/build_project_options.rs b/rust/lithe-core/src/tests/build_project_options.rs new file mode 100644 index 0000000000..8d3535fd00 --- /dev/null +++ b/rust/lithe-core/src/tests/build_project_options.rs @@ -0,0 +1,182 @@ +//! Native Build document storage syntax shared by macOS and Windows. + +use crate::build::project_options_command; +use serde_json::{json, Value}; + +#[test] +fn local_option_edits_check_declarations_preserve_siblings_and_reset_to_team_values() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/build-project-options/local-edits.json" + )) + .unwrap(); + let mut manifest: Value = serde_json::from_str(include_str!( + "../../../../shared/contracts/plugins/v1/fixtures/build-instance-options.plugin.json" + )) + .unwrap(); + // Exercise a workspace option with model-owned choices as well as the + // fixture's instance-scoped scalars; neither system is Maven-specific. + manifest["buildSystems"][0]["options"][1]["scope"] = json!("workspace"); + let edit = |local: &Value, root: &str, id: &str, value: Value, model: Value| { + crate::build::build_edit_stored_option_command(json!({"manifest":manifest,"buildSystemID":"cargo","request":{ + "document":fixture["team"],"local":local,"root":root,"optionID":id,"value":value,"modelValues":model + }})).unwrap() + }; + for case in fixture["cases"].as_array().unwrap() { + let result = edit( + &fixture["emptyLocal"], + case["root"].as_str().unwrap(), + case["optionID"].as_str().unwrap(), + case["value"].clone(), + case["modelValues"].clone(), + ); + assert_eq!( + result["status"] == "ok", + case["valid"].as_bool().unwrap(), + "{}: {result}", + case["name"] + ); + if result["status"] == "ok" { + let local = &result["edit"]["local"]; + let merged = crate::build::resolve_stored_options_command( + json!({"document":fixture["team"],"local":local}), + ) + .unwrap(); + assert_eq!( + merged["storage"]["document"]["systems"]["dormant"]["options"]["label"], + "keep" + ); + assert_eq!( + merged["storage"]["document"]["systems"]["cargo"]["instances"]["other"]["label"], + "team other" + ); + assert_eq!( + merged["storage"]["document"]["systems"]["cargo"]["savedChoices"]["features"], + json!(["remembered"]) + ); + } + } + let empty = edit( + &fixture["emptyLocal"], + "app", + "features", + json!([]), + json!({"features":["alpha"]}), + ); + let local = &empty["edit"]["local"]; + assert_eq!( + local["document"]["systems"]["cargo"]["options"]["features"], + json!(["beta"]) + ); + assert_eq!( + local["emptySelections"]["cargo"]["app"], + json!(["features"]) + ); + assert_eq!( + empty["edit"]["options"]["options"] + .as_array() + .unwrap() + .iter() + .find(|o| o["id"] == "features") + .unwrap()["value"], + json!([]) + ); + let reset = edit( + local, + "app", + "features", + Value::Null, + json!({"features":["alpha"]}), + ); + assert_eq!(reset["edit"]["local"]["emptySelections"], json!({})); + assert!( + reset["edit"]["local"]["document"]["systems"]["cargo"]["options"] + .get("features") + .is_none() + ); + let selected = edit( + &fixture["emptyLocal"], + "app", + "release", + json!(false), + json!({}), + ); + let reset = edit( + &selected["edit"]["local"], + "app", + "release", + Value::Null, + json!({}), + ); + assert_eq!( + reset["edit"]["options"]["options"] + .as_array() + .unwrap() + .iter() + .find(|o| o["id"] == "release") + .unwrap()["value"], + true + ); + let unchanged = edit( + &fixture["emptyLocal"], + "app", + "release", + Value::Null, + json!({}), + ); + assert_eq!(unchanged["edit"]["local"], fixture["emptyLocal"]); +} + +#[test] +fn native_build_options_validate_the_shared_cases_without_dropping_custom_values() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/build-project-options/validation.json" + )) + .unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let original = case["document"].clone(); + let result = project_options_command(json!({"document":original})).unwrap(); + assert_eq!( + result["status"] == "ok", + case["valid"].as_bool().unwrap(), + "{}: {result}", + case["name"] + ); + if result["status"] == "ok" { + assert_eq!( + result["document"]["systems"], + project_options_command(json!({"document":result["document"]})).unwrap() + ["document"]["systems"] + ); + } + assert_eq!(case["document"], original); + } + let defaults: Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/build-project-options/shared-defaults.json" + )) + .unwrap(); + let result = project_options_command(json!({"document":defaults})).unwrap(); + assert_eq!( + result["document"]["systems"]["maven"]["savedChoices"]["profiles"], + json!(["ci", "manual"]) + ); + assert_eq!( + result["document"]["systems"]["maven"]["instances"]["services/a"]["offline"], + true + ); +} + +#[test] +fn native_build_options_preserve_the_existing_document_bounds() { + for document in [ + json!({"version":1,"systems":(0..33).map(|i|(format!("tool-{i}"),json!({}))).collect::>()}), + json!({"version":1,"systems":{"tool":{"options":(0..257).map(|i|(format!("option-{i}"),json!(true))).collect::>()}}}), + json!({"version":1,"systems":{"tool":{"instances":(0..257).map(|i|(format!("root-{i}"),json!({}))).collect::>()}}}), + json!({"version":1,"systems":{"tool":{"options":{"text":"x".repeat(2*1024*1024)}}}}), + ] { + let result = project_options_command(json!({"document":document})).unwrap(); + assert_eq!( + result["error"]["code"], + "plugin.build.invalidOptionsDocument" + ); + } +} diff --git a/rust/lithe-core/src/tests/build_systems.rs b/rust/lithe-core/src/tests/build_systems.rs index f3f73b3c00..82347cc8c5 100644 --- a/rust/lithe-core/src/tests/build_systems.rs +++ b/rust/lithe-core/src/tests/build_systems.rs @@ -40,6 +40,16 @@ const BUILD_PLANS: &str = contract_file!("fixtures/build-plans.json"); fn fixture(name: &str) -> Value { let source = match name { "build-static-file.plugin.json" => CARGO_FIXTURE, + "build-instance-options.plugin.json" => { + contract_file!("fixtures/build-instance-options.plugin.json") + } + "build-provider-selectors.plugin.json" => { + contract_file!("fixtures/build-provider-selectors.plugin.json") + } + "build-provider-reports.plugin.json" => { + contract_file!("fixtures/build-provider-reports.plugin.json") + } + "build-toolchains.plugin.json" => contract_file!("fixtures/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, @@ -213,9 +223,331 @@ fn instance_ids(detection: &Value) -> Vec { .collect() } +#[test] +fn instance_option_scope_requires_a_capable_host_and_valid_declaration() { + let mut manifest = fixture("build-instance-options.plugin.json"); + let rejected = validate_command( + json!({"manifest": manifest, "hostCapabilities": {"buildSystems": [1, 2]}}), + ) + .unwrap(); + assert_eq!(rejected["status"], "rejected", "{rejected}"); + manifest["requires"]["buildSystems"] = json!(2); + let rejected = validate_command(json!({"manifest": manifest})).unwrap(); + assert_eq!(rejected["status"], "rejected", "{rejected}"); + manifest["requires"]["buildSystems"] = json!(3); + manifest["buildSystems"][0]["options"][0]["scope"] = json!("project"); + let rejected = validate_command(json!({"manifest": manifest})).unwrap(); + assert_eq!(rejected["status"], "rejected", "{rejected}"); +} + +#[test] +fn canonical_toolchain_candidates_keep_instance_identity_and_require_version_two() { + let mut manifest = fixture("build-toolchains.plugin.json"); + let plan = call( + "invocationPlan", + manifest.clone(), + "cargo", + json!({"root":"services/a","platform":"windows"}), + ); + assert_eq!( + plan["invocation"]["executables"], + json!([{"kind":"toolchain","toolchainID":"dev.lithe.fixture.build-toolchains:compiler","root":"services/a"}]) + ); + assert_eq!( + plan["invocation"]["runtimeDependencies"][0]["executables"], + json!([{"kind":"toolchain","toolchainID":"dev.lithe.fixture.build-toolchains:vm","root":"services/a"}]) + ); + assert_eq!( + plan["invocation"]["runtimeDependencies"][0]["version"]["arguments"], + json!(["--version"]) + ); + manifest["requires"]["executableResolution"] = json!(1); + let rejected = validate_command(json!({"manifest":manifest})).unwrap(); + assert_eq!(rejected["status"], "rejected", "{rejected}"); + manifest["requires"]["executableResolution"] = json!(2); + manifest["buildSystems"][0]["invocation"]["runtimeDependencies"][0]["executables"][0] + ["toolchainID"] = json!("../invalid"); + let rejected = validate_command(json!({"manifest":manifest})).unwrap(); + assert_eq!(rejected["status"], "rejected", "{rejected}"); +} + +#[test] +fn resolved_tool_environment_reaches_providers_with_option_precedence_and_bounded_inputs() { + let mut manifest = fixture("build-provider.plugin.json"); + manifest["buildSystems"][0]["options"] = json!([{"id":"mode","displayName":"Mode","kind":"boolean","environment":{"JAVA_HOME":"/fixture/option-jdk"}}]); + let executable = if cfg!(windows) { + "C:/fixture/mvn.cmd" + } else { + "/fixture/mvn" + }; + let request = json!({"root":".","options":{"mode":true},"tool":{"executable":executable, "environment":{"JAVA_HOME":"/fixture/selected-jdk","PATH":"/fixture/bin"}}}); + let plan = call("modelPlan", manifest.clone(), "gradle", request.clone()); + assert_eq!( + plan["plan"]["provider"]["params"]["environment"], + json!({"JAVA_HOME":"/fixture/option-jdk","PATH":"/fixture/bin"}) + ); + for (key, value) in [("invalid-name", "value"), ("PATH", "bad\0path")] { + let mut request = request.clone(); + request["tool"]["environment"] = json!({key:value}); + let rejected = call("modelPlan", manifest.clone(), "gradle", request); + assert_eq!(rejected["status"], "rejected", "{rejected}"); + } +} + +#[test] +fn instance_options_drive_static_run_entries_without_crossing_roots_or_scopes() { + let workspace = TemporaryWorkspace::new("build-instance-options"); + for root in ["a", "b", "c"] { + workspace.write(&format!("{root}/Cargo.toml"), &format!("[package]\nname = \"{root}\"\nversion = \"1.0.0\"\n[features]\ncli = []\ngui = []\n")) + .write(&format!("{root}/src/main.rs"), "fn main() {}\n"); + } + let manifest = fixture("build-instance-options.plugin.json"); + let result = build_detect_command(json!({ + "workspaceRoot": workspace.root(), "plugins": [{"manifest":manifest}], + "options":{"cargo":{"offline":true,"features":["gui"],"settingsFile":"/fixture/shared.xml"}}, + "instanceOptions":{"cargo":{ + "a":{"features":[],"settingsFile":"/fixture/a settings.xml","offline":false,"release":true}, + "b":{"features":["gui"],"settingsFile":"/fixture/b.xml"}, + "../a":{"features":["cli"]} + }}, + "instanceDefaults":{"cargo":{"features":["cli"],"settingsFile":"/fixture/team.xml","offline":false}} + })).unwrap(); + assert_eq!(result["status"], "ok", "{result}"); + let detection = &result["detection"]; + let a = instance(detection, "cargo:a"); + let b = instance(detection, "cargo:b"); + assert_eq!(a["options"]["features"], json!([])); + assert_eq!(a["options"]["offline"], true); + assert_eq!(a["options"]["settingsFile"], "/fixture/a settings.xml"); + assert_eq!(b["options"]["features"], json!(["gui"])); + assert_ne!(a["optionFingerprint"], b["optionFingerprint"]); + assert_eq!( + a["model"]["run"][0]["args"], + json!([ + "run", + "--bin", + "a", + "--release", + "--offline", + "--config", + "/fixture/a settings.xml" + ]) + ); + assert_eq!( + b["model"]["run"][0]["args"], + json!([ + "run", + "--bin", + "b", + "--features", + "gui", + "--offline", + "--config", + "/fixture/b.xml" + ]) + ); + let c = instance(detection, "cargo:c"); + assert_eq!( + c["model"]["run"][0]["args"], + json!([ + "run", + "--bin", + "c", + "--features", + "cli", + "--offline", + "--config", + "/fixture/team.xml" + ]) + ); + let options = call( + "resolveOptions", + manifest, + "cargo", + json!({"options":{"features":[]}}), + ); + assert_eq!(options["options"]["options"][0]["scope"], "instance"); +} + /// The Cargo plugin reproduces the built-in Cargo detector's run entries /// field for field, so moving Cargo onto the plugin model keeps every stored /// run configuration. +#[test] +fn provider_test_reports_are_inline_bounded_and_keep_project_identity() { + let workspace = TemporaryWorkspace::new("provider-test-reports"); + workspace + .write("nested/a/source.java", "class A {}") + .write("nested/b/source.java", "class B {}"); + let manifest = fixture("build-provider-reports.plugin.json"); + let plan = call( + "testPlan", + manifest.clone(), + "gradle", + json!({"root":"nested","userArguments":[]}), + ); + assert_eq!(plan["plan"]["results"], json!({"decoder":"provider"})); + assert_eq!(plan["plan"]["provider"]["params"]["testResults"], true); + let report = json!({"format":"litheTestResults","version":1,"tests":[ + {"project":"nested/a","suite":"demo.Same","case":"checks","status":"passed","path":"nested/a/source.java","line":1}, + {"project":"nested/b","suite":"demo.Same","case":"checks","status":"failed","message":"assertion failed"} + ]}); + let request = json!({"workspaceRoot":workspace.root(),"root":"nested","result":report}); + let decoded = call( + "decodeTestResults", + manifest.clone(), + "gradle", + request.clone(), + ); + assert_eq!(decoded["results"]["summary"]["total"], 2); + assert_eq!(decoded["results"]["summary"]["failed"], 1); + assert_eq!(decoded["results"]["tests"][0]["project"], "nested/a"); + assert_eq!(decoded["results"]["tests"][1]["project"], "nested/b"); + for invalid in [ + json!(null), + json!({"format":"wrong","version":1,"tests":[]}), + json!({"format":"litheTestResults","version":1,"tests":[{"suite":"s","case":"c","status":"unknown"}]}), + json!({"format":"litheTestResults","version":1,"tests":[{"suite":"s","case":"c","status":"passed","project":"../escape"}]}), + json!({"format":"litheTestResults","version":1,"tests":[{"suite":"s","case":"c","status":"passed","path":"outside.java"}]}), + json!({"format":"litheTestResults","version":1,"tests":[{"suite":"s","case":"c","status":"passed","line":0}]}), + json!({"format":"litheTestResults","version":1,"tests":[{"suite":"s","case":"c","status":"passed","invocations":0}]}), + ] { + let mut input = request.clone(); + input["result"] = invalid; + assert_ne!( + call("decodeTestResults", manifest.clone(), "gradle", input)["status"], + "ok" + ); + } + let mut absent = request; + absent.as_object_mut().unwrap().remove("result"); + assert_ne!( + call("decodeTestResults", manifest.clone(), "gradle", absent)["status"], + "ok" + ); + let mut old = manifest.clone(); + old["requires"]["buildProviders"] = json!(7); + assert_ne!( + validate_command(json!({"manifest":old})).unwrap()["status"], + "ok" + ); + let mut command = manifest; + command["buildSystems"][0]["test"]["source"] = json!("command"); + assert_ne!( + validate_command(json!({"manifest":command})).unwrap()["status"], + "ok" + ); +} + +#[test] +fn provider_test_coverage_is_bounded_canonical_and_does_not_change_legacy_results() { + let workspace = TemporaryWorkspace::new("canonical-coverage"); + let decode = |coverage: Option| { + let mut result = json!({"format":"litheTestResults","version":1,"tests":[]}); + if let Some(coverage) = coverage { + result["coverage"] = coverage; + } + call( + "decodeTestResults", + fixture("build-provider-reports.plugin.json"), + "gradle", + json!({"workspaceRoot":workspace.root(),"root":"nested","result":result}), + ) + }; + assert!(decode(None)["results"].get("coverage").is_none()); + let scopes = json!([{"project":"nested/b","suite":"Same","case":"one"}, + {"project":"nested/a","suite":"Same"}, {"project":"nested/a","suite":"Same"}]); + assert_eq!( + decode(Some(scopes))["results"]["coverage"], + json!([ + {"project":"nested/a","suite":"Same"}, {"project":"nested/b","suite":"Same","case":"one"}]) + ); + for scopes in [ + json!([{"project":"../escape"}]), + json!([{"project":"outside"}]), + json!([{"project":"nested/./a"}]), + json!([{"project":"nested/a","case":"one"}]), + json!([{"project":"nested/a","suite":""}]), + json!([{"project":"nested/a","suite":"bad\nname"}]), + json!(vec![json!({"project":"nested/a"}); 10_001]), + ] { + assert_ne!(decode(Some(scopes))["status"], "ok"); + } +} + +#[test] +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-test-durations.json")).unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let left = &case["values"][0]; + let right = &case["values"][1]; + let expected = &case["durationMillis"]; + let result = json!({"format":"litheTestResults","version":1,"tests":[ + {"project":".","suite":"Suite","case":"checks","status":"passed","durationMillis":left}, + {"project":".","suite":"Suite","case":"checks","status":"failed","durationMillis":right} + ]}); + let decoded = call( + "decodeTestResults", + manifest.clone(), + "gradle", + json!({ + "workspaceRoot":workspace.root(),"root":".","result":result + }), + ); + assert_eq!(decoded["status"], "ok"); + assert_eq!(decoded["results"]["tests"][0]["invocations"], 2); + assert_eq!(decoded["results"]["tests"][0]["status"], "failed"); + assert_eq!(&decoded["results"]["tests"][0]["durationMillis"], expected); + } +} + +#[test] +fn opaque_test_selectors_require_a_declared_namespace_and_never_become_host_filters() { + let manifest = fixture("build-provider-selectors.plugin.json"); + assert_eq!(validate(manifest.clone())["status"], "ok"); + let request = json!({"root":"nested","selectorKind":"org.example.semantic", + "selectors":["opaque # [case] () 中文"],"userArguments":[]}); + let plan = call("testPlan", manifest.clone(), "gradle", request.clone()); + assert_eq!(plan["status"], "ok"); + assert_eq!( + plan["plan"]["provider"]["params"]["selectors"], + request["selectors"] + ); + assert_eq!( + plan["plan"]["provider"]["params"]["selectorKind"], + "org.example.semantic" + ); + assert!(plan["plan"]["provider"]["params"].get("suite").is_none()); + for (key, value) in [ + ("selectorKind", json!("unclaimed")), + ("suite", json!("Suite")), + ("case", json!("method")), + ("selectors", json!([""])), + ("selectors", json!(["bad\nvalue"])), + ("selectors", json!(vec!["test"; 65])), + ] { + let mut invalid = request.clone(); + invalid[key] = value; + assert_ne!( + call("testPlan", manifest.clone(), "gradle", invalid)["status"], + "ok" + ); + } + let mut missing = request; + missing.as_object_mut().unwrap().remove("selectorKind"); + assert_ne!( + call("testPlan", manifest.clone(), "gradle", missing)["status"], + "ok" + ); + for version in 1..9 { + let mut old = manifest.clone(); + old["requires"]["buildProviders"] = json!(version); + assert_ne!(validate(old)["status"], "ok"); + } +} + #[test] fn cargo_run_entries_match_the_builtin_detector() { let workspace = TemporaryWorkspace::new("build-cargo-parity"); @@ -1853,6 +2185,73 @@ fn provider_execution_tasks_are_typed_and_version_gated() { ); } +#[test] +fn preparation_tasks_are_declared_versioned_and_unambiguous() { + let manifest: Value = serde_json::from_str(contract_file!( + "fixtures/build-provider-preparation.plugin.json" + )) + .unwrap(); + let validated = validate(manifest.clone()); + assert_eq!(validated["status"], "ok", "{validated}"); + assert_eq!( + validated["plugin"]["buildSystems"][0]["tasks"][0]["preparation"], + json!({"purpose":"run","sourceRoles":["generatedSource","source"]}) + ); + let review = install_review_command(json!({"manifest":manifest})).unwrap(); + let mut changed = manifest.clone(); + changed["buildSystems"][0]["tasks"][0]["preparation"]["purpose"] = json!("package"); + let updated = install_review_command( + json!({"manifest":changed,"approvedPrograms":review["review"]["programs"]}), + ) + .unwrap(); + assert_eq!( + updated["review"]["newPrograms"].as_array().unwrap().len(), + 1, + "Automatic invocation bindings are behavioral, not display-only metadata" + ); + let mut unversioned = manifest.clone(); + unversioned["requires"]["buildSystems"] = json!(3); + assert_eq!(validate(unversioned)["status"], "rejected"); + let old_host = validate_command(json!({"manifest":manifest, + "hostCapabilities":{"buildSystems":[1,2,3],"buildProviders":[1,2],"serverProcess":[1],"settings":[1]}})).unwrap(); + assert_eq!(old_host["status"], "rejected"); + assert_eq!(old_host["error"]["field"], "requires.buildSystems"); + + for binding in [ + json!({"purpose":"run","sourceRoles":[]}), + json!({"purpose":"run","sourceRoles":["testSource"]}), + json!({"purpose":"run","sourceRoles":["source","source"]}), + json!({"purpose":"run","sourceRoles":["excluded"]}), + json!({"purpose":"run","sourceRoles":["java"]}), + json!({"purpose":"","sourceRoles":["source"]}), + ] { + let mut invalid = manifest.clone(); + invalid["buildSystems"][0]["tasks"][0]["preparation"] = binding; + assert_eq!(validate(invalid)["status"], "rejected"); + } + for (field, value) in [ + ("scopes", json!(["workspace"])), + ("requiresUserArguments", json!(true)), + ("execution", json!("service")), + ("fromModel", json!(true)), + ] { + let mut invalid = manifest.clone(); + invalid["buildSystems"][0]["tasks"][0][field] = value; + assert_eq!(validate(invalid)["status"], "rejected"); + } + // Task IDs remain provider-owned, and no new protocol method is involved. + let plan = call( + "taskPlan", + manifest, + "gradle", + json!({ + "root":".", "task":"prepare-specs", "project":{"name":"app","path":"app"}}), + ); + assert_eq!(plan["status"], "ok", "{plan}"); + assert_eq!(plan["plan"]["provider"]["params"]["task"], "prepare-specs"); + assert_eq!(plan["plan"]["provider"]["method"], "lithe/build/execute"); +} + /// Test selection remains typed and report paths reuse the existing decoder; /// no test selector is interpolated into a host command. #[test] @@ -2409,3 +2808,183 @@ fn model_inputs_reject_invalid_manifests_models_and_snapshot_smuggling() { assert_eq!(plan["status"], "rejected", "{change}: {plan}"); } } + +/// Public typed choices survive planning; only an explicit workspace cwd widens containment. +#[test] +fn provider_run_options_preserve_argv_and_validate_declared_execution_and_cwd() { + let manifest: Value = serde_json::from_str(contract_file!( + "fixtures/build-provider-run-options.plugin.json" + )) + .unwrap(); + let fixture: Value = + serde_json::from_str(contract_file!("fixtures/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-run-preparation.json")) + .unwrap()["request"] + .clone(); + request["root"] = json!("app"); + request["run"] = json!("service"); + request["workspaceRoot"] = json!(workspace.root()); + request["launchOptions"] = fixture["launchOptions"].clone(); + let plan = |manifest: Value, request: Value| { + build_prepare_run_plan_command( + json!({"manifest":manifest,"buildSystemID":"gradle","request":request}), + ) + }; + let result = plan(manifest.clone(), request.clone()).unwrap(); + assert_eq!(result["status"], "ok", "{result}"); + assert_eq!( + result["plan"]["provider"]["params"]["launchOptions"], + fixture["launchOptions"] + ); + for (field, value) in [ + ("execution", json!("application")), + ("arguments", json!(["bad\u{0}"])), + ("runtimeArguments", json!(vec!["x"; 1025])), + ("environment", json!({"INVALID=NAME":"x"})), + ("environment", json!({"X":"bad\u{0}"})), + ("workingDirectory", json!("../outside")), + ("workingDirectory", json!("tools/missing")), + ("entrypoint", json!("")), + ("privateGoal", json!("secret")), + ] { + let mut invalid = request.clone(); + invalid["launchOptions"][field] = value; + let result = plan(manifest.clone(), invalid); + assert!( + result.is_err() || result.unwrap()["status"] == "rejected", + "{field}" + ); + } + let mut absent = request.clone(); + absent.as_object_mut().unwrap().remove("launchOptions"); + assert_eq!( + plan(manifest.clone(), absent).unwrap()["status"], + "rejected" + ); + let mut old = manifest.clone(); + old["requires"]["buildProviders"] = json!(9); + assert_eq!(validate(old.clone())["status"], "rejected"); + old["buildSystems"][0]["run"][0]["execution"] = json!("application"); + let mut application = request.clone(); + application["launchOptions"]["execution"] = json!("application"); + assert_eq!(validate(old.clone())["status"], "ok"); + assert_eq!(plan(old, application).unwrap()["status"], "rejected"); + let decode = |options: Value, result: Value| { + build_decode_run_preparation_command(json!({"manifest":manifest,"buildSystemID":"gradle", + "request":{"workspaceRoot":workspace.root(),"root":"app","launchOptions":options,"result":result}})).unwrap() + }; + let decoded = decode(fixture["launchOptions"].clone(), fixture["result"].clone()); + assert_eq!(decoded["status"], "ok", "{decoded}"); + assert_eq!(decoded["launch"], fixture["result"]); + let mut wrong = fixture["result"].clone(); + wrong["workingDirectory"] = json!("app"); + assert_eq!( + decode(fixture["launchOptions"].clone(), wrong)["status"], + "rejected" + ); + assert_eq!( + decode(Value::Null, fixture["result"].clone())["status"], + "rejected" + ); + std::fs::remove_dir_all(workspace.0.join("tools")).unwrap(); + assert_eq!( + decode(fixture["launchOptions"].clone(), fixture["result"].clone())["status"], + "rejected" + ); +} + +/// Runtime debugging requires an explicit process agreement and never a provider-selected host. +#[test] +fn provider_connection_debug_options_require_version_eleven_and_a_declared_registry_type() { + let mut manifest: Value = serde_json::from_str(contract_file!( + "fixtures/build-provider-run-options.plugin.json" + )) + .unwrap(); + let fixture: Value = serde_json::from_str(contract_file!( + "fixtures/build-connection-debug-options.json" + )) + .unwrap(); + 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-run-preparation.json")) + .unwrap()["request"] + .clone(); + request["root"] = json!("app"); + request["run"] = json!("service"); + request["workspaceRoot"] = json!(workspace.root()); + request["launchOptions"] = fixture["launchOptions"].clone(); + let plan = |manifest: &Value, request: &Value| { + build_prepare_run_plan_command( + json!({"manifest":manifest,"buildSystemID":"gradle","request":request}), + ) + }; + assert_eq!(plan(&manifest, &request).unwrap()["status"], "rejected"); + manifest["requires"]["buildProviders"] = json!(11); + assert_eq!(plan(&manifest, &request).unwrap()["status"], "rejected"); + let runs = manifest["buildSystems"][0]["run"].as_array_mut().unwrap(); + runs.iter_mut().find(|run| run["id"] == "service").unwrap()["debug"] = + json!({"configurationType":"java"}); + let planned = plan(&manifest, &request).unwrap(); + assert_eq!(planned["status"], "ok", "{planned}"); + assert_eq!( + planned["plan"]["provider"]["params"]["launchOptions"], + fixture["launchOptions"] + ); + let mut opaque_manifest = manifest.clone(); + let mut opaque_request = request.clone(); + let runs = opaque_manifest["buildSystems"][0]["run"] + .as_array_mut() + .unwrap(); + runs.iter_mut().find(|run| run["id"] == "service").unwrap()["debug"] = + json!({"configurationType":"runtime / λ"}); + opaque_request["launchOptions"]["debug"]["configurationType"] = json!("runtime / λ"); + assert_eq!( + plan(&opaque_manifest, &opaque_request).unwrap()["status"], + "ok" + ); + for debug in [ + Value::Null, + json!({}), + json!({"runtime":"native","port":51235}), + json!({"runtime":"jvm","port":0}), + json!({"runtime":"jvm","port":65536}), + json!({"runtime":"jvm","port":1.5}), + json!({"runtime":"jvm","port":51235,"host":"remote"}), + json!({"adapterID":"java","configurationType":"java","target":{"kind":"connection","host":"127.0.0.1","port":51235}}), + json!({"adapterID":"dev.example/adapter","configurationType":"other","target":{"kind":"connection","host":"127.0.0.1","port":51235}}), + json!({"adapterID":"dev.example/adapter","configurationType":"java","target":{"kind":"connection","host":"remote","port":51235}}), + json!({"adapterID":"dev.example/adapter","configurationType":"java","target":{"kind":"connection","host":"127.0.0.1","port":0}}), + ] { + let mut invalid = request.clone(); + invalid["launchOptions"]["debug"] = debug; + let result = plan(&manifest, &invalid); + assert!(result.is_err() || result.unwrap()["status"] == "rejected"); + } +} + +/// Canonical containment must survive a user-selected directory's filesystem alias. +#[cfg(unix)] +#[test] +fn provider_run_options_refuse_explicit_cwd_symlinks_outside_workspace() { + let workspace = TemporaryWorkspace::new("provider-options-symlink"); + let outside = TemporaryWorkspace::new("provider-options-outside"); + workspace.write("app/Main.java", "class Main {}"); + std::os::unix::fs::symlink(&outside.0, workspace.0.join("escape")).unwrap(); + let manifest: Value = serde_json::from_str(contract_file!( + "fixtures/build-provider-run-options.plugin.json" + )) + .unwrap(); + let mut fixture: Value = + serde_json::from_str(contract_file!("fixtures/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", + "request":{"workspaceRoot":workspace.root(),"root":"app","launchOptions":fixture["launchOptions"],"result":fixture["result"]}})).unwrap(); + assert_eq!(result["error"]["code"], "plugin.build.invalidOutput"); +} diff --git a/rust/lithe-core/src/tests/mod.rs b/rust/lithe-core/src/tests/mod.rs index 99b72ca707..106e208163 100644 --- a/rust/lithe-core/src/tests/mod.rs +++ b/rust/lithe-core/src/tests/mod.rs @@ -1,4 +1,6 @@ mod agent; +mod build_configuration; +mod build_project_options; mod build_runtime_versions; mod build_systems; mod community_build_plugins; diff --git a/rust/lithe-core/src/tests/project_generators.rs b/rust/lithe-core/src/tests/project_generators.rs index 50d1dd4539..47543415e7 100644 --- a/rust/lithe-core/src/tests/project_generators.rs +++ b/rust/lithe-core/src/tests/project_generators.rs @@ -44,6 +44,12 @@ fn java_language_only() -> Value { let mut manifest = json(OFFICIAL_JAVA); let object = manifest.as_object_mut().unwrap(); object.remove("projectGenerators"); + object.remove("debugAdapters"); + object["requires"] + .as_object_mut() + .unwrap() + .remove("debugAdapters"); + object["activationEvents"] = json!(["onLanguage:java"]); object["requires"] .as_object_mut() .unwrap() @@ -58,7 +64,7 @@ fn official_package_path() -> PathBuf { fn host() -> Value { json!({"languages": [1], "languageServers": [1, 2], "serverProcess": [1], "settings": [1, 2], - "buildSystems": [1], "projectGenerators": [1], "packageTrust": [1]}) + "buildSystems": [1], "projectGenerators": [1], "debugAdapters": [1], "packageTrust": [1], "toolDefinitions": [1], "executableResolution": [1, 2]}) } #[test] @@ -90,6 +96,10 @@ fn golden_project_plans_match() { #[test] fn catalog_lists_generators_with_requirement_providers() { + let language = java_language_only(); + let admission = + validate_command(json!({"manifest":language,"hostCapabilities":host()})).unwrap(); + assert_eq!(admission["status"], "ok", "{admission}"); let result = project_generator_catalog_command(json!({ "plugins": [ {"manifest": json(FIXTURE), "enabled": true}, diff --git a/rust/lithe-core/src/tests/run_configuration.rs b/rust/lithe-core/src/tests/run_configuration.rs index d60410f106..df5ad788f2 100644 --- a/rust/lithe-core/src/tests/run_configuration.rs +++ b/rust/lithe-core/src/tests/run_configuration.rs @@ -6,6 +6,356 @@ use std::collections::BTreeMap; use std::fs; use std::path::PathBuf; +#[test] +fn generic_preference_edits_preserve_run_metadata_without_creating_legacy_defaults_or_files() { + let root = temporary_root("generic-preference-editor"); + fs::create_dir_all(&root).unwrap(); + struct Cleanup(PathBuf); + impl Drop for Cleanup { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + let _cleanup = Cleanup(root.clone()); + let call = |command: &str, payload: Value| -> Value { + serde_json::from_str(&execute_json( + &serde_json::json!({ + "id":"generic-preference-editor", "command":command, "payload":payload + }) + .to_string(), + )) + .unwrap() + }; + let edited = call( + "toolchains.editConsumerPreferences", + serde_json::json!({ + "document":{"version":1}, "directory":"services/api", "consumer":"dev.example.build/compile", + "tool":"dev.example.runtime:sdk", "value":{"inherit":"consumer"} + }), + ); + assert_eq!(edited["ok"], true, "{edited}"); + assert_eq!( + edited["data"]["edit"]["preferences"]["stored"], + serde_json::json!({"inherit":"consumer"}) + ); + for original in [ + serde_json::json!({"version":2,"configurations":[]}), + serde_json::json!({"version":1,"configurations":[{"id":"user:legacy","type":"java.main","mainClass":"fixture.Main","workingDirectory":".","future":"preserve"}]}), + serde_json::json!({"version":2,"configurations":[{"id":"user:task","env":{"TOKEN":"fixture-value"},"future":[1,2]}], + "futureMetadata":{"keep":true},"toolchain":{"java":{"homePath":"/fixture/legacy-jdk"},"futureToolchain":{"keep":true}}}), + ] { + let result = call( + "runConfig.updateToolPreferences", + serde_json::json!({ + "localDocument":original,"preferences":edited["data"]["edit"]["document"] + }), + ); + assert_eq!(result["ok"], true, "{result}"); + let saved: Value = + serde_json::from_str(result["data"]["document"].as_str().unwrap()).unwrap(); + assert_eq!( + saved["toolchain"]["preferences"], + edited["data"]["edit"]["document"] + ); + let mut without_preferences = saved; + without_preferences["toolchain"] + .as_object_mut() + .unwrap() + .remove("preferences"); + if original.get("toolchain").is_none() { + without_preferences + .as_object_mut() + .unwrap() + .remove("toolchain"); + } + assert_eq!(without_preferences, original); + } + assert!(!root.join(".lithe").exists()); +} + +#[test] +fn generic_preference_editor_rejects_invalid_documents_and_references_without_writing() { + let root = temporary_root("invalid-generic-preference-editor"); + fs::create_dir_all(&root).unwrap(); + struct Cleanup(PathBuf); + impl Drop for Cleanup { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + let _cleanup = Cleanup(root.clone()); + for (local, preferences) in [ + ( + serde_json::json!({"version":4294967298_u64,"configurations":[]}), + serde_json::json!({"version":1}), + ), + ( + serde_json::json!({"version":999,"configurations":[]}), + serde_json::json!({"version":1}), + ), + ( + serde_json::json!({"version":2,"configurations":{}}), + serde_json::json!({"version":1}), + ), + ( + serde_json::json!({"version":2,"configurations":[],"toolchain":42}), + serde_json::json!({"version":1}), + ), + ( + serde_json::json!({"version":2,"configurations":[]}), + serde_json::json!({"version":1,"workspace":{"tools":{"java":{"inherit":"workspace"}}}}), + ), + ] { + let result: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id":"invalid-generic-preferences", "command":"runConfig.updateToolPreferences", + "payload":{"localDocument":local,"preferences":preferences} + }) + .to_string(), + )) + .unwrap(); + assert_eq!(result["ok"], false, "{result}"); + assert!(result["data"].is_null()); + } + assert!(!root.join(".lithe").exists()); +} + +#[test] +fn project_tool_preferences_preserve_symbolic_choices_across_legacy_editor_saves_and_explicit_reset( +) { + let root = temporary_root("project-tool-preferences"); + fs::create_dir_all(&root).unwrap(); + struct Cleanup(PathBuf); + impl Drop for Cleanup { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + let _cleanup = Cleanup(root.clone()); + let fixture: Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/toolchains/consumer-preferences.json" + )) + .unwrap(); + let call = |command: &str, payload: Value| -> Value { + serde_json::from_str(&execute_json( + &serde_json::json!({"id":"preferences-store","command":command,"payload":payload}) + .to_string(), + )) + .unwrap() + }; + let document = serde_json::json!({"version":2,"configurations":[],"toolchain":{"preferences":fixture["document"]}}); + let changed = call( + "runConfig.updateOptions", + serde_json::json!({"root":root,"scope":"local","configurationId":"","localDocument":document, + "toolchain":{"javaHomePath":"/fixture/changed-jdk"}}), + ); + assert_eq!(changed["ok"], true, "{changed}"); + let saved: Value = serde_json::from_str(changed["data"]["document"].as_str().unwrap()).unwrap(); + assert_eq!(saved["toolchain"]["preferences"], fixture["document"]); + let inspected = call( + "runConfig.projectToolchain", + serde_json::json!({"root":root,"path":".","localDocument":saved}), + ); + assert_eq!(inspected["ok"], true, "{inspected}"); + assert_eq!(inspected["data"]["toolPreferences"], fixture["document"]); + let reset = call( + "runConfig.updateOptions", + serde_json::json!({"root":root,"scope":"local","configurationId":"","localDocument":saved, + "toolchain":{"preferences":{"version":1}}}), + ); + assert_eq!(reset["ok"], true, "{reset}"); + let reset: Value = serde_json::from_str(reset["data"]["document"].as_str().unwrap()).unwrap(); + assert_eq!( + reset["toolchain"]["preferences"], + serde_json::json!({"version":1}) + ); + assert!(!root.join(".lithe/run/local.json").exists()); +} + +#[test] +fn project_tool_preferences_reject_invalid_references_in_reads_and_edits_without_clearing_sources() +{ + let root = temporary_root("invalid-tool-preferences"); + fs::create_dir_all(&root).unwrap(); + struct Cleanup(PathBuf); + impl Drop for Cleanup { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + let _cleanup = Cleanup(root.clone()); + let invalid = serde_json::json!({"version":1,"workspace":{"tools":{"dev.example.runtime:sdk":{"inherit":"workspace"}}}}); + for (command, payload) in [ + ( + "runConfig.projectToolchain", + serde_json::json!({"root":root,"path":".","localDocument":{"version":2,"configurations":[],"toolchain":{"preferences":invalid}}}), + ), + ( + "runConfig.updateOptions", + serde_json::json!({"root":root,"scope":"local","configurationId":"","localDocument":{"version":2,"configurations":[]},"toolchain":{"preferences":invalid}}), + ), + ] { + let reply: Value = serde_json::from_str(&execute_json( + &serde_json::json!({"id":"invalid-preferences","command":command,"payload":payload}) + .to_string(), + )) + .unwrap(); + assert_eq!(reply["ok"], false, "{reply}"); + assert!(reply["data"].is_null()); + } + assert!(!root.join(".lithe/run/local.json").exists()); +} + +/// Native editors format exact argv without shell expansion; an empty quoted +/// item must survive the common document mutation path on both platforms. +#[test] +fn argument_editor_text_roundtrips_the_shared_argv_fixture() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/run-configuration/argument-text.json" + )) + .unwrap(); + let root = temporary_root("run-argument-text"); + fs::create_dir_all(&root).unwrap(); + struct Cleanup(PathBuf); + impl Drop for Cleanup { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + let _cleanup = Cleanup(root.clone()); + let document = serde_json::json!({"version":2,"configurations":[{"id":"user:arguments","name":"Arguments", + "provider":"build.provider","execution":"task","extensions":{"build":{"pluginID":"dev.example.engine", + "buildSystemID":"engine","root":".","run":"task","projectID":"project","targetID":"target","execution":"task"}}}]}); + for case in fixture["cases"].as_array().unwrap() { + let reply: Value = serde_json::from_str(&execute_json(&serde_json::json!({"id":"argument-text","command":"runConfig.updateOptions", + "payload":{"root":root,"scope":"local","configurationId":"user:arguments","localDocument":document,"arguments":case["text"]}}).to_string())).unwrap(); + assert_eq!(reply["ok"], true, "{reply}"); + let result: Value = + serde_json::from_str(reply["data"]["document"].as_str().unwrap()).unwrap(); + assert_eq!(result["configurations"][0]["args"], case["values"]); + } + assert!(!root.join(".lithe/run/local.json").exists()); +} + +#[test] +fn configuration_toolchain_overrides_match_shared_fixture_without_writing_sources() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/run-configuration/configuration-toolchain-selection.json" + )) + .unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let root = temporary_root("configuration-toolchain-selection"); + struct Cleanup(PathBuf); + impl Drop for Cleanup { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + let _cleanup = Cleanup(root.clone()); + for directory in [".lithe/run", "reactor", "other"] { + fs::create_dir_all(root.join(directory)).unwrap(); + } + let local = root.join(".lithe/run/local.json"); + let team = root.join(".lithe/run/configurations.json"); + let original = b"{ keep the original local document untouched }"; + fs::write(&local, original).unwrap(); + if let Some(generated) = case.get("generatedDocument") { + seed_generated(&root, generated.to_string()); + } + let team_bytes = case + .get("teamDocument") + .map(|value| serde_json::to_vec(value).unwrap()); + if let Some(bytes) = &team_bytes { + fs::write(&team, bytes).unwrap(); + } else if team.exists() { + fs::remove_file(&team).unwrap(); + } + let mut request = case["request"].clone(); + request["root"] = serde_json::json!(root); + let response: Value = serde_json::from_str(&execute_json(&serde_json::json!({ + "id":"configuration-toolchain", "command":"runConfig.projectToolchain", "payload":request + }).to_string())).unwrap(); + let expected = &fixture["expected"][case["name"].as_str().unwrap()]; + if let Some(code) = expected["errorCode"].as_str() { + assert_eq!(response["ok"], false, "{}: {response}", case["name"]); + assert_eq!( + response["error"]["code"], code, + "{}: {response}", + case["name"] + ); + } else { + assert_eq!(response["ok"], true, "{}: {response}", case["name"]); + assert_eq!(response["data"], *expected, "{}", case["name"]); + } + assert_eq!(fs::read(&local).unwrap(), original); + if let Some(bytes) = team_bytes { + assert_eq!(fs::read(&team).unwrap(), bytes); + } + } +} + +#[test] +fn project_toolchain_selection_matches_shared_fixture_without_writing_local_data() { + let root = temporary_root("provider-toolchain-selection"); + struct Cleanup(PathBuf); + impl Drop for Cleanup { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + fs::create_dir_all(root.join(".lithe/run")).unwrap(); + let _cleanup = Cleanup(root.clone()); + let local = root.join(".lithe/run/local.json"); + let original = b"{ corrupt saved document remains untouched }"; + fs::write(&local, original).unwrap(); + let fixture: Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/run-configuration/project-toolchain-selection.json" + )) + .unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let expected = &fixture["expected"][case["name"].as_str().unwrap()]; + let mut request = case["request"].clone(); + request["root"] = serde_json::json!(root); + let response: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id":"project-toolchain", "command":"runConfig.projectToolchain", "payload":request + }) + .to_string(), + )) + .unwrap(); + if let Some(code) = expected["errorCode"].as_str() { + assert_eq!( + response["error"]["code"], code, + "{}: {response}", + case["name"] + ); + } else { + assert_eq!(response["ok"], true, "{}: {response}", case["name"]); + assert_eq!(response["data"], *expected, "{}", case["name"]); + } + assert_eq!(fs::read(&local).unwrap(), original); + } + let response: Value = serde_json::from_str(&execute_json(&serde_json::json!({ + "id":"project-toolchain-disk", "command":"runConfig.projectToolchain", "payload":{"root":root,"path":"."} + }).to_string())).unwrap(); + assert_eq!(response["ok"], false); + assert_eq!(fs::read(&local).unwrap(), original); + fs::create_dir_all(root.join(".lithe/toolchains")).unwrap(); + fs::write( + root.join(".lithe/toolchains/local.json"), + r#"{"version":1,"toolchains":{"project-node":{"executable":"C:/Example Node/node.exe"}}}"#, + ) + .unwrap(); + let response: Value = serde_json::from_str(&execute_json(&serde_json::json!({ + "id":"project-toolchain-runtimes", "command":"runConfig.projectToolchain", "payload":{"root":root,"path":".","localDocument":{"version":2,"configurations":[]}} + }).to_string())).unwrap(); + assert_eq!( + response["data"]["toolchain"]["runtimeExecutablePaths"]["project-node"], + "C:/Example Node/node.exe" + ); + assert_eq!(fs::read(&local).unwrap(), original); +} + /// A Maven goal is not evidence of a launchable application. Only confirmed /// production entries can promote that exact module to a Spring service. #[test] @@ -98,6 +448,108 @@ fn spring_services_require_unique_confirmed_entries_in_their_own_module() { .any(|entry| entry["provider"] == "spring-boot.maven")); } +#[test] +fn subproject_toolchain_paths_roundtrip_and_reject_invalid_scope_keys() { + let root = temporary_root("subproject-toolchain-paths"); + fs::create_dir_all(&root).unwrap(); + struct Cleanup(PathBuf); + impl Drop for Cleanup { + fn drop(&mut self) { + fs::remove_dir_all(&self.0).unwrap(); + } + } + let _cleanup = Cleanup(root.clone()); + let fixture: Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/run-configuration/subproject-toolchains.json" + )) + .unwrap(); + let call = |command: &str, payload: Value| -> Value { + serde_json::from_str(&execute_json( + &serde_json::json!({"id":"subproject-toolchains", "command":command,"payload":payload}) + .to_string(), + )) + .unwrap() + }; + let updated = call( + "runConfig.updateOptions", + serde_json::json!({ + "root":root,"scope":"local","configurationId":"", "toolchain":fixture["toolchain"] + }), + ); + assert_eq!(updated["ok"], true, "{updated}"); + let saved: Value = serde_json::from_str(updated["data"]["document"].as_str().unwrap()).unwrap(); + assert_eq!( + saved["toolchain"]["pathOverrides"], + fixture["expected"]["pathOverrides"] + ); + let read = call( + "runConfig.inspect", + serde_json::json!({"root":root,"localDocument":saved,"checkFingerprint":false}), + ); + assert_eq!(read["ok"], true, "{read}"); + assert_eq!( + read["data"]["toolchain"]["pathOverrides"], + fixture["expected"]["pathOverrides"] + ); + let changed_defaults = call( + "runConfig.updateOptions", + serde_json::json!({ + "root":root,"scope":"local","configurationId":"", "localDocument":saved, + "toolchain":{"javaHomePath":"/fixture/changed-project-jdk"} + }), + ); + assert_eq!(changed_defaults["ok"], true, "{changed_defaults}"); + let retained: Value = + serde_json::from_str(changed_defaults["data"]["document"].as_str().unwrap()).unwrap(); + assert_eq!( + retained["toolchain"]["pathOverrides"], + fixture["expected"]["pathOverrides"] + ); + let reset = call( + "runConfig.updateOptions", + serde_json::json!({ + "root":root,"scope":"local","configurationId":"", "localDocument":retained, + "toolchain":{"pathOverrides":{}} + }), + ); + assert_eq!(reset["ok"], true, "{reset}"); + let reset: Value = serde_json::from_str(reset["data"]["document"].as_str().unwrap()).unwrap(); + assert_eq!(reset["toolchain"]["pathOverrides"], serde_json::json!({})); + for invalid in fixture["invalidRoots"].as_array().unwrap() { + let mut toolchain = fixture["toolchain"].clone(); + toolchain["pathOverrides"] = + serde_json::json!({invalid.as_str().unwrap(): {"javaHomePath":"/fixture/jdk"}}); + let result = call( + "runConfig.updateOptions", + serde_json::json!({"root":root,"scope":"local","configurationId":"","toolchain":toolchain}), + ); + assert_eq!(result["ok"], false, "{invalid}: {result}"); + let result = call( + "runConfig.inspect", + serde_json::json!({"root":root,"localDocument":{ + "version":2,"configurations":[],"toolchain":{"pathOverrides":toolchain["pathOverrides"]} + },"checkFingerprint":false}), + ); + assert_eq!(result["ok"], false, "{invalid}: {result}"); + } + for value in [ + serde_json::json!(null), + serde_json::json!([]), + serde_json::json!({"a":{"javaHomePath":42}}), + serde_json::json!({"a":{"javaHomePath":"\n"}}), + serde_json::json!({"a":{"unknownField":"preserve-this-value"}}), + ] { + let result = call( + "runConfig.inspect", + serde_json::json!({"root":root,"localDocument":{ + "version":2,"configurations":[],"toolchain":{"pathOverrides":value} + },"checkFingerprint":false}), + ); + assert_eq!(result["ok"], false, "{result}"); + } + assert!(!root.join(".lithe/run/local.json").exists()); +} + #[test] fn saved_provider_tasks_do_not_suppress_detection_after_restart() { let root = temporary_root("saved-task-detection"); @@ -536,9 +988,9 @@ fn run_configuration_generation_uses_a_maven_project_below_the_workspace() { response["data"]["toolchainRequirements"]["toolchains"]["project-maven"]["wrapper"], "./mvnw" ); - assert_eq!( - response["data"]["toolchainRequirements"]["toolchains"]["project-maven"]["minimumVersion"], - "3.9.9" + assert!( + response["data"]["toolchainRequirements"]["toolchains"]["project-maven"]["minimumVersion"] + .is_null() ); assert!( response["data"]["toolchainRequirements"]["toolchains"]["project-maven"]["version"] @@ -1253,6 +1705,29 @@ fn maven_test_source_main_uses_the_test_classpath() { }]) ); + // A current host receipt avoids both a second resource process and a + // legacy POM scan. Windows clients without the receipt retain the old plan. + fs::write(root.join("pom.xml"), "").unwrap(); + let fixture: Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/run-configuration/provider-prepared-java.json" + )) + .unwrap(); + let prepared: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id":"provider-prepared-main", "command":"runConfig.createLaunchPlan", "payload":{ + "root":root,"configurationId":"java-main:com.example.MainTests", + "javaLaunch":fixture["javaLaunch"] + } + }) + .to_string(), + )) + .unwrap(); + assert_eq!(prepared["ok"], true, "{prepared}"); + assert!(prepared["data"].get("preLaunchSteps").is_none()); + assert_eq!( + prepared["data"]["arguments"], + fixture["expected"]["arguments"] + ); fs::remove_dir_all(root).unwrap(); } @@ -2230,9 +2705,9 @@ fn run_configuration_generation_detects_declared_toolchain_versions() { generated["data"]["toolchainRequirements"]["toolchains"]["project-jdk"]["preferredVendor"], "temurin" ); - assert_eq!( - generated["data"]["toolchainRequirements"]["toolchains"]["project-maven"]["minimumVersion"], - "3.9.9" + assert!( + generated["data"]["toolchainRequirements"]["toolchains"]["project-maven"]["minimumVersion"] + .is_null() ); assert!( generated["data"]["toolchainRequirements"]["toolchains"]["project-maven"]["version"] @@ -4702,8 +5177,32 @@ fn build_provider_configurations_persist_before_generation_and_reuse_identity() updated_doc["configurations"][0]["extensions"]["annotations"], serde_json::json!({"label":"keep user metadata"}) ); - for (field, value) in [ - ("root", serde_json::json!("../escape")), + for execution in fixture["executionKinds"].as_array().unwrap() { + let mut reference = fixture["reference"].clone(); + reference["execution"] = execution.clone(); + let saved_kind = save(reference); + assert_eq!(saved_kind["ok"], true, "{saved_kind}"); + assert_eq!(saved_kind["data"]["id"], saved["data"]["id"]); + let kind_document: Value = + serde_json::from_str(saved_kind["data"]["document"].as_str().unwrap()).unwrap(); + assert_eq!(kind_document["configurations"][0]["execution"], *execution); + fs::write(&local, kind_document.to_string()).unwrap(); + let kind_resolved = call("runConfig.resolve", serde_json::json!({"root":root})); + assert_eq!(kind_resolved["ok"], true, "{kind_resolved}"); + assert_eq!( + kind_resolved["data"]["configurations"][0]["execution"], + *execution + ); + let inputs = call( + "runConfig.providerLaunchInputs", + serde_json::json!({"root":root,"configurationId":saved_kind["data"]["id"],"providerRoot":"app"}), + ); + assert_eq!(inputs["ok"], true, "{inputs}"); + assert_eq!(inputs["data"]["launchOptions"]["execution"], *execution); + assert_eq!(inputs["data"]["reference"]["execution"], *execution); + } + for (field, value) in [ + ("root", serde_json::json!("../escape")), ("root", serde_json::json!("/outside")), ("root", serde_json::json!("app\\bad")), ("projectID", serde_json::json!("")), @@ -4735,3 +5234,888 @@ fn build_provider_configurations_persist_before_generation_and_reuse_identity() false ); } + +/// Persisted identity and selector identity serve different purposes: imported IDs +/// retain defaults/overrides, while the selector removes only the duplicate auto entry. +#[test] +fn explicit_provider_references_keep_arbitrary_ids_and_shadow_only_current_selector() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/run-configuration/build-provider-stable-identities.json" + )) + .unwrap(); + let root = temporary_root("provider-stable-identity"); + fs::create_dir_all(root.join("app")).unwrap(); + fs::create_dir_all(root.join(".lithe/run")).unwrap(); + struct Cleanup(PathBuf); + impl Drop for Cleanup { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + let _cleanup = Cleanup(root.clone()); + let call = |command: &str, payload: Value| -> Value { + let reply: Value = serde_json::from_str(&execute_json( + &serde_json::json!({"id":"stable","command":command,"payload":payload}).to_string(), + )) + .unwrap(); + assert_eq!(reply["ok"], true, "{reply}"); + reply["data"].clone() + }; + let stable = fixture["expected"]["stableID"].clone(); + let reference = fixture["reference"].clone(); + let team = serde_json::json!({"version":2,"configurations":[{"id":stable,"name":"Team service","provider":"build.provider","execution":"service","cwd":"app","extensions":{"build":reference}}]}); + let team_path = root.join(".lithe/run/configurations.json"); + let local_path = root.join(".lithe/run/local.json"); + fs::write(&team_path, team.to_string()).unwrap(); + let inspected = call( + "runConfig.inspect", + serde_json::json!({"root":root,"checkFingerprint":false}), + ); + assert_eq!(inspected["status"], "ready"); + assert_eq!(inspected["requiresGeneration"], true); + let before = call("runConfig.resolve", serde_json::json!({"root":root})); + assert_eq!(before["configurations"][0]["id"], stable); + assert!(before["diagnostics"] + .as_array() + .unwrap() + .iter() + .all(|d| d["code"] != "orphanedOverride")); + let mut second = reference.clone(); + second["targetID"] = serde_json::json!("another/opaque-target"); + let generated = call( + "runConfig.generate", + serde_json::json!({"root":root,"paths":[],"projectOwnership":[],"providerRuns":[ + {"projectPath":"app","name":"Auto A","reference":reference},{"projectPath":"app","name":"Auto B","reference":second} + ]}), + ); + assert_eq!( + generated["generated"]["configurations"] + .as_array() + .unwrap() + .len(), + 2 + ); + fs::write( + root.join(".lithe/project.json"), + serde_json::json!({"version":1,"defaultRunConfiguration":stable}).to_string(), + ) + .unwrap(); + fs::write( + &local_path, + serde_json::json!({"version":2,"configurations":[{"id":stable,"env":{"EXAMPLE":"value"}}]}) + .to_string(), + ) + .unwrap(); + let resolved = call("runConfig.resolve", serde_json::json!({"root":root})); + let configs = resolved["configurations"].as_array().unwrap(); + assert_eq!(configs.len(), 2); + assert_eq!(resolved["defaultRunConfiguration"], stable); + assert_eq!( + configs + .iter() + .filter(|c| c["extensions"]["build"]["targetID"] == reference["targetID"]) + .count(), + 1 + ); + assert!(resolved["diagnostics"] + .as_array() + .unwrap() + .iter() + .all(|d| d["code"] != "orphanedOverride")); + // A local selector patch replaces the effective claim; it must not hide + // the formerly claimed auto target too. + fs::write(&local_path,serde_json::json!({"version":2,"configurations":[{"id":stable,"extensions":{"build":{"targetID":second["targetID"]}}}]}).to_string()).unwrap(); + let replaced = call("runConfig.resolve", serde_json::json!({"root":root})); + let configs = replaced["configurations"].as_array().unwrap(); + assert_eq!(configs.len(), 2); + assert_eq!( + configs + .iter() + .filter(|c| c["extensions"]["build"]["targetID"] == second["targetID"]) + .count(), + 1 + ); + assert_eq!( + configs + .iter() + .filter(|c| c["extensions"]["build"]["targetID"] == reference["targetID"]) + .count(), + 1 + ); + let original_team = fs::read(&team_path).unwrap(); + let original_local = fs::read(&local_path).unwrap(); + let prepared = serde_json::json!({"version":2,"configurations":[{"id":"prepared:stable","name":"Prepared","provider":"build.provider","execution":"service","extensions":{"build":reference}}]}); + let preview = call( + "runConfig.resolve", + serde_json::json!({"root":root,"teamDocument":prepared,"localDocument":{"version":2,"configurations":[]}}), + ); + assert!(preview["configurations"] + .as_array() + .unwrap() + .iter() + .any(|c| c["id"] == "prepared:stable")); + assert_eq!(fs::read(&team_path).unwrap(), original_team); + assert_eq!(fs::read(&local_path).unwrap(), original_local); + let mut invalid = prepared.clone(); + invalid["configurations"][0]["extensions"]["build-run"] = serde_json::json!({"entrypoint":17}); + let rejected:Value=serde_json::from_str(&execute_json(&serde_json::json!({"id":"invalid","command":"runConfig.resolve","payload":{"root":root,"teamDocument":invalid,"localDocument":{"version":2,"configurations":[]}}}).to_string())).unwrap(); + assert_eq!(rejected["ok"], false); + assert_eq!(fs::read(&team_path).unwrap(), original_team); +} + +/// Portable choices survive document edits without becoming selector identity or cached launch facts. +#[test] +fn provider_run_options_preserve_exact_arguments_and_layered_build_choices() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/run-configuration/build-provider-options.json" + )) + .unwrap(); + let root = temporary_root("provider-run-options"); + fs::create_dir_all(root.join("app/work")).unwrap(); + fs::create_dir_all(root.join(".lithe/run")).unwrap(); + struct Cleanup(PathBuf); + impl Drop for Cleanup { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + let _cleanup = Cleanup(root.clone()); + let call = |command: &str, payload: Value| -> Value { + let reply: Value = serde_json::from_str(&execute_json( + &serde_json::json!({"id":"choices","command":command,"payload":payload}).to_string(), + )) + .unwrap(); + assert_eq!(reply["ok"], true, "{reply}"); + reply["data"].clone() + }; + let save = || { + call( + "runConfig.createUserConfiguration", + serde_json::json!({"root":root,"scope":"local","type":"buildProvider","name":"Service","build":fixture["reference"]}), + ) + }; + let saved = save(); + let id = saved["id"].clone(); + let local_path = root.join(".lithe/run/local.json"); + let team_path = root.join(".lithe/run/configurations.json"); + fs::write(&local_path, saved["document"].as_str().unwrap()).unwrap(); + let team = serde_json::json!({"version":2,"configurations":[{"id":id,"extensions":{"build-run":fixture["choices"]}}]}); + fs::write(&team_path, team.to_string()).unwrap(); + let read = || { + call( + "runConfig.providerLaunchInputs", + serde_json::json!({"root":root,"configurationId":id,"providerRoot":"app"}), + ) + }; + let initial = read(); + assert_eq!(initial["tools"], fixture["choices"]["tools"]); + assert!(initial["launchOptions"].get("tools").is_none()); + assert_eq!( + initial["buildSystemID"], + fixture["expected"]["buildSystemID"] + ); + assert_eq!( + initial["launchOptions"]["execution"], + fixture["expected"]["execution"] + ); + assert_eq!( + initial["launchOptions"]["runtimeArguments"], + fixture["choices"]["runtimeArguments"] + ); + assert_eq!( + initial["launchOptions"]["entrypoint"], + fixture["choices"]["entrypoint"] + ); + assert_eq!(initial["optionOverrides"], fixture["choices"]["options"]); + assert_eq!( + initial["launchOptions"]["arguments"], + fixture["reference"]["userArguments"] + ); + let update = |choices: Option| { + let mut payload = serde_json::json!({"root":root,"scope":"local","configurationId":id, + "arguments":"--application=\"one two\" 雪","environment":fixture["environment"],"workingDirectory":fixture["workingDirectory"]}); + if let Some(choices) = choices { + payload["providerRunOptions"] = choices; + } + call("runConfig.updateOptions", payload) + }; + let one_tool = update(Some( + serde_json::json!({"tools":{"dev.example.engine:compiler":fixture["changed"]["tools"]["dev.example.engine:compiler"]}}), + )); + fs::write(&local_path, one_tool["document"].as_str().unwrap()).unwrap(); + let inherited = read(); + assert_eq!( + inherited["tools"]["dev.example.engine:compiler"], + fixture["changed"]["tools"]["dev.example.engine:compiler"] + ); + assert_eq!( + inherited["tools"]["dev.example.engine:vm"], + fixture["choices"]["tools"]["dev.example.engine:vm"] + ); + let document = update(Some(fixture["changed"].clone())); + fs::write(&local_path, document["document"].as_str().unwrap()).unwrap(); + let changed = read(); + assert_eq!(changed["tools"], fixture["expected"]["tools"]); + let toolchain = call( + "runConfig.projectToolchain", + serde_json::json!({"root":root,"path":"app","configurationId":id}), + ); + assert_eq!(toolchain["definedTools"], changed["tools"]); + assert!( + !root.join(".lithe/local.json").exists(), + "Run choices must not create directory pins" + ); + assert_eq!( + changed["launchOptions"]["runtimeArguments"], + fixture["changed"]["runtimeArguments"] + ); + assert!( + changed["launchOptions"].get("entrypoint").is_none(), + "Explicit empty clears the team entrypoint" + ); + assert_eq!( + changed["optionOverrides"], + serde_json::json!({}), + "An explicit empty map clears team options" + ); + assert_eq!(changed["launchOptions"]["arguments"], fixture["arguments"]); + assert_eq!( + changed["launchOptions"]["environment"], + fixture["environment"] + ); + assert_eq!( + changed["launchOptions"]["workingDirectory"], + fixture["workingDirectory"] + ); + assert_eq!(changed["reference"], fixture["reference"]); + let omitted = update(None); + let omitted: Value = serde_json::from_str(omitted["document"].as_str().unwrap()).unwrap(); + assert_eq!( + omitted["configurations"][0]["extensions"]["build-run"], + fixture["changed"] + ); + let resaved = save(); + assert_eq!(resaved["id"], id); + let resaved: Value = serde_json::from_str(resaved["document"].as_str().unwrap()).unwrap(); + assert_eq!( + resaved["configurations"][0]["cwd"], + fixture["workingDirectory"] + ); + assert_eq!( + resaved["configurations"][0]["extensions"]["build-run"], + fixture["changed"] + ); + assert_eq!( + serde_json::from_slice::(&fs::read(&team_path).unwrap()).unwrap(), + team + ); + assert!(!root.join(".lithe/run/generated.json").exists()); +} + +/// Malformed portable choices fail before a provider request; unknown fields cannot smuggle launch facts. +#[test] +fn provider_run_options_reject_malformed_or_oversized_values() { + let root = temporary_root("provider-run-options-invalid"); + fs::create_dir_all(root.join(".lithe/run")).unwrap(); + struct Cleanup(PathBuf); + impl Drop for Cleanup { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + let _cleanup = Cleanup(root.clone()); + let reference = serde_json::json!({"pluginID":"dev.example","buildSystemID":"engine","root":".","run":"serve","projectID":"p","targetID":"t"}); + let local_path = root.join(".lithe/run/local.json"); + for choices in [ + serde_json::json!(null), + serde_json::json!({"runtimeArguments":null}), + serde_json::json!({"runtimeArguments":["bad\u{0}"]}), + serde_json::json!({"runtimeArguments":vec!["a";1025]}), + serde_json::json!({"runtimeArguments":["a".repeat(65537)]}), + serde_json::json!({"entrypoint":null}), + serde_json::json!({"entrypoint":"a".repeat(4097)}), + serde_json::json!({"options":null}), + serde_json::json!({"options":{"bad key":true}}), + serde_json::json!({"options":{"opaque":"a".repeat(65536)}}), + serde_json::json!({"options":(0..65).map(|n|(format!("key-{n}"),serde_json::json!(false))).collect::>()}), + serde_json::json!({"tools":null}), + serde_json::json!({"tools":{"bad key":{"home":"/kit"}}}), + serde_json::json!({"tools":{"dev.example:compiler":{"home":"relative/kit"}}}), + serde_json::json!({"tools":{"dev.example:compiler":{"executable":"/a/../b"}}}), + serde_json::json!({"tools":{"dev.example:compiler":{"home":"/kit","executable":"/bin/compiler"}}}), + serde_json::json!({"tools":{"dev.example:compiler":{"home":"/kit","version":"21"}}}), + serde_json::json!({"tools":(0..65).map(|n|(format!("dev.example:tool-{n}"),serde_json::json!(null))).collect::>()}), + serde_json::json!({"command":"cached"}), + serde_json::json!({"debug":{"adapter":"cached"}}), + ] { + fs::write(&local_path, serde_json::json!({"version":2,"configurations":[{"id":"user:service","name":"Service","provider":"build.provider","execution":"application","cwd":".","extensions":{"build":reference,"build-run":choices}}]}).to_string()).unwrap(); + let reply: Value = serde_json::from_str(&execute_json(&serde_json::json!({"id":"invalid","command":"runConfig.resolve","payload":{"root":root}}).to_string())).unwrap(); + assert_eq!(reply["ok"], false, "Invalid choice accepted"); + } + // The mutation port must reject explicit null as well as persisted null, + // rather than treating malformed input as an omitted legacy field. + fs::write(&local_path, serde_json::json!({"version":2,"configurations":[{"id":"user:service","name":"Service","provider":"build.provider","execution":"application","cwd":".","extensions":{"build":reference}}]}).to_string()).unwrap(); + for options in [ + serde_json::json!(null), + serde_json::json!({"runtimeArguments":null}), + serde_json::json!({"entrypoint":"bad\u{0}"}), + ] { + let reply: Value = serde_json::from_str(&execute_json(&serde_json::json!({"id":"invalid-update","command":"runConfig.updateOptions","payload":{"root":root,"scope":"local","configurationId":"user:service","providerRunOptions":options}}).to_string())).unwrap(); + assert_eq!(reply["ok"], false, "Invalid mutation accepted"); + } +} + +/// A non-Java provider owns discovery; regenerating labels must retain user choices, +/// while withdrawing a target must not resurrect it from the previous detected layer. +#[test] +fn generic_provider_runs_regenerate_without_language_facts_and_keep_stable_overrides() { + let root = temporary_root("generic-provider-generation"); + fs::create_dir_all(root.join("app")).unwrap(); + fs::create_dir_all(root.join(".lithe/run")).unwrap(); + struct Cleanup(PathBuf); + impl Drop for Cleanup { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + let _cleanup = Cleanup(root.clone()); + let call = |command: &str, payload: Value| -> Value { + let result: Value = serde_json::from_str(&execute_json( + &serde_json::json!({"id":"generic","command":command,"payload":payload}).to_string(), + )) + .unwrap(); + assert_eq!(result["ok"], true, "{result}"); + result["data"].clone() + }; + let generate = |runs: Value| { + call( + "runConfig.generate", + serde_json::json!({ + "root":root,"paths":[],"projectOwnership":[],"providerRuns":runs + }), + ) + }; + let reference = serde_json::json!({"pluginID":"dev.example.engine","buildSystemID":"engine","root":"app", + "run":"serve","projectID":"project/opaque:1","targetID":"target/opaque:2","execution":"service","userArguments":[]}); + let mut runs = + serde_json::json!([{"projectPath":"app","name":"Initial label","reference":reference}]); + let first = generate(runs.clone()); + let configuration = &first["generated"]["configurations"][0]; + let id = configuration["id"].clone(); + assert_eq!(configuration["provider"], "build.provider"); + assert!(configuration["toolchains"].as_object().unwrap().is_empty()); + assert!(configuration["extensions"].get("maven").is_none()); + fs::write( + root.join(".lithe/run/local.json"), + serde_json::json!({"version":2,"configurations":[ + {"id":id,"env":{"EXAMPLE":"preserved"},"args":["user choice"],"cwd":"app"} + ]}) + .to_string(), + ) + .unwrap(); + let resolved = call("runConfig.resolve", serde_json::json!({"root":root})); + assert_eq!(resolved["defaultRunConfiguration"], id); + runs[0]["name"] = serde_json::json!("Renamed upstream label"); + runs[0]["reference"]["execution"] = serde_json::json!("task"); + let renamed = generate(runs.clone()); + assert_eq!(renamed["generated"]["configurations"][0]["id"], id); + assert_ne!( + renamed["generated"]["generator"]["providerRunFingerprint"], + first["generated"]["generator"]["providerRunFingerprint"] + ); + let inputs = call( + "runConfig.providerLaunchInputs", + serde_json::json!({"root":root,"configurationId":id,"providerRoot":"app"}), + ); + assert_eq!(inputs["launchOptions"]["execution"], "task"); + assert_eq!(inputs["launchOptions"]["workingDirectory"], "app"); + assert_eq!( + inputs["launchOptions"]["arguments"], + serde_json::json!(["user choice"]) + ); + assert_eq!( + inputs["launchOptions"]["environment"], + serde_json::json!({"EXAMPLE":"preserved"}) + ); + let withdrawn = generate(serde_json::json!([])); + assert!(!withdrawn["generated"]["configurations"] + .as_array() + .unwrap() + .iter() + .any(|item| item["id"] == id)); + let resolved = call("runConfig.resolve", serde_json::json!({"root":root})); + assert!(resolved["diagnostics"] + .as_array() + .unwrap() + .iter() + .any(|item| item["code"] == "orphanedOverride" && item["id"] == id)); + assert!(!resolved["configurations"] + .as_array() + .unwrap() + .iter() + .any(|item| item["id"] == id)); + let restored = generate(runs); + assert_eq!(restored["generated"]["configurations"][0]["id"], id); + let inputs = call( + "runConfig.providerLaunchInputs", + serde_json::json!({"root":root,"configurationId":id,"providerRoot":"app"}), + ); + assert_eq!( + inputs["launchOptions"]["arguments"], + serde_json::json!(["user choice"]) + ); + assert!(!root.join(".lithe/run/generated.json").exists()); +} + +#[test] +fn provider_project_ownership_controls_java_modules_and_source_roles_without_parsing_poms() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/run-configuration/provider-project-ownership.json" + )) + .unwrap(); + let root = temporary_root("provider-run-ownership"); + struct Cleanup(PathBuf); + impl Drop for Cleanup { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + let _cleanup = Cleanup(root.clone()); + for (path, contents) in fixture["files"].as_object().unwrap() { + let path = root.join(path); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, contents.as_str().unwrap()).unwrap(); + } + let generate_with_runs = |ownership: Value, runs: Value| -> Value { + serde_json::from_str(&execute_json(&serde_json::json!({ + "id":"generate", "command":"runConfig.generate", "payload": { + "root":root,"paths":fixture["files"].as_object().unwrap().keys().collect::>(), + "modulePaths":["incorrect-legacy-module"], "javaEntrypoints":fixture["javaEntrypoints"], + "projectOwnership":ownership, "providerRuns":runs + } + }).to_string())).unwrap() + }; + let generate = + |ownership: Value| generate_with_runs(ownership, fixture["providerRuns"].clone()); + let output = generate(fixture["projectOwnership"].clone()); + assert_eq!(output["ok"], true, "{output}"); + for expected in fixture["expected"]["configurations"].as_array().unwrap() { + let actual = output["data"]["generated"]["configurations"] + .as_array() + .unwrap() + .iter() + .find(|c| c["id"] == expected["id"]) + .unwrap(); + assert_eq!(actual["cwd"], expected["cwd"]); + assert_eq!(actual["extensions"]["java"]["module"], expected["module"]); + assert!(actual["extensions"]["maven"].is_null()); + assert_eq!( + actual["extensions"]["java"]["build"]["pluginID"], + "dev.example.engine" + ); + assert_eq!( + actual["extensions"]["java"]["sourceSet"], + expected["sourceSet"] + ); + } + for expected in fixture["expected"]["services"].as_array().unwrap() { + let actual = output["data"]["generated"]["configurations"] + .as_array() + .unwrap() + .iter() + .find(|c| c["id"] == expected["id"]) + .unwrap(); + assert_eq!(actual["cwd"], expected["cwd"]); + assert_eq!(actual["execution"], "service"); + assert_eq!(actual["provider"], "build.provider"); + assert_eq!(actual["extensions"]["build"], expected["reference"]); + assert!(actual["extensions"]["maven"].is_null()); + assert_eq!(actual["toolchains"], serde_json::json!({})); + } + assert_eq!( + output["data"]["toolchainRequirements"]["toolchains"]["project-jdk"]["minimumVersion"], + fixture["expected"]["minimumJavaVersion"] + ); + // A provider's language floor is authoritative even before JDT finds sources. + let empty_sources: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id":"provider-floor","command":"runConfig.generate","payload":{ + "root":root,"paths":[],"javaEntrypoints":{"schemaVersion":1,"entries":[]}, + "projectOwnership":fixture["projectOwnership"] + } + }) + .to_string(), + )) + .unwrap(); + assert_eq!(empty_sources["ok"], true, "{empty_sources}"); + assert_eq!( + empty_sources["data"]["toolchainRequirements"]["toolchains"]["project-jdk"] + ["minimumVersion"], + fixture["expected"]["minimumJavaVersion"] + ); + // Build tool versions and wrappers belong to installed definitions/providers, + // even when exactly one imported reactor exists. + assert!(output["data"]["toolchainRequirements"]["toolchains"]["project-maven"].is_null()); + let single = generate( + serde_json::json!([{"path":"alpha/app", "build":{"pluginID":"dev.example.engine","buildSystemID":"engine","root":"alpha","projectID":"app"}}]), + ); + assert!(single["data"]["toolchainRequirements"]["toolchains"]["project-maven"].is_null()); + // Same display names do not define identity; selectors belong to their provider. + assert_eq!( + output["data"]["generated"]["configurations"] + .as_array() + .unwrap() + .iter() + .filter(|c| c["provider"] == "build.provider") + .count(), + 2 + ); + fs::create_dir_all(root.join(".lithe/run")).unwrap(); + seed_generated(&root, output["data"]["generated"].to_string()); + let inspect_with_runs = |projects: Option, runs: Option| -> Value { + let mut payload = serde_json::json!({"root":root,"checkFingerprint":false}); + if let Some(projects) = projects { + payload["projectOwnership"] = projects; + } + if let Some(runs) = runs { + payload["providerRuns"] = runs; + } + let response: Value = serde_json::from_str(&execute_json(&serde_json::json!({"id":"inspect-provider", "command":"runConfig.inspect", "payload":payload}).to_string())).unwrap(); + assert_eq!(response["ok"], true, "{response}"); + response["data"]["diagnostics"].clone() + }; + let inspect = |projects: Option| { + let runs = projects.as_ref().map(|_| fixture["providerRuns"].clone()); + inspect_with_runs(projects, runs) + }; + let stale = |diagnostics: Value| { + diagnostics + .as_array() + .unwrap() + .iter() + .any(|item| item["code"] == "staleProviderModel") + }; + let original = fixture["projectOwnership"].clone(); + assert!(!stale(inspect(Some(original.clone())))); + let mut reordered = original.as_array().unwrap().clone(); + reordered.reverse(); + for project in &mut reordered { + project["contentRoots"].as_array_mut().unwrap().reverse(); + } + assert!(!stale(inspect(Some(serde_json::json!(reordered))))); + for (field, value) in [ + ("name", serde_json::json!("renamed")), + ("minimumJavaVersion", serde_json::json!("23")), + ( + "build", + serde_json::json!({"pluginID":"dev.example.changed","buildSystemID":"engine","root":"alpha","projectID":"alpha/app"}), + ), + ("contentRoots", serde_json::json!([])), + ] { + let mut changed = original.clone(); + changed[1][field] = value; + assert!(stale(inspect(Some(changed))), "{field}"); + } + let mut runs_reordered = fixture["providerRuns"].as_array().unwrap().clone(); + runs_reordered.reverse(); + let reordered_generation = + generate_with_runs(original.clone(), serde_json::json!(runs_reordered)); + assert_eq!( + reordered_generation["data"]["generated"]["configurations"], + output["data"]["generated"]["configurations"] + ); + assert!(!stale(inspect_with_runs( + Some(original.clone()), + Some(serde_json::json!(runs_reordered)) + ))); + let mut target_changed = fixture["providerRuns"].clone(); + target_changed[0]["reference"]["targetID"] = serde_json::json!("new/opaque-target"); + assert!(stale(inspect_with_runs( + Some(original.clone()), + Some(target_changed) + ))); + assert!(stale(inspect_with_runs( + Some(original.clone()), + Some(serde_json::json!([])) + ))); + let mut moved = original.clone(); + moved[1]["build"]["root"] = serde_json::json!("."); + assert!(stale(inspect(Some(moved)))); + assert!(stale(inspect(Some(serde_json::json!([]))))); + assert!( + !stale(inspect(Some(original.clone()))), + "Returning from Profile B to A must match the persisted A facts" + ); + let mut legacy = output["data"]["generated"].clone(); + legacy["generator"] + .as_object_mut() + .unwrap() + .remove("providerFingerprint"); + seed_generated(&root, legacy.to_string()); + assert!(stale(inspect(Some(original.clone())))); + assert!( + !stale(inspect(None)), + "Legacy consumers that omit provider facts remain compatible" + ); + // Generic document overrides stay separate from the opaque selector and + // need no Maven namespace, tool binding or framework parser. + seed_generated(&root, output["data"]["generated"].to_string()); + let service = &fixture["expected"]["services"][0]; + let local = root.join(".lithe/run/local.json"); + let java_id = &fixture["expected"]["configurations"][0]["id"]; + fs::write(&local, serde_json::json!({"version":2,"configurations":[{ + "id":java_id,"cwd":"alpha/app","extensions":{"java":{"build":{ + "pluginID":"dev.unrelated.engine","buildSystemID":"unrelated","root":"beta","projectID":"forged" + }}} + }]}).to_string()).unwrap(); + let resolved: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id":"resolve-owner","command":"runConfig.resolve","payload":{"root":root} + }) + .to_string(), + )) + .unwrap(); + assert_eq!(resolved["ok"], true, "{resolved}"); + let entry = resolved["data"]["configurations"] + .as_array() + .unwrap() + .iter() + .find(|value| value["id"] == *java_id) + .unwrap(); + assert_eq!( + entry["extensions"]["java"]["build"], + fixture["projectOwnership"][1]["build"] + ); + assert_eq!( + entry["cwd"], "alpha/app", + "A cwd override cannot replace the current build owner" + ); + // Replacing the complete namespace must not bypass current-model routing. + for namespace in [ + Value::Null, + serde_json::json!("invalid-owner"), + serde_json::json!({}), + ] { + fs::write( + &local, + serde_json::json!({"version":2,"configurations":[{ + "id":java_id,"extensions":{"java":namespace} + }]}) + .to_string(), + ) + .unwrap(); + let resolved: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id":"resolve-null-owner","command":"runConfig.resolve","payload":{"root":root} + }) + .to_string(), + )) + .unwrap(); + assert_eq!(resolved["ok"], true, "{resolved}"); + let entry = resolved["data"]["configurations"] + .as_array() + .unwrap() + .iter() + .find(|value| value["id"] == *java_id) + .unwrap(); + assert_eq!( + entry["extensions"]["java"]["build"], + fixture["projectOwnership"][1]["build"] + ); + } + fs::remove_file(&local).unwrap(); + fs::write( + &local, + serde_json::json!({"version":2,"configurations":[{ + "id":service["id"],"args":["serve","雪"],"env":{"EXAMPLE":"value"},"cwd":"alpha/app" + }]}) + .to_string(), + ) + .unwrap(); + let choices: Value = serde_json::from_str(&execute_json( + &serde_json::json!({"id":"choices","command":"runConfig.providerLaunchInputs", + "payload":{"root":root,"configurationId":service["id"],"providerRoot":"alpha"}}) + .to_string(), + )) + .unwrap(); + assert_eq!(choices["ok"], true, "{choices}"); + assert_eq!(choices["data"]["reference"], service["reference"]); + assert_eq!( + choices["data"]["launchOptions"]["arguments"], + serde_json::json!(["serve", "雪"]) + ); + assert_eq!( + choices["data"]["launchOptions"]["environment"], + serde_json::json!({"EXAMPLE":"value"}) + ); + assert_eq!( + choices["data"]["launchOptions"]["workingDirectory"], + "alpha/app" + ); + fs::remove_file(local).unwrap(); + let first_cmd = output["data"]["generated"]["generator"]["inputs"]["alpha/mvnw.cmd"].clone(); + assert!(first_cmd + .as_str() + .is_some_and(|value| value.starts_with("sha256:"))); + fs::write(root.join("alpha/mvnw.cmd"), "exit /b 98").unwrap(); + let changed = generate(fixture["projectOwnership"].clone()); + assert_ne!( + changed["data"]["generated"]["generator"]["inputs"]["alpha/mvnw.cmd"], + first_cmd + ); + assert_ne!( + changed["data"]["generated"]["generator"]["fingerprint"], + output["data"]["generated"]["generator"]["fingerprint"] + ); + let empty = generate_with_runs(serde_json::json!([]), serde_json::json!([])); + assert_eq!(empty["ok"], true, "{empty}"); + for configuration in empty["data"]["generated"]["configurations"] + .as_array() + .unwrap() + { + assert!( + configuration["toolchains"]["maven"].is_null(), + "An empty Provider snapshot must not revive POM-based Java ownership: {configuration}" + ); + } + let mut duplicate_reference = fixture["providerRuns"].as_array().unwrap().clone(); + duplicate_reference.push(duplicate_reference[0].clone()); + assert_eq!( + generate_with_runs(original.clone(), serde_json::json!(duplicate_reference))["ok"], + false + ); + for (path, value) in [ + ("root", serde_json::json!("outside")), + ("projectID", serde_json::json!("")), + ("execution", serde_json::json!("group")), + ("userArguments", serde_json::json!(["not-a-model-fact"])), + ] { + let mut invalid = fixture["providerRuns"].clone(); + invalid[0]["reference"][path] = value; + assert_eq!( + generate_with_runs(original.clone(), invalid)["ok"], + false, + "{path}" + ); + } + for invalid in [ + serde_json::json!([{"path":"app","minimumJavaVersion":"1.8"}]), + serde_json::json!([{"path":"app","minimumJavaVersion":"0"}]), + serde_json::json!([{"path":"app","mavenMinimumVersion":"3.*"}]), + serde_json::json!([{"path":"app","mavenReactorPath":"app","mavenWrapper":"other/mvnw"}]), + serde_json::json!([{"path":"app","mavenRunProvider":"quarkus.maven"}]), + serde_json::json!([{"path":"app","build":{"pluginID":"../escape","buildSystemID":"example","root":".","projectID":"app"}}]), + serde_json::json!([{"path":"app","build":{"pluginID":"dev.example","buildSystemID":"example","root":".","projectID":""}}]), + serde_json::json!([{"path":"app","build":{"pluginID":"dev.example","buildSystemID":"example","root":".","projectID":"app","framework":"invented"}}]), + serde_json::json!([{"path":"../escape"}]), + serde_json::json!([{"path":"app","build":{"pluginID":"dev.example.engine","buildSystemID":"engine","root":"other","projectID":"app"}}]), + serde_json::json!([{"path":"app"},{"path":"app"}]), + serde_json::json!([{"path":"app","contentRoots":[{"path":"/outside","kind":"source"}]}]), + serde_json::json!([{"path":"app","contentRoots":[{"path":"app/src","kind":"guessed"}]}]), + serde_json::json!((0..2001) + .map(|index| serde_json::json!({"path":format!("project-{index}")})) + .collect::>()), + ] { + assert_eq!(generate(invalid)["ok"], false); + } +} + +/// Providers receive saved typed choices; Core does not assemble a Maven goal or reread its POM. +#[test] +fn provider_launch_inputs_preserve_document_layers_and_current_selector() { + let root = temporary_root("provider-launch-inputs"); + fs::create_dir_all(root.join(".lithe/run")).unwrap(); + fs::create_dir_all(root.join("nested/app")).unwrap(); + fs::create_dir_all(root.join("tools")).unwrap(); + struct Cleanup(PathBuf); + impl Drop for Cleanup { + fn drop(&mut self) { + fs::remove_dir_all(&self.0).unwrap(); + } + } + let _cleanup = Cleanup(root.clone()); + fs::write(root.join("nested/pom.xml"), "deliberately not a POM").unwrap(); + let generated = serde_json::json!({"version":2,"configurations":[{"id":"service","name":"Service","provider":"build.provider","execution":"service", + "cwd":"nested","env":{},"extensions":{"build":{"pluginID":"dev.example.engine","buildSystemID":"engine","root":"nested","run":"serve", + "projectID":"project","targetID":"target","execution":"service"},"build-run":{"runtimeArguments":["--message=base value"]}}}]}); + seed_generated(&root, generated.to_string()); + let local = serde_json::json!({"version":2,"configurations":[{"id":"service","cwd":"tools","env":{"EXAMPLE":"value"}, + "args":["a b","雪",""],"extensions":{"build-run":{"runtimeArguments":["--message=hello world","C:\\literal"],"entrypoint":"opaque/entry:1","options":{"features":["run-feature"],"strict":false}}}}]}); + let call = |provider_root: &str, local: Option| -> Value { + let request = serde_json::json!({"id":"inputs","command":"runConfig.providerLaunchInputs","payload":{ + "root":root,"configurationId":"service","providerRoot":provider_root,"localDocument":local}}); + serde_json::from_str(&execute_json(&request.to_string())).unwrap() + }; + let inspect = || -> Value { + serde_json::from_str(&execute_json( + &serde_json::json!({"id":"inspect-launch-inputs","command":"runConfig.inspect", + "payload":{"root":root,"checkFingerprint":false}}) + .to_string(), + )) + .unwrap() + }; + let baseline = inspect()["data"]["generated"].clone(); + let original = call("nested", None); + assert_eq!(original["ok"], true, "{original}"); + assert_eq!(original["data"]["projectPath"], "nested"); + assert_eq!( + original["data"]["launchOptions"]["runtimeArguments"], + serde_json::json!(["--message=base value"]) + ); + assert!(original["data"]["launchOptions"] + .get("workingDirectory") + .is_none()); + let resolved = call("nested", Some(local.clone())); + assert_eq!(resolved["ok"], true, "{resolved}"); + assert_eq!(resolved["data"]["provider"], "serve"); + assert_eq!( + resolved["data"]["launchOptions"], + serde_json::json!({"execution":"service","runtimeArguments":["--message=hello world","C:\\literal"], + "arguments":["a b","雪",""],"environment":{"EXAMPLE":"value"},"entrypoint":"opaque/entry:1","workingDirectory":"tools"}) + ); + assert_eq!( + resolved["data"]["optionOverrides"], + serde_json::json!({"features":["run-feature"],"strict":false}) + ); + assert!(resolved["data"].get("executable").is_none()); + assert_eq!(call(".", Some(local.clone()))["ok"], false); + assert_eq!(call("../outside", Some(local.clone()))["ok"], false); + fs::remove_dir_all(root.join("tools")).unwrap(); + assert_eq!(call("nested", Some(local))["ok"], false); + assert_eq!(inspect()["data"]["generated"], baseline); + assert!(!root.join(".lithe/run/generated.json").exists()); +} + +/// Historical discriminators are data for a temporary host import, never a +/// capability to make Core interpret profiles, frameworks or module launches. +#[test] +fn provider_launch_inputs_require_a_typed_reference_after_domain_migration() { + let root = temporary_root("unmigrated-provider-inputs"); + fs::create_dir_all(&root).unwrap(); + struct Cleanup(PathBuf); + impl Drop for Cleanup { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + let _cleanup = Cleanup(root.clone()); + for provider in [ + "maven.module", + "spring-boot.maven", + "quarkus.maven", + "micronaut.maven", + ] { + let generated = serde_json::json!({"version":2,"configurations":[{"id":"legacy","name":"Legacy", + "provider":provider,"execution":"task","extensions":{"maven":{"module":".","profiles":["saved"],"skipTests":false}}}]}); + seed_generated(&root, generated.to_string()); + let reply: Value = serde_json::from_str(&execute_json( + &serde_json::json!({"id":"unmigrated","command":"runConfig.providerLaunchInputs", + "payload":{"root":root,"configurationId":"legacy","providerRoot":"."}}) + .to_string(), + )) + .unwrap(); + assert_eq!(reply["ok"], false, "{reply}"); + assert_eq!(reply["error"]["code"], "invalid_request"); + } + assert!(!root.join(".lithe/run/local.json").exists()); +} diff --git a/rust/lithe-core/src/tests/toolchain_definitions.rs b/rust/lithe-core/src/tests/toolchain_definitions.rs index f305bda9fb..e0a7b1bae6 100644 --- a/rust/lithe-core/src/tests/toolchain_definitions.rs +++ b/rust/lithe-core/src/tests/toolchain_definitions.rs @@ -218,6 +218,26 @@ fn a_definition_plugin_may_provide_installations_of_its_own_tool() { assert_eq!(result["providers"][0]["pluginID"], id); } +#[test] +fn build_toolchain_consumers_keep_resolution_two_without_owning_definitions() { + let mut plugin: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/contracts/plugins/v1/fixtures/build-toolchains.plugin.json" + ))) + .unwrap(); + assert!(validate_manifest_value(plugin.clone()).is_ok()); + plugin["buildSystems"][0]["invocation"]["executables"][0]["toolchainID"] = + json!("dev.lithe.fixture.compiler:compiler"); + assert!(validate_manifest_value(plugin.clone()).is_ok()); + plugin["buildSystems"][0]["invocation"]["executables"][0]["toolchainID"] = + json!("dev.lithe.fixture.compiler:../compiler"); + assert!(validate_manifest_value(plugin.clone()).is_err()); + plugin["buildSystems"][0]["invocation"]["executables"][0]["toolchainID"] = + json!("dev.lithe.fixture.compiler:compiler"); + plugin["requires"]["executableResolution"] = json!(1); + assert!(validate_manifest_value(plugin).is_err()); +} + #[test] fn executable_resolution_two_does_not_enable_toolchain_fields_in_legacy_build_sets() { let mut plugin: Value = serde_json::from_str(include_str!(concat!( diff --git a/rust/lithe-core/src/tests/toolchain_queries.rs b/rust/lithe-core/src/tests/toolchain_queries.rs index f479213a81..1dd2815b77 100644 --- a/rust/lithe-core/src/tests/toolchain_queries.rs +++ b/rust/lithe-core/src/tests/toolchain_queries.rs @@ -56,6 +56,38 @@ fn toolchain_queries_decode_installed_contract_reply_without_native_io() { assert_eq!(outcome["diagnostics"], json!([])); } +/// Core transport uses string JSON-RPC IDs; matching includes both type and value. +#[test] +fn toolchain_queries_preserve_bounded_string_and_numeric_wire_identities() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/toolchains/rpc-id-cases.json" + )) + .unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let mut value = input(); + value["request"]["id"] = case["id"].clone(); + value["response"]["id"] = case["id"].clone(); + assert_eq!( + query::decode(&value).is_ok(), + case["accepted"].as_bool().unwrap(), + "{case}" + ); + } + for (request, response) in [ + (json!(1), json!("1")), + (json!("1"), json!(1)), + (json!("one"), json!("two")), + ] { + let mut value = input(); + value["request"]["id"] = request; + value["response"]["id"] = response; + assert_eq!( + query::decode(&value).unwrap_err().code, + "plugin.toolchain.invalidReply" + ); + } +} + #[test] fn toolchain_queries_uninstalled_request_is_structured_unmet_data() { let mut value = input(); diff --git a/rust/lithe-core/src/tests/toolchain_resolution.rs b/rust/lithe-core/src/tests/toolchain_resolution.rs index 002445a0e8..7c9aa7a07a 100644 --- a/rust/lithe-core/src/tests/toolchain_resolution.rs +++ b/rust/lithe-core/src/tests/toolchain_resolution.rs @@ -76,6 +76,139 @@ fn query(input: &Value) -> Value { "currentGeneration":1,"platform":"macos","filesystem":input["filesystem"]}) } +#[test] +fn consumer_fixed_installation_precedes_directory_pins_without_granting_a_fallback() { + let mut value = input(); + installation(&mut value, HOME, "21.0.4"); + installation(&mut value, OLD, "17.0.12"); + value["local"] = json!({"version":1,"toolchains":{},"pins":{".":{"java":{"home":HOME,"version":"21","provider":"host:manual"}}}}); + value["fixed"] = json!({"home":OLD}); + // Broken lower-layer answers have no authority over this explicit choice. + value["queries"] = json!([{"invalid":"lower layer"}]); + let selected = resolve(&value); + assert_eq!(selected["outcome"]["selection"]["home"], OLD); + assert_eq!(selected["trace"][0]["candidateID"], "consumerFixed"); + value["fixed"] = json!({"home":"/opt/lithe-fixtures/missing"}); + assert_eq!(resolve(&value)["outcome"]["kind"], "terminalFailure"); +} + +#[test] +fn consumer_fixed_executable_preserves_its_argv_identity_and_has_no_invented_home() { + let mut value = input(); + let executable = "/opt/lithe-fixtures/custom tools/launcher"; + value["fixed"] = json!({"executable":executable}); + value["filesystem"] + .as_array_mut() + .unwrap() + .push(fact(executable, "file", true)); + let candidates = candidates_command(value_for_candidates(&value)).unwrap(); + assert_eq!(candidates["status"], "ok", "{candidates}"); + assert!(candidates["candidates"]["skipped"] + .as_array() + .unwrap() + .iter() + .any(|value| value["reason"] == "probeFailed" && value["executable"] == executable)); + value["probes"] = + json!([{"executable":executable,"output":"openjdk version \"21.0.4\"","exitCode":0}]); + let result = resolve(&value); + let selected = &result["outcome"]["selection"]; + assert_eq!(selected["executable"], executable); + assert_eq!(selected["version"], "21.0.4"); + assert!(selected["home"].is_null()); + assert_eq!( + selected["environment"], + json!({"LITHE_JAVA_PATH":executable}) + ); + assert_eq!(selected["pathPrepend"], json!([])); + value["minimumVersion"] = json!("23"); + assert_eq!( + resolve(&value)["outcome"]["reason"]["code"], + "versionUnsatisfied" + ); +} + +fn value_for_candidates(value: &Value) -> Value { + let mut result = json!({}); + for key in [ + "registry", + "tool", + "platform", + "filesystemCaseSensitive", + "workspaceRoot", + "workspaceRoots", + "filesystem", + "minimumVersion", + "probes", + "systemInstallations", + "environment", + "fixed", + "projectCodeAllowed", + ] { + if let Some(value) = value.get(key) { + result[key] = value.clone(); + } + } + result +} + +#[test] +fn consumer_fixed_project_files_require_the_current_action_before_any_version_probe() { + for alias in [false, true] { + let mut value = input(); + let inside = format!("{WORKSPACE}/scripts/launcher"); + let executable = if alias { + "/opt/lithe-fixtures/alias" + } else { + &inside + }; + value["fixed"] = json!({"executable":executable}); + value["filesystem"].as_array_mut().unwrap().push( + json!({"path":executable,"canonicalPath":inside,"kind":"file","executable":true}), + ); + let candidates = candidates_command(value_for_candidates(&value)).unwrap(); + assert_eq!(candidates["status"], "ok", "{candidates}"); + assert!(!candidates["candidates"]["skipped"] + .as_array() + .unwrap() + .iter() + .any(|row| row["executable"] == executable)); + assert_eq!( + resolve(&value)["outcome"]["reason"]["code"], + "projectCodeRequiresAction" + ); + value["projectCodeAllowed"] = json!(true); + value["probes"] = + json!([{"executable":executable,"output":"openjdk version \"21.0.4\"","exitCode":0}]); + assert_eq!(resolve(&value)["outcome"]["selection"]["projectCode"], true); + } +} + +#[test] +fn consumer_fixed_choices_reject_ambiguous_relative_and_authority_smuggling_fields() { + for fixed in [ + Value::Null, + json!({}), + json!({"home":HOME,"executable":"/opt/tool"}), + json!({"executable":"relative/tool"}), + json!({"home":"/opt/../tool"}), + json!({"executable":"/opt/tool","version":"21"}), + json!({"home":HOME,"provider":"host:official"}), + ] { + let mut value = input(); + value["fixed"] = fixed.clone(); + assert_eq!( + resolve_command(value.clone()).unwrap()["status"], + "rejected", + "{fixed}" + ); + assert_eq!( + candidates_command(value_for_candidates(&value)).unwrap()["status"], + "rejected", + "{fixed}" + ); + } +} + #[test] fn toolchain_resolution_pins_inherit_nearest_ancestor_without_sibling_prefixes() { let mut value = input(); diff --git a/rust/lithe-core/src/toolchain/fixed.rs b/rust/lithe-core/src/toolchain/fixed.rs new file mode 100644 index 0000000000..9b9051d092 --- /dev/null +++ b/rust/lithe-core/src/toolchain/fixed.rs @@ -0,0 +1,45 @@ +//! Inert consumer-owned choices; native hosts must attest paths and version probes. + +use super::pins; +use crate::plugins::declarative::{PluginRejection, Validation}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// One explicit installation or executable for a single consumer operation. +/// It never supplies a version, publisher identity, environment or launch permit. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged, deny_unknown_fields)] +pub enum FixedToolchainSelection { + /// An installation whose executable and exports follow the active definition. + Installation { + /// Absolute native installation directory, checked by the host at use time. + home: String, + }, + /// A user-selected launcher that need not follow an installation layout. + Executable { + /// Absolute native program path; home-based exports are unavailable. + executable: String, + }, +} + +impl FixedToolchainSelection { + /// Validates a saved path lexically, without looking up its definition or files. + pub(crate) fn parse(value: &Value) -> Validation { + let invalid = || { + PluginRejection::at( + "plugin.toolchain.invalidParams", + "fixed", + "Invalid explicit tool selection.", + ) + }; + let choice: Self = serde_json::from_value(value.clone()).map_err(|_| invalid())?; + let path = match &choice { + Self::Installation { home } => home, + Self::Executable { executable } => executable, + }; + if path.len() > 4096 || !pins::valid_native_path(path) { + return Err(invalid()); + } + Ok(choice) + } +} diff --git a/rust/lithe-core/src/toolchain/mod.rs b/rust/lithe-core/src/toolchain/mod.rs index f07fae1b5a..1526980e87 100644 --- a/rust/lithe-core/src/toolchain/mod.rs +++ b/rust/lithe-core/src/toolchain/mod.rs @@ -3,8 +3,10 @@ //! No manager configuration, native scanning or process lifecycle belongs here. pub(crate) mod definitions; +pub(crate) mod fixed; pub(crate) mod paths; pub(crate) mod pins; +pub(crate) mod preferences; pub(crate) mod query; mod resolution; @@ -51,6 +53,16 @@ pub fn resolve_pins_command(input: Value) -> Result { command(input, pins::resolve, "resolution") } +/// Resolves inert consumer choices without materializing symbolic inheritance. +pub fn resolve_consumer_preferences_command(input: Value) -> Result { + command(input, preferences::resolve, "preferences") +} + +/// Changes one inert consumer preference without native writes or side effects. +pub fn edit_consumer_preferences_command(input: Value) -> Result { + command(input, preferences::edit, "edit") +} + /// Resolves one tool using the same validated registry and candidate snapshot. pub fn resolve_command(input: Value) -> Result { command(input, resolution::resolve, "resolution") diff --git a/rust/lithe-core/src/toolchain/pins.rs b/rust/lithe-core/src/toolchain/pins.rs index c6ce6e3602..d5bc67e03f 100644 --- a/rust/lithe-core/src/toolchain/pins.rs +++ b/rust/lithe-core/src/toolchain/pins.rs @@ -138,7 +138,7 @@ fn validate_pin(value: &Value, field: &str) -> Validation<()> { } /// Accepts the root marker or normalized portable directory components. -fn valid_directory(value: &str) -> bool { +pub(super) fn valid_directory(value: &str) -> bool { value == "." || (!value.is_empty() && !value @@ -150,7 +150,7 @@ fn valid_directory(value: &str) -> bool { } /// Checks both reserved short IDs and plugin-qualified IDs, not registry membership. -fn valid_tool_id(value: &str) -> bool { +pub(super) fn valid_tool_id(value: &str) -> bool { value.len() <= 192 && match value.split_once(':') { Some((namespace, id)) => valid_namespace(namespace) && valid_local_id(id), @@ -170,7 +170,7 @@ fn valid_namespace(value: &str) -> bool { } /// Checks the bounded local portion of a tool or provider ID. -fn valid_local_id(value: &str) -> bool { +pub(super) fn valid_local_id(value: &str) -> bool { !value.is_empty() && value.len() <= 64 && value.as_bytes()[0].is_ascii_lowercase() @@ -180,7 +180,7 @@ fn valid_local_id(value: &str) -> bool { } /// Recognizes POSIX, drive-rooted Windows, and UNC paths on either build platform. -fn valid_native_path(value: &str) -> bool { +pub(crate) fn valid_native_path(value: &str) -> bool { if value.chars().any(char::is_control) { return false; } diff --git a/rust/lithe-core/src/toolchain/preferences.rs b/rust/lithe-core/src/toolchain/preferences.rs new file mode 100644 index 0000000000..26057da2b0 --- /dev/null +++ b/rust/lithe-core/src/toolchain/preferences.rs @@ -0,0 +1,572 @@ +//! Inert consumer-scoped tool choices and symbolic workspace inheritance. +//! +//! This module selects saved inputs, never installations or executable facts. +//! Native consumers still pass the chosen fixed value to the existing resolver +//! and revalidate the owning document after collecting native evidence. + +use super::{fixed::FixedToolchainSelection, pins}; +use crate::plugins::declarative::{PluginRejection, Validation}; +use serde_json::{json, Map, Value}; + +const MAX_DOCUMENT_BYTES: usize = 2 * 1024 * 1024; +const MAX_DIRECTORIES: usize = 256; +const MAX_CONSUMERS: usize = 128; +const MAX_TOOLS: usize = 64; + +/// Resolves an explicit Run choice, then consumer/directory preferences. +/// Inheritance stays symbolic: changing the workspace default affects the +/// next resolution without rewriting every consumer or directory. +pub(super) fn resolve(input: &Value) -> Validation { + let mut choice = resolve_choice(input)?; + let document = &input["document"]; + let directory = input["directory"].as_str().expect("validated directory"); + let tool = input["tool"].as_str().expect("validated tool"); + let scope = if directory == "." { + &document["workspace"] + } else { + &document["directories"][directory] + }; + let tools = input["consumer"] + .as_str() + .map_or(&scope["tools"], |consumer| &scope["consumers"][consumer]); + choice["stored"] = tools.get(tool).cloned().unwrap_or(Value::Null); + Ok(choice) +} + +fn resolve_choice(input: &Value) -> Validation { + let request = object( + input, + "input", + &["document", "directory", "consumer", "tool", "fixed"], + )?; + let document = request.get("document").ok_or_else(|| invalid("document"))?; + validate(document)?; + let directory = request + .get("directory") + .and_then(Value::as_str) + .ok_or_else(|| invalid("directory"))?; + if directory.len() > 2048 || !pins::valid_directory(directory) { + return Err(invalid("directory")); + } + let tool = request + .get("tool") + .and_then(Value::as_str) + .ok_or_else(|| invalid("tool"))?; + if !pins::valid_tool_id(tool) { + return Err(invalid("tool")); + } + let consumer = request + .get("consumer") + .filter(|value| !value.is_null()) + .map(|value| { + value + .as_str() + .filter(|id| valid_consumer(id)) + .ok_or_else(|| invalid("consumer")) + }) + .transpose()?; + if let Some(fixed) = request.get("fixed").filter(|value| !value.is_null()) { + FixedToolchainSelection::parse(fixed)?; + return Ok(json!({"fixed":fixed,"source":{"kind":"run"},"inheritance":[]})); + } + let workspace = &document["workspace"]; + let directories = document["directories"].as_object(); + let mut ancestors = Vec::new(); + let mut current = directory; + while current != "." { + ancestors.push(current); + current = current.rsplit_once('/').map_or(".", |(parent, _)| parent); + } + // A consumer's own saved choice takes precedence over general defaults. + // An absent entry inherits; an explicit reference can skip parent choices. + if let Some(consumer) = consumer { + for directory in &ancestors { + if let Some(binding) = directories + .and_then(|map| map.get(*directory)) + .and_then(|scope| scope["consumers"][consumer].get(tool)) + { + return resolved(binding, "consumer", directory, Some(consumer), input); + } + } + if let Some(binding) = workspace["consumers"][consumer].get(tool) { + return resolved(binding, "consumer", ".", Some(consumer), input); + } + } + for directory in ancestors { + if let Some(binding) = directories + .and_then(|map| map.get(directory)) + .and_then(|scope| scope["tools"].get(tool)) + { + return resolved(binding, "directory", directory, None, input); + } + } + match workspace["tools"].get(tool) { + Some(binding) => resolved(binding, "workspace", ".", None, input), + None => Ok(automatic(Vec::new())), + } +} + +/// Edits exactly one local preference; a null value removes only that override. +/// Empty containers on the edited path are pruned without touching siblings. +pub(super) fn edit(input: &Value) -> Validation { + let request = object( + input, + "input", + &["document", "directory", "consumer", "tool", "value"], + )?; + let value = request.get("value").ok_or_else(|| invalid("value"))?; + let mut context = input.clone(); + context + .as_object_mut() + .expect("validated input") + .remove("value"); + let before = resolve(&context)?; + if before["stored"] == *value { + return Ok(json!({"document":input["document"],"preferences":before})); + } + let directory = input["directory"].as_str().expect("validated directory"); + let tool = input["tool"].as_str().expect("validated tool"); + let mut path = if directory == "." { + vec!["workspace"] + } else { + vec!["directories", directory] + }; + if let Some(consumer) = input["consumer"].as_str() { + path.extend(["consumers", consumer]); + } else { + path.push("tools"); + } + path.push(tool); + let mut document = input["document"].clone(); + edit_path(&mut document, &path, (!value.is_null()).then_some(value))?; + validate(&document)?; + context["document"] = document.clone(); + Ok(json!({"document":document,"preferences":resolve(&context)?})) +} + +fn edit_path(document: &mut Value, path: &[&str], value: Option<&Value>) -> Validation<()> { + let (key, remaining) = path.split_first().expect("nonempty preference path"); + let object = document + .as_object_mut() + .ok_or_else(|| invalid("document"))?; + if remaining.is_empty() { + if let Some(value) = value { + object.insert((*key).into(), value.clone()); + } else { + object.remove(*key); + } + return Ok(()); + } + if value.is_none() && !object.contains_key(*key) { + return Ok(()); + } + let child = object + .entry((*key).to_string()) + .or_insert_with(|| json!({})); + edit_path(child, remaining, value)?; + if child.as_object().is_some_and(Map::is_empty) { + object.remove(*key); + } + Ok(()) +} + +fn resolved( + binding: &Value, + kind: &str, + directory: &str, + consumer: Option<&str>, + input: &Value, +) -> Validation { + let workspace = &input["document"]["workspace"]; + let tool = input["tool"].as_str().expect("validated tool"); + let source = json!({"kind":kind,"directory":directory,"consumer":consumer}); + let Some(inherit) = binding.get("inherit").and_then(Value::as_str) else { + return Ok(json!({"fixed":binding,"source":source,"inheritance":[]})); + }; + let mut inheritance = vec![source]; + if inherit == "tool" { + return general_tool_choice(input, inheritance); + } + if inherit == "consumer" { + let consumer = consumer.ok_or_else(|| invalid("inherit"))?; + if let Some(binding) = workspace["consumers"][consumer].get(tool) { + // Workspace consumer references can only point at workspace tools; + // they cannot point back into consumer or directory scopes. + if binding.get("inherit").is_none() { + return Ok( + json!({"fixed":binding,"source":{"kind":"consumer","directory":".","consumer":consumer},"inheritance":inheritance}), + ); + } + inheritance.push(json!({"kind":"consumer","directory":".","consumer":consumer})); + if binding["inherit"] == "tool" { + return general_tool_choice(input, inheritance); + } + } + } + match workspace["tools"].get(tool) { + Some(binding) => Ok( + json!({"fixed":binding,"source":{"kind":"workspace","directory":".","consumer":null},"inheritance":inheritance}), + ), + None => Ok(automatic(inheritance)), + } +} + +/// Consumer-to-tool references remove only the consumer dimension. General +/// tools cannot point back to consumers, so reusing the normal resolver keeps +/// directory ordering in one place and the reference graph acyclic. +fn general_tool_choice(input: &Value, mut inheritance: Vec) -> Validation { + let mut general = input.clone(); + let request = general.as_object_mut().expect("validated input"); + request.remove("consumer"); + request.remove("fixed"); + let mut selected = resolve_choice(&general)?; + inheritance.extend( + selected["inheritance"] + .as_array() + .expect("resolved inheritance") + .iter() + .cloned(), + ); + selected["inheritance"] = json!(inheritance); + Ok(selected) +} + +fn automatic(inheritance: Vec) -> Value { + json!({"fixed":null,"source":{"kind":"automatic"},"inheritance":inheritance}) +} + +/// Validates dormant IDs as inert preferences without requiring installation +/// or package trust. Their presence never registers a tool or grants authority. +pub(crate) fn validate(document: &Value) -> Validation<()> { + if document.to_string().len() > MAX_DOCUMENT_BYTES { + return Err(limit("document")); + } + let root = object( + document, + "document", + &["version", "workspace", "directories"], + )?; + if root.get("version").and_then(Value::as_u64) != Some(1) { + return Err(invalid("document.version")); + } + if let Some(workspace) = root.get("workspace") { + scope(workspace, "workspace", true)?; + } + if let Some(directories) = root.get("directories") { + let directories = map(directories, "directories", MAX_DIRECTORIES)?; + for (directory, value) in directories { + if directory == "." || directory.len() > 2048 || !pins::valid_directory(directory) { + return Err(invalid("directories")); + } + scope(value, "directories", false)?; + } + } + Ok(()) +} + +fn scope(value: &Value, field: &str, workspace: bool) -> Validation<()> { + let scope = object(value, field, &["tools", "consumers"])?; + if let Some(tools) = scope.get("tools") { + tools_map(tools, field, workspace, false)?; + } + if let Some(consumers) = scope.get("consumers") { + for (consumer, tools) in map(consumers, field, MAX_CONSUMERS)? { + if !valid_consumer(consumer) { + return Err(invalid(field)); + } + tools_map(tools, field, workspace, true)?; + } + } + Ok(()) +} + +fn tools_map(value: &Value, field: &str, workspace: bool, consumer: bool) -> Validation<()> { + for (tool, binding) in map(value, field, MAX_TOOLS)? { + if !pins::valid_tool_id(tool) { + return Err(invalid(field)); + } + if binding.get("inherit").is_some() { + let reference = object(binding, field, &["inherit"])?; + match reference.get("inherit").and_then(Value::as_str) { + Some("workspace") if !workspace || consumer => {} + Some("consumer") if !workspace && consumer => {} + Some("tool") if consumer => {} + _ => return Err(invalid(field)), + } + } else { + FixedToolchainSelection::parse(binding)?; + } + } + Ok(()) +} + +fn valid_consumer(value: &str) -> bool { + value.len() <= 256 + && value.split_once('/').is_some_and(|(plugin, local)| { + crate::plugins::declarative::is_plugin_id(plugin) && pins::valid_local_id(local) + }) +} + +fn map<'a>(value: &'a Value, field: &str, max: usize) -> Validation<&'a Map> { + let object = value.as_object().ok_or_else(|| invalid(field))?; + if object.len() > max { + return Err(limit(field)); + } + Ok(object) +} + +fn object<'a>(value: &'a Value, field: &str, keys: &[&str]) -> Validation<&'a Map> { + let object = value.as_object().ok_or_else(|| invalid(field))?; + if object.keys().any(|key| !keys.contains(&key.as_str())) { + return Err(invalid(field)); + } + Ok(object) +} + +fn invalid(field: &str) -> PluginRejection { + PluginRejection::at( + "plugin.toolchain.invalidParams", + field, + "Invalid consumer tool preferences.", + ) +} + +fn limit(field: &str) -> PluginRejection { + PluginRejection::at( + "plugin.toolchain.limitExceeded", + field, + "Consumer tool preferences exceed their budget.", + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shared_preferences_fixture_resolves_without_native_or_domain_facts() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/toolchains/consumer-preferences.json" + )) + .unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let input = json!({"document":fixture["document"],"tool":"dev.example.runtime:sdk", + "directory":case["directory"],"consumer":case["consumer"]}); + let result = crate::toolchain::resolve_consumer_preferences_command(input).unwrap(); + assert_eq!(result["status"], "ok", "{result}"); + assert_eq!(result["preferences"]["fixed"], case["expected"]); + } + } + + #[test] + fn consumer_preferences_json_dispatcher_preserves_the_value_boundary() { + let payload = request(document()); + let response: Value = serde_json::from_str(&crate::execute_json(&json!({ + "id":"consumer-preferences","command":"toolchains.resolveConsumerPreferences","payload":payload + }).to_string())).unwrap(); + assert_eq!(response["ok"], true, "{response}"); + assert_eq!( + response["data"]["preferences"]["fixed"], + json!({"home":"/fixture/sdk-one"}) + ); + } + + #[test] + fn explicit_preference_edit_preserves_siblings_and_reset_exposes_live_inheritance() { + let original = document(); + let edited = edit(&json!({"document":original,"directory":"nested/child","consumer":"dev.example.build/engine", + "tool":"dev.example.runtime:sdk","value":{"home":"/fixture/explicit-sdk"}})).unwrap(); + assert_eq!( + edited["preferences"]["stored"], + json!({"home":"/fixture/explicit-sdk"}) + ); + assert_eq!(edited["document"]["workspace"], original["workspace"]); + assert_eq!( + edited["document"]["directories"]["nested"], + original["directories"]["nested"] + ); + let inherited = edit(&json!({"document":edited["document"],"directory":"nested/child","consumer":"dev.example.build/engine", + "tool":"dev.example.runtime:sdk","value":null})).unwrap(); + assert!(inherited["preferences"]["stored"].is_null()); + assert_eq!( + inherited["preferences"]["fixed"], + json!({"home":"/fixture/sdk-one"}) + ); + assert_eq!(inherited["document"], original); + assert_eq!(edit(&json!({"document":{"version":1},"directory":"missing","consumer":null,"tool":"fixture","value":null})).unwrap()["document"],json!({"version":1})); + let empty = json!({"version":1,"workspace":{"consumers":{"dev.example.build/engine":{}}}}); + assert_eq!(edit(&json!({"document":empty,"directory":".","consumer":"dev.example.build/engine","tool":"fixture","value":null})).unwrap()["document"],empty); + assert_eq!(edit(&json!({"document":original,"directory":".","tool":"dev.example.runtime:sdk","value":{"home":"/fixture/sdk-one"}})).unwrap()["document"],original); + } + + #[test] + fn preference_editor_rejects_missing_values_and_cyclic_scope_references() { + for value in [ + json!({"inherit":"consumer"}), + json!({"inherit":"workspace"}), + json!({"home":"relative"}), + ] { + assert!(edit( + &json!({"document":{"version":1},"directory":".","tool":"fixture","value":value}) + ) + .is_err()); + } + assert!(edit(&json!({"document":{"version":1},"directory":".","tool":"fixture"})).is_err()); + let edited=edit(&json!({"document":document(),"directory":"nested/child","consumer":"dev.example.build/engine", + "tool":"dev.example.runtime:sdk","value":{"inherit":"consumer"}})).unwrap(); + assert_eq!( + edited["preferences"]["stored"], + json!({"inherit":"consumer"}) + ); + assert_eq!( + edited["preferences"]["fixed"], + json!({"home":"/fixture/sdk-one"}) + ); + } + + fn document() -> Value { + json!({"version":1,"workspace":{"tools":{"dev.example.runtime:sdk":{"home":"/fixture/sdk-one"}}, + "consumers":{"dev.example.build/engine":{"dev.example.runtime:sdk":{"inherit":"workspace"}}}}, + "directories":{"nested":{"tools":{"dev.example.runtime:sdk":{"home":"/fixture/directory-sdk"}}}}}) + } + fn request(document: Value) -> Value { + json!({"document":document,"tool":"dev.example.runtime:sdk","directory":"nested/child","consumer":"dev.example.build/engine"}) + } + + #[test] + fn symbolic_consumer_inheritance_follows_workspace_changes_without_freezing_paths() { + let mut document = document(); + assert_eq!( + resolve(&request(document.clone())).unwrap()["fixed"], + json!({"home":"/fixture/sdk-one"}) + ); + document["workspace"]["tools"]["dev.example.runtime:sdk"] = + json!({"home":"/fixture/sdk-two"}); + assert_eq!( + resolve(&request(document.clone())).unwrap()["fixed"], + json!({"home":"/fixture/sdk-two"}) + ); + document["workspace"]["tools"] = json!({}); + assert!(resolve(&request(document)).unwrap()["fixed"].is_null()); + } + + #[test] + fn consumer_tool_reference_follows_requested_directory_without_cycles_or_frozen_values() { + let mut document = document(); + document["workspace"]["consumers"]["dev.example.build/engine"]["dev.example.runtime:sdk"] = + json!({"inherit":"tool"}); + document["directories"]["nested"]["consumers"] = json!({"dev.example.build/engine":{"dev.example.runtime:sdk":{"home":"/fixture/parent-consumer"}}}); + document["directories"]["nested/child"] = json!({"consumers":{"dev.example.build/engine":{"dev.example.runtime:sdk":{"inherit":"consumer"}}}}); + let initial = resolve(&request(document.clone())).unwrap(); + assert_eq!(initial["fixed"], json!({"home":"/fixture/directory-sdk"})); + assert_eq!(initial["source"]["kind"], "directory"); + assert_eq!(initial["inheritance"].as_array().unwrap().len(), 2); + document["directories"]["nested"]["tools"]["dev.example.runtime:sdk"] = + json!({"home":"/fixture/changed-directory-sdk"}); + assert_eq!( + resolve(&request(document.clone())).unwrap()["fixed"], + json!({"home":"/fixture/changed-directory-sdk"}) + ); + document["workspace"]["tools"]["dev.example.runtime:sdk"] = + json!({"home":"/fixture/changed-workspace-sdk"}); + document["directories"]["nested/child"]["tools"] = + json!({"dev.example.runtime:sdk":{"inherit":"workspace"}}); + assert_eq!( + resolve(&request(document.clone())).unwrap()["fixed"], + json!({"home":"/fixture/changed-workspace-sdk"}) + ); + let mut explicit = request(document.clone()); + explicit["fixed"] = json!({"home":"/fixture/explicit-run"}); + assert_eq!(resolve(&explicit).unwrap()["fixed"], explicit["fixed"]); + for scope in ["workspace", "directories"] { + let mut invalid = document.clone(); + if scope == "workspace" { + invalid["workspace"]["tools"]["dev.example.runtime:sdk"] = + json!({"inherit":"tool"}); + } else { + invalid["directories"]["nested"]["tools"]["dev.example.runtime:sdk"] = + json!({"inherit":"tool"}); + } + assert!(resolve(&request(invalid)).is_err()); + } + } + + #[test] + fn directory_reference_can_skip_parent_consumer_choices_without_copying_the_workspace_value() { + let mut document = document(); + document["directories"]["nested"]["consumers"] = json!({"dev.example.build/engine":{"dev.example.runtime:sdk":{"home":"/fixture/parent-choice"}}}); + document["directories"]["nested/child"] = json!({"consumers":{"dev.example.build/engine":{"dev.example.runtime:sdk":{"inherit":"consumer"}}}}); + let value = resolve(&request(document.clone())).unwrap(); + assert_eq!(value["fixed"], json!({"home":"/fixture/sdk-one"})); + assert_eq!(value["inheritance"].as_array().unwrap().len(), 2); + document["workspace"]["consumers"]["dev.example.build/engine"]["dev.example.runtime:sdk"] = + json!({"home":"/fixture/consumer-workspace"}); + assert_eq!( + resolve(&request(document)).unwrap()["fixed"], + json!({"home":"/fixture/consumer-workspace"}) + ); + } + + #[test] + fn ordinary_directory_and_run_choices_remain_generic_and_nearest() { + let mut input = request(document()); + input["consumer"] = Value::Null; + assert_eq!( + resolve(&input).unwrap()["fixed"], + json!({"home":"/fixture/directory-sdk"}) + ); + input["fixed"] = json!({"executable":"C:/fixture/explicit.cmd"}); + assert_eq!(resolve(&input).unwrap()["source"]["kind"], "run"); + input["fixed"] = Value::Null; + assert_eq!(resolve(&input).unwrap()["source"]["kind"], "directory"); + // A consumer identity follows the installed manifest's ID grammar, + // even when that valid owner is not a dotted tool-definition namespace. + input["consumer"] = json!("fixture-owner/engine"); + assert_eq!(resolve(&input).unwrap()["source"]["kind"], "directory"); + } + + #[test] + fn preferences_reject_cycles_invalid_scopes_unknown_fields_and_relative_fixed_paths() { + let base = document(); + for (pointer, value) in [ + ( + "/workspace/tools/dev.example.runtime:sdk", + json!({"inherit":"workspace"}), + ), + ( + "/workspace/consumers/dev.example.build~1engine/dev.example.runtime:sdk", + json!({"inherit":"consumer"}), + ), + ( + "/directories/nested/tools/dev.example.runtime:sdk", + json!({"inherit":"consumer"}), + ), + ( + "/workspace/tools/dev.example.runtime:sdk", + json!({"home":"relative"}), + ), + ( + "/workspace/tools/dev.example.runtime:sdk", + json!({"home":"/fixture/sdk","inherit":"workspace"}), + ), + ] { + let mut document = base.clone(); + *document.pointer_mut(pointer).unwrap() = value; + assert!(validate(&document).is_err(), "{document}"); + } + for directory in [ + ".", + "../outside", + "nested/../outside", + "C:/outside", + "nested\\child", + ] { + let mut document = base.clone(); + document["directories"] = json!({directory:{}}); + assert!(validate(&document).is_err()); + } + let mut input = request(base); + input["consumer"] = json!("unqualified"); + assert!(resolve(&input).is_err()); + } +} diff --git a/rust/lithe-core/src/toolchain/query.rs b/rust/lithe-core/src/toolchain/query.rs index 264959499d..b2c55f4a88 100644 --- a/rust/lithe-core/src/toolchain/query.rs +++ b/rust/lithe-core/src/toolchain/query.rs @@ -15,7 +15,9 @@ const LIMIT: &str = "plugin.toolchain.limitExceeded"; #[serde(rename_all = "camelCase", deny_unknown_fields)] struct Request { jsonrpc: String, - id: u64, + // Preserve wire types: Core's LSP transport allocates bounded string IDs, + // while other compatible transports can allocate positive integer IDs. + id: Value, method: String, params: Params, } @@ -608,7 +610,8 @@ pub(crate) fn decode(input: &Value) -> Validation { .map_err(|_| reject(INVALID, "request", "Malformed toolchain query request."))?; let params = &request.params; if request.jsonrpc != "2.0" - || request.id == 0 + || !(request.id.as_u64().is_some_and(|id| id > 0) + || request.id.as_str().is_some_and(|id| bounded(id, 128))) || !identifier(¶ms.operation_id) || params.generation == 0 || !provider_identifier(¶ms.provider_id) @@ -795,7 +798,7 @@ pub(crate) fn decode(input: &Value) -> Validation { REPLY, )?; if response["jsonrpc"] != "2.0" - || response["id"].as_u64() != Some(request.id) + || response["id"] != request.id || response.get("result").is_some() == response.get("error").is_some() { return Err(reject( diff --git a/rust/lithe-core/src/toolchain/resolution.rs b/rust/lithe-core/src/toolchain/resolution.rs index 463bcfc5b1..2131bbab1d 100644 --- a/rust/lithe-core/src/toolchain/resolution.rs +++ b/rust/lithe-core/src/toolchain/resolution.rs @@ -1,6 +1,6 @@ //! Resolves captured toolchain candidates without native I/O or manager semantics. -use super::{definitions, paths, pins, query}; +use super::{definitions, fixed::FixedToolchainSelection, paths, pins, query}; use crate::plugins::declarative::{PluginRejection, Validation}; use regex::RegexBuilder; use serde::Deserialize; @@ -210,6 +210,47 @@ struct Snapshot<'a> { } impl Snapshot<'_> { + /// An explicit consumer choice precedes directory pins, but has exactly the + /// same native version and project-code boundary as other checked candidates. + fn fixed_selection(&self, fixed: &FixedToolchainSelection) -> Result { + match fixed { + FixedToolchainSelection::Installation { home } => { + self.selection(home, "host:manual", "pinned") + } + FixedToolchainSelection::Executable { executable } => { + let fact = self.fact(executable, "file").map_err(|_| "notFound")?; + if fact["executable"] != true { + return Err("notExecutable"); + } + let canonical = fact["canonicalPath"].as_str().ok_or("unsafePath")?; + let project = self.roots.iter().any(|root| { + paths::within(executable, root, self.platform, self.case_sensitive) + || paths::within(canonical, root, self.platform, self.case_sensitive) + }); + if project && self.input["projectCodeAllowed"] != true { + return Err("projectCodeRequiresAction"); + } + if self.definition["launch"] + .as_array() + .into_iter() + .flatten() + .any(|token| token == "${home}") + { + return Err("homeUnavailable"); + } + let version = self.version(executable)?; + self.environment_selection( + None, + executable, + Some(&version), + "host:manual", + "pinned", + project, + ) + } + } + } + fn key(&self, path: &str) -> Validation { paths::normalize(path, self.platform, self.case_sensitive).ok_or_else(|| reject("path")) } @@ -756,6 +797,8 @@ pub(crate) fn candidates(input: &Value) -> Validation { "probes", "systemInstallations", "providerInstallations", + "fixed", + "projectCodeAllowed", "environment", ], )?; @@ -766,6 +809,12 @@ pub(crate) fn candidates(input: &Value) -> Validation { .ok_or_else(|| reject("tool"))?; let definition = enabled(®istry, tool).ok_or_else(|| reject("tool"))?; let (snapshot, _) = prepare(input, definition)?; + if input + .get("projectCodeAllowed") + .is_some_and(|value| !value.is_boolean()) + { + return Err(reject("projectCodeAllowed")); + } string_maps(input, &["environment"])?; let mut shell = Vec::new(); for candidate in definition["executables"] @@ -812,7 +861,31 @@ pub(crate) fn candidates(input: &Value) -> Validation { .cmp(&version(left)) .then_with(|| left["home"].as_str().cmp(&right["home"].as_str())) }); - let skipped: Vec<_> = set.skipped.into_values().collect(); + let mut skipped: Vec<_> = set.skipped.into_values().collect(); + if let Some(value) = input.get("fixed") { + let fixed = FixedToolchainSelection::parse(value)?; + let executable = match &fixed { + FixedToolchainSelection::Installation { home } => snapshot + .checked(home, None) + .ok() + .and_then(|value| value["executable"].as_str().map(str::to_owned)), + FixedToolchainSelection::Executable { executable } => snapshot + .fact(executable, "file") + .ok() + .filter(|fact| fact["executable"] == true) + .map(|_| executable.clone()), + }; + if let Err(code) = snapshot.fixed_selection(&fixed) { + let mut row = json!({"reason":code}); + // A project program is not a probe target without the current action. + if code == "probeFailed" { + if let Some(executable) = executable { + row["executable"] = json!(executable); + } + } + skipped.push(row); + } + } let (selected, conflict) = automatic_choice(&snapshot, set.eligible, None)?; Ok( json!({"tool":tool,"shell":shell,"installations":installations,"skipped":skipped, @@ -847,6 +920,7 @@ pub(crate) fn resolve(input: &Value) -> Validation { "providerChoices", "generation", "context", + "fixed", ], )?; let registry = definitions::registry(&input["registry"])?; @@ -866,6 +940,10 @@ pub(crate) fn resolve(input: &Value) -> Validation { .cloned() .unwrap_or(json!({"version":1,"toolchains":{}})); let pin = pins::resolve(&json!({"document":document,"directory":directory,"tool":tool}))?; + let fixed = input + .get("fixed") + .map(FixedToolchainSelection::parse) + .transpose()?; let Some(definition) = enabled(®istry, tool) else { return Ok(output( input, @@ -892,6 +970,26 @@ pub(crate) fn resolve(input: &Value) -> Validation { if let Some(choices) = input.get("providerChoices") { fields(choices, &["project", "global", "automatic"])?; } + if let Some(fixed) = fixed { + return Ok(match snapshot.fixed_selection(&fixed) { + Ok(selection) => output( + input, + json!({"kind":"resolved","selection":selection}), + vec![json!({"candidateID":"consumerFixed","layer":"pinned","state":"resolved"})], + vec![], + vec![], + ), + Err(code) => output( + input, + json!({"kind":"terminalFailure","reason":reason(code,tool)}), + vec![ + json!({"candidateID":"consumerFixed","layer":"pinned","state":"terminalFailure","reason":reason(code,tool)}), + ], + vec![], + vec![], + ), + }); + } // A failed optional provider must never revoke a usable explicit fixed value. if let Some(home) = pin["pin"]["home"].as_str() { let result = snapshot.selection( diff --git a/rust/lithe-ide-host/src/bin/lithe-mcp.rs b/rust/lithe-ide-host/src/bin/lithe-mcp.rs index 8b44629b34..e5a4cc6a6f 100644 --- a/rust/lithe-ide-host/src/bin/lithe-mcp.rs +++ b/rust/lithe-ide-host/src/bin/lithe-mcp.rs @@ -20,7 +20,13 @@ impl ServerHandler for Ide { _: Option, _: RequestContext, ) -> Result { - let tools = catalog::tools() + let bytes = std::fs::read(&self.connection).map_err(|_| { + ErrorData::internal_error("The authorized IDE connection is unavailable", None) + })?; + let connection: Connection = serde_json::from_slice(&bytes) + .map_err(|_| ErrorData::internal_error("Invalid IDE connection descriptor", None))?; + let tools = catalog::served_tools(connection.tool_exposure.as_deref()) + .map_err(|_| ErrorData::internal_error("Invalid IDE capability declaration", None))? .into_iter() .map(serde_json::from_value) .collect::, _>>() diff --git a/rust/lithe-ide-host/src/catalog.rs b/rust/lithe-ide-host/src/catalog.rs index 77e08a73a8..b0ab855d7c 100644 --- a/rust/lithe-ide-host/src/catalog.rs +++ b/rust/lithe-ide-host/src/catalog.rs @@ -3,6 +3,70 @@ use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet}; + +/// Native workflow implementations select which known tools they actually serve. +/// A restricted argument list can only narrow the canonical input schema. +#[derive(Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ToolExposure { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub arguments: Option>, +} + +/// Resolves a bounded host declaration without accepting caller-supplied schemas. +/// Catalog order, permission hints, value types and required fields stay canonical. +pub fn exposed_tools(exposure: &[ToolExposure]) -> Result, String> { + let catalog = tools(); + if exposure.len() > catalog.len() { + return Err("Too many exposed IDE capabilities".into()); + } + let mut selected = BTreeMap::new(); + for item in exposure { + let known = catalog.iter().any(|tool| tool["name"] == item.name); + if !known + || selected + .insert(item.name.as_str(), item.arguments.as_ref()) + .is_some() + { + return Err("Unknown or duplicate exposed IDE capability".into()); + } + } + let mut result = Vec::new(); + for mut tool in catalog { + let name = tool["name"].as_str().unwrap(); + let Some(arguments) = selected.get(name) else { + continue; + }; + if let Some(arguments) = arguments { + let properties = tool["inputSchema"]["properties"].as_object().unwrap(); + let names: BTreeSet<_> = arguments.iter().map(String::as_str).collect(); + if names.len() != arguments.len() + || !names.iter().all(|name| properties.contains_key(*name)) + || !tool["inputSchema"]["required"] + .as_array() + .unwrap() + .iter() + .all(|name| names.contains(name.as_str().unwrap())) + { + return Err("Invalid exposed IDE argument selection".into()); + } + tool["inputSchema"]["properties"] + .as_object_mut() + .unwrap() + .retain(|name, _| names.contains(name.as_str())); + } + result.push(tool); + } + Ok(result) +} + +/// The tools a host serves. A host that declares no selection serves the whole +/// catalog, so a platform keeps its tools until it declares what it implements. +pub fn served_tools(exposure: Option<&[ToolExposure]>) -> Result, String> { + exposure.map_or_else(|| Ok(tools()), exposed_tools) +} /// Authorization is granted by the IDE settings UI, never by tool arguments. #[derive(Clone, Default, Deserialize, Serialize)] @@ -22,6 +86,10 @@ pub fn failure(code: &str, message: &str) -> Value { fn string() -> Value { json!({"type":"string", "maxLength":4096}) } +fn option_value() -> Value { + json!({"anyOf":[string(), {"type":"boolean"}, {"type":"null"}, + {"type":"array","items":string(),"maxItems":64}]}) +} fn schema(properties: Value, required: &[&str]) -> Value { json!({"type":"object", "properties":properties, "required":required, "additionalProperties":false}) } @@ -35,7 +103,11 @@ pub fn tools() -> Vec { ("lithe_maven_inspect", "Read Maven modules, available/selected profiles, skip-tests setting and reload state.", "read", schema(json!({}), &[])), ("lithe_maven_configure", "Save Maven profiles and skip-tests settings through the existing IDE workflow. Requires configure permission.", "configure", schema(json!({"profiles":{"type":"array","items":string(),"maxItems":64},"skipTests":{"type":"boolean"}}), &[])), ("lithe_maven_reload", "Reload the Maven project and synchronize the existing Java language service. May resolve dependencies and execute project tooling. Requires execute permission.", "execute", schema(json!({}), &[])), - ("lithe_maven_execute", "Execute Maven goals through the IDE, using saved profiles and toolchains. Returns operationID for output/status/stop. May execute project code and access the network. Requires execute permission.", "execute", schema(json!({"goals":{"type":"array","items":string(),"minItems":1,"maxItems":32},"modulePath":string()}), &["goals"])), + ("lithe_maven_execute", "Windows Maven execution (macOS uses lithe_build_execute). Execute Maven goals through the IDE, using saved profiles and toolchains. Returns operationID for output/status/stop. May execute project code and access the network. Requires execute permission.", "execute", schema(json!({"goals":{"type":"array","items":string(),"minItems":1,"maxItems":32},"modulePath":string()}), &["goals"])), + ("lithe_build_inspect", "List detected build plugin instances, current project/target/model task identities and declared tasks. No tooling is executed.", "read", schema(json!({}), &[])), + ("lithe_build_reload", "Ask a detected build plugin to load or reload its project model. May execute project tooling and access the network. Inspect the model state before selecting projects. Requires execute permission.", "execute", schema(json!({"instanceID":string()}), &["instanceID"])), + ("lithe_build_configure", "Set one declared build option using the same instance editor and persistent store as the IDE. Read option IDs, kinds and effective values from build inspect. Null resets that option. Invalid values are refused. Native files use the explicit native configuration save workflow. Requires configure permission.", "configure", schema(json!({"instanceID":string(),"optionID":string(),"value":option_value()}), &["instanceID","optionID","value"])), + ("lithe_build_execute", "Execute a declared plugin build task through the same Build workflow as the IDE. Use identities from build inspect. Arguments are separate tokens interpreted by the plugin. Returns operationID for output/status/stop. May execute project code and access the network. Requires execute permission.", "execute", schema(json!({"instanceID":string(),"taskID":string(),"projectID":string(),"targetID":string(),"modelTaskID":string(),"userArguments":{"type":"array","items":string(),"maxItems":256}}), &["instanceID","taskID"])), ("lithe_run_list", "List saved runnable configurations and readiness. Environment variable values and launch arguments are not disclosed.", "read", schema(json!({}), &[])), ("lithe_run_start", "Start an existing IDE run configuration through the normal preparation/build workflow. Returns operationID; pending UI decisions remain in the IDE. Requires execute permission.", "execute", schema(json!({"configurationID":string()}), &["configurationID"])), ("lithe_run_restart", "Restart the exact named operation using its existing run configuration. An expired or replaced operation is rejected. Requires execute permission.", "execute", schema(json!({"operationID":string()}), &["operationID"])), @@ -52,8 +124,39 @@ pub fn tools() -> Vec { /// Validates even calls made by in-process plugins; MCP schemas are not authorization. pub fn validate(name: &str, args: &Value, permissions: &Permissions) -> Result<(), Value> { - let tool = tools() - .into_iter() + validate_catalog(name, args, permissions, &tools()) +} + +/// Enforces the same native capability selection used by MCP discovery. +pub fn validate_exposed( + name: &str, + args: &Value, + permissions: &Permissions, + exposure: &[ToolExposure], +) -> Result<(), Value> { + validate_served(name, args, permissions, Some(exposure)) +} + +/// Validates against what the host serves; see [`served_tools`]. +pub fn validate_served( + name: &str, + args: &Value, + permissions: &Permissions, + exposure: Option<&[ToolExposure]>, +) -> Result<(), Value> { + let tools = served_tools(exposure) + .map_err(|_| failure("NOT_SUPPORTED", "Invalid IDE capability declaration"))?; + validate_catalog(name, args, permissions, &tools) +} + +fn validate_catalog( + name: &str, + args: &Value, + permissions: &Permissions, + tools: &[Value], +) -> Result<(), Value> { + let tool = tools + .iter() .find(|v| v["name"] == name) .ok_or_else(|| failure("NOT_SUPPORTED", "Unknown IDE capability"))?; let permission = tool["_meta"]["lithe/permission"].as_str().unwrap_or("read"); @@ -80,23 +183,7 @@ pub fn validate(name: &str, args: &Value, permissions: &Permissions) -> Result<( } for (key, value) in values { let spec = properties.get(key).ok_or_else(invalid)?; - let valid = match spec["type"].as_str() { - Some("string") => value - .as_str() - .is_some_and(|s| s.len() <= 4096 && !s.contains('\0')), - Some("boolean") => value.is_boolean(), - Some("array") => value.as_array().is_some_and(|a| { - a.len() <= spec["maxItems"].as_u64().unwrap_or(64) as usize - && a.len() >= spec["minItems"].as_u64().unwrap_or(0) as usize - && a.iter().all(|v| { - v.as_str().is_some_and(|s| { - !s.is_empty() && s.len() <= 4096 && !s.chars().any(char::is_control) - }) - }) - }), - _ => false, - }; - if !valid { + if !valid_value(spec, value) { return Err(invalid()); } } @@ -136,6 +223,31 @@ pub fn validate(name: &str, args: &Value, permissions: &Permissions) -> Result<( Ok(()) } +/// Validate the catalog's bounded scalar/list union before native dispatch. +/// No object, executable expression or arbitrary JSON enters build options. +fn valid_value(spec: &Value, value: &Value) -> bool { + if let Some(variants) = spec["anyOf"].as_array() { + return variants.iter().any(|variant| valid_value(variant, value)); + } + match spec["type"].as_str() { + Some("string") => value + .as_str() + .is_some_and(|s| s.len() <= 4096 && !s.contains('\0')), + Some("boolean") => value.is_boolean(), + Some("null") => value.is_null(), + Some("array") => value.as_array().is_some_and(|a| { + a.len() <= spec["maxItems"].as_u64().unwrap_or(64) as usize + && a.len() >= spec["minItems"].as_u64().unwrap_or(0) as usize + && a.iter().all(|v| { + v.as_str().is_some_and(|s| { + !s.is_empty() && s.len() <= 4096 && !s.chars().any(char::is_control) + }) + }) + }), + _ => false, + } +} + /// Pages UTF-8 output with a prefix fingerprint so a trimmed/replaced buffer resets explicitly. pub fn output_page(mut snapshot: Value, cursor: Option<&str>) -> Value { let output = snapshot["output"].as_str().unwrap_or("").to_owned(); @@ -170,6 +282,94 @@ pub fn output_page(mut snapshot: Value, cursor: Option<&str>) -> Value { #[cfg(test)] mod tests { use super::*; + #[test] + fn native_inventory_can_only_narrow_known_tools_and_their_argument_fields() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../shared/fixtures/ide-api/host-tool-exposure.json" + )) + .unwrap(); + let permissions = Permissions { + configure: true, + execute: true, + }; + for platform in ["macos", "windows"] { + let exposure: Vec = + serde_json::from_value(fixture[platform].clone()).unwrap(); + assert_eq!( + exposed_tools(&exposure).unwrap().len(), + fixture[platform].as_array().unwrap().len() + ); + } + let windows: Vec = + serde_json::from_value(fixture["windows"].clone()).unwrap(); + assert!(validate_exposed( + "lithe_build_execute", + &json!({"instanceID":"example:.","taskID":"compile"}), + &permissions, + &windows + ) + .is_ok()); + assert!(validate_exposed( + "lithe_build_configure", + &json!({"instanceID":"example:.","optionID":"offline","value":true}), + &permissions, + &windows + ) + .is_ok()); + assert!(validate_exposed( + "lithe_build_configure", + &json!({"instanceID":"example:.","optionID":"offline","value":false}), + &Permissions::default(), + &windows + ) + .is_err()); + let exposure: Vec = serde_json::from_value(fixture["macos"].clone()).unwrap(); + assert!(validate_exposed( + "lithe_environment_configure", + &json!({"javaHomePath":"/fixture/jdk"}), + &permissions, + &exposure + ) + .is_ok()); + assert_eq!( + validate_exposed( + "lithe_environment_configure", + &json!({"settingsPath":"/fixture/settings.xml"}), + &permissions, + &exposure + ) + .unwrap_err()["error"]["code"], + "INVALID_ARGUMENTS" + ); + assert_eq!( + validate_exposed( + "lithe_maven_configure", + &json!({"profiles":["ci"]}), + &permissions, + &exposure + ) + .unwrap_err()["error"]["code"], + "NOT_SUPPORTED" + ); + assert!(validate_exposed( + "lithe_build_execute", + &json!({"instanceID":"maven:.","taskID":"compile"}), + &Permissions::default(), + &exposure + ) + .is_err()); + for invalid in [ + json!([{"name":"unknown"}]), + json!([{"name":"lithe_project_inspect"},{"name":"lithe_project_inspect"}]), + json!([{"name":"lithe_project_inspect","arguments":["shell"]}]), + json!([{"name":"lithe_build_execute","arguments":["instanceID"]}]), + json!([{"name":"lithe_environment_configure","arguments":["javaHomePath","javaHomePath"]}]), + ] { + let exposure: Vec = serde_json::from_value(invalid).unwrap(); + assert!(exposed_tools(&exposure).is_err()); + } + } + #[test] fn shared_fixture_protects_the_plugin_and_mcp_contract() { let fixture: Value = @@ -230,6 +430,67 @@ mod tests { .is_ok()); } #[test] + fn build_authorization_and_argument_validation_cannot_be_bypassed() { + assert!(validate( + "lithe_build_execute", + &json!({"instanceID":"maven@.","taskID":"verify"}), + &Permissions::default() + ) + .is_err()); + let permissions = Permissions { + execute: true, + configure: true, + }; + for args in [ + json!({"instanceID":"maven@."}), + json!({"instanceID":"maven@.","taskID":"verify","command":"mvn"}), + json!({"instanceID":"maven@.","taskID":"verify","userArguments":["bad\u{0000}argument"]}), + json!({"instanceID":"maven@.","taskID":"verify","confirmed":true}), + ] { + assert!(validate("lithe_build_execute", &args, &permissions).is_err()); + } + assert!(validate("lithe_build_execute", &json!({"instanceID":"maven@.","taskID":"custom","projectID":"api","userArguments":["clean","verify"]}), &permissions).is_ok()); + } + #[test] + fn build_options_require_configure_and_accept_only_bounded_declared_value_shapes() { + let permission = Permissions { + configure: true, + execute: false, + }; + for value in [ + Value::Null, + json!(true), + json!("/path with spaces/settings.xml"), + json!(["ci", "local"]), + json!([]), + ] { + let args = json!({"instanceID":"fixture@.","optionID":"profiles","value":value}); + assert!(validate("lithe_build_configure", &args, &permission).is_ok()); + assert!(validate("lithe_build_configure", &args, &Permissions::default()).is_err()); + } + for value in [ + json!({"path":"anything"}), + json!(42), + json!([true]), + json!(["bad\nvalue"]), + json!(vec!["v"; 65]), + json!("x".repeat(4097)), + ] { + assert!(validate( + "lithe_build_configure", + &json!({"instanceID":"fixture@.","optionID":"profiles","value":value}), + &permission + ) + .is_err()); + } + assert!(validate( + "lithe_build_configure", + &json!({"instanceID":"fixture@.","optionID":"profiles"}), + &permission + ) + .is_err()); + } + #[test] fn output_cursor_detects_clear_and_truncation_and_preserves_unicode() { let first = output_page(json!({"output":"你好\n"}), None); let cursor = first["nextCursor"].as_str().unwrap(); diff --git a/rust/lithe-ide-host/src/lib.rs b/rust/lithe-ide-host/src/lib.rs index 5947209ff8..cbb3379f65 100644 --- a/rust/lithe-ide-host/src/lib.rs +++ b/rust/lithe-ide-host/src/lib.rs @@ -71,6 +71,8 @@ struct Broker { enabled: AtomicBool, token: String, permissions: Permissions, + /// The host's declared tool selection; `None` serves the whole catalog. + tool_exposure: Option>, pending: Mutex>, last_poll: Mutex, dispatcher: Mutex>, @@ -135,6 +137,9 @@ fn runtime() -> Result<&'static tokio::runtime::Runtime, String> { pub struct Connection { pub endpoint: String, pub token: String, + /// The tools the helper lists; absent when the host declared no selection. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_exposure: Option>, } fn private_file(path: &std::path::Path, truncate: bool) -> std::io::Result { @@ -159,6 +164,10 @@ struct Open { workspace_key: String, helper_path: PathBuf, permissions: Permissions, + /// Optional so a host that has not declared its native workflows keeps the + /// whole catalog; a declared selection is checked before anything opens. + #[serde(default)] + tool_exposure: Option>, #[serde(default)] owner_id: String, } @@ -166,6 +175,7 @@ struct Open { fn open(args: Value) -> Result { use sha2::{Digest, Sha256}; let args: Open = serde_json::from_value(args).map_err(|e| e.to_string())?; + catalog::served_tools(args.tool_exposure.as_deref())?; if !args.directory.is_absolute() || !args.helper_path.is_absolute() || args.workspace_key.is_empty() @@ -199,11 +209,13 @@ fn open(args: Value) -> Result { let descriptor = Connection { endpoint, token: token.clone(), + tool_exposure: args.tool_exposure.clone(), }; let broker = Arc::new(Broker { enabled: AtomicBool::new(true), token, permissions: args.permissions, + tool_exposure: args.tool_exposure, pending: Mutex::new(BTreeMap::new()), last_poll: Mutex::new(Instant::now()), dispatcher: Mutex::new(None), @@ -351,7 +363,12 @@ async fn call( )), ); } - if let Err(error) = catalog::validate(&request.name, &request.arguments, &broker.permissions) { + if let Err(error) = catalog::validate_served( + &request.name, + &request.arguments, + &broker.permissions, + broker.tool_exposure.as_deref(), + ) { return response(error); } let (dispatcher, receiver, id) = { @@ -436,28 +453,45 @@ pub fn execute(action: &str, args: Value) -> Result { return open(args); } if action == "catalog" { - return Ok(json!({"version":1,"tools":catalog::tools()})); + let tools = if let Some(value) = args.get("toolExposure") { + let exposure: Vec = + serde_json::from_value(value.clone()).map_err(|error| error.to_string())?; + catalog::exposed_tools(&exposure)? + } else { + catalog::tools() + }; + return Ok(json!({"version":1,"tools":tools})); } if action == "validate" { let permissions: Permissions = serde_json::from_value(args["permissions"].clone()).map_err(|e| e.to_string())?; - return Ok( - match catalog::validate( + let validation = if let Some(value) = args.get("toolExposure") { + let exposure: Vec = + serde_json::from_value(value.clone()).map_err(|error| error.to_string())?; + catalog::validate_exposed( args["name"].as_str().unwrap_or(""), &args["arguments"], &permissions, - ) { - Ok(()) => { - let name = args["name"].as_str().unwrap_or(""); - let mutation = name != "lithe_operation_stop" - && catalog::tools().iter().any(|tool| { - tool["name"] == name && tool["annotations"]["readOnlyHint"] == false - }); - json!({"ok":true,"mutation":mutation}) - } - Err(e) => e, - }, - ); + &exposure, + ) + } else { + catalog::validate( + args["name"].as_str().unwrap_or(""), + &args["arguments"], + &permissions, + ) + }; + return Ok(match validation { + Ok(()) => { + let name = args["name"].as_str().unwrap_or(""); + let mutation = name != "lithe_operation_stop" + && catalog::tools().iter().any(|tool| { + tool["name"] == name && tool["annotations"]["readOnlyHint"] == false + }); + json!({"ok":true,"mutation":mutation}) + } + Err(e) => e, + }); } let id = args["hostID"].as_str().ok_or("Missing hostID")?; let mut hosts = hosts().lock().map_err(|_| "IDE host unavailable")?; @@ -534,7 +568,7 @@ mod tests { root: root.clone(), id: String::new(), }; - let args = json!({"directory":root.join("app-data/mcp"),"workspaceKey":"fixture-project","helperPath":helper,"permissions":{"configure":false,"execute":false},"ownerId":format!("fixture-owner-{}", Uuid::new_v4())}); + let args = json!({"directory":root.join("app-data/mcp"),"workspaceKey":"fixture-project","helperPath":helper,"toolExposure":catalog::tools().iter().map(|t| json!({"name": t["name"]})).collect::>(),"permissions":{"configure":false,"execute":false},"ownerId":format!("fixture-owner-{}", Uuid::new_v4())}); let owner = args["ownerId"].clone(); let opened = open(args.clone()).unwrap(); fixture.id = opened["hostID"].as_str().unwrap().into(); @@ -557,7 +591,7 @@ mod tests { let denied: Value = client .post(&connection.endpoint) .bearer_auth(&connection.token) - .json(&json!({"name":"lithe_maven_execute","arguments":{"goals":["verify"]}})) + .json(&json!({"name":"lithe_build_execute","arguments":{"instanceID":"maven@.","taskID":"verify"}})) .send() .await .unwrap() @@ -621,6 +655,13 @@ mod tests { ); } fn push_fixture() -> (SessionFixture, Arc, HeaderMap) { + push_fixture_exposed(json!(catalog::tools() + .iter() + .map(|t| json!({"name": t["name"]})) + .collect::>())) + } + + fn push_fixture_exposed(exposure: Value) -> (SessionFixture, Arc, HeaderMap) { let root = std::env::temp_dir().join(format!("lithe-ide-push-{}", Uuid::new_v4())); std::fs::create_dir_all(&root).unwrap(); let helper = root.join("helper"); @@ -630,7 +671,7 @@ mod tests { id: String::new(), }; let opened = open( - json!({"directory":root.join("data"),"workspaceKey":"fixture", "helperPath":helper, + json!({"directory":root.join("data"),"workspaceKey":"fixture", "helperPath":helper,"toolExposure":exposure, "permissions":{"configure":true,"execute":true},"ownerId":"fixture-owner"}), ) .unwrap(); @@ -644,6 +685,87 @@ mod tests { (fixture, broker, headers) } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn an_authenticated_client_cannot_dispatch_unadvertised_native_workflows_or_fields() { + let exposure: Value = serde_json::from_str(include_str!( + "../../../shared/fixtures/ide-api/host-tool-exposure.json" + )) + .unwrap(); + let (_fixture, broker, headers) = push_fixture_exposed(exposure["macos"].clone()); + for (name, arguments, code) in [ + ( + "lithe_maven_execute", + json!({"goals":["verify"]}), + "NOT_SUPPORTED", + ), + ( + "lithe_environment_configure", + json!({"settingsPath":"/fixture/settings.xml"}), + "INVALID_ARGUMENTS", + ), + ] { + let (_, result) = tokio::time::timeout( + Duration::from_secs(2), + call( + State(broker.clone()), + headers.clone(), + Json(Call { + name: name.into(), + arguments, + }), + ), + ) + .await + .expect("unsupported workflows must fail before queueing"); + assert_eq!(result.0["error"]["code"], code); + assert!(broker.pending.lock().unwrap().is_empty()); + } + } + + #[test] + fn a_host_without_a_declaration_opens_and_serves_the_whole_catalog() { + let root = std::env::temp_dir().join(format!("lithe-ide-undeclared-{}", Uuid::new_v4())); + std::fs::create_dir_all(&root).unwrap(); + let helper = root.join("helper"); + std::fs::write(&helper, b"read-only").unwrap(); + let mut fixture = SessionFixture { + root: root.clone(), + id: String::new(), + }; + let opened = open( + json!({"directory":root.join("data"),"workspaceKey":"fixture", + "helperPath":helper,"permissions":{"configure":true,"execute":true}, + "ownerId":"fixture-owner"}), + ) + .expect("a host that declares no selection still opens"); + fixture.id = opened["hostID"].as_str().unwrap().into(); + let (broker, connection) = { + let hosts = hosts().lock().unwrap(); + let host = &hosts[&fixture.id]; + (host.broker.clone(), host.connection.clone()) + }; + assert!(broker.tool_exposure.is_none()); + let descriptor: Value = + serde_json::from_slice(&std::fs::read(connection).unwrap()).unwrap(); + assert!(descriptor.get("toolExposure").is_none()); + let served: Connection = serde_json::from_value(descriptor).unwrap(); + assert_eq!( + catalog::served_tools(served.tool_exposure.as_deref()).unwrap(), + catalog::tools() + ); + let permissions = catalog::Permissions { + configure: true, + execute: true, + }; + assert!(catalog::validate_served( + "lithe_maven_execute", + &json!({"goals":["verify"]}), + &permissions, + None + ) + .is_ok()); + } + async fn notification(rx: &mut tokio::sync::mpsc::UnboundedReceiver) -> Option { // test-stability: allow(rust-unbounded-receive) reason: Tokio's async receiver has no recv_timeout; the enclosing two-second timeout bounds this await without blocking the executor. tokio::time::timeout(Duration::from_secs(2), rx.recv()) diff --git a/scripts/ide-mcp/test-ide-mcp.mjs b/scripts/ide-mcp/test-ide-mcp.mjs index bc6194618d..9b60292149 100644 --- a/scripts/ide-mcp/test-ide-mcp.mjs +++ b/scripts/ide-mcp/test-ide-mcp.mjs @@ -11,6 +11,7 @@ import { fileURLToPath } from "node:url"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const executable = process.env.LITHE_MCP_TEST_EXECUTABLE || path.join(root,"rust/target/debug", process.platform === "win32" ? "lithe-mcp.exe" : "lithe-mcp"); +const exposure = JSON.parse(await readFile(path.join(root,"shared/fixtures/ide-api/host-tool-exposure.json"),"utf8")); const fixture = JSON.parse(await readFile(path.join(root,"shared/fixtures/ide-api/mcp-protocol.json"),"utf8")); for (const mode of fixture.modes) { test(`stdio MCP ${mode}: discovery, calls and tool failures`, {timeout:15000}, async t => { @@ -29,7 +30,7 @@ test(`stdio MCP ${mode}: discovery, calls and tool failures`, {timeout:15000}, a await new Promise((resolve,reject)=>{server.once("error",reject);server.listen(0,"127.0.0.1",resolve);}); t.after(()=>{server.closeAllConnections();server.close();}); const connection = path.join(directory,"connection.json"); - await writeFile(connection,JSON.stringify({endpoint:`http://127.0.0.1:${server.address().port}/call`,token:"fixture-private-token"}),{mode:0o600}); + await writeFile(connection,JSON.stringify({endpoint:`http://127.0.0.1:${server.address().port}/call`,token:"fixture-private-token", toolExposure: [...exposure.macos, ...exposure.windows.filter(tool => !exposure.macos.some(item => item.name === tool.name))]}),{mode:0o600}); const child = spawn(executable,["--connection",connection],{stdio:["pipe","pipe","pipe"]}); const pending = new Map(); let stderr = ""; @@ -79,13 +80,33 @@ test(`stdio MCP ${mode}: discovery, calls and tool failures`, {timeout:15000}, a assert.equal(listed.result.ttlMs,fixture.cachePolicy.ttlMs); assert.equal(listed.result.cacheScope,fixture.cachePolicy.cacheScope); assert.equal(listed.result.tools.length,fixture.toolCount); + for (const name of fixture.providerTools) { + assert.ok(listed.result.tools.some(tool=>tool.name===name), `Missing public Build tool: ${name}`); + } assert.ok(listed.result.tools.some(tool=>tool.name==="lithe_environment_configure")); assert.ok(listed.result.tools.some(tool=>tool.name==="lithe_operation_output")); assert.ok(!listed.result.tools.some(tool=>/write_file|shell/.test(tool.name))); + // The helper re-reads this grant's current capability selection; it never + // advertises another platform's retired tools or argument fields. + const descriptor = JSON.parse(await readFile(connection, "utf8")); + descriptor.toolExposure = exposure.macos; + await writeFile(connection, JSON.stringify(descriptor), {mode:0o600}); + const selected = await send("tools/list", params); + assert.equal(selected.result.tools.length, exposure.macos.length); + assert.ok(!selected.result.tools.some(tool => tool.name.startsWith("lithe_maven_"))); + const environment = selected.result.tools.find(tool => tool.name === "lithe_environment_configure"); + assert.deepEqual(Object.keys(environment.inputSchema.properties).sort(), ["javaHomePath", "mavenExecutablePath", "mavenJavaHomePath"].sort()); + descriptor.toolExposure = exposure.windows; + await writeFile(connection, JSON.stringify(descriptor), {mode:0o600}); + const windows = await send("tools/list", params); + assert.deepEqual(windows.result.tools.map(tool => tool.name).sort(), exposure.windows.map(tool => tool.name).sort()); + assert.ok(windows.result.tools.some(tool => tool.name === "lithe_build_execute")); + assert.ok(windows.result.tools.some(tool => tool.name === "lithe_maven_execute")); + assert.ok(windows.result.tools.some(tool => tool.name === "lithe_build_configure")); const inspected = await send("tools/call",{...params,name:"lithe_project_inspect",arguments:{}}); assert.equal(inspected.result.structuredContent.workspaceID,"fixture-project"); assert.notEqual(inspected.result.isError,true,"a nullable domain error field is a successful inspection"); - const denied = await send("tools/call",{...params,name:"lithe_maven_execute",arguments:{goals:["verify"]}}); + const denied = await send("tools/call",{...params,name:"lithe_build_execute",arguments:{instanceID:"fixture",taskID:"build"}}); assert.equal(denied.result.isError,true); assert.equal(denied.result.structuredContent.error.code,"PERMISSION_DENIED"); assert.equal(requests.length,2); diff --git a/scripts/plugins/official-plugin-releases.mjs b/scripts/plugins/official-plugin-releases.mjs index ad8a06a8f5..424b7b8c1d 100644 --- a/scripts/plugins/official-plugin-releases.mjs +++ b/scripts/plugins/official-plugin-releases.mjs @@ -48,7 +48,7 @@ export function releaseFor(plugin, readManifest = readPackageManifest) { const manifest = readManifest(plugin.packageDirectory); if (manifest.id !== plugin.id) throw new Error("Plugin manifest ID does not match its release configuration"); if (!versionPattern.test(manifest.version)) throw new Error(`${plugin.id} version must be major.minor.patch`); - if (![1, 2, 3].includes(manifest.pluginAPIVersion)) throw new Error("Missing or unsupported pluginAPIVersion"); + if (![1, 2, 3, 4].includes(manifest.pluginAPIVersion)) throw new Error("Missing or unsupported pluginAPIVersion"); if (!/^[a-z0-9-]+-v$/.test(plugin.tagPrefix)) throw new Error("Invalid plugin tag prefix"); const repository = plugin.index.repository.replace(/\/$/, ""); if (!/^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) { @@ -80,11 +80,11 @@ export function releaseFor(plugin, readManifest = readPackageManifest) { }); if (!assets.length) throw new Error("A release needs assets"); let requires; - if (manifest.pluginAPIVersion === 3) { + if (manifest.pluginAPIVersion >= 3) { if (!manifest.requires || typeof manifest.requires !== "object" || Array.isArray(manifest.requires) || !Object.keys(manifest.requires).length || Object.entries(manifest.requires).some(([id, v]) => !/^[a-z][A-Za-z0-9]*$/.test(id) || !Number.isSafeInteger(v) || v < 1)) { - throw new Error("API 3 requires the manifest's capability versions"); + throw new Error("API 3+ requires the manifest's capability versions"); } requires = Object.fromEntries(Object.entries(manifest.requires).sort(([a], [b]) => a.localeCompare(b))); } diff --git a/scripts/plugins/official-plugin-releases.test.mjs b/scripts/plugins/official-plugin-releases.test.mjs index 666b062ebc..17946d7c0c 100644 --- a/scripts/plugins/official-plugin-releases.test.mjs +++ b/scripts/plugins/official-plugin-releases.test.mjs @@ -39,6 +39,16 @@ test("plans carry the actual API and capability metadata plus every architecture assert.equal(plan([sample], () => false, readManifest).length, 1); }); +test("API 4 adapter packages retain capability requirements in plans and indexes", () => { + const api4 = { ...manifest, pluginAPIVersion: 4, requires: { debugAdapters: 1, languageServers: 1 } }; + const release = releaseFor(sample, () => api4); + assert.equal(release.pluginAPIVersion, 4); + assert.deepEqual(release.requires, api4.requires); + assert.deepEqual(list(empty(), release).plugins[0].versions[0].requires, api4.requires); + assert.throws(() => releaseFor(sample, () => ({ ...api4, requires: undefined }))); + assert.throws(() => releaseFor(sample, () => ({ ...api4, pluginAPIVersion: 5 }))); +}); + test("rejects inconsistent manifest, overlapping platforms, assets and missing capability metadata", () => { assert.throws(() => releaseFor(sample, () => ({ ...manifest, id: "wrong" }))); assert.throws(() => releaseFor(sample, () => ({ ...manifest, version: "1.0" }))); diff --git a/scripts/resources/reuse-worktree-resources.mjs b/scripts/resources/reuse-worktree-resources.mjs index cc41cbd0a5..ba5aaf406e 100644 --- a/scripts/resources/reuse-worktree-resources.mjs +++ b/scripts/resources/reuse-worktree-resources.mjs @@ -81,6 +81,15 @@ async function readRegistry() { // are mutable user state, never download artifacts. Runtime profile import is // owned by the platform startup adapters; this build reuse route always rejects // both original state and migration snapshots and must not gain a validator. + // Build Provider snapshots remain under the excluded per-workspace PluginState + // owner, including after removing the old macOS Maven tree output allocator. + // They are mutable upstream state and must never gain a download validator. + // User-authored Build preferences, machine-local differences and source-identity migration receipts are never reusable. + // Private instance defaults, Windows app-data/build-options/.json + // and runtime-preference migration receipts follow that same refusal route; no validator or copy stage. + // Per-operation Maven report snapshots have no reusable identity and must remain isolated. + // Native configuration probes (including their isolated repository manager), source-identity cache v2 and secret-bearing + // conditional replacements and private provider-written outputs/.json use the same explicit rejection route. // Plugin grammar workers use checkout-built editor assets; decoded grammars, // document tokens and editor action leases are memory-only, never reusable. // Shared logging catalogs and generated API sources also use the refusal route: @@ -414,6 +423,8 @@ async function main() { // Includes mcp-registry-metadata: responses have no copy stage; featured.json // and McpIcons follow Git source, never a downloaded/generated resource validator. // Includes plugin-web-state: per-window/workspace user JSON has no build identity or copy stage. + // Includes build-native-configuration-state: Windows private prepare tokens + // and document-module temporary siblings are operation-owned, never reusable. // Includes remote-server-artifacts: packaging rebuilds lithe-remote's // server-only binary from this worktree; no validator or copy stage applies. // Includes remote-connection-state: SSH settings, keychain secrets and placeholder roots are user-owned. @@ -432,6 +443,15 @@ async function main() { // Per-launch Codex helpers also own memory-only context replay tickets and // loopback capabilities; they stay on this exclusion route, never a copy stage. // Agent edit-and-paste state is user-owned runtime data, without a copy stage. + // editor-theme-assets rebuild palettes and license notices together; no build stamp permits reuse. + // official-plugin-packages also excludes Windows Build Provider workspace + // and Registry stdio debug-adapter state under platform app-data. + // Workspace hashes never admit a copy stage; shared language-server state + // is owned by the server, never copied or deleted by a Debug session. + // Operation-owned toolchain backends keep mutable workspace state beneath + // the same plugin owner; neither their state nor query facts may be reused. + // consumer-tool-preferences are user-owned Run data, including symbolic + // inheritance. No validator or copy phase can turn them into build input. // editor-theme-assets rebuild palettes, bundled Make/CMake/Groovy grammars and notices together. // editor-keymap-provider-build is worktree-local Cargo state and a sealed plugin, never a reusable cache. // Git-owned grammar inputs and memory-only tokenizer workers never enter a copy validator. diff --git a/scripts/resources/test-reuse-worktree-resources.mjs b/scripts/resources/test-reuse-worktree-resources.mjs index 906441d263..747bba7e0a 100644 --- a/scripts/resources/test-reuse-worktree-resources.mjs +++ b/scripts/resources/test-reuse-worktree-resources.mjs @@ -362,6 +362,46 @@ try { assert.notEqual(refused.status, 0); assert.match(diagnostics(refused), /jdt-maven-settings.*cannot be reused/); }); + await test("team and local Build preferences cannot be copied as worktree resources", { timeout: 15000 }, async () => { + const registry = JSON.parse(await fs.readFile(path.join(scriptDirectory, "worktree-resources.json"), "utf8")); + const resource = registry.excludedResources.find(item => item.id === "build-project-preferences"); + assert.equal(resource.reusable, false); + assert.ok(resource.locations.includes(".lithe/build/options.json")); + assert.ok(resource.locations.includes("//build-options/.json")); + assert.ok(resource.locations.includes("/BuildSystems.Migrations.Maven.")); + assert.ok(resource.locations.includes("/BuildSystems.Options...InstanceDefaults")); + assert.ok(resource.locations.includes("/BuildSystems.Migrations.Maven..Runtime")); + const refused = reuse(["--resource", resource.id]); + assert.notEqual(refused.status, 0); + assert.match(diagnostics(refused), /build-project-preferences.*cannot be reused/); + }); + await test("Maven operation reports cannot be copied between worktrees", { timeout: 15000 }, async () => { + const registry = JSON.parse(await fs.readFile(path.join(scriptDirectory, "worktree-resources.json"), "utf8")); + const resource = registry.excludedResources.find(item => item.id === "maven-operation-report-snapshots"); + assert.equal(resource.reusable, false); + assert.ok(resource.locations.includes("/execution-*/index.json")); + const refused = reuse(["--resource", resource.id]); + assert.notEqual(refused.status, 0); + assert.match(diagnostics(refused), /maven-operation-report-snapshots.*cannot be reused/); + }); + await test("native configuration probe and conditional replacement state cannot be reused", { timeout: 15000 }, async () => { + const registry = JSON.parse(await fs.readFile(path.join(scriptDirectory, "worktree-resources.json"), "utf8")); + const resource = registry.excludedResources.find(item => item.id === "build-native-configuration-state"); + assert.equal(resource.reusable, false); + assert.ok(resource.locations.some(location => location.endsWith("configuration-*/configuration.json"))); + assert.ok(resource.locations.some(location => location.endsWith("configuration-*/repository"))); + assert.ok(resource.locations.some(location => location.endsWith("maven-snapshot.json"))); + assert.ok(resource.locations.some(location => location.includes("..lithe-"))); + assert.ok(resource.locations.includes("/.lithe-document--.tmp")); + assert.ok(resource.locations.includes("/native-configuration//")); + assert.ok(resource.locations.includes("/outputs/.json (private native configuration replacement)")); + const listed = run(process.execPath, [reuseScript, "--list"]); + assertSucceeded(listed); + assert.ok(!listed.stdout.includes(resource.id)); + const refused = reuse(["--resource", resource.id]); + assert.notEqual(refused.status, 0); + assert.match(diagnostics(refused), /build-native-configuration-state.*cannot be reused/); + }); await test("plugin web state has no transferable build identity or copy stage", { timeout: 15000 }, async () => { const registry = JSON.parse(await fs.readFile(path.join(scriptDirectory, "worktree-resources.json"), "utf8")); const resource = registry.excludedResources.find(item => item.id === "plugin-web-state"); @@ -386,10 +426,17 @@ try { assert.notEqual(refused.status, 0); assert.match(diagnostics(refused), /official-plugin-packages is isolated.*cannot be reused/); }); - await test("plugin packages and marketplace state stay isolated in each worktree", { timeout: 15000 }, () => { + await test("plugin packages and marketplace state stay isolated in each worktree", { timeout: 15000 }, async () => { const listed = run(process.execPath, [reuseScript, "--list"]); assertSucceeded(listed); - for (const id of ["official-plugin-packages", "plugin-marketplace-state", "plugin-host-runtime-state", "mcp-registry-metadata", "mcp-tool-probe-state", "agent-instruction-library-state", "agent-hook-library-state"]) { + const registry = JSON.parse(await fs.readFile(path.join(scriptDirectory, "worktree-resources.json"), "utf8")); + const pluginState = registry.excludedResources.find(resource => resource.id === "official-plugin-packages"); + assert.ok(pluginState.locations.includes("/Lithe/PluginState//build-systems//workspaces//")); + assert.ok(pluginState.locations.includes("//plugin-state//build-systems///")); + assert.ok(pluginState.locations.includes("//plugin-state//toolchain-queries///")); + assert.ok(pluginState.locations.includes("//plugin-state//debug-adapters///")); + assert.equal(pluginState.reusable, false); + for (const id of ["consumer-tool-preferences", "official-plugin-packages", "plugin-marketplace-state", "plugin-host-runtime-state", "mcp-registry-metadata", "mcp-tool-probe-state", "agent-instruction-library-state", "agent-hook-library-state"]) { assert.ok(!listed.stdout.includes(id)); const refused = reuse(["--resource", id]); assert.notEqual(refused.status, 0); diff --git a/scripts/resources/worktree-resources.json b/scripts/resources/worktree-resources.json index c55dcb331b..7ba99121b2 100644 --- a/scripts/resources/worktree-resources.json +++ b/scripts/resources/worktree-resources.json @@ -185,10 +185,14 @@ "dist/verify///", "dist/Lithe-Java-Support--darwin-.zip", ".artifacts/java-plugin-distribution/", - "/lithe-package-verification.*/published-index.fixture.json" + "/lithe-package-verification.*/published-index.fixture.json", + "/Lithe/PluginState//build-systems//workspaces//", + "//plugin-state//build-systems///", + "//plugin-state//toolchain-queries///", + "//plugin-state//debug-adapters///" ], - "identity": "Assembled plugin packages carry build-time downloads (JDT LS, its extensions and one JDK per target architecture for Java Support) and an optional publisher signature; no reliable worktree identity stamp. Plugin state directories are mutable language-server state of one installation. Independent release archives bind plugin version, darwin architecture, upstream input hashes, API/requires and final publisher signature; host-sealed bundled copies are a different origin, never a reusable package", - "reason": "Assemble each official declarative plugin independently in every worktree and validate it with LitheOfficialPluginVerifier; do not copy or symlink it across worktrees. Java Support is assembled from the reusable jdtls and jdk download caches by Plugins/mac/Official/JavaSupport/assemble-package.sh, so reuse those caches rather than the package. Never copy or share plugin state at any stage. Release verification extracts a separate copy and checks its publisher signature; delete per-run dist/verify and local integration output when no longer needed. Never transfer a sealed-origin exception to user-installed copies. Runtime installation versions stay read-only, preserving app signatures and Sparkle delta baselines. Distribution decisions read the current checkout index or explicit LITHE_OFFICIAL_PLUGIN_INDEX/--marketplace-index build input; synthetic packaging indexes are per-test fixtures, never reusable or publishable source indexes." + "identity": "Assembled plugin packages carry build-time downloads (JDT LS, its extensions and one JDK per target architecture for Java Support) and an optional publisher signature; no reliable worktree identity stamp. Plugin state directories are mutable language-server state of one installation. Independent release archives bind plugin version, darwin architecture, upstream input hashes, API/requires and final publisher signature; host-sealed bundled copies are a different origin, never a reusable package Build Provider model/dependency snapshots and output state are mutable per-plugin/build-system/workspace inputs; they have no version/platform/architecture/toolchain identity stamp.", + "reason": "Assemble each official declarative plugin independently in every worktree and validate it with LitheOfficialPluginVerifier; do not copy or symlink it across worktrees. Java Support is assembled from the reusable jdtls and jdk download caches by Plugins/mac/Official/JavaSupport/assemble-package.sh, so reuse those caches rather than the package. Never copy or share plugin state at any stage. Release verification extracts a separate copy and checks its publisher signature; delete per-run dist/verify and local integration output when no longer needed. Never transfer a sealed-origin exception to user-installed copies. Runtime installation versions stay read-only, preserving app signatures and Sparkle delta baselines. Distribution decisions read the current checkout index or explicit LITHE_OFFICIAL_PLUGIN_INDEX/--marketplace-index build input; synthetic packaging indexes are per-test fixtures, never reusable or publishable source indexes. Maven uses existing Provider state instead of the removed macOS Maven dependency-tree temporary adapter. Never reconstruct a JAR path from a legacy tree. Windows Build Provider startup uses the same platform app-data owner with a separate build-system subtree, never a language-server state directory or package resource. Workspace hashes identify runtime owners, not reusable build artifacts. Native toolchain-provider backends are owned only by one Build preparation, reuse their process within that operation and stop before returning; their exclusive per-plugin/operation/workspace state is mutable, has no build identity and is never copied at any stage; stop owned processes before deleting that state. Registry stdio debugger state is mutable per-plugin/adapter/workspace state with no build identity stamp and no permitted copy stage; language-server hosted adapters retain the existing language-server state owner, and stopping Debug never removes shared server state." }, { "id": "language-tools", @@ -446,6 +450,64 @@ "identity": "User-owned SSH connection settings, remembered secrets, empty placeholder roots of opened remote workspaces, and servers installed on remote hosts; no build identity.", "reason": "Connections and secrets belong to the user's profile and the keychain. Placeholder roots are empty directories that key per-project state for remote folders and hold no files. Remote installs live on the hosts and are verified by SHA-256 before reuse. Daemon socket, lock and log identity includes the session key and complete executable SHA-256, so rebuilding an unchanged version cannot reconnect to older code. None has a copy stage between worktrees, and none is written into the app bundle or installation directory." }, + { + "id": "shared-i18n", + "reusable": false, + "locations": [ + "shared/i18n", + ".artifacts/i18n-windows/", + "macos/Resources/.lproj/Localizable.strings", + "macos/Sources/Lithe/Models/Settings/AppLanguage.generated.swift", + "windows/tauri/src/i18n/shared.generated.ts" + ], + "identity": "Current worktree shared locale manifest, message registry, per-locale translations and all generated native string-table bytes. Generated outputs have no sealed version/platform/architecture/toolchain reuse identity stamp.", + "reason": "Git owns source and checked-in generated outputs. Regenerate and validate them in each target worktree with scripts/i18n/generate-i18n.mjs; no download, cache validator or cross-worktree copy stage is permitted. Packaging copies registered native tables into its own build destination before signing and converts only that copy to binary plist. Runtime only reads bundled tables; it creates no state and changes neither code-signature bytes nor the Sparkle delta baseline." + }, + { + "id": "build-native-configuration-state", + "path": "/Lithe/PluginState//build-systems//workspaces//configuration-*", + "reusable": false, + "locations": [ + "/Lithe/PluginState//build-systems//workspaces//configuration-*/configuration.json", + "/Lithe/PluginState//build-systems//workspaces//configuration-*/repository", + "/Lithe/PluginState//build-systems//workspaces//run-*/configuration.json", + "/Lithe/PluginState//build-systems//workspaces//maven-snapshot.json", + "/..lithe-", + "/.lithe-document--.tmp", + "/native-configuration//", + "/outputs/.json (private native configuration replacement)", + ".artifacts/test-stability/native-configuration-ui/*.png" + ], + "identity": "Native configuration source revisions and effective values belong to the selected upstream session, source paths and exact native bytes; they are mutable runtime state without a version/platform/architecture/toolchain build identity stamp. Native UI evidence additionally depends on source revision, fixture, theme and viewport and is regenerated per test run.", + "reason": "The selected provider owns bounded configuration probes and removes their directories at operation end. Native document bytes and credentials stay in provider/native filesystem staging and never cross RPC; the existing outputs/.json file is privately created by the host, written by the provider without CREATE, hash-checked before guarded publication, and removed on success, failure, cancellation or owner revocation; guarded adjacent temporary replacements preserve metadata and are removed on success or retained only for explicit error recovery. Maven cache v2 records actual upstream-selected source URI/hash identities and invalidates them after external edits. Never copy, symlink or publish this state between worktrees at any stage; installation resources remain read-only and Sparkle delta inputs are unchanged. Settings-only probes snapshot the upstream-populated execution request before repository-session creation and redirect only their own repository manager to configuration-*/repository, then abort before project import. The private repository is removed with that probe; selected user/native repositories must remain uncreated and unchanged." + }, + { + "id": "build-project-preferences", + "path": ".lithe/build/options.json", + "reusable": false, + "locations": [ + ".lithe/build/options.json", + "/BuildSystems.Options..", + "/BuildSystems.Options...Instances", + "/BuildSystems.Migrations.Maven.", + "/BuildSystems.Options...InstanceDefaults", + "/BuildSystems.Migrations.Maven..Runtime", + "//build-options/.json" + ], + "identity": "User-authored team Build preferences and machine-local differences scoped to one workspace; no build, platform, architecture or toolchain artifact identity stamp. A bounded cutover receipt records source identities until guarded data migration completes.", + "reason": "Team values are ordinary workspace-owned source; local differences belong only to the platform preference store. Neither is a download, generated build input or portable cache. Passive reads never create or rewrite the file; an explicit edit or guarded activation migration preserves unrelated systems and checks exact source bytes. No copy, hardlink or symlink reuse stage is permitted. App/plugin resources and signing/Sparkle baselines remain read-only. Migration receipts are private recovery state, removed after all verified source removals; an external source change after partial publication blocks automatic cleanup. They contain no reusable generated resource and must never leave their owning workspace/profile. Private workspace defaults and the runtime-preference source receipt stay in the same owning platform profile; never transfer them into team preferences or worktree resource reuse. Windows local overrides use the platform app-data adapter and canonical workspace identity. The native document writer owns bounded adjacent replacement files; only explicit UI/Agent saves publish after checking team/local revisions and window cancellation. Local preferences and empty-selection markers are never reusable inputs or installation resources." + }, + { + "id": "maven-operation-report-snapshots", + "reusable": false, + "locations": [ + "/execution-*/index.json", + "/execution-*/report-.xml", + "/lithe-maven-execution-*/" + ], + "identity": "One plugin/workspace execution owns bounded fresh upstream reports, bound to the exact Maven operation and source hashes; no reusable version/platform/architecture/toolchain identity stamp.", + "reason": "The selected Maven core extension snapshots reports only from configured Surefire/Failsafe mojos, including checked failures; the backend validates captured hashes, parses through Apache Surefire and removes its private operation directory in try-with-resources. Reports and state never enter an installation directory. User project report outputs stay owned by the upstream engine; no copy, symlink or hardlink reuse stage is allowed for operation snapshots. Cancellation terminates Maven before normal cleanup; abandoned state belongs to the existing plugin workspace state retention lifecycle. Package bytes, signing and Sparkle baselines remain unchanged." + }, { "id": "remote-workspace-session-state", "reusable": false, @@ -465,19 +527,6 @@ "identity": "Mutable per-operation Git/build scratch, private plugin snapshots, connection-owned upload staging, provider workspace state and per-connection runtime, shell, executable and search snapshots. They have no reusable version/platform/architecture/toolchain build identity.", "reason": "The existing workspace engines own these records while generic host filesystem/process/PTY adapters perform I/O. Git/build scratch lives on its host and is never mirrored into the local placeholder. Upload staging expires or is removed on abort/disconnect; complete plugin runs are allocated by fs.createRunDirectory and released by session-owned identity after process destruction. Nonpersistent disconnect, shutdown and idle expiry remove published snapshots after worker drain; persistent disconnect retains them. Forced server termination recovery remains unsupported, and old unmanaged snapshots are never adopted or age-scanned. Provider state persists on the host. Native marketplace extraction is deleted after transfer or failure. Package trust/hash/architecture checks authorize one execution and do not establish reusable build identity. Host discovery is memory-only and invalidated on explicit refresh or connection replacement. All reuse stages reject these entries before validation or copying. Runtime writes do not reach installed bundles, code signatures or Sparkle delta baselines." }, - { - "id": "shared-i18n", - "reusable": false, - "locations": [ - "shared/i18n", - ".artifacts/i18n-windows/", - "macos/Resources/.lproj/Localizable.strings", - "macos/Sources/Lithe/Models/Settings/AppLanguage.generated.swift", - "windows/tauri/src/i18n/shared.generated.ts" - ], - "identity": "Current worktree shared locale manifest, message registry, per-locale translations and all generated native string-table bytes. Generated outputs have no sealed version/platform/architecture/toolchain reuse identity stamp.", - "reason": "Git owns source and checked-in generated outputs. Regenerate and validate them in each target worktree with scripts/i18n/generate-i18n.mjs; no download, cache validator or cross-worktree copy stage is permitted. Packaging copies registered native tables into its own build destination before signing and converts only that copy to binary plist. Runtime only reads bundled tables; it creates no state and changes neither code-signature bytes nor the Sparkle delta baseline." - }, { "id": "editor-theme-assets", "reusable": false, @@ -512,6 +561,17 @@ "identity": "Worktree-owned frontend perf bundles, local baselines and CPU profiles. They follow that worktree's sources and machine and carry no identity stamp.", "reason": "Never copy or share this state at any worktree reuse stage. A bundle or baseline from another worktree would compare different sources. run-windows-perf.mjs rebuilds the bundle in its own worktree; nothing here reaches an app bundle, install directory or Sparkle delta baseline." }, + { + "id": "consumer-tool-preferences", + "path": ".lithe/run/local.json", + "reusable": false, + "locations": [ + ".lithe/run/local.json (toolchain.preferences)", + "/.lithe/run/local.json adjacent atomic replacement files" + ], + "identity": "Machine-local user choices and symbolic workspace/consumer inheritance, not a generated resource; no version/platform/architecture/toolchain build identity stamp.", + "reason": "The existing Run document owns these preferences until explicit reset or workspace removal. Native operations capture and recheck the source after discovery; only guarded explicit Run/settings saves may replace it. Never copy, share, symlink or hardlink this state between worktrees at any validation or publication stage. App/plugin resources and code signatures/Sparkle delta remain unchanged. References are re-resolved at use time and do not become reusable execution evidence. The macOS Build editor keeps inert drafts in memory, preserves existing file metadata on explicit conditional publication and drains native writes on workspace shutdown; opening, cancelling and resetting an absent value create no resource." + }, { "id": "shared-logging", "reusable": false, diff --git a/scripts/rust-core/verify-rust-core-swift-bridge.sh b/scripts/rust-core/verify-rust-core-swift-bridge.sh index 555d0778f3..c5ed44d8fb 100755 --- a/scripts/rust-core/verify-rust-core-swift-bridge.sh +++ b/scripts/rust-core/verify-rust-core-swift-bridge.sh @@ -72,6 +72,6 @@ LITHE_RUN_GIT_EXECUTION_INTEGRATION=1 \ ./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh \ --suite-timeout-seconds 900 \ --report .artifacts/test-stability/git-console-bridge.json \ - -- --skip-build --filter 'MacGitHubGitOperationsTests|GitConsolePresentationBridgeTests|GitConsoleLifecycleBridgeTests|MavenDependencyTreeBridgeTests' + -- --skip-build --filter 'MacGitHubGitOperationsTests|GitConsolePresentationBridgeTests|GitConsoleLifecycleBridgeTests|RustBuildSystemOperationsTests' print "Rust Core Swift bridge verification passed: C ABI, linked symbols, and Git execution integration" diff --git a/shared/contracts/application-boundary.md b/shared/contracts/application-boundary.md index f619ef58b8..d559337986 100644 --- a/shared/contracts/application-boundary.md +++ b/shared/contracts/application-boundary.md @@ -173,7 +173,7 @@ These presentation actions do not add Agent Host commands or persist chat histor | GitHub | remote parsing, trusted request plans, normalized branch comparisons and pull requests/reviews/comments, deterministic ordering, and stable errors | OAuth configuration, HTTPS, browser opening, and operating-system credential storage | | [AI commit messages](ai-commit.md) | provider configuration parsing, commit rules, bounded diff evidence, request plans, and response text | local configuration discovery, credentials, HTTP, cancellation, and draft UI | | Agent conversation (ACP) | supported-agent catalog, Node.js/npm detection, adapter install with the user's npm and numbers-only live download progress, CLI provenance and owner-preserving updates, per-agent API-key and model delivery over ACP stdio (Codex gateway authentication; Claude public session options), explicit Codex subscription authentication via the installed CLI, bounded official App Server quota reads with account checks, ACP v1 connection per workspace and agent, agent-owned session history (list/load), session config options, user-selected file references as ACP resource links, streamed tool evidence, permission decisions, acknowledged cancellation with bounded recovery, and process-tree lifecycle in the shared Rust host | provider and agent settings, API-key storage, credential-independent local default-model reading through AI configuration ports, data directory, workspace selection, module enablement, conversation UI and compact subscription quota presentation, workspace/Agent-scoped local history annotations (favorites, title overrides and recoverable hidden rows), and user-selected Markdown export destinations | -| [IDE capability API / MCP](ide-api/v1.md) | tool catalog, permission/argument validation, stable execution IDs, bounded output cursors and errors | explicit project authorization, live environment/Maven/Run application actions, helper discovery and writable connection storage | +| [IDE capability API / MCP](ide-api/v1.md) | canonical tool schemas, narrowing host selection, permission/argument validation, stable execution IDs, bounded output cursors and errors | implemented workflow inventory, explicit project authorization, live environment/Build/Run application actions, helper discovery and writable connection storage | | Runtime | Java/Maven requirements, normalized candidates, and effective toolchain references | JDK/Maven probing and executable paths | | Language tooling | provider catalog, local fallback results, complete LSP process/session runtime, capabilities, diagnostics, UTF-16 edits, and normalized feature results | executable/environment discovery and UI provider routing | | Java/Maven/Spring | deterministic Maven-root selection, project structure, modules and profiles, bounded dependency-tree normalization; compiler diagnostic parsing; Java source structure, symbols, code vision, run-configuration detection, Spring configuration/bean/endpoint indexing, and JDTLS/Java Debug adapter policy | JDK/Maven discovery, local dependency-repository selection, Java/Maven child processes, and sockets | @@ -897,6 +897,34 @@ launch and the owned native process ID afterward. Missing or stale references fa A Provider application's native output remains visible even without generated Run configurations. See `../fixtures/run-configuration/build-provider.json`. +Windows native preparation returns `resolvedExecutables`, binding directly +launchable definition IDs to this operation's checked native executable paths. Provider Run accepts a +toolchain reference only when that ID is present; it hands the bound path and +selected environment to the native process resolver. Saved legacy runtime paths +and language-import fields cannot replace a missing binding. Conflicting roots +for one ID fail instead of selecting by iteration order. The host-only JSON shape +is exercised by Rust serialization and the TypeScript Run workflow using +`../fixtures/build/native-tool-context.json`; it is not a persisted launch permit. + +Automatic Provider run discovery uses the current public declaration/target pairs, +independently of language imports. The host projects generic selectors and upstream +labels; Core owns stable Run IDs, layering and snapshot freshness. Neither layer +interprets framework IDs or reconstructs commands/toolchains from those selectors. +The existing Java ownership projection remains separate during the remaining migration. + +Portable Provider user choices live in Run v2 `extensions.build-run`, separately +from `extensions.build` selector identity. Hosts preserve exact runtime argument +arrays, opaque entrypoints and declared build-option values; the current Provider +model/declaration validates them during preparation. This reuses protocol 10 rather +than adding tool-specific persistence fields. Migrating old domain formats remains +an explicit temporary adapter responsibility, with conflict-protected storage. + +Complete persisted Build references retain opaque Run IDs across format migrations; +the prefix does not confer authority. Core deduplicates only the generated counterpart +of the effective saved selector and validates it before use. Prepared team/local Run +layers can be resolved without filesystem publication; native adapters own cancellation, +source checks, metadata-preserving conditional writes, rollback and readback. + ### Project Java runtime and language-server runtime Platforms supply the selected project JDK with `javaRuntimes[].isDefault`, reusing diff --git a/shared/contracts/build-local-options-v1.schema.json b/shared/contracts/build-local-options-v1.schema.json new file mode 100644 index 0000000000..4b909c864b --- /dev/null +++ b/shared/contracts/build-local-options-v1.schema.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://lithe.dev/contracts/build-local-options-v1.schema.json", + "title": "Lithe machine-local Build preferences", + "type": "object", + "additionalProperties": false, + "required": ["version", "document"], + "properties": { + "version": {"const": 1}, + "document": {"$ref": "build-project-options-v1.schema.json"}, + "emptySelections": { + "type": "object", + "maxProperties": 32, + "propertyNames": {"pattern": "^[A-Za-z0-9-]{1,64}$"}, + "additionalProperties": { + "type": "object", + "maxProperties": 256, + "propertyNames": { + "minLength": 1, + "maxLength": 1024, + "pattern": "^(?:\\.|(?!/)(?!.*//)(?!.*/$)(?!.*(?:^|/)\\.{1,2}(?:/|$))[^\\\\:\\x00-\\x1f\\x7f]+)$" + }, + "additionalProperties": { + "type": "array", + "maxItems": 256, + "uniqueItems": true, + "items": {"type": "string", "pattern": "^[A-Za-z0-9_.-]{1,128}$"} + } + } + } + } +} diff --git a/shared/contracts/build-project-options-v1.schema.json b/shared/contracts/build-project-options-v1.schema.json new file mode 100644 index 0000000000..43827ace12 --- /dev/null +++ b/shared/contracts/build-project-options-v1.schema.json @@ -0,0 +1,82 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://lithe.dev/contracts/build-project-options-v1.schema.json", + "title": "Lithe team Build options", + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "systems" + ], + "properties": { + "version": { + "const": 1 + }, + "systems": { + "type": "object", + "maxProperties": 32, + "propertyNames": { + "pattern": "^[A-Za-z0-9-]{1,64}$" + }, + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "properties": { + "options": { + "$ref": "#/$defs/values" + }, + "instanceDefaults": { + "$ref": "#/$defs/values" + }, + "instances": { + "type": "object", + "maxProperties": 256, + "propertyNames": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "pattern": "^(?:\\.|(?!/)(?!.*//)(?!.*/$)(?!.*(?:^|/)\\.{1,2}(?:/|$))[^\\\\:\\x00-\\x1f\\x7f]+)$" + }, + "additionalProperties": { + "$ref": "#/$defs/values" + } + }, + "savedChoices": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "$defs": { + "values": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "maxProperties": 256, + "propertyNames": { + "pattern": "^[A-Za-z0-9_.-]{1,128}$" + } + } + } +} diff --git a/shared/contracts/ide-api/v1.md b/shared/contracts/ide-api/v1.md index f4168eaf0d..4164e3ae5c 100644 --- a/shared/contracts/ide-api/v1.md +++ b/shared/contracts/ide-api/v1.md @@ -1,6 +1,6 @@ # IDE capability API v1 -Lithe exposes the current project's environment, Maven and Run workflows to +Lithe exposes the current project's environment, plugin Build and Run workflows to plugins and MCP clients. File editing and arbitrary shell execution are outside this contract. Both platforms reuse their existing application actions, including project preparation, saved toolchain selection and native execution ownership. @@ -13,12 +13,27 @@ local broker and private connection descriptor; the `lithe-mcp` binary uses the official `rmcp` SDK for stdio MCP. HTTP here is an internal authenticated loopback bridge, not a public Streamable HTTP MCP endpoint. +Each native product selects its implemented workflows from that catalog. +`open.arguments.toolExposure` is an array of `{name, arguments?: string[]}`; the +same declaration is stored as `toolExposure` in the private connection descriptor. +A host that omits it serves the whole catalog under the catalog's own validation, +and its descriptor has no `toolExposure`. macOS always declares its selection; the +Windows host does not send its inventory yet. +Only known, unique tools and argument names are allowed. Argument selection may +remove optional fields, but cannot remove required fields, change types or widen +permissions. Schemas and ordering remain catalog-owned. The broker rejects tools +outside its declaration before queuing, and `lithe-mcp` resolves that declaration +for `tools/list`. Native plugin calls validate with the same declaration. The +local `catalog`/`validate` utilities also accept `toolExposure`; omitting it inspects +the full reference catalog and does not grant a connection any additional access. +`shared/fixtures/ide-api/host-tool-exposure.json` records both product inventories. + The user enables one opened project in Settings > MCP Configuration. Read access includes logs, which can contain sensitive project information. Configure and execute permissions are independent, disabled by default, and fixed until the connection is disabled. No tool argument grants permission. Closing the project, window, or IDE revokes the connection. Already accepted native executions remain -owned by the normal IDE Run/Maven workflow; disabling MCP does not kill work the +owned by the normal IDE Run/Build workflow; disabling MCP does not kill work the user may still be observing in the IDE. A closed project uses its existing execution cleanup. Re-enable access after reopening; copied configuration keeps the same project descriptor path. A second window cannot replace an enabled @@ -78,8 +93,7 @@ legacy and modern clients are verified in separate stdio sessions using ## Capabilities Every call carries `name` and an object `arguments`. Unknown keys, missing required -arguments, invalid types, traversal module paths, and non-goal Maven command -tokens are rejected before dispatch. See the executable schemas and +arguments, invalid types and unsupported selection fields are rejected before dispatch. See the executable schemas and `shared/fixtures/ide-api/calls.json`. Requests do not accept a workspace root: the connection already owns its workspace. Native toolchain paths are local configuration values, never portable workspace identifiers. @@ -92,16 +106,29 @@ configuration values, never portable workspace identifiers. | `lithe_maven_inspect` | none | read | project/modules, profiles, skip-tests and reload/error state | | `lithe_maven_configure` | optional `profiles`, `skipTests` | configure | saved profiles and reload state | | `lithe_maven_reload` | none | execute | outcome of existing project/Java synchronization | -| `lithe_maven_execute` | `goals`, optional `modulePath` | execute | `operationID`, state and any launch error | +| `lithe_build_inspect` | none | read | detected plugin instances, declarations and current model identities | +| `lithe_build_reload` | `instanceID` | execute | model reload requested; inspect until model state is ready | +| `lithe_build_configure` | `instanceID`, `optionID`, `value` (string, boolean, string list or null) | configure | checked persisted option and reload state; null resets the declared option | +| `lithe_build_execute` | `instanceID`, `taskID`, optional `projectID`, `targetID`, `modelTaskID`, `userArguments` | execute | `operationID` for the same Build execution shown by the IDE | +| `lithe_maven_execute` | `goals`, optional `modulePath` | execute | Windows current Maven execution; unavailable on macOS | | `lithe_run_list` | none | read | project configurations with IDs, names, providers and readiness | | `lithe_run_start` | `configurationID` | execute | `operationID` after launch; failure if launch was refused | | `lithe_run_restart` | `operationID` | execute | replacement execution's `operationID` | -| `lithe_operations_list` | none | read | retained Maven/Run operations and status, without log bodies | +| `lithe_operations_list` | none | read | retained Build/Run operations and status, without log bodies | | `lithe_operation_output` | `operationID`, optional opaque `cursor` | read | state, nullable exitCode, bounded output and cursor | | `lithe_operation_stop` | `operationID` | execute | stop request for that exact execution | -Empty configuration paths restore automatic selection. Maven-only paths require -a loaded Maven project. Configuration writes use the existing platform stores; +Empty configuration paths restore automatic selection. On macOS, +`lithe_environment_configure` exposes only `javaHomePath`, `mavenExecutablePath` +and `mavenJavaHomePath`; environment inspection returns those saved settings. +Maven invocation options use the plugin Build instance, and native XML uses its +explicit configuration workflow. Windows still exposes `settingsPath` and +`localRepositoryPath`, which require a loaded Maven project. +macOS environment tools read the canonical project toolchain without loading Run +or a Maven model. An open unsaved settings draft or an in-progress save returns +`BUSY`; the Agent cannot reset that draft or save unrelated edits. Source read +failures remain failures and never become automatic tool selection. +Configuration writes use the existing platform stores; they are not a transaction across multiple settings documents. An error can follow a partial save, so inspect settings before retrying. Credentials, full launch environment values and command arguments are not included in run configuration @@ -109,11 +136,46 @@ listing. Toolchain discovery uses existing installed tools; it does not install or download a JDK or Maven. Maven execution may download dependencies or execute plugins, and is therefore an execute capability. -Maven goals cover the existing lifecycle buttons (`clean`, `validate`, `compile`, -`test`, `package`, `verify`, `install`, `site`, `deploy`) and named plugin goals. -Profiles, paths and skip-tests are configured through their settings tools rather -than arbitrary flags. A module must exist in the current Maven model. A running -Maven operation is reported as busy rather than implicitly replaced. +On macOS, build execution uses the enabled plugin's detected instance and task +identities. Read them with `lithe_build_inspect`; model project, target and task +selectors require a loaded, current model and are resolved again for each call. +A model task also requires its declaring template and project. Arguments remain +an array of tokens all the way to the Provider; the host does not reinterpret +Maven goals, construct a command string or use the old Maven execution service. +`lithe_build_inspect` also returns each option's ID, kind, effective value, source, +choices and allowCustom, plus native-configuration availability. `lithe_build_configure` +validates one value with the same current instance/model used by the native option +editor, rejects invalid/dropped values before persistence, and reads it back. +Unknown options cannot create preferences. Empty multi-select uses the existing +instance marker, null removes the override, and sibling-only selections survive. +This edits invocation options; it does not mutate native XML. Native configuration +has a separate explicit source/draft/save/read-back workflow. Option changes cancel +stale Provider work and invalidate native drafts, models and language import. + +The same effective Build options, configured tool resolver and workspace Provider +serve UI and Agent calls. An active Build execution returns `BUSY`. + +macOS exposes the four plugin Build tools and removes all four Maven-specific +tools from discovery and admission. Windows connects Build inspect, reload, configure and +execute to its existing workspace-owned Provider controller, alongside the four +Maven tools whose current consumers are still being migrated. Detection reads +only admitted declarations and saved options. Product Build capability admission +remains closed; exposing an implemented API does not admit a plugin requiring +unimplemented capabilities. Windows configure shares the explicit native UI save path, persists machine-local workspace overrides, checks team/local source revisions and re-detects after publication. Null restores team/declaration inheritance; it does not edit a tool-native configuration file. +An unexposed call returns `NOT_SUPPORTED` before native execution. The complete +Maven project, settings and Run migration remains pending outside these Agent paths. + +Windows requires a complete, current model before executing a task. Reload uses +the existing model preparation and returns immediately; inspect reports its state. +Project, target and model-task IDs are matched to that model, including project +ownership and the declaring task template, then the same domain rechecks native +tools, inputs and model evidence. A target cannot be supplied with a model task. +Arguments retain their array boundaries. Build output/status/stop use the existing +bounded in-memory operation records, also shown by the Build output panel. Listing +summaries does not copy log bodies; output pagination uses the existing Core cursor. +Stopping an old retained ID never stops another operation. Revoking an MCP grant +prevents new starts; already accepted work remains owned by the product workspace, +whose close/plugin lifecycle cancels it and waits for cleanup. Run operates on saved project configurations, not the currently focused editor file. Existing Java build decisions are still shown in the IDE; the API never @@ -124,8 +186,11 @@ operation. Callers receive `OPERATION_EXPIRED`, never another run's output. ## Output and errors Operation IDs identify executions, not reusable UI slots. Copying/updating a run -snapshot preserves identity; restarting changes it. Maven retains output identity -after the process exits. Both the primary application output panel and service +snapshot preserves identity; restarting changes it. Build retains its latest execution identity, final state and exit code after +the task exits. Model/dependency logs in the shared UI console are not added to +that execution snapshot. Replacement or workspace reopen expires the old ID; an +old stop request cannot cancel the new task. Clearing output preserves the ID +and resets its buffer, allowing the existing cursor to report the discontinuity. Both the primary application output panel and service output tabs are included. Operations expose `running`, `preparing`, `stopping`, `completed`, `failed` or `cancelled` as applicable and a nullable `exitCode`. diff --git a/shared/contracts/plugins/v1/README.md b/shared/contracts/plugins/v1/README.md index 4fa94814a2..60f8614143 100644 --- a/shared/contracts/plugins/v1/README.md +++ b/shared/contracts/plugins/v1/README.md @@ -656,9 +656,10 @@ hosts. Level 3 versions each section on its own. A level 3 manifest lists in | `executionProviders` | `executionProviders[]` | `1` | | `settings` | `settings[]` and `settingActions[]` | `1`, `2` (adds [placement, scope, effect, page buttons](#settings-v2-placement-scope-effect-and-page-buttons)) | | `ui` | `ui` | `1`, `2` (web content), `3` (native controls), `4` ([backend-only commands](#backend-only-commands-ui-4)) | -| `buildSystems` | Declarative build detection, models, and command plans | `1` | -| `buildProviders` | `buildSystems[].provider`, including own-process and shared language-server providers; v2 tasks, v3 tests, v4 run, v5 native debug preparation, v6 read-only model inputs | `1`, `2`, `3`, `4`, `5`, `6` | +| `buildSystems` | Declarative build detection, models, and command plans; v2 executable setting adapter; v3 instance-scoped options; v4 declarative preparation roles | `1`, `2`, `3`, `4` | +| `buildProviders` | `buildSystems[].provider`, including own-process and shared language-server providers; v2 tasks, v3 tests, v4 run, v5 native debug preparation, v6 read-only model inputs, v7 native configuration, v8 inline test reports, v9 opaque semantic test selectors, v10 typed run options and service/task preparation, v11 registry connection debugging | `1`, `2`, `3`, `4`, `5`, `6`, `7`, `8`, `9`, `10`, `11` | | `buildModelInputs` | Optional public model sharing between enabled workspace plugins | `1` | +| `executableResolution` | Shared executable candidate ordering and strict selection; v2 canonical project/subproject toolchain registry bindings | `1`, `2` | | `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` | @@ -710,8 +711,8 @@ in `plugins.declarative.validate`, `plugins.marketplace.validateIndex`, and - `hostCapabilities` has no effect on levels 1 and 2. The macOS host sends its capability table with validation, marketplace filtering -and install review. It advertises `buildSystems: [1, 2]` and -`buildProviders: [1, 2, 3, 4, 5, 6]`; native Provider models, dependencies, tasks, tests and launch preparation +and install review. It advertises `buildSystems: [1, 2, 3, 4]` and +`buildProviders: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]`; native Provider models, dependencies, tasks, tests and launch preparation share workspace-owned sessions. Backend and VCS support use their own entries in the same table. Core validates supported versions without a host table, but admission must use the actual host table. Windows does not yet advertise @@ -1061,6 +1062,20 @@ declaration are relative, use `/`, and stay inside their base directory | `layout` | `excluded` directories relative to each project, added to every project as `excluded` content roots. | | `languageImport` | The project import Lithe's Java language server performs for this build system: `kind` `maven` or `gradle`, and `options` mapping its settings to the plugin's own option IDs. `maven` has `profiles` (`multiSelect`), `settingsFile` (`file`) and `localRepository` (`directory`); `gradle` has `offline` (`boolean`) and `gradleUserHome` (`directory`). `build.languageImport` turns the user's values into the `mavenContext` or `javaGradle` input of `lsp.startServer`; settings without an option keep the language server's defaults. | +With `requires.buildSystems: 3`, an option may declare `scope: "instance"`. +Omitted scope (or `"workspace"`) keeps workspace/build-system storage. Instance +values are independently persisted by normalized detected root: changing or +resetting `a` cannot change `b`, even when both accept the same choices. Explicit +`false`, `""`, and `[]` remain stored user values. The host merges only the +matching scope before detection, static run generation, model/dependency/task +requests, synchronization fingerprints, and language import. Instance data cannot +override workspace options, and workspace values cannot silently override an +instance option. When an existing option moves to instance scope, macOS copies its shared value +to each detected or previously stored root, preserving explicit instance values +and empty markers. It removes the shared representation only after readback, then +redetects static models. Failure retains the source and blocks publishing defaults +until retry; scope is not a fallback chain. See `fixtures/build-instance-options.plugin.json`. + ### Presentation Each enabled build system gets a tool window of its own, titled with its @@ -1183,7 +1198,7 @@ same invocation resolver; explicit invalid tool/runtime selection is a failure, never permission for independent discovery. It must reject stale project facts rather than guess a target. -The host offers `litheBuild.versions: [6, 5, 4, 3, 2, 1]`. Version 2 adds task execution +The host offers `litheBuild.versions: [11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1]`. Version 2 adds task execution and its progress notification to the two version 1 methods. A process that chooses version 1 cannot receive execution; unsupported negotiation fails explicitly. A plugin that needs this behavior declares capability version 2 @@ -1239,7 +1254,7 @@ widening the test run. Paths, scopes and declared filters are validated before the request is sent. The host's report project list only resolves report globs; it is not a second backend project model. -Only a process choosing protocol version 3 or 4 receives `lithe/build/test`. +Only a process choosing protocol version 3 or newer receives `lithe/build/test`. Versions 1 and 2 explicitly refuse tests. Version 3 retains every version 2 task behavior. Tests use the same Core-assigned operation identity, `lithe/build/progress`, tool hint, bounded `{ exitCode, output }` result, @@ -1260,7 +1275,7 @@ Provider tests may additionally declare `discovery: true`. This requires both `build.testDiscoveryPlan` accepts the common plan fields and optional `refresh` and `tool`, returning a request for the whole instance: `lithe/build/discoverTests` with its effective options. Project/output-file -selection is refused. This method also requires protocol version 3 or 4 and uses +selection is refused. This method also requires protocol version 3 or newer and uses the existing bounded request/cancellation lifecycle, without execution progress. The provider returns `{ "format": "litheTestDiscovery", "version": 1, @@ -1337,7 +1352,78 @@ Unknown fields are ignored. The host calls `build.decodeRunPreparation` with its workspace root, instance root and raw result; any malformed plan fails as `plugin.build.invalidOutput` before executable resolution. -#### Native debug preparation (version 5) +#### Typed run options and service/task preparation (version 10) + +Version 10 retains versions 1–9 and extends the same `lithe/build/prepareRun` +method. A provider run may declare `execution: "service"` or `"task"` only +with `requires.buildProviders: 10`. It still uses `fromTargets.tags`, never +host command or extension templates. Preparation ends before the host starts +the process; the host owns stdout/stderr, process identity, Stop and cleanup. +This version does not introduce provider-owned background services. + +The optional `launchOptions` object contains required `execution` +(`application`, `service` or `task`), `runtimeArguments` and `arguments` +(string arrays), and `environment` (a string map). Optional `entrypoint` is an +opaque nonempty string of at most 4,096 UTF-8 bytes without NUL; the owning +provider validates its meaning. Optional `workingDirectory` is a nonempty +workspace-relative directory, `.` allowed, without backslashes, absolute +paths, empty components or `..`. Unknown option fields are refused. Argument +and environment bounds match the launch result above, including empty argv +values. Options describe resolved user choices; providers must preserve +argument boundaries and cannot silently ignore unsupported choices. + +`execution` must match the run declaration (default `application`). Service +and task preparation require `launchOptions`; legacy application preparation +without it retains version 4 behavior. Hosts must reject options before +sending them to a session that negotiated a version below 10. Both SDKs +likewise reject unsupported or malformed options before invoking a handler. +Build options, resolved tool hints and native settings remain their existing +sources; launch options do not create a second profile or tool configuration. + +The host supplies `workspaceRoot` to `build.prepareRunPlan` when options contain +an explicit working directory. Core validates it against the canonical workspace, +including aliases, before preparation. The host passes the same options to +`build.decodeRunPreparation`; the result must use exactly that directory. +Only this explicit override may lie outside the selected instance. Without +an override the original instance containment rule still applies. Other +launch result validation and cancellation/stale-result behavior are unchanged. +Canonical fixtures: `fixtures/build-provider-run-options.plugin.json` and +`fixtures/build-run-options.json`. + +#### Registry connection debugging (buildProviders 11) + +Version 11 retains versions 1–10. A provider service run may declare +`debug: {"configurationType":"example"}`. This is a public DebugAdapter Registry +alias, not another plugin's private language server or a local adapter ID. +The host selects one currently enabled adapter advertising the type and `attach`, +respecting an explicit qualified adapter selection. Missing or ambiguous entries +fail before starting the provider process. It sends: + +```json +{"debug":{"adapterID":"dev.example/debug","configurationType":"example", + "target":{"kind":"connection","host":"127.0.0.1","port":51235}}} +``` + +These are the exact three debug fields and three target fields. The host owns +the port (integer 1–65535), process, readiness and cleanup. Explicit null, unknown +fields, remote hosts and application/task execution are rejected. The type must +match the selected run declaration. Both manifest and process agreement must +support v11; older SDKs reject before invoking a handler. The provider uses its +upstream engine to suspend the service until attachment; it owns runtime-specific +flags and fails if it cannot honor the request. It returns the ordinary portable +Run process plan, without selecting an endpoint or returning DAP arguments. + +The host retains the adapter's qualified identity, package and registry generation +through preparation, readiness and attach. Disable, replacement or stale input +revokes the operation and cleans its owned process. Adapter configuration is +produced by existing `debug.adapters.startArguments` with a `connection` target; +the selected registered adapter owns its transport. In particular, +`tcpLanguageServer` may refer only to a language server declared by that adapter's +own plugin. No built-in language debugger fallback is permitted. +This does not change same-package native `prepareDebug` (v5). +Canonical fixture: `fixtures/build-connection-debug-options.json`. + +### Native debug preparation (version 5) A provider run may additionally declare `debug: {"adapter":"native"}` with `requires.buildProviders: 5` and `requires.debugAdapters: 2`. `adapter` is a @@ -1397,6 +1483,82 @@ Service coordination, debug target registration and Java/Maven migration remain separate. See `fixtures/build-provider-run-preparation.plugin.json` and `fixtures/build-run-preparation.json`. +### Optional native configuration (buildProviders 7) + +An own-process provider may opt into `buildSystems[].configuration: {"source":"provider"}` +with `requires.buildProviders: 7`. Shared language-server providers cannot opt in. +`build.configurationPlan` uses the same selected instance/options/environment/tool +snapshot as model loading and emits `lithe/build/configuration`. It has no project, +output file, or mutation parameters. Wire versions below 7 refuse the method. +The negotiated v7 server feature is `litheBuildConfiguration: true`; earlier methods +retain their existing admission rules. Configuration reads are bounded requests +with the existing cancellation, timeout, workspace and package ownership. + +`build.decodeConfiguration` validates `result` and returns `configuration`: +`{"format":"litheBuildConfiguration","version":1,"sources":[...]}`. There are at most +8 sources, each with a unique provider-owned `id`, `scope` (`installation`, `user`, +`workspace`), file `uri`, exact byte `revision` (`missing` or `sha256:` plus 64 lower-case +hex digits), `writable`, and up to 64 `fields`. +Installation sources are always read-only. Two scopes may alias one URI only when +both are read-only and have identical revision and fields. File URIs +cannot carry credentials, queries or fragments. + +Each field contains `id`, `kind` (`text`, `file`, `directory`, `boolean`, `stringList`), +`value` (raw native value, or null for absent), `effectiveValue`, `effectiveSource` +(an upstream technical origin ID). IDs are opaque bindings interpreted by the +provider; there is no document-format, XPath, XML root or element binding in the +host contract. Fields cannot duplicate or expose credential/secret fields; +values and identifiers are bounded and kind-checked. Full native documents, secret +values, credential-bearing URLs and private keys must never enter this response, +logs or normal controls. Source discovery, interpolation, precedence and effective +values belong to the selected upstream engine; Core does not infer them. + +The native host populates raw/inherited values, displays scope and target URI, +maintains drafts, and requires explicit Save. Opening, cancelling, losing focus +and leaving a page do not write native sources. Passive page reads refuse a +project-owned executable (such as a cold Wrapper), and cannot silently delegate +failed automatic tool discovery to the Provider. An explicit Read using project +tool action authorizes that tooling; explicit Save also owns its required read-back. The confirmed edit is +`{sourceID, revision, values:{:}}`; null removes only +that native override. Unchanged inherited values must not create overrides. + +`build.configurationEditPlan` takes the same instance/tool/options context, +`snapshot`, confirmed `edits`, and a fresh, empty, **already existing** `outputFile` +inside the host's isolated workspace state. It validates the bounded field IDs, +types and revision, then plans `lithe/build/editConfiguration` (protocol 7). +The provider owns its native format and uses an upstream/mature reader/writer; +Maven XML, JSON and TOML do not add host format cases. The provider verifies the +source revision and writes replacement bytes only to that private staging file. +Open without CREATE or following a symlink, validate that it is the empty staging +file before writing, and never truncate another file. A cancelled operation cannot +recreate removed staging. +Preserve unrelated content, format-supported comments and secrets privately. + +The reply is only `{format:"litheConfigurationEdit", version:1, sourceID, +revision, changed}`. `build.decodeConfigurationEdit` binds this receipt to the +same snapshot/draft and rejects extra fields. Full documents and private bytes +must never appear in RPC, UI, logs or model inputs. The native adapter validates +the staged bytes against the receipt SHA-256 before publication. A no-op creates +no source or native override. Providers never write the final source through +this workflow; publication remains the host's guarded atomic transaction. +The output uses the existing private outputs lifecycle, is never reusable across +worktrees, and is deleted on success, failure, cancellation, workspace/window +close and plugin revocation. A late backend may finish its already-open file +handle, but cannot resurrect a removed file. Installation resources stay read-only. + +The platform adapter denies app/plugin installation roots, validates the selected +file and canonical target, preserves metadata and performs guarded atomic publication +against the original bytes. Before commit the operation must still own its workspace, +package, settings and monotonic option revision. Conflicts retain the draft. After +save the host invalidates build/test/language import facts and re-queries the owning +provider; read-back must identify the saved source and exact published revision. +A failed read-back is not a successful effective-value publication. Native probes, +output files and mutable provider caches live only in workspace/platform state and +are not cross-worktree reusable. Installed packages remain byte-for-byte unchanged. + +Fixtures: [declaration](fixtures/build-provider-configuration.plugin.json) and +[native facts](fixtures/build-configuration.json). + ### Optional read-only model inputs (buildModelInputs 1) A producer opts into `buildSystems[].modelSharing: { options: [] }`. @@ -1470,6 +1632,46 @@ Java/Maven business migration remain separate work. Shared fixtures: `fixtures/build-model-producer.plugin.json` and `fixtures/build-model-consumer.plugin.json`. +### Inline provider test reports (buildProviders 8) + +A provider test may declare `results: { "decoder": "provider" }` with +`requires.buildProviders: 8`. Core adds `testResults: true` to the test request; +wire versions below 8 refuse it before invocation. The reply includes +`testResults: { "format": "litheTestResults", "version": 1, "tests": [...] }` +next to `exitCode` and `output`. Ordinary execute replies may include the same +report when the task also ran tests. Missing required or malformed reports fail +explicitly; there is no host report-file fallback. + +Each case has `suite`, `case`, `status` (`passed`, `failed`, `error`, `skipped`), +optional workspace-relative `project` and source `path`, one-based `line`, +`durationMillis`, `message`, and `invocations` (default 1). A report contains at +most 10,000 cases and 8 MiB UTF-8 JSON. Paths remain inside the selected build +root, durations are nonnegative safe integers, and messages are bounded. Core +validates and aggregates by project/suite/case, so identical names in distinct +projects remain distinct; it computes summary/success from the cases. Repeated +case durations are summed only if every invocation has a duration and the total +is a safe integer; otherwise the aggregate duration is omitted. + +An optional `coverage: [{ "project": "module", "suite"?: "Suite", "case"?: "method" }]` +records completed scopes. A missing case covers the entire suite; a missing suite +covers the project (and forbids case). Scopes use canonical workspace-relative +projects inside the instance root, at most 10,000 entries, and bounded nonempty +suite/case names. Core sorts and deduplicates them. Hosts may replace old outcomes +within those scopes; without coverage they replace only the returned exact cases. +Providers must not claim a complete suite when upstream filters or interruption +could have limited execution. Opaque selectors are never parsed by hosts to infer +coverage. + +The provider owns report discovery, freshness and upstream format semantics. +It must preserve reports from failing executions and reject unreadable/oversized +reports instead of returning empty success. The host passes the reply to +`build.decodeTestResults` as `request.result` with the same run/configuration +identity, checks ownership again after decode, and only presents the result. +Cancellation or configuration changes revoke late reports just like late output. +The report is operation state in a platform-provided writable directory, never +installed package state or a cross-worktree cache. Fixture: +`fixtures/build-provider-reports.plugin.json`. + ### Build providers Some tools expose their model only through their own API (the Gradle Tooling @@ -1500,10 +1702,12 @@ with `buildSystemID`. For a provider source, `build.modelPlan` and | --- | --- | | `buildSystemID`, `root` | The instance. A model request always covers the whole instance. | | `options` | Effective option values by option ID. | -| `optionArguments`, `environment` | What the options contribute, for a provider that runs the tool. | +| `optionArguments`, `environment` | Arguments from options, and selected runtime/tool environment merged with option contributions (options win). The host does not forward its complete environment. Providers use this environment for every invocation and cache identity. | | `project` | Dependencies only: `{ name, path }` of the selected project, or `null` for the workspace. | | `refresh` | Optional; `true` when the user asked to reload (Reload, Retry, a page's `build.refresh`) or the host reloads after a `sync` file changed. The provider must then recompute instead of answering from a cache or snapshot of its own. Absent or `false` for a first load, which a provider may answer from a snapshot it still trusts. | -| `tool` | Optional; `{ executable }`, the absolute path of the build tool the host resolved from the build system's `invocation` the same way a task run does (project wrapper, environment variable, `PATH`, the user's override), so the provider need not repeat the lookup. Absent when the host could not resolve it; the provider then looks the tool up itself. It is never a reason to fail the request. | +| `tool` | Optional; `{ executable }`, the absolute path of the build tool the host resolved from the build system's `invocation` the same way a task run does (project wrapper, environment variable, `PATH`, the user's override), so the provider need not repeat the lookup. Absent when the host could not resolve it; the provider then looks the tool up itself. An explicit toolchain or path selection that cannot resolve fails before the Provider request; it cannot be dropped to trigger independent lookup. | + +The Core-only `tool` hint may include a bounded `environment` string map. Core merges it into the existing Provider `environment` parameter, with option contributions taking precedence; the wire `tool` remains `{ executable }`. This also applies to native configuration, tasks and tests. The backend process runtime and the selected build runtime are independent. The host passes `refresh` and `tool` in the `build.modelPlan` and `build.dependencyPlan` requests; Core validates `tool.executable` as an @@ -1522,12 +1726,13 @@ The manifest only says how to start the provider and which sources use it. Everything else is negotiated in the LSP `initialize` exchange, so a provider can add optional data within a negotiated protocol version: -- The host sends `capabilities.experimental.litheBuild: { "versions": [6, 5, 4, 3, 2, 1] }`, +- The host sends `capabilities.experimental.litheBuild: { "versions": [8, 7, 6, 5, 4, 3, 2, 1] }`, the build-provider protocol versions it speaks. Version 1 is the two requests above with the params and result formats of this section; version 2 adds typed task execution; version 3 adds typed test execution and discovery; version 4 adds application run preparation; version 5 adds native debug preparation; - version 6 accepts the host's explicitly declared read-only model inputs. + version 6 accepts the host's explicitly declared read-only model inputs; + version 7 adds source-backed native configuration; version 8 adds inline test reports. - The provider answers `capabilities.experimental.litheBuild: { "version": 1 }` with the version it chose from that list. A provider that answers no version, or one the host did not offer, is not used for build requests; a @@ -2934,7 +3139,7 @@ macOS 动作的可见、可用、勾选和激活中状态由宿主计算,不 仍可取消显示。多个窗口同时请求同一插件模块时只构造一次;禁用或关闭会取消 并清理在途激活,旧的菜单或命令引用不能重新调用已禁用的贡献。 -#### Shared executable candidates (`executableResolution` 1) +#### Shared executable candidates (`executableResolution` 1/2) Declare executable sources in `invocation.executables`, in preference order. Array order is the recommended form; no custom path setting is required for a @@ -3017,6 +3222,25 @@ without it reject new-style plugins before decoding unknown fields. Published See [`build-tool-path.plugin.json`](fixtures/build-tool-path.plugin.json). +With `executableResolution: 2`, a candidate may use +`{ "kind": "toolchain", "toolchainID": "org.example.compiler:compiler" }`. +Core accepts the same short/qualified ID grammar as tool definitions and adds the +normalized instance `root`. The macOS adapter resolves installed definitions +against fresh native evidence and directory pins. Existing host registry IDs +remain only for their unmigrated consumers. Unavailable selections are terminal, +including when a prerequisite runtime fails first; they cannot silently fall +back to Provider discovery. +Host-managed `java`, `node` or `go` dependencies may select one such candidate, +while retaining Core's version probe. Maven declares its namespace-owned tool +and the official Java definition for its Java prerequisite; no Maven-specific +JDK registry entry is added. Its backend bootstrap remains a separate consumer. +Passive import performs no version or runtime probes; a definition that requires +verification is not reported as an already verified import executable. +A toolchain that resolves to a project file remains project code, +including through a symlink, and native configuration requires explicit Read +before running it. See [`build-toolchains.plugin.json`](fixtures/build-toolchains.plugin.json). + + ### Host-rendered native content (`ui` 3, native schema 1) macOS supports `requires.ui: 3` for a tool window whose sole body is `native`. @@ -3126,6 +3350,52 @@ multi-instance selectors, global function commands, native renderer factories and Windows rendering remain follow-up work under #1209; this subset does not claim completion of that whole issue. +### Opaque semantic test selectors (buildProviders 9) + +Provider test declarations may advertise `selectorKinds`, up to 16 distinct public +namespace identifiers (120 bytes each). These require `buildProviders: 9`. A test +request may then carry `selectorKind` and `selectors` instead of `suite`/`case`. +The namespace must be declared; at most 64 nonempty, control-free UTF-8 values of +4096 bytes each are allowed. An empty list explicitly selects all tests within the +request's normal project/workspace scope. Values remain opaque to the host and +Core: only the owning language engine supplies semantic identities and the build +backend translates them into its upstream runner's selection syntax. Providers +must reject identities they cannot represent exactly, rather than widen execution. + +The wire session must explicitly negotiate version 9. Core and both SDKs reject +these fields on older versions before dispatch; ignoring a filter is unsafe even +when version 8 inline reports are supported. The operation identity, source/model +and configuration validity, cancellation, progress, and inline report rules still +apply. The shared example is `fixtures/build-provider-selectors.plugin.json`. + +### Declarative project preparation (buildSystems 4) + +A project task may declare `preparation: { "purpose": "run", "sourceRoles": +["source", "generatedSource"] }` with `requires.buildSystems: 4`. Purpose is +an opaque workflow identifier; `run` identifies preparation before resolving a +language launch. Roles are the canonical model content-root kinds. A provider +can declare a different task for `testSource`/`generatedTestSource`, with any +private task IDs. Hosts never prescribe task names or infer a role from a file +extension, build descriptor, class name or missing model entry. + +Bindings are unique by purpose/role within each build system. Preparation +requires a project-scoped task, task execution, no argument prompt, and no +`fromModel`. Empty, overlapping, duplicate or unknown roles are rejected. +Hosts select the deepest owning project and its deepest content root from the +current model, then require exactly one declared matching task. Missing or +ambiguous declarations fail; they do not fall back to a conventional task ID. +The selected ID travels through the existing execute plan and backend method. +An older host rejects the capability before invocation. No new RPC version is +needed, so buildProviders 8–10 are unchanged. Shared fixture: +`fixtures/build-provider-preparation.plugin.json`. + +The build backend owns filtering, custom resource directories, profiles and +reactor semantics. Hosts await successful completion, retain one operation +identity through cancellation/configuration changes, and only then resolve and +launch Java. Per-run option/tool overrides are ephemeral host inputs to the +existing validated plan; they never mutate saved build defaults or claim that +the persistent model reflects a different configuration. + ### Plugin-owned native terminals (function protocol 3) [`terminal-backend.plugin.json`](fixtures/terminal-backend.plugin.json) is the diff --git a/shared/contracts/plugins/v1/fixtures/build-configuration.json b/shared/contracts/plugins/v1/fixtures/build-configuration.json new file mode 100644 index 0000000000..c1944cdd9b --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/build-configuration.json @@ -0,0 +1,34 @@ +{ + "format": "litheBuildConfiguration", + "version": 1, + "sources": [ + { + "id": "user", + "scope": "user", + "uri": "file:///fixture/user/settings.xml", + "revision": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "writable": true, + "fields": [ + { + "id": "localRepository", + "kind": "directory", + "value": null, + "effectiveValue": "/fixture/repository", + "effectiveSource": "installation" + }, + { + "id": "activeProfiles", + "kind": "stringList", + "value": [ + "ci" + ], + "effectiveValue": [ + "ci", + "offline" + ], + "effectiveSource": "commandLine" + } + ] + } + ] +} diff --git a/shared/contracts/plugins/v1/fixtures/build-connection-debug-options.json b/shared/contracts/plugins/v1/fixtures/build-connection-debug-options.json new file mode 100644 index 0000000000..e3beac1d77 --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/build-connection-debug-options.json @@ -0,0 +1,46 @@ +{ + "format": "litheBuildRunOptions", + "version": 1, + "launchOptions": { + "execution": "service", + "runtimeArguments": [ + "-Dmessage=hello world", + "-Dquote=\"value\"", + "-Dpath=C:\\Users\\example" + ], + "arguments": [ + "--message=hello world", + "雪", + "" + ], + "environment": { + "EXAMPLE": "hello world" + }, + "workingDirectory": "tools", + "entrypoint": "example.Main", + "debug": { + "adapterID": "dev.lithe.plugin.java-support/java", + "configurationType": "java", + "target": { + "kind": "connection", + "host": "127.0.0.1", + "port": 51235 + } + } + }, + "result": { + "executable": { + "kind": "command", + "value": "engine" + }, + "arguments": [ + "serve", + "--root", + "app" + ], + "workingDirectory": "tools", + "environment": { + "EXAMPLE": "hello world" + } + } +} diff --git a/shared/contracts/plugins/v1/fixtures/build-instance-options.plugin.json b/shared/contracts/plugins/v1/fixtures/build-instance-options.plugin.json new file mode 100644 index 0000000000..9aa8cf6823 --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/build-instance-options.plugin.json @@ -0,0 +1,312 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.fixture.build-instance-options", + "displayName": "Instance Options Build Fixture", + "version": "0.1.0", + "pluginAPIVersion": 3, + "requires": { + "buildSystems": 3 + }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "process.build" + ], + "activationEvents": [ + "onBuildSystem:cargo" + ], + "languages": [], + "buildSystems": [ + { + "id": "cargo", + "displayName": "Cargo", + "detection": { + "markers": [ + "Cargo.toml" + ] + }, + "invocation": { + "executables": [ + { + "kind": "command", + "command": "cargo" + } + ], + "version": { + "arguments": [ + "--version" + ], + "pattern": "cargo (\\d+\\.\\d+\\.\\d+)" + }, + "minimumVersion": "1.70" + }, + "options": [ + { + "id": "release", + "displayName": "Release Profile", + "kind": "boolean", + "arguments": [ + "--release" + ], + "scope": "instance" + }, + { + "id": "features", + "displayName": "Features", + "kind": "multiSelect", + "choicesFromModel": true, + "arguments": [ + "--features", + "${value}" + ], + "scope": "instance" + }, + { + "id": "offline", + "displayName": "Offline", + "kind": "boolean", + "arguments": [ + "--offline" + ] + }, + { + "id": "settingsFile", + "displayName": "Settings File", + "kind": "file", + "scope": "instance", + "arguments": [ + "--config", + "${value}" + ] + }, + { + "id": "cacheDirectory", + "displayName": "Cache Directory", + "kind": "directory", + "scope": "instance", + "arguments": [ + "--cache", + "${value}" + ] + }, + { + "id": "label", + "displayName": "Label", + "kind": "text", + "scope": "instance", + "arguments": [ + "--label", + "${value}" + ] + } + ], + "model": { + "source": "static", + "file": "Cargo.toml", + "decoder": "toml", + "map": { + "projects": [ + { + "each": "/package", + "name": "/name", + "properties": { + "version": "/version", + "edition": "/edition" + }, + "targets": [ + { + "each": "$/bin/*", + "name": "/name", + "tags": [ + "application" + ] + }, + { + "fallback": true, + "whenFile": "src/main.rs", + "name": "/name", + "tags": [ + "application" + ] + }, + { + "each": "$/test/*", + "name": "/name", + "tags": [ + "test" + ] + }, + { + "each": "$/example/*", + "name": "/name", + "tags": [ + "example" + ] + } + ], + "contentRoots": [ + { + "path": { + "value": "src" + }, + "kind": "source" + }, + { + "path": { + "value": "tests" + }, + "kind": "testSource" + } + ] + } + ], + "optionValues": { + "features": { + "each": "/features/*", + "value": "@key" + } + } + } + }, + "tasks": [ + { + "id": "build", + "displayName": "Build", + "group": "build", + "scopes": [ + "project" + ], + "arguments": [ + "build", + "--message-format=short", + "${optionArguments}" + ] + }, + { + "id": "check", + "displayName": "Check", + "group": "verification", + "scopes": [ + "project" + ], + "arguments": [ + "check", + "--message-format=short", + "${optionArguments}" + ] + }, + { + "id": "clean", + "displayName": "Clean", + "group": "build", + "scopes": [ + "project" + ], + "arguments": [ + "clean" + ] + }, + { + "id": "example", + "displayName": "Run Example", + "group": "run", + "execution": "application", + "scopes": [ + "target" + ], + "targetTags": [ + "example" + ], + "arguments": [ + "run", + "--example", + "${target.name}", + "${optionArguments}" + ] + } + ], + "run": [ + { + "id": "binary", + "fromTargets": { + "tags": [ + "application" + ] + }, + "arguments": [ + "run", + "--bin", + "${target.name}", + "${optionArguments}" + ], + "extension": { + "bin": "${target.name}" + } + } + ], + "test": { + "scopes": [ + "project", + "target" + ], + "targetTags": [ + "test" + ], + "filters": [ + "case" + ], + "arguments": [ + "test", + "${optionArguments}", + { + "value": "--test", + "when": "targetSelected" + }, + { + "value": "${target.name}", + "when": "targetSelected" + }, + { + "value": "--", + "when": "caseSelected" + }, + { + "value": "${test.case}", + "when": "caseSelected" + }, + { + "value": "--exact", + "when": "caseSelected" + } + ] + }, + "diagnostics": { + "decoder": "lineMatcher", + "patterns": [ + { + "regex": "^([^\\s:][^:]*\\.rs):(\\d+):(\\d+): (error|warning)(?:\\[\\w+\\])?: (.+)$", + "file": 1, + "line": 2, + "column": 3, + "severity": 4, + "message": 5 + } + ] + }, + "sync": { + "watch": [ + "Cargo.toml", + "Cargo.lock" + ] + }, + "layout": { + "excluded": [ + "target" + ] + } + } + ] +} diff --git a/shared/contracts/plugins/v1/fixtures/build-provider-configuration.plugin.json b/shared/contracts/plugins/v1/fixtures/build-provider-configuration.plugin.json new file mode 100644 index 0000000000..ea4d15d349 --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/build-provider-configuration.plugin.json @@ -0,0 +1,178 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.fixture.build-provider-configuration", + "displayName": "Native Configuration Fixture", + "version": "0.1.0", + "pluginAPIVersion": 3, + "requires": { + "buildProviders": 7, + "buildSystems": 1, + "serverProcess": 1, + "settings": 1 + }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "languageServer.launch", + "process.build" + ], + "activationEvents": [ + "onBuildSystem:gradle" + ], + "languages": [], + "settings": [ + { + "id": "providerHeapMB", + "type": "integer", + "title": "Provider memory", + "description": "Upper bound of the Gradle provider's heap.", + "default": 1024, + "minimum": 256, + "maximum": 8192, + "unit": "MB" + } + ], + "buildSystems": [ + { + "id": "gradle", + "displayName": "Gradle", + "detection": { + "markers": [ + "build.gradle", + "build.gradle.kts", + "settings.gradle", + "settings.gradle.kts" + ], + "nesting": "outermost", + "priority": 60 + }, + "invocation": { + "executables": [ + { + "kind": "projectFile", + "path": "gradlew", + "requires": [ + "gradle/wrapper/gradle-wrapper.properties" + ], + "platforms": [ + "macos", + "linux" + ] + }, + { + "kind": "projectFile", + "path": "gradlew.bat", + "requires": [ + "gradle/wrapper/gradle-wrapper.properties" + ], + "platforms": [ + "windows" + ] + }, + { + "kind": "command", + "command": "gradle" + } + ], + "runtimeDependencies": [ + { + "id": "java", + "minimumVersion": "17" + } + ], + "globalArguments": [ + "--console=plain", + "${optionArguments}" + ] + }, + "provider": { + "launch": { + "kind": "runtime", + "tool": "java", + "minimumVersion": "17", + "runtimeArguments": [ + "-Xmx${setting:providerHeapMB}m" + ], + "entry": "provider/gradle-tooling-provider.jar" + }, + "arguments": [ + "--stdio", + "--cache", + "${workspaceStateDir}/tooling" + ], + "initializationOptions": { + "initScript": "${pluginDir}/provider/lithe-model.init.gradle" + }, + "executesProjectCode": true, + "mayUseNetwork": true + }, + "options": [ + { + "id": "offline", + "displayName": "Offline", + "kind": "boolean", + "arguments": [ + "--offline" + ] + } + ], + "model": { + "source": "provider" + }, + "tasks": [ + { + "id": "build", + "displayName": "Build", + "group": "build", + "scopes": [ + "workspace", + "project", + "target" + ], + "source": "provider", + "arguments": [] + }, + { + "id": "custom", + "displayName": "Custom", + "group": "other", + "scopes": [ + "workspace" + ], + "source": "provider", + "requiresUserArguments": true, + "arguments": [] + } + ], + "dependencies": { + "source": "provider", + "scopes": [ + "workspace", + "project" + ] + }, + "sync": { + "watch": [ + "build.gradle", + "build.gradle.kts", + "gradle.properties", + "gradle/libs.versions.toml", + "settings.gradle", + "settings.gradle.kts" + ] + }, + "layout": { + "excluded": [ + ".gradle", + "build" + ] + }, + "configuration": { + "source": "provider" + } + } + ] +} diff --git a/shared/contracts/plugins/v1/fixtures/build-provider-preparation.plugin.json b/shared/contracts/plugins/v1/fixtures/build-provider-preparation.plugin.json new file mode 100644 index 0000000000..7da03c34e5 --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/build-provider-preparation.plugin.json @@ -0,0 +1,186 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.fixture.build-provider-preparation", + "displayName": "Declarative Preparation Fixture", + "version": "0.1.0", + "pluginAPIVersion": 3, + "requires": { + "buildProviders": 2, + "buildSystems": 4, + "serverProcess": 1, + "settings": 1 + }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "languageServer.launch", + "process.build" + ], + "activationEvents": [ + "onBuildSystem:gradle" + ], + "languages": [], + "settings": [ + { + "id": "providerHeapMB", + "type": "integer", + "title": "Provider memory", + "description": "Upper bound of the Gradle provider's heap.", + "default": 1024, + "minimum": 256, + "maximum": 8192, + "unit": "MB" + } + ], + "buildSystems": [ + { + "id": "gradle", + "displayName": "Gradle", + "detection": { + "markers": [ + "build.gradle", + "build.gradle.kts", + "settings.gradle", + "settings.gradle.kts" + ], + "nesting": "outermost", + "priority": 60 + }, + "invocation": { + "executables": [ + { + "kind": "projectFile", + "path": "gradlew", + "requires": [ + "gradle/wrapper/gradle-wrapper.properties" + ], + "platforms": [ + "macos", + "linux" + ] + }, + { + "kind": "projectFile", + "path": "gradlew.bat", + "requires": [ + "gradle/wrapper/gradle-wrapper.properties" + ], + "platforms": [ + "windows" + ] + }, + { + "kind": "command", + "command": "gradle" + } + ], + "runtimeDependencies": [ + { + "id": "java", + "minimumVersion": "17" + } + ], + "globalArguments": [ + "--console=plain", + "${optionArguments}" + ] + }, + "provider": { + "launch": { + "kind": "runtime", + "tool": "java", + "minimumVersion": "17", + "runtimeArguments": [ + "-Xmx${setting:providerHeapMB}m" + ], + "entry": "provider/gradle-tooling-provider.jar" + }, + "arguments": [ + "--stdio", + "--cache", + "${workspaceStateDir}/tooling" + ], + "initializationOptions": { + "initScript": "${pluginDir}/provider/lithe-model.init.gradle" + }, + "executesProjectCode": true, + "mayUseNetwork": true + }, + "options": [ + { + "id": "offline", + "displayName": "Offline", + "kind": "boolean", + "arguments": [ + "--offline" + ] + } + ], + "model": { + "source": "provider" + }, + "tasks": [ + { + "id": "prepare-assets", + "displayName": "Prepare assets", + "group": "build", + "scopes": [ + "project" + ], + "source": "provider", + "preparation": { + "purpose": "run", + "sourceRoles": [ + "source", + "generatedSource" + ] + }, + "arguments": [] + }, + { + "id": "prepare-specs", + "displayName": "Prepare test assets", + "group": "build", + "scopes": [ + "project" + ], + "source": "provider", + "preparation": { + "purpose": "run", + "sourceRoles": [ + "testSource", + "generatedTestSource" + ] + }, + "arguments": [] + } + ], + "dependencies": { + "source": "provider", + "scopes": [ + "workspace", + "project" + ] + }, + "sync": { + "watch": [ + "build.gradle", + "build.gradle.kts", + "gradle.properties", + "gradle/libs.versions.toml", + "settings.gradle", + "settings.gradle.kts" + ] + }, + "layout": { + "excluded": [ + ".gradle", + "build" + ] + } + } + ] +} diff --git a/shared/contracts/plugins/v1/fixtures/build-provider-reports.plugin.json b/shared/contracts/plugins/v1/fixtures/build-provider-reports.plugin.json new file mode 100644 index 0000000000..4b7fc7db91 --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/build-provider-reports.plugin.json @@ -0,0 +1,192 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.fixture.build-provider-reports", + "displayName": "Build Provider Tests Fixture", + "version": "0.1.0", + "pluginAPIVersion": 3, + "requires": { + "buildProviders": 8, + "buildSystems": 1, + "serverProcess": 1, + "settings": 1 + }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "languageServer.launch", + "process.build" + ], + "activationEvents": [ + "onBuildSystem:gradle" + ], + "languages": [], + "settings": [ + { + "id": "providerHeapMB", + "type": "integer", + "title": "Provider memory", + "description": "Upper bound of the Gradle provider's heap.", + "default": 1024, + "minimum": 256, + "maximum": 8192, + "unit": "MB" + } + ], + "buildSystems": [ + { + "id": "gradle", + "displayName": "Gradle", + "detection": { + "markers": [ + "build.gradle", + "build.gradle.kts", + "settings.gradle", + "settings.gradle.kts" + ], + "nesting": "outermost", + "priority": 60 + }, + "invocation": { + "executables": [ + { + "kind": "projectFile", + "path": "gradlew", + "requires": [ + "gradle/wrapper/gradle-wrapper.properties" + ], + "platforms": [ + "macos", + "linux" + ] + }, + { + "kind": "projectFile", + "path": "gradlew.bat", + "requires": [ + "gradle/wrapper/gradle-wrapper.properties" + ], + "platforms": [ + "windows" + ] + }, + { + "kind": "command", + "command": "gradle" + } + ], + "runtimeDependencies": [ + { + "id": "java", + "minimumVersion": "17" + } + ], + "globalArguments": [ + "--console=plain", + "${optionArguments}" + ] + }, + "provider": { + "launch": { + "kind": "runtime", + "tool": "java", + "minimumVersion": "17", + "runtimeArguments": [ + "-Xmx${setting:providerHeapMB}m" + ], + "entry": "provider/gradle-tooling-provider.jar" + }, + "arguments": [ + "--stdio", + "--cache", + "${workspaceStateDir}/tooling" + ], + "initializationOptions": { + "initScript": "${pluginDir}/provider/lithe-model.init.gradle" + }, + "executesProjectCode": true, + "mayUseNetwork": true + }, + "options": [ + { + "id": "offline", + "displayName": "Offline", + "kind": "boolean", + "arguments": [ + "--offline" + ] + } + ], + "model": { + "source": "provider" + }, + "tasks": [ + { + "id": "build", + "displayName": "Build", + "group": "build", + "scopes": [ + "workspace", + "project", + "target" + ], + "source": "provider", + "arguments": [] + }, + { + "id": "custom", + "displayName": "Custom", + "group": "other", + "scopes": [ + "workspace" + ], + "source": "provider", + "requiresUserArguments": true, + "arguments": [] + } + ], + "dependencies": { + "source": "provider", + "scopes": [ + "workspace", + "project" + ] + }, + "sync": { + "watch": [ + "build.gradle", + "build.gradle.kts", + "gradle.properties", + "gradle/libs.versions.toml", + "settings.gradle", + "settings.gradle.kts" + ] + }, + "layout": { + "excluded": [ + ".gradle", + "build" + ] + }, + "test": { + "source": "provider", + "scopes": [ + "workspace", + "project", + "target" + ], + "filters": [ + "suite", + "case" + ], + "arguments": [], + "results": { + "decoder": "provider" + }, + "discovery": true + } + } + ] +} diff --git a/shared/contracts/plugins/v1/fixtures/build-provider-run-options.plugin.json b/shared/contracts/plugins/v1/fixtures/build-provider-run-options.plugin.json new file mode 100644 index 0000000000..12b980f537 --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/build-provider-run-options.plugin.json @@ -0,0 +1,209 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.fixture.build-provider-run-options", + "displayName": "Provider Run Options Fixture", + "version": "0.1.0", + "pluginAPIVersion": 3, + "requires": { + "buildProviders": 10, + "buildSystems": 1, + "serverProcess": 1, + "settings": 1 + }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "languageServer.launch", + "process.build" + ], + "activationEvents": [ + "onBuildSystem:gradle" + ], + "languages": [], + "settings": [ + { + "id": "providerHeapMB", + "type": "integer", + "title": "Provider memory", + "description": "Upper bound of the Gradle provider's heap.", + "default": 1024, + "minimum": 256, + "maximum": 8192, + "unit": "MB" + } + ], + "buildSystems": [ + { + "id": "gradle", + "displayName": "Gradle", + "detection": { + "markers": [ + "build.gradle", + "build.gradle.kts", + "settings.gradle", + "settings.gradle.kts" + ], + "nesting": "outermost", + "priority": 60 + }, + "invocation": { + "executables": [ + { + "kind": "projectFile", + "path": "gradlew", + "requires": [ + "gradle/wrapper/gradle-wrapper.properties" + ], + "platforms": [ + "macos", + "linux" + ] + }, + { + "kind": "projectFile", + "path": "gradlew.bat", + "requires": [ + "gradle/wrapper/gradle-wrapper.properties" + ], + "platforms": [ + "windows" + ] + }, + { + "kind": "command", + "command": "gradle" + } + ], + "runtimeDependencies": [ + { + "id": "java", + "minimumVersion": "17" + } + ], + "globalArguments": [ + "--console=plain", + "${optionArguments}" + ] + }, + "provider": { + "launch": { + "kind": "runtime", + "tool": "java", + "minimumVersion": "17", + "runtimeArguments": [ + "-Xmx${setting:providerHeapMB}m" + ], + "entry": "provider/gradle-tooling-provider.jar" + }, + "arguments": [ + "--stdio", + "--cache", + "${workspaceStateDir}/tooling" + ], + "initializationOptions": { + "initScript": "${pluginDir}/provider/lithe-model.init.gradle" + }, + "executesProjectCode": true, + "mayUseNetwork": true + }, + "options": [ + { + "id": "offline", + "displayName": "Offline", + "kind": "boolean", + "arguments": [ + "--offline" + ] + } + ], + "model": { + "source": "provider" + }, + "tasks": [ + { + "id": "build", + "displayName": "Build", + "group": "build", + "scopes": [ + "workspace", + "project", + "target" + ], + "source": "provider", + "arguments": [] + }, + { + "id": "custom", + "displayName": "Custom", + "group": "other", + "scopes": [ + "workspace" + ], + "source": "provider", + "requiresUserArguments": true, + "arguments": [] + } + ], + "dependencies": { + "source": "provider", + "scopes": [ + "workspace", + "project" + ] + }, + "sync": { + "watch": [ + "build.gradle", + "build.gradle.kts", + "gradle.properties", + "gradle/libs.versions.toml", + "settings.gradle", + "settings.gradle.kts" + ] + }, + "layout": { + "excluded": [ + ".gradle", + "build" + ] + }, + "test": { + "source": "provider", + "scopes": [ + "workspace", + "project", + "target" + ], + "filters": [ + "suite", + "case" + ], + "arguments": [], + "results": { + "decoder": "junitXml", + "reports": [ + "build/test-results/**/*.xml" + ] + }, + "discovery": true + }, + "run": [ + { + "id": "service", + "source": "provider", + "name": "Run Service", + "fromTargets": { + "tags": [ + "application" + ] + }, + "arguments": [], + "execution": "service" + } + ] + } + ] +} diff --git a/shared/contracts/plugins/v1/fixtures/build-provider-selectors.plugin.json b/shared/contracts/plugins/v1/fixtures/build-provider-selectors.plugin.json new file mode 100644 index 0000000000..e36cd3b4a5 --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/build-provider-selectors.plugin.json @@ -0,0 +1,195 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.fixture.build-provider-selectors", + "displayName": "Build Provider Tests Fixture", + "version": "0.1.0", + "pluginAPIVersion": 3, + "requires": { + "buildProviders": 9, + "buildSystems": 1, + "serverProcess": 1, + "settings": 1 + }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "languageServer.launch", + "process.build" + ], + "activationEvents": [ + "onBuildSystem:gradle" + ], + "languages": [], + "settings": [ + { + "id": "providerHeapMB", + "type": "integer", + "title": "Provider memory", + "description": "Upper bound of the Gradle provider's heap.", + "default": 1024, + "minimum": 256, + "maximum": 8192, + "unit": "MB" + } + ], + "buildSystems": [ + { + "id": "gradle", + "displayName": "Gradle", + "detection": { + "markers": [ + "build.gradle", + "build.gradle.kts", + "settings.gradle", + "settings.gradle.kts" + ], + "nesting": "outermost", + "priority": 60 + }, + "invocation": { + "executables": [ + { + "kind": "projectFile", + "path": "gradlew", + "requires": [ + "gradle/wrapper/gradle-wrapper.properties" + ], + "platforms": [ + "macos", + "linux" + ] + }, + { + "kind": "projectFile", + "path": "gradlew.bat", + "requires": [ + "gradle/wrapper/gradle-wrapper.properties" + ], + "platforms": [ + "windows" + ] + }, + { + "kind": "command", + "command": "gradle" + } + ], + "runtimeDependencies": [ + { + "id": "java", + "minimumVersion": "17" + } + ], + "globalArguments": [ + "--console=plain", + "${optionArguments}" + ] + }, + "provider": { + "launch": { + "kind": "runtime", + "tool": "java", + "minimumVersion": "17", + "runtimeArguments": [ + "-Xmx${setting:providerHeapMB}m" + ], + "entry": "provider/gradle-tooling-provider.jar" + }, + "arguments": [ + "--stdio", + "--cache", + "${workspaceStateDir}/tooling" + ], + "initializationOptions": { + "initScript": "${pluginDir}/provider/lithe-model.init.gradle" + }, + "executesProjectCode": true, + "mayUseNetwork": true + }, + "options": [ + { + "id": "offline", + "displayName": "Offline", + "kind": "boolean", + "arguments": [ + "--offline" + ] + } + ], + "model": { + "source": "provider" + }, + "tasks": [ + { + "id": "build", + "displayName": "Build", + "group": "build", + "scopes": [ + "workspace", + "project", + "target" + ], + "source": "provider", + "arguments": [] + }, + { + "id": "custom", + "displayName": "Custom", + "group": "other", + "scopes": [ + "workspace" + ], + "source": "provider", + "requiresUserArguments": true, + "arguments": [] + } + ], + "dependencies": { + "source": "provider", + "scopes": [ + "workspace", + "project" + ] + }, + "sync": { + "watch": [ + "build.gradle", + "build.gradle.kts", + "gradle.properties", + "gradle/libs.versions.toml", + "settings.gradle", + "settings.gradle.kts" + ] + }, + "layout": { + "excluded": [ + ".gradle", + "build" + ] + }, + "test": { + "source": "provider", + "scopes": [ + "workspace", + "project", + "target" + ], + "filters": [ + "suite", + "case" + ], + "arguments": [], + "results": { + "decoder": "provider" + }, + "discovery": true, + "selectorKinds": [ + "org.example.semantic" + ] + } + } + ] +} diff --git a/shared/contracts/plugins/v1/fixtures/build-run-options.json b/shared/contracts/plugins/v1/fixtures/build-run-options.json new file mode 100644 index 0000000000..b8644d81cb --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/build-run-options.json @@ -0,0 +1,37 @@ +{ + "format": "litheBuildRunOptions", + "version": 1, + "launchOptions": { + "execution": "service", + "runtimeArguments": [ + "-Dmessage=hello world", + "-Dquote=\"value\"", + "-Dpath=C:\\Users\\example" + ], + "arguments": [ + "--message=hello world", + "雪", + "" + ], + "environment": { + "EXAMPLE": "hello world" + }, + "workingDirectory": "tools", + "entrypoint": "example.Main" + }, + "result": { + "executable": { + "kind": "toolchain", + "value": "project-maven" + }, + "arguments": [ + "--file", + "app/pom.xml", + "framework:run" + ], + "workingDirectory": "tools", + "environment": { + "EXAMPLE": "hello world" + } + } +} diff --git a/shared/contracts/plugins/v1/fixtures/build-test-durations.json b/shared/contracts/plugins/v1/fixtures/build-test-durations.json new file mode 100644 index 0000000000..5e156fe2af --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/build-test-durations.json @@ -0,0 +1,45 @@ +{ + "version": 1, + "cases": [ + { + "name": "sum", + "values": [ + 10, + 20 + ], + "durationMillis": 30 + }, + { + "name": "zero", + "values": [ + 0, + 0 + ], + "durationMillis": 0 + }, + { + "name": "missing-first", + "values": [ + null, + 20 + ], + "durationMillis": null + }, + { + "name": "missing-second", + "values": [ + 10, + null + ], + "durationMillis": null + }, + { + "name": "unsafe-total", + "values": [ + 9007199254740990, + 2 + ], + "durationMillis": null + } + ] +} diff --git a/shared/contracts/plugins/v1/fixtures/build-toolchains.plugin.json b/shared/contracts/plugins/v1/fixtures/build-toolchains.plugin.json new file mode 100644 index 0000000000..386c84a11b --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/build-toolchains.plugin.json @@ -0,0 +1,329 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.fixture.build-toolchains", + "displayName": "Canonical Toolchain Build Fixture", + "version": "0.1.0", + "pluginAPIVersion": 3, + "requires": { + "buildSystems": 3, + "executableResolution": 2 + }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "process.build" + ], + "activationEvents": [ + "onBuildSystem:cargo" + ], + "languages": [], + "buildSystems": [ + { + "id": "cargo", + "displayName": "Cargo", + "detection": { + "markers": [ + "Cargo.toml" + ] + }, + "invocation": { + "executables": [ + { + "kind": "toolchain", + "toolchainID": "dev.lithe.fixture.build-toolchains:compiler" + } + ], + "runtimeDependencies": [ + { + "id": "vm", + "minimumVersion": "8", + "version": { + "arguments": ["--version"], + "pattern": "VM (\\d+(?:\\.\\d+)*)" + }, + "executables": [ + { + "kind": "toolchain", + "toolchainID": "dev.lithe.fixture.build-toolchains:vm" + } + ] + } + ], + "version": { + "arguments": [ + "--version" + ], + "pattern": "Fixture (\\d+\\.\\d+)" + }, + "minimumVersion": "1.0" + }, + "options": [ + { + "id": "release", + "displayName": "Release Profile", + "kind": "boolean", + "arguments": [ + "--release" + ], + "scope": "instance" + }, + { + "id": "features", + "displayName": "Features", + "kind": "multiSelect", + "choicesFromModel": true, + "arguments": [ + "--features", + "${value}" + ], + "scope": "instance" + }, + { + "id": "offline", + "displayName": "Offline", + "kind": "boolean", + "arguments": [ + "--offline" + ] + }, + { + "id": "settingsFile", + "displayName": "Settings File", + "kind": "file", + "scope": "instance", + "arguments": [ + "--config", + "${value}" + ] + }, + { + "id": "cacheDirectory", + "displayName": "Cache Directory", + "kind": "directory", + "scope": "instance", + "arguments": [ + "--cache", + "${value}" + ] + }, + { + "id": "label", + "displayName": "Label", + "kind": "text", + "scope": "instance", + "arguments": [ + "--label", + "${value}" + ] + } + ], + "model": { + "source": "static", + "file": "Cargo.toml", + "decoder": "toml", + "map": { + "projects": [ + { + "each": "/package", + "name": "/name", + "properties": { + "version": "/version", + "edition": "/edition" + }, + "targets": [ + { + "each": "$/bin/*", + "name": "/name", + "tags": [ + "application" + ] + }, + { + "fallback": true, + "whenFile": "src/main.rs", + "name": "/name", + "tags": [ + "application" + ] + }, + { + "each": "$/test/*", + "name": "/name", + "tags": [ + "test" + ] + }, + { + "each": "$/example/*", + "name": "/name", + "tags": [ + "example" + ] + } + ], + "contentRoots": [ + { + "path": { + "value": "src" + }, + "kind": "source" + }, + { + "path": { + "value": "tests" + }, + "kind": "testSource" + } + ] + } + ], + "optionValues": { + "features": { + "each": "/features/*", + "value": "@key" + } + } + } + }, + "tasks": [ + { + "id": "build", + "displayName": "Build", + "group": "build", + "scopes": [ + "project" + ], + "arguments": [ + "build", + "--message-format=short", + "${optionArguments}" + ] + }, + { + "id": "check", + "displayName": "Check", + "group": "verification", + "scopes": [ + "project" + ], + "arguments": [ + "check", + "--message-format=short", + "${optionArguments}" + ] + }, + { + "id": "clean", + "displayName": "Clean", + "group": "build", + "scopes": [ + "project" + ], + "arguments": [ + "clean" + ] + }, + { + "id": "example", + "displayName": "Run Example", + "group": "run", + "execution": "application", + "scopes": [ + "target" + ], + "targetTags": [ + "example" + ], + "arguments": [ + "run", + "--example", + "${target.name}", + "${optionArguments}" + ] + } + ], + "run": [ + { + "id": "binary", + "fromTargets": { + "tags": [ + "application" + ] + }, + "arguments": [ + "run", + "--bin", + "${target.name}", + "${optionArguments}" + ], + "extension": { + "bin": "${target.name}" + } + } + ], + "test": { + "scopes": [ + "project", + "target" + ], + "targetTags": [ + "test" + ], + "filters": [ + "case" + ], + "arguments": [ + "test", + "${optionArguments}", + { + "value": "--test", + "when": "targetSelected" + }, + { + "value": "${target.name}", + "when": "targetSelected" + }, + { + "value": "--", + "when": "caseSelected" + }, + { + "value": "${test.case}", + "when": "caseSelected" + }, + { + "value": "--exact", + "when": "caseSelected" + } + ] + }, + "diagnostics": { + "decoder": "lineMatcher", + "patterns": [ + { + "regex": "^([^\\s:][^:]*\\.rs):(\\d+):(\\d+): (error|warning)(?:\\[\\w+\\])?: (.+)$", + "file": 1, + "line": 2, + "column": 3, + "severity": 4, + "message": 5 + } + ] + }, + "sync": { + "watch": [ + "Cargo.toml", + "Cargo.lock" + ] + }, + "layout": { + "excluded": [ + "target" + ] + } + } + ] +} diff --git a/shared/contracts/plugins/v1/fixtures/invalid-manifests.json b/shared/contracts/plugins/v1/fixtures/invalid-manifests.json index 2a20bb42db..5ca1ec5364 100644 --- a/shared/contracts/plugins/v1/fixtures/invalid-manifests.json +++ b/shared/contracts/plugins/v1/fixtures/invalid-manifests.json @@ -825,7 +825,7 @@ { "name": "future capability version", "base": "build-static-file.plugin.json", - "edits": [{ "op": "set", "path": "/requires/buildSystems", "value": 3 }], + "edits": [{ "op": "set", "path": "/requires/buildSystems", "value": 5 }], "expectedCode": "plugin.api.unsupported" }, { diff --git a/shared/contracts/plugins/v1/plugin-manifest.schema.json b/shared/contracts/plugins/v1/plugin-manifest.schema.json index 6c6f5727a1..55c66948ee 100644 --- a/shared/contracts/plugins/v1/plugin-manifest.schema.json +++ b/shared/contracts/plugins/v1/plugin-manifest.schema.json @@ -38,8 +38,8 @@ "properties": { "backend": { "description": "Required exactly when backend is present: the plugin's own process. Version 1 serves only its web pages. Version 2 may also start on onWorkspaceOpen or a plugin command, receives workspace contexts through plugin-function protocol 3, and may be granted workspace.metadata.", "enum": [1, 2] }, "buildModelInputs": {"description": "Optional public model sharing between already enabled workspace plugins; no installation, private function, or process authority. Required exactly when modelSharing or modelInputs is used. Consumers require buildProviders 6. Version 1 producers cannot also consume inputs.", "enum": [1]}, - "buildProviders": { "description": "Required exactly when any buildSystems[].provider is present, including own-process and shared language-server providers. Declares host support for Provider orchestration, independent of declarative buildSystems and serverProcess launch support; version 2 adds task execution, version 3 test execution/discovery, version 4 application run preparation, and version 5 native debug preparation; version 6 optional read-only model inputs.", "enum": [1, 2, 3, 4, 5, 6] }, - "buildSystems": { "description": "Declarative build detection, models, and command plans; does not imply buildProviders support.", "enum": [1, 2] }, + "buildProviders": { "description": "Required exactly when any buildSystems[].provider is present, including own-process and shared language-server providers. Declares host support for Provider orchestration, independent of declarative buildSystems and serverProcess launch support; version 2 adds task execution, version 3 test execution/discovery, version 4 application run preparation, and version 5 native debug preparation; version 6 optional read-only model inputs; version 7 non-sensitive native configuration sources; version 8 inline provider test reports; version 9 opaque semantic test selectors; version 10 typed run options and service/task preparation; version 11 registry-selected adapter and host-owned connection target for service debugging.", "enum": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] }, + "buildSystems": { "description": "Declarative build detection, models, and command plans; v3 adds instance-scoped options; does not imply buildProviders support.", "enum": [1, 2, 3] }, "debugAdapters": { "description": "Required exactly when debugAdapters is non-empty. Version 2 adds bounded target argument and environment bindings. The DAP session itself is negotiated by the adapter in initialize.", "enum": [1, 2] }, "executableResolution": { "enum": [1, 2], "description": "Shared executable candidate ordering and strict selection." }, "executionProviders": { "enum": [1] }, @@ -1955,6 +1955,78 @@ } } ] + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "toolchainID" + ], + "properties": { + "kind": { + "const": "toolchain" + }, + "toolchainID": { + "type": "string", + "maxLength": 192, + "pattern": "^(?:[a-z0-9]+(?:[.-][a-z0-9]+)*:)?[a-z][a-z0-9-]{0,63}$" + }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "priority": { + "type": "integer", + "minimum": -2147483648, + "maximum": 2147483647 + }, + "insertBefore": { + "type": "string", + "minLength": 1 + }, + "insertAfter": { + "type": "string", + "minLength": 1 + }, + "platforms": { + "type": "array", + "items": { + "enum": [ + "macos", + "linux", + "windows" + ] + } + } + }, + "allOf": [ + { + "not": { + "required": [ + "priority", + "insertBefore" + ] + } + }, + { + "not": { + "required": [ + "priority", + "insertAfter" + ] + } + }, + { + "not": { + "required": [ + "insertBefore", + "insertAfter" + ] + } + } + ] } ] }, @@ -2292,6 +2364,7 @@ "invocation" ], "properties": { + "configuration": {"type": "object", "additionalProperties": false, "required": ["source"], "properties": {"source": {"const": "provider"}}, "description": "Format-independent native configuration facts and provider-owned staged editing; requires buildProviders 7 and an owned provider."}, "modelSharing": {"type": "object", "additionalProperties": false, "properties": {"options": {"type": "array", "maxItems": 32, "uniqueItems": true, "items": {"type": "string"}}}, "description": "Opt into publishing normalized projects, targets and content roots, plus only these explicitly shared ordinary build option values. Never tasks, run plans, settings or optionValues."}, "modelInputs": {"type": "array", "minItems": 1, "maxItems": 8, "uniqueItems": true, "items": {"type": "object", "additionalProperties": false, "required": ["pluginID", "buildSystemID"], "properties": {"pluginID": {"type": "string"}, "buildSystemID": {"type": "string"}}}, "description": "Exact optional producer slots, sorted by Core. Missing or disabled producers are unavailable and never installed or activated automatically."}, "id": { @@ -2403,7 +2476,7 @@ "$ref": "#/$defs/buildVersionProbe" } }, - "description": "java, node, and go are host-managed and take only id and minimumVersion; any other runtime declares its executables and version probe." + "description": "java, node, and go use host version probes. With executableResolution 2 they may select one toolchain candidate; otherwise they take only id and minimumVersion. Other runtimes declare their executables and version probe." } }, "version": { @@ -2454,6 +2527,10 @@ ] }, "default": {}, + "scope": { + "description": "Instance scope requires buildSystems 3; omitted means workspace. Hosts persist each detected root independently.", + "enum": ["workspace", "instance"] + }, "choices": { "type": "array", "items": { @@ -2667,6 +2744,19 @@ "description": "Omitted expands argv in Core; provider sends lithe/build/execute. Provider tasks require buildProviders 2, empty arguments, and task execution.", "enum": ["provider"] }, + "preparation": { + "description": "buildSystems 4: automatic project preparation selected by workflow purpose and canonical model source role. Bindings must be unique within one build system; no prompted or fromModel tasks.", + "type": "object", + "additionalProperties": false, + "required": ["purpose", "sourceRoles"], + "properties": { + "purpose": { "type": "string", "pattern": "^[a-zA-Z][a-zA-Z0-9._-]*$", "maxLength": 120 }, + "sourceRoles": { + "type": "array", "minItems": 1, "maxItems": 6, "uniqueItems": true, + "items": { "enum": ["source", "generatedSource", "resource", "testSource", "generatedTestSource", "testResource"] } + } + } + }, "requiresUserArguments": { "type": "boolean" }, @@ -2698,12 +2788,16 @@ ], "properties": { "debug": { - "description": "Native executable debug preparation (buildProviders 5); same-package debugAdapters 2 launch adapter with typed program/cwd/args/env bindings.", - "type": "object", "additionalProperties": false, "required": ["adapter"], - "properties": { "adapter": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$", "maxLength": 64 } } + "description": "Same-package native launch adapter (v5), or a public registry configurationType for connection attach (v11 service runs).", + "type": "object", "additionalProperties": false, + "oneOf": [{"required": ["adapter"]}, {"required": ["configurationType"]}], + "properties": { + "adapter": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$", "maxLength": 64 }, + "configurationType": { "type": "string", "minLength": 1, "maxLength": 256 } + } }, "source": { - "description": "Provider-owned application preparation (buildProviders 4), fromTargets only, without host command templates.", + "description": "Provider-owned application preparation (buildProviders 4) or service/task preparation (buildProviders 10), fromTargets only, without host command templates.", "enum": ["provider"] }, "id": { @@ -2804,6 +2898,11 @@ "arguments" ], "properties": { + "selectorKinds": { + "type": "array", "maxItems": 16, "uniqueItems": true, + "description": "Public opaque semantic selector namespaces; provider tests and buildProviders 9 required.", + "items": {"type": "string", "maxLength": 120, "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$"} + }, "discovery": { "type": "boolean", "description": "Opt in to lithe/build/discoverTests; requires provider test source and suite/case filters." @@ -2873,6 +2972,18 @@ "const": "hostIntegration" } } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "decoder" + ], + "properties": { + "decoder": { + "const": "provider" + } + } } ] } diff --git a/shared/contracts/plugins/v1/proposals/toolchains/query.schema.json b/shared/contracts/plugins/v1/proposals/toolchains/query.schema.json index 4f1fa55cd1..b541ecc12b 100644 --- a/shared/contracts/plugins/v1/proposals/toolchains/query.schema.json +++ b/shared/contracts/plugins/v1/proposals/toolchains/query.schema.json @@ -334,8 +334,10 @@ "const": "2.0" }, "id": { - "type": "integer", - "minimum": 1 + "oneOf": [ + { "type": "integer", "minimum": 1 }, + { "type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[^\\u0000-\\u001f\\u007f]*$" } + ] }, "method": { "const": "toolchains/query" @@ -358,8 +360,10 @@ "const": "2.0" }, "id": { - "type": "integer", - "minimum": 1 + "oneOf": [ + { "type": "integer", "minimum": 1 }, + { "type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[^\\u0000-\\u001f\\u007f]*$" } + ] }, "result": { "$ref": "#/$defs/result" @@ -379,8 +383,10 @@ "const": "2.0" }, "id": { - "type": "integer", - "minimum": 1 + "oneOf": [ + { "type": "integer", "minimum": 1 }, + { "type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[^\\u0000-\\u001f\\u007f]*$" } + ] }, "error": { "type": "object", diff --git a/shared/contracts/plugins/v1/toolchains.md b/shared/contracts/plugins/v1/toolchains.md index af255a173d..732b43fe69 100644 --- a/shared/contracts/plugins/v1/toolchains.md +++ b/shared/contracts/plugins/v1/toolchains.md @@ -8,8 +8,16 @@ reuses each enabled provider's plugin backend, sends `toolchains/query` through `lsp.toolchainRequest`, passes every raw reply with native facts to `decodeReply`, runs the definition's version probe on executables Core returns from `candidates`, and shows the result. Any change to a watched input reloads the -whole snapshot. Nothing is saved, and no build, run or language-server launch -uses the result yet. Windows is not connected. +whole snapshot. Nothing is saved by these pages. The pinned Stage D integration +adds a separate fresh-resolution adapter for macOS Build and Provider Run; +settings detections are never used as launch permits. Existing Java and +language-server consumers still use their previous adapters. Windows now has +native Build plumbing for directory pins, declared scans, shell candidates and +operation-owned provider queries through Core's LSP transport. A window-bound +cancellation owner covers preparation and backend cleanup; this does not provide +a general background services host. Publisher signatures provide independent +origin evidence when present; legacy unsigned records never do. Windows product +capability admission remains closed. The [broader proposal](proposals/toolchains/README.md) still describes unsupported consumer migration, native lifecycle, persistence and manager-plugin work. The active shape is [plugin-manifest.schema.json](plugin-manifest.schema.json), @@ -23,6 +31,9 @@ not the proposal envelopes. `backend.launch` / `toolchains.provide`. Capability lists are exact: missing or unused capabilities reject the manifest. A definition/provider alone is a valid contribution. Old executable candidate sets retain their strict source types. +Build consumers may also require executableResolution 2 for their existing +`toolchain` candidate without contributing a definition; they do not gain +definition-owned candidate fields. Definitions own IDs, labels/icons, `executableLayouts`, ordered `executables`, `version`, `homeLayouts`, `discovery`, `homeVariable`, `commands`, `wrappers`, @@ -47,6 +58,16 @@ execution. Short definition IDs require independently verified official origin; community definitions use `:`. Community providers may answer short IDs. No host-owned tool table, name alias or manager parser is added. +A native Build consumer that directly executes a tool currently accepts only +`["${executable}", "${arguments}"]`: its tool context carries a program and +separate Build arguments. Extra launch flags or `${home}` tokens are refused +before probing, rather than dropped. An installation dependency instead consumes +checked version evidence and declared exports; it does not instantiate the tool's +entry-launch recipe. For example a runtime can declare a JAR recipe while a build +engine consumes only that runtime's installation and environment. Such a dependency +is not published as a direct executable binding. Entry processes continue to use +their own declared process recipe; supporting more direct templates remains pending. + ## Two installation routes A definition receives installations through either route, or both. Neither is @@ -113,6 +134,10 @@ the host-owned session, not a provider claim. `platform` is `macos`, `windows` o generation,providerID,authority:{kind:"local"},context,workspace,tools}}`. `context` is `{contextID,generation}` or `null`; workspace queries require it. `workspace` is `{rootID,root,directory}` or `null` for global-only queries. +The JSON-RPC `id` is a positive integer or a nonempty string of at most 128 +UTF-8 bytes without control characters; the reply preserves its exact type and +value. Core's LSP transport uses string IDs. Numeric `1` and string `"1"` are +distinct identities and cannot acknowledge each other's request. `directory` is normalized workspace-relative, with `.` for the opened root. Tools are unique and sorted. This envelope is validated as inert data; no query is dispatched and no protocol capability is negotiated by this command. @@ -168,7 +193,7 @@ Saving, conflict protection and `.lithe/.gitignore` updates are not implemented. platform,workspaceRoot,filesystem}` and accepts `workspaceRoots`, `filesystemCaseSensitive`, `local`, `minimumVersion`, `probes`, `queries`, `systemInstallations`, `globalDefault`, `environment`, `commandHomes`, -`projectCodeAllowed` and `providerChoices`. `local` defaults to an empty v1 +`projectCodeAllowed`, `providerChoices` and `fixed`. `local` defaults to an empty v1 document. Probes are host observations `{executable,output,exitCode}` keyed by the canonical executable a selection reports; system installations are `{tool,home}`. Shell environment home variables and `commandHomes` come from the native login-shell @@ -192,6 +217,18 @@ that could not answer. Safety, identity and cancellation failures are not converted to missing tools. A valid explicit pin does not need lower-layer provider answers at all. +A consumer may supply `fixed:{home}` or `fixed:{executable}` for one operation. +Each object has exactly one field, an absolute native path bounded to 4,096 UTF-8 +bytes, without traversal or control characters. It contains no version, environment, +provider or execution grant. This explicit choice precedes directory pins and fails +terminally if invalid; lower scans and providers cannot replace it. Installation +choices use the definition's layouts and exports. An executable choice preserves +its lexical launcher path, has no inferred home, and omits home exports and PATH +entries. It requires a fresh successful version probe even without a minimum. +A definition whose launch requires `${home}` cannot use that choice. A lexical or +canonical executable inside the workspace requires the current explicit action +before it becomes a probe target. Directory pins and packages stay unchanged. + Four layers apply: fixed nearest pin; nearest project source (wrapper > environment > declaration, native reader before compatible reader of the same source); application global default > unambiguous/chosen provider global > login shell; @@ -229,12 +266,16 @@ are 256 roots, 4096 entries and 64 ancestors for the host adapter. automatic layers without resolving a directory. It requires `{registry,tool, platform,workspaceRoot,filesystem}` and accepts `workspaceRoots`, `filesystemCaseSensitive`, `minimumVersion`, `probes`, `systemInstallations`, -`providerInstallations` and `environment`, with the same meaning and checks as in +`providerInstallations`, `environment`, `fixed` and `projectCodeAllowed`, with the same meaning and checks as in `resolve`. `providerInstallations` (at most 512) are `{tool,home,provider}` rows a host copies from `decodeReply` snapshots; `provider` must name a registry provider `pluginID:providerID` declaring the tool, and the row keeps that attribution when a scan reports the same canonical home. The output is `{tool,shell,installations,skipped,selected,conflicts}`: +A checked `fixed` choice without a version contributes a `probeFailed` skipped row +with its executable for the native probe. An unauthorized workspace program never +contributes such a target. This candidate report itself grants no authority. + - `shell` lists each declared home variable present in `environment` as `{candidateID,variable,home,executable?,version?|reason?}`, checked through diff --git a/shared/contracts/run-configuration-v2.schema.json b/shared/contracts/run-configuration-v2.schema.json index 6869f15180..057f1e4504 100644 --- a/shared/contracts/run-configuration-v2.schema.json +++ b/shared/contracts/run-configuration-v2.schema.json @@ -11,6 +11,8 @@ "required": ["fingerprint"], "properties": { "fingerprint": { "type": "string", "pattern": "^sha256:" }, + "providerFingerprint": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" }, + "providerRunFingerprint": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" }, "unreadablePaths": { "type": "array", "items": { "type": "string" }, "uniqueItems": true }, "inputs": { "type": "object", @@ -24,6 +26,7 @@ "type": "object", "description": "Machine-local project toolchain defaults. Stored only in the local layer; non-empty per-configuration paths override matching defaults.", "properties": { + "preferences": { "$ref": "toolchain-consumer-preferences-v1.schema.json" }, "java": { "type": "object", "properties": { @@ -38,6 +41,21 @@ "javaHomePath": { "type": "string" } }, "additionalProperties": false + }, + "pathOverrides": { + "type": "object", + "description": "Machine-local subproject selections, keyed by normalized workspace-relative root. Empty fields inherit project defaults. Hosts preserve the map when saving individual run configurations.", + "maxProperties": 256, + "propertyNames": { "type": "string", "minLength": 1, "maxLength": 1024, "pattern": "^(?!/)(?!.*//)(?!.*/$)(?!.*(?:^|/)(?:\\.|\\.\\.)(?:/|$))[^\\\\:\\x00-\\x1f\\x7f]+$" }, + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "properties": { + "javaHomePath": { "type": "string", "maxLength": 4096 }, + "mavenExecutablePath": { "type": "string", "maxLength": 4096 }, + "mavenJavaHomePath": { "type": "string", "maxLength": 4096 } + } + } } }, "additionalProperties": false @@ -48,6 +66,17 @@ } }, "$defs": { + "buildProjectReference": { + "type": "object", "additionalProperties": false, + "description": "Read-only current model owner; generated ownership cannot be overridden by document layers.", + "required": ["pluginID", "buildSystemID", "root", "projectID"], + "properties": { + "pluginID": { "type": "string", "minLength": 1, "maxLength": 200 }, + "buildSystemID": { "type": "string", "minLength": 1, "maxLength": 120 }, + "root": { "$ref": "#/$defs/relativePath" }, + "projectID": { "type": "string", "minLength": 1, "maxLength": 256 } + } + }, "relativePath": { "type": "string", "pattern": "^(?!/)(?!.*(^|/)\\.\\.(?:/|$)).*$" @@ -63,6 +92,7 @@ "targetID" ], "properties": { + "execution": { "enum": ["application", "service", "task"] }, "pluginID": { "type": "string", "minLength": 1, @@ -102,6 +132,75 @@ }, "additionalProperties": false }, + "fixedToolSelection": { + "oneOf": [ + { + "type": "object", + "required": [ + "home" + ], + "additionalProperties": false, + "properties": { + "home": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "description": "Absolute native path; Core rejects traversal and control characters before use." + } + } + }, + { + "type": "object", + "required": [ + "executable" + ], + "additionalProperties": false, + "properties": { + "executable": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "description": "Absolute native path; Core rejects traversal and control characters before use." + } + } + } + ] + }, + "buildProviderRunOptions": { + "type": "object", + "additionalProperties": false, + "properties": { + "runtimeArguments": { + "type": "array", + "maxItems": 1024, + "items": { + "type": "string", + "maxLength": 65536, + "pattern": "^[^\\u0000]*$" + } + }, + "entrypoint": { + "type": "string", + "maxLength": 4096, + "pattern": "^[^\\u0000]*$" + }, + "options": { + "type": "object", + "maxProperties": 64, + "description": "Opaque declared Build option values, bounded to 64 KiB UTF-8 JSON by Core; validated against the current declaration during preparation.", + "propertyNames": { + "pattern": "^[A-Za-z0-9._-]{1,200}$" + } + }, + "tools": { + "type": "object", + "maxProperties": 64, + "description": "Per-Run fixed tools, bounded to 64 KiB UTF-8 JSON by Core. Entries merge by tool ID; null clears an inherited Run choice and resumes directory policy.", + "propertyNames": { "pattern": "^(?:[a-z0-9]+(?:[.-][a-z0-9]+)*:)?[a-z][a-z0-9-]{0,63}$", "maxLength": 192 }, + "additionalProperties": { "oneOf": [{ "$ref": "#/$defs/fixedToolSelection" }, { "type": "null" }] } + } + } + }, "configuration": { "type": "object", "required": ["id", "provider"], @@ -136,6 +235,13 @@ "extensions": { "type": "object", "propertyNames": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" }, + "properties": { + "java": { "type": "object", "properties": { + "build": { "$ref": "#/$defs/buildProjectReference" }, + "module": { "type": "string" }, + "mainClass": { "type": ["string", "null"] } + } } + }, "additionalProperties": { "type": "object" } }, "disabled": { "type": "boolean" }, @@ -147,15 +253,16 @@ "then": { "required": ["extensions"], "properties": { - "execution": { "const": "application" }, + "execution": { "enum": ["application", "service", "task"] }, "command": false, - "args": { "maxItems": 0 }, - "env": { "maxProperties": 0 }, "toolchains": { "maxProperties": 0 }, "debug": false, "extensions": { "required": ["build"], - "properties": { "build": { "$ref": "#/$defs/buildProviderReference" } } + "properties": { + "build": { "$ref": "#/$defs/buildProviderReference" }, + "build-run": { "$ref": "#/$defs/buildProviderRunOptions" } + } } } } diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 014f5f12e3..de62e8e935 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -185,6 +185,10 @@ launch already succeeds there. Strings returned by the core are UTF-8 JSON allocated by Rust. The caller must release response strings with `lithe_core_free_string`. +Rust native adapters may call `execution::java_version_from_release` to read the +full JDK version from bounded host-read `release` metadata, then reuse Core's +runtime candidate ordering. Passive Build discovery never falls back to running +Java when that metadata is missing or invalid; explicit tool execution owns probes. ### Localized action search `search.actions` accepts `{query, candidates: [{texts: [string]}]}` and returns @@ -1208,7 +1212,7 @@ package manager owns the download and Lithe does not infer bytes from logs. | `lsp.startServer` | Start one Rust-owned process/session and begin initialization | | `lsp.jdtWorkspaceKey` | Derive the deterministic JDT LS workspace-state directory key | | `java.workspacePolicy` | Decide Java workspace activation and classify changed paths | -| `java.runMarkers` | Project JDT main/test discovery and Maven test outcomes into editor Run markers | +| `java.runMarkers` | Project JDT main/test discovery and canonical build test outcomes into editor Run markers | | `java.jdtWorkspaceFingerprint` | Reduce platform build-file observations to the portable JDT LS workspace fingerprint | | `java.jdtCacheRetention` | Select expired inactive JDT LS workspace-state keys from platform metadata | | `lsp.stopServer` | Gracefully shut down a session, with a bounded force-stop fallback | @@ -1244,9 +1248,11 @@ package manager owns the download and Lithe does not infer bytes from logs. | `mybatis.index` | Build a deterministic MyBatis mapper-interface and XML statement index | | `runConfig.selectJava` | Select a project-compatible automatic JDK from platform-probed candidates | | `runConfig.inspect` | Inspect `.lithe` run documents, versions, and staleness without writing files | +| `runConfig.projectToolchain` | Read saved project tool paths for one Provider root, with the nearest path override | | `runConfig.generate` | Generate deterministic Java/Maven configurations and toolchain requirements | | `runConfig.resolve` | Merge generated, project, and local layers and return diagnostics | | `runConfig.updateOptions` | Apply typed option edits and return an updated project or local document | +| `runConfig.updateToolPreferences` | Replace only generic preferences in a captured local Run document, preserving legacy defaults and unrelated metadata | | `runConfig.saveEditorChanges` | Prepare the local and optional project documents for one editor save | | `runConfig.createUserConfiguration` | Validate a typed user configuration and return an updated document | | `runConfig.createLaunchPlan` | Project one effective configuration into a platform-neutral Run or Debug plan | @@ -1340,6 +1346,8 @@ package manager owns the download and Lithe does not infer bytes from logs. | `toolchains.decodeReply` | Validate an inert toolchain query/reply pair against bound provider identity, generation, declared tools and host-native filesystem evidence; no transport or native I/O | | `toolchains.failureReply` | Build the JSON-RPC error reply for a query that missed its deadline or found no backend, so the host passes it to `toolchains.decodeReply` like a provider's own error; cancellation has none | | `toolchains.resolvePins` | Validate a captured local document and find the nearest ancestor directory pin, retaining dormant and legacy entries without probing or writing | +| `toolchains.resolveConsumerPreferences` | Validate an inert consumer preference document and resolve one tool's explicit Run, consumer, directory or workspace choice while keeping workspace/consumer inheritance symbolic; no native files or launch authority | +| `toolchains.editConsumerPreferences` | Edit or reset one exact consumer/directory tool preference; return the document and effective input without native writes | | `toolchains.resolve` | Resolve the fixed/project/global/automatic layers from one generation-bound snapshot, retaining unmet requirements, unavailable providers with their cause, conflicts and terminal fixed-value failures | | `toolchains.checkVersion` | Match captured output with an enabled plugin definition's version expression and compare generic numeric components with an optional minimum; never starts a probe | | `toolchains.discoveryPlan` | Return platform-filtered definition-owned scan declarations, layouts, probes, PATH entries, login-shell home variables and budgets; never scans native directories | @@ -1347,16 +1355,22 @@ package manager owns the download and Lithe does not infer bytes from logs. | `plugins.declarative.decodeGrammar` | Validate independent static editor resources (`languages` 2) | | `plugins.editorActions.decode` | Validate independent action registration or collection results | | `build.detect` | Find every build-system instance the enabled plugins declare in a workspace, with models that need no process | +| `build.resolveStoredOptions` | `{ document, local: { version: 1, document, emptySelections?: { : { : [optionID] } } } }`, without a manifest | `storage: { document, local, emptySelections }`: checked team/local merge; local values override matching keys, unrelated systems/roots/choices stay intact. No filesystem I/O. Local documents are machine/workspace preferences, not team files or plugin-native configuration. Their storage syntax is [build-local-options-v1.schema.json](build-local-options-v1.schema.json), with additional cross-field membership and byte limits checked by Core. | +| `build.editStoredOption` | `{ document, local, root, optionID, value, modelValues? }` in the standard manifest/build-system envelope | `edit: { local, options }`: changes one declared local preference. `null` removes the local override and restores team/declaration inheritance. Invalid or partially accepted edits reject before persistence. Workspace multi-selections preserve sibling-only values; explicit per-root empty markers apply only to workspace multi-select declarations. Platforms compare both native source revisions, atomically write only the local document and read back. | | `build.invocationPlan` | List a build system's executable candidates, runtime dependencies, and version probe for one platform | | `build.checkToolVersion` | Decide whether a build tool's or runtime's version output meets the declared minimum | | `build.resolveModelInputs` | Validate enabled producer facts and project a bounded public model snapshot for a declared consumer | | `build.resolveOptions` | Check stored build options against the declaration and return effective values, choices, and the fingerprint | +| `build.configurationPlan` | Plan a read of the provider's non-sensitive native configuration (v7) | +| `build.decodeConfiguration` | Validate native source identities, raw/effective values and XML bindings | +| `build.configurationEditPlan` | Plans format-independent provider editing into private host staging | +| `build.decodeConfigurationEdit` | Validates the receipt without native document bytes | | `build.modelPlan` | Expand the command that prints a command-sourced build model, or the request a build provider answers | | `build.taskPlan` | Expand one declared or model task for a workspace, project, or target | | `build.testPlan` | Plan a test run for a scope, suite, or case and say where its reports will be | | `build.testDiscoveryPlan` | Plan opt-in Provider test discovery for an instance | | `build.decodeTestDiscovery` | Validate, deduplicate and order Provider test selectors | -| `build.prepareRunPlan` | Plan Provider-owned application preparation from a model target | +| `build.prepareRunPlan` | Plan Provider-owned application/service/task preparation from a model target | | `build.decodeRunPreparation` | Validate portable launch data before the native Run host starts a process | | `build.prepareDebugPlan` | Plan same-plugin native debug preparation (Provider v5) | | `build.decodeDebugPreparation` | Validate an existing instance-contained native debug program and bounded process facts | @@ -3076,7 +3090,9 @@ workspaces or older structural states. `java.runMarkers` accepts `{ mainMethods, testItems, testCases }`: the `methods` of one file's `javaMainMethods` answer, the `items` of its `javaTestItems` answer (empty when the platform cannot run tests for the file), -and recorded `maven.testResults` `testCases`. It returns `{ markers }` ordered by +and canonical build-provider report cases (`suite`, `case`, `status`). The older +`className`/`method` field names remain accepted for Windows compatibility. +It returns `{ markers }` ordered by zero-based `line`, then `kind` (`main`, `testClass`, `testMethod`). `endLine` is the last line of the declaration as reported upstream: the end of the body for test classes and methods, and the name line for `main`. Each marker @@ -3180,6 +3196,11 @@ provider-sourced `build.taskPlan`/`build.testPlan`, `build.testDiscoveryPlan`, o execution lifecycle and `lithe/build/discoverTests` with the raw result lifecycle. Version 4 adds `lithe/build/prepareRun`, with execution progress but a raw launch plan passed to `build.decodeRunPreparation` before native process startup. Version 5 adds `lithe/build/prepareDebug`: raw native executable facts pass through `build.decodeDebugPreparation` and the existing DebugAdapter Registry, not private functions or a second DAP engine. +Version 10 adds typed `launchOptions` for this run method and host-owned service/task +preparation. These options require explicit version-10 negotiation before a +request is written. The decoder accepts a workspace cwd outside the instance +only when it matches the same host-validated explicit option. Native debug +preparation retains its existing application contract. Other methods are `invalid_request`; tasks on version 1 and tests on versions 1/2 or run preparation on versions 1/2/3 are `not_supported`. Every session offers @@ -3320,7 +3341,16 @@ and an empty diagnostic context. The round-trip fixture is `javaEntrypoints` invokes Java Debug Server's `vscode.java.resolveMainClass` for the session workspace and returns schema version 1 with deterministic workspace-relative `{ sourcePath, mainClass, -projectName? }` entries plus diagnostics for unusable upstream records. +projectName? }` entries plus diagnostics for unusable upstream records. Discovery +waits behind pending Maven profile updates and JDT project-configuration jobs, +using the same configuration barrier as the following Java build. Up to 128 +waiting discovery operations are allowed per session. Their deadlines include +configuration waiting and the eventual upstream query, bounded by the session's +Java build timeout. Cancellation before dispatch completes that operation without +writing a JSON-RPC discovery request or cancellation; shutdown/failure settles +every waiter. A completed profile update must precede discovery of source roots +introduced by that profile. + `javaTestItems` requires a file URI, invokes Java Test's `vscode.java.test.findTestTypesAndMethods`, and returns schema version 1 with a typed class/method tree. Each item carries the upstream identity, label, fully @@ -3598,6 +3628,27 @@ Windows `run_resolve_toolchains` includes an optional `warning` on resolved JDKs, including inherited Maven JDKs. This warning remains visible even when no run configuration exists to carry a scoped `toolchainVersionMismatch` diagnostic. +`runConfig.projectToolchain` reads `{root,path,localDocument?,configurationId?}` and returns +`{toolchain:{javaHomePath,mavenExecutablePath,mavenJavaHomePath,runtimeExecutablePaths}}`, even without +generated Run configurations. `path` is the current Provider's normalized, +workspace-relative project/reactor root (`.` for the workspace). The nearest +`toolchain.pathOverrides` entry owns all three fields; its empty/missing fields +inherit workspace defaults, not another ancestor override. Matching uses path +segments, so `app` never owns `app2`. Malformed stored paths/documents fail +without changing source bytes. The command does not discover, probe or run any +tool, inspect project descriptors, or write files. `runtimeExecutablePaths` +comes from the existing `.lithe/toolchains/local.json`; platforms resolve the selected +paths through their existing toolchain adapters. Shared examples are in +`shared/fixtures/run-configuration/project-toolchain-selection.json`. +When `configurationId` is supplied, explicit Java/Maven paths from that current +Run configuration override the canonical project/reactor selection. Core reuses +the existing team/local configuration merge without projecting global defaults +as explicit configuration choices. Empty configuration fields inherit the +canonical selection; missing, disabled, mismatched-root or malformed choices fail +instead of falling back. No generated layer is required for an explicitly saved +Provider reference. Shared cases are in +`shared/fixtures/run-configuration/configuration-toolchain-selection.json`. + `runConfig.inspect` also returns the local document-level `toolchain`, including when no generated configuration exists (`status: "missing"`). Settings, toolchain-only callers, and initial run-panel presentation may send @@ -3641,8 +3692,67 @@ toolchain payload with an empty `configurationId`; service overrides are untouch The `runConfig.*` commands implement the versioned project protocol described by the JSON Schemas in this directory. `runConfig.inspect` accepts `root` and never writes files. `runConfig.generate` accepts `root`, relative Java `paths`, -relative `modulePaths`, and optional schema-versioned `javaEntrypoints` from the -current `lsp.request` result. When Java tooling is still preparing, omission +relative `modulePaths`, optional schema-versioned `javaEntrypoints` from the +current `lsp.request` result, and optional `projectOwnership`. The latter is a +host projection of current enabled Provider projects: `{ path, +name?, build?: { pluginID, buildSystemID, root, projectID }, minimumJavaVersion?, contentRoots: [{ path, kind }] }`, using workspace-relative +paths and the model's `source`/`testSource`/generated-source roles. Core validates +project/root containment, unique project paths, roles and bounds (2,000 projects, +10,000 content roots). When supplied, including `[]`, these facts own Java +project membership and source sets: `modulePaths` and ancestor POM inference +do not supplement them. `build` is an exact current model identity, not a build +semantic tag. Generated Java entries store it under `extensions.java.build`, +with Java-owned `module` and `mainClass`; they contain no Maven extension or +Maven tool binding. Its root stays independent of editable cwd and document +layers cannot replace generated build ownership. An omitted snapshot retains the current +Windows generator's legacy ownership path. A supplied ownership snapshot disables +legacy Maven framework detection. Framework selection is not an ownership field; +run discovery uses the independent generic snapshot described below. +`minimumJavaVersion` is an optional normalized Java feature version (1–999), +owned by the upstream compiler model, including before entrypoint discovery +returns any sources. When the snapshot is supplied, Core uses +the highest known project floor and never parses POM compiler properties; a +platform Java version file can raise but cannot lower that floor. Missing means +unknown, not permission to infer. Build-tool versions and wrapper paths are not +ownership fields. With provider ownership Core emits no Maven requirement and +never reads wrapper properties or scripts to supplement it. Installed tool +definitions/providers own these choices. Native Java preparation routes by the +exact enabled package, declaration, instance and project identity. Legacy +consumers that omit provider ownership retain their old Maven launch path until +they migrate; those fields are not accepted in the new ownership projection. See `fixtures/run-configuration/provider-project-ownership.json`. +The generated document records optional `generator.providerFingerprint`, a +SHA-256 identity of the validated projection, with projects and roots sorted. +It covers project labels, ownership, source roles and requirement +facts, but never environment values or native configuration secrets. +`runConfig.inspect` accepts the same optional `projectOwnership` and emits +`staleProviderModel` when current facts differ or a legacy generated document +has no provider identity. `checkFingerprint: false` skips file hashing only; +this model comparison remains active. Explicit `[]` represents disabled/missing +providers, while omission preserves existing Windows callers. A return to the +same semantic facts matches again; asynchronous host publication still needs +its independent monotonic operation revision to reject ABA replies. + +`runConfig.generate` and `runConfig.inspect` also accept optional `providerRuns`: +a complete list of `{projectPath,name,reference}`. The native host matches current +model targets against enabled public run declarations; `reference` has exactly the +`extensions.build` selector shape below, with empty model `userArguments`. This +snapshot is independent of Java entrypoints and language imports. Core validates +at most 10,000 unique selectors, provider-root/project containment and bounded labels, +then creates `build.provider` entries without interpreting target IDs, tool names, +frameworks, commands or debug adapters. An explicit `[]` withdraws generated runs; +omission preserves older callers. It also disables the legacy framework detector. +IDs hash plugin/build/root/run/project/target identity, so changed labels or execution +kind retain user overrides. Current declarations still validate the execution kind +before preparation. `generator.providerRunFingerprint` hashes the complete sorted +snapshot; changed or missing metadata yields `staleProviderModel`, including when +file fingerprinting is disabled. Withdrawn entries cannot be restored from cached +configurations; unmatched overrides stay in their user-owned layer and are diagnosed. +See `fixtures/run-configuration/provider-project-ownership.json`. macOS supplies this +snapshot; Windows retains its existing generator until its Provider host is migrated. + +Java configurations with the same class and module name in independent reactors +remain distinct; a common reactor retains its existing configuration IDs. + When Java tooling is still preparing, omission retains the previous generated entrypoint facts instead of running a local scanner or replacing them with an empty list. Those previous facts come from this process's last generation, else from the optional `cacheDirectory` (an @@ -3654,8 +3764,10 @@ layer for `inspect`, `resolve`, and `selectJava`; nothing is written into the workspace. `inspect` returns `requiresGeneration: true` while this process has no detected layer. Hosts identify the workspace when this flag is true or the status is `missing`; a saved provider task can make `status` be `ready` before -detection, and must not suppress discovery of the remaining entries. Maven root +detection, and must not suppress discovery of the remaining entries. Without +`projectOwnership`, legacy Maven root discovery checks `pom.xml` along each supplied path's ancestor chain, so a + reactor nested below the opened workspace does not depend on the platform including build descriptors in `paths`. Maven ownership is resolved per Java entry: standalone sources keep the JDK launch path, while entries from @@ -3665,13 +3777,17 @@ detector revision; either changing marks the detected layer stale and requires regeneration. `runConfig.resolve` accepts `root`, optional local `toolchainCandidates`, and -optional `localDocument`. When `localDocument` is present, Core uses that JSON -object as the local layer instead of reading `.lithe/run/local.json`. It merges +optional `localDocument` / `teamDocument`. A supplied document replaces that layer +for this read-only resolution; omitted layers come from `.lithe/run/local.json` / +`.lithe/run/configurations.json`. Hosts can preflight a complete two-file transaction +against the current detected layer without publishing either file. Core never writes +these documents; hosts still own source-identity checks, cancellation, conditional +publication and readback. It merges configurations by stable ID using this precedence: `local.json > configurations.json > detected layer`. Scalars and arrays are replaced by the higher layer, while toolchain maps merge by key. It returns effective configurations, their source, the default (the team default from -`.lithe/project.json`, else the first detected framework service, else the first +`.lithe/project.json`, else the first detected service, else the first detected entry other than Current File), structured diagnostics for stale, orphaned, missing, disabled, and toolchain mismatch states, the effective global `toolchain`, and the machine-local @@ -3736,19 +3852,78 @@ per-configuration toolchain path overrides the corresponding project default. Provider applications can be explicitly saved with `runConfig.createUserConfiguration` using `{root,scope:"local",name,type:"buildProvider",build:{pluginID,buildSystemID, -root,run,projectID,targetID,userArguments?}}`. Core upserts the existing local -reference by selector identity and retains its `user:` ID, unrelated configurations +root,run,projectID,targetID,userArguments?,execution?}}`. Core upserts the existing local +reference by selector identity and retains its existing ID (new references use `user:` IDs), unrelated configurations and project toolchain defaults. `extensions.build` in Run v2 holds the typed -reference; process commands, top-level args/environment/toolchains/debug are -refused for `build.provider`. Current model membership is revalidated by the +reference; optional execution is application/service/task and must match the current +declaration before preparing (omitted older references mean application). The saved +configuration retains that execution kind when reopened. Process commands, toolchain +bindings and cached debug adapters are refused for `build.provider`. Generic args, +environment and cwd overrides are user choices forwarded through typed launch options; +explicit args replace older reference userArguments, including an empty array. +An implicit selector-root cwd remains omitted from launch options for v4 compatibility. Current model membership is revalidated by the native build workflow before every preparation. No generated document is needed: -`runConfig.inspect` recognizes explicit saved local references and `runConfig.resolve` +`runConfig.inspect` recognizes explicit saved team/local references and `runConfig.resolve` merges them against an empty generated layer. A workspace without generated data or an explicit saved Provider reference retains the existing missing state. +Complete saved `build.provider` entries (name, provider, selector and matching execution) +can retain any existing Run ID, including IDs imported from older formats; a prefix +does not grant launch authority. Sparse unknown-ID patches remain orphaned. Such an +entry also makes a team-only workspace runnable before generation, while still returning +`requiresGeneration: true`. Current declaration/target validation remains mandatory. +When generation contains that selector, resolution suppresses its duplicate automatic +entry, retains all explicitly saved configurations, and resolves defaults through the +same effective selector. Local selector patches update the effective claim, so an +older team target does not accidentally hide a different auto entry. Detection +fingerprints and the detected document remain unchanged by user-layer resolution. +See `fixtures/run-configuration/build-provider-stable-identities.json`. + The platform guards the source snapshot, atomically writes only the local layer, then re-reads the result. `shared/fixtures/run-configuration/build-provider.json` is the canonical persistence example. +`extensions.build-run` stores portable user choices separately from the selector: +`{runtimeArguments?: string[], entrypoint?: string, options?: object, tools?: object}`. Runtime arguments +retain exact argv boundaries (at most 1,024 elements, each at most 65,536 UTF-8 bytes). +The opaque entrypoint is at most 4,096 bytes; an empty string explicitly clears an +inherited entrypoint. `options` holds at most 64 declared Build option values, within +64 KiB of UTF-8 JSON; keys are bounded portable identifiers. Core does not interpret +the values. The native Build service validates every value against the current +selected declaration/model before preparation, including explicit `false` and `[]`. +`tools` maps at most 64 definition IDs to `{home:absolutePath}` or +`{executable:absolutePath}`, or null. Its total UTF-8 JSON is bounded to 64 KiB; +paths are at most 4,096 bytes, with the toolchain lexical path checks. Entries merge +by tool ID across team/local layers. Null clears an inherited Run choice and resumes +directory policy; omission or an empty map preserves existing tool entries. The +native host resolves each active choice against an enabled, referenced definition, +current filesystem facts and version probes. It never changes a directory pin. +Commands, cached executable evidence and debug adapters are not fields in this namespace. + +`runConfig.updateOptions` and `runConfig.saveEditorChanges` accept optional +`providerRunOptions` in this shape for a `build.provider` configuration. Omission +preserves that namespace for older callers; explicit null is rejected. A supplied +snapshot replaces each of its fields in the selected layer, normalizing omitted fields +to `runtimeArguments:[]`, `entrypoint:""`, `options:{}` so reset can clear inherited +choices. `tools` instead uses the per-entry inheritance and null-clearing rules above. +Other extension namespaces and the selector remain intact. Re-saving the +same selector preserves its working-directory override and portable choices. +The production macOS store, Windows editor and preference copies carry the typed snapshot unchanged. +See `fixtures/run-configuration/build-provider-options.json`; no new Provider RPC or +SDK protocol version is needed, because preparation reuses accepted v10 launch options. Editor argument text uses quoting/escaping without expansion or execution; empty quoted tokens remain empty argv items. `fixtures/run-configuration/argument-text.json` is shared by Core and both editors. + +`runConfig.providerLaunchInputs` takes `{root,configurationId,providerRoot,localDocument?}` +and requires a complete `build.provider` selector. Unmigrated tool-specific records are rejected; only an isolated host import may translate their retired format. It resolves the same document layers for one preparation. It returns bounded typed +`launchOptions`, explicit build-option overrides and project identity without planning +a tool command. For `build.provider`, `extensions.build-run.runtimeArguments` and its +non-empty entrypoint populate launch options, and its `options` populate optionOverrides. +Active fixed choices are returned separately in `tools`, and in `definedTools` by +`runConfig.projectToolchain` for the matching configuration/provider root. They are +host-only resolution inputs and are never forwarded as Provider launch options. +Saved selectors additionally return the exact typed `reference`; the native Build service revalidates its plugin, instance, run, project, +target and execution against the current enabled model. Parameters must be read again +before process launch, and an invalidated configuration or operation rejects late replies. + + `runConfig.updateOptions` and `runConfig.createUserConfiguration` are pure document transformations. They validate scope, paths, supported types, stable IDs, main classes, modules, and argument parsing, then return UTF-8 JSON in the @@ -3771,6 +3946,18 @@ When `updateOptions` carries a `toolchain` object (`javaHomePath`, global toolchain into the local layer instead of patching a configuration; project scope rejects this payload because toolchain paths are machine-local. +The project toolchain may also carry `pathOverrides`, a map of normalized +workspace-relative subproject roots to `{ javaHomePath?, mavenExecutablePath?, +mavenJavaHomePath? }`. The map has at most 256 entries; roots reject absolute, +parent, dot, empty-segment, drive, backslash and control-character forms. Empty +fields inherit project defaults. This map lives only in `.lithe/run/local.json` +and is returned by inspection/resolution; hosts preserve it when saving a run +configuration or project defaults. An absent map identifies a document predating +subproject migration; a saved empty map is an explicit choice. Old platform +preferences are copied and read back before cleanup, with already saved choices +remaining authoritative. See `shared/fixtures/run-configuration/subproject-toolchains.json`. + + `runConfig.saveEditorChanges` accepts the normal option-edit payload plus the required `toolchain` object. In addition to Java and Maven paths, that object may contain `runtimeExecutablePaths`, keyed by stable generic toolchain ID. It @@ -3821,7 +4008,13 @@ framework arguments, including `-am` for selected reactor modules. A project-owned `java.main` caller, or a `spring-boot.maven` caller whose generated configuration records a Java entry source, instead supplies `javaLaunch` with the exact JDT LS-selected `mainClass` plus structured `classPaths` and -`modulePaths`. Core then produces a direct `project-jdk` launch; it never +`modulePaths`. An optional `javaLaunch.buildPrepared: true` is a host receipt +that the current build provider resource task completed before JDT launch +resolution. Core then omits its legacy Maven resource pre-launch step and does +not inspect POMs for that step. The host must recheck workspace/configuration +generation and cancellation before handing off the launch; it cannot reuse a +receipt from another operation. Missing/false retains compatibility for current +Windows callers. See `fixtures/run-configuration/provider-prepared-java.json`. Core then produces a direct `project-jdk` launch; it never projects those Java entry points into a reactor-wide Maven launch goal. A Spring Boot configuration without a resolved Java entry source retains its Maven-goal compatibility path. It @@ -4523,7 +4716,7 @@ a host advertises each only after implementing its lifecycle and requests. macOS advertises all six and routes provider tasks, tests and run preparation through the existing workspace session. Prepared applications use the existing native Run host. Windows does not yet advertise Provider lifecycle support. -Except `build.detect`, every command accepts +Except `build.detect`, `build.projectOptions` and `build.resolveStoredOptions`, every command accepts `{ manifest, buildSystemID, request }`, revalidates the manifest, and answers `plugin.build.unavailable` for an undeclared build system or operation. Results use the `plugins.declarative.*` envelope under the key named below; @@ -4555,18 +4748,23 @@ and the directories are absolute host paths used only as substitution text. | Command | Request | Result | | --- | --- | --- | -| `build.detect` | `{ workspaceRoot, plugins: [{ manifest }], options?: { : { : value } } }` | `detection`: `{ instances, rejected: [{ index, error }], shadowed: [{ pluginID, buildSystemID, by }] }`; each instance is `{ id, pluginID, buildSystemID, displayName, root, markers, modelSource, optionFingerprint }` plus `model` or `modelError` for `static` and `hostIntegration` sources; a `provider` source has neither | +| `build.detect` | `{ workspaceRoot, plugins: [{ manifest }], options?: { : { : value } }, instanceOptions?: { : { : { : value } } }, instanceDefaults?: { : { : value } }, emptySelections?: { : { : [optionID] } } }` | `detection`: `{ instances, rejected: [{ index, error }], shadowed: [{ pluginID, buildSystemID, by }] }`; each instance is `{ id, pluginID, buildSystemID, displayName, root, markers, modelSource, optionFingerprint, options, droppedOptions }` plus `model` or `modelError` for `static` and `hostIntegration` sources; a `provider` source has neither. `options` contains accepted stored values after Core scope/root merging, including explicit empty selections; `droppedOptions` names rejected stored values without rewriting the source document. | +| `build.projectOptions` | `{ document }`, host-read persisted v1 Build options, without a manifest | `document`: validated storage with version 1 and systems; options, instance defaults, root values, explicit empty selections and saved custom choices are retained. Missing system fields normalize to empty maps; malformed, unsupported or oversized documents reject with `plugin.build.invalidOptionsDocument`. This pure operation never reads or writes a file and does not replace declared option resolution. | | `build.invocationPlan` | `{ root, platform: "macos" \| "linux" \| "windows" }` | `invocation`: `{ buildSystemID, root, executables, environmentInputs, runtimeDependencies, version, minimumVersion }` with project-file paths joined to `root`; every runtime dependency with a known probe, host-managed ones included, carries `version: { arguments }` | | `build.checkToolVersion` | `{ runtime?, output }` | `version`: `{ satisfied, version? }`; `runtime` checks that runtime dependency instead of the tool | | `build.resolveModelInputs` | `{revision, sources: [{manifest, instances: [{buildSystemID, root, state, model?, options?}]}]}` from installed enabled current-workspace sources | `modelInputs`: version 1 public snapshot with exact optional producer slots, state, normalized projects/targets/content roots, configuration fingerprint and explicitly shared options; no tasks/run/settings/private functions; see the [plugin contract](https://github.com/1lck/Lithe-IDEA/blob/preview/shared/contracts/plugins/v1/README.md#optional-read-only-model-inputs-buildmodelinputs-1) | -| `build.resolveOptions` | `{ options, modelValues? }` | `options`: `{ options: [{ id, displayName, kind, value, source, choices, allowCustom }], dropped, fingerprint }` | +| `build.resolveOptions` | `{ options, modelValues? }` | `options`: `{ options: [{ id, displayName, kind, scope?, value, source, choices, allowCustom }], dropped, fingerprint }` | +| `build.configurationPlan` | plan fields and optional host-selected `tool` | `plan.provider` for `lithe/build/configuration` (v7); no mutation, project or output-file parameters | +| `build.decodeConfiguration` | `{ result }` | `configuration`: bounded `litheBuildConfiguration` v1 native sources/fields; see the plugin contract | +| `build.configurationEditPlan` | `{ root, options?, workspaceStateDirectory, outputFile, snapshot, edits: { sourceID, revision, values }, tool? }` | Provider request `lithe/build/editConfiguration`; existing private staging only, no document bytes | +| `build.decodeConfigurationEdit` | `{ snapshot, edits, result }` | `edit: { sourceID, revision, changed }`; validates bounded receipt and exact draft/source identity; native adapter verifies staged hash and publishes atomically | | `build.modelPlan`, `build.dependencyPlan` | plan fields plus `project?` (dependencies), `outputFile?`, and for provider sources `refresh?` (boolean) and `tool?: { executable }` (absolute path) | `plan`: `{ buildSystemID, root, arguments, environment, workingDirectory, execution, optionFingerprint, output, decoder, executesProjectCode, mayUseNetwork }`; for a `provider` source `{ buildSystemID, root, provider: { method, params }, decoder, executesProjectCode, mayUseNetwork, optionFingerprint }`, where `method` is `lithe/build/model` or `lithe/build/dependencies` and `params` is `{ buildSystemID, root, options, optionArguments, environment, project?, refresh?, tool? }`; `refresh` appears only when `true` and `tool` only when the host sent it | | `build.taskPlan` | plan fields plus `task`, `project?`, `target?`, `modelTask?`, `userArguments?` | command `plan` with `arguments`, `environment`, `workingDirectory`, `execution`, and `optionFingerprint`; a provider task instead returns `provider: {method: "lithe/build/execute", params}` with effective options, selected task/entities, user arguments and effect metadata | | `build.testPlan` | plan fields plus `project?`, `target?`, `suite?`, `case?`, `userArguments?`, `projects?` (the model's project directories) | command plan or `provider: {method: "lithe/build/test", params}` (buildProviders 3), plus `results`: `null`, `{ decoder, reports }` with workspace-relative globs (for the selected project, or for the root and every listed project when none is selected), or command-only `{ decoder: "hostIntegration" }`. Command filters that templates cannot place, such as a case without its suite when the tool only addresses `Suite#case`, are `plugin.build.invalidSelection`; Provider filters remain separate typed values and must be declared supported | | `build.testDiscoveryPlan` | common plan fields plus `refresh?`, `tool?`; no project/output file | `plan.provider` for `lithe/build/discoverTests`, only with `test.discovery: true` (Provider v3), using the same effective options and effect metadata | | `build.decodeTestDiscovery` | common plan fields plus canonical `result` | `discovery.tests` with validated `{id, suite, case, project?, target?}` selectors; bounded at 10,000 items/32 MiB, deterministic and exactly runnable; malformed replies reject in full | -| `build.prepareRunPlan` | common plan fields, `run`, required `project`/`target`, `userArguments?`; no output file | `plan.provider` for `lithe/build/prepareRun` (Provider v4), selected declaration/tags/paths/options validated | -| `build.decodeRunPreparation` | `workspaceRoot`, instance `root`, raw `result` | `launch` with portable `{executable:{kind,value},arguments,workingDirectory,environment}`; bounded strings and workspace directory containment, `plugin.build.invalidOutput` on invalid result | +| `build.prepareRunPlan` | common plan fields, `run`, required `project`/`target`, `userArguments?`, `launchOptions?`, `workspaceRoot?` (required for an explicit launch cwd); no output file | `plan.provider` for `lithe/build/prepareRun` (Provider v4), selected declaration/tags/paths/options validated; typed launch options and service/task execution require v10 | +| `build.decodeRunPreparation` | `workspaceRoot`, instance `root`, raw `result`, same optional `launchOptions` | `launch` with portable `{executable:{kind,value},arguments,workingDirectory,environment}`; bounded strings and workspace directory containment, `plugin.build.invalidOutput` on invalid result | | `build.prepareDebugPlan` | same selected fields as `build.prepareRunPlan`; run declares same-plugin `debug.adapter` | `plan.provider` for `lithe/build/prepareDebug` (v5); no cross-package reference | | `build.decodeDebugPreparation` | `workspaceRoot`, instance `root`, selected `run`, raw `result` | `preparation` with declaration-owned `adapter`, normalized `root`, `program`, `arguments`, `workingDirectory`, `environment`; program is an existing regular file contained in the instance after canonicalization | | `build.decodeModel` | `{ workspaceRoot, root, output?, outputFile?, result?, options? }`; `result` is the provider's JSON answer | `model`: `{ projects, targets, tasks, contentRoots, optionValues, run }`; `run` entries carry `buildSystem: { id, root, projectID, targetID?, optionFingerprint }` | @@ -4576,6 +4774,16 @@ and the directories are absolute host paths used only as substitution text. | `build.syncFingerprint` | `{ workspaceRoot, root, projects, options? }` | `sync`: `{ fingerprint, watch }` | | `build.languageImport` | `{ workspaceRoot, root, options?, executablePath? }` | `languageImport`: `null` without a `languageImport` declaration; `{ kind: "maven", mavenContext: { version, reactorPath, profiles, settingsPath?, localRepositoryPath?, mavenExecutablePath? } }` for `lsp.startServer.mavenContext` and `lsp.updateMavenConfiguration`; or `{ kind: "gradle", javaGradle: { wrapperPresent, offline, userHome? } }` for `lsp.startServer.javaGradle`, where `wrapperPresent` reports `gradle/wrapper/gradle-wrapper.properties` under `root` | +`executableResolution: 2` adds `{ kind: "toolchain", toolchainID, root }` invocation +candidates. Hosts resolve them through the same canonical project/subproject +selection used by Run, preserving terminal failure and project-code identity. +The optional plan hint `tool.environment` is a bounded string map (64 entries, +valid environment names, 32768 bytes per value, no NUL). It merges into Provider +`environment` before option contributions, for models, dependencies and native +configuration; task/test hosts apply the same selected environment. Only selected +lookup/runtime inputs cross this boundary, never the full process environment. + + `outputFile` is an absolute host-allocated path the host creates fresh for each run and removes afterwards; a command whose declaration writes to a file refuses a plan without it. Unlike the `plugins.declarative.*` commands, @@ -5093,6 +5301,10 @@ Fixtures live in `shared/fixtures/process-lifecycle/settle-v1.json`. `lithe-ide-host` adapter. This local host command is not remotely exposed. See [IDE API v1](ide-api/v1.md) for the allowlisted plugin/MCP capabilities, connection ownership, authorization, output cursors and shutdown semantics. +Native `open` accepts a `toolExposure` selection of canonical tools and optional +argument names; a host that omits it serves the whole catalog. A selection is stored +with the connection and enforced before requests enter its queue. It can only narrow known schemas; it cannot define tool +types or permissions. Both MCP discovery and native plugin validation use it. Native Rust hosts can attach an owner-checked, connection-lifetime callback using `attach_ide_request_dispatcher`; this is separate from the request-scoped Git/Agent progress callback. It forwards notification IDs only. `ideHost.control` action @@ -5254,6 +5466,29 @@ reveal, creation and import. It keeps native loading separate from management and rejects stale results after project changes. Windows can use the same Core operations; the settings UI and Windows runtime validation remain pending. + +Team Build options use `shared/contracts/build-project-options-v1.schema.json` +at `.lithe/build/options.json`. `options` holds workspace-scoped values; +`instanceDefaults` applies to every detected root's instance-scoped options; +`instances` overrides those defaults by normalized root. Platform-local values +then override that root. Storage writes local differences only; passive detection +never rewrites the shared document. Explicit local reset reveals the shared value. +Malformed, unsupported, or inaccessible documents fail instead of becoming empty +options. Source documents stay read-only during resolution; the host only writes +one through guarded publication for an explicit edit or activation migration. +`build.detect.instanceDefaults` receives this same team default snapshot before +static model/run generation; hosts use the same merge in every subsequent plan. + +`savedChoices` preserves unselected custom choices. Hosts display them only when +the resolved option permits custom values; they never change selection or upstream +model facts. Storage validates bounded documents and preserves invalid originals. + +`build.decodeTestResults.request.result` accepts a canonical inline report only +for `test.results.decoder=provider` (buildProviders 8). Provider replies carry +`testResults` with the same execution. Core validates bounded case fields and +workspace-relative paths and aggregates by optional project/suite/case identity; +the host does not read tool-specific report directories for this decoder. + ### Project JDK supplied to JDT LS `lsp.startServer.javaRuntimes` entries contain `homePath`, `version`, and optional @@ -5353,6 +5588,46 @@ workspace root set changes. Removing a nested root restores parent scanning. An unavailable non-current root does not fail a scoped request for an accessible root; a missing descendant retains its ownership boundary until removed by the host. +### Consumer tool preferences (development) + +`toolchains.resolveConsumerPreferences` accepts `{document, directory, consumer?, tool, fixed?}`. +The document follows `toolchain-consumer-preferences-v1.schema.json`; `directory` is workspace-relative +and `consumer` is an inert `pluginID/contributionID` identity. A non-null explicit Run `fixed` value +wins. Otherwise the nearest consumer choice precedes general directory/workspace choices. +`inherit: workspace` reads the workspace tool afresh; `inherit: consumer` in a directory reads that +consumer's workspace preference and then the workspace tool. A consumer's `inherit: tool` reads +the general tool at the original requested directory, including its nearest general override; +following a workspace consumer reference preserves that original directory. General tool entries +cannot refer back to consumers or to themselves, so the reference graph cannot form cycles. +An absent general choice returns `fixed: null`, +which means the normal toolchain resolver still owns automatic/project/global selection. + +The response is `{status: ok, preferences: {fixed, source, inheritance, stored}}`, or the standard rejected +`plugin.toolchain.invalidParams` / `plugin.toolchain.limitExceeded` envelope. Fixed values remain +inert inputs to the existing resolver; this command cannot register a tool, prove package trust, +resolve a version, write preferences or grant permission to run a program. Native consumers must +recheck their source bytes and active definitions after discovery. The shared fixture is +`shared/fixtures/toolchains/consumer-preferences.json`. The machine-local Run document stores it at `toolchain.preferences`. Run editor updates omit to +preserve, or pass an empty v1 document to reset. `runConfig.projectToolchain` returns the inert +`toolPreferences` alongside existing fields. The native host supplies the current +installed plugin/build-system contribution identity; both native Build consumers read these preferences +before the existing proof pipeline and recheck their bytes afterwards. `stored` is the value at exactly +the requested scope, or null when that scope inherits. It is separate from the resolved `fixed` input. + +`toolchains.editConsumerPreferences` accepts `{document, directory, consumer?, tool, value}`. +`value` is required: null removes only that scope's override; otherwise it is a fixed selection or +an allowed symbolic inheritance reference. The command validates both the original and edited +documents, preserves siblings, prunes empty containers along the edited path, and returns +`{status: ok, edit: {document, preferences}}`. Resetting an absent value is a no-op. + +`runConfig.updateToolPreferences` accepts `{localDocument, preferences}` and returns +`{document}` containing the replacement local Run JSON. It replaces only `toolchain.preferences`; +it does not materialize legacy language/build defaults, migrate v1 Run entries or modify other fields. +The captured `localDocument` is required; this command performs no native I/O. Unknown document +versions are rejected without numeric narrowing. The native adapter owns bounded reads, cancellation, +read-only package boundaries, optimistic comparison, metadata-preserving atomic replacement and +read-back. No editor command discovers or executes tools. Global defaults and complete one-time +legacy migration remain incomplete; these APIs alone do not complete Stage D. ### Windows source-breakpoint relocation diff --git a/shared/contracts/toolchain-consumer-preferences-v1.schema.json b/shared/contracts/toolchain-consumer-preferences-v1.schema.json new file mode 100644 index 0000000000..38a0301e9d --- /dev/null +++ b/shared/contracts/toolchain-consumer-preferences-v1.schema.json @@ -0,0 +1,220 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://lithe.dev/contracts/toolchain-consumer-preferences-v1.schema.json", + "title": "Lithe consumer tool preferences v1", + "description": "Inert machine-local inputs. An inherited choice is a reference, never a cached installation or launch permit.", + "type": "object", + "required": [ + "version" + ], + "additionalProperties": false, + "properties": { + "version": { + "const": 1 + }, + "workspace": { + "$ref": "#/$defs/workspace" + }, + "directories": { + "type": "object", + "maxProperties": 256, + "propertyNames": { + "type": "string", + "minLength": 1, + "maxLength": 2048, + "pattern": "^(?!\\.\\.?($|/))[^\\\\/:\\x00-\\x1f]+(?:/(?!\\.\\.?($|/))[^\\\\/:\\x00-\\x1f]+)*$" + }, + "additionalProperties": { + "$ref": "#/$defs/directory" + } + } + }, + "$defs": { + "fixed": { + "oneOf": [ + { + "type": "object", + "required": [ + "home" + ], + "additionalProperties": false, + "properties": { + "home": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "description": "Absolute native path; Core rejects traversal and control characters before use." + } + } + }, + { + "type": "object", + "required": [ + "executable" + ], + "additionalProperties": false, + "properties": { + "executable": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "description": "Absolute native path; Core rejects traversal and control characters before use." + } + } + } + ] + }, + "workspaceReference": { + "type": "object", + "required": [ + "inherit" + ], + "additionalProperties": false, + "properties": { + "inherit": { + "const": "workspace" + } + } + }, + "consumerReference": { + "type": "object", + "required": [ + "inherit" + ], + "additionalProperties": false, + "properties": { + "inherit": { + "enum": [ + "workspace", + "consumer", + "tool" + ] + } + } + }, + "fixedTools": { + "type": "object", + "maxProperties": 64, + "additionalProperties": { + "$ref": "#/$defs/fixed" + }, + "propertyNames": { + "$ref": "#/$defs/toolID" + } + }, + "workspaceTools": { + "type": "object", + "maxProperties": 64, + "additionalProperties": { + "oneOf": [ + { + "$ref": "#/$defs/fixed" + }, + { + "$ref": "#/$defs/workspaceReference" + } + ] + }, + "propertyNames": { + "$ref": "#/$defs/toolID" + } + }, + "consumerTools": { + "type": "object", + "maxProperties": 64, + "additionalProperties": { + "oneOf": [ + { + "$ref": "#/$defs/fixed" + }, + { + "$ref": "#/$defs/consumerReference" + } + ] + }, + "propertyNames": { + "$ref": "#/$defs/toolID" + } + }, + "workspace": { + "type": "object", + "additionalProperties": false, + "properties": { + "tools": { + "$ref": "#/$defs/fixedTools" + }, + "consumers": { + "type": "object", + "maxProperties": 128, + "additionalProperties": { + "$ref": "#/$defs/workspaceConsumerTools" + }, + "propertyNames": { + "$ref": "#/$defs/consumerID" + } + } + } + }, + "directory": { + "type": "object", + "additionalProperties": false, + "properties": { + "tools": { + "$ref": "#/$defs/workspaceTools" + }, + "consumers": { + "type": "object", + "maxProperties": 128, + "additionalProperties": { + "$ref": "#/$defs/consumerTools" + }, + "propertyNames": { + "$ref": "#/$defs/consumerID" + } + } + } + }, + "toolID": { + "type": "string", + "maxLength": 192, + "pattern": "^(?:[a-z][a-z0-9-]{0,63}|[a-z0-9]+(?:[.-][a-z0-9]+)*:[a-z][a-z0-9-]{0,63})$" + }, + "consumerID": { + "type": "string", + "maxLength": 256, + "pattern": "^(?!\\.{1,2}/)[a-z0-9.-]+/[a-z][a-z0-9-]{0,63}$" + }, + "workspaceConsumerReference": { + "type": "object", + "required": [ + "inherit" + ], + "additionalProperties": false, + "properties": { + "inherit": { + "enum": [ + "workspace", + "tool" + ] + } + } + }, + "workspaceConsumerTools": { + "type": "object", + "maxProperties": 64, + "additionalProperties": { + "oneOf": [ + { + "$ref": "#/$defs/fixed" + }, + { + "$ref": "#/$defs/workspaceConsumerReference" + } + ] + }, + "propertyNames": { + "$ref": "#/$defs/toolID" + } + } + } +} diff --git a/shared/fixtures/build-project-options/local-edits.json b/shared/fixtures/build-project-options/local-edits.json new file mode 100644 index 0000000000..1cbe878123 --- /dev/null +++ b/shared/fixtures/build-project-options/local-edits.json @@ -0,0 +1,14 @@ +{ + "version": 1, + "team": {"version": 1, "systems": {"cargo": {"options": {"offline": true, "features": ["alpha", "beta"]}, "instanceDefaults": {"release": true}, "instances": {"other": {"label": "team other"}}, "savedChoices": {"features": ["remembered"]}}, "dormant": {"options": {"label": "keep"}}}}, + "emptyLocal": {"version": 1, "document": {"version": 1, "systems": {}}, "emptySelections": {}}, + "cases": [ + {"name": "instance false overrides team default", "root": "app", "optionID": "release", "value": false, "modelValues": {}, "valid": true}, + {"name": "workspace false remains explicit", "root": "app", "optionID": "offline", "value": false, "modelValues": {}, "valid": true}, + {"name": "explicit empty preserves sibling feature", "root": "app", "optionID": "features", "value": [], "modelValues": {"features": ["alpha"]}, "valid": true}, + {"name": "invalid model feature refuses whole edit", "root": "app", "optionID": "features", "value": ["alpha", "unknown"], "modelValues": {"features": ["alpha"]}, "valid": false}, + {"name": "unknown option refuses", "root": "app", "optionID": "unknown", "value": true, "modelValues": {}, "valid": false}, + {"name": "wrong type refuses", "root": "app", "optionID": "offline", "value": "false", "modelValues": {}, "valid": false}, + {"name": "escaping root refuses", "root": "../other", "optionID": "release", "value": true, "modelValues": {}, "valid": false} + ] +} diff --git a/shared/fixtures/build-project-options/shared-defaults.json b/shared/fixtures/build-project-options/shared-defaults.json new file mode 100644 index 0000000000..fc63a3cada --- /dev/null +++ b/shared/fixtures/build-project-options/shared-defaults.json @@ -0,0 +1,25 @@ +{ + "version": 1, + "systems": { + "maven": { + "options": {}, + "instanceDefaults": { + "profiles": [ + "ci" + ], + "skipTests": false + }, + "instances": { + "services/a": { + "offline": true + } + }, + "savedChoices": { + "profiles": [ + "ci", + "manual" + ] + } + } + } +} diff --git a/shared/fixtures/build-project-options/validation.json b/shared/fixtures/build-project-options/validation.json new file mode 100644 index 0000000000..b300e01a92 --- /dev/null +++ b/shared/fixtures/build-project-options/validation.json @@ -0,0 +1,25 @@ +{ + "version": 1, + "cases": [ + { "name": "empty document", "valid": true, "document": {"version": 1, "systems": {}} }, + { "name": "numeric schema const", "valid": true, "document": {"version": 1.0, "systems": {}} }, + { "name": "per root empty selection and inactive custom choice", "valid": true, "document": {"version": 1, "systems": {"maven": {"instanceDefaults": {"profiles": ["ci"], "offline": false}, "instances": {"services/项目": {"profiles": []}, ".": {"settingsFile": "selected.xml"}}, "savedChoices": {"profiles": ["ci", "manual"]}}}} }, + { "name": "unknown system retained", "valid": true, "document": {"version": 1, "systems": {"future-tool": {"options": {"custom.option": "saved"}}}} }, + { "name": "boolean version", "valid": false, "document": {"version": true, "systems": {}} }, + { "name": "unsupported version", "valid": false, "document": {"version": 2, "systems": {}} }, + { "name": "unknown document field", "valid": false, "document": {"version": 1, "systems": {}, "extra": true} }, + { "name": "unknown system field", "valid": false, "document": {"version": 1, "systems": {"maven": {"extra": true}}} }, + { "name": "numeric option", "valid": false, "document": {"version": 1, "systems": {"maven": {"options": {"threads": 2}}}} }, + { "name": "null option", "valid": false, "document": {"version": 1, "systems": {"maven": {"options": {"profiles": null}}}} }, + { "name": "mixed list", "valid": false, "document": {"version": 1, "systems": {"maven": {"options": {"profiles": ["ci", false]}}}} }, + { "name": "empty system id", "valid": false, "document": {"version": 1, "systems": {"": {}}} }, + { "name": "invalid system id", "valid": false, "document": {"version": 1, "systems": {"maven.plugin": {}}} }, + { "name": "invalid option id", "valid": false, "document": {"version": 1, "systems": {"maven": {"options": {"profile/value": []}}}} }, + { "name": "root escape", "valid": false, "document": {"version": 1, "systems": {"maven": {"instances": {"../outside": {}}}}} }, + { "name": "absolute root", "valid": false, "document": {"version": 1, "systems": {"maven": {"instances": {"/outside": {}}}}} }, + { "name": "windows root", "valid": false, "document": {"version": 1, "systems": {"maven": {"instances": {"C:/outside": {}}}}} }, + { "name": "empty segment", "valid": false, "document": {"version": 1, "systems": {"maven": {"instances": {"a//b": {}}}}} }, + { "name": "backslash root", "valid": false, "document": {"version": 1, "systems": {"maven": {"instances": {"a\\b": {}}}}} }, + { "name": "malformed saved choices", "valid": false, "document": {"version": 1, "systems": {"maven": {"savedChoices": {"profiles": [false]}}}} } + ] +} diff --git a/shared/fixtures/build-provider/test-results-v1.json b/shared/fixtures/build-provider/test-results-v1.json new file mode 100644 index 0000000000..5b0ae4f277 --- /dev/null +++ b/shared/fixtures/build-provider/test-results-v1.json @@ -0,0 +1,39 @@ +{ + "tests": [ + { + "suite": "checkout / HTTP", + "case": "accepts(payload)[1]", + "status": "passed", + "invocations": 2, + "durationMillis": 10 + }, + { + "suite": "checkout / HTTP", + "case": "accepts(payload)[2]", + "status": "error", + "message": "Rejected payload", + "durationMillis": 125, + "path": "tests/checkout.spec", + "line": 7 + }, + { + "suite": "checkout / HTTP", + "case": "disabled", + "status": "skipped", + "durationMillis": 0 + }, + { + "suite": "checkout / HTTP", + "case": "unmeasured", + "status": "passed" + } + ], + "summary": { + "total": 4, + "passed": 2, + "failed": 0, + "errors": 1, + "skipped": 1 + }, + "success": false +} diff --git a/shared/fixtures/build/native-tool-context.json b/shared/fixtures/build/native-tool-context.json new file mode 100644 index 0000000000..8217acf6d4 --- /dev/null +++ b/shared/fixtures/build/native-tool-context.json @@ -0,0 +1,13 @@ +{ + "manifest": { "id": "dev.example.build" }, + "identity": { "operationEvidence": "fixture-only" }, + "pluginDirectory": "C:/plugins/example", + "workspaceStateDirectory": "C:/state/example", + "environment": { "COMPILER_HOME": "C:/tools/compiler" }, + "tool": { "executable": "C:/tools/builder/bin/build.cmd" }, + "resolvedExecutables": { + "dev.example.tools:builder": "C:/tools/builder/bin/build.cmd", + "dev.example.tools:compiler": "C:/tools/compiler/bin/compiler.exe" + }, + "executesProjectCode": false +} diff --git a/shared/fixtures/ide-api/calls.json b/shared/fixtures/ide-api/calls.json index a17ae91f41..44ccd6cdce 100644 --- a/shared/fixtures/ide-api/calls.json +++ b/shared/fixtures/ide-api/calls.json @@ -1,17 +1,115 @@ { "version": 1, "accepted": [ - {"name":"lithe_project_inspect","arguments":{}}, - {"name":"lithe_environment_configure","arguments":{"javaHomePath":"","mavenExecutablePath":"mvnw"}}, - {"name":"lithe_maven_execute","arguments":{"goals":["clean","verify"],"modulePath":"services/api"}}, - {"name":"lithe_run_start","arguments":{"configurationID":"service-api"}}, - {"name":"lithe_operation_output","arguments":{"operationID":"run:fixture-execution"}} + { + "name": "lithe_project_inspect", + "arguments": {} + }, + { + "name": "lithe_environment_configure", + "arguments": { + "javaHomePath": "", + "mavenExecutablePath": "mvnw" + } + }, + { + "name": "lithe_build_execute", + "arguments": { + "instanceID": "maven@.", + "taskID": "custom", + "projectID": "services/api", + "userArguments": [ + "clean", + "verify" + ] + } + }, + { + "name": "lithe_run_start", + "arguments": { + "configurationID": "service-api" + } + }, + { + "name": "lithe_operation_output", + "arguments": { + "operationID": "run:fixture-execution" + } + }, + { + "name": "lithe_build_inspect", + "arguments": {} + }, + { + "name": "lithe_build_reload", + "arguments": { + "instanceID": "maven@." + } + }, + { + "name": "lithe_maven_execute", + "arguments": { + "goals": [ + "clean", + "verify" + ], + "modulePath": "services/api" + } + } ], "rejected": [ - {"name":"lithe_environment_configure","arguments":{"apiKey":"never-return-a-secret"}}, - {"name":"lithe_maven_execute","arguments":{"goals":["verify"],"modulePath":"../outside"}}, - {"name":"lithe_maven_execute","arguments":{"goals":["verify && echo unexpected"]}}, - {"name":"lithe_run_start","arguments":{"configurationID":23}}, - {"name":"lithe_operation_stop","arguments":{"sessionID":"ambiguous-output-slot"}} + { + "name": "lithe_environment_configure", + "arguments": { + "apiKey": "never-return-a-secret" + } + }, + { + "name": "lithe_build_execute", + "arguments": { + "instanceID": "maven@.", + "taskID": "verify", + "command": "mvn" + } + }, + { + "name": "lithe_build_execute", + "arguments": { + "instanceID": "maven@.", + "taskID": "verify", + "userArguments": [ + 42 + ] + } + }, + { + "name": "lithe_run_start", + "arguments": { + "configurationID": 23 + } + }, + { + "name": "lithe_operation_stop", + "arguments": { + "sessionID": "ambiguous-output-slot" + } + }, + { + "name": "lithe_maven_execute", + "arguments": { + "goals": [ + "verify" + ], + "modulePath": "../outside" + } + }, + { + "name": "lithe_maven_execute", + "arguments": { + "goals": [ + "verify && echo unexpected" + ] + } + } ] } diff --git a/shared/fixtures/ide-api/host-tool-exposure.json b/shared/fixtures/ide-api/host-tool-exposure.json new file mode 100644 index 0000000000..7485ff62ca --- /dev/null +++ b/shared/fixtures/ide-api/host-tool-exposure.json @@ -0,0 +1,102 @@ +{ + "version": 1, + "macos": [ + { + "name": "lithe_project_inspect" + }, + { + "name": "lithe_environment_inspect" + }, + { + "name": "lithe_environment_configure", + "arguments": [ + "javaHomePath", + "mavenExecutablePath", + "mavenJavaHomePath" + ] + }, + { + "name": "lithe_run_list" + }, + { + "name": "lithe_run_start" + }, + { + "name": "lithe_run_restart" + }, + { + "name": "lithe_operations_list" + }, + { + "name": "lithe_operation_output" + }, + { + "name": "lithe_operation_stop" + }, + { + "name": "lithe_build_inspect" + }, + { + "name": "lithe_build_reload" + }, + { + "name": "lithe_build_configure" + }, + { + "name": "lithe_build_execute" + } + ], + "windows": [ + { + "name": "lithe_project_inspect" + }, + { + "name": "lithe_environment_inspect" + }, + { + "name": "lithe_environment_configure" + }, + { + "name": "lithe_run_list" + }, + { + "name": "lithe_run_start" + }, + { + "name": "lithe_run_restart" + }, + { + "name": "lithe_operations_list" + }, + { + "name": "lithe_operation_output" + }, + { + "name": "lithe_operation_stop" + }, + { + "name": "lithe_maven_inspect" + }, + { + "name": "lithe_maven_configure" + }, + { + "name": "lithe_maven_reload" + }, + { + "name": "lithe_maven_execute" + }, + { + "name": "lithe_build_inspect" + }, + { + "name": "lithe_build_reload" + }, + { + "name": "lithe_build_configure" + }, + { + "name": "lithe_build_execute" + } + ] +} diff --git a/shared/fixtures/ide-api/mcp-protocol.json b/shared/fixtures/ide-api/mcp-protocol.json index 3c87db10db..3b8e32e052 100644 --- a/shared/fixtures/ide-api/mcp-protocol.json +++ b/shared/fixtures/ide-api/mcp-protocol.json @@ -2,7 +2,20 @@ "version": 1, "legacyProtocolVersion": "2025-11-25", "modernProtocolVersion": "2026-07-28", - "cachePolicy": { "ttlMs": 0, "cacheScope": "private" }, - "toolCount": 13, - "modes": ["legacy", "modern", "modern-after-initialize"] + "cachePolicy": { + "ttlMs": 0, + "cacheScope": "private" + }, + "toolCount": 17, + "modes": [ + "legacy", + "modern", + "modern-after-initialize" + ], + "providerTools": [ + "lithe_build_inspect", + "lithe_build_reload", + "lithe_build_configure", + "lithe_build_execute" + ] } diff --git a/shared/fixtures/java/run-markers-v1.json b/shared/fixtures/java/run-markers-v1.json index 2af2fad084..4679503154 100644 --- a/shared/fixtures/java/run-markers-v1.json +++ b/shared/fixtures/java/run-markers-v1.json @@ -84,6 +84,133 @@ { "line": 12, "endLine": 12, "kind": "testMethod", "label": "Refunds.refunds", "testClass": "demo.OrderTest$Refunds", "testMethod": "refunds", "testItemId": "app@demo.OrderTest$Refunds#refunds()", "status": "failed" } ] } + }, + { + "name": "nested-test-classes-with-canonical-provider-outcomes", + "request": { + "testItems": [ + { + "id": "app@demo.OrderTest", + "label": "OrderTest", + "fullName": "demo.OrderTest", + "projectName": "app", + "testKind": 0, + "testLevel": 5, + "range": { + "startLine": 5, + "startUtf16Column": 6, + "endLine": 14, + "endUtf16Column": 1 + }, + "children": [ + { + "id": "app@demo.OrderTest#creates()", + "label": "creates()", + "fullName": "demo.OrderTest#creates()", + "projectName": "app", + "testKind": 0, + "testLevel": 6, + "range": { + "startLine": 7, + "startUtf16Column": 9, + "endLine": 7, + "endUtf16Column": 21 + }, + "children": [] + }, + { + "id": "app@demo.OrderTest$Refunds", + "label": "Refunds", + "fullName": "demo.OrderTest$Refunds", + "projectName": "app", + "testKind": 0, + "testLevel": 5, + "range": { + "startLine": 10, + "startUtf16Column": 10, + "endLine": 13, + "endUtf16Column": 5 + }, + "children": [ + { + "id": "app@demo.OrderTest$Refunds#refunds()", + "label": "refunds()", + "fullName": "demo.OrderTest$Refunds#refunds()", + "projectName": "app", + "testKind": 0, + "testLevel": 6, + "range": { + "startLine": 12, + "startUtf16Column": 13, + "endLine": 12, + "endUtf16Column": 25 + }, + "children": [] + } + ] + } + ] + } + ], + "testCases": [ + { + "status": "passed", + "message": null, + "invocations": 1, + "suite": "demo.OrderTest", + "case": "creates" + }, + { + "status": "failed", + "message": "expected refund", + "invocations": 1, + "suite": "demo.OrderTest$Refunds", + "case": "refunds" + } + ] + }, + "expected": { + "markers": [ + { + "line": 5, + "endLine": 14, + "kind": "testClass", + "label": "OrderTest", + "testClass": "demo.OrderTest", + "testItemId": "app@demo.OrderTest", + "status": "failed" + }, + { + "line": 7, + "endLine": 7, + "kind": "testMethod", + "label": "OrderTest.creates", + "testClass": "demo.OrderTest", + "testMethod": "creates", + "testItemId": "app@demo.OrderTest#creates()", + "status": "passed" + }, + { + "line": 10, + "endLine": 13, + "kind": "testClass", + "label": "Refunds", + "testClass": "demo.OrderTest$Refunds", + "testItemId": "app@demo.OrderTest$Refunds", + "status": "failed" + }, + { + "line": 12, + "endLine": 12, + "kind": "testMethod", + "label": "Refunds.refunds", + "testClass": "demo.OrderTest$Refunds", + "testMethod": "refunds", + "testItemId": "app@demo.OrderTest$Refunds#refunds()", + "status": "failed" + } + ] + } } ] } diff --git a/shared/fixtures/plugins/publisher-signature.json b/shared/fixtures/plugins/publisher-signature.json new file mode 100644 index 0000000000..717cbc0da9 --- /dev/null +++ b/shared/fixtures/plugins/publisher-signature.json @@ -0,0 +1,30 @@ +{ + "description": "Cross-platform public test key only; no production trust. Canonical payload matches PluginPackageSignature.swift.", + "publicKey": "11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo=", + "contents": { + "plugin.json": "{\"id\":\"dev.example.signed\",\"version\":\"1.2.3\"}\n", + "bin/runner.cmd": "@echo fixture\r\n", + "lib/z.txt": "last ASCII\n", + "lib/ä-雪.txt": "雪 and accents\n", + "lib/é.txt": "Canonical equivalence preserves original UTF-8 bytes\n" + }, + "document": { + "schemaVersion": 1, + "algorithm": "ed25519", + "keyID": "fixture-rfc8032", + "pluginID": "dev.example.signed", + "pluginVersion": "1.2.3", + "files": { + "plugin.json": "ecfdff6026a4a0664e339246c79bd3cb3efa60e1688cc78eaf41b6e33b648555", + "bin/runner.cmd": "a25251292e203527ce5f352d622dfdadbbab8edaef1a0d472dcb31543c0c25cd", + "lib/z.txt": "51214621962a32a2acddd443c7d2e7311a6ce5e383f747d15003dc46153dc393", + "lib/ä-雪.txt": "4362be8d0439a283303be550a63ce3d9b6afd0f9dd2ca8e82adc81c317254e5c", + "lib/é.txt": "85ca67aabba68a386ecb31a062b228490cee96f1f9a95d3772e7a7061f0676c3" + }, + "signature": "r6fo1F3xoG9BXu+w9jErYL7xQJuttPpvnSPUOI+DzbVF4E0TfHP7L171lzV5mhXi1o287cMr9z7aWdGnV9t+Cg==" + }, + "payload": "schemaVersion=1\nalgorithm=ed25519\nkeyID=fixture-rfc8032\npluginID=ZGV2LmV4YW1wbGUuc2lnbmVk\npluginVersion=MS4yLjM\nfile=YmluL3J1bm5lci5jbWQ=a25251292e203527ce5f352d622dfdadbbab8edaef1a0d472dcb31543c0c25cd\nfile=bGliL3oudHh0=51214621962a32a2acddd443c7d2e7311a6ce5e383f747d15003dc46153dc393\nfile=bGliL8OkLembqi50eHQ=4362be8d0439a283303be550a63ce3d9b6afd0f9dd2ca8e82adc81c317254e5c\nfile=bGliL2XMgS50eHQ=85ca67aabba68a386ecb31a062b228490cee96f1f9a95d3772e7a7061f0676c3\nfile=cGx1Z2luLmpzb24=ecfdff6026a4a0664e339246c79bd3cb3efa60e1688cc78eaf41b6e33b648555\n", + "trustedPublisherKeys": { + "lithe-official-plugins-v2": "yTjUaLpCNXXEQDuan5Ymw5rCtR0JeTNmh6hyTfOFjTs=" + } +} diff --git a/shared/fixtures/run-configuration/argument-text.json b/shared/fixtures/run-configuration/argument-text.json new file mode 100644 index 0000000000..ced4f9841d --- /dev/null +++ b/shared/fixtures/run-configuration/argument-text.json @@ -0,0 +1,40 @@ +{ + "version": 1, + "cases": [ + { + "values": [], + "text": "" + }, + { + "values": [ + "validate", + "雪" + ], + "text": "validate 雪" + }, + { + "values": [ + "", + "two words", + "quote\"", + "single'quote", + "C:\\fixture\\path", + "line\nvalue" + ], + "text": "\"\" \"two words\" \"quote\\\"\" \"single'quote\" \"C:\\\\fixture\\\\path\" \"line\nvalue\"" + }, + { + "values": [ + "a…b", + "e f", + "\\", + "\"\"" + ], + "text": "\"a…b\" \"e f\" \"\\\\\" \"\\\"\\\"\"" + } + ], + "expected": { + "exactArgv": true, + "shellExpansion": false + } +} diff --git a/shared/fixtures/run-configuration/build-provider-options.json b/shared/fixtures/run-configuration/build-provider-options.json new file mode 100644 index 0000000000..1f30129875 --- /dev/null +++ b/shared/fixtures/run-configuration/build-provider-options.json @@ -0,0 +1,70 @@ +{ + "version": 1, + "reference": { + "pluginID": "dev.example.engine", + "buildSystemID": "engine", + "root": "app", + "run": "serve", + "projectID": "opaque/project:1", + "targetID": "opaque/target:2", + "execution": "service", + "userArguments": [ + "legacy argument" + ] + }, + "choices": { + "runtimeArguments": [ + "--opaque=one two", + "", + "雪", + "quote\"\\literal" + ], + "entrypoint": "opaque/entry:雪", + "options": { + "features": [ + "a", + "雪" + ], + "strict": false, + "empty": [] + }, + "tools": { + "dev.example.engine:compiler": { + "executable": "/fixture/tools/team compiler" + }, + "dev.example.engine:vm": { + "home": "C:/Fixture Kits/team vm" + } + } + }, + "changed": { + "runtimeArguments": [ + "--next=value" + ], + "entrypoint": "", + "options": {}, + "tools": { + "dev.example.engine:compiler": { + "executable": "/fixture/tools/local compiler" + }, + "dev.example.engine:vm": null + } + }, + "arguments": [ + "--application=one two", + "雪" + ], + "environment": { + "EXAMPLE": "value" + }, + "workingDirectory": "app/work", + "expected": { + "buildSystemID": "engine", + "execution": "service", + "tools": { + "dev.example.engine:compiler": { + "executable": "/fixture/tools/local compiler" + } + } + } +} diff --git a/shared/fixtures/run-configuration/build-provider-stable-identities.json b/shared/fixtures/run-configuration/build-provider-stable-identities.json new file mode 100644 index 0000000000..42142c88dc --- /dev/null +++ b/shared/fixtures/run-configuration/build-provider-stable-identities.json @@ -0,0 +1,17 @@ +{ + "version": 1, + "reference": { + "pluginID": "dev.example.engine", + "buildSystemID": "engine", + "root": "app", + "run": "serve", + "projectID": "opaque/project", + "targetID": "opaque/target", + "execution": "service", + "userArguments": [] + }, + "expected": { + "stableID": "imported:old-stable-id", + "duplicateGeneratedEntries": 0 + } +} diff --git a/shared/fixtures/run-configuration/build-provider.json b/shared/fixtures/run-configuration/build-provider.json index 1f75eb646d..bf80250fc0 100644 --- a/shared/fixtures/run-configuration/build-provider.json +++ b/shared/fixtures/run-configuration/build-provider.json @@ -26,6 +26,11 @@ "inspectionStatus": "ready", "requiresGeneration": true, "configurationCount": 1, - "provider": "build.provider" - } + "provider": "build.provider", + "applicationReferenceUsesLaunchOptions": false + }, + "executionKinds": [ + "service", + "task" + ] } diff --git a/shared/fixtures/run-configuration/configuration-toolchain-selection.json b/shared/fixtures/run-configuration/configuration-toolchain-selection.json new file mode 100644 index 0000000000..7e6217c2e7 --- /dev/null +++ b/shared/fixtures/run-configuration/configuration-toolchain-selection.json @@ -0,0 +1,446 @@ +{ + "description": "Canonical reactor paths plus explicit Run overrides, without applying project defaults as configuration overrides or writing source documents.", + "cases": [ + { + "name": "explicit configuration paths override canonical project defaults", + "request": { + "path": ".", + "configurationId": "maven-run", + "localDocument": { + "version": 2, + "toolchain": { + "java": { + "homePath": "C:/Global JDK" + }, + "maven": { + "executablePath": "C:/Global Maven/bin/mvn.cmd", + "javaHomePath": "C:/Global Maven JDK" + }, + "pathOverrides": { + "reactor": { + "javaHomePath": "C:/Reactor JDK", + "mavenExecutablePath": "C:/Reactor Maven/bin/mvn.cmd", + "mavenJavaHomePath": "C:/Reactor Maven JDK" + } + } + }, + "configurations": [ + { + "id": "maven-run", + "extensions": { + "java": { + "homePath": " C:/Run JDK ", + "mavenExecutablePath": "C:/Run Maven/bin/mvn.cmd", + "mavenJavaHomePath": "C:/Run Maven JDK" + } + } + } + ] + } + }, + "generatedDocument": { + "version": 2, + "configurations": [ + { + "id": "maven-run", + "name": "Module", + "provider": "maven.module", + "execution": "task", + "cwd": ".", + "toolchains": { + "java": "project-jdk", + "maven": "project-maven" + }, + "extensions": { + "maven": { + "module": "." + } + } + } + ] + } + }, + { + "name": "empty configuration values inherit the canonical reactor instead of global defaults", + "request": { + "path": "reactor", + "configurationId": "maven-run", + "localDocument": { + "version": 2, + "toolchain": { + "java": { + "homePath": "C:/Global JDK" + }, + "maven": { + "executablePath": "C:/Global Maven/bin/mvn.cmd", + "javaHomePath": "C:/Global Maven JDK" + }, + "pathOverrides": { + "reactor": { + "javaHomePath": "C:/Reactor JDK", + "mavenExecutablePath": "C:/Reactor Maven/bin/mvn.cmd", + "mavenJavaHomePath": "C:/Reactor Maven JDK" + } + } + }, + "configurations": [ + { + "id": "maven-run", + "extensions": { + "java": { + "homePath": "", + "mavenExecutablePath": "", + "mavenJavaHomePath": "" + } + } + } + ] + } + }, + "generatedDocument": { + "version": 2, + "configurations": [ + { + "id": "maven-run", + "name": "Module", + "provider": "maven.module", + "execution": "task", + "cwd": "reactor", + "toolchains": { + "java": "project-jdk", + "maven": "project-maven" + }, + "extensions": { + "maven": { + "module": "." + } + } + } + ] + } + }, + { + "name": "a configuration cannot select another provider root", + "request": { + "path": ".", + "configurationId": "maven-run", + "localDocument": { + "version": 2, + "toolchain": { + "java": { + "homePath": "C:/Global JDK" + }, + "maven": { + "executablePath": "C:/Global Maven/bin/mvn.cmd", + "javaHomePath": "C:/Global Maven JDK" + }, + "pathOverrides": { + "reactor": { + "javaHomePath": "C:/Reactor JDK", + "mavenExecutablePath": "C:/Reactor Maven/bin/mvn.cmd", + "mavenJavaHomePath": "C:/Reactor Maven JDK" + } + } + }, + "configurations": [ + { + "id": "maven-run", + "extensions": { + "java": {} + } + } + ] + } + }, + "generatedDocument": { + "version": 2, + "configurations": [ + { + "id": "maven-run", + "name": "Module", + "provider": "maven.module", + "execution": "task", + "cwd": "other", + "toolchains": { + "java": "project-jdk", + "maven": "project-maven" + }, + "extensions": { + "maven": { + "module": "." + } + } + } + ] + } + }, + { + "name": "an unknown configuration cannot fall back to project paths", + "request": { + "path": ".", + "configurationId": "missing-run", + "localDocument": { + "version": 2, + "toolchain": { + "java": { + "homePath": "C:/Global JDK" + }, + "maven": { + "executablePath": "C:/Global Maven/bin/mvn.cmd", + "javaHomePath": "C:/Global Maven JDK" + }, + "pathOverrides": { + "reactor": { + "javaHomePath": "C:/Reactor JDK", + "mavenExecutablePath": "C:/Reactor Maven/bin/mvn.cmd", + "mavenJavaHomePath": "C:/Reactor Maven JDK" + } + } + }, + "configurations": [ + { + "id": "maven-run", + "extensions": { + "java": {} + } + } + ] + } + }, + "generatedDocument": { + "version": 2, + "configurations": [ + { + "id": "maven-run", + "name": "Module", + "provider": "maven.module", + "execution": "task", + "cwd": ".", + "toolchains": { + "java": "project-jdk", + "maven": "project-maven" + }, + "extensions": { + "maven": { + "module": "." + } + } + } + ] + } + }, + { + "name": "malformed explicit paths cannot fall back to a valid default", + "request": { + "path": ".", + "configurationId": "maven-run", + "localDocument": { + "version": 2, + "toolchain": { + "java": { + "homePath": "C:/Global JDK" + }, + "maven": { + "executablePath": "C:/Global Maven/bin/mvn.cmd", + "javaHomePath": "C:/Global Maven JDK" + }, + "pathOverrides": { + "reactor": { + "javaHomePath": "C:/Reactor JDK", + "mavenExecutablePath": "C:/Reactor Maven/bin/mvn.cmd", + "mavenJavaHomePath": "C:/Reactor Maven JDK" + } + } + }, + "configurations": [ + { + "id": "maven-run", + "extensions": { + "java": { + "mavenExecutablePath": false + } + } + } + ] + } + }, + "generatedDocument": { + "version": 2, + "configurations": [ + { + "id": "maven-run", + "name": "Module", + "provider": "maven.module", + "execution": "task", + "cwd": ".", + "toolchains": { + "java": "project-jdk", + "maven": "project-maven" + }, + "extensions": { + "maven": { + "module": "." + } + } + } + ] + } + }, + { + "name": "team and local explicit fields reuse the existing Run merge", + "request": { + "path": "reactor", + "configurationId": "maven-run", + "localDocument": { + "version": 2, + "toolchain": { + "java": { + "homePath": "C:/Global JDK" + }, + "maven": { + "executablePath": "C:/Global Maven/bin/mvn.cmd", + "javaHomePath": "C:/Global Maven JDK" + }, + "pathOverrides": { + "reactor": { + "javaHomePath": "C:/Reactor JDK", + "mavenExecutablePath": "C:/Reactor Maven/bin/mvn.cmd", + "mavenJavaHomePath": "C:/Reactor Maven JDK" + } + } + }, + "configurations": [ + { + "id": "maven-run", + "extensions": { + "java": { + "mavenExecutablePath": "", + "mavenJavaHomePath": "C:/Local Maven JDK" + } + } + } + ] + } + }, + "teamDocument": { + "version": 2, + "configurations": [ + { + "id": "maven-run", + "extensions": { + "java": { + "homePath": "C:/Team JDK", + "mavenExecutablePath": "C:/Team Maven/bin/mvn.cmd" + } + } + } + ] + }, + "generatedDocument": { + "version": 2, + "configurations": [ + { + "id": "maven-run", + "name": "Module", + "provider": "maven.module", + "execution": "task", + "cwd": "reactor", + "toolchains": { + "java": "project-jdk", + "maven": "project-maven" + }, + "extensions": { + "maven": { + "module": "." + } + } + } + ] + } + }, + { + "name": "saved selectors inherit canonical paths without a generated layer", + "request": { + "path": "reactor", + "configurationId": "user:reference", + "localDocument": { + "version": 2, + "toolchain": { + "maven": { + "executablePath": "C:/Global Maven/bin/mvn.cmd" + }, + "pathOverrides": { + "reactor": { + "mavenExecutablePath": "C:/Reactor Maven/bin/mvn.cmd" + } + } + }, + "configurations": [ + { + "id": "user:reference", + "name": "Reference", + "provider": "build.provider", + "execution": "task", + "extensions": { + "build": { + "pluginID": "dev.example.build", + "buildSystemID": "example", + "root": "reactor", + "run": "module", + "projectID": "p", + "targetID": "t", + "execution": "task", + "userArguments": [] + } + } + } + ] + } + } + } + ], + "version": 1, + "expected": { + "explicit configuration paths override canonical project defaults": { + "toolchain": { + "javaHomePath": "C:/Run JDK", + "mavenExecutablePath": "C:/Run Maven/bin/mvn.cmd", + "mavenJavaHomePath": "C:/Run Maven JDK", + "runtimeExecutablePaths": {} + } + }, + "empty configuration values inherit the canonical reactor instead of global defaults": { + "toolchain": { + "javaHomePath": "C:/Reactor JDK", + "mavenExecutablePath": "C:/Reactor Maven/bin/mvn.cmd", + "mavenJavaHomePath": "C:/Reactor Maven JDK", + "runtimeExecutablePaths": {} + } + }, + "a configuration cannot select another provider root": { + "errorCode": "invalid_request" + }, + "an unknown configuration cannot fall back to project paths": { + "errorCode": "invalid_request" + }, + "malformed explicit paths cannot fall back to a valid default": { + "errorCode": "parse_failed" + }, + "team and local explicit fields reuse the existing Run merge": { + "toolchain": { + "javaHomePath": "C:/Team JDK", + "mavenExecutablePath": "C:/Reactor Maven/bin/mvn.cmd", + "mavenJavaHomePath": "C:/Local Maven JDK", + "runtimeExecutablePaths": {} + } + }, + "saved selectors inherit canonical paths without a generated layer": { + "toolchain": { + "javaHomePath": "", + "mavenExecutablePath": "C:/Reactor Maven/bin/mvn.cmd", + "mavenJavaHomePath": "", + "runtimeExecutablePaths": {} + } + } + } +} diff --git a/shared/fixtures/run-configuration/project-environment.json b/shared/fixtures/run-configuration/project-environment.json index 91c2aea2d7..bf70b30077 100644 --- a/shared/fixtures/run-configuration/project-environment.json +++ b/shared/fixtures/run-configuration/project-environment.json @@ -9,12 +9,14 @@ "toolchain": { "javaHomePath": "/fixture/project-jdk", "mavenExecutablePath": "/fixture/maven", - "mavenJavaHomePath": "" + "mavenJavaHomePath": "", + "pathOverrides": {} }, "expected": { "toolchain": { "java": { "homePath": "/fixture/project-jdk" }, - "maven": { "executablePath": "/fixture/maven", "javaHomePath": "" } + "maven": { "executablePath": "/fixture/maven", "javaHomePath": "" }, + "pathOverrides": {} } } } diff --git a/shared/fixtures/run-configuration/project-toolchain-selection.json b/shared/fixtures/run-configuration/project-toolchain-selection.json new file mode 100644 index 0000000000..b3a538c0c2 --- /dev/null +++ b/shared/fixtures/run-configuration/project-toolchain-selection.json @@ -0,0 +1,328 @@ +{ + "description": "Read-only canonical Run tool paths for a Provider root; platforms discover executables after shared selection.", + "cases": [ + { + "name": "defaults without generated configurations", + "request": { + "path": ".", + "localDocument": { + "version": 2, + "configurations": [], + "toolchain": { + "java": { + "homePath": " C:/Example JDK " + }, + "maven": { + "executablePath": "C:/Example Maven/bin/mvn.cmd", + "javaHomePath": "C:/Maven JDK" + } + } + } + } + }, + { + "name": "nearest reactor override", + "request": { + "path": "reactor/app", + "localDocument": { + "version": 2, + "configurations": [], + "toolchain": { + "java": { + "homePath": " C:/Example JDK " + }, + "maven": { + "executablePath": "C:/Example Maven/bin/mvn.cmd", + "javaHomePath": "C:/Maven JDK" + }, + "pathOverrides": { + "reactor": { + "mavenExecutablePath": "C:/Reactor Maven/mvn.cmd" + } + } + } + } + } + }, + { + "name": "nearest empty fields inherit project rather than an ancestor", + "request": { + "path": "reactor/app/sub", + "localDocument": { + "version": 2, + "configurations": [], + "toolchain": { + "java": { + "homePath": " C:/Example JDK " + }, + "maven": { + "executablePath": "C:/Example Maven/bin/mvn.cmd", + "javaHomePath": "C:/Maven JDK" + }, + "pathOverrides": { + "reactor": { + "javaHomePath": "C:/Parent JDK" + }, + "reactor/app": { + "mavenExecutablePath": "C:/App Maven/mvn.cmd", + "javaHomePath": "" + } + } + } + } + } + }, + { + "name": "path segments do not match adjacent project names", + "request": { + "path": "reactor/app2", + "localDocument": { + "version": 2, + "configurations": [], + "toolchain": { + "java": { + "homePath": " C:/Example JDK " + }, + "maven": { + "executablePath": "C:/Example Maven/bin/mvn.cmd", + "javaHomePath": "C:/Maven JDK" + }, + "pathOverrides": { + "reactor/app": { + "javaHomePath": "C:/Wrong JDK" + } + } + } + } + } + }, + { + "name": "explicit automatic override inherits project defaults", + "request": { + "path": "reactor", + "localDocument": { + "version": 2, + "configurations": [], + "toolchain": { + "java": { + "homePath": " C:/Example JDK " + }, + "maven": { + "executablePath": "C:/Example Maven/bin/mvn.cmd", + "javaHomePath": "C:/Maven JDK" + }, + "pathOverrides": { + "reactor": {} + } + } + } + } + }, + { + "name": "missing toolchain stays automatic", + "request": { + "path": ".", + "localDocument": { + "version": 2, + "configurations": [] + } + } + }, + { + "name": "escaping owner", + "request": { + "path": "../outside", + "localDocument": { + "version": 2, + "configurations": [], + "toolchain": { + "java": { + "homePath": " C:/Example JDK " + }, + "maven": { + "executablePath": "C:/Example Maven/bin/mvn.cmd", + "javaHomePath": "C:/Maven JDK" + } + } + } + } + }, + { + "name": "noncanonical owner", + "request": { + "path": "reactor\\app", + "localDocument": { + "version": 2, + "configurations": [], + "toolchain": { + "java": { + "homePath": " C:/Example JDK " + }, + "maven": { + "executablePath": "C:/Example Maven/bin/mvn.cmd", + "javaHomePath": "C:/Maven JDK" + } + } + } + } + }, + { + "name": "empty owner", + "request": { + "path": "", + "localDocument": { + "version": 2, + "configurations": [], + "toolchain": { + "java": { + "homePath": " C:/Example JDK " + }, + "maven": { + "executablePath": "C:/Example Maven/bin/mvn.cmd", + "javaHomePath": "C:/Maven JDK" + } + } + } + } + }, + { + "name": "ambiguous owner", + "request": { + "path": "reactor/./app", + "localDocument": { + "version": 2, + "configurations": [], + "toolchain": { + "java": { + "homePath": " C:/Example JDK " + }, + "maven": { + "executablePath": "C:/Example Maven/bin/mvn.cmd", + "javaHomePath": "C:/Maven JDK" + } + } + } + } + }, + { + "name": "unknown document version", + "request": { + "path": ".", + "localDocument": { + "version": 99 + } + } + }, + { + "name": "malformed tool path", + "request": { + "path": ".", + "localDocument": { + "version": 2, + "configurations": [], + "toolchain": { + "java": { + "homePath": 42 + } + } + } + } + }, + { + "name": "malformed override", + "request": { + "path": "app", + "localDocument": { + "version": 2, + "configurations": [], + "toolchain": { + "java": { + "homePath": " C:/Example JDK " + }, + "maven": { + "executablePath": "C:/Example Maven/bin/mvn.cmd", + "javaHomePath": "C:/Maven JDK" + }, + "pathOverrides": { + "app": { + "unknownChoice": "value" + } + } + } + } + } + } + ], + "version": 1, + "expected": { + "defaults without generated configurations": { + "toolchain": { + "javaHomePath": "C:/Example JDK", + "mavenExecutablePath": "C:/Example Maven/bin/mvn.cmd", + "mavenJavaHomePath": "C:/Maven JDK", + "runtimeExecutablePaths": {} + } + }, + "nearest reactor override": { + "toolchain": { + "javaHomePath": "C:/Example JDK", + "mavenExecutablePath": "C:/Reactor Maven/mvn.cmd", + "mavenJavaHomePath": "C:/Maven JDK", + "runtimeExecutablePaths": {} + } + }, + "nearest empty fields inherit project rather than an ancestor": { + "toolchain": { + "javaHomePath": "C:/Example JDK", + "mavenExecutablePath": "C:/App Maven/mvn.cmd", + "mavenJavaHomePath": "C:/Maven JDK", + "runtimeExecutablePaths": {} + } + }, + "path segments do not match adjacent project names": { + "toolchain": { + "javaHomePath": "C:/Example JDK", + "mavenExecutablePath": "C:/Example Maven/bin/mvn.cmd", + "mavenJavaHomePath": "C:/Maven JDK", + "runtimeExecutablePaths": {} + } + }, + "explicit automatic override inherits project defaults": { + "toolchain": { + "javaHomePath": "C:/Example JDK", + "mavenExecutablePath": "C:/Example Maven/bin/mvn.cmd", + "mavenJavaHomePath": "C:/Maven JDK", + "runtimeExecutablePaths": {} + } + }, + "missing toolchain stays automatic": { + "toolchain": { + "javaHomePath": "", + "mavenExecutablePath": "", + "mavenJavaHomePath": "", + "runtimeExecutablePaths": {} + } + }, + "escaping owner": { + "errorCode": "invalid_request" + }, + "noncanonical owner": { + "errorCode": "invalid_request" + }, + "empty owner": { + "errorCode": "invalid_request" + }, + "ambiguous owner": { + "errorCode": "invalid_request" + }, + "unknown document version": { + "errorCode": "not_supported" + }, + "malformed tool path": { + "errorCode": "parse_failed" + }, + "malformed override": { + "errorCode": "invalid_request" + } + } +} diff --git a/shared/fixtures/run-configuration/provider-prepared-java.json b/shared/fixtures/run-configuration/provider-prepared-java.json new file mode 100644 index 0000000000..3a594fc798 --- /dev/null +++ b/shared/fixtures/run-configuration/provider-prepared-java.json @@ -0,0 +1,17 @@ +{ + "javaLaunch": { + "mainClass": "com.example.MainTests", + "classPaths": [ + "/workspace/target/test-classes" + ], + "modulePaths": [], + "buildPrepared": true + }, + "version": 1, + "expected": { + "arguments": [ + "com.example.MainTests" + ], + "hasPreLaunchSteps": false + } +} diff --git a/shared/fixtures/run-configuration/provider-project-ownership.json b/shared/fixtures/run-configuration/provider-project-ownership.json new file mode 100644 index 0000000000..76f473072e --- /dev/null +++ b/shared/fixtures/run-configuration/provider-project-ownership.json @@ -0,0 +1,156 @@ +{ + "version": 1, + "files": { + "alpha/pom.xml": "99", + "alpha/app/pom.xml": "", + "alpha/app/custom-checks/demo/Main.java": "package demo; class Main { public static void main(String[] args) {} }", + "beta/pom.xml": "", + "beta/app/sources/demo/Main.java": "package demo; @org.springframework.boot.autoconfigure.SpringBootApplication class Main { public static void main(String[] args) {} }", + ".java-version": "11", + "alpha/mvnw.cmd": "exit /b 99" + }, + "javaEntrypoints": { + "schemaVersion": 1, + "entries": [ + { + "sourcePath": "alpha/app/custom-checks/demo/Main.java", + "mainClass": "demo.Main", + "projectName": "alpha-app" + }, + { + "sourcePath": "beta/app/sources/demo/Main.java", + "mainClass": "demo.Main", + "projectName": "beta-app" + } + ], + "diagnostics": [] + }, + "projectOwnership": [ + { + "path": "alpha", + "contentRoots": [], + "name": "reactor", + "build": { + "pluginID": "dev.example.engine", + "buildSystemID": "engine", + "root": "alpha", + "projectID": "alpha" + } + }, + { + "path": "alpha/app", + "contentRoots": [ + { + "path": "alpha/app/custom-checks", + "kind": "testSource" + }, + { + "path": "alpha/app/generated-checks", + "kind": "generatedTestSource" + } + ], + "name": "app", + "minimumJavaVersion": "17", + "build": { + "pluginID": "dev.example.engine", + "buildSystemID": "engine", + "root": "alpha", + "projectID": "alpha/app" + } + }, + { + "path": "beta/app", + "contentRoots": [ + { + "path": "beta/app/sources", + "kind": "source" + } + ], + "name": "app", + "minimumJavaVersion": "21", + "build": { + "pluginID": "dev.example.engine", + "buildSystemID": "engine", + "root": "beta", + "projectID": "beta/app" + } + } + ], + "expected": { + "configurations": [ + { + "id": "java-main:demo.Main:alpha/app", + "cwd": "alpha", + "module": "app", + "sourceSet": "test" + }, + { + "id": "java-main:demo.Main:beta/app", + "cwd": "beta", + "module": "app", + "sourceSet": "main" + } + ], + "services": [ + { + "id": "build.provider:41b56f3f0d9046e60aad2d801b216bcc301a0e515dcf749413fbabff07b0ece1", + "cwd": "alpha", + "reference": { + "pluginID": "dev.example.engine", + "buildSystemID": "engine", + "root": "alpha", + "run": "serve", + "projectID": "opaque-project-1", + "targetID": "http/opaque:1", + "execution": "service", + "userArguments": [] + } + }, + { + "id": "build.provider:0d63f34f97ab396280a2b2b3226ad1a539db4d4cc35232c79a6ec4acba568ac2", + "cwd": "beta", + "reference": { + "pluginID": "dev.example.engine", + "buildSystemID": "engine", + "root": "beta", + "run": "serve", + "projectID": "opaque-project-2", + "targetID": "http/opaque:2", + "execution": "service", + "userArguments": [] + } + } + ], + "minimumJavaVersion": "21" + }, + "providerRuns": [ + { + "name": "Application service", + "reference": { + "pluginID": "dev.example.engine", + "buildSystemID": "engine", + "root": "alpha", + "run": "serve", + "projectID": "opaque-project-1", + "targetID": "http/opaque:1", + "execution": "service", + "userArguments": [] + }, + "projectPath": "alpha/app" + }, + { + "name": "Application service", + "reference": { + "pluginID": "dev.example.engine", + "buildSystemID": "engine", + "root": "beta", + "run": "serve", + "projectID": "opaque-project-2", + "targetID": "http/opaque:2", + "execution": "service", + "userArguments": [] + }, + "projectPath": "beta/app" + } + ] +} diff --git a/shared/fixtures/run-configuration/subproject-toolchains.json b/shared/fixtures/run-configuration/subproject-toolchains.json new file mode 100644 index 0000000000..03e1077399 --- /dev/null +++ b/shared/fixtures/run-configuration/subproject-toolchains.json @@ -0,0 +1,45 @@ +{ + "version": 1, + "toolchain": { + "javaHomePath": "/fixture/project-jdk", + "pathOverrides": { + "services/a": { + "javaHomePath": "/fixture/a-jdk", + "mavenExecutablePath": "/fixture/a maven/bin/mvn", + "mavenJavaHomePath": "" + }, + "services/b": { + "javaHomePath": "", + "mavenExecutablePath": "./mvnw", + "mavenJavaHomePath": "/fixture/b-maven-jdk" + } + } + }, + "invalidRoots": [ + "", + ".", + "..", + "../outside", + "/absolute", + "a/../b", + "a/./b", + "a//b", + "a/", + "C:/absolute", + "a\\b" + ], + "expected": { + "pathOverrides": { + "services/a": { + "javaHomePath": "/fixture/a-jdk", + "mavenExecutablePath": "/fixture/a maven/bin/mvn", + "mavenJavaHomePath": "" + }, + "services/b": { + "javaHomePath": "", + "mavenExecutablePath": "./mvnw", + "mavenJavaHomePath": "/fixture/b-maven-jdk" + } + } + } +} diff --git a/shared/fixtures/toolchains/consumer-preferences.json b/shared/fixtures/toolchains/consumer-preferences.json new file mode 100644 index 0000000000..58fc680586 --- /dev/null +++ b/shared/fixtures/toolchains/consumer-preferences.json @@ -0,0 +1,100 @@ +{ + "version": 1, + "description": "The consumer follows the workspace tool rather than freezing its current home or inheriting an unrelated directory override. A second consumer follows the general tool at the requested directory, without copying the current installation.", + "document": { + "version": 1, + "workspace": { + "tools": { + "dev.example.runtime:sdk": { + "home": "/fixture/sdk-one" + } + }, + "consumers": { + "dev.example.build/engine": { + "dev.example.runtime:sdk": { + "inherit": "workspace" + } + }, + "dev.example.app/runner": { + "dev.example.runtime:sdk": { + "inherit": "tool" + } + } + } + }, + "directories": { + "nested": { + "tools": { + "dev.example.runtime:sdk": { + "home": "/fixture/directory-sdk" + } + }, + "consumers": { + "dev.example.build/engine": { + "dev.example.runtime:sdk": { + "home": "/fixture/parent-choice" + } + } + } + }, + "nested/child": { + "consumers": { + "dev.example.build/engine": { + "dev.example.runtime:sdk": { + "inherit": "consumer" + } + }, + "dev.example.app/runner": { + "dev.example.runtime:sdk": { + "inherit": "consumer" + } + } + } + } + } + }, + "cases": [ + { + "directory": "nested/child", + "consumer": "dev.example.build/engine", + "expected": { + "home": "/fixture/sdk-one" + } + }, + { + "directory": "nested/other", + "consumer": "dev.example.build/engine", + "expected": { + "home": "/fixture/parent-choice" + } + }, + { + "directory": "nested/child", + "consumer": null, + "expected": { + "home": "/fixture/directory-sdk" + } + }, + { + "directory": ".", + "consumer": "dev.example.build/engine", + "expected": { + "home": "/fixture/sdk-one" + } + }, + { + "directory": "nested/child", + "consumer": "dev.example.app/runner", + "expected": { + "home": "/fixture/directory-sdk" + } + }, + { + "directory": ".", + "consumer": "dev.example.app/runner", + "expected": { + "home": "/fixture/sdk-one" + } + } + ] +} diff --git a/shared/fixtures/toolchains/rpc-id-cases.json b/shared/fixtures/toolchains/rpc-id-cases.json new file mode 100644 index 0000000000..8ce394827f --- /dev/null +++ b/shared/fixtures/toolchains/rpc-id-cases.json @@ -0,0 +1,44 @@ +{ + "cases": [ + { + "id": 1, + "accepted": true + }, + { + "id": "1", + "accepted": true + }, + { + "id": "rpc:雪-2", + "accepted": true + }, + { + "id": 0, + "accepted": false + }, + { + "id": -1, + "accepted": false + }, + { + "id": null, + "accepted": false + }, + { + "id": true, + "accepted": false + }, + { + "id": "", + "accepted": false + }, + { + "id": "bad\n", + "accepted": false + }, + { + "id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "accepted": false + } + ] +} diff --git a/shared/i18n/locales/en-US.json b/shared/i18n/locales/en-US.json index d61cb797fd..0bb6ed87a3 100644 --- a/shared/i18n/locales/en-US.json +++ b/shared/i18n/locales/en-US.json @@ -1,12 +1,5 @@ { "messages": { - "aiCommit.outputTokenField": "Output token field", - "aiCommit.outputTokenFieldInfo": "Lithe uses this field for commit requests. Choose max_tokens for older gateways.", - "native.keymapImportAmbiguous": "More than one plugin can import shortcuts from this source. Disable duplicate providers and scan again.", - "native.keymapImportSourceReadPermission": "Read declared shortcut files from the editor you select for import", - "native.keymapImportProviderPermission": "Offer shortcut mappings for import", - "native.keymapImportRuntimeUnavailable": "Shortcut import is unavailable. Update Lithe and the editor import plugin, then try again.", - "markdown.renderFailed": "Markdown preview could not be rendered. Edit or reopen the document to retry.", "agent.actions.close": "Close the Agent panel", "agent.actions.closeSearch": "Close search", "agent.actions.hideDetails": "Hide session details", @@ -1589,12 +1582,6 @@ "git.behindRemote": "{count} commits behind the remote branch", "git.binaryDiffDescription": "{name} cannot be displayed as a text diff.", "git.binaryFileChanged": "Binary file changed", - "git.blame.annotate": "Annotate with Git Blame", - "git.blame.copyRevision": "Copy Revision Number", - "git.blame.hide": "Close Annotations", - "git.blame.revisionCopied": "Revision number copied", - "git.blame.showDiff": "Show Diff", - "git.blame.uncommitted": "Uncommitted", "git.branch": "Branch", "git.branchActions": "Branch actions for {branch}", "git.branchCount": "{count} branch", @@ -2384,7 +2371,6 @@ "git.revertCommitConfirm": "Create a new commit that reverts {hash} \"{message}\"?", "git.revertInProgress": "Revert in progress", "git.rollback": "Rollback", - "git.rollbackSelectionChanged": "The selected changes changed. Refresh and review them before rolling back.", "git.rollbackPathsConfirm": "Roll back changes in {count} selected path(s)? This cannot be undone.", "git.saveCommitMessage": "Save Message", "git.saved.changed": "The repository changed. The shelf was retained; review the working tree before retrying.", @@ -3130,8 +3116,6 @@ "keybindings.resetToDefault": "Reset to default", "keybindings.resetToDefaultAria": "Reset to default keybinding", "keybindings.user": "User", - "keybindings.invalidWhen": "The shortcut condition could not be saved. Check the expression and try again.", - "keybindings.saveFailed": "Failed to save shortcut: {error}", "layout.actions": "Actions", "layout.pinned": "Pinned", "layout.projectDots": "Project Dots", @@ -3378,7 +3362,6 @@ "menu.saveAsTitle": "Save As", "menu.saveFileFailed": "Failed to save file. Please try again.", "menu.selectAll": "Select All", - "menu.settings": "Settings", "menu.showHover": "Show Hover", "menu.showLocalHistory": "Show Local History", "menu.sourceControl": "Source Control", @@ -4055,7 +4038,6 @@ "native.couldNotCopyYourStableSettings": "Could not copy your Stable settings", "native.couldNotFindTheJavaSourceFor": "Could not find the Java source for {arg1}", "native.couldNotInspectSavedFile": "Could not inspect saved file", - "native.couldNotFilterGitHistoryTryAgain": "Could not filter Git history. Try again.", "native.couldNotInspectTheSelectedHistoryRefreshThePreviewToRetry": "Could not inspect the selected history. Refresh the preview to retry.", "native.couldNotInspectThisWorktree": "Could not inspect this worktree", "native.couldNotInstallUpdate": "Could not install update", @@ -4149,8 +4131,6 @@ "native.database": "Database", "native.date": "Date", "native.debug": "Debug", - "native.debugStartOrContinue": "Debug: Start or Continue", - "native.startDebuggingOrContinuePausedSession": "Start debugging or continue the paused session", "native.debugResume": "Debug: Resume", "native.debugStepInto": "Debug: Step Into", "native.debugStepOut": "Debug: Step Out", @@ -4485,9 +4465,6 @@ "native.filterProblemsBySeverity": "Filter problems by severity", "native.filterResults": "Filter results", "native.find": "Find", - "native.findAction": "Find Action", - "native.findAction.variant2": "Find Action…", - "native.findAndExecuteCommand": "Find and execute a command", "native.findFilesAndActions": "Find files and actions", "native.findInFile": "Find in File", "native.findInFile.variant2": "Find in File…", @@ -5969,7 +5946,6 @@ "native.selectForCompare": "Select for Compare", "native.selectRepositoryForCommitsAndBranchOperations": "Select repository for commits and branch operations", "native.selectRunConfiguration": "Select run configuration", - "native.savedStashInvalidUtf8": "Saved changes contain text that is not UTF-8. Inspect the stash with Git.", "native.selectSavedChanges": "Select saved changes", "native.selectShell": "Select shell", "native.selectTestJVM": "Select test JVM", @@ -6759,7 +6735,6 @@ "navigation.reference": "reference", "navigation.resolving": "Finding {target}…", "navigation.typeDefinition": "type definition", - "navigation.syntaxFallback": "Based on syntax only: {name} is still importing the project.", "navigation.waitingForImport": "{name} is importing the project ({percent}%). Navigation is available once the import finishes.", "newProject.backToStarters": "Back to project starters", "newProject.chooseParentFolder": "Choose a parent folder", @@ -7332,10 +7307,6 @@ "settings.editor.lintOnSaveDescription": "Run linter when saving files", "settings.editor.maxOpenTabs": "Max Open Tabs", "settings.editor.maxOpenTabsDescription": "Tabs close automatically past this count. Pinned and unsaved tabs, terminals, and agents do not count. 0 means no limit.", - "settings.editor.tabLimitPolicy": "When the Tab Limit Is Reached", - "settings.editor.tabLimitPolicyDescription": "Which tab closes when opening another tab would exceed the limit. Pinned tabs and tabs with unsaved changes are never closed.", - "settings.editor.tabLimitPolicy.leastRecentlyUsed": "Close least recently used", - "settings.editor.tabLimitPolicy.oldestOpened": "Close oldest opened", "settings.editor.parameterHints": "Parameter Hints", "settings.editor.parameterHintsDescription": "Show function parameter hints", "settings.editor.phase": "Phase", @@ -9530,38 +9501,6 @@ "document.fileAccess.thisFileTypeCannotBeOpenedInThisEditor": "This file type cannot be opened in this editor", "document.fileAccess.updateAllHardLinks": "Update All Hard Links", "document.fileAccess.savePermissionDenied": "Could not save {arg1}: permission denied. Check the file and parent folder permissions. Your edits are still unsaved.", - "settings.network.title": "Network", - "settings.network.proxyServer": "Proxy server", - "settings.network.proxyMode": "Proxy mode", - "settings.network.mode.followSystem": "Follow system proxy", - "settings.network.mode.noProxy": "No proxy", - "settings.network.mode.custom": "Custom proxy server", - "settings.network.proxyType": "Proxy type", - "settings.network.type.http": "HTTP", - "settings.network.type.socks5": "SOCKS5", - "settings.network.host": "Server address", - "settings.network.username": "Username", - "settings.network.exclusions": "Exclusion list", - "settings.network.optional": "Optional", - "settings.network.hostPlaceholder": "For example 127.0.0.1", - "settings.network.portPlaceholder": "For example 7890", - "settings.network.exclusionsPlaceholder": "For example 127.0.0.1, localhost, *.internal.example", - "settings.network.testConnection": "Test connection", - "settings.network.testing": "Testing…", - "settings.network.enterAddressAndPort": "Enter a server address and a port between 1 and 65535 first.", - "settings.network.connectionSucceeded": "Connection succeeded in {arg1} ms.", - "settings.network.checkFailed": "The connection check failed.", - "settings.network.proxyAnsweredWithHTTP": "The proxy answered with HTTP {arg1}.", - "settings.network.credentialsUnavailable": "Proxy credentials cannot be saved in this build.", - "settings.network.info.scope": "These settings apply to Lithe's own requests: the plugin marketplace, GitHub and AI providers. Terminal commands, Git, JVM dependency downloads and the built-in updater keep their own network settings; the updater always follows the system proxy. Changes apply to new requests.", - "settings.network.info.mode": "Follow system uses the proxies configured in macOS network settings. No proxy contacts every host directly. Custom routes requests through the server configured below.", - "settings.network.info.type": "HTTP proxies carry HTTPS requests through CONNECT. SOCKS5 tunnels every protocol and port.", - "settings.network.info.host": "The proxy server's host name or IP address, for example 127.0.0.1.", - "settings.network.info.port": "The proxy server's port, for example 7890.", - "settings.network.info.username": "Leave empty when the proxy does not require authentication.", - "settings.network.info.passwordSaved": "A password is saved for its original proxy. An empty field reuses it only when the proxy type, address, port and username match.", - "settings.network.info.password": "Leave empty when the proxy does not require authentication. The password is stored in the Keychain, never in settings files.", - "settings.network.info.exclusions": "Addresses that bypass the proxy, separated by commas. Entries support wildcard domains such as *.internal.example.", "git.rollback.title": "Rollback Changes", "git.rollback.confirm": "Rollback", "git.rollback.include": "Include in rollback", @@ -9644,6 +9583,88 @@ "javaImport.manual": "Manually", "javaImport.onOpen": "Import Java projects on open", "javaImport.onOpenDescription": "Ask lets you adjust Maven settings first. Manual waits until you open a Java file, run code, or reload Maven.", + "ide.build.unavailable": "Build plugins are unavailable", + "ide.build.chooseInstance": "Choose a detected build instance", + "ide.build.busy": "A build operation is already active", + "ide.build.chooseTask": "Choose a declared build task", + "ide.build.reloadSelection": "Reload the build model before selecting a project, target or model task", + "ide.build.selectionExpired": "The build selection is no longer available", + "ide.build.launchFailed": "Build launch did not start", + "ide.build.restart": "Use Build execute to start another build", + "native.projectEnvironmentSettingsChangedOnDisk": "Project environment settings changed on disk. Reload before saving; your draft has been kept.", + "native.couldNotVerifySavedProjectEnvironment": "Could not verify the saved project environment settings. The previous preferences have been kept when possible.", + "native.projectEnvironmentSaveInProgress": "Project environment settings are being saved. Wait before saving again.", + "native.nativeConfigurationUnavailable": "Native configuration is unavailable.", + "native.nativeConfigurationStale": "Native configuration changed. Reload before saving; your draft has been kept.", + "native.nativeConfigurationBusy": "Native configuration is busy. Wait for the current operation to finish.", + "native.nativeConfigurationReadBackFailed": "Native configuration was saved, but its current effective values could not be verified. Reload before editing again.", + "native.nativeConfigurationDiskConflict": "Native configuration changed on disk. Reload before saving; your draft has been kept.", + "native.nativeConfigurationSourceReadOnly": "This native configuration source is read-only. Installed applications and plugin packages cannot be edited.", + "native.nativeConfigurationFileRequired": "The native configuration must be a writable file smaller than 2 MB.", + "native.nativeConfigurationTitle": "Native configuration", + "native.nativeConfigurationOpenProject": "Open a project to read its native configuration.", + "native.nativeConfigurationNoInstance": "This project has no matching build-system instance.", + "native.nativeConfigurationNotRead": "No native configuration has been read.", + "native.buildOptionUnavailable": "The build option is no longer available. Reload the build instance.", + "native.buildOptionInvalid": "The build option value is invalid. Inspect its current kind and choices.", + "native.buildOptionReadBackFailed": "The saved build option could not be verified. Reload before editing again.", + "native.nativeConfigurationExplicitRead": "Reading configuration with this project tool requires an explicit action.", + "native.nativeConfigurationReadUsingProjectTool": "Read using project tool", + "native.buildOptionMigrationCouldNotBeVerified": "Build option migration could not be verified. The previous configuration has been retained. Reload the build instance to retry.", + "native.selectedToolchainUnavailable": "The selected toolchain is unavailable.", + "native.savedBuildOptionsInvalid": "The saved build options are invalid. The original configuration has been kept.", + "native.buildOptionsSaveFailed": "The build options could not be saved or verified. Reload before retrying.", + "native.buildOptionsConflict": "Build options changed on disk. Reload before saving; your changes have been kept.", + "native.updateMavenBeforeRunGeneration": "Update the Maven Build plugin before generating Run configurations.", + "native.staleProviderRunGeneration": "Build provider model changed. Regenerate Run configurations before running.", + "macos.buildProvider.jvmDebugPortTimeout": "The framework did not open its JVM debug port before the deadline. Check the Run output.", + "macos.buildProvider.jvmDebugRemoteUnavailable": "Framework service debugging is not available in remote workspaces yet.", + "build.window.addArgument": "Add argument", + "macos.native.savedRunMigrationNeedsUpdate": "Some saved run configurations cannot be migrated yet. Keep the existing files and retry after updating Lithe and its build plugin.", + "git.blame.annotate": "Annotate with Git Blame", + "git.blame.copyRevision": "Copy Revision Number", + "git.blame.hide": "Close Annotations", + "git.blame.revisionCopied": "Revision number copied", + "git.blame.showDiff": "Show Diff", + "git.blame.uncommitted": "Uncommitted", + "menu.settings": "Settings", + "navigation.syntaxFallback": "Based on syntax only: {name} is still importing the project.", + "settings.editor.tabLimitPolicy": "When the Tab Limit Is Reached", + "settings.editor.tabLimitPolicyDescription": "Which tab closes when opening another tab would exceed the limit. Pinned tabs and tabs with unsaved changes are never closed.", + "settings.editor.tabLimitPolicy.leastRecentlyUsed": "Close least recently used", + "settings.editor.tabLimitPolicy.oldestOpened": "Close oldest opened", + "settings.network.title": "Network", + "settings.network.proxyServer": "Proxy server", + "settings.network.proxyMode": "Proxy mode", + "settings.network.mode.followSystem": "Follow system proxy", + "settings.network.mode.noProxy": "No proxy", + "settings.network.mode.custom": "Custom proxy server", + "settings.network.proxyType": "Proxy type", + "settings.network.type.http": "HTTP", + "settings.network.type.socks5": "SOCKS5", + "settings.network.host": "Server address", + "settings.network.username": "Username", + "settings.network.exclusions": "Exclusion list", + "settings.network.optional": "Optional", + "settings.network.hostPlaceholder": "For example 127.0.0.1", + "settings.network.portPlaceholder": "For example 7890", + "settings.network.exclusionsPlaceholder": "For example 127.0.0.1, localhost, *.internal.example", + "settings.network.testConnection": "Test connection", + "settings.network.testing": "Testing…", + "settings.network.enterAddressAndPort": "Enter a server address and a port between 1 and 65535 first.", + "settings.network.connectionSucceeded": "Connection succeeded in {arg1} ms.", + "settings.network.checkFailed": "The connection check failed.", + "settings.network.proxyAnsweredWithHTTP": "The proxy answered with HTTP {arg1}.", + "settings.network.credentialsUnavailable": "Proxy credentials cannot be saved in this build.", + "settings.network.info.scope": "These settings apply to Lithe's own requests: the plugin marketplace, GitHub and AI providers. Terminal commands, Git, JVM dependency downloads and the built-in updater keep their own network settings; the updater always follows the system proxy. Changes apply to new requests.", + "settings.network.info.mode": "Follow system uses the proxies configured in macOS network settings. No proxy contacts every host directly. Custom routes requests through the server configured below.", + "settings.network.info.type": "HTTP proxies carry HTTPS requests through CONNECT. SOCKS5 tunnels every protocol and port.", + "settings.network.info.host": "The proxy server's host name or IP address, for example 127.0.0.1.", + "settings.network.info.port": "The proxy server's port, for example 7890.", + "settings.network.info.username": "Leave empty when the proxy does not require authentication.", + "settings.network.info.passwordSaved": "A password is saved for its original proxy. An empty field reuses it only when the proxy type, address, port and username match.", + "settings.network.info.password": "Leave empty when the proxy does not require authentication. The password is stored in the Keychain, never in settings files.", + "settings.network.info.exclusions": "Addresses that bypass the proxy, separated by commas. Entries support wildcard domains such as *.internal.example.", "native.thePluginBackendIsNoLongerRunning": "The plugin backend is no longer running.", "native.onlyHtmlAndHtmFilesCanBeShownInABrowser": "Only .html and .htm files can be shown in a browser.", "native.theWindowNowShowsADifferentWorkspaceThanTheOneThisCallWasMadeFor": "The window now shows a different workspace than the one this call was made for.", @@ -9849,6 +9870,9 @@ "native.toolchainSettings.providerIsUnavailable": "{provider} is unavailable", "native.toolchainSettings.noAnswerWithinSeconds": "No answer within {seconds} s", "lsp.projectJdkInvalid": "The project JDK is invalid or its version could not be read. Check JDK Home in Project settings.", + "run.provider.runtimeArguments": "Runtime arguments", + "run.provider.runtimeArgumentsHint": "Each row is one argument. Spaces, quotes and empty values are preserved.", + "run.provider.entrypoint": "Entry point", "plugin.terminal.open": "Open a plugin terminal", "plugin.terminal.cancelled": "The plugin terminal was not opened.", "plugin.terminal.unavailable": "The terminal is unavailable.", @@ -9859,6 +9883,41 @@ "plugin.terminal.busy": "The plugin terminal is changing state. Try again when it finishes.", "plugin.terminal.permission": "Open and manage its own native terminals, retaining bounded display history for reopening", "native.downloadingAndVerifyingPlugin": "Downloading and verifying plugin…", + "native.toolchainPreferences.allBuildConsumers": "All Build consumers", + "native.toolchainPreferences.automaticSelection": "Automatic selection", + "native.toolchainPreferences.buildToolDefaults": "Build tool defaults", + "native.toolchainPreferences.consumer": "Consumer", + "native.toolchainPreferences.editDefaults": "Edit defaults", + "native.toolchainPreferences.executableFile": "Executable file", + "native.toolchainPreferences.installationDirectory": "Installation directory", + "native.toolchainPreferences.localBuildPreferencesInfo": "These machine-local choices apply to Build providers and their Run configurations on the next operation. Saving does not probe or execute a tool. Inherited choices follow later default changes.", + "native.toolchainPreferences.nextBuildInput": "Next Build input", + "native.toolchainPreferences.savedInput": "Saved input", + "native.toolchainPreferences.savedInputInfo": "A saved input is revalidated by the existing toolchain resolver when the consumer runs. It is not a verified current installation.", + "native.toolchainPreferences.useConsumerDefault": "Use consumer default", + "native.toolchainPreferences.useInheritedChoice": "Use inherited choice", + "native.toolchainPreferences.useWorkspaceDefault": "Use workspace default", + "native.toolchainPreferences.useDirectoryToolDefault": "Use directory tool default", + "native.toolchainPreferences.preferenceDirectoriesInfo": "Lists the project root and directories with version files, fixed versions or saved tool preferences, together with their parents.", + "native.toolchainPreferences.defaultsBadge": "Defaults", + "native.toolchainPreferences.sourceChangedDuringLoad": "Toolchain settings changed while loading. Refresh and try again.", + "aiCommit.outputTokenField": "Output token field", + "aiCommit.outputTokenFieldInfo": "Lithe uses this field for commit requests. Choose max_tokens for older gateways.", + "native.keymapImportAmbiguous": "More than one plugin can import shortcuts from this source. Disable duplicate providers and scan again.", + "native.keymapImportSourceReadPermission": "Read declared shortcut files from the editor you select for import", + "native.keymapImportProviderPermission": "Offer shortcut mappings for import", + "native.keymapImportRuntimeUnavailable": "Shortcut import is unavailable. Update Lithe and the editor import plugin, then try again.", + "markdown.renderFailed": "Markdown preview could not be rendered. Edit or reopen the document to retry.", + "git.rollbackSelectionChanged": "The selected changes changed. Refresh and review them before rolling back.", + "keybindings.invalidWhen": "The shortcut condition could not be saved. Check the expression and try again.", + "keybindings.saveFailed": "Failed to save shortcut: {error}", + "native.couldNotFilterGitHistoryTryAgain": "Could not filter Git history. Try again.", + "native.debugStartOrContinue": "Debug: Start or Continue", + "native.startDebuggingOrContinuePausedSession": "Start debugging or continue the paused session", + "native.findAction": "Find Action", + "native.findAction.variant2": "Find Action…", + "native.findAndExecuteCommand": "Find and execute a command", + "native.savedStashInvalidUtf8": "Saved changes contain text that is not UTF-8. Inspect the stash with Git.", "settings.editorImport.choose.ide.configuration.directory": "Choose IDE configuration directory", "settings.editorImport.column.based.wrapping.is.not.supported.and": "Column-based wrapping is not supported and was skipped.", "settings.editorImport.custom.idea.schemes.may.override.fonts.scheme": "Custom IDEA schemes may override fonts; scheme colors and font settings were skipped.", diff --git a/shared/i18n/locales/zh-CN.json b/shared/i18n/locales/zh-CN.json index e4e38f5343..c8e8fce707 100644 --- a/shared/i18n/locales/zh-CN.json +++ b/shared/i18n/locales/zh-CN.json @@ -1,12 +1,5 @@ { "messages": { - "aiCommit.outputTokenField": "输出额度字段", - "aiCommit.outputTokenFieldInfo": "Lithe 使用此字段发送提交信息请求。旧网关请选择 max_tokens。", - "native.keymapImportAmbiguous": "多个插件都可以从此来源导入快捷键。请停用重复的提供方后重新扫描。", - "native.keymapImportSourceReadPermission": "读取所选编辑器中声明的快捷键配置文件以供导入", - "native.keymapImportProviderPermission": "提供可导入的快捷键映射", - "native.keymapImportRuntimeUnavailable": "快捷键导入不可用。请更新 Lithe 和编辑器导入插件后重试。", - "markdown.renderFailed": "无法渲染 Markdown 预览,请编辑或重新打开文档重试。", "agent.actions.close": "关闭 Agent 面板", "agent.actions.closeSearch": "关闭查找", "agent.actions.hideDetails": "隐藏会话详情", @@ -1589,12 +1582,6 @@ "git.behindRemote": "落后远端分支 {count} 个提交", "git.binaryDiffDescription": "{name} 无法以文本差异显示。", "git.binaryFileChanged": "二进制文件已更改", - "git.blame.annotate": "使用 Git Blame 注释", - "git.blame.copyRevision": "复制修订号", - "git.blame.hide": "关闭注释", - "git.blame.revisionCopied": "已复制修订号", - "git.blame.showDiff": "显示差异", - "git.blame.uncommitted": "未提交", "git.branch": "分支", "git.branchActions": "{branch} 的分支操作", "git.branchCount": "{count} 个分支", @@ -2384,7 +2371,6 @@ "git.revertCommitConfirm": "确定创建一个新提交来还原 {hash}“{message}”吗?", "git.revertInProgress": "还原进行中", "git.rollback": "回滚", - "git.rollbackSelectionChanged": "选中的更改已发生变化,请刷新并重新确认后再回滚。", "git.rollbackPathsConfirm": "确定回滚选中的 {count} 个路径吗?此操作无法撤销。", "git.saveCommitMessage": "保存消息", "git.saved.changed": "仓库已发生变化。搁置记录已保留,请检查工作区后再重试。", @@ -3130,8 +3116,6 @@ "keybindings.resetToDefault": "重置为默认", "keybindings.resetToDefaultAria": "重置为默认快捷键", "keybindings.user": "用户", - "keybindings.invalidWhen": "无法保存快捷键条件,请检查表达式后重试。", - "keybindings.saveFailed": "保存快捷键失败:{error}", "layout.actions": "操作", "layout.pinned": "已固定", "layout.projectDots": "项目圆点", @@ -3378,7 +3362,6 @@ "menu.saveAsTitle": "另存为", "menu.saveFileFailed": "保存文件失败。请重试。", "menu.selectAll": "全选", - "menu.settings": "设置", "menu.showHover": "显示悬停信息", "menu.showLocalHistory": "显示本地历史", "menu.sourceControl": "源代码管理", @@ -4055,7 +4038,6 @@ "native.couldNotCopyYourStableSettings": "无法复制正式版设置", "native.couldNotFindTheJavaSourceFor": "找不到 {arg1} 对应的 Java 源码", "native.couldNotInspectSavedFile": "无法读取已保存的文件", - "native.couldNotFilterGitHistoryTryAgain": "无法筛选 Git 历史记录,请重试。", "native.couldNotInspectTheSelectedHistoryRefreshThePreviewToRetry": "无法检查所选历史。请刷新预览后重试。", "native.couldNotInspectThisWorktree": "无法检查此工作树", "native.couldNotInstallUpdate": "无法安装更新", @@ -4149,8 +4131,6 @@ "native.database": "数据库连接", "native.date": "日期", "native.debug": "调试", - "native.debugStartOrContinue": "调试:启动或继续", - "native.startDebuggingOrContinuePausedSession": "启动调试或继续已暂停的会话", "native.debugResume": "调试:继续", "native.debugStepInto": "调试:步入", "native.debugStepOut": "调试:步出", @@ -4485,9 +4465,6 @@ "native.filterProblemsBySeverity": "按严重级别筛选问题", "native.filterResults": "筛选结果", "native.find": "查找", - "native.findAction": "查找操作", - "native.findAction.variant2": "查找操作…", - "native.findAndExecuteCommand": "查找并执行命令", "native.findFilesAndActions": "查找文件和操作", "native.findInFile": "在文件中查找", "native.findInFile.variant2": "在文件中查找…", @@ -5969,7 +5946,6 @@ "native.selectForCompare": "选择以进行比较", "native.selectRepositoryForCommitsAndBranchOperations": "选择用于提交和分支操作的仓库", "native.selectRunConfiguration": "选择运行配置", - "native.savedStashInvalidUtf8": "保存的更改包含非 UTF-8 文本,请使用 Git 查看该暂存记录。", "native.selectSavedChanges": "选择已保存的更改", "native.selectShell": "选择 Shell", "native.selectTestJVM": "选择测试 JVM", @@ -6759,7 +6735,6 @@ "navigation.reference": "引用", "navigation.resolving": "正在查找{target}…", "navigation.typeDefinition": "类型定义", - "navigation.syntaxFallback": "仅按语法定位:{name} 仍在导入项目。", "navigation.waitingForImport": "{name} 正在导入项目({percent}%),导入完成后才能跳转。", "newProject.backToStarters": "返回项目模板", "newProject.chooseParentFolder": "选择父文件夹", @@ -7332,10 +7307,6 @@ "settings.editor.lintOnSaveDescription": "保存文件时运行检查器", "settings.editor.maxOpenTabs": "最大打开标签数", "settings.editor.maxOpenTabsDescription": "标签超过此数量时自动关闭。已固定、未保存的标签以及终端和 Agent 不计入。0 表示不限制。", - "settings.editor.tabLimitPolicy": "超出标签上限时", - "settings.editor.tabLimitPolicyDescription": "打开新标签导致超出上限时关闭哪个标签。已固定和有未保存修改的标签不会被关闭。", - "settings.editor.tabLimitPolicy.leastRecentlyUsed": "关闭最久未使用的", - "settings.editor.tabLimitPolicy.oldestOpened": "关闭最早打开的", "settings.editor.parameterHints": "参数提示", "settings.editor.parameterHintsDescription": "显示函数参数提示", "settings.editor.phase": "渐变", @@ -9530,38 +9501,6 @@ "document.fileAccess.thisFileTypeCannotBeOpenedInThisEditor": "此编辑器不支持打开这种文件类型", "document.fileAccess.updateAllHardLinks": "更新所有硬链接", "document.fileAccess.savePermissionDenied": "无法保存 {arg1}:权限不足。请检查文件及其父文件夹的权限,编辑内容尚未保存。", - "settings.network.title": "网络", - "settings.network.proxyServer": "代理服务器", - "settings.network.proxyMode": "代理模式", - "settings.network.mode.followSystem": "跟随系统代理", - "settings.network.mode.noProxy": "不走代理", - "settings.network.mode.custom": "自定义代理服务器", - "settings.network.proxyType": "代理类型", - "settings.network.type.http": "HTTP", - "settings.network.type.socks5": "SOCKS5", - "settings.network.host": "服务器地址", - "settings.network.username": "用户名", - "settings.network.exclusions": "排除列表", - "settings.network.optional": "选填", - "settings.network.hostPlaceholder": "例如 127.0.0.1", - "settings.network.portPlaceholder": "例如 7890", - "settings.network.exclusionsPlaceholder": "例如 127.0.0.1, localhost, *.internal.example", - "settings.network.testConnection": "测试连接", - "settings.network.testing": "测试中…", - "settings.network.enterAddressAndPort": "请先填写服务器地址和 1 到 65535 之间的端口。", - "settings.network.connectionSucceeded": "连接成功,耗时 {arg1} 毫秒。", - "settings.network.checkFailed": "连接检查失败。", - "settings.network.proxyAnsweredWithHTTP": "代理返回了 HTTP {arg1}。", - "settings.network.credentialsUnavailable": "此构建无法保存代理凭据。", - "settings.network.info.scope": "这些设置作用于 Lithe 自身的请求:插件市场、GitHub 和 AI 提供方。终端命令、Git、JVM 依赖下载和内置更新器使用各自的网络设置;更新器始终跟随系统代理。更改对新请求生效。", - "settings.network.info.mode": "「跟随系统」使用 macOS 网络设置中配置的代理;「不走代理」直接连接所有主机;「自定义」经由下方配置的代理服务器转发请求。", - "settings.network.info.type": "HTTP 代理通过 CONNECT 承载 HTTPS 请求;SOCKS5 隧道承载任意协议与端口。", - "settings.network.info.host": "代理服务器的主机名或 IP 地址,例如 127.0.0.1。", - "settings.network.info.port": "代理服务器的端口,例如 7890。", - "settings.network.info.username": "代理无需认证时留空。", - "settings.network.info.passwordSaved": "密码已绑定到原代理。仅当代理类型、地址、端口和用户名一致时,空密码框才会复用已存密码。", - "settings.network.info.password": "代理无需认证时留空。密码保存在钥匙串中,不会写入设置文件。", - "settings.network.info.exclusions": "不经过代理的地址,用逗号分隔。支持通配符域名,例如 *.internal.example。", "git.rollback.title": "回滚更改", "git.rollback.confirm": "回滚", "git.rollback.include": "纳入回滚", @@ -9644,6 +9583,88 @@ "javaImport.manual": "手动", "javaImport.onOpen": "打开 Java 项目时导入", "javaImport.onOpenDescription": "选择“询问”可以先调整 Maven 设置;选择“手动”会等到打开 Java 文件、运行代码或重新加载 Maven 时再导入。", + "ide.build.unavailable": "构建插件不可用", + "ide.build.chooseInstance": "请选择已检测到的构建实例", + "ide.build.busy": "已有构建操作正在运行", + "ide.build.chooseTask": "请选择插件声明的构建任务", + "ide.build.reloadSelection": "选择项目、目标或模型任务前,请重新加载构建模型", + "ide.build.selectionExpired": "所选构建项已不可用", + "ide.build.launchFailed": "构建未能启动", + "ide.build.restart": "请使用构建执行工具开始新的构建", + "native.projectEnvironmentSettingsChangedOnDisk": "项目环境设置已被外部修改。请重新加载后再保存;你的草稿已保留。", + "native.couldNotVerifySavedProjectEnvironment": "无法核对已保存的项目环境设置。已尽可能保留原偏好。", + "native.projectEnvironmentSaveInProgress": "正在保存项目环境设置,请等待完成后再保存。", + "native.nativeConfigurationUnavailable": "原生配置不可用。", + "native.nativeConfigurationStale": "原生配置已更改。请重新加载后再保存;已保留你的草稿。", + "native.nativeConfigurationBusy": "原生配置正在处理中。请等待当前操作完成。", + "native.nativeConfigurationReadBackFailed": "原生配置已保存,但无法核实当前生效值。请重新加载后再编辑。", + "native.nativeConfigurationDiskConflict": "磁盘上的原生配置已更改。请重新加载后再保存;已保留你的草稿。", + "native.nativeConfigurationSourceReadOnly": "此原生配置来源为只读。不能编辑已安装的应用和插件包。", + "native.nativeConfigurationFileRequired": "原生配置必须是小于 2 MB 的可写文件。", + "native.nativeConfigurationTitle": "原生配置", + "native.nativeConfigurationOpenProject": "打开项目以读取其原生配置。", + "native.nativeConfigurationNoInstance": "此项目没有匹配的构建系统实例。", + "native.nativeConfigurationNotRead": "尚未读取原生配置。", + "native.buildOptionUnavailable": "此构建选项已不可用。请重新加载构建实例。", + "native.buildOptionInvalid": "构建选项值无效。请查看其当前类型和可选值。", + "native.buildOptionReadBackFailed": "无法核实已保存的构建选项。请重新加载后再编辑。", + "native.nativeConfigurationExplicitRead": "读取配置需要显式运行此项目工具。", + "native.nativeConfigurationReadUsingProjectTool": "使用项目工具读取", + "native.buildOptionMigrationCouldNotBeVerified": "无法验证构建选项迁移结果,已保留原配置。请重新加载构建实例以重试。", + "native.selectedToolchainUnavailable": "所选工具链不可用。", + "native.savedBuildOptionsInvalid": "已保存的构建选项无效,原配置已保留。", + "native.buildOptionsSaveFailed": "无法保存或验证构建选项,请重新加载后重试。", + "native.buildOptionsConflict": "构建选项已在磁盘上变更,请重新加载后保存;你的更改已保留。", + "native.updateMavenBeforeRunGeneration": "请先更新 Maven Build 插件,再生成运行配置。", + "native.staleProviderRunGeneration": "构建 Provider 模型已更改。请重新生成运行配置后再运行。", + "macos.buildProvider.jvmDebugPortTimeout": "框架未在期限内开放 JVM 调试端口,请查看运行输出。", + "macos.buildProvider.jvmDebugRemoteUnavailable": "远程工作区暂不支持框架服务调试。", + "build.window.addArgument": "添加参数", + "macos.native.savedRunMigrationNeedsUpdate": "部分已保存的运行配置暂时无法迁移。请保留现有文件,在更新 Lithe 及其构建插件后重试。", + "git.blame.annotate": "使用 Git Blame 注释", + "git.blame.copyRevision": "复制修订号", + "git.blame.hide": "关闭注释", + "git.blame.revisionCopied": "已复制修订号", + "git.blame.showDiff": "显示差异", + "git.blame.uncommitted": "未提交", + "menu.settings": "设置", + "navigation.syntaxFallback": "仅按语法定位:{name} 仍在导入项目。", + "settings.editor.tabLimitPolicy": "超出标签上限时", + "settings.editor.tabLimitPolicyDescription": "打开新标签导致超出上限时关闭哪个标签。已固定和有未保存修改的标签不会被关闭。", + "settings.editor.tabLimitPolicy.leastRecentlyUsed": "关闭最久未使用的", + "settings.editor.tabLimitPolicy.oldestOpened": "关闭最早打开的", + "settings.network.title": "网络", + "settings.network.proxyServer": "代理服务器", + "settings.network.proxyMode": "代理模式", + "settings.network.mode.followSystem": "跟随系统代理", + "settings.network.mode.noProxy": "不走代理", + "settings.network.mode.custom": "自定义代理服务器", + "settings.network.proxyType": "代理类型", + "settings.network.type.http": "HTTP", + "settings.network.type.socks5": "SOCKS5", + "settings.network.host": "服务器地址", + "settings.network.username": "用户名", + "settings.network.exclusions": "排除列表", + "settings.network.optional": "选填", + "settings.network.hostPlaceholder": "例如 127.0.0.1", + "settings.network.portPlaceholder": "例如 7890", + "settings.network.exclusionsPlaceholder": "例如 127.0.0.1, localhost, *.internal.example", + "settings.network.testConnection": "测试连接", + "settings.network.testing": "测试中…", + "settings.network.enterAddressAndPort": "请先填写服务器地址和 1 到 65535 之间的端口。", + "settings.network.connectionSucceeded": "连接成功,耗时 {arg1} 毫秒。", + "settings.network.checkFailed": "连接检查失败。", + "settings.network.proxyAnsweredWithHTTP": "代理返回了 HTTP {arg1}。", + "settings.network.credentialsUnavailable": "此构建无法保存代理凭据。", + "settings.network.info.scope": "这些设置作用于 Lithe 自身的请求:插件市场、GitHub 和 AI 提供方。终端命令、Git、JVM 依赖下载和内置更新器使用各自的网络设置;更新器始终跟随系统代理。更改对新请求生效。", + "settings.network.info.mode": "「跟随系统」使用 macOS 网络设置中配置的代理;「不走代理」直接连接所有主机;「自定义」经由下方配置的代理服务器转发请求。", + "settings.network.info.type": "HTTP 代理通过 CONNECT 承载 HTTPS 请求;SOCKS5 隧道承载任意协议与端口。", + "settings.network.info.host": "代理服务器的主机名或 IP 地址,例如 127.0.0.1。", + "settings.network.info.port": "代理服务器的端口,例如 7890。", + "settings.network.info.username": "代理无需认证时留空。", + "settings.network.info.passwordSaved": "密码已绑定到原代理。仅当代理类型、地址、端口和用户名一致时,空密码框才会复用已存密码。", + "settings.network.info.password": "代理无需认证时留空。密码保存在钥匙串中,不会写入设置文件。", + "settings.network.info.exclusions": "不经过代理的地址,用逗号分隔。支持通配符域名,例如 *.internal.example。", "native.thePluginBackendIsNoLongerRunning": "插件后端已不在运行。", "native.onlyHtmlAndHtmFilesCanBeShownInABrowser": "只有 .html 和 .htm 文件可以在浏览器中显示。", "native.theWindowNowShowsADifferentWorkspaceThanTheOneThisCallWasMadeFor": "窗口现在显示的工作区与本次调用针对的工作区不同。", @@ -9849,6 +9870,9 @@ "native.toolchainSettings.providerIsUnavailable": "{provider} 不可用", "native.toolchainSettings.noAnswerWithinSeconds": "{seconds} 秒内未响应", "lsp.projectJdkInvalid": "项目 JDK 无效或无法读取其版本。请检查项目设置中的 JDK Home。", + "run.provider.runtimeArguments": "运行时参数", + "run.provider.runtimeArgumentsHint": "每行是一个独立参数,空格、引号和空值会原样保留。", + "run.provider.entrypoint": "入口", "plugin.terminal.open": "打开插件终端", "plugin.terminal.cancelled": "未打开插件终端。", "plugin.terminal.unavailable": "终端不可用。", @@ -9859,6 +9883,41 @@ "plugin.terminal.busy": "插件终端正在切换状态,请完成后重试。", "plugin.terminal.permission": "打开并管理自己的原生终端,保留有限的显示历史用于重新打开", "native.downloadingAndVerifyingPlugin": "正在下载并校验插件…", + "native.toolchainPreferences.allBuildConsumers": "所有构建使用方", + "native.toolchainPreferences.automaticSelection": "自动选择", + "native.toolchainPreferences.buildToolDefaults": "构建工具默认值", + "native.toolchainPreferences.consumer": "使用方", + "native.toolchainPreferences.editDefaults": "编辑默认值", + "native.toolchainPreferences.executableFile": "可执行文件", + "native.toolchainPreferences.installationDirectory": "安装目录", + "native.toolchainPreferences.localBuildPreferencesInfo": "这些本机选择会在构建提供者及其运行配置的下一次操作中生效。保存不会探测或执行工具。继承项会随默认值的后续变化更新。", + "native.toolchainPreferences.nextBuildInput": "下次构建输入", + "native.toolchainPreferences.savedInput": "已保存的输入", + "native.toolchainPreferences.savedInputInfo": "使用方运行时会由现有工具链解析器重新校验此输入。它不代表已验证的当前安装。", + "native.toolchainPreferences.useConsumerDefault": "使用方的工作区默认值", + "native.toolchainPreferences.useInheritedChoice": "沿用继承选择", + "native.toolchainPreferences.useWorkspaceDefault": "使用工作区默认值", + "native.toolchainPreferences.useDirectoryToolDefault": "使用当前目录的工具默认值", + "native.toolchainPreferences.preferenceDirectoriesInfo": "列出项目根目录、具有版本文件、固定版本或已保存工具偏好的目录,以及它们的父目录。", + "native.toolchainPreferences.defaultsBadge": "默认值", + "native.toolchainPreferences.sourceChangedDuringLoad": "加载期间工具链设置发生变化。请刷新后重试。", + "aiCommit.outputTokenField": "输出额度字段", + "aiCommit.outputTokenFieldInfo": "Lithe 使用此字段发送提交信息请求。旧网关请选择 max_tokens。", + "native.keymapImportAmbiguous": "多个插件都可以从此来源导入快捷键。请停用重复的提供方后重新扫描。", + "native.keymapImportSourceReadPermission": "读取所选编辑器中声明的快捷键配置文件以供导入", + "native.keymapImportProviderPermission": "提供可导入的快捷键映射", + "native.keymapImportRuntimeUnavailable": "快捷键导入不可用。请更新 Lithe 和编辑器导入插件后重试。", + "markdown.renderFailed": "无法渲染 Markdown 预览,请编辑或重新打开文档重试。", + "git.rollbackSelectionChanged": "选中的更改已发生变化,请刷新并重新确认后再回滚。", + "keybindings.invalidWhen": "无法保存快捷键条件,请检查表达式后重试。", + "keybindings.saveFailed": "保存快捷键失败:{error}", + "native.couldNotFilterGitHistoryTryAgain": "无法筛选 Git 历史记录,请重试。", + "native.debugStartOrContinue": "调试:启动或继续", + "native.startDebuggingOrContinuePausedSession": "启动调试或继续已暂停的会话", + "native.findAction": "查找操作", + "native.findAction.variant2": "查找操作…", + "native.findAndExecuteCommand": "查找并执行命令", + "native.savedStashInvalidUtf8": "保存的更改包含非 UTF-8 文本,请使用 Git 查看该暂存记录。", "settings.editorImport.choose.ide.configuration.directory": "选择 IDE 配置目录", "settings.editorImport.column.based.wrapping.is.not.supported.and": "不支持按指定列数换行,已跳过。", "settings.editorImport.custom.idea.schemes.may.override.fonts.scheme": "自定义 IDEA 配色方案可能覆盖字体,此次已跳过方案颜色和字体设置。", diff --git a/shared/i18n/messages.json b/shared/i18n/messages.json index eb167c7544..30c54a1c33 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -1,66 +1,6 @@ { "schemaVersion": 1, "messages": [ - { - "id": "aiCommit.outputTokenField", - "description": "Lithe Chat Completions output budget field", - "platforms": ["macos"], - "macos": {"key": "Output token field", "arguments": []} - }, - { - "id": "aiCommit.outputTokenFieldInfo", - "description": "Explain Lithe's gateway compatibility setting", - "platforms": ["macos"], - "macos": {"key": "Lithe uses this field for commit requests. Choose max_tokens for older gateways.", "arguments": []} - }, - { - "id": "native.keymapImportAmbiguous", - "description": "More than one plugin matched a selected shortcut source; ask the user to disable duplicates before retrying.", - "platforms": ["macos"], - "macos": { - "key": "More than one plugin can import shortcuts from this source. Disable duplicate providers and scan again.", - "arguments": [] - } - }, - { - "id": "native.keymapImportSourceReadPermission", - "description": "Plugin permission for scoped reads of the selected shortcut import source.", - "platforms": ["macos"], - "macos": { - "key": "Read declared shortcut files from the editor you select for import", - "arguments": [] - } - }, - { - "id": "native.keymapImportProviderPermission", - "description": "Plugin permission for offering shortcut mappings during import.", - "platforms": ["macos"], - "macos": { - "key": "Offer shortcut mappings for import", - "arguments": [] - } - }, - { - "id": "native.keymapImportRuntimeUnavailable", - "description": "Shortcut import is unavailable in this runtime; suggest updating the application and plugin.", - "platforms": ["macos"], - "macos": { - "key": "Shortcut import is unavailable. Update Lithe and the editor import plugin, then try again.", - "arguments": [] - } - }, - { - "id": "debugger.breakpointRelocationFailed", - "description": "Breakpoint relocation failed; reset unsafe source anchors before debugging.", - "platforms": [ - "windows" - ] - }, - { - "id": "markdown.renderFailed", - "description": "Windows Markdown preview rendering failed; edit or reopen to retry", - "platforms": ["windows"] - }, { "id": "agent.actions.close", "description": "Windows UI: agent.actions.close", @@ -12499,48 +12439,6 @@ "windows" ] }, - { - "id": "git.blame.annotate", - "description": "Windows UI: git.blame.annotate", - "platforms": [ - "windows" - ] - }, - { - "id": "git.blame.copyRevision", - "description": "Windows UI: git.blame.copyRevision", - "platforms": [ - "windows" - ] - }, - { - "id": "git.blame.hide", - "description": "Windows UI: git.blame.hide", - "platforms": [ - "windows" - ] - }, - { - "id": "git.blame.revisionCopied", - "description": "Windows UI: git.blame.revisionCopied", - "platforms": [ - "windows" - ] - }, - { - "id": "git.blame.showDiff", - "description": "Windows UI: git.blame.showDiff", - "platforms": [ - "windows" - ] - }, - { - "id": "git.blame.uncommitted", - "description": "Windows UI: git.blame.uncommitted", - "platforms": [ - "windows" - ] - }, { "id": "git.branch", "description": "Windows UI: git.branch", @@ -18621,13 +18519,6 @@ "windows" ] }, - { - "id": "git.rollbackSelectionChanged", - "description": "Windows UI: selected Git changes changed before rollback", - "platforms": [ - "windows" - ] - }, { "id": "git.rollbackPathsConfirm", "description": "Windows UI: git.rollbackPathsConfirm", @@ -24134,19 +24025,6 @@ "windows" ] }, - { - "id": "keybindings.invalidWhen", - "description": "Windows shortcut context validation or concurrent save failed", - "platforms": [ - "windows" - ] - }, - { - "id": "keybindings.saveFailed", - "description": "Windows shortcut save failure", - "platforms": ["windows"], - "parameters": { "error": "string" } - }, { "id": "layout.actions", "description": "Windows UI: layout.actions", @@ -25964,13 +25842,6 @@ "arguments": [] } }, - { - "id": "menu.settings", - "description": "Windows UI: menu.settings", - "platforms": [ - "windows" - ] - }, { "id": "menu.showHover", "description": "Windows UI: menu.showHover", @@ -33367,12 +33238,6 @@ "arguments": [] } }, - { - "id": "native.couldNotFilterGitHistoryTryAgain", - "description": "Native UI: Git history date filter failed", - "platforms": ["macos"], - "macos": {"key": "Could not filter Git history. Try again.", "arguments": []} - }, { "id": "native.couldNotInspectTheSelectedHistoryRefreshThePreviewToRetry", "description": "Native UI: Could not inspect the selected history. Refresh the preview to retry.", @@ -34469,28 +34334,6 @@ "arguments": [] } }, - { - "id": "native.debugStartOrContinue", - "description": "Native UI: Debug: Start or Continue", - "platforms": [ - "macos" - ], - "macos": { - "key": "Debug: Start or Continue", - "arguments": [] - } - }, - { - "id": "native.startDebuggingOrContinuePausedSession", - "description": "Native UI: Start debugging or continue the paused session", - "platforms": [ - "macos" - ], - "macos": { - "key": "Start debugging or continue the paused session", - "arguments": [] - } - }, { "id": "native.debugResume", "description": "Native UI: Debug: Resume", @@ -35861,17 +35704,6 @@ "arguments": [] } }, - { - "id": "native.downloadedDataWasKeptTryAgainToResumeFromWhereItStopped", - "description": "Native UI: Downloaded data was kept. Try again to resume from where it stopped.", - "platforms": [ - "macos" - ], - "macos": { - "key": "Downloaded data was kept. Try again to resume from where it stopped.", - "arguments": [] - } - }, { "id": "native.downloadedLldOfLldEnabled", "description": "Native UI: Downloaded (%lld of %lld enabled)", @@ -38284,39 +38116,6 @@ "arguments": [] } }, - { - "id": "native.findAction", - "description": "Native UI: Find Action", - "platforms": [ - "macos" - ], - "macos": { - "key": "Find Action", - "arguments": [] - } - }, - { - "id": "native.findAction.variant2", - "description": "Native UI: Find Action…", - "platforms": [ - "macos" - ], - "macos": { - "key": "Find Action…", - "arguments": [] - } - }, - { - "id": "native.findAndExecuteCommand", - "description": "Native UI: Find and execute a command", - "platforms": [ - "macos" - ], - "macos": { - "key": "Find and execute a command", - "arguments": [] - } - }, { "id": "native.findFilesAndActions", "description": "Native UI: Find files and actions", @@ -55969,17 +55768,6 @@ "arguments": [] } }, - { - "id": "native.savedStashInvalidUtf8", - "description": "Native UI: saved stash contains non-UTF-8 text", - "platforms": [ - "macos" - ], - "macos": { - "key": "Saved changes contain text that is not UTF-8. Inspect the stash with Git.", - "arguments": [] - } - }, { "id": "native.selectSavedChanges", "description": "Native UI: Select saved changes", @@ -64887,16 +64675,6 @@ "windows" ] }, - { - "id": "navigation.syntaxFallback", - "description": "Windows UI: a jump was resolved from syntax alone because the language server is still importing the project", - "platforms": [ - "windows" - ], - "parameters": { - "name": "string" - } - }, { "id": "navigation.waitingForImport", "description": "Windows UI: navigation is unavailable while the language server imports the project", @@ -69084,34 +68862,6 @@ "windows" ] }, - { - "id": "settings.editor.tabLimitPolicy", - "description": "Windows UI: settings.editor.tabLimitPolicy", - "platforms": [ - "windows" - ] - }, - { - "id": "settings.editor.tabLimitPolicyDescription", - "description": "Windows UI: settings.editor.tabLimitPolicyDescription", - "platforms": [ - "windows" - ] - }, - { - "id": "settings.editor.tabLimitPolicy.leastRecentlyUsed", - "description": "Windows UI: settings.editor.tabLimitPolicy.leastRecentlyUsed", - "platforms": [ - "windows" - ] - }, - { - "id": "settings.editor.tabLimitPolicy.oldestOpened", - "description": "Windows UI: settings.editor.tabLimitPolicy.oldestOpened", - "platforms": [ - "windows" - ] - }, { "id": "settings.editor.parameterHints", "description": "Windows UI: settings.editor.parameterHints", @@ -71809,534 +71559,172 @@ ] }, { - "id": "settings.network.checkFailed", - "description": "Native UI: The connection check failed.", + "id": "settings.noMatching", + "description": "Windows UI: settings.noMatching", "platforms": [ - "macos" - ], - "macos": { - "key": "The connection check failed.", - "arguments": [] - } + "windows" + ] }, { - "id": "settings.network.connectionSucceeded", - "description": "Native UI: Connection succeeded in %@ ms.", + "id": "settings.noMatchingSettings", + "description": "Windows UI: settings.noMatchingSettings", "platforms": [ - "macos" - ], - "macos": { - "key": "Connection succeeded in %@ ms.", - "arguments": [ - "arg1" - ] - }, - "parameters": { - "arg1": "string" - } + "windows" + ] }, { - "id": "settings.network.credentialsUnavailable", - "description": "Native UI: Proxy credentials cannot be saved in this build.", + "id": "settings.noResults", + "description": "Settings label: No settings found.", "platforms": [ - "macos" + "macos", + "windows" ], "macos": { - "key": "Proxy credentials cannot be saved in this build.", + "key": "No settings found", "arguments": [] } }, { - "id": "settings.network.enterAddressAndPort", - "description": "Native UI: Enter a server address and a port between 1 and 65535 first.", + "id": "settings.project.detected", + "description": "Windows UI: settings.project.detected", "platforms": [ - "macos" - ], - "macos": { - "key": "Enter a server address and a port between 1 and 65535 first.", - "arguments": [] - } + "windows" + ] }, { - "id": "settings.network.exclusions", - "description": "Native UI: Exclusion list", + "id": "settings.project.loading", + "description": "Windows UI: settings.project.loading", "platforms": [ - "macos" - ], - "macos": { - "key": "Exclusion list", - "arguments": [] - } + "windows" + ] }, { - "id": "settings.network.exclusionsPlaceholder", - "description": "Native UI: For example 127.0.0.1, localhost, *.internal.example", + "id": "settings.project.mavenAutomatic", + "description": "Windows UI: settings.project.mavenAutomatic", "platforms": [ - "macos" - ], - "macos": { - "key": "For example 127.0.0.1, localhost, *.internal.example", - "arguments": [] - } + "windows" + ] }, { - "id": "settings.network.host", - "description": "Native UI: Server address", + "id": "settings.project.openProject", + "description": "Windows UI: settings.project.openProject", "platforms": [ - "macos" - ], - "macos": { - "key": "Server address", - "arguments": [] - } + "windows" + ] }, { - "id": "settings.network.hostPlaceholder", - "description": "Native UI: For example 127.0.0.1", + "id": "settings.project.openSettings", + "description": "Windows UI: settings.project.openSettings", "platforms": [ - "macos" - ], - "macos": { - "key": "For example 127.0.0.1", - "arguments": [] - } + "windows" + ] }, { - "id": "settings.network.info.exclusions", - "description": "Native UI: Addresses that bypass the proxy, separated by commas. Entries support wildcard domains such as *.internal.example.", + "id": "settings.project.overrides", + "description": "Windows UI: settings.project.overrides", "platforms": [ - "macos" - ], - "macos": { - "key": "Addresses that bypass the proxy, separated by commas. Entries support wildcard domains such as *.internal.example.", - "arguments": [] - } + "windows" + ] }, { - "id": "settings.network.info.host", - "description": "Native UI: The proxy server's host name or IP address, for example 127.0.0.1.", + "id": "settings.project.refresh", + "description": "Windows UI: settings.project.refresh", "platforms": [ - "macos" - ], - "macos": { - "key": "The proxy server's host name or IP address, for example 127.0.0.1.", - "arguments": [] - } + "windows" + ] }, { - "id": "settings.network.info.mode", - "description": "Native UI: Follow system uses the proxies configured in macOS network settings. No proxy contacts every host directly. Custom routes requests through the server configured below.", + "id": "settings.project.reloadFailed", + "description": "Windows UI: settings.project.reloadFailed", "platforms": [ - "macos" - ], - "macos": { - "key": "Follow system uses the proxies configured in macOS network settings. No proxy contacts every host directly. Custom routes requests through the server configured below.", - "arguments": [] - } + "windows" + ] }, { - "id": "settings.network.info.password", - "description": "Native UI: Leave empty when the proxy does not require authentication. The password is stored in the Keychain, never in settings files.", + "id": "settings.project.saved", + "description": "Windows UI: settings.project.saved", "platforms": [ - "macos" - ], - "macos": { - "key": "Leave empty when the proxy does not require authentication. The password is stored in the Keychain, never in settings files.", - "arguments": [] - } + "windows" + ] }, { - "id": "settings.network.info.passwordSaved", - "description": "Native UI: A password is saved for its original proxy. An empty field reuses it only when the proxy type, address, port and username match.", + "id": "settings.project.savedReloadFailed", + "description": "Windows UI: settings.project.savedReloadFailed", "platforms": [ - "macos" - ], - "macos": { - "key": "A password is saved for its original proxy. An empty field reuses it only when the proxy type, address, port and username match.", - "arguments": [] - } + "windows" + ] }, { - "id": "settings.network.info.port", - "description": "Native UI: The proxy server's port, for example 7890.", + "id": "settings.project.scope", + "description": "Windows UI: settings.project.scope", "platforms": [ - "macos" - ], - "macos": { - "key": "The proxy server's port, for example 7890.", - "arguments": [] - } + "windows" + ] }, { - "id": "settings.network.info.scope", - "description": "Native UI: These settings apply to Lithe's own requests: the plugin marketplace, GitHub and AI providers. Terminal commands, Git, JVM dependency downloads and the built-in updater keep their own network settings; the updater always follows the system proxy. Changes apply to new requests.", + "id": "settings.project.scopeTitle", + "description": "Windows UI: settings.project.scopeTitle", "platforms": [ - "macos" - ], - "macos": { - "key": "These settings apply to Lithe's own requests: the plugin marketplace, GitHub and AI providers. Terminal commands, Git, JVM dependency downloads and the built-in updater keep their own network settings; the updater always follows the system proxy. Changes apply to new requests.", - "arguments": [] - } + "windows" + ] }, { - "id": "settings.network.info.type", - "description": "Native UI: HTTP proxies carry HTTPS requests through CONNECT. SOCKS5 tunnels every protocol and port.", + "id": "settings.project.title", + "description": "Windows UI: settings.project.title", "platforms": [ - "macos" - ], - "macos": { - "key": "HTTP proxies carry HTTPS requests through CONNECT. SOCKS5 tunnels every protocol and port.", - "arguments": [] - } + "windows" + ] }, { - "id": "settings.network.info.username", - "description": "Native UI: Leave empty when the proxy does not require authentication.", + "id": "settings.project.useProjectJdk", + "description": "Windows UI: settings.project.useProjectJdk", "platforms": [ - "macos" - ], - "macos": { - "key": "Leave empty when the proxy does not require authentication.", - "arguments": [] - } + "windows" + ] }, { - "id": "settings.network.mode.custom", - "description": "Native UI: Custom proxy server", + "id": "settings.resizeCategories", + "description": "Windows UI: settings.resizeCategories", "platforms": [ - "macos" - ], - "macos": { - "key": "Custom proxy server", - "arguments": [] - } + "windows" + ] }, { - "id": "settings.network.mode.followSystem", - "description": "Native UI: Follow system proxy", + "id": "settings.resizePlugins", + "description": "Windows UI: settings.resizePlugins", "platforms": [ - "macos" - ], - "macos": { - "key": "Follow system proxy", - "arguments": [] - } + "windows" + ] }, { - "id": "settings.network.mode.noProxy", - "description": "Native UI: No proxy", + "id": "settings.run.description", + "description": "Windows UI: settings.run.description", "platforms": [ - "macos" - ], - "macos": { - "key": "No proxy", - "arguments": [] - } + "windows" + ] }, { - "id": "settings.network.optional", - "description": "Native UI: Optional", + "id": "settings.run.title", + "description": "Windows UI: settings.run.title", "platforms": [ - "macos" - ], - "macos": { - "key": "Optional", - "arguments": [] - } + "windows" + ] }, { - "id": "settings.network.portPlaceholder", - "description": "Native UI: For example 7890", + "id": "settings.search", + "description": "Settings label: Search settings.", "platforms": [ - "macos" + "macos", + "windows" ], "macos": { - "key": "For example 7890", + "key": "Search settings", "arguments": [] } }, { - "id": "settings.network.proxyAnsweredWithHTTP", - "description": "Native UI: The proxy answered with HTTP %@.", - "platforms": [ - "macos" - ], - "macos": { - "key": "The proxy answered with HTTP %@.", - "arguments": [ - "arg1" - ] - }, - "parameters": { - "arg1": "string" - } - }, - { - "id": "settings.network.proxyMode", - "description": "Native UI: Proxy mode", - "platforms": [ - "macos" - ], - "macos": { - "key": "Proxy mode", - "arguments": [] - } - }, - { - "id": "settings.network.proxyServer", - "description": "Native UI: Proxy server", - "platforms": [ - "macos" - ], - "macos": { - "key": "Proxy server", - "arguments": [] - } - }, - { - "id": "settings.network.proxyType", - "description": "Native UI: Proxy type", - "platforms": [ - "macos" - ], - "macos": { - "key": "Proxy type", - "arguments": [] - } - }, - { - "id": "settings.network.testConnection", - "description": "Native UI: Test connection", - "platforms": [ - "macos" - ], - "macos": { - "key": "Test connection", - "arguments": [] - } - }, - { - "id": "settings.network.testing", - "description": "Native UI: Testing…", - "platforms": [ - "macos" - ], - "macos": { - "key": "Testing…", - "arguments": [] - } - }, - { - "id": "settings.network.title", - "description": "Native UI: Network", - "platforms": [ - "macos" - ], - "macos": { - "key": "Network", - "arguments": [] - } - }, - { - "id": "settings.network.type.http", - "description": "Native UI: HTTP", - "platforms": [ - "macos" - ], - "macos": { - "key": "HTTP", - "arguments": [] - } - }, - { - "id": "settings.network.type.socks5", - "description": "Native UI: SOCKS5", - "platforms": [ - "macos" - ], - "macos": { - "key": "SOCKS5", - "arguments": [] - } - }, - { - "id": "settings.network.username", - "description": "Native UI: Username", - "platforms": [ - "macos" - ], - "macos": { - "key": "Username", - "arguments": [] - } - }, - { - "id": "settings.noMatching", - "description": "Windows UI: settings.noMatching", - "platforms": [ - "windows" - ] - }, - { - "id": "settings.noMatchingSettings", - "description": "Windows UI: settings.noMatchingSettings", - "platforms": [ - "windows" - ] - }, - { - "id": "settings.noResults", - "description": "Settings label: No settings found.", - "platforms": [ - "macos", - "windows" - ], - "macos": { - "key": "No settings found", - "arguments": [] - } - }, - { - "id": "settings.project.detected", - "description": "Windows UI: settings.project.detected", - "platforms": [ - "windows" - ] - }, - { - "id": "settings.project.loading", - "description": "Windows UI: settings.project.loading", - "platforms": [ - "windows" - ] - }, - { - "id": "settings.project.mavenAutomatic", - "description": "Windows UI: settings.project.mavenAutomatic", - "platforms": [ - "windows" - ] - }, - { - "id": "settings.project.openProject", - "description": "Windows UI: settings.project.openProject", - "platforms": [ - "windows" - ] - }, - { - "id": "settings.project.openSettings", - "description": "Windows UI: settings.project.openSettings", - "platforms": [ - "windows" - ] - }, - { - "id": "settings.project.overrides", - "description": "Windows UI: settings.project.overrides", - "platforms": [ - "windows" - ] - }, - { - "id": "settings.project.refresh", - "description": "Windows UI: settings.project.refresh", - "platforms": [ - "windows" - ] - }, - { - "id": "settings.project.reloadFailed", - "description": "Windows UI: settings.project.reloadFailed", - "platforms": [ - "windows" - ] - }, - { - "id": "settings.project.saved", - "description": "Windows UI: settings.project.saved", - "platforms": [ - "windows" - ] - }, - { - "id": "settings.project.savedReloadFailed", - "description": "Windows UI: settings.project.savedReloadFailed", - "platforms": [ - "windows" - ] - }, - { - "id": "settings.project.scope", - "description": "Windows UI: settings.project.scope", - "platforms": [ - "windows" - ] - }, - { - "id": "settings.project.scopeTitle", - "description": "Windows UI: settings.project.scopeTitle", - "platforms": [ - "windows" - ] - }, - { - "id": "settings.project.title", - "description": "Windows UI: settings.project.title", - "platforms": [ - "windows" - ] - }, - { - "id": "settings.project.useProjectJdk", - "description": "Windows UI: settings.project.useProjectJdk", - "platforms": [ - "windows" - ] - }, - { - "id": "settings.resizeCategories", - "description": "Windows UI: settings.resizeCategories", - "platforms": [ - "windows" - ] - }, - { - "id": "settings.resizePlugins", - "description": "Windows UI: settings.resizePlugins", - "platforms": [ - "windows" - ] - }, - { - "id": "settings.run.description", - "description": "Windows UI: settings.run.description", - "platforms": [ - "windows" - ] - }, - { - "id": "settings.run.title", - "description": "Windows UI: settings.run.title", - "platforms": [ - "windows" - ] - }, - { - "id": "settings.search", - "description": "Settings label: Search settings.", - "platforms": [ - "macos", - "windows" - ], - "macos": { - "key": "Search settings", - "arguments": [] - } - }, - { - "id": "settings.sections", - "description": "Windows UI: settings.sections", + "id": "settings.sections", + "description": "Windows UI: settings.sections", "platforms": [ "windows" ] @@ -92132,6 +91520,884 @@ "windows" ] }, + { + "id": "ide.build.unavailable", + "description": "IDE Build Provider operation result", + "platforms": [ + "macos" + ], + "macos": { + "key": "Build plugins are unavailable", + "arguments": [] + } + }, + { + "id": "ide.build.chooseInstance", + "description": "IDE Build Provider operation result", + "platforms": [ + "macos" + ], + "macos": { + "key": "Choose a detected build instance", + "arguments": [] + } + }, + { + "id": "ide.build.busy", + "description": "IDE Build Provider operation result", + "platforms": [ + "macos" + ], + "macos": { + "key": "A build operation is already active", + "arguments": [] + } + }, + { + "id": "ide.build.chooseTask", + "description": "IDE Build Provider operation result", + "platforms": [ + "macos" + ], + "macos": { + "key": "Choose a declared build task", + "arguments": [] + } + }, + { + "id": "ide.build.reloadSelection", + "description": "IDE Build Provider operation result", + "platforms": [ + "macos" + ], + "macos": { + "key": "Reload the build model before selecting a project, target or model task", + "arguments": [] + } + }, + { + "id": "ide.build.selectionExpired", + "description": "IDE Build Provider operation result", + "platforms": [ + "macos" + ], + "macos": { + "key": "The build selection is no longer available", + "arguments": [] + } + }, + { + "id": "ide.build.launchFailed", + "description": "IDE Build Provider operation result", + "platforms": [ + "macos" + ], + "macos": { + "key": "Build launch did not start", + "arguments": [] + } + }, + { + "id": "ide.build.restart", + "description": "IDE Build Provider operation result", + "platforms": [ + "macos" + ], + "macos": { + "key": "Use Build execute to start another build", + "arguments": [] + } + }, + { + "id": "native.projectEnvironmentSettingsChangedOnDisk", + "description": "Native UI: Project environment settings changed on disk. Reload before saving; your draft has been kept.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Project environment settings changed on disk. Reload before saving; your draft has been kept.", + "arguments": [] + } + }, + { + "id": "native.couldNotVerifySavedProjectEnvironment", + "description": "Native UI: Could not verify the saved project environment settings. The previous preferences have been kept when possible.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Could not verify the saved project environment settings. The previous preferences have been kept when possible.", + "arguments": [] + } + }, + { + "id": "native.projectEnvironmentSaveInProgress", + "description": "Native UI: Project environment settings are being saved. Wait before saving again.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Project environment settings are being saved. Wait before saving again.", + "arguments": [] + } + }, + { + "id": "native.nativeConfigurationUnavailable", + "description": "Native build configuration: Native configuration is unavailable.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Native configuration is unavailable.", + "arguments": [] + } + }, + { + "id": "native.nativeConfigurationStale", + "description": "Native build configuration: Native configuration changed. Reload before saving; your draft has been kept.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Native configuration changed. Reload before saving; your draft has been kept.", + "arguments": [] + } + }, + { + "id": "native.nativeConfigurationBusy", + "description": "Native build configuration: Native configuration is busy. Wait for the current operation to finish.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Native configuration is busy. Wait for the current operation to finish.", + "arguments": [] + } + }, + { + "id": "native.nativeConfigurationReadBackFailed", + "description": "Native build configuration: Native configuration was saved, but its current effective values could not be verified. Reload before editing again.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Native configuration was saved, but its current effective values could not be verified. Reload before editing again.", + "arguments": [] + } + }, + { + "id": "native.nativeConfigurationDiskConflict", + "description": "Native build configuration: Native configuration changed on disk. Reload before saving; your draft has been kept.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Native configuration changed on disk. Reload before saving; your draft has been kept.", + "arguments": [] + } + }, + { + "id": "native.nativeConfigurationSourceReadOnly", + "description": "Native build configuration: This native configuration source is read-only. Installed applications and plugin packages cannot be edited.", + "platforms": [ + "macos" + ], + "macos": { + "key": "This native configuration source is read-only. Installed applications and plugin packages cannot be edited.", + "arguments": [] + } + }, + { + "id": "native.nativeConfigurationFileRequired", + "description": "Native build configuration: The native configuration must be a writable file smaller than 2 MB.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The native configuration must be a writable file smaller than 2 MB.", + "arguments": [] + } + }, + { + "id": "native.nativeConfigurationTitle", + "description": "Native build configuration: Native configuration", + "platforms": [ + "macos" + ], + "macos": { + "key": "Native configuration", + "arguments": [] + } + }, + { + "id": "native.nativeConfigurationOpenProject", + "description": "Native build configuration: Open a project to read its native configuration.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Open a project to read its native configuration.", + "arguments": [] + } + }, + { + "id": "native.nativeConfigurationNoInstance", + "description": "Native build configuration: This project has no matching build-system instance.", + "platforms": [ + "macos" + ], + "macos": { + "key": "This project has no matching build-system instance.", + "arguments": [] + } + }, + { + "id": "native.nativeConfigurationNotRead", + "description": "Native build configuration: No native configuration has been read.", + "platforms": [ + "macos" + ], + "macos": { + "key": "No native configuration has been read.", + "arguments": [] + } + }, + { + "id": "native.buildOptionUnavailable", + "description": "Agent build configuration: The build option is no longer available. Reload the build instance.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The build option is no longer available. Reload the build instance.", + "arguments": [] + } + }, + { + "id": "native.buildOptionInvalid", + "description": "Agent build configuration: The build option value is invalid. Inspect its current kind and choices.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The build option value is invalid. Inspect its current kind and choices.", + "arguments": [] + } + }, + { + "id": "native.buildOptionReadBackFailed", + "description": "Agent build configuration: The saved build option could not be verified. Reload before editing again.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The saved build option could not be verified. Reload before editing again.", + "arguments": [] + } + }, + { + "id": "native.nativeConfigurationExplicitRead", + "description": "Native build configuration: Reading configuration with this project tool requires an explicit action.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Reading configuration with this project tool requires an explicit action.", + "arguments": [] + } + }, + { + "id": "native.nativeConfigurationReadUsingProjectTool", + "description": "Native build configuration: Read using project tool", + "platforms": [ + "macos" + ], + "macos": { + "key": "Read using project tool", + "arguments": [] + } + }, + { + "id": "native.buildOptionMigrationCouldNotBeVerified", + "description": "Native UI: Build option migration could not be verified. The previous configuration has been retained. Reload the build instance to retry.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Build option migration could not be verified. The previous configuration has been retained. Reload the build instance to retry.", + "arguments": [] + } + }, + { + "id": "native.selectedToolchainUnavailable", + "description": "Native UI: The selected toolchain is unavailable.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The selected toolchain is unavailable.", + "arguments": [] + } + }, + { + "id": "native.savedBuildOptionsInvalid", + "description": "Native UI: The saved build options are invalid. The original configuration has been kept.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The saved build options are invalid. The original configuration has been kept.", + "arguments": [] + } + }, + { + "id": "native.buildOptionsSaveFailed", + "description": "Native UI: The build options could not be saved or verified. Reload before retrying.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The build options could not be saved or verified. Reload before retrying.", + "arguments": [] + } + }, + { + "id": "native.buildOptionsConflict", + "description": "Native UI: Build options changed on disk. Reload before saving; your changes have been kept.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Build options changed on disk. Reload before saving; your changes have been kept.", + "arguments": [] + } + }, + { + "id": "native.updateMavenBeforeRunGeneration", + "description": "Native UI: Maven model upgrade required for authoritative Run generation.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Update the Maven Build plugin before generating Run configurations.", + "arguments": [] + } + }, + { + "id": "native.staleProviderRunGeneration", + "description": "Native UI: a saved Run generation belongs to a previous build Provider model.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Build provider model changed. Regenerate Run configurations before running.", + "arguments": [] + } + }, + { + "id": "macos.buildProvider.jvmDebugPortTimeout", + "description": "JVM framework debug listener deadline", + "platforms": [ + "macos" + ], + "macos": { + "key": "The framework did not open its JVM debug port before the deadline. Check the Run output.", + "arguments": [] + } + }, + { + "id": "macos.buildProvider.jvmDebugRemoteUnavailable", + "description": "Framework JVM debug needs a local workspace until endpoint forwarding is supported", + "platforms": [ + "macos" + ], + "macos": { + "key": "Framework service debugging is not available in remote workspaces yet.", + "arguments": [] + } + }, + { + "id": "build.window.addArgument", + "description": "Windows Build task catalog and parameter dialog", + "platforms": [ + "macos" + ], + "macos": { + "key": "Add argument", + "arguments": [] + } + }, + { + "id": "macos.native.savedRunMigrationNeedsUpdate", + "description": "Saved Run migration: retain unsupported or ambiguous legacy inputs until a compatible cutover is available.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Some saved run configurations cannot be migrated yet. Keep the existing files and retry after updating Lithe and its build plugin.", + "arguments": [] + } + }, + { + "id": "git.blame.annotate", + "description": "Windows UI: git.blame.annotate", + "platforms": [ + "windows" + ] + }, + { + "id": "git.blame.copyRevision", + "description": "Windows UI: git.blame.copyRevision", + "platforms": [ + "windows" + ] + }, + { + "id": "git.blame.hide", + "description": "Windows UI: git.blame.hide", + "platforms": [ + "windows" + ] + }, + { + "id": "git.blame.revisionCopied", + "description": "Windows UI: git.blame.revisionCopied", + "platforms": [ + "windows" + ] + }, + { + "id": "git.blame.showDiff", + "description": "Windows UI: git.blame.showDiff", + "platforms": [ + "windows" + ] + }, + { + "id": "git.blame.uncommitted", + "description": "Windows UI: git.blame.uncommitted", + "platforms": [ + "windows" + ] + }, + { + "id": "menu.settings", + "description": "Windows UI: menu.settings", + "platforms": [ + "windows" + ] + }, + { + "id": "native.downloadedDataWasKeptTryAgainToResumeFromWhereItStopped", + "description": "Native UI: Downloaded data was kept. Try again to resume from where it stopped.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Downloaded data was kept. Try again to resume from where it stopped.", + "arguments": [] + } + }, + { + "id": "navigation.syntaxFallback", + "description": "Windows UI: a jump was resolved from syntax alone because the language server is still importing the project", + "platforms": [ + "windows" + ], + "parameters": { + "name": "string" + } + }, + { + "id": "settings.editor.tabLimitPolicy", + "description": "Windows UI: settings.editor.tabLimitPolicy", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.tabLimitPolicyDescription", + "description": "Windows UI: settings.editor.tabLimitPolicyDescription", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.tabLimitPolicy.leastRecentlyUsed", + "description": "Windows UI: settings.editor.tabLimitPolicy.leastRecentlyUsed", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.editor.tabLimitPolicy.oldestOpened", + "description": "Windows UI: settings.editor.tabLimitPolicy.oldestOpened", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.network.checkFailed", + "description": "Native UI: The connection check failed.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The connection check failed.", + "arguments": [] + } + }, + { + "id": "settings.network.connectionSucceeded", + "description": "Native UI: Connection succeeded in %@ ms.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Connection succeeded in %@ ms.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "settings.network.credentialsUnavailable", + "description": "Native UI: Proxy credentials cannot be saved in this build.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Proxy credentials cannot be saved in this build.", + "arguments": [] + } + }, + { + "id": "settings.network.enterAddressAndPort", + "description": "Native UI: Enter a server address and a port between 1 and 65535 first.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Enter a server address and a port between 1 and 65535 first.", + "arguments": [] + } + }, + { + "id": "settings.network.exclusions", + "description": "Native UI: Exclusion list", + "platforms": [ + "macos" + ], + "macos": { + "key": "Exclusion list", + "arguments": [] + } + }, + { + "id": "settings.network.exclusionsPlaceholder", + "description": "Native UI: For example 127.0.0.1, localhost, *.internal.example", + "platforms": [ + "macos" + ], + "macos": { + "key": "For example 127.0.0.1, localhost, *.internal.example", + "arguments": [] + } + }, + { + "id": "settings.network.host", + "description": "Native UI: Server address", + "platforms": [ + "macos" + ], + "macos": { + "key": "Server address", + "arguments": [] + } + }, + { + "id": "settings.network.hostPlaceholder", + "description": "Native UI: For example 127.0.0.1", + "platforms": [ + "macos" + ], + "macos": { + "key": "For example 127.0.0.1", + "arguments": [] + } + }, + { + "id": "settings.network.info.exclusions", + "description": "Native UI: Addresses that bypass the proxy, separated by commas. Entries support wildcard domains such as *.internal.example.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Addresses that bypass the proxy, separated by commas. Entries support wildcard domains such as *.internal.example.", + "arguments": [] + } + }, + { + "id": "settings.network.info.host", + "description": "Native UI: The proxy server's host name or IP address, for example 127.0.0.1.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The proxy server's host name or IP address, for example 127.0.0.1.", + "arguments": [] + } + }, + { + "id": "settings.network.info.mode", + "description": "Native UI: Follow system uses the proxies configured in macOS network settings. No proxy contacts every host directly. Custom routes requests through the server configured below.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Follow system uses the proxies configured in macOS network settings. No proxy contacts every host directly. Custom routes requests through the server configured below.", + "arguments": [] + } + }, + { + "id": "settings.network.info.password", + "description": "Native UI: Leave empty when the proxy does not require authentication. The password is stored in the Keychain, never in settings files.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Leave empty when the proxy does not require authentication. The password is stored in the Keychain, never in settings files.", + "arguments": [] + } + }, + { + "id": "settings.network.info.passwordSaved", + "description": "Native UI: A password is saved for its original proxy. An empty field reuses it only when the proxy type, address, port and username match.", + "platforms": [ + "macos" + ], + "macos": { + "key": "A password is saved for its original proxy. An empty field reuses it only when the proxy type, address, port and username match.", + "arguments": [] + } + }, + { + "id": "settings.network.info.port", + "description": "Native UI: The proxy server's port, for example 7890.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The proxy server's port, for example 7890.", + "arguments": [] + } + }, + { + "id": "settings.network.info.scope", + "description": "Native UI: These settings apply to Lithe's own requests: the plugin marketplace, GitHub and AI providers. Terminal commands, Git, JVM dependency downloads and the built-in updater keep their own network settings; the updater always follows the system proxy. Changes apply to new requests.", + "platforms": [ + "macos" + ], + "macos": { + "key": "These settings apply to Lithe's own requests: the plugin marketplace, GitHub and AI providers. Terminal commands, Git, JVM dependency downloads and the built-in updater keep their own network settings; the updater always follows the system proxy. Changes apply to new requests.", + "arguments": [] + } + }, + { + "id": "settings.network.info.type", + "description": "Native UI: HTTP proxies carry HTTPS requests through CONNECT. SOCKS5 tunnels every protocol and port.", + "platforms": [ + "macos" + ], + "macos": { + "key": "HTTP proxies carry HTTPS requests through CONNECT. SOCKS5 tunnels every protocol and port.", + "arguments": [] + } + }, + { + "id": "settings.network.info.username", + "description": "Native UI: Leave empty when the proxy does not require authentication.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Leave empty when the proxy does not require authentication.", + "arguments": [] + } + }, + { + "id": "settings.network.mode.custom", + "description": "Native UI: Custom proxy server", + "platforms": [ + "macos" + ], + "macos": { + "key": "Custom proxy server", + "arguments": [] + } + }, + { + "id": "settings.network.mode.followSystem", + "description": "Native UI: Follow system proxy", + "platforms": [ + "macos" + ], + "macos": { + "key": "Follow system proxy", + "arguments": [] + } + }, + { + "id": "settings.network.mode.noProxy", + "description": "Native UI: No proxy", + "platforms": [ + "macos" + ], + "macos": { + "key": "No proxy", + "arguments": [] + } + }, + { + "id": "settings.network.optional", + "description": "Native UI: Optional", + "platforms": [ + "macos" + ], + "macos": { + "key": "Optional", + "arguments": [] + } + }, + { + "id": "settings.network.portPlaceholder", + "description": "Native UI: For example 7890", + "platforms": [ + "macos" + ], + "macos": { + "key": "For example 7890", + "arguments": [] + } + }, + { + "id": "settings.network.proxyAnsweredWithHTTP", + "description": "Native UI: The proxy answered with HTTP %@.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The proxy answered with HTTP %@.", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, + { + "id": "settings.network.proxyMode", + "description": "Native UI: Proxy mode", + "platforms": [ + "macos" + ], + "macos": { + "key": "Proxy mode", + "arguments": [] + } + }, + { + "id": "settings.network.proxyServer", + "description": "Native UI: Proxy server", + "platforms": [ + "macos" + ], + "macos": { + "key": "Proxy server", + "arguments": [] + } + }, + { + "id": "settings.network.proxyType", + "description": "Native UI: Proxy type", + "platforms": [ + "macos" + ], + "macos": { + "key": "Proxy type", + "arguments": [] + } + }, + { + "id": "settings.network.testConnection", + "description": "Native UI: Test connection", + "platforms": [ + "macos" + ], + "macos": { + "key": "Test connection", + "arguments": [] + } + }, + { + "id": "settings.network.testing", + "description": "Native UI: Testing…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Testing…", + "arguments": [] + } + }, + { + "id": "settings.network.title", + "description": "Native UI: Network", + "platforms": [ + "macos" + ], + "macos": { + "key": "Network", + "arguments": [] + } + }, + { + "id": "settings.network.type.http", + "description": "Native UI: HTTP", + "platforms": [ + "macos" + ], + "macos": { + "key": "HTTP", + "arguments": [] + } + }, + { + "id": "settings.network.type.socks5", + "description": "Native UI: SOCKS5", + "platforms": [ + "macos" + ], + "macos": { + "key": "SOCKS5", + "arguments": [] + } + }, + { + "id": "settings.network.username", + "description": "Native UI: Username", + "platforms": [ + "macos" + ], + "macos": { + "key": "Username", + "arguments": [] + } + }, { "id": "native.thePluginBackendIsNoLongerRunning", "description": "Native UI: The plugin backend is no longer running.", @@ -94590,6 +94856,39 @@ "windows" ] }, + { + "id": "run.provider.runtimeArguments", + "description": "Provider Run configuration editor", + "platforms": [ + "macos" + ], + "macos": { + "key": "Runtime arguments", + "arguments": [] + } + }, + { + "id": "run.provider.runtimeArgumentsHint", + "description": "Provider Run configuration editor", + "platforms": [ + "macos" + ], + "macos": { + "key": "Each row is one argument. Spaces, quotes and empty values are preserved.", + "arguments": [] + } + }, + { + "id": "run.provider.entrypoint", + "description": "Provider Run configuration editor", + "platforms": [ + "macos" + ], + "macos": { + "key": "Entry point", + "arguments": [] + } + }, { "id": "plugin.terminal.open", "description": "Plugin-owned integrated terminal host API", @@ -94705,6 +95004,385 @@ "arguments": [] } }, + { + "id": "native.toolchainPreferences.buildToolDefaults", + "description": "Machine-local Build tool preference editor: Build tool defaults", + "platforms": [ + "macos" + ], + "macos": { + "key": "Build tool defaults", + "arguments": [] + } + }, + { + "id": "native.toolchainPreferences.editDefaults", + "description": "Machine-local Build tool preference editor: Edit defaults", + "platforms": [ + "macos" + ], + "macos": { + "key": "Edit defaults", + "arguments": [] + } + }, + { + "id": "native.toolchainPreferences.localBuildPreferencesInfo", + "description": "Machine-local Build tool preference editor: These machine-local choices apply to Build providers and their Run configurations on the next operation. Saving does not probe or execute a tool. Inherited choices follow later default changes.", + "platforms": [ + "macos" + ], + "macos": { + "key": "These machine-local choices apply to Build providers and their Run configurations on the next operation. Saving does not probe or execute a tool. Inherited choices follow later default changes.", + "arguments": [] + } + }, + { + "id": "native.toolchainPreferences.consumer", + "description": "Machine-local Build tool preference editor: Consumer", + "platforms": [ + "macos" + ], + "macos": { + "key": "Consumer", + "arguments": [] + } + }, + { + "id": "native.toolchainPreferences.allBuildConsumers", + "description": "Machine-local Build tool preference editor: All Build consumers", + "platforms": [ + "macos" + ], + "macos": { + "key": "All Build consumers", + "arguments": [] + } + }, + { + "id": "native.toolchainPreferences.savedInput", + "description": "Machine-local Build tool preference editor: Saved input", + "platforms": [ + "macos" + ], + "macos": { + "key": "Saved input", + "arguments": [] + } + }, + { + "id": "native.toolchainPreferences.savedInputInfo", + "description": "Machine-local Build tool preference editor: A saved input is revalidated by the existing toolchain resolver when the consumer runs. It is not a verified current installation.", + "platforms": [ + "macos" + ], + "macos": { + "key": "A saved input is revalidated by the existing toolchain resolver when the consumer runs. It is not a verified current installation.", + "arguments": [] + } + }, + { + "id": "native.toolchainPreferences.nextBuildInput", + "description": "Machine-local Build tool preference editor: Next Build input", + "platforms": [ + "macos" + ], + "macos": { + "key": "Next Build input", + "arguments": [] + } + }, + { + "id": "native.toolchainPreferences.automaticSelection", + "description": "Machine-local Build tool preference editor: Automatic selection", + "platforms": [ + "macos" + ], + "macos": { + "key": "Automatic selection", + "arguments": [] + } + }, + { + "id": "native.toolchainPreferences.useInheritedChoice", + "description": "Machine-local Build tool preference editor: Use inherited choice", + "platforms": [ + "macos" + ], + "macos": { + "key": "Use inherited choice", + "arguments": [] + } + }, + { + "id": "native.toolchainPreferences.useWorkspaceDefault", + "description": "Machine-local Build tool preference editor: Use workspace default", + "platforms": [ + "macos" + ], + "macos": { + "key": "Use workspace default", + "arguments": [] + } + }, + { + "id": "native.toolchainPreferences.useConsumerDefault", + "description": "Machine-local Build tool preference editor: Use consumer default", + "platforms": [ + "macos" + ], + "macos": { + "key": "Use consumer default", + "arguments": [] + } + }, + { + "id": "native.toolchainPreferences.installationDirectory", + "description": "Machine-local Build tool preference editor: Installation directory", + "platforms": [ + "macos" + ], + "macos": { + "key": "Installation directory", + "arguments": [] + } + }, + { + "id": "native.toolchainPreferences.executableFile", + "description": "Machine-local Build tool preference editor: Executable file", + "platforms": [ + "macos" + ], + "macos": { + "key": "Executable file", + "arguments": [] + } + }, + { + "id": "native.toolchainPreferences.useDirectoryToolDefault", + "description": "Build consumer follows the general tool at its requested directory.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Use directory tool default", + "arguments": [] + } + }, + { + "id": "native.toolchainPreferences.preferenceDirectoriesInfo", + "description": "Saved tool preference scope presentation.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Lists the project root and directories with version files, fixed versions or saved tool preferences, together with their parents.", + "arguments": [] + } + }, + { + "id": "native.toolchainPreferences.defaultsBadge", + "description": "Saved tool preference scope presentation.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Defaults", + "arguments": [] + } + }, + { + "id": "native.toolchainPreferences.sourceChangedDuringLoad", + "description": "Concurrent preference edits exhausted the bounded snapshot retry.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Toolchain settings changed while loading. Refresh and try again.", + "arguments": [] + } + }, + { + "id": "aiCommit.outputTokenField", + "description": "Lithe Chat Completions output budget field", + "platforms": [ + "macos" + ], + "macos": { + "key": "Output token field", + "arguments": [] + } + }, + { + "id": "aiCommit.outputTokenFieldInfo", + "description": "Explain Lithe's gateway compatibility setting", + "platforms": [ + "macos" + ], + "macos": { + "key": "Lithe uses this field for commit requests. Choose max_tokens for older gateways.", + "arguments": [] + } + }, + { + "id": "native.keymapImportAmbiguous", + "description": "More than one plugin matched a selected shortcut source; ask the user to disable duplicates before retrying.", + "platforms": [ + "macos" + ], + "macos": { + "key": "More than one plugin can import shortcuts from this source. Disable duplicate providers and scan again.", + "arguments": [] + } + }, + { + "id": "native.keymapImportSourceReadPermission", + "description": "Plugin permission for scoped reads of the selected shortcut import source.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Read declared shortcut files from the editor you select for import", + "arguments": [] + } + }, + { + "id": "native.keymapImportProviderPermission", + "description": "Plugin permission for offering shortcut mappings during import.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Offer shortcut mappings for import", + "arguments": [] + } + }, + { + "id": "native.keymapImportRuntimeUnavailable", + "description": "Shortcut import is unavailable in this runtime; suggest updating the application and plugin.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Shortcut import is unavailable. Update Lithe and the editor import plugin, then try again.", + "arguments": [] + } + }, + { + "id": "debugger.breakpointRelocationFailed", + "description": "Breakpoint relocation failed; reset unsafe source anchors before debugging.", + "platforms": [ + "windows" + ] + }, + { + "id": "markdown.renderFailed", + "description": "Windows Markdown preview rendering failed; edit or reopen to retry", + "platforms": [ + "windows" + ] + }, + { + "id": "git.rollbackSelectionChanged", + "description": "Windows UI: selected Git changes changed before rollback", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.invalidWhen", + "description": "Windows shortcut context validation or concurrent save failed", + "platforms": [ + "windows" + ] + }, + { + "id": "keybindings.saveFailed", + "description": "Windows shortcut save failure", + "platforms": [ + "windows" + ], + "parameters": { + "error": "string" + } + }, + { + "id": "native.couldNotFilterGitHistoryTryAgain", + "description": "Native UI: Git history date filter failed", + "platforms": [ + "macos" + ], + "macos": { + "key": "Could not filter Git history. Try again.", + "arguments": [] + } + }, + { + "id": "native.debugStartOrContinue", + "description": "Native UI: Debug: Start or Continue", + "platforms": [ + "macos" + ], + "macos": { + "key": "Debug: Start or Continue", + "arguments": [] + } + }, + { + "id": "native.startDebuggingOrContinuePausedSession", + "description": "Native UI: Start debugging or continue the paused session", + "platforms": [ + "macos" + ], + "macos": { + "key": "Start debugging or continue the paused session", + "arguments": [] + } + }, + { + "id": "native.findAction", + "description": "Native UI: Find Action", + "platforms": [ + "macos" + ], + "macos": { + "key": "Find Action", + "arguments": [] + } + }, + { + "id": "native.findAction.variant2", + "description": "Native UI: Find Action…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Find Action…", + "arguments": [] + } + }, + { + "id": "native.findAndExecuteCommand", + "description": "Native UI: Find and execute a command", + "platforms": [ + "macos" + ], + "macos": { + "key": "Find and execute a command", + "arguments": [] + } + }, + { + "id": "native.savedStashInvalidUtf8", + "description": "Native UI: saved stash contains non-UTF-8 text", + "platforms": [ + "macos" + ], + "macos": { + "key": "Saved changes contain text that is not UTF-8. Inspect the stash with Git.", + "arguments": [] + } + }, { "id": "settings.editorImport.choose.ide.configuration.directory", "description": "macOS editor settings import: Choose IDE configuration directory", diff --git a/shared/platform-feature-matrix/features/build-model-project-presentation.json b/shared/platform-feature-matrix/features/build-model-project-presentation.json new file mode 100644 index 0000000000..49707d37e4 --- /dev/null +++ b/shared/platform-feature-matrix/features/build-model-project-presentation.json @@ -0,0 +1,29 @@ +{ + "id": "build-model-project-presentation", + "area": "扩展", + "group": "插件与主题", + "capability": "搜索模块标签与编辑器测试源角色消费插件构建模型,按最深路径匹配并拒绝过期模型", + "macos": { + "implementationStatus": "implemented", + "verificationStatus": "pending", + "evidence": [ + "macos/Sources/LitheExecutionModule/Application/BuildProjectPresentation.swift", + "macos/Sources/LitheExecutionModule/Application/BuildSystemsFeatureModel.swift", + "macos/Sources/Lithe/Models/Editor/EditorDocument.swift", + "macos/Sources/Lithe/Views/Editor/EditorAreaView.swift", + "macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift", + "macos/Tests/LitheExecutionModuleTests/BuildProjectPresentationTests.swift", + "macos/Tests/LitheTests/EditorTabLayoutTests.swift" + ] + }, + "windows": { + "evidence": [ + "windows/tauri/src-tauri/src/plugin_host/mod.rs" + ], + "implementationStatus": "missing", + "verificationStatus": "not-applicable" + }, + "owner": "Extensions", + "verification": "使用 Maven Provider 的多模块模型与自定义源码/测试目录,加载模型后检查搜索结果模块名称、普通/测试/生成测试/测试资源标签角色;父工程与子模块匹配最深路径,app-copy 不得匹配 app。源码模型过期后不沿用旧角色,JDT 的 Java 测试语义仍作未导入文件的回显依据。检查虚拟文档和工作区外路径不误匹配。BuildProjectPresentationTests、EditorTabLayoutTests 自动化;完整 UI 验收仍 pending。Windows 尚未接 Build Provider,不能把其旧 Maven 模型路径计入此能力。", + "notes": " Windows 通用 Build 入口增加按工作区/模块的声明任务、模型任务分类与显式参数确认,参数逐项保留边界;依赖仅展示 Core/Provider 节点,最多四份内存快照,输入失效/取消拒绝晚到解码。模型事实与进度分别通知,原生配置保存撤销模型后任务树立即清空;目标/Run/Agent/Java、旧数据与正式 Windows 准入仍待完成,不据前端回归标为实机 verified。 Windows 后续基础设施可展示 Core 规范诊断/测试报告及精确模块用例,结果/清单各只保留四份快照,大报告虚拟化且输出不复制报告。Core watch 与执行前指纹核对撤销过期模型,源码编辑撤销发现清单;团队文件变化合并只读检测。外部原生来源文件观察、Run/Java/Agent 与旧数据迁移仍未完成,Build 能力准入保持关闭。" +} diff --git a/shared/platform-feature-matrix/features/build-native-configuration.json b/shared/platform-feature-matrix/features/build-native-configuration.json new file mode 100644 index 0000000000..fb231c3e1f --- /dev/null +++ b/shared/platform-feature-matrix/features/build-native-configuration.json @@ -0,0 +1,30 @@ +{ + "id": "build-native-configuration", + "area": "扩展", + "group": "插件与主题", + "capability": "Build Provider 协议 7 读取原生配置的实际来源、原值和生效值;macOS 通用插件设置页维护草稿、明确目标保存、条件原子替换和上游回读,安装目录只读,秘密字段不经过 Provider 或界面", + "macos": { + "implementationStatus": "implemented", + "verificationStatus": "pending", + "evidence": [ + "rust/lithe-core/src/build/configuration.rs", + "shared/contracts/plugins/v1/fixtures/build-configuration.json", + "macos/Sources/LitheExecutionModule/Services/BuildNativeConfigurationCoordinator.swift", + "macos/Sources/Lithe/Platform/MacOS/Build/MacBuildNativeConfigurationStore.swift", + "macos/Sources/Lithe/Views/App/ContributedBuildConfigurationSection.swift", + "macos/Tests/LitheTests/BuildNativeConfigurationTests.swift", + "macos/Tests/LitheTests/MacNativeBuildProviderIntegrationTests.swift", + "macos/Tests/LitheExecutionModuleTests/BuildSystemServiceTests.swift" + ] + }, + "windows": { + "evidence": [ + "rust/lithe-core/src/build/configuration.rs" + ], + "implementationStatus": "missing", + "verificationStatus": "not-applicable" + }, + "owner": "Plugin Platform / Build", + "verification": "Core 定向 120 项和 macOS 配置/生命周期 11 项通过。显式 stage D 真实 Maven macOS 场景通过:损坏 POM 前读取、安装继承、明确保存、敏感内容/权限保留、外部修改冲突、重开回读、Provider 退出及包清单不变。Node SDK 58 项、Java SDK 单元测试与 Node/Java SDK 端到端示例通过。设置页原生控件在 600/900 宽度、浅色/深色主题四个组合通过,已记录截图;发行组合、正式 SDK 锁定与完整 Maven/Java 工作流尚未完成,因此保持 pending;Windows 通用编辑器和原生保存 adapter 已接入准备中的插件设置入口;Build 产品准入关闭,未进行 Windows 实机验收。执行 BuildNativeConfigurationTests、NativeConfigurationSettingsControlTests、BuildSystemServiceTests 及带 LITHE_STAGE_D_MAVEN_PACKAGE 的 MacNativeBuildProviderIntegrationTests;手工从插件注册的设置页读取、启用覆盖、保存/取消/重载,覆盖外部编辑、旧选项 ABA、切换项目、插件停用和读取失败。 自动读取项目 Wrapper 或自动工具解析失败时不发 Provider 请求;显式读取才运行项目工具,定向测试通过。 只读探针还验证继承、保存后回读和 CLI 指定的实际仓库均未被创建;Maven 官方完整执行请求提供有效值,仓库管理器仅在本次可清理状态目录初始化。 Windows 前端覆盖继承值回显/启用覆盖、取消不写、保存后上游回读、来源间草稿保留、失效/冲突/读回失败、只读及迟到文件选择;设置关闭后重开保留草稿。旧数据迁移和 Windows/WebView2 实机工作流仍待完成,missing/not-applicable 表示产品尚未准入。 本机受影响前端 120 项(新增原生设置 9 项)、CI Build 同范围 59 项、隔离生命周期 6 项通过,类型检查与生产前端构建通过;Windows 原生未执行。 Review 后 v7 已移除 XML 根/路径绑定及 Core XML 编辑器,改为提供者生成私有暂存内容、返回来源/哈希回执,宿主保留确认、冲突检查、元数据保护、原子发布和回读。127 项 macOS 回归、真实 Maven 生产原生设置流程 1 项、Node/Java JSON 配置 E2E、Windows 前端 71 项和类型检查通过;Windows 原生事务新增通用 JSON/错误哈希/清理测试,既有 watcher 的 4 个失败另记,未验证 Windows 实机。暂存写入禁止 CREATE,取消/撤销的迟到回复不能发布或重建文件;私密内容不进入 RPC/可观察状态。", + "notes": " Windows 基础设施复用窗口文档 watcher 观察原生配置来源及链接目标;缺失父目录只观察最近现存祖先,注册不创建用户目录。版本检查只返回路径和一致性,不传 XML;模型发布和执行前检查原生版本,外部变化撤销模型/草稿,自己的保存与回读不误取消。插件撤销等待来源读取排空。Run/Java/Agent 与旧数据迁移和实机验收仍未完成,产品能力准入保持关闭。" +} diff --git a/shared/platform-feature-matrix/features/build-provider-jvm-debug.json b/shared/platform-feature-matrix/features/build-provider-jvm-debug.json new file mode 100644 index 0000000000..7c0007af46 --- /dev/null +++ b/shared/platform-feature-matrix/features/build-provider-jvm-debug.json @@ -0,0 +1,37 @@ +{ + "id": "build-provider-jvm-debug", + "area": "运行与调试", + "group": "调试器", + "capability": "Maven 框架服务调试通过 Provider 准备当前目标,保留框架运行方式、工具与参数,再通过注册表中的适配器连接本机暂停的 JVM", + "macos": { + "evidence": [ + "macos/Sources/Lithe/Models/AppModel/AppModel+Debugging.swift", + "macos/Sources/Lithe/Models/AppModel/AppModel+BuildProviderDebugging.swift", + "macos/Sources/LitheExecutionModule/Services/RunService.swift", + "macos/Sources/Lithe/Platform/MacOS/Debug/MacDebugPortAvailabilityChecker.swift", + "macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift", + "shared/contracts/plugins/v1/fixtures/build-connection-debug-options.json", + "rust/lithe-core/src/tests/build_systems.rs", + "macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift", + "macos/Sources/LitheExecutionModule/Services/BuildSystemService.swift", + "macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift", + "macos/Tests/LitheLanguageIntelligenceModuleTests/LanguageIntelligenceModuleTests.swift", + "Plugins/mac/Official/JavaSupport/plugin.json", + "macos/Sources/Lithe/Platform/MacOS/Debug/MacBuildDebugLaunchResolver.swift", + "macos/Tests/LitheTests/PluginDebugAdapterTests.swift" + ], + "implementationStatus": "implemented", + "verificationStatus": "pending" + }, + "windows": { + "evidence": [ + "shared/contracts/plugins/v1/fixtures/build-connection-debug-options.json", + "rust/lithe-core/src/tests/build_systems.rs" + ], + "implementationStatus": "missing", + "verificationStatus": "not-applicable" + }, + "owner": "Build / Debug", + "verification": "用当前 Maven 插件选择框架服务,配置 Profile、运行 JDK、JVM/应用参数和环境,启动调试,确认先暂停且可命中断点;覆盖编译失败、端口超时、Stop、替换、配置 ABA、插件更新/停用、工作区重开及非当前调试会话结束时的进程清理。协议与生命周期测试不能替代完整原生 UI 和发行组合验收。 Windows 增量:服务目标显式保存并调试,唯一当前 Registry 类型/身份、Core 连接绑定、stdio/同包语言服务器、完整参数/初始化身份/禁用能力;覆盖重复/缺失适配器、包和设置变化、注册 ABA、监听取消、迟到启动回执、Run 退出/替换、清理失败重试。原生能力准入仍关闭,正式 Windows 适配器与真实框架断点 UI 尚未验收。", + "notes": "协议 11 传递当前注册表适配器身份、配置类型和宿主选定的 connection 回环端口;领域参数由 Maven 插件按当前 Mojo 描述符设置。普通 Maven 目标没有确定的调试 JVM。Windows 仍待 Provider 宿主迁移,不宣称完成。 当前仅支持本机工作区;远程工作区在具备端口转发前明确拒绝此入口。 配套 Maven 插件已用真实 Quarkus 3.15.5 与 Micronaut Maven Plugin 4.7.2 验证暂停、JDI 连接/恢复、参数、环境及 reactor 依赖;宿主断点 UI 与完整 Micronaut 应用仍待验收。 Windows 已接服务连接调试的原生注册表规划、目标入口和 Run/DAP 生命周期;尚未开放完整插件准入或文件型/原生程序 Registry 调试。" +} diff --git a/shared/platform-feature-matrix/features/build-provider-readonly-model-inputs.json b/shared/platform-feature-matrix/features/build-provider-readonly-model-inputs.json index 76e865d7f3..c2c3ff560e 100644 --- a/shared/platform-feature-matrix/features/build-provider-readonly-model-inputs.json +++ b/shared/platform-feature-matrix/features/build-provider-readonly-model-inputs.json @@ -26,5 +26,6 @@ "verificationStatus": "not-applicable" }, "owner": "Extensions", - "verification": "本地运行 Core model_inputs 测试、BuildSystemServiceTests 和 RustBuildSystemOperationsTests:核对精确来源、只读字段投影、选项指纹、不可用/过期状态、路径/大小边界、协议 5 拒绝、协议 6 接收,以及生产者重载/选项 ABA/停用/构建文件修改/工作区替换后忽略取消的旧结果不能发布。在 lithe-plugin-docs 使用当前工作区快照执行 Node/Java 两个真实后端的模型共享端到端场景,核对插件包字节不变。手动验收仍需安装一对正式插件,在原生窗口加载生产者与消费者,再修改选项、禁用生产者并确认旧模型不能启动任务;当前 Java/Maven 插件尚未迁移,Windows 宿主缺失,均不视为通过实机验收。 并行生产者回归:同时暂停 A/B 模型回复,按两种顺序释放,确认二者均非过期且消费者收到两个 ready 模型;生产者自身选项 ABA 仍使旧回复 stale。" + "verification": "本地运行 Core model_inputs 测试、BuildSystemServiceTests 和 RustBuildSystemOperationsTests:核对精确来源、只读字段投影、选项指纹、不可用/过期状态、路径/大小边界、协议 5 拒绝、协议 6 接收,以及生产者重载/选项 ABA/停用/构建文件修改/工作区替换后忽略取消的旧结果不能发布。在 lithe-plugin-docs 使用当前工作区快照执行 Node/Java 两个真实后端的模型共享端到端场景,核对插件包字节不变。手动验收仍需安装一对正式插件,在原生窗口加载生产者与消费者,再修改选项、禁用生产者并确认旧模型不能启动任务;当前 Java/Maven 插件尚未迁移,Windows 宿主缺失,均不视为通过实机验收。 并行生产者回归:同时暂停 A/B 模型回复,按两种顺序释放,确认二者均非过期且消费者收到两个 ready 模型;生产者自身选项 ABA 仍使旧回复 stale。", + "notes": " Windows 基础设施复用窗口文档 watcher 观察原生配置来源及链接目标;缺失父目录只观察最近现存祖先,注册不创建用户目录。版本检查只返回路径和一致性,不传 XML;模型发布和执行前检查原生版本,外部变化撤销模型/草稿,自己的保存与回读不误取消。插件撤销等待来源读取排空。Run/Java/Agent 与旧数据迁移和实机验收仍未完成,产品能力准入保持关闭。 Windows 原生来源只读版本检查与保存端口独立,模型及已保存 Run 准备无需写入权限。每个 Run 直接准备自己的模型,停止一个服务只排空自己的读取,不依赖默认模型;准备交接前重新检查工具与完整模型/来源版本。仍未开放产品能力。 Windows 每次 Run 的模型和原生来源租约与默认模型/草稿独立,Core 分别核对默认和临时选项指纹;沿用 reactor 工具选择并由 Core 合并该 Run 的明确路径覆盖;空值继承 reactor,默认工具无效不阻断显式有效覆盖。临时模型在启动确认后释放,关闭不停止工作区共享后端;真实跨平台 Maven 集成验证不同入口、默认模型保持及后续共享任务。Windows 实机仍未验收。" } diff --git a/shared/platform-feature-matrix/features/build-provider-refresh-and-tool.json b/shared/platform-feature-matrix/features/build-provider-refresh-and-tool.json index e7a0fbe5f2..9febfdb519 100644 --- a/shared/platform-feature-matrix/features/build-provider-refresh-and-tool.json +++ b/shared/platform-feature-matrix/features/build-provider-refresh-and-tool.json @@ -29,5 +29,6 @@ "verificationStatus": "not-applicable" }, "owner": "Extensions", - "verification": "Core 侧 build.modelPlan、build.dependencyPlan 对 provider 来源接受 refresh 与 tool.executable:只在 refresh 为 true 时写入 params.refresh,只在宿主给出工具时写入 params.tool,tool.executable 不是绝对路径时返回 plugin.build.invalidSelection,command 来源忽略两者;不带参数时计划与之前逐字节一致(build-plans.json 金样)。macOS 宿主先向 Core 要计划,确认是 provider 请求后才用 MacBuildToolResolver 解析工具并带上路径重新计划;工具解析失败只在 Build Output 记一行说明,请求照常发出且不带 tool。工具窗口的刷新按钮、构建文件变化横幅的 Reload、失败后的 Retry、依赖节点的 Reload/Retry 以及插件网页的 build.refresh 都带 refresh;首次点击“Load Project Model”与首次展开依赖不带。Windows 宿主尚未接入插件后端与 build.* 命令。自动化:cargo test -p lithe-core --lib build_systems、scripts/architecture/verify-shared-contracts.sh、./scripts/macos/test/test-macos.sh --filter 'BuildSystemServiceTests|MacBuildProviderSessionsTests|PluginWebBridgeTests'。手动验证:安装带 Provider 的 Maven 插件(lithe-plugin-maven 常驻后端版本),打开项目后首次加载模型,后端日志中 params 无 refresh、tool.executable 为项目 mvnw 或 PATH 中的 mvn;点工具栏刷新或修改 pom.xml 后点 Reload,params.refresh 为 true;临时移除 mvn 与 mvnw 后加载,Build Output 提示由 Provider 自行查找工具且请求仍然发出。" + "verification": "Core 侧 build.modelPlan、build.dependencyPlan 对 provider 来源接受 refresh 与 tool.executable:只在 refresh 为 true 时写入 params.refresh,只在宿主给出工具时写入 params.tool,tool.executable 不是绝对路径时返回 plugin.build.invalidSelection,command 来源忽略两者;不带参数时计划与之前逐字节一致(build-plans.json 金样)。macOS 宿主先向 Core 要计划,确认是 provider 请求后才用 MacBuildToolResolver 解析工具并带上路径重新计划;工具解析失败只在 Build Output 记一行说明,请求照常发出且不带 tool。工具窗口的刷新按钮、构建文件变化横幅的 Reload、失败后的 Retry、依赖节点的 Reload/Retry 以及插件网页的 build.refresh 都带 refresh;首次点击“Load Project Model”与首次展开依赖不带。Windows 宿主尚未接入插件后端与 build.* 命令。自动化:cargo test -p lithe-core --lib build_systems、scripts/architecture/verify-shared-contracts.sh、./scripts/macos/test/test-macos.sh --filter 'BuildSystemServiceTests|MacBuildProviderSessionsTests|PluginWebBridgeTests'。手动验证:安装带 Provider 的 Maven 插件(lithe-plugin-maven 常驻后端版本),打开项目后首次加载模型,后端日志中 params 无 refresh、tool.executable 为项目 mvnw 或 PATH 中的 mvn;点工具栏刷新或修改 pom.xml 后点 Reload,params.refresh 为 true;临时移除 mvn 与 mvnw 后加载,Build Output 提示由 Provider 自行查找工具且请求仍然发出。", + "notes": " Windows 后续基础设施可展示 Core 规范诊断/测试报告及精确模块用例,结果/清单各只保留四份快照,大报告虚拟化且输出不复制报告。Core watch 与执行前指纹核对撤销过期模型,源码编辑撤销发现清单;团队文件变化合并只读检测。外部原生来源文件观察、Run/Java/Agent 与旧数据迁移仍未完成,Build 能力准入保持关闭。 Windows 基础设施复用窗口文档 watcher 观察原生配置来源及链接目标;缺失父目录只观察最近现存祖先,注册不创建用户目录。版本检查只返回路径和一致性,不传 XML;模型发布和执行前检查原生版本,外部变化撤销模型/草稿,自己的保存与回读不误取消。插件撤销等待来源读取排空。Run/Java/Agent 与旧数据迁移和实机验收仍未完成,产品能力准入保持关闭。" } diff --git a/shared/platform-feature-matrix/features/build-system-plugins.json b/shared/platform-feature-matrix/features/build-system-plugins.json index 2bc133c07a..7ee542b80b 100644 --- a/shared/platform-feature-matrix/features/build-system-plugins.json +++ b/shared/platform-feature-matrix/features/build-system-plugins.json @@ -2,7 +2,7 @@ "id": "build-system-plugins", "area": "扩展", "group": "插件与主题", - "capability": "构建系统插件(API 3 buildSystems):插件用 JSON 声明构建工具的识别、可执行文件、选项、任务、运行入口、测试和输出格式,或由插件自带、与语言服务器同类的 Provider 进程回答模型和依赖,Rust Core 统一校验、生成命令行或 Provider 请求并解码结果,宿主为每个构建系统打开一个按声明(icon、presentation、category)布局的工具窗口;通过共享 executableResolution 候选解析支持可选设置路径、优先级与相对排序,空值自动发现、显式失败不回退;buildProviders 2/3 原生任务与测试执行复用工作区后端会话,带进度、取消、结果与配置失效隔离;协议 3 可按声明发现测试并在现有构建树按精确选择器运行,源码/设置变化撤销旧清单;协议 4 可为应用目标准备启动计划,Core 校验后交给原生 Run 管理进程;Provider 应用可保存到本地 Run v2 配置并重新准备后重启;插件自有原生程序经 Provider v5 构建并准备 Debug,Build 树和保存的 Run 配置复用原生调试会话", + "capability": "构建系统插件(API 3 buildSystems):插件用 JSON 声明构建工具的识别、可执行文件、选项、任务、运行入口、测试和输出格式,或由插件自带、与语言服务器同类的 Provider 进程回答模型和依赖,Rust Core 统一校验、生成命令行或 Provider 请求并解码结果,宿主为每个构建系统打开一个按声明(icon、presentation、category)布局的工具窗口;通过共享 executableResolution 候选解析支持可选设置路径、优先级与相对排序,空值自动发现、显式失败不回退;buildProviders 2/3 原生任务与测试执行复用工作区后端会话,带进度、取消、结果与配置失效隔离;协议 3 可按声明发现测试并在现有构建树按精确选择器运行,源码/设置变化撤销旧清单;协议 4 可为应用目标准备启动计划,Core 校验后交给原生 Run 管理进程;Provider 应用可保存到本地 Run v2 配置并重新准备后重启;插件自有原生程序经 Provider v5 构建并准备 Debug,Build 树和保存的 Run 配置复用原生调试会话;协议 7 可选原生配置投影经通用设置页确认保存并回读(独立能力行 build-native-configuration);buildSystems 3 可声明按实例根独立保存选项;executableResolution 2 复用 Run 工具链注册表和实例本机选择;团队 Build 偏好与本机差异覆盖分开保存;buildProviders 8 支持后端随执行返回有界规范报告并保留工程身份;协议 9 支持声明命名空间的语义测试标识,旧后端不能忽略筛选条件", "macos": { "evidence": [ "rust/lithe-core/src/build", @@ -54,7 +54,24 @@ "macos/Tests/LitheTests/MacNativeBuildProviderIntegrationTests.swift", "macos/Tests/LitheTests/MacNativeProviderDebugIntegrationTests.swift", "macos/Tests/LitheTests/ProviderWorkflowPresentationTests.swift", - "docs/assets/provider-workflow-review/README.md" + "docs/assets/provider-workflow-review/README.md", + "macos/Sources/Lithe/Views/App/ContributedBuildConfigurationSection.swift", + "rust/lithe-core/src/build/configuration.rs", + "shared/contracts/plugins/v1/fixtures/build-instance-options.plugin.json", + "shared/contracts/plugins/v1/fixtures/build-toolchains.plugin.json", + "macos/Sources/Lithe/Core/Ports/RunToolchainProviders.swift", + "shared/contracts/build-project-options-v1.schema.json", + "macos/Sources/Lithe/Platform/MacOS/Build/MacBuildOptionsStore.swift", + "shared/contracts/plugins/v1/fixtures/build-provider-reports.plugin.json", + "rust/lithe-core/src/build/outputs.rs", + "shared/contracts/plugins/v1/fixtures/build-provider-selectors.plugin.json", + "shared/contracts/plugins/v1/fixtures/build-test-durations.json", + "macos/Sources/LitheExecutionModule/Services/BuildLanguageTests.swift", + "shared/contracts/plugins/v1/fixtures/build-provider-run-options.plugin.json", + "shared/contracts/plugins/v1/fixtures/build-run-options.json", + "rust/lithe-core/src/build/project_options.rs", + "shared/fixtures/build-project-options/validation.json", + "macos/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift" ], "implementationStatus": "partial", "verificationStatus": "pending" @@ -69,5 +86,6 @@ "verificationStatus": "not-applicable" }, "owner": "Extensions", - "verification": "macOS 已接入 static、command、hostIntegration、provider 四种模型来源:宿主向 Core 声明 buildSystems 与 buildProviders 能力,插件市场因此列出兼容的 API 3 声明式和 Provider 版本;Provider 进程按插件、构建系统和工作区各自独立,经各自 stdio 用 JSON-RPC(LSP 基础协议)与宿主通信,initialize 协商 capabilities.experimental.litheBuild 后宿主才经 lsp.buildRequest 发送 lithe/build/model、lithe/build/dependencies,回复交给 Core 解码;共享写法只复用同一插件的语言服务器会话;进程只在用户加载模型或依赖时启动,切换工作区、关闭项目窗口、停止执行模块、停用或更新插件、退出应用时停止;ExecutionFeatureGraph.stop 经 BuildSystemService.stop 同时回收已就绪和初始化中的 Provider,停止后的初始化完成也不得重新登记会话;状态写入插件状态目录;每个已启用的构建系统插件在打开工作区后各有一个右侧工具窗口(标题为 displayName、图标为 icon),不依赖识别结果,工作区没有对应工程时窗口内显示未检测到该构建系统,工具栏放运行、参数任务、刷新和 presentation=toolbar 的开关,树顶部是 presentation=tree 的勾选选项,工程下按 category(缺省按 group)分级列出任务,再列测试、目标和依赖;内置 Maven 窗口与共享 Build 窗口已删除,Build Output 显示输出、诊断与测试结果。窗口恢复先激活所属插件模块,激活中的命令面板项禁用;参数任务遵守 workspace/project/target 作用范围与目标标签,模型或工作区变化后清除旧选择。隔离回归覆盖这些边界、窗口布局恢复及包内图标读取前后文件清单与字节不变;多实例的根目录选择器保留每个实例的模型候选和生效值,编辑多选时保留兄弟实例独有的已选值;显式清空按实例根持久化为空覆盖,检测、同步指纹、任务计划和语言服务器导入使用同一覆盖,兄弟编辑或重开不会恢复非空默认值,非空编辑清当前实例标记而共享编辑/重置清该选项全部标记;真实 Core、生产存储与服务回归核对最终 argv 和导入值;列表单选与多选支持 allowCustom 原生输入和移除已选自定义值,文本/文件/目录清空提交显式空字符串,只有重置删除覆盖。插件选项在项目打开、选项修改和模型加载完成后通过 Core build.languageImport 归一化为 mavenContext/javaGradle,与构建计划使用同一生效值,并在 Java 启动前等待同一工作区代次的最新配置;运行时准备后再等一次,覆盖等待期间失效。唯一发布回调优先 Maven 原地热更新,被拒绝时串行恢复重启;停用最后一个 Maven 插件时,仅清除曾带 Maven 输入的同一会话,纯 Gradle 首次启动保持 nil。Gradle 在下次启动生效,取消/重置拒绝旧检测与恢复任务,被动工具路径发现不执行 Wrapper/版本探测;没有对应插件导入输入时兼容原 Maven 设置。命令模型 run 配置尚未接入;buildProviders 2 已接入原生任务 lithe/build/execute,复用工作区进程并校验协议版本、进度/结果、取消/超时和旧结果隔离,真实安装包/工具工作流仍待验收,Provider 无操作系统级沙盒;Windows 宿主尚未调用 build.* 命令。自动化:cargo test -p lithe-core --lib build_systems、cargo test -p lithe-core --lib lsp、scripts/architecture/verify-shared-contracts.sh、./scripts/macos/test/test-macos.sh --filter 'BuildSystemToolWindowTests|RustBuildSystemOperationsTests|BuildSystemServiceTests|PluginMarketplaceServiceTests'。手动验证:安装 fixtures 中 build-static-file、build-command-stdout 形态的插件包,确认每个构建系统各出现一个工具窗口,列出工程、分级任务、选项和依赖,停用插件后窗口消失;执行任务与测试的 argv 与 build-plans.json 一致,停止与输出上限生效;JUnit 报告与 lineMatcher 诊断可跳转到源码;命令模型和项目内 Wrapper 只在用户点击加载或运行后启动;禁用插件后实例消失;Windows 接入后另需验证未信任工作区不启动任何模型命令或 Wrapper;build-provider 形态的自有 Provider 只在用户操作或工作区信任后启动、停用插件时停止,build-provider-language-server 形态复用语言服务器会话而不启动第二个进程;运行前后 app bundle 文件清单不变(scripts/macos/verify/verify-runtime-bundle-immutability.sh)。;新路径选择使用 executableResolution 1 + settings 2,已发布 buildSystems 2 旧字段只作统一候选输入适配:MacBuildToolResolverTests 验证带空格绝对路径、权限与版本失败不回退,BuildSystemServiceTests 验证 Provider 失败不独立发现,RustBuildSystemOperationsTests 验证全局值接入。Cargo 独立仓库以显式本地 Core bridge 验证自选路径用于 metadata 和全部构建/测试/运行/清理;发布前需把独立仓库 sdk.lock.json 的宿主 pin 更新到包含该能力的已发布提交。;回归增加非法存储值不回默认、数组/priority/插入顺序与拒绝循环、宿主注入发现、空设置跳过和被动选择不探测;Language Server/Execution Provider 新清单语法尚未开放,Windows 不声明 executableResolution。;真实 Core + macOS resolver + BuildSystemService 组合回归覆盖前置 Runtime 缺失/版本不足时保留工具或后置 Runtime 的非空 setting 约束,Provider 不发送独立发现请求;全部设置为空时仍可回退。自定义 Runtime 先构造含所选目录的 PATH,再以同一环境探测与执行,同目录 helper 的带空格路径回归通过。;buildProviders 3 增加原生测试执行 lithe/build/test,筛选参数保持类型化,报告复用 JUnit/标准结果解析;旧协议明确拒绝测试,报告解析期间配置失效会取消操作并丢弃迟到结果。原生树已接入按需测试发现,Core 校验完整清单并保留模块身份,运行前重查当前清单;配置/源码/包变化、停用/重开使缓存或进行中的发现失效。真实 Maven 模块编译与单方法测试已有生产 macOS Provider 会话证据;Windows 工作流仍待验收。;buildProviders 4 应用目标准备接入原生 Build 树,构建进度在 Build Output 展示,验证后转交 RunService 解析工具与启动/停止应用;准备期间源码、选项、工作区或停用变化拒绝迟到启动,旧操作 ID 不得停止替换进程;Core 拒绝绝对可执行路径、越界目录/符号链接及超限参数环境。服务编排、调试与 Java/Maven 消费者迁移尚未完成;真实插件/发行包和 Windows 工作流仍待验收。;Run v2 本地层显式保存 Provider 选择器,参数保持原始数组;无 generated.json 也能读回/显示,重复保存保留 ID/其他配置/项目环境;重启重新进入当前 Provider,停止与晚到结果按身份隔离。原生入口保存工作区文档后再准备,Run 窗口在未生成旧配置时仍显示 Provider 输出。;Agent 入口回归从真实本地配置启动,验证准备到进程退出保持公开操作 ID、准备中停止与重启重新准备,旧 ID 不能停止替换操作;Provider 与进程为可控替身,真实插件端到端验收仍待完成。;配套独立仓库 Node/Java SDK 源码补齐协议 2/3/4 的处理器、版本准入与进度;无页面示例经真实 Core 验证非零构建退出码、精确筛选、运行前构建及准备计划、并行工作区、取消工具清理和完整包不变,另保留锁定旧契约的兼容场景与 TypeScript 调用方编译检查。SDK 尚未发布,真实 Maven/JDT 与 Windows 验收仍待完成。 原生 Debug:运行声明引用同包适配器,点击 Build 树 Debug 或保存配置的 Debug 后先保存并构建,验证程序路径/参数/环境交给注册表;停止、源码或配置变化、模型重载、插件停用与工作区切换均丢弃过期结果,解码后的文件别名变化和不可执行文件在平台交接前拒绝。Node/Java SDK 进程的准备协议与 Core 解码有端到端覆盖;真实适配器、Java/JDT、跨插件协作和 Windows 尚未验收。 Maven 独立插件的本地增量已经删除生命周期/插件目标/自定义目标/测试的命令模板,改由 Provider 3 执行;真实 Core C ABI + Java 后端 + Maven 三模块工程验证模块编译、单方法 Surefire 报告和模型共享,受控进程回归覆盖取消、非零退出和输出超限。插件与新版 SDK 已分别提交 Draft PR #7/#4,尚未发布,正式 SDK 锁定仍待更新;生产 macOS Provider 会话已经执行模型、编译和单方法测试,包 SHA-256/权限保持不变;显式 SDK CI 在 Java 8 上通过当前与 3.6.3 Maven 的 55 项开发检查(各自包括 3 项打包);旧 MavenService、Agent/设置/Run/Java 迁移、正式包、完整 macOS UI 与 Windows 验收未完成,保持 partial/pending。 真实原生调试集成:公开 Node SDK Provider 用 Clang 编译 C 程序,经生产 macOS 会话、Core 准备、Registry 明确身份、生产 stdio 传输和 LLDB 命中源码断点、读取 main 栈帧、保留空格参数后继续并退出码 0;包 SHA-256/权限不变,所有本次进程回收。测试显式选择 SDK/工具,完整 UI、正式发行与 Windows 尚未验收,verification 保持 pending。 Build 树应用从文档保存阶段起持有同一可取消运行 ID;保存 gate 下启动新任务/测试或停止、切换工作区、选项/源码/模型/插件失效后,旧保存返回不得启动 Provider、替换新运行或清空新输出;保存失败不启动,成功后才准备。 原生视图隔离回归用真实 Core 与受控 Provider 数据检查深浅主题的测试发现、Debug 入口和 Run 输出;截图仅验证该视图状态,不代替完整发行包或 Windows 实机验收。" + "verification": "macOS 已接入 static、command、hostIntegration、provider 四种模型来源:宿主向 Core 声明 buildSystems 与 buildProviders 能力,插件市场因此列出兼容的 API 3 声明式和 Provider 版本;Provider 进程按插件、构建系统和工作区各自独立,经各自 stdio 用 JSON-RPC(LSP 基础协议)与宿主通信,initialize 协商 capabilities.experimental.litheBuild 后宿主才经 lsp.buildRequest 发送 lithe/build/model、lithe/build/dependencies,回复交给 Core 解码;共享写法只复用同一插件的语言服务器会话;进程只在用户加载模型或依赖时启动,切换工作区、关闭项目窗口、停止执行模块、停用或更新插件、退出应用时停止;ExecutionFeatureGraph.stop 经 BuildSystemService.stop 同时回收已就绪和初始化中的 Provider,停止后的初始化完成也不得重新登记会话;状态写入插件状态目录;每个已启用的构建系统插件在打开工作区后各有一个右侧工具窗口(标题为 displayName、图标为 icon),不依赖识别结果,工作区没有对应工程时窗口内显示未检测到该构建系统,工具栏放运行、参数任务、刷新和 presentation=toolbar 的开关,树顶部是 presentation=tree 的勾选选项,工程下按 category(缺省按 group)分级列出任务,再列测试、目标和依赖;内置 Maven 窗口与共享 Build 窗口已删除,Build Output 显示输出、诊断与测试结果。窗口恢复先激活所属插件模块,激活中的命令面板项禁用;参数任务遵守 workspace/project/target 作用范围与目标标签,模型或工作区变化后清除旧选择。隔离回归覆盖这些边界、窗口布局恢复及包内图标读取前后文件清单与字节不变;多实例的根目录选择器保留每个实例的模型候选和生效值,编辑多选时保留兄弟实例独有的已选值;显式清空按实例根持久化为空覆盖,检测、同步指纹、任务计划和语言服务器导入使用同一覆盖,兄弟编辑或重开不会恢复非空默认值,非空编辑清当前实例标记而共享编辑/重置清该选项全部标记;真实 Core、生产存储与服务回归核对最终 argv 和导入值;列表单选与多选支持 allowCustom 原生输入和移除已选自定义值,文本/文件/目录清空提交显式空字符串,只有重置删除覆盖。插件选项在项目打开、选项修改和模型加载完成后通过 Core build.languageImport 归一化为 mavenContext/javaGradle,与构建计划使用同一生效值,并在 Java 启动前等待同一工作区代次的最新配置;运行时准备后再等一次,覆盖等待期间失效。唯一发布回调优先 Maven 原地热更新,被拒绝时串行恢复重启;停用最后一个 Maven 插件时,仅清除曾带 Maven 输入的同一会话,纯 Gradle 首次启动保持 nil。Gradle 在下次启动生效,取消/重置拒绝旧检测与恢复任务,被动工具路径发现不执行 Wrapper/版本探测;没有对应插件导入输入时兼容原 Maven 设置。命令模型 run 配置尚未接入;buildProviders 2 已接入原生任务 lithe/build/execute,复用工作区进程并校验协议版本、进度/结果、取消/超时和旧结果隔离,真实安装包/工具工作流仍待验收,Provider 无操作系统级沙盒;Windows 宿主尚未调用 build.* 命令。自动化:cargo test -p lithe-core --lib build_systems、cargo test -p lithe-core --lib lsp、scripts/architecture/verify-shared-contracts.sh、./scripts/macos/test/test-macos.sh --filter 'BuildSystemToolWindowTests|RustBuildSystemOperationsTests|BuildSystemServiceTests|PluginMarketplaceServiceTests'。手动验证:安装 fixtures 中 build-static-file、build-command-stdout 形态的插件包,确认每个构建系统各出现一个工具窗口,列出工程、分级任务、选项和依赖,停用插件后窗口消失;执行任务与测试的 argv 与 build-plans.json 一致,停止与输出上限生效;JUnit 报告与 lineMatcher 诊断可跳转到源码;命令模型和项目内 Wrapper 只在用户点击加载或运行后启动;禁用插件后实例消失;Windows 接入后另需验证未信任工作区不启动任何模型命令或 Wrapper;build-provider 形态的自有 Provider 只在用户操作或工作区信任后启动、停用插件时停止,build-provider-language-server 形态复用语言服务器会话而不启动第二个进程;运行前后 app bundle 文件清单不变(scripts/macos/verify/verify-runtime-bundle-immutability.sh)。;新路径选择使用 executableResolution 1 + settings 2,已发布 buildSystems 2 旧字段只作统一候选输入适配:MacBuildToolResolverTests 验证带空格绝对路径、权限与版本失败不回退,BuildSystemServiceTests 验证 Provider 失败不独立发现,RustBuildSystemOperationsTests 验证全局值接入。Cargo 独立仓库以显式本地 Core bridge 验证自选路径用于 metadata 和全部构建/测试/运行/清理;发布前需把独立仓库 sdk.lock.json 的宿主 pin 更新到包含该能力的已发布提交。;回归增加非法存储值不回默认、数组/priority/插入顺序与拒绝循环、宿主注入发现、空设置跳过和被动选择不探测;Language Server/Execution Provider 新清单语法尚未开放,Windows 不声明 executableResolution。;真实 Core + macOS resolver + BuildSystemService 组合回归覆盖前置 Runtime 缺失/版本不足时保留工具或后置 Runtime 的非空 setting 约束,Provider 不发送独立发现请求;全部设置为空时仍可回退。自定义 Runtime 先构造含所选目录的 PATH,再以同一环境探测与执行,同目录 helper 的带空格路径回归通过。;buildProviders 3 增加原生测试执行 lithe/build/test,筛选参数保持类型化,报告复用 JUnit/标准结果解析;旧协议明确拒绝测试,报告解析期间配置失效会取消操作并丢弃迟到结果。原生树已接入按需测试发现,Core 校验完整清单并保留模块身份,运行前重查当前清单;配置/源码/包变化、停用/重开使缓存或进行中的发现失效。真实 Maven 模块编译与单方法测试已有生产 macOS Provider 会话证据;Windows 工作流仍待验收。;buildProviders 4 应用目标准备接入原生 Build 树,构建进度在 Build Output 展示,验证后转交 RunService 解析工具与启动/停止应用;准备期间源码、选项、工作区或停用变化拒绝迟到启动,旧操作 ID 不得停止替换进程;Core 拒绝绝对可执行路径、越界目录/符号链接及超限参数环境。服务编排、调试与 Java/Maven 消费者迁移尚未完成;真实插件/发行包和 Windows 工作流仍待验收。;Run v2 本地层显式保存 Provider 选择器,参数保持原始数组;无 generated.json 也能读回/显示,重复保存保留 ID/其他配置/项目环境;重启重新进入当前 Provider,停止与晚到结果按身份隔离。原生入口保存工作区文档后再准备,Run 窗口在未生成旧配置时仍显示 Provider 输出。;Agent 入口回归从真实本地配置启动,验证准备到进程退出保持公开操作 ID、准备中停止与重启重新准备,旧 ID 不能停止替换操作;Provider 与进程为可控替身,真实插件端到端验收仍待完成。;配套独立仓库 Node/Java SDK 源码补齐协议 2/3/4 的处理器、版本准入与进度;无页面示例经真实 Core 验证非零构建退出码、精确筛选、运行前构建及准备计划、并行工作区、取消工具清理和完整包不变,另保留锁定旧契约的兼容场景与 TypeScript 调用方编译检查。SDK 尚未发布,真实 Maven/JDT 与 Windows 验收仍待完成。 原生 Debug:运行声明引用同包适配器,点击 Build 树 Debug 或保存配置的 Debug 后先保存并构建,验证程序路径/参数/环境交给注册表;停止、源码或配置变化、模型重载、插件停用与工作区切换均丢弃过期结果,解码后的文件别名变化和不可执行文件在平台交接前拒绝。Node/Java SDK 进程的准备协议与 Core 解码有端到端覆盖;真实适配器、Java/JDT、跨插件协作和 Windows 尚未验收。 Maven 独立插件的本地增量已经删除生命周期/插件目标/自定义目标/测试的命令模板,改由 Provider 3 执行;真实 Core C ABI + Java 后端 + Maven 三模块工程验证模块编译、单方法 Surefire 报告和模型共享,受控进程回归覆盖取消、非零退出和输出超限。插件与新版 SDK 已分别提交 Draft PR #7/#4,尚未发布,正式 SDK 锁定仍待更新;生产 macOS Provider 会话已经执行模型、编译和单方法测试,包 SHA-256/权限保持不变;显式 SDK CI 在 Java 8 上通过当前与 3.6.3 Maven 的 55 项开发检查(各自包括 3 项打包);旧 MavenService、Agent/设置/Run/Java 迁移、正式包、完整 macOS UI 与 Windows 验收未完成,保持 partial/pending。 真实原生调试集成:公开 Node SDK Provider 用 Clang 编译 C 程序,经生产 macOS 会话、Core 准备、Registry 明确身份、生产 stdio 传输和 LLDB 命中源码断点、读取 main 栈帧、保留空格参数后继续并退出码 0;包 SHA-256/权限不变,所有本次进程回收。测试显式选择 SDK/工具,完整 UI、正式发行与 Windows 尚未验收,verification 保持 pending。 Build 树应用从文档保存阶段起持有同一可取消运行 ID;保存 gate 下启动新任务/测试或停止、切换工作区、选项/源码/模型/插件失效后,旧保存返回不得启动 Provider、替换新运行或清空新输出;保存失败不启动,成功后才准备。 原生视图隔离回归用真实 Core 与受控 Provider 数据检查深浅主题的测试发现、Debug 入口和 Run 输出;截图仅验证该视图状态,不代替完整发行包或 Windows 实机验收。 实例作用域:用同一工作区的两个独立根验证 Profiles、文件/目录、布尔与文本值独立持久化;编辑、清空或重置 A 不影响 B,重开后任务、静态运行入口、同步和语言导入读取相同根的配置;未声明作用域的插件保留共享行为。旧 Maven 数据迁移与 Windows 运行验收另行完成,保持 partial/pending。 作用域变更的旧 Build 偏好先复制并验证目标,再删除共享源;写入失败阻止默认配置发布并允许重试,已有实例值优先,暂未检测到根的空标记也迁移保留。 工具链候选通过真实 Core 与生产本地文档验证按实例选择 Maven/JDK,Provider 环境保留选中的 JAVA_HOME/PATH 且不转发无关测试凭据;被动导入不探测版本或运行 Wrapper,显式路径失败不能独立发现。Windows 不声明 executableResolution 2,旧 Maven 服务迁移仍待完成。 共享 Build 文档验证工作区/实例默认值、逐根覆盖和本机差异合并;重开和检测不改共享字节,重置本机值恢复继承,外部修改冲突或文档损坏明确失败并保留原始数据。 未选中的自定义候选仅在 allowCustom 选项中展示,不改变选择和模型;真实 Core、生产存储和服务回归通过,新增实例静态入口继承团队默认值。 协议 8 回归覆盖协商拒绝、随执行返回的报告、缺失/损坏失败、模块同名测试隔离、取消/配置变更后拒绝晚到解码,以及普通任务中的测试结果。Maven 开发包用官方已配置测试 Mojo 的报告目录和 Apache Surefire parser,真实 Core/Maven 覆盖带空格自定义目录与非零退出的失败用例,坏 XML/外部实体拒绝,包字节保持不变。Java/Node SDK 同步,正式发布和全部旧 Maven/Java 消费者迁移尚未完成。 本机选项、逐根映射和空多选标记中存在非 Data 存储值时,检测失败并保留原始偏好,不得当缺失键发布默认值。 本机实例默认值继承、逐根覆盖与重置通过真实 Core 和生产存储验证,团队文档字节不变;本机默认值不进入资源复用。 固定宿主 ac24a96af 与 SDK 71667af 的 Java 8 CI(https://github.com/Lithe-IDEA/lithe-plugin-maven/actions/runs/40792694290)在当前 Maven/3.6.3 各 72 项通过,覆盖新原生配置与 Surefire/Failsafe 报告、取消清理和包字节不变;正式发行组合仍待验证。 协议 10 接通带版本协商的类型化运行选项与服务/任务准备(Core、Swift、Node/Java SDK)。原生 Run/Start All 为每个 Maven 服务独立准备,配置 ABA/Stop/重置拒绝旧回复,已停进程输出不能覆盖新会话;Build 树按服务/任务类型发请求;停用和包更新在删除包前停止准备及进程,旧启动注册更新后不可再次使用。后端复用 Maven reactor 和 Mojo 执行器,真实 Core→Maven 验证未安装兄弟模块、精确 argv、cwd、目标撤销和包哈希不变。macOS 无调用者的旧 Maven 桥和工程模型已删除;Windows 当前旧命令消费者、Core 旧实现和正式发行/完整 UI 验收仍待完成。 保存 Provider service/task 引用保留执行类型、稳定配置 ID 和参数,重开后按当前声明重验;独立准备至进程交接保留 Agent 操作身份。 服务所有者使用完整插件包身份;停用其中一个插件不会停止同名构建系统下另一插件的进程。preview 内存检测层保留 Provider 模型摘要,自动识别不写工作区。 后续本机真实 Micronaut HTTP 应用(Platform 4.7.6 / Core 与注解处理器 4.7.14)在 JVM 暂停后经 JDI 恢复,通过 Netty 控制器和编译期注入服务读取未安装的 reactor 兄弟依赖,参数、环境及包字节不变;这是旧固定 CI 之后的本地四步框架增量,不代替 Debug UI、正式包或 Windows 原生验收。 Windows Build 选项编辑与 Agent 共用原生本机存储:开页只读,修改草稿后显式保存/恢复继承;隐藏重开保留草稿。团队/本机两层版本同时检查,冲突、关闭、插件撤销拒绝迟到写入。实例选择独立,工作区多选保留兄弟实例值和显式空选择;无关系统/根/记忆选项与团队文件字节保持。保存后重新检测并使旧模型失效。长选项/模型选择使用有界视口,不启动工具或 Provider。产品能力准入仍关闭,不能据此认定 Windows 完整 Maven 产品迁移完成。", + "notes": "协议 9 测试链路已删除 macOS Maven CLI/报告 adapter,JDT 标识原样传给后端;规范报告按项目隔离并声明覆盖范围,整类重跑清除旧方法、单方法保留兄弟项,参数化调用的完整安全耗时相加。350 项原生回归和真实 Maven Provider 通过;最低运行时新增 CI、完整 UI/JDT、正式发行和 Windows 保持 pending。 Windows 已补独立 Provider 的原生启动解析、Core 会话复用、进度/取消和包/设置/运行时替换基础,受控前端回归覆盖晚到启动、回复先于 IPC 确认与停止失败。产品模型/任务/配置消费者与共享语言会话型 Provider 尚未接入,hostCapabilities 继续不声明 Build 能力,Windows 保持 missing,不能删除旧 Maven 命令。 Windows 工作区域已补经 Core 计划和解码的模型、逐工程依赖、任务和执行内测试报告,受控测试保护工具/设置/包失效、取消、迟到解码及有界历史;独立 Maven 仓库用生产 TypeScript、真实 Core/Provider/reactor 的 7 步集成通过,包字节不变。Core 只读项目工具选择及全量 1434 项回归通过。原生工具适配和 UI/配置/Run 迁移、共享语言会话及 Windows 实机验收仍待完成,准入保持关闭,Windows 继续 missing。 Windows 服务层原生配置读取不依赖可用模型,自动读取项目工具先停止并要求显式产品动作;受控测试覆盖严格解码和失效,真实集成在损坏 POM 下验证来源及包字节不变、秘密不导出、仓库/构建目录不创建。设置页与 Windows 原生写回/配置迁移仍待接入,准入继续关闭。 Windows 服务层新增原生配置准备/提交、读取回执及写后回读;私有 XML 留在有界原生内存,实际窗口/操作令牌在取消、关闭和插件变更时释放,文件替换复用既有文档模块。新增文件回归验证注释/秘密/权限、磁盘冲突、并发创建、链接换向及安装目录只读;草稿/确认 UI、旧数据迁移及 Windows 原生验收仍未完成,产品准入保持关闭。 团队 Build 文档语法已集中到 Core,macOS/Windows 使用相同共享 fixture;检测返回同一作用域/实例根合并的保存值和被拒绝键,显式清空不会恢复默认值。Windows 新增工作区所有者、通用右侧 Build 与底部输出接线,受控点击验证显式加载、模型任务、Stop、重挂载及 10,000 项虚拟列表;插件停用确认等待全部 Build 清理,目录刷新不丢历史进程目标。配置 UI/旧数据迁移、文件观察、声明任务/依赖/报告呈现、Run/Java/Agent 和 Windows 实机仍待完成,准入关闭,Windows 继续 missing。 Windows 插件设置详情已接入通用原生来源编辑器,草稿由工作区持有,明确保存后回读;失败重载/读回撤销基线,跨来源草稿与迟到文件选择得到隔离。对应控件/文件回归不代替 Windows 原生验收,旧数据迁移和产品准入仍待完成。 Windows 通用 Build 入口增加按工作区/模块的声明任务、模型任务分类与显式参数确认,参数逐项保留边界;依赖仅展示 Core/Provider 节点,最多四份内存快照,输入失效/取消拒绝晚到解码。模型事实与进度分别通知,原生配置保存撤销模型后任务树立即清空;目标/Run/Agent/Java、旧数据与正式 Windows 准入仍待完成,不据前端回归标为实机 verified。 Windows 后续基础设施可展示 Core 规范诊断/测试报告及精确模块用例,结果/清单各只保留四份快照,大报告虚拟化且输出不复制报告。Core watch 与执行前指纹核对撤销过期模型,源码编辑撤销发现清单;团队文件变化合并只读检测。外部原生来源文件观察已接通,完整 Run/Java/Agent 与旧数据迁移仍未完成,Build 能力准入保持关闭。 Windows 已保存 Provider 引用通过公共 Core 计划接入现有 Run,各服务直接加载自己的模型并分别取消;交接前重查完整保存选项、工具、工程指纹和原生来源。准备与进程由现有插件/工作区所有者管理,迟到启动确认必须排空。目标树已接显式保存并运行;完整 Run/Debug、Java/Agent、旧数据和实机验收仍待完成,准入继续关闭。 Windows 运行准备现持有独立临时模型/来源租约,Core 校验单次 Profiles/空选择/false;先用 canonical reactor 工具选择,再由 Core 应用该 Run 明确保存的路径;空路径继承 reactor,模型不依赖默认工具可用。关闭临时模型不停止共享后端;启动前只读复验目录,未变时保留身份,漏通知也能发现团队选项变化。原生能力表决定旧框架/模块入口何时切换,当前仍关闭。两端的已保存应用保留协议 4 原始参数,真实新版选项继续按协议 10/11 拒绝旧后端。 Windows 目标树按声明和当前工程列出运行选择器,参数确认后以 Core 稳定 ID 保存并接入现有 Run;原生复用文档 CAS/元数据/原子替换及读回,拒绝包路径和工作区外别名。保存阶段可 Stop/关闭撤销,忽略取消的迟到保存不能启动;首次打开 Run 面板等待目标交接,不取消该启动。大目标列表虚拟化,完整 Debug/JDT 与 Windows 实机仍待验收,准入保持关闭。 Windows Agent 的 Build 查询、重载与执行现复用同一工作区 controller 和 Provider,精确目标转交当前模型/Core,输出/停止使用原有操作 ID;不新增 Maven 特判或后端状态。通用配置写入和完整产品准入未完成,不据此删除旧消费者。" } diff --git a/shared/platform-feature-matrix/features/ide-api-maven.json b/shared/platform-feature-matrix/features/ide-api-maven.json index a81d85b2bb..6d98dc09b1 100644 --- a/shared/platform-feature-matrix/features/ide-api-maven.json +++ b/shared/platform-feature-matrix/features/ide-api-maven.json @@ -2,7 +2,7 @@ "id": "ide-api-maven", "area": "AI", "group": "IDE MCP", - "capability": "插件与 MCP 读取 Maven 模块和 Profile,保存配置、重载并执行既有生命周期和插件目标", + "capability": "插件与 MCP 按已接通工作流发现工具;macOS 使用通用 Build 查询、配置和执行;Windows 接通 Build 查询、重载、执行与原生有界输出/停止,保留仍有消费者的 Maven 入口,通用配置写入与产品准入未完成", "macos": { "implementationStatus": "implemented", "verificationStatus": "pending", @@ -10,7 +10,13 @@ "macos/Sources/Lithe/Application/Features/IdeCapabilitiesFeatureModel.swift", "macos/Sources/Lithe/Views/App/McpSettingsView.swift", "macos/Tests/LitheTests/IdeCapabilitiesTests.swift", - "rust/lithe-ide-host/src/lib.rs" + "rust/lithe-ide-host/src/lib.rs", + "macos/Sources/LitheExecutionModule/Services/BuildSystemService.swift", + "macos/Tests/LitheExecutionModuleTests/BuildSystemServiceTests.swift", + "shared/contracts/ide-api/v1.md", + "shared/fixtures/ide-api/host-tool-exposure.json", + "rust/lithe-ide-host/src/catalog.rs", + "macos/Sources/Lithe/Application/Features/RuntimeSettingsFeatureModel.swift" ] }, "windows": { @@ -24,5 +30,5 @@ ] }, "owner": "Agent", - "verification": "双端对同一示例项目设置 Profile/skipTests,重载、执行 clean verify,核对按钮与 MCP 的命令、输出、失败和取消。 运行前后对比安装目录文件清单与哈希;当前 Linux 仅执行共享协议及前端验证,原生运行待验证。" + "verification": "macOS 用 lithe_build_inspect/reload 查询 Maven 插件实例及模型,执行 goal 任务并以独立参数数组传 clean verify;核对 UI/Agent 使用同一生效配置、最终退出码、完成后输出、取消、旧 ID 失效和同路径重开。确认旧 MavenService 任务执行和专属进程已删除。Windows 尚未接 Provider,新入口明确 NOT_AVAILABLE,现有 Maven 执行继续验证。配置、项目加载、依赖、Run/Java 协作完整迁移及真实安装包/Windows 流程仍待阶段 D 验收。 通用 Build 选项入口已通过真实 Maven Agent compile 场景、非法布尔值不落盘、Core 类型/大小/权限边界及实例多选清空/重置回归;Windows 的新入口明确不可用单元测试通过,实机仍 pending。 原生工具选择 fixture 覆盖 macOS/Windows 各 13 项,Rust 拒绝未知工具、扩大参数、移除必填项和权限越权;真实 stdio 的三种协议会话验证发现清单与 macOS 环境参数收窄,认证 HTTP 在入队前拒绝旧 Maven 工具。macOS 旧入口在模块激活前拒绝;Windows 原生验证仍 pending。 macOS 环境 API 无需执行模块或 Maven 工程即可读取和保存 canonical 工具选择,保留旧表单只读投影;未保存草稿返回 BUSY,损坏来源拒绝并保留字节。 Windows Agent 后续接通三项通用 Build 工具,使用现有工作区 controller、模型、原生工具准备和 Provider 所有者;只读 inspect 不探测工具或启动后端。共享权限/曝光 fixture、授权撤销期间的迟到检测、异步 inspect 模型撤销、精确项目/目标/模型任务和模板拒绝、argv、忙碌、输出、旧 ID/取消回归已有本机证据。模型任务不接 target,目标从当前模型二次核对;Core 最终校验。配置写入未暴露,产品 Build 准入关闭;必须在对应提交的 Windows CI 与正式产品流程验证后再记 verified。" } diff --git a/shared/platform-feature-matrix/features/ide-api-run-output.json b/shared/platform-feature-matrix/features/ide-api-run-output.json index 67bed92364..266bd9c9d3 100644 --- a/shared/platform-feature-matrix/features/ide-api-run-output.json +++ b/shared/platform-feature-matrix/features/ide-api-run-output.json @@ -2,7 +2,7 @@ "id": "ide-api-run-output", "area": "AI", "group": "IDE MCP", - "capability": "插件与 MCP 列举运行配置、启动重启,按执行 ID 读取增量输出和停止", + "capability": "插件与 MCP 列举运行配置、启动重启,按执行 ID 查询和停止 Run 及 macOS 插件 Build 输出", "macos": { "implementationStatus": "implemented", "verificationStatus": "pending", @@ -24,5 +24,5 @@ ] }, "owner": "Agent", - "verification": "运行服务、读增量输出、清空日志验证 reset、重启后拒绝旧 ID,停止正确进程,检查退出码及资源清理。 运行前后对比安装目录文件清单与哈希;当前 Linux 仅执行共享协议及前端验证,原生运行待验证。" + "verification": "运行服务、读增量输出、清空日志验证 reset、重启后拒绝旧 ID,停止正确进程,检查退出码及资源清理。 运行前后对比安装目录文件清单与哈希;当前 Linux 仅执行共享协议及前端验证,原生运行待验证。 macOS Build 快照保留完成后的身份、退出码和任务输出;模型/依赖控制台日志不混入该任务,新运行或工作区重开后旧 ID 返回 OPERATION_EXPIRED,旧停止不得取消替换操作。" } diff --git a/shared/platform-feature-matrix/features/ide-mcp-connection.json b/shared/platform-feature-matrix/features/ide-mcp-connection.json index 140b078bee..5a57e38bc3 100644 --- a/shared/platform-feature-matrix/features/ide-mcp-connection.json +++ b/shared/platform-feature-matrix/features/ide-mcp-connection.json @@ -34,5 +34,5 @@ }, "owner": "Agent", "notes": "macOS 设置页把 MCP 授权整理为“服务器资源库 + Lithe IDE 访问”两段:资源库负责第三方服务器发现与编辑,见独立能力 mcp-server-library;Lithe IDE 访问段保留状态徽标、状态卡、项目权限和 Agent 配置,安全说明收进 ⓘ。开启授权时还会把随包的 lithe-mcp 写入当前项目的 Claude Code(projects..mcpServers)与 Codex(<项目>/.codex/config.toml)配置,并在关闭授权或项目时按账本清理;全局配置不受影响。 Windows 原生开启请求使用 ownerId 绑定当前窗口;关闭窗口仍使用 closeOwner 的 ownerID,避免严格字段校验拒绝开启请求。 Windows 改用窗口定向独立事件通知和执行前 claim,取消 WebView 轮询心跳依赖;过期、取消和重复通知不会执行操作,单次失败保留授权且不重放操作。设置页分别展示授权和最近请求结果,错误提示使用共享翻译。独立事件避免序号缺失阻塞后续请求,Windows 单调时钟阻止系统时间回拨延长请求有效期。共享 MCP helper 为新版协议补齐 ttlMs/cacheScope。", - "verification": "检查设置侧栏 MCP 配置入口与搜索跳转;未打开项目时资源库照常列出全局服务器,Lithe IDE 访问段显示打开项目提示;切换项目后展示对应授权。双端打包启动后开启连接,配置真实 Agent,验证工具发现、默认拒绝写入、启用权限、禁用、项目关闭和多窗口隔离。macOS 打开 MCP 配置页确认状态徽标与状态卡文案跟随启用状态变化,header ⓘ 可打开完整安全说明,配置代码块只在存在配置时出现且可选中复制;启用后确认项目级 lithe 条目写入两个 Agent 的配置,禁用后条目清理,外部修改过的条目保留并提示。运行前后对比安装目录文件清单与哈希;当前 Linux 仅执行共享协议及前端验证,原生运行待验证。 Windows 验证开启请求通过真实 Host 解码、客户端不能覆盖窗口所有者、关闭其他窗口保留连接及所属窗口关闭删除连接文件。 验证旧版、新版直接发现与初始化后新版发现三个独立 stdio 会话;Windows 验证原生通知、过期领取拒绝、失败后新请求恢复、最小化与休眠恢复、系统时钟回拨、丢失通知后交付新请求、打开失败和关闭后监听释放、中英文错误提示及无重复执行。" + "verification": "检查设置侧栏 MCP 配置入口与搜索跳转;未打开项目时资源库照常列出全局服务器,Lithe IDE 访问段显示打开项目提示;切换项目后展示对应授权。双端打包启动后开启连接,配置真实 Agent,验证工具发现、默认拒绝写入、启用权限、禁用、项目关闭和多窗口隔离。macOS 打开 MCP 配置页确认状态徽标与状态卡文案跟随启用状态变化,header ⓘ 可打开完整安全说明,配置代码块只在存在配置时出现且可选中复制;启用后确认项目级 lithe 条目写入两个 Agent 的配置,禁用后条目清理,外部修改过的条目保留并提示。运行前后对比安装目录文件清单与哈希;当前 Linux 仅执行共享协议及前端验证,原生运行待验证。 Windows 验证开启请求通过真实 Host 解码、客户端不能覆盖窗口所有者、关闭其他窗口保留连接及所属窗口关闭删除连接文件。 验证旧版、新版直接发现与初始化后新版发现三个独立 stdio 会话;Windows 验证原生通知、过期领取拒绝、失败后新请求恢复、最小化与休眠恢复、系统时钟回拨、丢失通知后交付新请求、打开失败和关闭后监听释放、中英文错误提示及无重复执行。 Windows 暴露 lithe_build_configure,经过既有 configure 权限,复用 UI 的同一原生本机偏好保存与读回;false、空数组和 null 重置不混淆,旧版本/并发保存和工作区关闭不覆盖新状态;活动构建返回 BUSY。验证共享工具清单、真实 stdio 发现与授权拒绝,旧 Maven 消费者尚未删除。" } diff --git a/shared/platform-feature-matrix/features/java-maven-profiles-dependencies.json b/shared/platform-feature-matrix/features/java-maven-profiles-dependencies.json index f4fb7402cf..fbc928a229 100644 --- a/shared/platform-feature-matrix/features/java-maven-profiles-dependencies.json +++ b/shared/platform-feature-matrix/features/java-maven-profiles-dependencies.json @@ -2,14 +2,14 @@ "id": "java-maven-profiles-dependencies", "area": "Java", "group": "Java/Maven", - "capability": "Profile 与依赖树", + "capability": "Profile 与 Provider 依赖树;Java 导航使用 JDT 解析的依赖快照", "macos": { "evidence": [ - "macos/Sources/Lithe/Views/Run", - "macos/Sources/Lithe/Services/Java", - "macos/Sources/LitheExecutionModule/Services/MavenService.swift", - "macos/Sources/Lithe/Platform/MacOS/Persistence/MacMavenDependencyOutputStore.swift", - "rust/lithe-core/src/project/maven_dependency_tree.rs" + "macos/Sources/Lithe/Views/Run/BuildSystemToolWindowView.swift", + "macos/Sources/LitheExecutionModule/Services/BuildSystemService.swift", + "macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift", + "macos/Tests/LitheTests/MacNativeBuildProviderIntegrationTests.swift", + "macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -24,5 +24,5 @@ "verificationStatus": "pending" }, "owner": "Java / Maven", - "verification": "切换 Maven profile 并刷新依赖树,确认模块归属、顺序、版本冲突/重复/依赖管理标注和错误边界;在依赖节点超过 6,000 个的项目中确认依赖树完整加载。 Windows 在 Maven 面板展开「依赖项」应直接加载依赖树,失败时显示宿主返回的真实原因,而不是通用的“无法加载 Maven 依赖”(#970)。" + "verification": "切换 Maven profile 并刷新依赖树,确认模块归属、顺序、版本冲突/重复/依赖管理标注和错误边界;在依赖节点超过 6,000 个的项目中确认依赖树完整加载。 Windows 在 Maven 面板展开「依赖项」应直接加载依赖树,失败时显示宿主返回的真实原因,而不是通用的“无法加载 Maven 依赖”(#970)。 macOS 已删除旧 MavenService 的依赖进程、临时树 adapter、Swift Maven 依赖命令桥及坐标拼接 JAR 路径 fallback。Build 树从工作区 Maven Provider 读取依赖;Dependencies 导航只消费语言 Provider 的真实类路径/虚拟文档和用户显式路径,不从旧 Maven 树回填。Windows 的现存 Core 命令和 adapter 仍保留,后续迁移完成前不得删共享实现。" } diff --git a/shared/platform-feature-matrix/features/java-maven-project-settings.json b/shared/platform-feature-matrix/features/java-maven-project-settings.json index ba358d6550..86ed3e8229 100644 --- a/shared/platform-feature-matrix/features/java-maven-project-settings.json +++ b/shared/platform-feature-matrix/features/java-maven-project-settings.json @@ -2,13 +2,22 @@ "id": "java-maven-project-settings", "area": "Java", "group": "Java/Maven", - "capability": "按项目配置 Maven 工具链、settings.xml 与本地仓库;项目默认值明确标注“本机 · 此项目”,运行配置区分本机保存与团队共享", + "capability": "项目与子项目工具链使用 canonical Run local.json;Maven 调用选项和原生 XML 归 Build Provider,旧配置只经校验、迁移和读回后清理", "macos": { "evidence": [ "macos/Sources/Lithe/Views/App/ProjectRuntimeSettingsView.swift", "macos/Sources/Lithe/Services/Java/ProjectRuntimeService.swift", "macos/Sources/Lithe/Views/Run/JavaRunConfigurationEditorView.swift", - "macos/Resources/zh-Hans.lproj/Localizable.strings" + "macos/Resources/zh-Hans.lproj/Localizable.strings", + "macos/Sources/Lithe/Platform/MacOS/Persistence/MacProjectRuntimeSettingsStore.swift", + "macos/Sources/Lithe/Core/Ports/ProjectRuntimeSettingsStoring.swift", + "macos/Tests/LitheTests/ProjectRuntimePersistenceTests.swift", + "shared/fixtures/run-configuration/subproject-toolchains.json", + "macos/Sources/Lithe/Models/Runtime/ProjectRuntimeInventory.swift", + "macos/Tests/LitheTests/ProjectRuntimeSettingsTests.swift", + "macos/Sources/Lithe/Platform/MacOS/Persistence/MacLegacyMavenConfigurationMigration.swift", + "macos/Tests/LitheTests/LegacyMavenConfigurationMigrationTests.swift", + "macos/Sources/LitheExecutionModule/Services/BuildSystemService.swift" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -29,5 +38,5 @@ "verificationStatus": "pending" }, "owner": "Java / Maven", - "verification": "在 macOS 项目设置和 Windows Maven 设置页分别切换两个项目,配置 Maven、JDK、settings.xml 与本地仓库,验证保存重开、自动检测展示及项目间隔离;Windows 还需验证写盘失败后可直接重试。 Windows 自定义 settings.xml 后,仓库留空应显示该文件中的目录;验证 XML 实体和 CDATA、继承的项目 JDK/Maven、切换项目丢弃未保存草稿;冷启动 Wrapper 不应触发下载,版本探测卡住时设置窗口保持响应并在超时后清理探测进程;Run 配置显式选择的 Maven/JDK 优先于项目设置,未设置的字段分别继承 Maven 设置;确认主任务与前置任务使用相同工具链,Maven 设置上下文缺失时仍保留显式覆盖,均未配置时交由平台自动发现。 双端中英文检查项目 JDK/Maven 页的“本机 · 此项目”副标题、默认继承与团队共享说明,以及运行配置的本机/项目保存范围;原有 JDK、Maven、项目环境关键词仍能搜索到页面。 Windows 成对配置读取和保存使用同一事务锁,验证并发保存不会争用临时文件或返回混合配置。" + "verification": "macOS 项目默认及子项目工具链只读取 canonical Run local.json,Maven 调用选项和 XML 在 Provider 设置中读取、编辑、确认并读回。315 项原生回归覆盖取消/重开/外部冲突、损坏偏好拒绝、迁移失败保留源、部分发布后改源拒绝、显式自动与新默认值优先,以及检测官方 reactor 后转移旧文件和运行偏好再重新检测。旧 XML 表单、解析器、MavenService/FeatureModel、POM 专属加载与 MacMavenConfigurationStore 已删除;Run 工程/源码根只读取当前模型。真实 Maven Agent compile 通过且插件包清单不变。尚须完整设置 UI、正式包和 Windows 原生验收;Windows 当前仍保留独立 Maven 设置/存储,覆盖配置隔离、XML 实体/CDATA、保存冲突、Wrapper 不被动执行和工具选择失败,不把 macOS 结果当作 Windows 证据。 Windows 成对配置读取和保存使用同一事务锁,并覆盖并发保存和失败重试。" } diff --git a/shared/platform-feature-matrix/features/java-maven-project.json b/shared/platform-feature-matrix/features/java-maven-project.json index cd0d73247f..6b1bb1dc3d 100644 --- a/shared/platform-feature-matrix/features/java-maven-project.json +++ b/shared/platform-feature-matrix/features/java-maven-project.json @@ -6,7 +6,14 @@ "macos": { "evidence": [ "macos/Sources/Lithe/Services/Java", - "shared/contracts/application-boundary.md" + "shared/contracts/application-boundary.md", + "macos/Sources/LitheExecutionModule/Services/BuildLanguageTests.swift", + "macos/Tests/LitheExecutionModuleTests/BuildSystemServiceTests.swift", + "rust/lithe-core/src/lsp/interface/engine_real_jdt_maven_provider_tests.rs", + "macos/Sources/Lithe/Models/AppModel/AppModel+JavaEntrypoints.swift", + "rust/lithe-core/src/execution/project_ownership.rs", + "shared/fixtures/run-configuration/provider-project-ownership.json", + "macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -20,5 +27,5 @@ "verificationStatus": "pending" }, "owner": "Java / Maven", - "verification": "使用单模块、多模块和非标准源码目录 fixture 对比项目模型。" + "verification": "使用单模块、多模块和非标准源码目录 fixture 对比项目模型。 阶段 D:语言任务按当前工程的内容根角色选择资源准备,拒绝其他工程更深内容根串入;通过符号链接打开的工作区可接收 JDT 规范源码路径。新增显式工具集成串起 Maven Provider、JDT 构建/运行路径与真实 JVM,覆盖嵌套 reactor、依赖模块、非标准主/测试源码、过滤资源和导入后的资源修改,进程关闭后比较插件包和 JDT 安装目录 SHA-256。Run 专属 Profile 在生产 Provider 和真实 JDT 链路中已验证;完整 UI、正式发行及 Windows 保持 pending。 Java 运行配置生成新增当前 Provider 工程归属投影:模型就绪后转交工程路径、reactor 根和源码角色,Core 不用祖先 POM 或目录名称补齐已提供的 Java 归属;空快照禁止旧推断,同名独立 reactor 保持两项配置。共享损坏 POM fixture 经 Core 与生产 Swift bridge 验证;Windows 未提供新字段,保留兼容路径。旧启动规划仍待迁移。 Maven 框架识别已使用当前 Provider 生效插件模型,涵盖 Profile/继承和既有服务 ID。 Java 编译要求使用 Provider 的 Maven 执行计划和参数解析结果,含 Profile、测试版本和自定义 execution;未知不回退 POM,本机 Java 版本偏好不得降低已知下限。 Wrapper 元数据改用 Provider 读取原生 properties/脚本,未知不补齐;新增/替换/删除脚本撤销缓存,双 reactor 不共用全局 Wrapper,Windows cmd 参与指纹。 Provider 模型变化后的已存生成文档身份校验已接通并验证:持久化模型投影摘要,列表异步检查并在 Java/Maven 启动前重新核对,缺模型/失效/空快照拒绝旧生成结果;旧启动规划和完整平台/发行验收保持 pending。 模型身份批 289 项 Swift、4 项生产 Provider 通过,默认 Profile A→B→A 在 POM 不变时使生成过期/恢复(7.496 秒);输入/工作区/重置迟到结果和连续通知合并受测试覆盖。" } diff --git a/shared/platform-feature-matrix/features/java-maven-settings-reach-jdt.json b/shared/platform-feature-matrix/features/java-maven-settings-reach-jdt.json index 62b03960ef..287342bcf6 100644 --- a/shared/platform-feature-matrix/features/java-maven-settings-reach-jdt.json +++ b/shared/platform-feature-matrix/features/java-maven-settings-reach-jdt.json @@ -6,7 +6,6 @@ "macos": { "evidence": [ "macos/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift", - "macos/Sources/LitheExecutionModule/Services/MavenService.swift", "macos/Sources/LitheExecutionModule/Services/BuildSystemService.swift", "macos/Sources/Lithe/Models/AppModel/AppModel+BuildSystems.swift", "macos/Sources/Lithe/Models/AppModel/AppModel+JavaIndex.swift", @@ -15,7 +14,17 @@ "macos/Tests/LitheLanguageIntelligenceModuleTests/LanguageIntelligenceModuleTests.swift", "macos/Sources/Lithe/Application/Composition/JavaLanguageServerPreparationCoordinator.swift", "macos/Tests/LitheTests/JavaLanguageServerRuntimeTests.swift", - "rust/lithe-core/src/lsp/languages/jdt_maven_settings.rs" + "rust/lithe-core/src/lsp/languages/jdt_maven_settings.rs", + "macos/Sources/LitheExecutionModule/Services/RunService.swift", + "macos/Sources/LitheExecutionModule/Module/ExecutionFeatureGraph.swift", + "macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift", + "macos/Sources/Lithe/Platform/MacOS/Persistence/MacLegacyMavenConfigurationMigration.swift", + "macos/Sources/Lithe/Platform/MacOS/Build/MacBuildOptionsStore.swift", + "macos/Sources/Lithe/Models/AppModel/AppModel+Debugging.swift", + "macos/Sources/Lithe/Models/AppModel/AppModel+JavaBuildPreparation.swift", + "macos/Sources/LitheExecutionModule/Services/BuildLanguageTests.swift", + "rust/lithe-core/src/lsp/interface/engine_real_jdt_maven_provider_tests.rs", + "macos/Tests/LitheTests/MacNativeBuildProviderIntegrationTests.swift" ], "implementationStatus": "partial", "verificationStatus": "pending" @@ -33,5 +42,5 @@ "verificationStatus": "pending" }, "owner": "Java / Maven", - "verification": "macOS:内置 Maven 工具窗口已由构建插件窗口取代,pom.xml 依赖解析问题暂不在构建窗口中列出(编辑器诊断仍显示);JDT 的 Maven 配置已优先消费 Core build.languageImport 归一化的插件选项,与原生插件窗口构建计划使用同一实例的生效值;检测、选项修改、模型加载和插件停用通过唯一回调发布,宿主不按插件名字反推设置。项目打开时在插件注册结束后等待导入,Java 运行时准备结束后、同步启动前再等待最新配置,覆盖首次等待结束后又被监听文件变化失效的顺序;选项发布时优先原地热更新,若会话因启动时没有 Maven 输入而拒绝更新,则通过 reloadJavaWorkspace 恢复重启。连续选项发布先取消并等待旧恢复清理,再应用最新输入;工作区关闭、同路径重开代次或停用 Java 会阻止旧任务继续启动。未声明对应导入输入时保留 MavenService 兼容设置;Maven 插件停用且兼容输入也为空时,仅向曾带 Maven 输入的同一活动会话发送空配置清除旧 Profiles/settings/仓库/工具路径,停止后释放记录,纯 Gradle 首次启动和重开保持无 Maven 输入。Gradle 输入经同一通路传递并在下次启动生效。macOS 与现有 Windows 路径均需验收:打开依赖尚未下载的 Maven 项目,在 Java 导入过程中修改 settings.xml、本地仓库或镜像并保存,确认第三方 import 在导入完成后解析成功;修改 settings.xml 内容(路径不变)后重新加载,确认语言服务不重启而依赖重新解析;故意写错依赖版本,检查编辑器诊断和跳转,Windows 另检查原 Maven 问题列表;初始化期间设置不变时重新加载,就绪后仍执行一次强制更新;旧 profile 请求失败或超时期间切换 profile,确认最终应用最新选择;保存与重新加载交错时,已发送的 settings 副本保持可读。本次未执行真实 Java 导入或 Windows 验收,保持 pending。" + "verification": "macOS:内置 Maven 工具窗口已由构建插件窗口取代,pom.xml 依赖解析问题暂不在构建窗口中列出(编辑器诊断仍显示);JDT 的 Maven 配置只消费 Core build.languageImport 归一化的插件选项,与原生插件窗口构建计划使用同一实例的生效值;检测、选项修改、模型加载和插件停用通过唯一回调发布,宿主不按插件名字反推设置。项目打开时在插件注册结束后等待导入,Java 运行时准备结束后、同步启动前再等待最新配置,覆盖首次等待结束后又被监听文件变化失效的顺序;选项发布时优先原地热更新,若会话因启动时没有 Maven 输入而拒绝更新,则通过 reloadJavaWorkspace 恢复重启。连续选项发布先取消并等待旧恢复清理,再应用最新输入;工作区关闭、同路径重开代次或停用 Java 会阻止旧任务继续启动。已删除 AppModel 的 MavenService 补值与语言会话的旧配置回调;导入读取失败明确报告且不发送空设置,恢复后仍能清除该会话以前的覆盖。没有启用 Build 插件时不注入 Lithe 构建覆盖;Maven 插件停用时,仅向曾带 Maven 输入的同一活动会话发送空配置清除旧 Profiles/settings/仓库/工具路径,停止后释放记录,纯 Gradle 首次启动和重开保持无 Maven 输入。Gradle 输入经同一通路传递并在下次启动生效。macOS 与现有 Windows 路径均需验收:打开依赖尚未下载的 Maven 项目,在 Java 导入过程中修改 settings.xml、本地仓库或镜像并保存,确认第三方 import 在导入完成后解析成功;修改 settings.xml 内容(路径不变)后重新加载,确认语言服务不重启而依赖重新解析;故意写错依赖版本,检查编辑器诊断和跳转,Windows 另检查原 Maven 问题列表;初始化期间设置不变时重新加载,就绪后仍执行一次强制更新;旧 profile 请求失败或超时期间切换 profile,确认最终应用最新选择;保存与重新加载交错时,已发送的 settings 副本保持可读。本次未执行真实 Java 导入或 Windows 验收,保持 pending。 Run 准备已停用 MavenService 上下文回调,消费 Build 服务同一代、按 reactor 根隔离的导入值;普通运行和服务运行在选项失效/工作区替换期间拒绝规划旧输入,检测完成后才使用新值。Run 的工程模型、配置生成和 Maven 规划仍未迁移,保持 partial/pending。 阶段 D 删除旧 MavenService/配置存储及设置页 XML 读取,Provider 被动检测后执行受保护迁移;Debug 准备等待 Build 输入并核对同一代次,ABA 变化不能启动旧准备。 新增来源与代次回归已在315项原生计时中通过;不代表 Windows 或完整JDT多reactor流程已验证。 阶段 D 单次 Run Profile 增量:资源准备在原有操作身份内用临时选项/工具读取 Provider 当前模型,按该模型选资源角色,再返回同一份 build.languageImport;不保存或发布成默认模型。Run/Debug 的 JDT 冷启动或热更新使用该输入,入口发现等待 Profile/JDT 配置屏障,导入代次含 ABA 在发现/构建/路径和错误决策后拒绝旧结果;下次默认发现恢复当前 Build 输入。异步发现与 Java 命令取消只撤销对应 Core 操作。真实 JDT 从无入口源码根切换 Profile 后首次查询得到两项入口,两个真实 JVM 读取新过滤资源通过;宿主替身覆盖冷启动/热更新/配置变化/ABA/取消。运行生成、框架规划、正式包与 Windows Provider 仍 pending。" } diff --git a/shared/platform-feature-matrix/features/plugin-debug-adapters.json b/shared/platform-feature-matrix/features/plugin-debug-adapters.json index 10a2229785..644d447fcc 100644 --- a/shared/platform-feature-matrix/features/plugin-debug-adapters.json +++ b/shared/platform-feature-matrix/features/plugin-debug-adapters.json @@ -50,6 +50,6 @@ "verificationStatus": "not-applicable" }, "owner": "Debug", - "verification": "macOS:安装声明了 debugAdapters 的插件,在其语言文件上启动调试,确认适配器按声明的 stdio 传输由宿主拉起并命中断点;可执行文件缺失时显示插件设置里的安装提示而不是笼统错误;声明 tcpPort 或 tcpAnnounced 传输的适配器在启动进程前被拒绝并说明原因。声明 tcpLanguageServer 传输的适配器(调试服务随同一插件的语言服务器运行,如 Java Debug Server 之于 JDT LS)不由宿主拉起进程:宿主启动该插件声明的语言服务器,用 endpoint.command 向它要端口并连接 127.0.0.1,端口无效、语言服务器不属于该插件或不可用时会话以原因结束。从保存配置直接启动且未打开源码时也会激活、登记语言服务器并探测工具;停止调试取消端口命令而不停止共享语言服务器,拒绝工作区或会话替换后的旧端口。适配器只声明 attach,或 selectors 的 targetKinds 不含 file 时,不会被选作文件启动的调试器;Java 在迁入 Java Support 插件前仍使用宿主自带适配器。capabilities.disabled 中的能力在调试会话里不可用,并随 debug.createSession 传给 Core。会话进行中禁用、更新或卸载该插件,或在模块设置里停用它,只停止该插件拥有的会话,同语言其他插件的会话继续运行。启动后 app bundle 文件清单不变(scripts/macos/verify/verify-runtime-bundle-immutability.sh)。远程 attach 目前只有 Core 契约:attach 配置可声明 targetBindings.connection,Core 校验主机、端口与路径映射并按适配器自己的参数路径写入启动参数,非 localhost、127.0.0.0/8、::1 的端点返回 remote:true;macOS 宿主尚未接入 attach 与“附加到远程”对话框,因此本项暂不能在应用里验证远程附加,接入后补充:连接新的非回环端点前必须弹确认且默认不记住,附加结束只断开不结束远端程序。Windows:宿主尚未接入 debugAdapters,Core 校验通过不代表 Windows 能调试插件语言。 原生宿主路由回归:使用 shared/fixtures/debug/adapter-selection-v1.json 验证自动文件选择、明确身份、无源文件目标、空身份、已撤回身份和 launch/attach 不匹配;同语言多个适配器分别收到断点,重试保持身份,附加会话启动失败保留原会话。这里只验证宿主交接与受控会话,Provider 原生程序准备 RPC 有受控流程与真实 SDK 进程覆盖;真实适配器的完整工作流与 Windows 接入仍未验收。 真实原生调试集成:公开 Node SDK Provider 用 Clang 编译 C 程序,经生产 macOS 会话、Core 准备、Registry 明确身份、生产 stdio 传输和 LLDB 命中源码断点、读取 main 栈帧、保留空格参数后继续并退出码 0;包 SHA-256/权限不变,所有本次进程回收。测试显式选择 SDK/工具,完整 UI、正式发行与 Windows 尚未验收,verification 保持 pending。 与显式 DAP 端点同时使用时,先按当前 Registry 身份校验请求能力,再调用端点工厂;launch/attach 与默认/明确身份的组合回归覆盖不会回退到本地适配器,配置编码与步进变换保留两个字段。 日志验收:在最新macOS宿主分别启动、初始化、暂停/继续、正常结束、取消、超时和适配器拒绝,确认debug/的关键生命周期INFO、内部状态DEBUG、真实失败ERROR;启动与原生进程非零退出固定stage/exit_code进入debug,原始stderr、DAP正文、源码、变量、内存和程序输出不进入诊断日志。同一次launch失败不能重复记session失败,取消不记ERROR。共享Core组件验证不能替代macOS真实UI和Windows v2验收。", - "notes": "普通 TCP 生命周期与用户显式 DAP 端点入口见 debug-tcp-transport;不放宽插件 tcpLanguageServer 回环约束,tcpPort/tcpAnnounced 托管模式仍拒绝。" + "verification": "macOS:安装声明了 debugAdapters 的插件,在其语言文件上启动调试,确认适配器按声明的 stdio 传输由宿主拉起并命中断点;可执行文件缺失时显示插件设置里的安装提示而不是笼统错误;声明 tcpPort 或 tcpAnnounced 传输的适配器在启动进程前被拒绝并说明原因。声明 tcpLanguageServer 传输的适配器(调试服务随同一插件的语言服务器运行,如 Java Debug Server 之于 JDT LS)不由宿主拉起进程:宿主启动该插件声明的语言服务器,用 endpoint.command 向它要端口并连接 127.0.0.1,端口无效、语言服务器不属于该插件或不可用时会话以原因结束。从保存配置直接启动且未打开源码时也会激活、登记语言服务器并探测工具;停止调试取消端口命令而不停止共享语言服务器,拒绝工作区或会话替换后的旧端口。适配器只声明 attach,或 selectors 的 targetKinds 不含 file 时,不会被选作文件启动的调试器;Java 在迁入 Java Support 插件前仍使用宿主自带适配器。capabilities.disabled 中的能力在调试会话里不可用,并随 debug.createSession 传给 Core。会话进行中禁用、更新或卸载该插件,或在模块设置里停用它,只停止该插件拥有的会话,同语言其他插件的会话继续运行。启动后 app bundle 文件清单不变(scripts/macos/verify/verify-runtime-bundle-immutability.sh)。远程 attach 目前只有 Core 契约:attach 配置可声明 targetBindings.connection,Core 校验主机、端口与路径映射并按适配器自己的参数路径写入启动参数,非 localhost、127.0.0.0/8、::1 的端点返回 remote:true;macOS 宿主尚未接入 attach 与“附加到远程”对话框,因此本项暂不能在应用里验证远程附加,接入后补充:连接新的非回环端点前必须弹确认且默认不记住,附加结束只断开不结束远端程序。Windows:宿主尚未接入 debugAdapters,Core 校验通过不代表 Windows 能调试插件语言。 原生宿主路由回归:使用 shared/fixtures/debug/adapter-selection-v1.json 验证自动文件选择、明确身份、无源文件目标、空身份、已撤回身份和 launch/attach 不匹配;同语言多个适配器分别收到断点,重试保持身份,附加会话启动失败保留原会话。这里只验证宿主交接与受控会话,Provider 原生程序准备 RPC 有受控流程与真实 SDK 进程覆盖;真实适配器的完整工作流与 Windows 接入仍未验收。 真实原生调试集成:公开 Node SDK Provider 用 Clang 编译 C 程序,经生产 macOS 会话、Core 准备、Registry 明确身份、生产 stdio 传输和 LLDB 命中源码断点、读取 main 栈帧、保留空格参数后继续并退出码 0;包 SHA-256/权限不变,所有本次进程回收。测试显式选择 SDK/工具,完整 UI、正式发行与 Windows 尚未验收,verification 保持 pending。 与显式 DAP 端点同时使用时,先按当前 Registry 身份校验请求能力,再调用端点工厂;launch/attach 与默认/明确身份的组合回归覆盖不会回退到本地适配器,配置编码与步进变换保留两个字段。 Windows 增量:服务目标显式保存并调试,唯一当前 Registry 类型/身份、Core 连接绑定、stdio/同包语言服务器、完整参数/初始化身份/禁用能力;覆盖重复/缺失适配器、包和设置变化、注册 ABA、监听取消、迟到启动回执、Run 退出/替换、清理失败重试。原生能力准入仍关闭,正式 Windows 适配器与真实框架断点 UI 尚未验收。 日志验收:在最新macOS宿主分别启动、初始化、暂停/继续、正常结束、取消、超时和适配器拒绝,确认debug/的关键生命周期INFO、内部状态DEBUG、真实失败ERROR;启动与原生进程非零退出固定stage/exit_code进入debug,原始stderr、DAP正文、源码、变量、内存和程序输出不进入诊断日志。同一次launch失败不能重复记session失败,取消不记ERROR。共享Core组件验证不能替代macOS真实UI和Windows v2验收。", + "notes": "普通 TCP 生命周期与用户显式 DAP 端点入口见 debug-tcp-transport;不放宽插件 tcpLanguageServer 回环约束,tcpPort/tcpAnnounced 托管模式仍拒绝。 Windows 已接服务连接调试的原生注册表规划、目标入口和 Run/DAP 生命周期;尚未开放完整插件准入或文件型/原生程序 Registry 调试。" } diff --git a/shared/platform-feature-matrix/features/plugin-injected-toolchains.json b/shared/platform-feature-matrix/features/plugin-injected-toolchains.json index 37e1e9345a..b99bc1b009 100644 --- a/shared/platform-feature-matrix/features/plugin-injected-toolchains.json +++ b/shared/platform-feature-matrix/features/plugin-injected-toolchains.json @@ -2,7 +2,7 @@ "id": "plugin-injected-toolchains", "area": "扩展", "group": "插件与主题", - "capability": "工具链领域框架:共享 Core 校验插件贡献、来源与四层解析;macOS 提供只读的运行环境和环境来源设置页,目录、工具、版本文件、固定值、登录 Shell 与版本管理器均来自已启用插件的声明和磁盘上的真实文件。自动检测按插件声明真实扫描本机安装并运行定义的版本探测;版本管理器通过插件后端的 toolchains/query 报告安装和项目/全局要求,回复经 Core 校验后显示,监视的文件变化时整份重查;每行哪一层生效由 Core toolchains.resolve 按同一份事实决定。Lithe 全局默认、保存与运行时消费者仍未接入,不能把只读页面当作已生效配置。 macOS 已登记 toolDefinitions、toolchainProviders 与 executableResolution 2,允许具备这些能力的插件经过市场筛选、安装审查和加载;仅含工具定义的插件也有独立启用模块。", + "capability": "工具链领域框架:共享 Core 校验插件贡献、来源与四层解析;macOS 提供运行环境和环境来源查看页,并为已接通的 Build 使用方提供本机偏好编辑,目录、工具、版本文件、固定值、登录 Shell 与版本管理器均来自已启用插件的声明和磁盘上的真实文件。自动检测按插件声明真实扫描本机安装并运行定义的版本探测;版本管理器通过插件后端的 toolchains/query 报告安装和项目/全局要求,回复经 Core 校验后显示,监视的文件变化时整份重查;设置页每行生效层由 Core toolchains.resolve 按同一份事实决定。macOS Build/Provider Run 开始使用独立的单次解析,页面结果不授予执行权;Lithe 全局默认、目录 pin 保存与旧 Java/Windows 消费者仍未接入,不能把页面发现结果当作执行许可。 两端 Build 运行前按当前贡献身份读取同一 Run 本机文档的通用工具偏好,Core 保留工作区/消费者符号继承,显式 Run 优先,探测后重查来源;macOS Build 偏好编辑已接显式 Save/Cancel/Reset、外部冲突保护及关闭取消,Windows 设置编辑和旧默认转换尚未接入。 消费者可用 tool 符号引用跟随最初请求目录的通用工具,间接继承保持目录身份;两端 adapter 使用共享 Core,macOS 编辑器可保存并重开该引用。 macOS 目录列表包含只有偏好的作用域和父目录;同一 Run 文件变化会刷新干净表单、保留冲突草稿并避开正在保存的操作,加载连续变化只重试一次。 macOS 已登记 toolDefinitions、toolchainProviders 与 executableResolution 2,允许具备这些能力的插件经过市场筛选、安装审查和加载;仅含工具定义的插件也有独立启用模块。", "macos": { "implementationStatus": "partial", "verificationStatus": "pending", @@ -31,7 +31,26 @@ "macos/Sources/Lithe/Platform/MacOS/Runtime/MacToolchainDiscovery+Providers.swift", "macos/Sources/Lithe/Platform/MacOS/Runtime/MacToolchainInputWatcher.swift", "macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift", + "macos/Sources/Lithe/Core/Ports/DefinedToolchains.swift", + "macos/Sources/Lithe/Platform/MacOS/Runtime/MacDefinedToolchainResolver.swift", + "macos/Sources/Lithe/Platform/MacOS/Runtime/MacToolchainDiscovery+Resolution.swift", + "macos/Sources/Lithe/Platform/MacOS/Build/MacBuildToolResolver.swift", + "macos/Tests/LitheTests/MacBuildToolResolverTests.swift", + "macos/Tests/LitheTests/RunLaunchEnvironmentTests.swift", "macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift", + "rust/lithe-core/src/toolchain/fixed.rs", + "rust/lithe-core/src/execution/provider_run_options.rs", + "macos/Tests/LitheTests/MacLegacyMavenRunConfigurationMigrationTests.swift", + "rust/lithe-core/src/toolchain/preferences.rs", + "shared/contracts/toolchain-consumer-preferences-v1.schema.json", + "shared/fixtures/toolchains/consumer-preferences.json", + "rust/lithe-core/src/execution/configuration/project_toolchain.rs", + "macos/Sources/Lithe/Core/Ports/ToolchainPreferences.swift", + "macos/Sources/Lithe/Application/Features/ToolchainPreferencesFeatureModel.swift", + "macos/Sources/Lithe/Platform/MacOS/Runtime/MacToolchainPreferencesStore.swift", + "macos/Sources/Lithe/Views/App/ToolchainPreferenceEditorView.swift", + "macos/Tests/LitheTests/ToolchainPreferencesTests.swift", + "macos/Tests/LitheTests/MacNativeBuildProviderIntegrationTests.swift", "macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift", "macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift", "macos/Tests/LitheTests/ToolchainPluginAdmissionTests.swift" @@ -50,6 +69,6 @@ ] }, "owner": "Plugin Platform", - "verification": "共享 Core:cargo test --manifest-path rust/Cargo.toml -p lithe-core --lib toolchain,核对定义独占与官方短 ID、第二种通用工具、旧候选边界、答案身份/代次、Windows 路径大小写、工作区排除、固定值失效不回落、最近项目要求、冲突选择、最低版本、环境隔离和纯声明扫描;按逐项计时工具生成 HTML/JUnit。macOS 与 Windows 原生后续验收均需先实现对应 adapter:启动提供方并协商、取消/超时和过期结果隔离、输入新建/删除后整体重查、目录固定保存/取消/冲突回读、插件停用撤回、真实 Build/LSP/Run 消费者取得同一选择、发行目录哈希不变与子进程清理。未完成原生流程前保持 pending,不以 Core 测试代替实机验证。 提供方查询:cargo test -p lithe-core --lib toolchain(含 lsp 引擎脚本后端:只有插件后端被提供 litheToolchains、未协商的提供方不写请求、错误回复原样返回;别名链接只有链接路径事实时仍选中规范 home);MacToolchainDiscoveryTests 用真实临时目录与注入的查询验证同一轮查询共享代次而 operationID 各不相同、链接 home 规范化、工作区内 home 整份拒绝,以及 toolchains.resolve 选出项目要求、未安装的要求记为 unmet 并退到下一层、固定值只在自己的目录生效且失效时不回落,以及超时和后端不可用的提供方经 toolchains.failureReply 交给 Core 记为 providerUnavailable(cause 区分两者)并由下一层决定;ToolchainSettingsLoaderTests 验证每行读取其固定值目录或最近版本文件目录的解析结果;ToolchainSettingsFeatureTests 验证监视文件连续变化只重载一次、外部输入观察随关闭释放。真实 mise 插件端到端尚未在应用内验证。 原生布局回归 ToolchainSettingsLayoutTests:同一设置宿主按900/1180/1400/900重复缩放,真实分隔条拖动;来源页按500/666/946/500缩放,检查AX控件边界、卡片不重叠、选择和快照不改变。此几何验证不代表提供方查询或保存功能已完成。 声明式扫描 MacToolchainDiscoveryTests:真实临时目录、注入的用户目录与探测,覆盖 homeLayouts 首个匹配、隐藏与未匹配条目、链接到工作区的条目不探测、只探测 Core 返回的可执行文件、同版本冲突、被拒声明不扫描,以及隔离预览样例清单通过 Core 准入;不运行本机安装的工具。 ToolchainPluginAdmissionTests 使用正式契约 fixture 与真实 Core 验证市场可安装、完整安装审查、清单加载,以及无后端纯工具定义插件的启用模块和工厂登记;本地编译验证不代替真实 mise 与 Windows 工作流验收。 2026-11-02 本机 Swift 6.4.2:准入、后端、发现和加载器 42 项通过,新增两个准入用例分别 4/6 ms;Swift 6.3.3 在本机 Xcode 27.2 SDK 下编译依赖清单崩溃,不能作为固定工具链通过。真实 mise 0.2.1 发布包 SHA-256 与市场一致,Core 证明修复前 unsupported、修复后 available;真实后端完成协商、查询并停止销毁,当前环境未安装 mise,查询真实返回 miseNotFound,未验证安装列表和应用内工作流。", - "notes": "本条仅记录 #1581 重设计的 Core 基础切片。两端共享同一确定性模型;macOS 已接入只读设置查询,登记工具链 hostCapabilities,Windows 继续不登记;现有 Java/Maven/Node 发现与选择未迁移。安装来源支持两条途径:工具定义里的声明式扫描(由宿主展开)和提供方查询(toolchains/query,可只报告安装);Core 在自动层把两条途径按规范化目录合并;macOS 设置页已接入声明式扫描(宿主按 discoveryPlan 展开、收集文件事实、只探测 toolchains.candidates 返回的可执行文件,扫描与探测有数量和时长上限),macOS 设置页已接入提供方查询(启动或复用插件后端、协商 litheToolchains、同一轮查询共享代次而每次查询独立 operationID、超时 15 秒、取消发送 $/cancelRequest,回复交 toolchains.decodeReply 用宿主读取的文件事实校验,失败原样显示提供方原因;超时或后端不可用时由 toolchains.failureReply 生成错误回复交 Core 记为 providerUnavailable,工具行写出哪个版本管理器未响应或不可用);Windows 仍未接。Core 不运行 mise/SDKMAN、不解析其文件格式、不下载、不监听或写配置。通用 runtime 消费者、环境/来源界面、项目环境 symlink 完整原生验证及带后端免重启仍未完成;没有新增运行时资源或可复用下载目录。 macOS 设置页只读:运行环境页按目录列出工具,展开后按固定、项目文件、全局、自动四个来源排列并框出 Core toolchains.resolve 选中的来源,提供方报告的要求与版本显示在项目与全局步骤中,固定值由 Core 校验、不可用时显示原因,来源冲突只显示原因;环境来源页显示登录 Shell 的读取状态、变量数(不显示值)、耗时和 PATH 各目录的来源,以及插件声明的版本管理器在本项目找到的版本文件。未实现的添加、切换、保存和冲突选择入口不显示。Debug 隔离预览只有插件清单是样例,工作区文件、固定值和登录 Shell 均真实读取,不启动工具、不写真实配置。Core 独立位于 rust/lithe-core/src/toolchain/,插件只是声明贡献入口。" + "verification": "共享 Core:cargo test --manifest-path rust/Cargo.toml -p lithe-core --lib toolchain,核对定义独占与官方短 ID、第二种通用工具、旧候选边界、答案身份/代次、Windows 路径大小写、工作区排除、固定值失效不回落、最近项目要求、冲突选择、最低版本、环境隔离和纯声明扫描;按逐项计时工具生成 HTML/JUnit。macOS 继续补齐下列消费者与保存验收,Windows 须先实现对应 adapter:启动提供方并协商、取消/超时和过期结果隔离、输入新建/删除后整体重查、目录固定保存/取消/冲突回读、插件停用撤回、真实 Build/LSP/Run 消费者取得同一选择、发行目录哈希不变与子进程清理。未完成原生流程前保持 pending,不以 Core 测试代替实机验证。 提供方查询:cargo test -p lithe-core --lib toolchain(含 lsp 引擎脚本后端:只有插件后端被提供 litheToolchains、未协商的提供方不写请求、错误回复原样返回;别名链接只有链接路径事实时仍选中规范 home);MacToolchainDiscoveryTests 用真实临时目录与注入的查询验证全局与目录查询各带独立 operationID/代次、链接 home 规范化、工作区内 home 整份拒绝;ToolchainSettingsFeatureTests 验证监视文件连续变化只重载一次、外部输入观察随关闭释放。真实 mise 插件端到端尚未在应用内验证。 原生布局回归 ToolchainSettingsLayoutTests:同一设置宿主按900/1180/1400/900重复缩放,真实分隔条拖动;来源页按500/666/946/500缩放,检查AX控件边界、卡片不重叠、选择和快照不改变。此几何验证不代表提供方查询或保存功能已完成。 声明式扫描 MacToolchainDiscoveryTests:真实临时目录、注入的用户目录与探测,覆盖 homeLayouts 首个匹配、隐藏与未匹配条目、链接到工作区的条目不探测、只探测 Core 返回的可执行文件、同版本冲突、被拒声明不扫描,以及隔离预览样例清单通过 Core 准入;不运行本机安装的工具。 固定集成新增 MacToolchainDiscoveryTests 的运行前解析回归和 MacBuildToolResolverTests,验证两个目录使用各自 pin、最低版本、独立环境与失败不回落;RunLaunchEnvironmentTests 证明操作返回值不会留下共享执行许可槽。MacNativeBuildProviderIntegrationTests.installedDefinitionsResolveTheRealBuildAndRuntimeThroughSavedChoices 使用明确选定的真实 Java/Maven 经生产 resolver 完成版本检查和环境传递,比较两个插件目录清单不变;该测试不是完整应用 UI 验收。 生产准入回归 installedToolDeclarationsPassTheProductionHostAdmission 经 RustDeclarativePluginOperations 验证工具定义和提供者;真实 Maven 工作流同时覆盖安装准入、工具解析和原生配置。Build PATH 保留声明的附加目录,拒绝空目录项。 Windows 原生解析基础:lithe-project::defined_toolchain 逐项计时覆盖两个工具、声明式扫描、固定目录、最近继承、同版本冲突、最低版本、显式 Wrapper、输入撤销及宿主秘密不进入 RPC。真实 Windows 命令包装和进程清理继续由现有 CI 验证;准入仍关闭。macOS Wrapper 的真实 Maven 委托启动器覆盖声明最低版本,不运行被动探测。 本次回归另核对同目录两个 Run 独立选择、团队/本机逐项合并和显式清空、私有环境隔离、失效固定值不回落、工作区启动器被动零探测,以及未知默认值迁移失败保留源文件。 本批显式 LITHE_TEST_NODE 驱动真实 Core/Node 会话,逐项计时覆盖单次协商、多次查询、原始错误、无协商拒绝、静默超时、后端意外退出与撤销清理;原生解析验证查询与当前事实、来源在探测中变化及稳定模型摘要。安装状态按 plugin/operation/workspace 隔离且不可跨工作树复制。 #1581 最终合入回归包含固定值与未扫描目录、同轮次/上下文、冲突及提供方超时和不可用的来源展示。 通用默认增量:更改工作区工具 home 后,消费者继承读取新值,按 Run 固定值保持独立;失效明确路径不回落,来源在探测期间改变或越出工作区时拒绝。旧编辑保存保留通用偏好,显式空 v1 文档清除。矩阵保持 pending,未把手写测试文档当成设置页或旧配置迁移验收。 macOS Build 偏好编辑:ToolchainPreferencesTests 验证打开/草稿/取消/空重置不建文件、明确保存/回读与权限保留、外部冲突保留原始草稿基线、仅偏好文档不阻断旧默认迁移、缺失子目录的父链接/最终链接/只读安装目录拒绝及主动取消;原生 Save/Reset 按钮在深浅主题通过实际存储执行。ToolchainSettingsLoaderTests 验证使用方来自当前 Core invocation 并随停用撤回。运行全量 Core 与相关 macOS 逐项计时;完整产品、全局默认、旧消费者和 Windows 设置页仍待验收。 tool 引用验证:共享 fixture/Core 覆盖最近通用目录、根目录、跳过父消费者、目录与工作区默认变化、显式 Run 优先和禁止通用工具反向引用;MacToolchainDiscoveryTests 与 lithe-project::defined_toolchain 的真实临时安装/注入探测验证运行前重读,不冻结安装路径。 本机目录/重载回归覆盖仅消费者偏好、父目录连接、重置撤回、链接/非法层级拒绝、单次重试与可刷新错误、未修改表单更新、冲突草稿保留及自身保存通知不等待/撤销保存。真实 Maven 集成通过生产 Save 保存默认、切换两种明确选定的 JDK 并检查 Maven 进程输出,显式 Run 保持原 JDK,插件文件清单不变;该集成不代替完整 GUI。 运行前来源审计 missingPreferenceLeafBehindAParentAliasIsRejectedBeforeToolWork 覆盖两级父目录及存在/悬空链接,四种情况均在工具探测前拒绝。 ToolchainPluginAdmissionTests 使用正式契约 fixture 与真实 Core 验证市场可安装、完整安装审查、清单加载,以及无后端纯工具定义插件的启用模块和工厂登记;本地编译验证不代替真实 mise 与 Windows 工作流验收。 2026-11-02 本机 Swift 6.4.2:准入、后端、发现和加载器 42 项通过,新增两个准入用例分别 4/6 ms;Swift 6.3.3 在本机 Xcode 27.2 SDK 下编译依赖清单崩溃,不能作为固定工具链通过。真实 mise 0.2.1 发布包 SHA-256 与市场一致,Core 证明修复前 unsupported、修复后 available;真实后端完成协商、查询并停止销毁,当前环境未安装 mise,查询真实返回 miseNotFound,未验证安装列表和应用内工作流。", + "notes": "本条记录 #1581 的固定基础与 #1495 的预集成。macOS 发现结果只读,Build 偏好可明确保存;Build/Provider Run 新路径通过单次原生解析消费定义;Windows、旧 Java/Node 消费者和已有工具数据迁移仍未完成。安装来源支持两条途径:工具定义里的声明式扫描(由宿主展开)和提供方查询(toolchains/query,可只报告安装);Core 在自动层把两条途径按规范化目录合并;macOS 设置页已接入声明式扫描(宿主按 discoveryPlan 展开、收集文件事实、只探测 toolchains.candidates 返回的可执行文件,扫描与探测有数量和时长上限),macOS 设置页已接入提供方查询(启动或复用插件后端、协商 litheToolchains、每次查询独立 operationID 与代次、超时 15 秒、取消发送 $/cancelRequest,回复交 toolchains.decodeReply 用宿主读取的文件事实校验,失败原样显示提供方原因);Windows 已接单次准备持有的 Provider 查询,完整产品准入和工作区关闭主动取消仍待补齐;目录 pin、声明扫描和 Shell 候选已有原生 Build 解析基础,产品准入仍关闭。Core 不运行 mise/SDKMAN、不解析其文件格式、不下载、不监听或写配置。通用 runtime 消费者、环境/来源界面、项目环境 symlink 完整原生验证及带后端免重启仍未完成;没有新增运行时资源或可复用下载目录。 macOS 设置页只读:运行环境页按目录列出工具,展开后按固定、项目文件、全局、自动四个来源排列并框出首个命中,提供方报告的要求与版本显示在项目与全局步骤中、固定值显示已保存未验证;环境来源页显示登录 Shell 的读取状态、变量数(不显示值)、耗时和 PATH 各目录的来源,以及插件声明的版本管理器在本项目找到的版本文件。未实现的添加、切换、保存和查询入口不显示。Debug 隔离预览只有插件清单是样例,工作区文件、固定值和登录 Shell 均真实读取,不启动工具、不写真实配置。Core 独立位于 rust/lithe-core/src/toolchain/,插件只是声明贡献入口。 #1495 固定 b87c7f6 的预集成增加 macOS Build/Provider Run 单次解析:已有目录 pin、官方来源、定义内布局、声明最低版本和原生文件证据共同校验;固定 home 失效不回落、Wrapper 只允许显式启动、不被动探测,异步期间文件变化拒绝。Maven 社区包使用命名空间 ID;Java 短 ID 由官方包提供。Windows 456 项前端回归、类型检查与生产构建在 macOS 完成,原生 Windows CI 待执行。页面保存、旧工具迁移和完整产品验收仍 pending。 macOS 已实现消费者对应的 toolDefinitions:1/toolchainProviders:1 同步加入真实宿主能力表;此项不开放 Windows 能力。Windows 首轮 CI 41307483413 前端通过,Rust/JDT 因 Unix 专用路径 fixture 失败,修复后待重跑。 Windows 已增加无工具名分支的目录 pin/扫描/环境/命令原生解析基础,固定值跳过低层扫描和 Provider;需要 Provider 而无固定值时明确拒绝,不忽略未实现来源。官方短 ID 不从本机安装记录猜测。原生整体仍 pending,查询后端进展见本轮验证。显式 Wrapper 的最低版本在 Core 校验身份后有界探测并重新执行同一最低版本要求。 Windows Build 新增的 project-maven-jdk 别名已删除,共享 fixture 用命名空间工具和显式版本证明通用消费;旧 project-maven 的真实 Java/Windows 消费者继续按阶段计划处理。 按 Run 的通用 tools 选择已接入两端 Build 准备:明确安装目录/可执行文件优先于目录 pin,按工具 ID 继承并支持 null 清空,保存不改写目录 pin。旧 Maven 单次转换器只转移可无损表达的显式 Maven/JDK 选择;带项目默认值、目录覆盖或应用 JDK 继承的来源保留并明确拒绝转换,完整默认值迁移仍待完成。Windows CI 41315520152 的 Core、前端和真实 JDT 通过,项目原生测试停在路径前缀期望值,修复测试夹具后仍需整轮通过。 Windows 增加与 macOS 格式一致的发布者验签;签名存在时必须验证,扫描持有完整内容摘要,定义消费和启动再核对。无签名安装记录仍不证明官方身份,缺签名不恢复旧的已验证证明。packageTrust、Build/Debug 等能力准入仍关闭。原生 invoke 的 Error 对象按稳定代码翻译工具不可用和签名失败。 Build 当前工具上下文只支持直接 executable/arguments 模板;两端对额外 flag/home 启动模板在探测前明确拒绝,不能静默删除声明参数。完整模板消费仍未实现。 本批 Windows 工具查询复用 Core LSP 引擎,单次准备内协商并复用后端,持有独占的操作状态目录,返回前停止并清理;当前只接 packaged 后端,非空未实现 hostServices 明确拒绝。查询和原生文件事实交 Core 验证,提供方来源改变撤销结果;已校验不可用回复可检查低层,安全或身份错误不回落。Core 与两端接受保留类型和值的有界字符串/正整数 JSON-RPC ID,修复真实 Core 字符串 ID 被误拒绝。上一提交 2a8bcddf8 的 Windows CI 41377299138 全部通过,本批新增查询须在自己的 CI 组合验证;不开放能力准入或宣称完整迁移。 已整合 #1581 最终合入实现;设置页按相同目录轮次、上下文与文件事实交 Core 判断生效层、冲突和提供方不可用,界面不再重建选择语义。 #1581 实际合并后整合 preview@50b4ebe0f,保留最终设置页按轮次交 Core 判断来源的实现;本机 Project 94 项、Swift 228 项、Windows 前端 469 项通过,旧四项 macOS watcher 超时已由上游修复。新组合仍须自己的 Windows CI;Core 全量断言通过但进程计时超限记录保留,三项定向复验通过,不修改门槛。 Windows 工具准备现由窗口绑定令牌与同一 AbortSignal 撤销:先登记再派发,取消早于派发仍拒绝;窗口销毁撤销自己的所有者,扫描/查询/最终返回检查当前性,已启动版本探测保持原五秒进程期限。新登记仅在内存,容量/过期有界,不驱逐活跃租约。Build/Run/私有 scope/启动前验证都转交信号,完整产品准入仍关闭。 Windows Provider Run 的工具 ID 现在只使用当前原生 resolvedExecutables 绑定;删除从 Maven languageImport、旧 Maven 路径及应用 runtime 路径补齐启动程序的逻辑。缺少绑定和跨根路径冲突明确拒绝;Rust Serialize 与真实 TypeScript Run 编排共用 native-tool-context fixture 验证边界,Windows 产品准入仍关闭。 真实 Maven/JDK 流程补出用途边界:带 entry/JAR 模板的运行时安装依赖只消费已校验版本和导出,不实例化该模板;两端显式标记安装依赖,主程序直接执行仍拒绝不支持的模板。Windows 直接绑定过滤非直接模板,防止将依赖升级成绕过模板的启动许可。原失败保留,修正后的真实工作流另行计时,不按工具名特判。 宿主 e3fea58db 的 Windows CI 41401417746 全部通过,实际覆盖窗口取消和原生绑定:Project 100 通过/1 跳过、host 394 通过/3 跳过,Core、前端和真实 JDT 同时通过;不覆盖其后的安装依赖用途修正。用途修正的本机 Project 100 项通过,macOS 真实 pin/Wrapper 和原生配置行为通过;双 reactor 原组合计时超限保留,两个独立工作流减少重复读取后为 9.968/5.379 秒,通过原门槛。完整 Windows 产品准入和旧默认值迁移继续 pending。 当前 96f10837e 的 Windows CI 41558993167 全部通过;其后目录列表/实时编辑重载和真实 Maven 测试按独立本机报告验证,不借用前一提交的 Windows 结论。" } diff --git a/shared/platform-feature-matrix/features/run-discovery.json b/shared/platform-feature-matrix/features/run-discovery.json index d14858932e..50e1894be9 100644 --- a/shared/platform-feature-matrix/features/run-discovery.json +++ b/shared/platform-feature-matrix/features/run-discovery.json @@ -17,7 +17,19 @@ "rust/lithe-core/src/execution/configuration.rs", "rust/lithe-core/src/execution/generated_layer.rs", "macos/Sources/Lithe/Platform/MacOS/RunConfiguration/MacRunConfigurationStore.swift", - "rust/lithe-core/src/tests/run_configuration.rs" + "rust/lithe-core/src/tests/run_configuration.rs", + "rust/lithe-core/src/execution/provider_runs.rs", + "macos/Sources/LitheExecutionModule/Services/BuildSystemService.swift", + "macos/Tests/LitheExecutionModuleTests/BuildSystemServiceTests.swift", + "rust/lithe-core/src/execution/provider_run_options.rs", + "shared/fixtures/run-configuration/build-provider-options.json", + "macos/Sources/Lithe/Platform/MacOS/Persistence/MacLegacyMavenRunConfigurationMigration.swift", + "macos/Tests/LitheTests/MacLegacyMavenRunConfigurationMigrationTests.swift", + "shared/fixtures/run-configuration/build-provider-stable-identities.json", + "shared/fixtures/run-configuration/argument-text.json", + "macos/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift", + "macos/Sources/Lithe/Services/Debug/DebugLaunchSourceResolver.swift", + "macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -57,6 +69,6 @@ "verificationStatus": "pending" }, "owner": "Run", - "verification": "验证 Spring Boot、Java、Maven、Gradle、npm、Cargo、Go、Python 和 Docker Compose 入口识别;Maven 框架 plugin 只在父 POM 声明时,子模块沿 链继承后仍识别为对应服务,父 POM 按 groupId:artifactId:version 完整坐标匹配、仅同名 artifactId 或版本不符的不继承、含未解析 ${...} 表达式的坐标不作数,聚合但未声明 的模块不继承、false 的 plugin 不下传且对更深层级保持阻断、 声明保持不生效、框架 plugin 按完整坐标 groupId:artifactId 识别:每个框架的全部官方坐标组均被接受(Quarkus 的 io.quarkus 与平台再发布的 io.quarkus.platform,Micronaut 3.x 的 io.micronaut.build 与 4+ 的 io.micronaut.maven,Spring Boot 的 org.springframework.boot)、省略 的声明按 Maven 默认组 org.apache.maven.plugins 处理,其他组下的同名插件与含 ${...} 表达式的 group 不识别为框架,子模块的同名自定义 plugin 也不遮蔽父继承的正品;源码根与报告目录的提取同样按完整坐标匹配,build-helper 执行级源根与 Surefire/Failsafe 独立 reportsDirectory 均有官方组与自定义组对照;Java 运行入口缺少主类时 inspect 报 staleJavaEntrypoint 并提示重新生成,重新生成后由 JDT 答案恢复;多模块项目把服务工作目录改成模块目录后,服务仍留在列表中并以该目录启动;填写不存在的目录或 ${workspaceFolder} 等变量时,保存被拒绝并提示原因;在 Windows 确认重新扫描服务使用双箭头刷新图标,配置按语言生态、类型与实例呈现三栏布局,Java 下展示 Maven、Gradle 与 Spring Boot 等已识别类型,Node.js 下展示包管理器与框架;语言切换和外部选中能定位配置,空生态不显示,语言和配置栏不显示重复栏头,生态不显示配置数量;语言侧栏通过拖动阈值折叠和展开,收起与展开阈值不同以避免边界跳动,不显示专门折叠按钮;键盘可调整宽度及恢复展开,保存展开宽度与拖动折叠状态,拖动期间只更新局部宽度和紧凑标记,文字与图标对齐即时切换,结束后保存;语言行、类型行和实例行使用一致的紧凑行高,去掉列表上下留白和选中实例的圆角边距;窄面板自动缩成带提示的图标栏且扩大后恢复,右侧输出保留可用空间,窗口缩放或卸载时清理正在进行的拖动;所选配置的日志、运行状态、顶部停止按钮、清空输出与标准输入均指向其自身会话;保持选中 application 并后台启动服务后,顶部停止仍指向该应用的独立会话,不停止后台服务,左侧使用 SVG 箭头和类型图标,基础设施默认折叠,选择配置自动展开所在分组,运行、停止、编辑和完整名称提示可用;根据 Core 提供的 npm、Gradle 和 Python 元数据展示明确识别的框架图标,构建与测试任务保留工具图标,未知类型回退通用图标;同名自定义 provider 与已知框架分别分组且折叠状态独立,相同已知框架跨 Maven/Gradle provider 合组,项目与基础设施不合组;Java 生态分组使用 Java 文件类型图标,Java 启动类实例使用原始 Class 与 RunnableMark SVG 同画布叠加图标;Maven 启动的框架服务使用 MavenRunConfigurationType 对应的 Maven 图标,Make 外部任务分组使用任务文件夹、实例使用 Shell 配置图标,均保留原始明暗 SVG,运行或失败不改变类型图标,深浅主题均可辨认;macOS Run 缺少配置且可重新生成时,检查空状态文字与链接在深浅主题下按 IDEA 工具窗口样式显示、窄面板不截断,并确认“识别并生成”仍触发原有生成流程;应用语言设为英文而系统语言为中文时,Run 空状态及其他本面板文案仍应显示英文;Run 顶部标题字号、颜色、41pt 高度和底部分隔线应与 Terminal、Git Log 等工具窗口一致;有项目配置或 Current File 正在运行/已有退出结果时,运行、重启、重新扫描和软换行按钮显示在独立的 36pt 操作栏,检查 16pt IDEA 图标、22pt 共享按钮的悬停、按下、选中及禁用状态;操作栏只保留一个运行/重运行动作:首次启动显示 Run,当前配置已运行过后显示 Rerun;右侧固定停止按钮,未运行禁用、运行中启用,两者始终指向当前选中配置;操作栏不重复显示 Running 圆点及状态文字,重运行按钮复用顶部工具栏的绿色 rerun_stroke 图标;标题栏菜单中的清空输出与原操作一致,收起按钮沿用共享样式;仅有 Current File 的项目启动后也可从 Run 操作栏停止,结束后保留重运行入口;Run 内容区不额外插入 Java 项目准备状态行,准备进度和失败入口保留在工作台状态栏;Run 标题、操作栏、配置列表标题、控制台标签底边及两侧纵向分割线统一使用工具窗口边框颜色和 1pt 绘制,左侧图标栏不出现 5pt 实心粗线,栏内分组线与 Git Log 一致使用共享 divider 颜色、22pt 宽度、1pt 高度及上下 3pt 间距;操作按钮组之间使用专用 ActionToolbar.separator,详情编辑按钮的边框由共享按钮样式负责,拖动命中范围保持不变;配置树直接显示分组与数量,不显示 Application types 标题;默认隐藏勾选框,通过更多菜单或右键进入批量选择,退出清空选择;配置单行显示名称,类型及服务地址放到悬停提示,行内仅运行/停止,编辑/固定/打开服务保留在共享右键菜单,固定后显示标记;分组与配置使用共享树行高、缩进及焦点选择样式;Run 范围筛选以固定图标栏呈现,应用范围筛选及当前运行导航使用中性 ToolWindowRun 图标,实际运行操作继续使用绿色 Execute 图标;全部采用 IDEA groups、服务采用 server 图标,固定、应用及任务沿用对应 IDEA 原始资源;检查所有图标的 16pt 尺寸、明暗 SVG 自动切换、提示和筛选,右键勾选范围和配置行勾选共享同一批量选择;配置行使用 IDEA 固定、编辑、运行及停止图标,悬停与点击维持箭头光标;配置过期提示使用共享中性通知表面及警告图标,操作仍可重新识别;Run 标题栏仅显示 Run,移除重复配置名与右侧状态胶囊;Console 和 Configuration details 标签位于运行操作按钮组右侧,配置列表选中项表示当前配置,内容区直接显示日志或详情;配置详情使用共享 13pt 字体、原生 Grid 自适应标签列、可选择且可换行的路径及值,编辑配置按钮复用共享 LitheCommitDialogButtonStyle 与 IDEA EditSource 图标,检查深浅主题、窄列及点击跳转设置,服务链接保留原动作;两个标签使用共享 LitheToolWindowTabStyle,检查 13pt 文字、28pt 标签、原始明暗 SVG、悬停及内容区聚焦/失焦状态,保留标签切换;Run 与 Debug 均启用共享 OutputTextView 的 IDEA 渲染入口,运行日志采用一致的 13pt 字体、1.2 倍行高和 ANSI 配色,不给普通 INFO 日志整行染蓝,源码链接继续跳转;Copy 与 Jump to latest 为共享图标按钮,复制不带 ANSI,向上滚动后可恢复跟随;其他配置恢复状态保留各自提示。 Current File 运行一次后修改未保存并切换编辑标签,重运行应先保存原运行文件再构建启动计划;原文件保存失败时提示错误并中止重启,当前焦点文档不被误保存;调整 Run 面板高度,外层横向操作栏固定为 36pt,剩余高度交给配置树和控制台。 缓存目录不可访问时仍可发现兄弟目录和隐藏源码;可选子目录不可读时保留其旧配置并显示 incompleteProjectScan,不误报删除或新鲜;根目录不可读仍失败,完整重新生成后解除未知基线。 两端 Run 面板显示扫描不完整的共享中英文提示;恢复访问权限后重新识别。 Java 文件枚举达到 8,000 条上限后不再访问后续目录,返回顺序稳定;不可读兄弟目录不阻断结果,完整指纹扫描仍继续遍历。 保存 Maven/Gradle 构建任务后重启,确认用户任务和 Spring Boot 等自动入口同时恢复;再次加载不重复生成。 自动 Spring 服务必须同时具备 Maven 插件和同模块唯一的 JDT 已确认非测试 Spring 入口;继承插件的库模块、只有测试 main 的模块、只有子模块入口的父模块不能成为服务,多入口保留独立 Java 配置。验证传空 paths 不影响确认入口的模块归属、JDT 未就绪保留已确认结果、明确空结果移除自动服务,已有服务 ID 和用户配置保持兼容。 #1562:Windows 普通应用 A/B 独立会话和输出;先启动 A 再 B,停止及重跑 B 不影响 A,切换配置只显示所属输出,关闭项目清理两者。 IDE API 在 primary 忙碌时仍能启动独立应用,同一配置准备期间拒绝重复启动;保存期间会话可见且可停止,停止后不再启动进程,顶部停止不影响后台服务;旧 Debug 回调不能停止重跑后的应用。 缺少工具链时错误只归属目标应用,不覆盖 primary 或后台会话,也不把已运行的目标实例标记为退出。", - "notes": " Windows普通Java文件的main入口在工作区搜索遗漏时,先保存并让JDT对当前文件确认,再生成命名运行配置;拒绝过期入口、准备失败和工作区切换结果。Java入口源码按容错UTF-8读取(非法字节替换为占位符),GBK项目不再因单个非UTF-8文件导致整个运行列表生成失败,Spring Boot注解等ASCII分类标记不受影响。 macOS 运行就绪要求匹配快照且工具链和配置选择加载完成,加载中的清单不可作为启动许可;受控工具链与快照测试验证完成前后状态,以及 Current File 不被迟到的默认配置覆盖。 远程工作区尚未接入运行配置发现:Core 对 runConfig.inspect 返回 NotSupported,仅 runConfig.selectJava 可用;共享扫描的远程契约与进度见 remote-development-workspace。 识别结果只保存在 Core 进程内存中,宿主不再写入工作区的 .lithe/run/generated.json、.lithe/toolchains/requirements.json、.lithe/project.json 和 .lithe/.gitignore;上次的 Java 入口缓存在应用缓存目录(run-configurations/),旧版本留下的 generated.json 只作只读的上次入口来源。本进程尚未识别的工作区在加载时自动识别(只读扫描、不启动进程),自动识别不提示入口数量;未写 defaultRunConfiguration 时由 Core 优先选框架服务。识别前 JDK 选择从 pom.xml、.java-version 与 Maven Wrapper 推导最低版本。 保存过构建任务的工作区重启后仍按 Core requiresGeneration 标记自动识别,保留用户任务并恢复其他检测入口;同一进程完成识别后不重复生成。" + "verification": "验证 Spring Boot、Java、Maven、Gradle、npm、Cargo、Go、Python 和 Docker Compose 入口识别;Maven 框架 plugin 只在父 POM 声明时,子模块沿 链继承后仍识别为对应服务,父 POM 按 groupId:artifactId:version 完整坐标匹配、仅同名 artifactId 或版本不符的不继承、含未解析 ${...} 表达式的坐标不作数,聚合但未声明 的模块不继承、false 的 plugin 不下传且对更深层级保持阻断、 声明保持不生效、框架 plugin 按完整坐标 groupId:artifactId 识别:每个框架的全部官方坐标组均被接受(Quarkus 的 io.quarkus 与平台再发布的 io.quarkus.platform,Micronaut 3.x 的 io.micronaut.build 与 4+ 的 io.micronaut.maven,Spring Boot 的 org.springframework.boot)、省略 的声明按 Maven 默认组 org.apache.maven.plugins 处理,其他组下的同名插件与含 ${...} 表达式的 group 不识别为框架,子模块的同名自定义 plugin 也不遮蔽父继承的正品;源码根与报告目录的提取同样按完整坐标匹配,build-helper 执行级源根与 Surefire/Failsafe 独立 reportsDirectory 均有官方组与自定义组对照;Java 运行入口缺少主类时 inspect 报 staleJavaEntrypoint 并提示重新生成,重新生成后由 JDT 答案恢复;多模块项目把服务工作目录改成模块目录后,服务仍留在列表中并以该目录启动;填写不存在的目录或 ${workspaceFolder} 等变量时,保存被拒绝并提示原因;在 Windows 确认重新扫描服务使用双箭头刷新图标,配置按语言生态、类型与实例呈现三栏布局,Java 下展示 Maven、Gradle 与 Spring Boot 等已识别类型,Node.js 下展示包管理器与框架;语言切换和外部选中能定位配置,空生态不显示,语言和配置栏不显示重复栏头,生态不显示配置数量;语言侧栏通过拖动阈值折叠和展开,收起与展开阈值不同以避免边界跳动,不显示专门折叠按钮;键盘可调整宽度及恢复展开,保存展开宽度与拖动折叠状态,拖动期间只更新局部宽度和紧凑标记,文字与图标对齐即时切换,结束后保存;语言行、类型行和实例行使用一致的紧凑行高,去掉列表上下留白和选中实例的圆角边距;窄面板自动缩成带提示的图标栏且扩大后恢复,右侧输出保留可用空间,窗口缩放或卸载时清理正在进行的拖动;所选配置的日志、运行状态、顶部停止按钮、清空输出与标准输入均指向其自身会话;保持选中 application 并后台启动服务后,顶部停止仍指向该应用的独立会话,不停止后台服务,左侧使用 SVG 箭头和类型图标,基础设施默认折叠,选择配置自动展开所在分组,运行、停止、编辑和完整名称提示可用;根据 Core 提供的 npm、Gradle 和 Python 元数据展示明确识别的框架图标,构建与测试任务保留工具图标,未知类型回退通用图标;同名自定义 provider 与已知框架分别分组且折叠状态独立,相同已知框架跨 Maven/Gradle provider 合组,项目与基础设施不合组;Java 生态分组使用 Java 文件类型图标,Java 启动类实例使用原始 Class 与 RunnableMark SVG 同画布叠加图标;Maven 启动的框架服务使用 MavenRunConfigurationType 对应的 Maven 图标,Make 外部任务分组使用任务文件夹、实例使用 Shell 配置图标,均保留原始明暗 SVG,运行或失败不改变类型图标,深浅主题均可辨认;macOS Run 缺少配置且可重新生成时,检查空状态文字与链接在深浅主题下按 IDEA 工具窗口样式显示、窄面板不截断,并确认“识别并生成”仍触发原有生成流程;应用语言设为英文而系统语言为中文时,Run 空状态及其他本面板文案仍应显示英文;Run 顶部标题字号、颜色、41pt 高度和底部分隔线应与 Terminal、Git Log 等工具窗口一致;有项目配置或 Current File 正在运行/已有退出结果时,运行、重启、重新扫描和软换行按钮显示在独立的 36pt 操作栏,检查 16pt IDEA 图标、22pt 共享按钮的悬停、按下、选中及禁用状态;操作栏只保留一个运行/重运行动作:首次启动显示 Run,当前配置已运行过后显示 Rerun;右侧固定停止按钮,未运行禁用、运行中启用,两者始终指向当前选中配置;操作栏不重复显示 Running 圆点及状态文字,重运行按钮复用顶部工具栏的绿色 rerun_stroke 图标;标题栏菜单中的清空输出与原操作一致,收起按钮沿用共享样式;仅有 Current File 的项目启动后也可从 Run 操作栏停止,结束后保留重运行入口;Run 内容区不额外插入 Java 项目准备状态行,准备进度和失败入口保留在工作台状态栏;Run 标题、操作栏、配置列表标题、控制台标签底边及两侧纵向分割线统一使用工具窗口边框颜色和 1pt 绘制,左侧图标栏不出现 5pt 实心粗线,栏内分组线与 Git Log 一致使用共享 divider 颜色、22pt 宽度、1pt 高度及上下 3pt 间距;操作按钮组之间使用专用 ActionToolbar.separator,详情编辑按钮的边框由共享按钮样式负责,拖动命中范围保持不变;配置树直接显示分组与数量,不显示 Application types 标题;默认隐藏勾选框,通过更多菜单或右键进入批量选择,退出清空选择;配置单行显示名称,类型及服务地址放到悬停提示,行内仅运行/停止,编辑/固定/打开服务保留在共享右键菜单,固定后显示标记;分组与配置使用共享树行高、缩进及焦点选择样式;Run 范围筛选以固定图标栏呈现,应用范围筛选及当前运行导航使用中性 ToolWindowRun 图标,实际运行操作继续使用绿色 Execute 图标;全部采用 IDEA groups、服务采用 server 图标,固定、应用及任务沿用对应 IDEA 原始资源;检查所有图标的 16pt 尺寸、明暗 SVG 自动切换、提示和筛选,右键勾选范围和配置行勾选共享同一批量选择;配置行使用 IDEA 固定、编辑、运行及停止图标,悬停与点击维持箭头光标;配置过期提示使用共享中性通知表面及警告图标,操作仍可重新识别;Run 标题栏仅显示 Run,移除重复配置名与右侧状态胶囊;Console 和 Configuration details 标签位于运行操作按钮组右侧,配置列表选中项表示当前配置,内容区直接显示日志或详情;配置详情使用共享 13pt 字体、原生 Grid 自适应标签列、可选择且可换行的路径及值,编辑配置按钮复用共享 LitheCommitDialogButtonStyle 与 IDEA EditSource 图标,检查深浅主题、窄列及点击跳转设置,服务链接保留原动作;两个标签使用共享 LitheToolWindowTabStyle,检查 13pt 文字、28pt 标签、原始明暗 SVG、悬停及内容区聚焦/失焦状态,保留标签切换;Run 与 Debug 均启用共享 OutputTextView 的 IDEA 渲染入口,运行日志采用一致的 13pt 字体、1.2 倍行高和 ANSI 配色,不给普通 INFO 日志整行染蓝,源码链接继续跳转;Copy 与 Jump to latest 为共享图标按钮,复制不带 ANSI,向上滚动后可恢复跟随;其他配置恢复状态保留各自提示。 Current File 运行一次后修改未保存并切换编辑标签,重运行应先保存原运行文件再构建启动计划;原文件保存失败时提示错误并中止重启,当前焦点文档不被误保存;调整 Run 面板高度,外层横向操作栏固定为 36pt,剩余高度交给配置树和控制台。 缓存目录不可访问时仍可发现兄弟目录和隐藏源码;可选子目录不可读时保留其旧配置并显示 incompleteProjectScan,不误报删除或新鲜;根目录不可读仍失败,完整重新生成后解除未知基线。 两端 Run 面板显示扫描不完整的共享中英文提示;恢复访问权限后重新识别。 Java 文件枚举达到 8,000 条上限后不再访问后续目录,返回顺序稳定;不可读兄弟目录不阻断结果,完整指纹扫描仍继续遍历。 保存 Maven/Gradle 构建任务后重启,确认用户任务和 Spring Boot 等自动入口同时恢复;再次加载不重复生成。 自动 Spring 服务必须同时具备 Maven 插件和同模块唯一的 JDT 已确认非测试 Spring 入口;继承插件的库模块、只有测试 main 的模块、只有子模块入口的父模块不能成为服务,多入口保留独立 Java 配置。验证传空 paths 不影响确认入口的模块归属、JDT 未就绪保留已确认结果、明确空结果移除自动服务,已有服务 ID 和用户配置保持兼容。 #1562:Windows 普通应用 A/B 独立会话和输出;先启动 A 再 B,停止及重跑 B 不影响 A,切换配置只显示所属输出,关闭项目清理两者。 IDE API 在 primary 忙碌时仍能启动独立应用,同一配置准备期间拒绝重复启动;保存期间会话可见且可停止,停止后不再启动进程,顶部停止不影响后台服务;旧 Debug 回调不能停止重跑后的应用。 缺少工具链时错误只归属目标应用,不覆盖 primary 或后台会话,也不把已运行的目标实例标记为退出。 macOS 通用 Provider 运行发现:无 Java 导入和私有框架属性时,按当前公开声明及目标标签生成 build.provider;名称/执行类型变化保留 ID 和参数、环境、目录覆盖;模型撤回、重排、恢复、停用及重新生成核对指纹,撤回目标不从旧检测层复活。默认项优先 service,不依赖框架名单。 通用 Provider 选项:团队/本机层覆盖运行时 argv、入口与 Build 选项,覆盖空字符串参数、引号、反斜杠、Unicode、false/空数组、显式清空、旧调用者省略、未知/超限字段和重存目录保持;真实 Maven 的保存任务使用按次 Profile,不改默认模型。 旧 Run v2 转换:验证稳定 ID、默认项、两层覆盖和目标唯一性;转换前预检两层,覆盖源文件变化、未改写输入变化、偏好变化、各写入阶段取消、外部修改与回滚冲突。工作区/模型/任务取消及新生成撤销写入资格;只读安装目录和越界链接不写入。真实 Maven 模型中的显式 validate 任务转换后准备,原 ID、Profile、环境和默认模型保持一致。 当前 Provider 归属投影改用 build={pluginID,buildSystemID,root,projectID},生成 Java 数据位于 extensions.java,不再产出 Maven 扩展或 Maven 工具绑定。共享 provider-project-ownership fixture 由 Rust、Swift bridge 与 Windows 映射回归共同读取,覆盖同名类、非标准源码根、角色、稳定身份和 cwd 覆盖;团队/本机伪造 build 身份被当前模型拥有的值替换。原生精确导入查找拒绝错误插件/构建声明/根/项目以及已撤销模型。旧 Windows 生成路径在未提供 Provider 归属时保留,删除前需先迁移真实消费者。 macOS Debug 缺少精确源码路径时,使用通用 Build 归属根加模块路径定位源码;嵌套实例与工作区根实例优先于残留 Maven 字段,当前实例没有源码时不得选择其他实例的同名类。原生源码选择回归不替代 Registry/DAP 与完整产品验收。", + "notes": " Windows普通Java文件的main入口在工作区搜索遗漏时,先保存并让JDT对当前文件确认,再生成命名运行配置;拒绝过期入口、准备失败和工作区切换结果。Java入口源码按容错UTF-8读取(非法字节替换为占位符),GBK项目不再因单个非UTF-8文件导致整个运行列表生成失败,Spring Boot注解等ASCII分类标记不受影响。 macOS 运行就绪要求匹配快照且工具链和配置选择加载完成,加载中的清单不可作为启动许可;受控工具链与快照测试验证完成前后状态,以及 Current File 不被迟到的默认配置覆盖。 远程工作区尚未接入运行配置发现:Core 对 runConfig.inspect 返回 NotSupported,仅 runConfig.selectJava 可用;共享扫描的远程契约与进度见 remote-development-workspace。 识别结果只保存在 Core 进程内存中,宿主不再写入工作区的 .lithe/run/generated.json、.lithe/toolchains/requirements.json、.lithe/project.json 和 .lithe/.gitignore;上次的 Java 入口缓存在应用缓存目录(run-configurations/),旧版本留下的 generated.json 只作只读的上次入口来源。本进程尚未识别的工作区在加载时自动识别(只读扫描、不启动进程),自动识别不提示入口数量;未写 defaultRunConfiguration 时由 Core 优先选声明为 service 的入口。识别前 JDK 选择从 pom.xml、.java-version 与 Maven Wrapper 推导最低版本。 保存过构建任务的工作区重启后仍按 Core requiresGeneration 标记自动识别,保留用户任务并恢复其他检测入口;同一进程完成识别后不重复生成。 macOS 的新运行发现使用独立 providerRuns 快照,Core 删除 mavenRunProvider 框架投影;Windows 仍使用旧生成路径。旧 Maven 用户配置和 reactor/Wrapper/工具需求迁移仍待完成。 新的通用选项保存在 extensions.build-run,复用 v10;完整旧格式迁移仍待完成。 已实现旧 Run v1/v2 与活动偏好的受限转换;自定义工具路径仍等待通用工具接入;v1/v2、可确认的活动偏好和空目标列表已经转换,模块不受支持的运行时选择交给提供者拒绝。转换器与退役偏好回滚记录按稳定 N+2 窗口删除。 两端 Provider 路径只接受完整选择器,Windows 数字端口旧调用拒绝冒充注册表调试。编辑器往返精确 argv 与通用选项,首次偏好文档保留 Java 配置,活动来源修改会回滚且保留外部新字节。 #1558 预集成后续删除归属投影的 mavenReactorPath:当前引用记录四元组并进入完整快照指纹,原生 Java 准备按四元组从同一启用模型取得导入输入。Java 的主类、模块和源码事实保留在 java 命名空间;已有 Java 用户层旧扩展继续按兼容规则读取,属于后续 Java 消费者迁移。首轮 Windows CI 41307483413 的前端通过,两条 Rust/JDT lane 均被同一 Unix 专用工具路径 fixture 阻断,已修正为原生路径并等待重跑;不能标记 Windows 验证通过。" } diff --git a/shared/platform-feature-matrix/features/run-java-test.json b/shared/platform-feature-matrix/features/run-java-test.json index 867e281ec3..120a95a179 100644 --- a/shared/platform-feature-matrix/features/run-java-test.json +++ b/shared/platform-feature-matrix/features/run-java-test.json @@ -23,9 +23,15 @@ "macos/Sources/Lithe/Views/Components/LitheCommitDialogStyle.swift", "macos/Sources/Lithe/Models/Java/ActiveTestTarget.swift", "macos/Tests/LitheTests/RunEntryPointTests.swift", - "rust/lithe-core/src/project/maven_test_reports.rs", "rust/lithe-core/src/tests/build_systems.rs", - "shared/fixtures/maven/testcase-durations-v1.json" + "shared/fixtures/maven/testcase-durations-v1.json", + "macos/Sources/LitheExecutionModule/Services/BuildLanguageTests.swift", + "macos/Tests/LitheTests/MacNativeBuildProviderIntegrationTests.swift", + "shared/contracts/plugins/v1/fixtures/build-provider-selectors.plugin.json", + "shared/contracts/plugins/v1/fixtures/build-test-durations.json", + "shared/fixtures/run-configuration/provider-prepared-java.json", + "shared/fixtures/build-provider/test-results-v1.json", + "shared/contracts/plugins/v1/fixtures/build-provider-preparation.plugin.json" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -41,6 +47,6 @@ "verificationStatus": "pending" }, "owner": "Run", - "verification": "运行 main、单测试和测试类,验证参数、输出、失败状态和终端策略。 Windows Run 运行输出使用 Geist Mono 显示 ====、==>、=>、!=、>=,确认禁用连字后逐字符显示且复制文本与原始输出一致;检查有输出与空态、ANSI 颜色、自动换行切换及横向滚动。 macOS Tests 顶部核对 41pt 无图标/计数标题、统一底线、运行名称标签的活动/非活动状态;运行中和完成后关闭标签均清理本次运行,保留发现列表并可重新运行。 核对第二行刷新、重跑、停止、清空的 IDEA 原始 16pt 明暗 SVG 与 22pt 按钮悬停范围。 Tests 详情无重复标题/文字 Run 按钮、工具栏右侧无运行状态;第二行首个 Run/Rerun 图标按选中提供方与精确作用域切换,运行中禁用,Stop 常驻;切换测试后运行当前选择,Java 单项 Debug 入口保留。 Tests 布局核对左侧报告状态树(通过/失败/跳过和组折叠)、顶部单行结果摘要、IDEA 控制台及右侧导航/换行/滚动/打印/清空动作;右侧 Details 复用共享收缩条与分隔容器,展开保留作用域、命令和失败定位。控制台左右工具栏均验证明暗主题、背景与原生文本身份。 第二行核对刷新紧挨垃圾桶左侧,两者继续执行发现刷新和输出清空。 Test 的 Performance 展开采集本次进程树 CPU 与 Resident Memory(含构建启动器和测试子进程),复用 Debug 图表及共享折叠条;收起/隐藏停止采集,重跑不混旧数据,结束保留历史,Details 继续显示原有作用域、命令与失败定位。 Stop 等图标禁用时使用 IDEA 灰度,只有可用停止动作保留红色;Performance 收起再展开后时间轴继续保持进程年龄。 Test 右侧 Performance/Details 共用一个可收缩区域,顶部两个标签切换;核对11pt常规标题和Islands文字色。Java选择本次启动器后代中的JUnit/Surefire/Gradle测试JVM,实时CPU/堆曲线,多个worker切换。开始/停止JFR录制、Show Results查看JDK热点方法和分配报告,结束/重跑/关闭正确取消及清理。非Java测试继续用明确标注的CPU/RSS;缺失worker或工具明确提示,不采样Maven/Gradle启动器。 macOS:Run、Debug、Test 共用 OutputTextView/LitheConsoleStyle 默认字体、行高和日志等级色;纯文本及 Maven/Spring ANSI 日志的 INFO 标签均为绿色、WARN 黄色、ERROR 红色,正文及其他 ANSI 片段保留;检查明暗主题、复制、折叠与源码链接。 Test Performance 录制和 Show Results 核对明暗主题、原始录制 SVG、禁用灰度不叠加透明度;Show Results 复用 Commit 的共享 LitheActionButtonStyle,核对28pt可见高度、3pt外留白、13pt常规字、透明/白色背景和独立禁用文字/边框,录制结束后可打开结果。 Show Results 结果窗口使用共享 LitheCenteredPopup 原生标题栏与对话框背景/底部关闭动作,验证 Esc、关闭按钮、系统关闭、重复打开、父窗口关闭及明暗主题,内容保持共享 OutputTextView 的复制和横纵滚动。 Test 结果摘要下方移除普通横线,核对3pt半圆端状态条:失败/错误红色优先于通过绿色;无结果、运行准备和全部跳过不画,重跑清空旧状态。深浅主题使用 ProgressBar 专用色,保持摘要文字和控制台工具栏不变。 调整面板宽度时状态条右端始终停在控制台右侧工具栏左边,不覆盖工具栏及其分隔线。 非Maven无结构化报告时核对成功、失败、取消和超时摘要与状态条;Gradle内置测试通过--no-daemon保持worker属于本次进程树,真实集成先启动兼容常驻daemon,再确认发现与采样的worker身份及独立清理。 Test左侧结果树:根/方法名13pt常规文字、共享Tree文字色;原始16pt明暗SVG覆盖未运行/通过/断言失败/执行错误/跳过和展开箭头,errors保留Error图标而非Failed;核对24pt行高、19pt缩进、12pt两侧留白、折叠、选中/失焦/hover及长名称省略,明暗主题不重染SVG。 结果摘要核对IDEA连续文案(失败、通过、忽略)、中英文单复数、13ptRegular、Label专用失败/警告色、灰色总数和16pt状态图标,覆盖通过但含忽略和全部忽略,不造耗时。结果树类/目录行双击名称展开或折叠,单击选择、箭头点击不变,叶子双击不折叠、不运行。 Test发现列表在首次运行、刷新和重跑等待结果时按工作区/文件/类/方法区分16pt图标,文件复用共享分类;明暗SVG不缺失,结果生成后状态图标优先,保留共享字体、行高、选中与双击展开。 Test未运行且无结构化结果时不显示摘要空态行,仅保留共享控制台的Run a test to see its output提示;运行中及无报告的成功、失败、取消、超时仍显示摘要。 Maven编译失败产生零计数报告、部分测试通过但整体非零退出时,保留Failed/Timed Out文字、错误图标和红色状态条;Gradle同时启用fork编译和测试时,Performance仅发现带Gradle Test Executor身份的worker,不采样通用Gradle Worker Daemon。 回归验证单测→workspace/file→顶栏Run/Debug仍保留新scope;共享testcase-durations-v1.json贯穿Rust报告解析、聚合、通用build.decodeTestResults与Swift呈现,覆盖不同方法耗时、重复调用、部分缺失、非法/负值/超大值及零值;实际Windows UI尚未验收。 回归普通配置仍运行时选择测试,顶栏Run只运行当前测试;单方法JDT启动标签为logsIn()时暂停/继续仍关联所选scope。合成结果树的渲染、选择与默认选中共用节点,报告类/方法可重跑;无语义源码关联的报告节点明确禁用Debug,不猜测源码路径。 macOS边界回归:A处于running/paused时切换测试B,实际注册Debug动作必须准备B;保持同一目标时才重启。混合Java/Node/Python项目只在当前运行的provider组展示报告结果、状态和耗时;关闭或清空带真实方法结果的运行后恢复工作区、文件和未执行测试入口,同时保留编辑器标记历史,并可重新选择文件/工作区运行。", - "notes": " 顶栏保留并重跑完整测试scope,工作区/文件/类方法切换不会回退旧单测。方法耗时由Core读取Surefire XML的time并通过可选durationMillis传递,参数化调用累加,任一缺失/非法/溢出则隐藏;控制台整次摘要不作为方法耗时。通用Maven构建结果保留同一字段,Windows契约允许可选值。 顶栏测试动作优先于底层普通配置重启,普通配置运行样式不会串到测试目标。合成结果节点进入统一选择集合;保留JDT发现节点的源码关联,无源码报告节点禁用Debug。 当前报告与provider绑定,Tests列表不使用编辑器历史testOutcomes回填;顶栏Debug先校验当前测试目标再决定重启。" + "verification": "阶段 D 协议 9 增量通过 350 项 macOS、1368 项 Core/集成计时、当前 SDK E2E 及 Maven 3.9.16/JDK 26 组合;完整发行 UI/JDT、正式 SDK/插件锁定和 Windows 实机仍 pending。运行 main、单测试和测试类,验证参数、输出、失败状态和终端策略。 Windows Run 运行输出使用 Geist Mono 显示 ====、==>、=>、!=、>=,确认禁用连字后逐字符显示且复制文本与原始输出一致;检查有输出与空态、ANSI 颜色、自动换行切换及横向滚动。 macOS Tests 顶部核对 41pt 无图标/计数标题、统一底线、运行名称标签的活动/非活动状态;运行中和完成后关闭标签均清理本次运行,保留发现列表并可重新运行。 核对第二行刷新、重跑、停止、清空的 IDEA 原始 16pt 明暗 SVG 与 22pt 按钮悬停范围。 Tests 详情无重复标题/文字 Run 按钮、工具栏右侧无运行状态;第二行首个 Run/Rerun 图标按选中提供方与精确作用域切换,运行中禁用,Stop 常驻;切换测试后运行当前选择,Java 单项 Debug 入口保留。 Tests 布局核对左侧报告状态树(通过/失败/跳过和组折叠)、顶部单行结果摘要、IDEA 控制台及右侧导航/换行/滚动/打印/清空动作;右侧 Details 复用共享收缩条与分隔容器,展开保留作用域、命令和失败定位。控制台左右工具栏均验证明暗主题、背景与原生文本身份。 第二行核对刷新紧挨垃圾桶左侧,两者继续执行发现刷新和输出清空。 Test 的 Performance 展开采集本次进程树 CPU 与 Resident Memory(含构建启动器和测试子进程),复用 Debug 图表及共享折叠条;收起/隐藏停止采集,重跑不混旧数据,结束保留历史,Details 继续显示原有作用域、命令与失败定位。 Stop 等图标禁用时使用 IDEA 灰度,只有可用停止动作保留红色;Performance 收起再展开后时间轴继续保持进程年龄。 Test 右侧 Performance/Details 共用一个可收缩区域,顶部两个标签切换;核对11pt常规标题和Islands文字色。Java选择本次启动器后代中的JUnit/Surefire/Gradle测试JVM,实时CPU/堆曲线,多个worker切换。开始/停止JFR录制、Show Results查看JDK热点方法和分配报告,结束/重跑/关闭正确取消及清理。非Java测试继续用明确标注的CPU/RSS;缺失worker或工具明确提示,不采样Maven/Gradle启动器。 macOS:Run、Debug、Test 共用 OutputTextView/LitheConsoleStyle 默认字体、行高和日志等级色;纯文本及 Maven/Spring ANSI 日志的 INFO 标签均为绿色、WARN 黄色、ERROR 红色,正文及其他 ANSI 片段保留;检查明暗主题、复制、折叠与源码链接。 Test Performance 录制和 Show Results 核对明暗主题、原始录制 SVG、禁用灰度不叠加透明度;Show Results 复用 Commit 的共享 LitheActionButtonStyle,核对28pt可见高度、3pt外留白、13pt常规字、透明/白色背景和独立禁用文字/边框,录制结束后可打开结果。 Show Results 结果窗口使用共享 LitheCenteredPopup 原生标题栏与对话框背景/底部关闭动作,验证 Esc、关闭按钮、系统关闭、重复打开、父窗口关闭及明暗主题,内容保持共享 OutputTextView 的复制和横纵滚动。 Test 结果摘要下方移除普通横线,核对3pt半圆端状态条:失败/错误红色优先于通过绿色;无结果、运行准备和全部跳过不画,重跑清空旧状态。深浅主题使用 ProgressBar 专用色,保持摘要文字和控制台工具栏不变。 调整面板宽度时状态条右端始终停在控制台右侧工具栏左边,不覆盖工具栏及其分隔线。 非Maven无结构化报告时核对成功、失败、取消和超时摘要与状态条;Gradle内置测试通过--no-daemon保持worker属于本次进程树,真实集成先启动兼容常驻daemon,再确认发现与采样的worker身份及独立清理。 Test左侧结果树:根/方法名13pt常规文字、共享Tree文字色;原始16pt明暗SVG覆盖未运行/通过/断言失败/执行错误/跳过和展开箭头,errors保留Error图标而非Failed;核对24pt行高、19pt缩进、12pt两侧留白、折叠、选中/失焦/hover及长名称省略,明暗主题不重染SVG。 结果摘要核对IDEA连续文案(失败、通过、忽略)、中英文单复数、13ptRegular、Label专用失败/警告色、灰色总数和16pt状态图标,覆盖通过但含忽略和全部忽略,不造耗时。结果树类/目录行双击名称展开或折叠,单击选择、箭头点击不变,叶子双击不折叠、不运行。 Test发现列表在首次运行、刷新和重跑等待结果时按工作区/文件/类/方法区分16pt图标,文件复用共享分类;明暗SVG不缺失,结果生成后状态图标优先,保留共享字体、行高、选中与双击展开。 Test未运行且无结构化结果时不显示摘要空态行,仅保留共享控制台的Run a test to see its output提示;运行中及无报告的成功、失败、取消、超时仍显示摘要。 Maven编译失败产生零计数报告、部分测试通过但整体非零退出时,保留Failed/Timed Out文字、错误图标和红色状态条;Gradle同时启用fork编译和测试时,Performance仅发现带Gradle Test Executor身份的worker,不采样通用Gradle Worker Daemon。 回归验证单测→workspace/file→顶栏Run/Debug仍保留新scope;共享testcase-durations-v1.json贯穿Rust报告解析、聚合、通用build.decodeTestResults与Swift呈现,覆盖不同方法耗时、重复调用、部分缺失、非法/负值/超大值及零值;实际Windows UI尚未验收。 回归普通配置仍运行时选择测试,顶栏Run只运行当前测试;单方法JDT启动标签为logsIn()时暂停/继续仍关联所选scope。合成结果树的渲染、选择与默认选中共用节点,报告类/方法可重跑;无语义源码关联的报告节点明确禁用Debug,不猜测源码路径。 macOS边界回归:A处于running/paused时切换测试B,实际注册Debug动作必须准备B;保持同一目标时才重启。混合Java/Node/Python项目只在当前运行的provider组展示报告结果、状态和耗时;关闭或清空带真实方法结果的运行后恢复工作区、文件和未执行测试入口,同时保留编辑器标记历史,并可重新选择文件/工作区运行。 Review 修正:macOS 结果服务、Tests 呈现及 Java 行标记直接消费 BuildTestResults;保留上游摘要与调用次数,耗时只读规范报告,名称不解析括号或参数后缀。非 Maven fixture 覆盖来源定位、独立重跑引用、清空引用及多报告汇总;旧 Windows className/method 字段与通用 suite/case 的行标记 fixture 必须一致。 准备任务使用 buildSystems 4 的 preparation.purpose/sourceRoles,由插件声明内部任务 ID,宿主按当前模型匹配唯一任务;覆盖缺失用途、缺失角色、角色冲突、未知源码根、临时 Profile、取消及工作区变化。真实 Maven 自定义过滤资源经生产 macOS 会话与 Core 执行,插件包清单不变;Windows 产品准入仍关闭。 通用报告按语言提供者隔离编辑器历史,混合结果保留精确源码节点与全部无源码报告项,不因部分 JDT 匹配而丢弃其余用例;源码执行标识原样保留。", + "notes": "Java 启动资源准备新增公开 Provider 任务约定,等待保存、当前模型和成功状态后才交给 JDT;旧 Core 资源规划仅为未携带当前回执的 Windows 调用保留。单次覆盖不写 Build 默认值,失败/取消/ABA/工作区切换必须拒绝启动;本机实际资源过滤和原生取消回归通过;整合 preview 后 344 项计时通过,后续最低运行时新增 CI、完整 UI/JDT 和 Windows 实机仍待验证。 协议 9 测试链路已删除 macOS Maven CLI/报告 adapter,JDT 标识原样传给后端;规范报告按项目隔离并声明覆盖范围,整类重跑清除旧方法、单方法保留兄弟项,参数化调用的完整安全耗时相加。350 项原生回归和真实 Maven Provider 通过;最低运行时新增 CI、完整 UI/JDT、正式发行和 Windows 保持 pending。 阶段 D:macOS Maven 测试已走协议 9,JDT 原样给出语义标识,Build Provider 按当前工程/选项执行并返回规范报告。旧宿主 Maven 测试命令、报告扫描和 RustJavaMavenOperations 的 Maven 方法已删除。报告重跑保留后端和工程身份;同名模块结果/耗时隔离,PID 来自宿主受管 Provider 会话。保存、重跑、取消、过期输出、模型/配置 ABA 与测试 Debug 准备均保留身份。 顶栏保留并重跑完整测试scope,工作区/文件/类方法切换不会回退旧单测。方法耗时由Core读取Surefire XML的time并通过可选durationMillis传递,参数化调用累加,任一缺失/非法/溢出则隐藏;控制台整次摘要不作为方法耗时。通用Maven构建结果保留同一字段,Windows契约允许可选值。 顶栏测试动作优先于底层普通配置重启,普通配置运行样式不会串到测试目标。合成结果节点进入统一选择集合;保留JDT发现节点的源码关联,无源码报告节点禁用Debug。 当前报告与provider绑定,Tests列表不使用编辑器历史testOutcomes回填;顶栏Debug先校验当前测试目标再决定重启。" } diff --git a/shared/platform-feature-matrix/features/run-output-snapshot.json b/shared/platform-feature-matrix/features/run-output-snapshot.json index 8ad765cfbb..cc987618fe 100644 --- a/shared/platform-feature-matrix/features/run-output-snapshot.json +++ b/shared/platform-feature-matrix/features/run-output-snapshot.json @@ -22,5 +22,6 @@ ] }, "owner": "Run", - "verification": "Windows:运行持续输出的服务,在输出区右键选择「快照当前输出」,确认编辑器区出现以捕获时间命名的只读标签,内容与当时的缓冲一致;继续产生输出并使其超过环形上限后,快照内容不变且可搜索、复制;连续创建多个快照各自独立成标签;把 maxOpenTabs 调小并打开大量其他文件,快照标签不被自动淘汰,仅用户手动关闭后消失;无输出时右键菜单不显示快照项。macOS:待实现后按同等场景验证。" + "verification": "Windows:运行持续输出的服务,在输出区右键选择「快照当前输出」,确认编辑器区出现以捕获时间命名的只读标签,内容与当时的缓冲一致;继续产生输出并使其超过环形上限后,快照内容不变且可搜索、复制;连续创建多个快照各自独立成标签;把 maxOpenTabs 调小并打开大量其他文件,快照标签不被自动淘汰,仅用户手动关闭后消失;无输出时右键菜单不显示快照项。macOS:待实现后按同等场景验证。", + "notes": " Windows Native Run 输出与退出事件携带执行 ID,只有匹配当前执行才追加输出或完成会话;同步重启后不能被旧退出解除工作区绑定。Provider 准备进度使用有界输出与单调偏移追加现有面板,不重复复制模型/报告。前端回归通过,Windows 原生验收仍 pending。" } diff --git a/shared/platform-feature-matrix/features/run-processes-end-with-ide.json b/shared/platform-feature-matrix/features/run-processes-end-with-ide.json index 45ab2339d3..2f348a3038 100644 --- a/shared/platform-feature-matrix/features/run-processes-end-with-ide.json +++ b/shared/platform-feature-matrix/features/run-processes-end-with-ide.json @@ -44,5 +44,5 @@ }, "owner": "Run", "verification": "运行一个长驻服务(如 Spring Boot)、一个 setsid 脱离的后台进程和一个忽略终止信号的程序,分别通过菜单退出、更新重启和对主进程 kill -9 关闭 IDE,确认除用户选择保留的项目外,带本实例 LITHE_PROCESS_SCOPE 的进程全部退出、端口已释放;主进程和监护进程同时被结束后再次启动,确认残留进程出现在启动清扫确认中。 Windows Run 原生测试按父 PID 和 ping.exe 名称唯一识别夹具后代,覆盖快照乱序、大小写、缺失和歧义;停止前持有该后代进程句柄,以两秒退出信号确认根进程和后代结束,以零等待确认另一窗口仍在运行;cmd 禁用 AutoRun,首字节就绪等待有界,夹具在断言失败时也清理所属进程树。", - "notes": "macOS 用 LITHE_PROCESS_SCOPE 标记本实例启动的进程,退出时做最终清扫;崩溃由随应用启动的监护进程兜底(Agent 任务和后台服务留到下次启动再确认)。分类规则在 Rust Core(processLifecycle.inventory),Windows 可直接复用。两端在关闭项目或窗口、退出、安装更新前按 Rust Core 的 runServicesOnExit 规则列出运行服务并询问(可设为直接停止)。Windows 的运行会话、调试适配器和语言服务器放在 Job Object 中,停止和 IDE 崩溃时都按整组结束;菜单退出走窗口关闭守卫,更新安装前先停止运行服务。Windows 终端和 Agent 进程树尚未放入 Job Object,也没有监护进程和启动清扫,IDE 崩溃时它们仍可能残留。" + "notes": "macOS 用 LITHE_PROCESS_SCOPE 标记本实例启动的进程,退出时做最终清扫;崩溃由随应用启动的监护进程兜底(Agent 任务和后台服务留到下次启动再确认)。分类规则在 Rust Core(processLifecycle.inventory),Windows 可直接复用。两端在关闭项目或窗口、退出、安装更新前按 Rust Core 的 runServicesOnExit 规则列出运行服务并询问(可设为直接停止)。Windows 的运行会话、调试适配器和语言服务器放在 Job Object 中,停止和 IDE 崩溃时都按整组结束;菜单退出走窗口关闭守卫,更新安装前先停止运行服务。Windows 终端和 Agent 进程树尚未放入 Job Object,也没有监护进程和启动清扫,IDE 崩溃时它们仍可能残留。 Windows 已保存 Provider Run 在准备前登记到现有插件/工作区进程所有者;停止等待迟到原生启动确认后按执行 ID 清理,失败保留所有者供重试。批量清理排空全部会话,旧清理不能删除替换执行。仅受控回归和跨平台 Maven 工作流通过,Windows 原生生命周期验收仍 pending。 Windows Provider 的临时来源租约与启动确认一起由 Run 所有者持有;早到退出不能通过通用事件回调提前释放。来源清理失败停止对应 Native 执行并保留所有者,失败在同一次 Stop 内不自动重试,下一次 Stop/重启/关闭可明确重试;成功释放准备回调,避免长期服务保留临时模型。仅受控回归,Windows 实机 pending。 Windows 目标树按声明和当前工程列出运行选择器,参数确认后以 Core 稳定 ID 保存并接入现有 Run;原生复用文档 CAS/元数据/原子替换及读回,拒绝包路径和工作区外别名。保存阶段可 Stop/关闭撤销,忽略取消的迟到保存不能启动;首次打开 Run 面板等待目标交接,不取消该启动。大目标列表虚拟化,完整 Debug/JDT 与 Windows 实机仍待验收,准入保持关闭。" } diff --git a/shared/platform-feature-matrix/features/run-save-toolchain.json b/shared/platform-feature-matrix/features/run-save-toolchain.json index a911ac5c11..a727c97702 100644 --- a/shared/platform-feature-matrix/features/run-save-toolchain.json +++ b/shared/platform-feature-matrix/features/run-save-toolchain.json @@ -14,7 +14,10 @@ "macos/Tests/LitheLanguageIntelligenceModuleTests/LanguageIntelligenceModuleTests.swift", "rust/lithe-core/src/lsp/interface/engine_real_jdt_tests.rs", "rust/lithe-core/src/execution/environment.rs", - "shared/fixtures/execution/launch-environment-v1.json" + "shared/fixtures/execution/launch-environment-v1.json", + "macos/Sources/Lithe/Views/Run/ProviderRunOptionsEditor.swift", + "macos/Sources/LitheCoreContracts/Execution/ExecutionContracts.swift", + "macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -36,5 +39,6 @@ "verificationStatus": "pending" }, "owner": "Run", - "verification": "修改未保存文件后运行,验证同步、JDK/Maven 选择和版本不匹配诊断;Maven Java Main 与 Spring Boot 直启在 JDT 构建后、JVM 启动前执行 Maven 原生资源处理,覆盖模块依赖、Profile、settings、测试资源和自定义 cwd;资源处理失败或超过十分钟期限不得启动 JVM,超时提示“Pre-launch step timed out after 600 seconds.”。 Issue #379:分别从 Run 面板和编辑器启动 Maven 主源码及测试源码中的 main(含默认包 MainTests、自定义源码/测试输出目录、test scope 依赖与多模块),确认 JDT 自动选择 classpath/module-path,测试入口包含测试输出和依赖,主源码入口排除测试输出;Run、Debug 与服务更新路径校验采用相同的自动 scope。 运行配置与工具链完成解析后才发布快照就绪状态;重叠加载不得保存临时 Current File 选择,已完成加载后的用户选择须保留。 打开运行配置编辑对话框,确认标题栏显示运行时图标、类型与生效来源胶囊,保存范围和 Maven 测试项使用共享分段控件,项目环境入口整行可点击;保存范围、继承/覆盖说明和环境变量格式默认收进 ⓘ,路径输入、环境变量编辑器以及保存/取消按钮与设置页样式一致;窄窗口下内容仍可滚动且底部按钮可见。 项目 JDK 为 8、Maven JDK 为 21 时,Maven goal 和资源前置步骤使用 21,Java 主程序使用 8;显式 JAVA_HOME 优先于选择,Windows 不同大小写环境键不产生冲突。 Windows 并发保存运行配置、生成配置及 Maven 配置时,读取快照、写入和回滚由同一事务锁保护;失败任务不能恢复掉另一个已完成任务的新值。" + "verification": "修改未保存文件后运行,验证同步、JDK/Maven 选择和版本不匹配诊断;Maven Java Main 与 Spring Boot 直启在 JDT 构建后、JVM 启动前执行 Maven 原生资源处理,覆盖模块依赖、Profile、settings、测试资源和自定义 cwd;资源处理失败或超过十分钟期限不得启动 JVM,超时提示“Pre-launch step timed out after 600 seconds.”。 Issue #379:分别从 Run 面板和编辑器启动 Maven 主源码及测试源码中的 main(含默认包 MainTests、自定义源码/测试输出目录、test scope 依赖与多模块),确认 JDT 自动选择 classpath/module-path,测试入口包含测试输出和依赖,主源码入口排除测试输出;Run、Debug 与服务更新路径校验采用相同的自动 scope。 运行配置与工具链完成解析后才发布快照就绪状态;重叠加载不得保存临时 Current File 选择,已完成加载后的用户选择须保留。 打开运行配置编辑对话框,确认标题栏显示运行时图标、类型与生效来源胶囊,保存范围和 Maven 测试项使用共享分段控件,项目环境入口整行可点击;保存范围、继承/覆盖说明和环境变量格式默认收进 ⓘ,路径输入、环境变量编辑器以及保存/取消按钮与设置页样式一致;窄窗口下内容仍可滚动且底部按钮可见。 项目 JDK 为 8、Maven JDK 为 21 时,Maven goal 和资源前置步骤使用 21,Java 主程序使用 8;显式 JAVA_HOME 优先于选择,Windows 不同大小写环境键不产生冲突。 Windows 并发保存运行配置、生成配置及 Maven 配置时,读取快照、写入和回滚由同一事务锁保护;失败任务不能恢复掉另一个已完成任务的新值。 Provider 运行项以逐行数组编辑运行时参数及不透明入口,验证空值、空格、引号、反斜杠、Unicode 和参数内换行在保存/重开后保持;修改入口或参数不得丢失按次选项与工具选择,开页/取消不写入。macOS Reset 明确清空继承的按 Run 工具,Windows 保存通过原对话框的显式保存动作;1024 项数组只挂载视口行。该增量的原生存储/组件回归不代替完整 macOS/Windows 产品验收。 Reset 从已解析的团队/本机合并草稿清空所有按 Run 工具;重开仍保持清空,团队文件字节不得改变。macOS 参数按钮复用已打包 IDEA 图标。", + "notes": " 同一本机 Run 文档开始支持通用 consumer preferences;省略字段的旧保存保持其原值,显式空 v1 文档重置,两端运行前重新解析继承。现有设置页与旧 Maven 默认的一次性转换尚未完成。" }