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 e1d0460845..86c2d678e3 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,6 +26,8 @@ macOS 设置侧栏现在只有七个一级入口:外观与行为、插件、 插件如果带来了新的设置,默认放进“插件”页;只有它新增的是上表中某个分组本来就该有的能力(例如新的版本控制系统)时,才放进那个分组。 +“运行环境”和“环境来源”是构建分组的工具链通用框架页,位于旧项目页和运行配置前;工具名称、提供方及使用方来自贡献快照,不是给某个插件开的专用设置页。提供方自己的配置继续从插件页注入。当前页面只读,旧项目 JDK/Maven 页面保留到实际消费者迁移后再删除,完整进度见[工具链提案](../../proposed/architecture/2026-10-31-plugin-injected-toolchains.md)。 + AI 分组内按“AI 服务商 → Agent 管理 → 指令 → 技能 → Hooks → MCP 配置 → AI 与提交”排列:先放基础配置,再放行为规则和扩展能力,最后放具体应用功能。顺序仍只在 `SettingsGroup.categories` 中声明,搜索过滤后的侧栏也沿用这一顺序。 ### 二级折叠:AI › 资源库 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 new file mode 100644 index 0000000000..b2e10f7b4c --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-10-31-plugin-injected-toolchains.md @@ -0,0 +1,210 @@ +# Agent 笔记:由插件定义工具的工具链发现与选择 + +状态:提议中 + +## 先说结论 + +从 Dock 启动时找不到版本管理器安装的运行时、项目要求的版本未生效,不能靠宿主逐个补目录和版本文件解析器解决。本方案把工具定义、安装事实提供方和使用方分开:插件说明工具是什么,版本管理器回答当前事实,宿主只提供统一选择、查询和环境展示框架;Java、Maven 等定义全部由插件贡献,不保留宿主内置定义表或兼容别名。当前已实现 Core 基础切片及 macOS 只读设置页;macOS 设置页已按声明式路线真实扫描本机安装、运行定义的版本探测并交 Core 选择,并通过插件后端查询提供方、监听其输入后整份重查;每行哪一层生效由 Core `toolchains.resolve` 用页面展示的同一份事实决定,Swift 不自己排优先级。保存固定值、选择版本管理器等写入操作尚未实现,Windows 查询和运行时消费者也未接入。完整方案仍在本提案中跟踪,不能把页面出现解释成产品已能使用 mise 或已经完成工具迁移。 + +## 问题 + +[PR #1581](https://github.com/1lck/Lithe-IDEA/pull/1581) 最初用 `toolchainSources` 声明安装目录、版本文件格式和选择规则。它能发现 mise 的安装,但 Core 仍需要理解 `miseToml`、`toolVersions`、`lts`、`prefix:` 等私有语义;接入 nvm 或 SDKMAN 又要修改 Core。这既复制上游规则,也无法保证新工具只改插件就能接入。 + +原方案还通过文件名把候选插入不同的运行时选择链。版本管理器贡献“发现结果”,却可能改变任意同名命令的启动目标;要求的版本未安装时静默回落,用户仍看不见选错版本的原因。 + +设计依据以 [2026-10-31 的改写计划](https://github.com/1lck/Lithe-IDEA/pull/1581#issuecomment-6541366112) 和 [交互原型](https://gist.github.com/xiaoyumuxi/2ae889a6592599b13998bc811c647942) 为准;原型中的工具名称和演示数据不是宿主预置表。通用边界遵循[独立插件能力与领域契约](../../implemented/architecture/2026-10-31-plugin-capability-and-domain-boundaries.md),候选解析复用 [#1420](https://github.com/1lck/Lithe-IDEA/pull/1420) 已落地的[可执行文件候选规则](../../implemented/architecture/2026-10-21-plugin-settings-surfaces.md),不另写第二套顺序算法。 + +## 提案 + +### 1. 三类贡献,各自保留真实来源 + +这里的工具链是“某个工具的一份可用安装及其选择来源”,不是 Java 的别名。提供方(Provider)是回答已安装版本、项目要求和全局默认的插件;使用方是确实需要该工具的语言服务器、构建任务或运行配置。 + +| 角色 | 负责 | 不负责 | +| --- | --- | --- | +| 工具定义插件 | 工具 ID、名称和图标、可执行布局、版本探测、发现位置、包装脚本、环境导出及启动模板 | 在宿主里注册私有解析器,或借显示名称覆盖别人的工具 | +| 提供方插件 | 调用上游或读取自己支持的配置,返回安装;声明了 `watchFiles` 时再返回带来源的要求和监听对象 | 修改宿主选择顺序、执行下载或自动信任项目配置 | +| 使用方插件 | 显式引用工具 ID,声明最低版本、工作目录和切换后的生效策略 | 按文件名猜工具,或在宿主选定后私自再找另一份安装 | +| Rust Core | 校验声明和结果、确定性候选排序、四层选择、带原因的解析轨迹 | 解释管理器私有版本表达式,启动进程或直接拥有平台文件系统能力 | +| 平台宿主 | 安装身份验证、只读文件探测、进程与查询生命周期、存储、文件观察、通用界面 | 内置工具定义、硬编码 SDKMAN 等位置、解析 `mise.toml` 或 `.sdkmanrc` | + +安装从两条路线进入,同一个工具可以只用一条,也可以两条都用;两条都没有时,工具仍可以靠固定值、项目文件、全局默认和登录 Shell 的命令解析: + +| 路线 | 由谁声明 | 适合 | 谁执行 | +| --- | --- | --- | --- | +| 声明式扫描 | 工具定义的 `discovery`、`homeLayouts`、`homeVariable`、`commands` 和 `version` | 安装位置有固定目录规律:用户目录、环境变量或系统绝对目录下的单层 glob,可选的 home 后缀,再用正则从探测输出取版本 | 宿主按 `discoveryPlan` 展开,结果来源记为 `host:system`;不运行插件代码 | +| 提供方查询 | `toolchainProviders` 与 `toolchains/query` | 原语表达不了的位置:注册表、plist、管理器数据库、命令输出、管理器私有版本文件 | 插件自带的后端 | + +声明式原语是冻结的:扫描根只有 `home`、`environment`、`absolute` 三种,匹配只有单层 `*`。遇到新的安装形态不加新的根类型,也不在宿主写解析器,而是改用提供方。定义工具的插件可以同时为自己的工具声明提供方;不声明 `watchFiles` 的提供方只报告安装,它返回的任何项目或全局要求都会被拒绝,因为没有声明过的读取方式能锚定来源文件。 + +两条路线在自动层合成一个候选集。同一个规范化 home 只算一份安装:来源标签优先记提供方,其次才是宿主扫描,多个提供方之间取限定 ID 较小者;标签只用于展示,不参与在不同 home 之间做选择。不同 home 的最高版本相同时仍是自动层冲突,冲突里列出所有路线。`discoveryPlan` 对每个工具同时给出扫描规则和负责它的提供方,宿主据此扫描和查询,不再自己判断哪些插件和某个工具有关。 + +宿主扫描没有自己的工具表;没有定义时不扫描、不导出变量、不生成工具行。 + +工具链是独立领域,Core 代码放在 `rust/lithe-core/src/toolchain/`,不能因为事实由插件贡献就把解析、固定和选择模型放进 `plugins/`。插件清单模块只负责贡献准入,调用工具链领域校验;公开契约仍在插件契约目录中描述贡献接口,不改变工具链本身的含义。 + +正确做法:Java 支持插件贡献工具定义,mise 插件调用 mise 自己的机器可读接口回答选中了哪份安装,宿主展示来源并执行通用校验。不要这样做:把 `miseToml` 改名成 `tomlTools` 后继续在 Core 解释它,或用 `if tool == "java"` 把返回目录当作特殊的 JDK home。 + +### 2. ID 和执行权限不能靠声明自证 + +工具 ID 是显式关联,不靠命令名关联。社区插件定义自己的工具时使用 `:`;不带前缀的短名只允许经宿主验证的官方签名插件定义。这里的“官方”来自安装验证后的发布者身份,不能来自清单中的自称字段、插件 ID 前缀或显示名称。重复 ID 拒绝后来的注册者,不允许覆盖已有定义。 + +社区提供方可以回答 `java` 等短名,但结果只对它已声明、请求已授权、且存在已启用定义的工具生效。提供方不能通过返回 `git` 的同名文件取得宿主命令替换权。安装审查须列出被影响的工具、运行的后端和探测参数;`toolchains.provide` 的说明必须表达“影响工具选择”,不能只说读取目录。 + +定义中的环境导出与路径模板由 Core 做通用限制:不能覆盖 `PATH` 或动态加载器变量;路径追加只能落在确认过的 home 内;版本探测只接受受限参数,启动按参数数组展开,不经 shell 拼接。正则只负责从探测输出提取可比较版本,不能把管理器的 `lts`、厂商别名或配置优先级重新交给宿主解释。 + +插件未启用时,提供方仍可能观察到项目要求该工具。该要求可以生成仅用于展示的“定义插件未启用”诊断,但不能进入候选、导出环境或启动程序。提示中的插件身份与“在插件页启用”操作来自可信的已安装注册表;没有可确认的定义插件时只报告缺失定义,不根据提供方任意字符串构造安装或启用目标。 + +### 3. 四层选择,共用一个结果 + +解析器(resolver,按候选顺序寻找可用程序的机制)扩展现有 `executables` 数组及 `skip`、`resolved`、`terminalFailure` 三种结果。工具可执行布局与候选来源分别表达,不能让同一个字段有时表示 `bin/java`,有时表示解析规则。Core 产出的同一快照用于环境页、使用方和实际进程启动,不能让各入口再选择一次。 + +| 层 | 通用规则 | 不可用时 | +| --- | --- | --- | +| 固定 | 从当前目录向上找最近的祖先 `pins`,只接受用户明确固定的安装 | 非空固定值无效返回终止失败(`terminalFailure`),不回落 | +| 项目文件 | 最近目录优先;同目录内包装脚本(Wrapper)优先于项目环境,项目环境优先于版本声明;同文件原生读取优先于兼容读取 | 未安装的要求返回跳过(`skip`)并携带结构化原因,继续下一层且保留“未满足”状态 | +| 全局 | Lithe 全局默认优先,其次是提供方全局设置,最后是登录 Shell 的环境 | 提供方全局要求冲突时让用户选择;选择前使用登录 Shell,不按插件 ID 猜测 | +| 自动 | 在已通过校验且满足最低版本的安装中选择最新版本 | 没有候选时显示真实缺失原因,不制造默认安装 | + +候选的稳定排序和同集合插入约束沿用 #1420;不能让使用方跨集合改变别的工具的优先级。不同原生提供方对同一项目位置给出无法比较的要求时,应保留冲突并按契约处理,不把稳定展示排序误当成业务优先级。 + +管理器的 `request` 是不透明的来源信息,例如 `lts` 由管理器解释;Core 只比较已探测出的规范版本。手动添加只提供可固定的安装,不参与自动层。语言服务器、构建模型和运行配置分别声明 `restart`、`resync` 或 `nextRun`,界面据此提示;不能一看到文件变化就强制重启所有运行中的用户任务。 + +Core 基础切片先独立验证固定值,因此低层提供方超时不能撤销一个有效固定值。没有固定值时,提供方超时或不可用保留诊断并允许下一层继续;身份、代次、安全校验和主动取消失败不能伪装成“未安装”。最近的项目要求即使未满足也屏蔽父目录要求,不能以“找到了能运行的版本”为由改变来源优先级。 + +### 4. 目录固定与手动添加的写入边界 + +环境页按目录展示结果,子目录继承最近祖先的固定值。固定写到 `.lithe/toolchains/local.json` 的 `pins`;现有 `toolchains` 字段保留,草案不授权提前迁移旧数据。目录标识使用规范的工作区相对路径,根为 `.`,不能用绝对路径或 `..` 把固定作用域扩展到工作区外。 + +`local.json` 保存本机安装路径,因此首次明确保存时,平台存储适配器还要确保 `.lithe/.gitignore` 包含 `/toolchains/local.json`,保留已有内容。打开项目或设置页只读,不创建这两个文件;未修改、取消、失焦和切页不写入。保存必须检查读取基线,受保护地原子替换并回读;任一步失败都报告真实结果、保留草稿和既有数据,不能把部分写入报告成全部成功。 + +“添加…”由用户选择目录,再按该工具的布局和版本探测校验。手动安装记录仅存在平台应用存储;选择固定后才把对应安装引用写进本地项目固定文件。它不是自动发现结果,也不写安装包或项目的便携配置。 + +### 5. 查询由宿主管,版本语义由插件管 + +宿主使用已有进程、能力协商和 JSON-RPC(带请求 ID 的进程间消息)机制请求 `toolchains/query`,不另建每个工具一套通信框架。每次查询捕获 `operationID`、工作区、已启用定义与提供方快照及代次;关闭或切换工作区、停用插件、输入变化时先使旧代次失效,再取消请求和回收本次拥有的资源。超时和停止都有本地期限,晚到结果不能发布到新工作区或新插件集合。 + +`watchFiles` 声明文件名或有界 glob(有限目录匹配规则),返回结果也可以列出实际读取的输入。必须观察尚不存在的匹配项及其父目录,否则新建版本文件不会触发刷新。任何一个相关文件创建、删除、改名或修改,都撤销整份查询快照并重新查询全部已启用提供方,不把一部分旧安装和一部分新要求拼在一起;短时间事件可以合并,但不能丢掉失效。 + +缓存身份包含工作区、定义和提供方的规范清单、启用状态、运行环境及输入指纹。只比较已存在文件的修改时间不足以覆盖新建、删除和目录替换。无变化可复用,检查失败则显示失败或重新查询,不能把读取错误当作“没有变化”。本机提供方不能服务远程工作区;远程接入须在远端适配器完成前保持不可用。 + +### 6. 项目代码与外部安装分开授权 + +提供方返回的安装须有数量上限,路径必须是绝对路径,并通过平台文件事实和 Core 通用规则校验:真实目标存在、可执行、规范化后不在工作区内。符号链接也按最终目标判断;不能只检查文本前缀。校验结论和选中目标绑定,在实际启动边界重新确认,不能把一次旧 `stat` 当作永久授权。 + +项目包装脚本和项目环境本来就在工作区内,不能套用上述规则后全部禁用,也不能因此放宽外部提供方安装校验。它们必须是单独标识的项目代码来源,沿现有“执行仓库代码”的明确用户操作和逐操作确认规则启动;当前没有持久的工作区信任状态,不能假设曾经打开项目就是永久授权。后台探测不能借版本校验执行仓库携带的任意程序。 + +提供方后端不能依赖它正在发现的工具,否则会出现“先找到 Node 才能问插件 Node 在哪里”的循环。定义插件为自己的工具当提供方时同样如此:Java 定义插件的提供方不能跑在它要找的 JDK 上。第一阶段使用自带运行时或单文件原生程序,构建时固定并验证资源;不在启动时下载运行时、自动安装版本或执行 `mise trust`。带后端插件仍按现行[热激活边界](../../implemented/architecture/2026-10-26-plugin-live-activation.md)要求重启,本提案不提前放宽。 + +当前 Core 切片尚无原生链接链身份验证:项目程序和环境 home 的规范身份与声明路径不同时先拒绝,不能把仅有布尔授权当作符号链接安全证明。因此常见 venv 解释器链接及规范化后不再匹配观察声明的配置链接仍有保守限制,后续平台接入必须单独验证再放开。Wrapper 没有安装 home 时返回 `null`,不导出基于 home 的变量或 PATH,避免把仓库根误作工具安装。 + +### 7. 统一界面不等于统一业务表单 + +环境页由宿主提供目录树、工具行和使用方列表。工具名称、图标、探测、导出的变量及使用关系都来自已启用插件;一行展开后按“固定、项目要求、全局默认、自动检测”的顺序列出四个来源:首个有值的来源加框并标“首个命中”,没有值的来源变淡但不隐藏,让解析顺序一眼可读;解释放进信息提示。来源页展示登录 Shell 的读取结果和插件声明的版本管理器,手动安装接入后也放在这里;提供方自己的可执行文件位置、离线选项等仍由插件在自己的设置页注入。 + +设置遵循[真实配置编辑规则](../../implemented/architecture/2026-10-25-settings-native-configuration-editing.md)和[侧栏分类](../../implemented/feature/2026-10-15-macos-settings-hierarchy.md):默认只读发现真实来源并回显,编辑草稿经明确确认后写回、检查冲突、重新读取;非敏感配置不能保存成上游不使用的影子配置,秘密不能明文回显。环境固定和手动安装是 Lithe 自己的偏好,必须标明作用域,不冒充管理器原生配置。 + +插件停用或卸载后,撤销其工具行、使用方、导出变量和设置页,取消它拥有的查询及监听;写下的固定值保留,重新启用再校验并恢复。存在固定值不代表定义仍然有效,不能靠残留固定继续导出环境或启动程序。所有用户文案实现时进入 `shared/i18n/`,不把原型里的字符串直接写进视图或服务。 + +当前 macOS 页面先交付通用只读框架:构建分组新增“运行环境”和“环境来源”,保留旧项目页,直到真实使用方迁移后再删除旧链路。加载器只读取已注册且已启用插件的声明与本机 `local.json`;固定继承仍调用 Core,不在 Swift 维护第二份规则。页面只显示读到的事实:存在的版本文件在提供方回答前说明“版本未查询”,回答后显示提供方原文的要求和对应安装版本;固定值由 Core 校验并探测,生效时与其他层一样显示版本,不可用时显示 Core 的原因,只有没能检查时才显示“已保存,未验证”;全局默认列出登录 Shell 里定义声明的 home 变量及其探测出的版本,再列 `PATH` 上按声明顺序找到的第一个命令并标“版本未验证”(不运行它);自动检测列出声明式扫描找到并通过 Core 校验的安装,版本最高的打勾,同版本不同 home 标冲突,未通过校验的只计数并在悬停里给原因。提供方报告的全局要求列在全局步骤,安装并入自动检测并保留提供方归属;使用方与变量没有事实就不显示。每行标出的“第几层生效”、摘要里的版本和徽标都来自 Core 对这一行的解析结果(见“macOS 设置页的解析取舍”)。添加、切换和保存还没有真实实现,所以界面上直接不放这些控件,也不放禁用的占位按钮。目录树只列项目根目录、自己有版本文件或固定值的目录及其上级,扫描与行数有上限,超出时用诊断说明覆盖了多少目录。隔离预览(`--toolchain-settings-demo`,仅 Debug)只用样例插件清单代替已安装插件:工作区是真实文件(临时生成或 `--toolchain-demo-workspace` 指定),固定、版本文件、自动检测和登录 Shell 都走生产加载器、Core 和启动时的同一读取流程;它不进入生产插件表或运行流程,退出时删除临时目录。远程工作区在读本机文件前拒绝。 + +来源页只展示两类已有事实。第一类是登录 Shell 读取:使用的 Shell、读取状态、变量数量、耗时与时间,以及 `PATH` 各目录来自登录 Shell 还是 Lithe 的启动环境。其他变量可能含有密钥,只计数不显示值。“重新读取”只刷新子进程缺少的变量和下次启动用的 `PATH` 缓存,不改本进程的 `PATH`(线程启动后 `setenv` 不安全);发现 `PATH` 变了就提示重启。第二类是插件声明的版本管理器:它监视的文件、本项目里实际找到的文件,以及来自哪个插件。每个版本管理器显示最近一次查询的结果:耗时,或提供方原样给出的失败原因(如未受信任的配置),不替用户执行 trust 或安装。 + +macOS 的声明式路线由 `MacToolchainDiscovery` 执行,宿主不认识任何工具名,只照 `discoveryPlan` 做三件事: + +1. 展开每个工具的扫描根和单层 glob(隐藏项只在模式本身以 `.` 开头时匹配),跟随链接,丢弃规范路径落在工作区内的条目;再按 `homeLayouts` 首个匹配取 home,未匹配的条目跳过。 +2. 为 home、可执行布局和 `pathPrepend` 收集 `stat`/`realpath` 文件事实,连同登录 Shell 中声明的 home 变量交给 `toolchains.candidates`;Core 返回需要探测的规范可执行文件。 +3. 只探测这些文件,参数取自定义的 `version`,不经 shell;再把输出交回 Core 取版本并选出自动层。 + +扫描根和条目数沿用 `discoveryPlan` 的上限,每个工具的安装数和文件事实数沿用 Core 的上限,探测有总数(64)、并发(最多 4)和总时长(20 秒)上限;超出部分作为诊断显示,不当作“未找到”。卡片里的路径是扫描到的条目(例如 `jdk-21.jdk`),因为 home 后缀(`Contents/Home`)对每个安装都一样,悬停显示完整 home。Homebrew 的 python 这类目录形态无法用冻结的原语表达,隔离预览不扫描它,留给提供方路线;不为它扩充原语或在宿主写特判。隔离预览的样例清单必须通过 Core 准入,测试会检查这一点,避免预览靠宿主放宽校验才能显示。 + +设置页复用既有布局框架,而不是把 HTML 的像素宽度写成整页不可压缩尺寸。嵌套 `LitheSplitPaneView` 声明理想页尺寸,目录和详情接受父容器分配的双轴边界;标签组用共享 `LitheChipFlowLayout` 换行;工具行和提供方行用 `ToolchainEdgeLayout`,主体放不下时把右侧徽标移到下一行;四个来源卡片用 `ToolchainStepGridLayout` 等宽分列,列数只取 4、2、1,避免出现半空的一行,同一行卡片取最高的高度。卡片里的路径保持单行,从前面截断以保留文件名,悬停显示完整路径。宽度不足时改变行列数并重新计算真实高度,不能只裁掉控件、让 `ViewThatFits` 测量与显示走不同高度分支,或用最大卡片高度压住增长的内容。原生缩放回归同时检查反复缩小/放大、分隔条拖动、卡片边界及后续行顺序;隔离预览也使用生产 `SettingsWindowChrome` 和外观容器,避免演示窗口与产品的内容尺寸含义不同。 + +### 8. 资源归属和分阶段迁移 + +| 资源 | 所有者与生命周期 | 可写位置及校验 | +| --- | --- | --- | +| 工具定义与插件固定资源 | 插件版本,构建时生成并签名;运行时只读 | 已安装包内仅可读,不影响代码签名或 Sparkle 增量更新基线 | +| 查询快照与文件指纹 | 工作区和插件代次,失效、关闭或停用即撤销 | 内存;若以后落盘缓存,单独补资源注册、身份与清理规则 | +| 固定值 | 用户的本机项目偏好,停用后仍保留 | 工作区 `.lithe/toolchains/local.json`,明确保存并回读 | +| 手动安装与全局默认 | 用户应用偏好,用户删除或修改前保留 | 平台应用存储,不复制运行时,不写 app bundle | +| 查询后端临时物和日志 | 插件、工作区和操作拥有,结束时有界清理 | 平台缓存或临时适配器,禁止从发行目录推导写入目标 | + +本轮没有下载、运行时缓存或生成资源入口,不更新资源复用清单。后续新增时同步 `scripts/resources/worktree-resources.json`、校验路由和测试、`docs/ci-builds.md`;查询状态和语言服务器工作区状态不能跨工作树共享。 + +## 考虑过的备选方案 + +- **继续补 `toolchainSources` 的目录与文件格式。** 最快复用旧 PR,但每个新管理器仍迫使 Core 发版;别名、全局覆盖和信任语义会持续偏离上游,因此不采用。 +- **把工具定义移成宿主 `shared/` 下的数据文件。** 少写几个代码分支,却仍由宿主决定有哪些工具;插件停用后工具行和环境导出无法自然消失,违背全部由插件注入的目标,因此不采用。 +- **安装发现只用声明式。** 简单工具不用写代码,但每遇到注册表、plist、管理器数据库或命令输出就得扩充原语,宿主最终变相成为管理器解析器,因此原语冻结,超出部分交给提供方。 +- **安装发现只用提供方。** 表达能力最强,但目录规律固定的工具也要打包原生后端、走进程生命周期,并在安装后重启;这类工具保留声明式扫描作为不运行插件代码的路线。 +- **让版本管理器直接改 PATH 或注入任意命令。** 能解决 Dock 的环境缺失,却把发现权限扩大成替换宿主程序的权限,也丢掉工具 ID、来源和使用方关系,因此不采用。 +- **提供方主动推送并为每个项目常驻。** 可减少部分查询,但增加生命周期与一致性成本;当前请求式方案已有取消、期限和输入失效边界,先不新增常驻推送协议。 +- **每个运行时一条选择链或一个设置表单。** 局部实现直接,但 JDK、Maven 和其他命令继续出现不同优先级;选择、显示与执行会再次漂移,因此采用同一候选机制和通用展示骨架。 + +## 验收标准 + +| 阶段 | 交付内容 | 必须证明 | +| --- | --- | --- | +| 1:提案 | 本笔记、隔离的清单贡献、查询和解析、`pins` 契约草案及 fixture | Java 风格定义、mise 提供方、未定义工具反例可读且一致;未实施的草案字段不能被误当成已支持能力 | +| 2:Core 与查询客户端,部分完成 | Core 已支持定义/答案校验、目录固定、四层解析、同时列出扫描规则与提供方的发现计划、设置页候选(`toolchains.candidates`)、只报告安装的提供方和版本校验;macOS 设置页已接入声明式扫描、版本探测、提供方查询客户端和输入监听;Windows 未接入 | 已有纯数据测试保护固定失效、来源与最低版本、冲突、路径和数量边界,以及只扫描、只由定义插件自供、两条路线报告同一 home 时只算一份;macOS 用真实临时目录和注入的探测证明扫描、工作区排除、只探测 Core 返回的文件和冲突;macOS 已用脚本后端与注入查询证明协商、原样回复、同一轮共享代次而操作 ID 各不相同、整份重查与合并,并用 `toolchains.resolve` 证明项目要求、未安装要求退到下一层、固定值生效与失效,以及超时和后端不可用的提供方带着各自原因留在结果里、由下一层决定;真实后端超时、进程回收与 Windows 仍须平台实现证明;不新增宿主工具表 | +| 3:统一选择与设置界面,部分完成 | macOS 环境/来源页和只读加载器已接入;实际启动与展示共用快照、Java/Maven 定义迁移尚未完成 | 现有目录、插件撤销、错误及过期快照测试;手动添加、确认保存、冲突回读和消费者切换仍须后续验证;同项删除被替换的专属路径 | +| 4:两个真实提供方 | mise 后端调用上游输出,SDKMAN 作为不同机制的验收插件,设置各自注入插件页 | 第二个提供方只改插件;SDKMAN 路径从 `MacRuntimeDiscovery` 迁出;原生工作流、离线/未信任/未安装/取消、进程清理与安装包哈希不变 | + +阶段 3 清理 `ProjectRuntimeService` 的专属选择链和 `project-jdk`、`project-maven` 等被替代引用前,须按 [Java/Maven 迁移清单](2026-10-26-maven-java-domain-plugin-migration.md)核对真实消费者和数据迁移。Node、Go、Python 等在其插件声明定义之前保持当前实现,不新增特判;不能在本次提案删除仍在生产使用的代码,或用永久兼容别名伪装迁移完成。 + +契约草案位于 `shared/contracts/plugins/v1/proposals/toolchains/`,其中尚未实现的消费者启动、查询客户端和界面行为继续保留未支持标记。Core 已实现的清单贡献和命令由本次正式契约单独说明,不把草案全集直接当作能力承诺;旧 `local-v1` schema 与 Run 配置读写路径保持不变,新 Core 固定解析入口只处理调用方传入的 JSON,不打开文件或保存忽略规则。合并后在插件文档仓库按合并的 `preview` 提交执行 `node scripts/contract.mjs sync --revision `;实际插件后端协议接入时同步 Node/Java SDK,并在合并前执行 SDK 端到端验证。每阶段的功能矩阵只记录实际实现和目标平台运行证据,不把本提案或 Core 单元测试当作 Windows 已完成。 + +## 风险 + +- **声明会影响启动目标。** 清单 schema 只能检查形状,不能证明官方身份、文件存在或可执行;实现必须同时具备安装验证、平台文件证据、Core 通用限制和启动边界复核。 +- **版本比较再次泄漏工具语义。** 已安装版本需要统一的可比较结果,未知版本不能靠工具名修补;Java 8 的特殊输出应由定义的探测规则处理,无法规范的版本不能静默参与“最新”选择。 +- **文件监听遗漏或查询失败。** 新建文件、符号链接替换和丢失事件可能留下旧快照,必须覆盖目录身份变化和失败状态;不能把失败翻译成“已更新”。 +- **源优先级仍可能冲突。** 原生与兼容声明之间、多个原生项目要求之间不能仅靠稳定排序得到业务答案;需要明确冲突结果与用户可行动入口。 +- **多文件保存不是天然事务。** 忽略规则和固定文件的保存需定义失败与重试结果;任何部分成功都不得丢掉用户源数据或误报全部成功。 +- **迁移跨度大。** Java/Maven 仍有真实生产消费者;按阶段替换并删除对应旧路径,否则会长期保留两套真源。未验证的平台保持待验证,不能以契约共用推断产品一致。 + +## 验证 + +当前验证覆盖文档、契约、Core 纯数据边界和 macOS 设置页的扫描与布局测试,不声称运行时流程已验收: + +- `./scripts/agent-notes/verify-agent-notes.sh`:中文笔记格式、生命周期、链接与路径。 +- `./scripts/architecture/verify-shared-contracts.sh`:现行共享契约及 JSON 有效性;不能替代草案 schema 的专门校验或未来 Core 语义测试。 +- `./scripts/platform-feature-matrix/verify-platform-feature-matrix.sh`:矩阵记录实际 Core 切片与平台待接入状态,不把纯数据测试标成产品运行时已验证。 +- `cargo test --manifest-path rust/Cargo.toml -p lithe-core --lib toolchain_definitions -- --test-threads=1`:短 ID 来源、重复归属、嵌套字段失败即拒绝、只报告安装的提供方,以及定义插件为自己的工具声明提供方。 +- `cargo test --manifest-path rust/Cargo.toml -p lithe-core --lib toolchain_pins -- --test-threads=1`:目录按组件继承、失效数据、跨平台路径语法、数量边界和 legacy 数据保留。 +- `cargo test --manifest-path rust/Cargo.toml -p lithe-core --lib toolchain_resolution -- --test-threads=1`:通过正式命令验证四层选择、真实回复解码、冲突和版本规则,以及设置页候选的登录 Shell home、跳过原因和自动选择;文件事实与探测结果由隔离快照注入,不访问本机工具。 +- macOS 稳定性测试 `--filter 'MacToolchainDiscoveryTests|ToolchainSettings'`:真实临时目录与注入的 home 和探测,验证扫描、隐藏项与未匹配条目、工作区链接不探测、同版本冲突、拒绝的声明不扫描、Core 解析出的生效层与未满足要求、固定值只在自己的目录生效且不进入自动检测,以及隔离预览样例清单通过 Core 准入;不运行本机安装的工具。 + +后续实现必须按对应平台执行稳定性测试、Core/边界验证和 `./scripts/macos/verify/verify-runtime-bundle-immutability.sh`,并留下真实视口、完整启动流程和进程清理证据。安全与生命周期测试使用隔离文件系统、可控制的时钟/事件和明确本地期限,不依赖开发者安装的 mise、SDKMAN 或个人配置。 + +## 适用范围 + +- `shared/contracts/plugins/v1/` +- `shared/contracts/toolchain-local-v1.schema.json` +- `rust/lithe-core/src/plugins/` +- `rust/lithe-core/src/execution/` +- `macos/Sources/Lithe/Services/Java/ProjectRuntimeService.swift` +- `macos/Sources/Lithe/Platform/MacOS/Runtime/` +- `macos/Sources/Lithe/Platform/MacOS/Plugins/` +- `windows/tauri/src/platform/` +- `Plugins/mac/` +- `Plugins/win/` + +## macOS 查询客户端的取舍 + +- 复用 `MacPluginBackendSessions` 的单插件单进程,不另起进程:查询与页面调用共用同一后端和设置重启规则。协商结果在 Core 会话状态里,未协商的提供方由 Core 拒绝,不写到进程。 +- Core 只搬运原始回复(`{id,response}`),所有成功与错误规则留给 `toolchains.decodeReply`;否则引擎和解码器会各维护一份错误码表。 +- 每个提供方先发一次全局查询(`workspace:null`),再对根目录和有自己版本文件的目录逐个查询(上限 32)。这样项目配置不可用时仍能列出安装;未打开窗口的工作区没有 context,只做全局查询并说明原因。同一目录的一轮查询里所有提供方共用一个代次(generation),每个请求的操作 ID 不同;全局查询单独一个代次。原因是 `toolchains.resolve` 要求它收到的每份回复都和解析请求同代次、同 context,各自独立的代次无法放在一起解析。如果加载途中窗口的 context 变了,后到的回复不进这一轮:Core 不解析它,页面也不显示它的项目要求,否则用户看到的要求和生效结果会来自两个不同的 context。 +- **没回复的查询也交给 Core。** 查询超过 15 秒没有回复,或者后端起不来、断开、没有协商这个提供方时,宿主调用 `toolchains.failureReply` 生成一份错误回复(超时用 `plugin.toolchain.timeout`,其余用 `plugin.toolchain.providerUnavailable`),再和提供方自己发来的错误一样交给 `decodeReply`。这份回复照常进入目录轮次,`toolchains.resolve` 把它记成 `providerUnavailable` 并继续解析下一层。原因有两个:一是契约要求这类提供方要“保持可见”,不能让它从解析结果里消失;二是错误码映射只在 Core 维护一份,Swift 不手写 JSON-RPC 错误码。Core 只为问过这个工具的查询记录,并用 `cause` 区分是超时还是不可用,所以工具行可以写出“mise 未响应”或“mise 不可用”。超时由接线层把 Core 的 `requestTimeout` 转成 `MacToolchainDiscovery.QueryTimeout`;取消不生成回复,因为取消后这次加载的结果整份丢弃。提供方回复不合法或路径不安全时仍按失败处理,不进轮次,这和契约里“安全与身份失败不转成缺少工具”一致。 +- 宿主只读取回复与定义点名的路径(home、可执行布局、`${home}` 前缀、来源文件)作为事实。别名链接(如 mise 的 `latest`)只有链接路径的事实,Core 由链接事实推出规范目标再校验,避免宿主为规范路径再读一遍。 +- 失效用整份重载:工作区内按 `watchFiles` 尾部匹配,工作区外按回复的 `watch` 用一个 FSEvents 流监听其最近存在的父目录;短时间事件合并为一次重载,关闭页面即释放监听。 + +## macOS 设置页的解析取舍 + +- **哪一层生效由 Core 决定。** 页面曾在 Swift 里按“固定 → 项目 → 全局 → 自动”自己找第一个有值的层,提供方加入项目要求和全局要求后,这份顺序就和 Core 的规则分叉了(例如未安装的项目要求该退到下一层,同一层两个版本管理器不一致该报冲突)。现在每个查询过的目录、每个工具调用一次 `toolchains.resolve`,页面的摘要、徽标和“第几层生效”都读它的结果。不要在视图里再写优先级判断;需要新的展示信息时,从 Core 返回的 `trace`、`unmet`、`conflicts` 读。没给出回答的提供方也从 `unmet` 读(`providerUnavailable` 和它的 `cause`),工具行在结果旁边写出是哪个版本管理器、是未响应还是不可用;版本文件存在但提供方没回答时,第 2 步也显示同样的原因,而不是“版本未查询”。 +- **解析与查询用同一份事实。** 解析请求的文件事实由定义需要的事实加上这一轮回复点名的路径组成,并替换进每份回复的 `filesystem`,这样 Core 重新解码回复时和解析时看到的是同一个快照。注意查询阶段给 `decodeReply` 的事实和解析阶段的事实是分两次读的:如果两次之间文件变了,页面列出的安装与要求(来自查询阶段)可能和生效结果(来自解析阶段)暂时不一致,下一次加载会重新读取;但单次解析内部不会混用两份事实。 +- **固定值单独决定。** Core 先按目录向上找固定值,找到就只看它:能用就生效,不能用就是终止失败,不再往下找。所以只有固定值、没有版本文件的目录不发提供方查询,只为它固定的工具解析一次(每次加载最多 256 个)。固定的 home 也要有文件事实和探测结果,宿主通过 `toolchains.candidates` 让 Core 检查这些 home 并给出要探测的文件;这批结果只决定探测什么,不会让固定的 home 出现在自动检测列表里。 +- **每行读哪个目录。** 有固定值的行读固定值所在目录的结果;其余行读最近一个有自己版本文件的目录(没有就是根目录)。 +- **已知限制。** 一是 `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)。 diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index 6b08dd12bd..24a3033030 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -16,8 +16,10 @@ "%@ build files changed" = "%@ build files changed"; "%@ could not be reached. Check your internet, proxy, or VPN connection and try again." = "%@ could not be reached. Check your internet, proxy, or VPN connection and try again."; "%@ debug adapter is unavailable." = "%@ debug adapter is unavailable."; +"%@ did not answer: %@" = "%@ did not answer: %@"; "%@ did not finish within %lld seconds." = "%@ did not finish within %lld seconds."; "%@ did not report a version; %@ or newer is required." = "%@ did not report a version; %@ or newer is required."; +"%@ did not respond" = "%@ did not respond"; "%@ disabled" = "%@ disabled"; "%@ does not include %@; Lithe renders those characters with %@ and the system font." = "%@ does not include %@; Lithe renders those characters with %@ and the system font."; "%@ does not provide archived conversations." = "%@ does not provide archived conversations."; @@ -35,6 +37,7 @@ "%@ is no longer an official plugin. It moved to the community plugin %@ in Lithe Store and gets no further updates here. Uninstall it, restart Lithe, then install the community version from the Marketplace." = "%@ is no longer an official plugin. It moved to the community plugin %@ in Lithe Store and gets no further updates here. Uninstall it, restart Lithe, then install the community version from the Marketplace."; "%@ is not available." = "%@ is not available."; "%@ is not installed. Lithe installs it with your npm into its application data." = "%@ is not installed. Lithe installs it with your npm into its application data."; +"%@ is unavailable" = "%@ is unavailable"; "%@ is version %@; %@ or newer is required." = "%@ is version %@; %@ or newer is required."; "%@ keeps no workspace state that Lithe can reset." = "%@ keeps no workspace state that Lithe can reset."; "%@ language server (inside the plugin)" = "%@ language server (inside the plugin)"; @@ -81,6 +84,7 @@ "%lld changed files" = "%lld changed files"; "%lld characters" = "%lld characters"; "%lld characters total" = "%lld characters total"; +"%lld directories" = "%lld directories"; "%lld failed" = "%lld failed"; "%lld file(s) · %@" = "%lld file(s) · %@"; "%lld files" = "%lld files"; @@ -94,7 +98,9 @@ "%lld interrupted" = "%lld interrupted"; "%lld lines" = "%lld lines"; "%lld model calls" = "%lld model calls"; +"%lld more" = "%lld more"; "%lld more notifications" = "%lld more notifications"; +"%lld ms" = "%lld ms"; "%lld non-streaming" = "%lld non-streaming"; "%lld of %lld" = "%lld of %lld"; "%lld of %lld services selected" = "%lld of %lld services selected"; @@ -125,6 +131,8 @@ "%lld turns" = "%lld turns"; "%lld unchanged lines" = "%lld unchanged lines"; "%lld usages" = "%lld usages"; +"%lld variables" = "%lld variables"; +"%lld version files" = "%lld version files"; "%lld worktree records need attention" = "%lld worktree records need attention"; "%lld worktrees" = "%lld worktrees"; "%lld/%lld completed" = "%lld/%lld completed"; @@ -180,6 +188,7 @@ "A terminal session is still running." = "A terminal session is still running."; "A test path must stay inside the current workspace." = "A test path must stay inside the current workspace."; "A unique filename could not be created for the image" = "A unique filename could not be created for the image"; +"A version saved for this directory on this computer. It applies first, also in the directories below." = "A version saved for this directory on this computer. It applies first, also in the directories below."; "A worktree operation is already running, or the repository is unavailable." = "A worktree operation is already running, or the repository is unavailable."; "ACP adapter" = "ACP adapter"; "AI" = "AI"; @@ -345,6 +354,7 @@ "Another Hook update is running. Try again." = "Another Hook update is running. Try again."; "Another global entry uses this name with a different definition." = "Another global entry uses this name with a different definition."; "Another instruction save is running. Try again." = "Another instruction save is running. Try again."; +"Answered in %lld ms" = "Answered in %lld ms"; "Any Time" = "Any Time"; "Appearance" = "Appearance"; "Appearance & Behavior" = "Appearance & Behavior"; @@ -400,6 +410,7 @@ "Asked for access to %@" = "Asked for access to %@"; "Assignees" = "Assignees"; "Astro Support" = "Astro Support"; +"At launch Lithe reads your login shell's environment, as a terminal does, and gives it to the terminals, tools and language servers it starts. Only PATH is listed; other variables can hold secrets and are only counted." = "At launch Lithe reads your login shell's environment, as a terminal does, and gives it to the terminals, tools and language servers it starts. Only PATH is listed; other variables can hold secrets and are only counted."; "Attach" = "Attach"; "Attach Request" = "Attach Request"; "Attach file" = "Attach file"; @@ -423,6 +434,7 @@ "AutoBuild is rebuilt nightly and may contain unfinished features, serious bugs, or other problems. Stable releases are recommended for most users." = "AutoBuild is rebuilt nightly and may contain unfinished features, serious bugs, or other problems. Stable releases are recommended for most users."; "AutoBuild — Built at %@" = "AutoBuild — Built at %@"; "Automatic" = "Automatic"; +"Automatic discovery" = "Automatic discovery"; "Automatic query ×%lld, identical results" = "Automatic query ×%lld, identical results"; "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."; @@ -907,6 +919,7 @@ "Connection timeout" = "Connection timeout"; "Connections: %lld" = "Connections: %lld"; "Console" = "Console"; +"Consumers" = "Consumers"; "Content" = "Content"; "Content SHA-256" = "Content SHA-256"; "Context" = "Context"; @@ -977,6 +990,7 @@ "Copy session ID" = "Copy session ID"; "Copy table" = "Copy table"; "Copy worktree path" = "Copy worktree path"; +"Could not be determined" = "Could not be determined"; "Could not check for updates" = "Could not check for updates"; "Could not check for updates." = "Could not check for updates."; "Could not clear this project's local history. Check storage permissions and try again." = "Could not clear this project's local history. Check storage permissions and try again."; @@ -1013,6 +1027,7 @@ "Could not read the project folder. Check that it still exists and that Lithe has permission to access it." = "Could not read the project folder. Check that it still exists and that Lithe has permission to access it."; "Could not read the selected built-in background." = "Could not read the selected built-in background."; "Could not read this file" = "Could not read this file"; +"Could not read toolchain settings." = "Could not read toolchain settings."; "Could not refresh Git status. Refresh to verify the changes." = "Could not refresh Git status. Refresh to verify the changes."; "Could not register Lithe MCP in %@: %@" = "Could not register Lithe MCP in %@: %@"; "Could not remove Lithe MCP from %@: %@" = "Could not remove Lithe MCP from %@: %@"; @@ -1132,6 +1147,7 @@ "Default push behavior" = "Default push behavior"; "Default shell" = "Default shell"; "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"; "Definitions" = "Definitions"; "Delete" = "Delete"; @@ -1207,6 +1223,7 @@ "Direct connection" = "Direct connection"; "Direct retry applies only to public MCP Registry searches on this page. System proxy settings and Agent server connections are unchanged. TLS certificate validation remains enabled." = "Direct retry applies only to public MCP Registry searches on this page. System proxy settings and Agent server connections are unchanged. TLS certificate validation remains enabled."; "Directories" = "Directories"; +"Directories in the order tools are looked up. The login shell's directories come first, then any that only the environment Lithe was launched with had." = "Directories in the order tools are looked up. The login shell's directories come first, then any that only the environment Lithe was launched with had."; "Directory" = "Directory"; "Directory imports preserve scripts, templates and other supporting files. Existing skills are never overwritten." = "Directory imports preserve scripts, templates and other supporting files. Existing skills are never overwritten."; "Directory or file name" = "Directory or file name"; @@ -1238,6 +1255,7 @@ "Disconnected" = "Disconnected"; "Discover MCP servers" = "Discover MCP servers"; "Discover Tests" = "Discover Tests"; +"Discovered" = "Discovered"; "Discovered Tests" = "Discovered Tests"; "Discovering Maven…" = "Discovering Maven…"; "Discovering…" = "Discovering…"; @@ -1295,6 +1313,7 @@ "Each repository has its own commit. Completed steps are kept if another repository fails." = "Each repository has its own commit. Completed steps are kept if another repository fails."; "Each row explains the setting, the value Git is actually using, and where that value comes from." = "Each row explains the setting, the value Git is actually using, and where that value comes from."; "Each submodule is pushed before its parent." = "Each submodule is pushed before its parent."; +"Each tool uses the first source that has a value: a fixed version, then project version files, then global defaults, then automatic discovery. Installed versions are checked by running each tool's own version command. Enabled version managers report what version files ask for." = "Each tool uses the first source that has a value: a fixed version, then project version files, then global defaults, then automatic discovery. Installed versions are checked by running each tool's own version command. Enabled version managers report what version files ask for."; "Earlier" = "Earlier"; "Earlier Git commands were truncated to limit memory use." = "Earlier Git commands were truncated to limit memory use."; "Earlier Git output was omitted to limit memory use." = "Earlier Git output was omitted to limit memory use."; @@ -1463,6 +1482,7 @@ "Export as PDF…" = "Export as PDF…"; "Export conversations" = "Export conversations"; "Export logs" = "Export logs"; +"Exported environment" = "Exported environment"; "Exporting conversations…" = "Exporting conversations…"; "Exports the index against HEAD." = "Exports the index against HEAD."; "Exports the working tree's net changes against HEAD, including selected untracked files." = "Exports the working tree's net changes against HEAD, including selected untracked files."; @@ -1532,6 +1552,7 @@ "File tree row height" = "File tree row height"; "Files" = "Files"; "Files added. Save your editor changes before sending so the Agent can read them." = "Files added. Save your editor changes before sending so the Agent can read them."; +"Files declared by enabled version managers that exist in this directory. A directory without its own uses the nearest parent's." = "Files declared by enabled version managers that exist in this directory. A directory without its own uses the nearest parent's."; "Files return to the default ChangeList and may be included by Stage All. Their Git staging state is unchanged." = "Files return to the default ChangeList and may be included by Stage All. Their Git staging state is unchanged."; "Files will stay unchanged. Nothing will be staged or committed automatically." = "Files will stay unchanged. Nothing will be staged or committed automatically."; "Filter by changed path" = "Filter by changed path"; @@ -1559,8 +1580,12 @@ "Find references to the selected symbol" = "Find references to the selected symbol"; "Finished" = "Finished"; "First %lld locations" = "First %lld locations"; +"First match" = "First match"; "Fit to Window" = "Fit to Window"; "Fix the failing checks below before installing." = "Fix the failing checks below before installing."; +"Fixed" = "Fixed"; +"Fixed in parent" = "Fixed in parent"; +"Fixed versions are saved on this computer in .lithe/toolchains/local.json and also apply to the directories below. This page reads them without changing the file." = "Fixed versions are saved on this computer in .lithe/toolchains/local.json and also apply to the directories below. This page reads them without changing the file."; "Fixup" = "Fixup"; "Floating Point" = "Floating Point"; "Folder" = "Folder"; @@ -1593,6 +1618,7 @@ "Frames are not available" = "Frames are not available"; "Frames rendered per second" = "Frames rendered per second"; "Free space" = "Free space"; +"From %@" = "From %@"; "From (UTC ISO 8601)" = "From (UTC ISO 8601)"; "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."; "Full Program Declaration" = "Full Program Declaration"; @@ -1666,6 +1692,7 @@ "GitHub user %@" = "GitHub user %@"; "Global Git" = "Global Git"; "Global Git configuration" = "Global Git configuration"; +"Global defaults" = "Global defaults"; "Global identity applies to other repositories unless they override it." = "Global identity applies to other repositories unless they override it."; "Global instructions" = "Global instructions"; "Global instructions apply to all projects using this Agent. Each Agent keeps its own native file." = "Global instructions apply to all projects using this Agent. Each Agent keeps its own native file."; @@ -1845,6 +1872,8 @@ "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: %@" = "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."; "Installed" = "Installed"; "Installed %@" = "Installed %@"; "Installed %@ · update to %@ available" = "Installed %@ · update to %@ available"; @@ -1854,6 +1883,7 @@ "Installed from Disk" = "Installed from Disk"; "Installed on this Mac: %@" = "Installed on this Mac: %@"; "Installed plugin %@ is invalid: %@" = "Installed plugin %@ is invalid: %@"; +"Installed toolchain declarations could not be checked for discovery." = "Installed toolchain declarations could not be checked for discovery."; "Installing packages…" = "Installing packages…"; "Installing update %@…" = "Installing update %@…"; "Installing update…" = "Installing update…"; @@ -1990,6 +2020,7 @@ "Latest turn as reported by the Agent; its accounting scope depends on the Agent. Turn on Model call statistics for session totals." = "Latest turn as reported by the Agent; its accounting scope depends on the Agent. Turn on Model call statistics for session totals."; "Launch" = "Launch"; "Launch Request" = "Launch Request"; +"Launch environment" = "Launch environment"; "Launching" = "Launching"; "Layout" = "Layout"; "Leave a review" = "Leave a review"; @@ -2019,6 +2050,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 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"; "Lithe Fetch Defaults" = "Lithe Fetch Defaults"; @@ -2139,6 +2171,7 @@ "Log message" = "Log message"; "Log timeline" = "Log timeline"; "Log: %@" = "Log: %@"; +"Login shell" = "Login shell"; "Logs" = "Logs"; "Logs and Diagnostics" = "Logs and Diagnostics"; "Look Up Again" = "Look Up Again"; @@ -2405,6 +2438,7 @@ "No Spring Boot or Maven Module configurations" = "No Spring Boot or Maven Module configurations"; "No Spring MVC endpoints found" = "No Spring MVC endpoints found"; "No additional paths" = "No additional paths"; +"No answer within %lld s" = "No answer within %lld s"; "No available local branch. Branches already checked out in another worktree cannot be reused." = "No available local branch. Branches already checked out in another worktree cannot be reused."; "No background image selected" = "No background image selected"; "No branches found" = "No branches found"; @@ -2434,6 +2468,8 @@ "No differences were found for this selection." = "No differences were found for this selection."; "No earlier conversations" = "No earlier conversations"; "No effective value configured" = "No effective value configured"; +"No enabled plugin defines tools." = "No enabled plugin defines tools."; +"No enabled plugin provides a version manager." = "No enabled plugin provides a version manager."; "No executable location was found at the cursor" = "No executable location was found at the cursor"; "No file edits" = "No file edits"; "No files match the conflict filter" = "No files match the conflict filter"; @@ -2484,6 +2520,7 @@ "No program to debug was found. Build it first, or choose one in the “%@” setting of %@." = "No program to debug was found. Build it first, or choose one in the “%@” setting of %@."; "No program to debug was found. Build it first, or choose one in the “%@” setting of %@. Looked in: %@" = "No program to debug was found. Build it first, or choose one in the “%@” setting of %@. Looked in: %@"; "No project entry point detected" = "No project entry point detected"; +"No project environment is available." = "No project environment is available."; "No project history recorded yet" = "No project history recorded yet"; "No project is open" = "No project is open"; "No project is open. Rows backed by Git configuration write to the global Git configuration; commit identity needs a repository." = "No project is open. Rows backed by Git configuration write to the global Git configuration; commit identity needs a repository."; @@ -2527,6 +2564,7 @@ "No upstream configured" = "No upstream configured"; "No usages found" = "No usages found"; "No verified Homebrew formula is configured for %@." = "No verified Homebrew formula is configured for %@."; +"No version file" = "No version file"; "No web browser could be opened." = "No web browser could be opened."; "No worktrees" = "No worktrees"; "Node.js %lld or later" = "Node.js %lld or later"; @@ -2538,9 +2576,11 @@ "Non-streaming responses" = "Non-streaming responses"; "None" = "None"; "None (fastest)" = "None (fastest)"; +"None; uses the parent directory's" = "None; uses the parent directory's"; "Normal Folder" = "Normal Folder"; "Not Assigned" = "Not Assigned"; "Not Supported" = "Not Supported"; +"Not a valid installation" = "Not a valid installation"; "Not available" = "Not available"; "Not available yet: dependencies are not indexed" = "Not available yet: dependencies are not indexed"; "Not detected" = "Not detected"; @@ -2550,6 +2590,10 @@ "Not installed" = "Not installed"; "Not installed · %@" = "Not installed · %@"; "Not installed. Install it to start using this Agent." = "Not installed. Install it to start using this Agent."; +"Not pinned" = "Not pinned"; +"Not queried" = "Not queried"; +"Not read yet" = "Not read yet"; +"Not read yet; started with the PATH saved last time" = "Not read yet; started with the PATH saved last time"; "Not refreshed yet" = "Not refreshed yet"; "Not run" = "Not run"; "Not running" = "Not running"; @@ -2558,7 +2602,9 @@ "Not started" = "Not started"; "Not supported on %@ yet." = "Not supported on %@ yet."; "Not tested" = "Not tested"; +"Not usable" = "Not usable"; "Not used" = "Not used"; +"Not used in this project" = "Not used in this project"; "Not used yet." = "Not used yet."; "Nothing to push" = "Nothing to push"; "Notifications" = "Notifications"; @@ -2599,6 +2645,13 @@ "Only the commit message changes. %lld commits will receive new identities. Staged files are not included." = "Only the commit message changes. %lld commits will receive new identities. Staged files are not included."; "Only the current branch can be updated" = "Only the current branch can be updated"; "Only the files below are collected. Credentials, tokens, and home-directory paths are redacted automatically; workspace source, editor buffers, and terminal history are never included." = "Only the files below are collected. Credentials, tokens, and home-directory paths are redacted automatically; workspace source, editor buffers, and terminal history are never included."; +"Only the first %lld directories are shown." = "Only the first %lld directories are shown."; +"Only the first %lld directories were queried." = "Only the first %lld directories were queried."; +"Only the first %lld discovered entries were checked." = "Only the first %lld discovered entries were checked."; +"Only the first %lld discovery locations were scanned." = "Only the first %lld discovery locations were scanned."; +"Only the first %lld fixed versions were checked." = "Only the first %lld fixed versions were checked."; +"Only the first %lld installation versions were checked." = "Only the first %lld installation versions were checked."; +"Only the first %lld installations of a tool were checked." = "Only the first %lld installations of a tool were checked."; "Only the most recent archived conversations are shown." = "Only the most recent archived conversations are shown."; "Only the reported Agent edits will be reversed. Conflicting changes and unsaved editor changes will be preserved." = "Only the reported Agent edits will be reversed. Conflicting changes and unsaved editor changes will be preserved."; "Open" = "Open"; @@ -2621,6 +2674,7 @@ "Open Marketplace" = "Open Marketplace"; "Open Patch…" = "Open Patch…"; "Open Plugin Management" = "Open Plugin Management"; +"Open Plugins" = "Open Plugins"; "Open Project" = "Open Project"; "Open Projects" = "Open Projects"; "Open Project…" = "Open Project…"; @@ -2636,6 +2690,7 @@ "Open a local folder to start working." = "Open a local folder to start working."; "Open a local project folder" = "Open a local project folder"; "Open a project" = "Open a project"; +"Open a project and enable a plugin that defines tools to inspect its environment." = "Open a project and enable a plugin that defines tools to inspect its environment."; "Open a project before choosing project paths." = "Open a project before choosing project paths."; "Open a project before starting a debug terminal." = "Open a project before starting a debug terminal."; "Open a project containing a pom.xml file." = "Open a project containing a pom.xml file."; @@ -2877,7 +2932,9 @@ "Project opened" = "Project opened"; "Project options" = "Project options"; "Project paths must stay inside the current project." = "Project paths must stay inside the current project."; +"Project requirements" = "Project requirements"; "Project root" = "Project root"; +"Project root directory" = "Project root directory"; "Project run configuration is invalid" = "Project run configuration is invalid"; "Project run configuration not found" = "Project run configuration not found"; "Project runtime settings are saved locally for this project." = "Project runtime settings are saved locally for this project."; @@ -2948,6 +3005,7 @@ "Push to %@" = "Push to %@"; "Push…" = "Push…"; "Python Support" = "Python Support"; +"Query" = "Query"; "Quick Information" = "Quick Information"; "Quick responses with basic reasoning" = "Quick responses with basic reasoning"; "Quit" = "Quit"; @@ -2965,6 +3023,8 @@ "Read %lld files" = "Read %lld files"; "Read Memory at Address" = "Read Memory at Address"; "Read Memory at Value" = "Read Memory at Value"; +"Read again" = "Read again"; +"Read result" = "Read result"; "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"; @@ -2975,6 +3035,7 @@ "Reading memory…" = "Reading memory…"; "Reading project…" = "Reading project…"; "Reading registered checkouts." = "Reading registered checkouts."; +"Reading…" = "Reading…"; "Reads the API URL, model and key from ~/.claude/settings.json and ~/.claude.json. Edit those files to change them, then fetch again." = "Reads the API URL, model and key from ~/.claude/settings.json and ~/.claude.json. Edit those files to change them, then fetch again."; "Reads the API URL, model and key from ~/.codex/config.toml and auth.json. Edit those files to change them, then fetch again." = "Reads the API URL, model and key from ~/.codex/config.toml and auth.json. Edit those files to change them, then fetch again."; "Ready" = "Ready"; @@ -3146,6 +3207,7 @@ "Replay the current branch onto %@. Conflicts may require terminal resolution." = "Replay the current branch onto %@. Conflicts may require terminal resolution."; "Replays every commit onto the base branch without a merge commit." = "Replays every commit onto the base branch without a merge commit."; "Reported change" = "Reported change"; +"Reports installed versions only" = "Reports installed versions only"; "Repository" = "Repository"; "Repository Diff" = "Repository Diff"; "Repository Diff: %@" = "Repository Diff: %@"; @@ -3155,6 +3217,7 @@ "Repository needs attention" = "Repository needs attention"; "Request" = "Request"; "Request changes" = "Request changes"; +"Requested %@ is not installed" = "Requested %@ is not installed"; "Requested by the plugin “%@”." = "Requested by the plugin “%@”."; "Required fields are marked with *" = "Required fields are marked with *"; "Required plugin %@ cannot be disabled." = "Required plugin %@ cannot be disabled."; @@ -3321,6 +3384,8 @@ "Runs on %@ shipped with the plugin" = "Runs on %@ shipped with the plugin"; "Runs the %@ installed on this Mac through the %@ adapter." = "Runs the %@ installed on this Mac through the %@ adapter."; "Runtime" = "Runtime"; +"Runtime Environment" = "Runtime Environment"; +"Runtime Environment — Isolated Preview" = "Runtime Environment — Isolated Preview"; "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."; @@ -3376,6 +3441,7 @@ "Saved in Keychain" = "Saved in Keychain"; "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"; "Saves, connects, and opens the remote folder in this window." = "Saves, connects, and opens the remote folder in this window."; "Saving build provider run configurations is unavailable." = "Saving build provider run configurations is unavailable."; "Saving replaces the current GitHub metadata." = "Saving replaces the current GitHub metadata."; @@ -3502,6 +3568,7 @@ "Setting values only fill the places the program templates reserve for them, so changing a setting never changes the programs above." = "Setting values only fill the places the program templates reserve for them, so changing a setting never changes the programs above."; "Settings" = "Settings"; "Settings…" = "Settings…"; +"Several installations have the same version" = "Several installations have the same version"; "Share project on" = "Share project on"; "Shared instructions" = "Shared instructions"; "Shared instructions cannot contain Lithe synchronization markers." = "Shared instructions cannot contain Lithe synchronization markers."; @@ -3654,6 +3721,7 @@ "Solidity Support" = "Solidity Support"; "Some MCP configuration files could not be restored: %@. Refresh and check these files before retrying." = "Some MCP configuration files could not be restored: %@. Refresh and check these files before retrying."; "Some dependencies are not shown." = "Some dependencies are not shown."; +"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 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 special files could not be copied" = "Some special files could not be copied"; @@ -4002,6 +4070,7 @@ "The downloaded update failed its SHA-256 verification. Do not install it; retry or use the Release page." = "The downloaded update failed its SHA-256 verification. Do not install it; retry or use the Release page."; "The downloaded update failed its checksum verification." = "The downloaded update failed its checksum verification."; "The edit stop changed. Refresh the session before amending." = "The edit stop changed. Refresh the session before amending."; +"The environment snapshot could not be read. Refresh to try again." = "The environment snapshot could not be read. Refresh to try again."; "The expression does not hold an address" = "The expression does not hold an address"; "The featured MCP catalog could not be loaded." = "The featured MCP catalog could not be loaded."; "The field value does not match its declaration or exceeds its size limit." = "The field value does not match its declaration or exceeds its size limit."; @@ -4029,6 +4098,7 @@ "The host has no bridge method %@." = "The host has no bridge method %@."; "The host key was not accepted. If the server was reinstalled, remove its old entry from ~/.ssh/known_hosts." = "The host key was not accepted. If the server was reinstalled, remove its old entry from ~/.ssh/known_hosts."; "The image destination is outside the current workspace" = "The image destination is outside the current workspace"; +"The installed toolchain declarations are invalid." = "The installed toolchain declarations are invalid."; "The installer reported an error, but the new CLI version was verified." = "The installer reported an error, but the new CLI version was verified."; "The instruction directory changed. Reload before saving." = "The instruction directory changed. Reload before saving."; "The instruction path must be a regular file, not a symbolic link or directory." = "The instruction path must be a regular file, not a symbolic link or directory."; @@ -4044,6 +4114,7 @@ "The local Codex account changed. Reconnect to refresh quota." = "The local Codex account changed. Reconnect to refresh quota."; "The local adapter is at or above the verified version." = "The local adapter is at or above the verified version."; "The location service did not answer." = "The location service did not answer."; +"The login shell's PATH has changed. Restart Lithe so that terminals and tools use it." = "The login shell's PATH has changed. Restart Lithe so that terminals and tools use it."; "The message text or its buttons are empty, too long, or too many." = "The message text or its buttons are empty, too long, or too many."; "The model is too large; some entries are not shown." = "The model is too large; some entries are not shown."; "The monitored JVM has exited or changed." = "The monitored JVM has exited or changed."; @@ -4061,6 +4132,7 @@ "The patch changes the base commit's tree into the target commit's tree." = "The patch changes the base commit's tree into the target commit's tree."; "The plugin archive expands beyond the allowed size: %@" = "The plugin archive expands beyond the allowed size: %@"; "The plugin archive is larger than %@ MB." = "The plugin archive is larger than %@ MB."; +"The plugin backend did not offer this version manager." = "The plugin backend did not offer this version manager."; "The plugin backend failed to start." = "The plugin backend failed to start."; "The plugin backend is no longer running." = "The plugin backend is no longer running."; "The plugin backend stopped before it was ready." = "The plugin backend stopped before it was ready."; @@ -4116,6 +4188,7 @@ "The remote terminal closed before it started." = "The remote terminal closed before it started."; "The remote terminal is busy. Wait before sending more input." = "The remote terminal is busy. Wait before sending more input."; "The remote terminal must be started asynchronously." = "The remote terminal must be started asynchronously."; +"The reply could not be read." = "The reply could not be read."; "The reported file changes have been updated. Review them again before rolling back." = "The reported file changes have been updated. Review them again before rolling back."; "The repository changed or another Git operation is running. Preview the patch again." = "The repository changed or another Git operation is running. Preview the patch again."; "The repository changed or another Git operation is running. Refresh before retrying." = "The repository changed or another Git operation is running. Refresh before retrying."; @@ -4130,6 +4203,7 @@ "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 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."; "The selected AI provider has no API key." = "The selected AI provider has no API key."; "The selected background image authorization is invalid." = "The selected background image authorization is invalid."; @@ -4168,6 +4242,7 @@ "The switch controls the Lithe shared block, not the Agent or its own instructions. Save shared changes before switching. Open a row to edit the complete native file." = "The switch controls the Lithe shared block, not the Agent or its own instructions. Save shared changes before switching. Open a row to edit the complete native file."; "The terminal directory is outside the remote workspace." = "The terminal directory is outside the remote workspace."; "The test file is outside the current workspace: %@" = "The test file is outside the current workspace: %@"; +"The toolchain query could not be built." = "The toolchain query could not be built."; "The update could not be installed. Download the release manually and try again." = "The update could not be installed. Download the release manually and try again."; "The update package could not be downloaded. Check your internet connection and try again." = "The update package could not be downloaded. Check your internet connection and try again."; "The update package could not be downloaded. Check your internet, proxy, or VPN connection and try again." = "The update package could not be downloaded. Check your internet, proxy, or VPN connection and try again."; @@ -4287,6 +4362,7 @@ "This removes the tag from the repository and affects collaborators who reference it. You can restore it from the banner afterwards." = "This removes the tag from the repository and affects collaborators who reference it. You can restore it from the banner afterwards."; "This repository" = "This repository"; "This response is sent only to the running Git operation." = "This response is sent only to the running Git operation."; +"This row comes from the tool definition of an enabled plugin. Lithe has no built-in tools." = "This row comes from the tool definition of an enabled plugin. Lithe has no built-in tools."; "This runtime is older than the generator requires." = "This runtime is older than the generator requires."; "This sends the current branch to its configured remote." = "This sends the current branch to its configured remote."; "This sends the selected local branch to its configured remote." = "This sends the selected local branch to its configured remote."; @@ -4299,11 +4375,13 @@ "This value is not allowed." = "This value is not allowed."; "This version control provider does not support this action." = "This version control provider does not support this action."; "This version is newer than the selected runtime supports." = "This version is newer than the selected runtime supports."; +"This version manager comes from an enabled plugin. Lithe has no built-in version managers." = "This version manager comes from an enabled plugin. Lithe has no built-in version managers."; "This version of Lithe cannot read update manifest schema %@. Open the Release page and update manually." = "This version of Lithe cannot read update manifest schema %@. Open the Release page and update manually."; "This will start %lld detected services." = "This will start %lld detected services."; "This window" = "This window"; "This working copy format is not supported. Upgrade it with the command-line client." = "This working copy format is not supported. Upgrade it with the command-line client."; "This working copy is no longer available. Refresh and try again." = "This working copy is no longer available. Refresh and try again."; +"This workspace is not open in a window, so only global versions were read." = "This workspace is not open in a window, so only global versions were read."; "This worktree has a detached HEAD. Publish it as a branch before creating a pull request." = "This worktree has a detached HEAD. Publish it as a branch before creating a pull request."; "This worktree has no uncommitted changes." = "This worktree has no uncommitted changes."; "Threads" = "Threads"; @@ -4332,6 +4410,7 @@ "Token usage" = "Token usage"; "Tokens at which the Agent summarizes its context automatically. Lower it to cap what each request costs. Leave empty for the Agent's default. Applies after reconnecting." = "Tokens at which the Agent summarizes its context automatically. Lower it to cap what each request costs. Leave empty for the Agent's default. Applies after reconnecting."; "Tokens of context this Agent may use. The context ring reads as full at this size. Codex applies it as its context window; for Claude Code the model decides its own size, so this sets where it compacts when no threshold is given. Leave empty for the Agent's default. Applies after reconnecting." = "Tokens of context this Agent may use. The context ring reads as full at this size. Codex applies it as its context window; for Claude Code the model decides its own size, so this sets where it compacts when no threshold is given. Leave empty for the Agent's default. Applies after reconnecting."; +"Too many files to check for one tool; some installations were skipped." = "Too many files to check for one tool; some installations were skipped."; "Tool" = "Tool"; "Tool Window" = "Tool Window"; "Tool Windows" = "Tool Windows"; @@ -4342,6 +4421,8 @@ "Tool definitions" = "Tool definitions"; "Tool filter" = "Tool filter"; "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."; "Tools" = "Tools"; "Tools sent" = "Tools sent"; "Total" = "Total"; @@ -4361,6 +4442,7 @@ "Turn on Model call statistics in Agent settings and reconnect to see the MCP tools sent with each request." = "Turn on Model call statistics in Agent settings and reconnect to see the MCP tools sent with each request."; "Turn usage" = "Turn usage"; "Turned off auto-merge" = "Turned off auto-merge"; +"Turned off by LITHE_SKIP_SHELL_ENV" = "Turned off by LITHE_SKIP_SHELL_ENV"; "Turned on auto-merge" = "Turned on auto-merge"; "Turning this off stops every Agent process." = "Turning this off stops every Agent process."; "Turns" = "Turns"; @@ -4538,6 +4620,8 @@ "Username" = "Username"; "Uses the claude.ai account signed in to Claude Code. Sign in with /login in Claude Code if the Agent asks." = "Uses the claude.ai account signed in to Claude Code. Sign in with /login in Claude Code if the Agent asks."; "Uses your local ChatGPT account." = "Uses your local ChatGPT account."; +"Using the PATH saved last time" = "Using the PATH saved last time"; +"Using the environment Lithe was launched with" = "Using the environment Lithe was launched with"; "Usually the reversed domain name of your organization, such as com.example." = "Usually the reversed domain name of your organization, such as com.example."; "VM arguments" = "VM arguments"; "VM options" = "VM options"; @@ -4559,9 +4643,20 @@ "Version %@ is installed." = "Version %@ is installed."; "Version %@ is installed. Restart Lithe to load it." = "Version %@ is installed. Restart Lithe to load it."; "Version Control" = "Version Control"; +"Version check failed" = "Version check failed"; "Version control integration" = "Version control integration"; "Version control provider" = "Version control provider"; +"Version file" = "Version file"; +"Version files" = "Version files"; +"Version files in this directory or its parents, watched by the version managers of this tool, and the versions those managers report for them." = "Version files in this directory or its parents, watched by the version managers of this tool, and the versions those managers report for them."; +"Version files were checked in the first %lld directories only." = "Version files were checked in the first %lld directories only."; +"Version managers" = "Version managers"; +"Version managers come from enabled plugins. Lithe asks them what is installed and what this project requires; they never install or trust anything for you." = "Version managers come from enabled plugins. Lithe asks them what is installed and what this project requires; they never install or trust anything for you."; +"Version managers report different versions" = "Version managers report different versions"; +"Version not queried" = "Version not queried"; +"Version not verified" = "Version not verified"; "Version status" = "Version status"; +"Version too old" = "Version too old"; "Versions & Source" = "Versions & Source"; "View" = "View"; "View Breakpoints" = "View Breakpoints"; @@ -4600,6 +4695,7 @@ "Waiting to quit to complete the update." = "Waiting to quit to complete the update."; "Warning" = "Warning"; "Warnings" = "Warnings"; +"Watched files" = "Watched files"; "Watches" = "Watches"; "Watching pull request checks" = "Watching pull request checks"; "Watching pull request checks and fixing failures" = "Watching pull request checks and fixing failures"; @@ -4608,6 +4704,7 @@ "What Fetch removes or downloads before you start working." = "What Fetch removes or downloads before you start working."; "What Fetch will do" = "What Fetch will do"; "What does this pull request change?" = "What does this pull request change?"; +"What version managers report as global, then the home variables and commands this tool's plugin declares, read from the login shell. A home's version comes from the tool's own version command; a command found on PATH is not run." = "What version managers report as global, then the home variables and commands this tool's plugin declares, read from the login shell. A home's version comes from the tool's own version command; a command found on PATH is not run."; "When a session starts" = "When a session starts"; "When a turn ends" = "When a turn ends"; "When quitting Lithe" = "When quitting Lithe"; @@ -4619,6 +4716,7 @@ "Where should this apply?" = "Where should this apply?"; "Where the rows below that are backed by Git configuration are written." = "Where the rows below that are backed by Git configuration are written."; "Where would you like to open the project ‘%@’?" = "Where would you like to open the project ‘%@’?"; +"Which %@ to use could not be determined." = "Which %@ to use could not be determined."; "Whitespace comparison" = "Whitespace comparison"; "Whole Words" = "Whole Words"; "Wildcard" = "Wildcard"; @@ -9093,6 +9191,105 @@ "native.toolWindow" = "Tool Window"; "native.toolWindows" = "Tool Windows"; "native.toolbarActions" = "Toolbar Actions"; +"native.toolchainSettings.answeredIn" = "Answered in %1$lld ms"; +"native.toolchainSettings.automaticDiscovery" = "Automatic discovery"; +"native.toolchainSettings.automaticStepInfo" = "Installations found in the locations this tool's plugin declares. Each version comes from the tool's own version command, and the highest one is used. Installations inside the project are never run."; +"native.toolchainSettings.backendDidNotOfferProvider" = "The plugin backend did not offer this version manager."; +"native.toolchainSettings.consumers" = "Consumers"; +"native.toolchainSettings.couldNotReadToolchainSettings" = "Could not read toolchain settings."; +"native.toolchainSettings.definition" = "Definition"; +"native.toolchainSettings.directoryCount" = "%1$lld directories"; +"native.toolchainSettings.directoryTreeInfo" = "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."; +"native.toolchainSettings.discovered" = "Discovered"; +"native.toolchainSettings.discoveryDeclarationsUnchecked" = "Installed toolchain declarations could not be checked for discovery."; +"native.toolchainSettings.exportedEnvironment" = "Exported environment"; +"native.toolchainSettings.firstDirectoriesQueried" = "Only the first %1$lld directories were queried."; +"native.toolchainSettings.firstMatch" = "First match"; +"native.toolchainSettings.fixed" = "Fixed"; +"native.toolchainSettings.fixedInParent" = "Fixed in parent"; +"native.toolchainSettings.fixedInfo" = "Fixed versions are saved on this computer in .lithe/toolchains/local.json and also apply to the directories below. This page reads them without changing the file."; +"native.toolchainSettings.fromDirectory" = "From %1$@"; +"native.toolchainSettings.globalDefaults" = "Global defaults"; +"native.toolchainSettings.globalStepInfo" = "What version managers report as global, then the home variables and commands this tool's plugin declares, read from the login shell. A home's version comes from the tool's own version command; a command found on PATH is not run."; +"native.toolchainSettings.installationsCouldNotBeChecked" = "Installations of %1$@ could not be checked."; +"native.toolchainSettings.launchEnvironment" = "Launch environment"; +"native.toolchainSettings.loginShell" = "Login shell"; +"native.toolchainSettings.loginShellInfo" = "At launch Lithe reads your login shell's environment, as a terminal does, and gives it to the terminals, tools and language servers it starts. Only PATH is listed; other variables can hold secrets and are only counted."; +"native.toolchainSettings.moreInstallations" = "%1$lld more"; +"native.toolchainSettings.noAnswerWithinSeconds" = "No answer within %1$lld s"; +"native.toolchainSettings.noEnabledPluginDefinesTools" = "No enabled plugin defines tools."; +"native.toolchainSettings.noEnabledPluginProvidesVersionManager" = "No enabled plugin provides a version manager."; +"native.toolchainSettings.noProjectEnvironmentIsAvailable" = "No project environment is available."; +"native.toolchainSettings.noVersionFile" = "No version file"; +"native.toolchainSettings.noneUsesParent" = "None; uses the parent directory's"; +"native.toolchainSettings.notAValidInstallation" = "Not a valid installation"; +"native.toolchainSettings.notPinned" = "Not pinned"; +"native.toolchainSettings.notQueried" = "Not queried"; +"native.toolchainSettings.notReadStartedFromCache" = "Not read yet; started with the PATH saved last time"; +"native.toolchainSettings.notReadYet" = "Not read yet"; +"native.toolchainSettings.notUsable" = "Not usable"; +"native.toolchainSettings.notUsedInThisProject" = "Not used in this project"; +"native.toolchainSettings.onlyFirstDirectoriesShown" = "Only the first %1$lld directories are shown."; +"native.toolchainSettings.onlyFirstEntriesChecked" = "Only the first %1$lld discovered entries were checked."; +"native.toolchainSettings.onlyFirstInstallationsChecked" = "Only the first %1$lld installations of a tool were checked."; +"native.toolchainSettings.onlyFirstLocationsScanned" = "Only the first %1$lld discovery locations were scanned."; +"native.toolchainSettings.onlyFirstPinsChecked" = "Only the first %1$lld fixed versions were checked."; +"native.toolchainSettings.onlyFirstVersionsChecked" = "Only the first %1$lld installation versions were checked."; +"native.toolchainSettings.openAProjectAndEnableAPluginThatDefinesToolsToInspectItsEnvironment" = "Open a project and enable a plugin that defines tools to inspect its environment."; +"native.toolchainSettings.openPlugins" = "Open Plugins"; +"native.toolchainSettings.pathChangedRestart" = "The login shell's PATH has changed. Restart Lithe so that terminals and tools use it."; +"native.toolchainSettings.pathInfo" = "Directories in the order tools are looked up. The login shell's directories come first, then any that only the environment Lithe was launched with had."; +"native.toolchainSettings.pinnedStepInfo" = "A version saved for this directory on this computer. It applies first, also in the directories below."; +"native.toolchainSettings.projectRequirements" = "Project requirements"; +"native.toolchainSettings.projectRootDirectory" = "Project root directory"; +"native.toolchainSettings.projectStepInfo" = "Version files in this directory or its parents, watched by the version managers of this tool, and the versions those managers report for them."; +"native.toolchainSettings.providerConflict" = "Version managers report different versions"; +"native.toolchainSettings.providerDidNotAnswer" = "%1$@ did not answer: %2$@"; +"native.toolchainSettings.providerDidNotRespond" = "%1$@ did not respond"; +"native.toolchainSettings.providerIsUnavailable" = "%1$@ is unavailable"; +"native.toolchainSettings.query" = "Query"; +"native.toolchainSettings.queryMilliseconds" = "%1$lld ms"; +"native.toolchainSettings.queryNotBuilt" = "The toolchain query could not be built."; +"native.toolchainSettings.readAgain" = "Read again"; +"native.toolchainSettings.readResult" = "Read result"; +"native.toolchainSettings.reading" = "Reading…"; +"native.toolchainSettings.remoteUnavailable" = "Toolchain settings are not available for remote workspaces yet."; +"native.toolchainSettings.replyUnreadable" = "The reply could not be read."; +"native.toolchainSettings.reportsInstalledVersionsOnly" = "Reports installed versions only"; +"native.toolchainSettings.requestedNotInstalled" = "Requested %1$@ is not installed"; +"native.toolchainSettings.resolutionUnavailable" = "Which %1$@ to use could not be determined."; +"native.toolchainSettings.resolutionUndetermined" = "Could not be determined"; +"native.toolchainSettings.runtimeEnvironment" = "Runtime Environment"; +"native.toolchainSettings.runtimeEnvironmentIsolatedPreview" = "Runtime Environment — Isolated Preview"; +"native.toolchainSettings.sameVersionConflict" = "Several installations have the same version"; +"native.toolchainSettings.savedNotVerified" = "Saved, not verified"; +"native.toolchainSettings.theEnvironmentSnapshotCouldNotBeReadRefreshToTryAgain" = "The environment snapshot could not be read. Refresh to try again."; +"native.toolchainSettings.theInstalledToolchainDeclarationsAreInvalid" = "The installed toolchain declarations are invalid."; +"native.toolchainSettings.theProjectRoot" = "the project root"; +"native.toolchainSettings.theSavedToolchainSettingsAreInvalid" = "The saved toolchain settings are invalid."; +"native.toolchainSettings.tooManyFilesForOneTool" = "Too many files to check for one tool; some installations were skipped."; +"native.toolchainSettings.toolDefinitionInfo" = "This row comes from the tool definition of an enabled plugin. Lithe has no built-in tools."; +"native.toolchainSettings.toolchainSources" = "Toolchain Sources"; +"native.toolchainSettings.toolsInfo" = "Each tool uses the first source that has a value: a fixed version, then project version files, then global defaults, then automatic discovery. Installed versions are checked by running each tool's own version command. Enabled version managers report what version files ask for."; +"native.toolchainSettings.turnedOffBySkipVariable" = "Turned off by LITHE_SKIP_SHELL_ENV"; +"native.toolchainSettings.usingCachedPath" = "Using the PATH saved last time"; +"native.toolchainSettings.usingLaunchEnvironment" = "Using the environment Lithe was launched with"; +"native.toolchainSettings.variableCount" = "%1$lld variables"; +"native.toolchainSettings.versionCheckFailed" = "Version check failed"; +"native.toolchainSettings.versionFile" = "Version file"; +"native.toolchainSettings.versionFileCount" = "%1$lld version files"; +"native.toolchainSettings.versionFiles" = "Version files"; +"native.toolchainSettings.versionFilesCheckedInFirstDirectories" = "Version files were checked in the first %1$lld directories only."; +"native.toolchainSettings.versionFilesInfo" = "Files declared by enabled version managers that exist in this directory. A directory without its own uses the nearest parent's."; +"native.toolchainSettings.versionManagerDefinitionInfo" = "This version manager comes from an enabled plugin. Lithe has no built-in version managers."; +"native.toolchainSettings.versionManagers" = "Version managers"; +"native.toolchainSettings.versionManagersInfo" = "Version managers come from enabled plugins. Lithe asks them what is installed and what this project requires; they never install or trust anything for you."; +"native.toolchainSettings.versionNotQueried" = "Version not queried"; +"native.toolchainSettings.versionNotVerified" = "Version not verified"; +"native.toolchainSettings.versionTooOld" = "Version too old"; +"native.toolchainSettings.versionsNotCheckedInTime" = "Some installation versions were not checked in time."; +"native.toolchainSettings.watchedFiles" = "Watched files"; +"native.toolchainSettings.workspaceNotOpenGlobalOnly" = "This workspace is not open in a window, so only global versions were read."; "native.tools" = "Tools"; "native.toolsSent" = "Tools sent"; "native.total" = "Total"; @@ -9515,6 +9712,7 @@ "system Maven" = "system Maven"; "test Java" = "test Java"; "test resources" = "test resources"; +"the project root" = "the project root"; "ui.browse" = "Browse"; "ui.cancel" = "Cancel"; "ui.choose" = "Choose"; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 5f7ffbc62d..7200d76a2f 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -16,8 +16,10 @@ "%@ build files changed" = "%@ 构建文件已更改"; "%@ could not be reached. Check your internet, proxy, or VPN connection and try again." = "无法连接 %@。请检查网络、代理或 VPN 后重试。"; "%@ debug adapter is unavailable." = "%@ 调试适配器不可用。"; +"%@ did not answer: %@" = "%@ 未回答:%@"; "%@ did not finish within %lld seconds." = "%1$@ 未在 %2$lld 秒内完成。"; "%@ did not report a version; %@ or newer is required." = "%1$@ 没有报告版本,需要 %2$@ 或更高版本。"; +"%@ did not respond" = "%@ 未响应"; "%@ disabled" = "%@ 已停用"; "%@ does not include %@; Lithe renders those characters with %@ and the system font." = "%@ 不包含%@;Lithe 会用 %@ 和系统字体显示这些字符。"; "%@ does not provide archived conversations." = "%@ 不支持归档会话。"; @@ -35,6 +37,7 @@ "%@ is no longer an official plugin. It moved to the community plugin %@ in Lithe Store and gets no further updates here. Uninstall it, restart Lithe, then install the community version from the Marketplace." = "%@ 已不再是官方插件,已迁移为 Lithe Store 中的社区插件 %@,这里不会再收到更新。请先卸载它并重启 Lithe,再从插件市场安装社区版本。"; "%@ is not available." = "%@ 不可用。"; "%@ is not installed. Lithe installs it with your npm into its application data." = "%@ 尚未安装。Lithe 会用你的 npm 把它安装到应用数据目录。"; +"%@ is unavailable" = "%@ 不可用"; "%@ is version %@; %@ or newer is required." = "%1$@ 的版本是 %2$@,需要 %3$@ 或更高版本。"; "%@ keeps no workspace state that Lithe can reset." = "%@ 没有 Lithe 可以重置的工作区状态。"; "%@ language server (inside the plugin)" = "%@ 语言服务器(插件内置)"; @@ -81,6 +84,7 @@ "%lld changed files" = "%lld 个文件有更改"; "%lld characters" = "%lld 个字符"; "%lld characters total" = "共 %lld 个字符"; +"%lld directories" = "%lld 个目录"; "%lld failed" = "%lld 个失败"; "%lld file(s) · %@" = "%lld 个文件 · %@"; "%lld files" = "%lld 个文件"; @@ -94,7 +98,9 @@ "%lld interrupted" = "%lld 个已中断"; "%lld lines" = "%lld 行"; "%lld model calls" = "%lld 次模型调用"; +"%lld more" = "另外 %lld 个"; "%lld more notifications" = "还有 %lld 条通知"; +"%lld ms" = "%lld 毫秒"; "%lld non-streaming" = "%lld 次非流式"; "%lld of %lld" = "第 %lld 个,共 %lld 个"; "%lld of %lld services selected" = "已选择 %2$lld 个服务中的 %1$lld 个"; @@ -125,6 +131,8 @@ "%lld turns" = "%lld 轮"; "%lld unchanged lines" = "%lld 行未更改"; "%lld usages" = "%lld 处使用"; +"%lld variables" = "%lld 个变量"; +"%lld version files" = "%lld 个版本文件"; "%lld worktree records need attention" = "%lld 个工作树需要修复"; "%lld worktrees" = "%lld 个工作树"; "%lld/%lld completed" = "已完成 %lld/%lld"; @@ -180,6 +188,7 @@ "A terminal session is still running." = "终端会话仍在运行。"; "A test path must stay inside the current workspace." = "测试路径必须保持在当前工作区内。"; "A unique filename could not be created for the image" = "无法为图片创建唯一的文件名"; +"A version saved for this directory on this computer. It applies first, also in the directories below." = "在这台电脑上为此目录保存的版本,优先级最高,也作用于其下的子目录。"; "A worktree operation is already running, or the repository is unavailable." = "已有工作树操作正在运行,或仓库不可用。"; "ACP adapter" = "ACP 适配器"; "AI" = "AI"; @@ -345,6 +354,7 @@ "Another Hook update is running. Try again." = "另一个 Hook 更新正在进行,请稍后重试。"; "Another global entry uses this name with a different definition." = "另一个全局条目使用了相同名称但定义不同。"; "Another instruction save is running. Try again." = "另一项指令保存正在进行,请重试。"; +"Answered in %lld ms" = "%lld 毫秒内已回答"; "Any Time" = "不限时间"; "Appearance" = "外观"; "Appearance & Behavior" = "外观与行为"; @@ -400,6 +410,7 @@ "Asked for access to %@" = "请求访问 %@"; "Assignees" = "负责人"; "Astro Support" = "Astro 支持"; +"At launch Lithe reads your login shell's environment, as a terminal does, and gives it to the terminals, tools and language servers it starts. Only PATH is listed; other variables can hold secrets and are only counted." = "Lithe 启动时会像终端一样读取登录 Shell 的环境,并提供给它启动的终端、工具和语言服务器。这里只列出 PATH;其他变量可能包含密钥,只显示数量。"; "Attach" = "连接"; "Attach Request" = "附加请求"; "Attach file" = "附件"; @@ -423,6 +434,7 @@ "AutoBuild is rebuilt nightly and may contain unfinished features, serious bugs, or other problems. Stable releases are recommended for most users." = "AutoBuild 每晚滚动构建,可能包含未完成的功能、严重缺陷或其他异常,普通用户建议使用正式版。"; "AutoBuild — Built at %@" = "AutoBuild — 构建自 %@"; "Automatic" = "自动"; +"Automatic discovery" = "自动检测"; "Automatic query ×%lld, identical results" = "自动查询 %lld 次,结果相同"; "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。"; @@ -907,6 +919,7 @@ "Connection timeout" = "连接超时"; "Connections: %lld" = "连接:%lld"; "Console" = "控制台"; +"Consumers" = "使用方"; "Content" = "内容"; "Content SHA-256" = "内容 SHA-256"; "Context" = "上下文"; @@ -977,6 +990,7 @@ "Copy session ID" = "复制会话 ID"; "Copy table" = "复制表格"; "Copy worktree path" = "复制工作树路径"; +"Could not be determined" = "无法确定"; "Could not check for updates" = "无法检查更新"; "Could not check for updates." = "无法检查更新。"; "Could not clear this project's local history. Check storage permissions and try again." = "无法清空此项目的本地历史。请检查存储目录权限后重试。"; @@ -1013,6 +1027,7 @@ "Could not read the project folder. Check that it still exists and that Lithe has permission to access it." = "无法读取项目文件夹,请确认文件夹仍然存在并允许 Lithe 访问。"; "Could not read the selected built-in background." = "无法读取所选的内置背景。"; "Could not read this file" = "无法读取此文件"; +"Could not read toolchain settings." = "无法读取工具链设置。"; "Could not refresh Git status. Refresh to verify the changes." = "无法刷新 Git 状态。请刷新以确认更改结果。"; "Could not register Lithe MCP in %@: %@" = "无法在 %@ 中注册 Lithe MCP:%@"; "Could not remove Lithe MCP from %@: %@" = "无法从 %@ 中移除 Lithe MCP:%@"; @@ -1132,6 +1147,7 @@ "Default push behavior" = "默认 Push 行为"; "Default shell" = "默认 Shell"; "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" = "没有找到定义"; "Definitions" = "定义"; "Delete" = "删除"; @@ -1207,6 +1223,7 @@ "Direct connection" = "直连"; "Direct retry applies only to public MCP Registry searches on this page. System proxy settings and Agent server connections are unchanged. TLS certificate validation remains enabled." = "直连仅用于此页面的公开 MCP Registry 查询,不修改系统代理或 Agent 的服务器连接设置,并继续校验 TLS 证书。"; "Directories" = "目录"; +"Directories in the order tools are looked up. The login shell's directories come first, then any that only the environment Lithe was launched with had." = "按查找工具的顺序排列的目录。登录 Shell 的目录在前,其后是只有 Lithe 启动时环境才有的目录。"; "Directory" = "目录"; "Directory imports preserve scripts, templates and other supporting files. Existing skills are never overwritten." = "导入目录会保留脚本、模板等配套文件,不会覆盖已有技能。"; "Directory or file name" = "目录或文件名"; @@ -1238,6 +1255,7 @@ "Disconnected" = "已断开"; "Discover MCP servers" = "发现 MCP 服务器"; "Discover Tests" = "发现测试"; +"Discovered" = "自动发现"; "Discovered Tests" = "已发现的测试"; "Discovering Maven…" = "正在检测 Maven…"; "Discovering…" = "正在检测…"; @@ -1295,6 +1313,7 @@ "Each repository has its own commit. Completed steps are kept if another repository fails." = "每个仓库会分别提交。某个仓库失败时,已完成的步骤仍会保留。"; "Each row explains the setting, the value Git is actually using, and where that value comes from." = "每一行都会说明设置用途、Git 实际采用的值,以及这个值来自哪里。"; "Each submodule is pushed before its parent." = "先推送子模块,再推送父仓库。"; +"Each tool uses the first source that has a value: a fixed version, then project version files, then global defaults, then automatic discovery. Installed versions are checked by running each tool's own version command. Enabled version managers report what version files ask for." = "每个工具采用第一个有值的来源,依次为固定版本、项目版本文件、全局默认、自动检测。已安装的版本通过运行该工具自己的版本命令来检查。已启用的版本管理器会报告版本文件所要求的版本。"; "Earlier" = "更早"; "Earlier Git commands were truncated to limit memory use." = "为限制内存占用,较早的 Git 执行记录已截断,无法展开。"; "Earlier Git output was omitted to limit memory use." = "为限制内存占用,已省略较早的 Git 输出。"; @@ -1463,6 +1482,7 @@ "Export as PDF…" = "导出为 PDF…"; "Export conversations" = "导出会话"; "Export logs" = "导出日志"; +"Exported environment" = "导出环境变量"; "Exporting conversations…" = "正在导出会话…"; "Exports the index against HEAD." = "导出暂存区相对 HEAD 的更改。"; "Exports the working tree's net changes against HEAD, including selected untracked files." = "导出工作树相对 HEAD 的最终更改,包含所选的未跟踪文件。"; @@ -1532,6 +1552,7 @@ "File tree row height" = "文件树行高"; "Files" = "文件"; "Files added. Save your editor changes before sending so the Agent can read them." = "文件已添加。请在发送前保存编辑内容,以便 Agent 读取最新文件。"; +"Files declared by enabled version managers that exist in this directory. A directory without its own uses the nearest parent's." = "已启用的版本管理器所声明、且存在于此目录中的文件。没有自己版本文件的目录沿用最近上级目录的。"; "Files return to the default ChangeList and may be included by Stage All. Their Git staging state is unchanged." = "文件将回到默认更改列表,可能被全部暂存操作选中。现有 Git 暂存状态保持不变。"; "Files will stay unchanged. Nothing will be staged or committed automatically." = "文件内容保持不变,不会自动暂存或提交。"; "Filter by changed path" = "按更改路径筛选"; @@ -1559,8 +1580,12 @@ "Find references to the selected symbol" = "查找所选符号的引用"; "Finished" = "已完成"; "First %lld locations" = "前 %lld 个位置"; +"First match" = "首个命中"; "Fit to Window" = "适应窗口"; "Fix the failing checks below before installing." = "请先解决下方未通过的检测项,再进行安装。"; +"Fixed" = "固定"; +"Fixed in parent" = "上级目录固定"; +"Fixed versions are saved on this computer in .lithe/toolchains/local.json and also apply to the directories below. This page reads them without changing the file." = "固定版本只保存在这台电脑的 .lithe/toolchains/local.json 中,并作用于其下的子目录。此页面只读取,不会修改该文件。"; "Fixup" = "合并并丢弃消息"; "Floating Point" = "浮点数"; "Folder" = "分组"; @@ -1593,6 +1618,7 @@ "Frames are not available" = "当前无可用栈帧"; "Frames rendered per second" = "每秒渲染帧数"; "Free space" = "剩余空间"; +"From %@" = "来自 %@"; "From (UTC ISO 8601)" = "起始时间(UTC ISO 8601)"; "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 只作用于左侧列出的后端项目。"; "Full Program Declaration" = "完整程序声明"; @@ -1666,6 +1692,7 @@ "GitHub user %@" = "GitHub 用户 %@"; "Global Git" = "全局 Git"; "Global Git configuration" = "全局 Git 配置"; +"Global defaults" = "全局默认"; "Global identity applies to other repositories unless they override it." = "全局身份会用于其他仓库,仓库自身的配置可以覆盖它。"; "Global instructions" = "全局指令"; "Global instructions apply to all projects using this Agent. Each Agent keeps its own native file." = "全局指令适用于使用此 Agent 的所有项目。每个 Agent 使用各自的原生文件。"; @@ -1845,6 +1872,8 @@ "Install the tool on the remote host, add it to the login PATH, and refresh the available runtimes." = "请在远程主机上安装工具并加入登录环境的 PATH,然后刷新可用运行环境。"; "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 of %@ could not be checked." = "无法检查 %@ 的安装。"; "Installed" = "已安装"; "Installed %@" = "已安装 %@"; "Installed %@ · update to %@ available" = "已安装 %@ · 可更新到 %@"; @@ -1854,6 +1883,7 @@ "Installed from Disk" = "从磁盘安装"; "Installed on this Mac: %@" = "本机已安装:%@"; "Installed plugin %@ is invalid: %@" = "已安装的插件 %@ 无效:%@"; +"Installed toolchain declarations could not be checked for discovery." = "无法检查已安装的工具链声明,未进行自动检测。"; "Installing packages…" = "正在安装软件包…"; "Installing update %@…" = "正在安装更新 %@…"; "Installing update…" = "正在安装更新…"; @@ -1990,6 +2020,7 @@ "Latest turn as reported by the Agent; its accounting scope depends on the Agent. Turn on Model call statistics for session totals." = "最近一轮由 Agent 上报的计数,统计口径由 Agent 决定;开启“模型调用统计”后显示整个会话的合计。"; "Launch" = "启动方式"; "Launch Request" = "启动请求"; +"Launch environment" = "启动时环境"; "Launching" = "启动中"; "Layout" = "布局"; "Leave a review" = "提交审查"; @@ -2019,6 +2050,7 @@ "Linked this conversation to a pull request" = "已将此对话关联到拉取请求"; "Linked worktree" = "关联工作树"; "Listed in" = "所在插件仓库"; +"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"; "Lithe Fetch Defaults" = "Lithe 获取默认值"; @@ -2139,6 +2171,7 @@ "Log message" = "日志消息"; "Log timeline" = "日志时间线"; "Log: %@" = "日志:%@"; +"Login shell" = "登录 Shell"; "Logs" = "日志"; "Logs and Diagnostics" = "日志与诊断"; "Look Up Again" = "重新查询"; @@ -2405,6 +2438,7 @@ "No Spring Boot or Maven Module configurations" = "没有 Spring Boot 或 Maven Module 配置"; "No Spring MVC endpoints found" = "未找到 Spring MVC 端点"; "No additional paths" = "没有其他路径"; +"No answer within %lld s" = "%lld 秒内未响应"; "No available local branch. Branches already checked out in another worktree cannot be reused." = "没有可用的本地分支。已在其他工作树检出的分支不能重复使用。"; "No background image selected" = "未选择背景图片"; "No branches found" = "没有匹配的分支"; @@ -2434,6 +2468,8 @@ "No differences were found for this selection." = "所选内容没有差异。"; "No earlier conversations" = "没有更早的对话"; "No effective value configured" = "尚未配置有效值"; +"No enabled plugin defines tools." = "没有已启用的插件定义工具。"; +"No enabled plugin provides a version manager." = "没有已启用的插件提供版本管理器。"; "No executable location was found at the cursor" = "光标所在位置没有可执行的代码"; "No file edits" = "暂无文件修改"; "No files match the conflict filter" = "没有符合冲突筛选条件的文件"; @@ -2484,6 +2520,7 @@ "No program to debug was found. Build it first, or choose one in the “%@” setting of %@." = "没有找到要调试的程序。请先编译它,或在 %2$@ 的“%1$@”设置中选择程序。"; "No program to debug was found. Build it first, or choose one in the “%@” setting of %@. Looked in: %@" = "没有找到要调试的程序。请先编译它,或在 %2$@ 的“%1$@”设置中选择程序。已查找:%3$@"; "No project entry point detected" = "未识别到项目启动入口"; +"No project environment is available." = "没有可用的项目环境。"; "No project history recorded yet" = "还没有项目历史记录"; "No project is open" = "未打开项目"; "No project is open. Rows backed by Git configuration write to the global Git configuration; commit identity needs a repository." = "当前没有打开项目。由 Git 配置支持的选项会写入全局 Git 配置;提交身份需要打开仓库。"; @@ -2527,6 +2564,7 @@ "No upstream configured" = "未设置上游分支"; "No usages found" = "没有找到调用位置"; "No verified Homebrew formula is configured for %@." = "没有为 %@ 配置经过验证的 Homebrew formula。"; +"No version file" = "没有版本文件"; "No web browser could be opened." = "无法打开网页浏览器。"; "No worktrees" = "没有工作树"; "Node.js %lld or later" = "Node.js %lld 或更高版本"; @@ -2538,9 +2576,11 @@ "Non-streaming responses" = "非流式响应"; "None" = "无"; "None (fastest)" = "无(最快)"; +"None; uses the parent directory's" = "无,沿用上级目录"; "Normal Folder" = "普通文件夹"; "Not Assigned" = "未分配"; "Not Supported" = "不支持"; +"Not a valid installation" = "不是有效的安装"; "Not available" = "不可用"; "Not available yet: dependencies are not indexed" = "尚不可用:依赖尚未建立索引"; "Not detected" = "未检测"; @@ -2550,6 +2590,10 @@ "Not installed" = "未安装"; "Not installed · %@" = "未安装 · %@"; "Not installed. Install it to start using this Agent." = "尚未安装。安装后即可使用该 Agent。"; +"Not pinned" = "未固定"; +"Not queried" = "未查询"; +"Not read yet" = "尚未读取"; +"Not read yet; started with the PATH saved last time" = "尚未读取,启动时使用了上次保存的 PATH"; "Not refreshed yet" = "尚未刷新"; "Not run" = "尚未运行"; "Not running" = "未运行"; @@ -2558,7 +2602,9 @@ "Not started" = "未启动"; "Not supported on %@ yet." = "暂不支持 %@ 平台"; "Not tested" = "未测试"; +"Not usable" = "不可用"; "Not used" = "不使用"; +"Not used in this project" = "此项目未使用"; "Not used yet." = "尚未被使用。"; "Nothing to push" = "没有可推送的内容"; "Notifications" = "通知"; @@ -2599,6 +2645,13 @@ "Only the commit message changes. %lld commits will receive new identities. Staged files are not included." = "仅修改提交消息,%lld 笔提交的哈希会改变。不会包含当前暂存的文件。"; "Only the current branch can be updated" = "只能更新当前分支"; "Only the files below are collected. Credentials, tokens, and home-directory paths are redacted automatically; workspace source, editor buffers, and terminal history are never included." = "仅收集下列文件。凭据、令牌和用户目录路径会被自动脱敏;工作区源代码、编辑器缓冲区内容和终端历史绝不会被包含。"; +"Only the first %lld directories are shown." = "只显示前 %lld 个目录。"; +"Only the first %lld directories were queried." = "只查询了前 %lld 个目录。"; +"Only the first %lld discovered entries were checked." = "只检查了前 %lld 个发现的条目。"; +"Only the first %lld discovery locations were scanned." = "只扫描了前 %lld 个检测位置。"; +"Only the first %lld fixed versions were checked." = "只检查了前 %lld 个固定版本。"; +"Only the first %lld installation versions were checked." = "只检查了前 %lld 个安装的版本。"; +"Only the first %lld installations of a tool were checked." = "每个工具只检查了前 %lld 个安装。"; "Only the most recent archived conversations are shown." = "仅显示最近的归档会话。"; "Only the reported Agent edits will be reversed. Conflicting changes and unsaved editor changes will be preserved." = "仅回撤 Agent 上报的修改;有冲突或未保存编辑的文件会保留。"; "Open" = "打开"; @@ -2621,6 +2674,7 @@ "Open Marketplace" = "打开插件市场"; "Open Patch…" = "打开补丁…"; "Open Plugin Management" = "打开插件管理"; +"Open Plugins" = "打开插件页"; "Open Project" = "打开项目"; "Open Projects" = "打开的项目"; "Open Project…" = "打开项目…"; @@ -2636,6 +2690,7 @@ "Open a local folder to start working." = "打开本地文件夹开始工作。"; "Open a local project folder" = "打开本地项目文件夹"; "Open a project" = "打开项目"; +"Open a project and enable a plugin that defines tools to inspect its environment." = "打开项目并启用定义工具的插件,即可查看环境。"; "Open a project before choosing project paths." = "请先打开项目,再选择项目路径。"; "Open a project before starting a debug terminal." = "请先打开项目,再启动调试终端。"; "Open a project containing a pom.xml file." = "打开一个包含 pom.xml 文件的项目。"; @@ -2877,7 +2932,9 @@ "Project opened" = "项目已打开"; "Project options" = "项目选项"; "Project paths must stay inside the current project." = "项目路径必须位于当前项目内。"; +"Project requirements" = "项目要求"; "Project root" = "项目根目录"; +"Project root directory" = "项目根目录"; "Project run configuration is invalid" = "项目运行配置无效"; "Project run configuration not found" = "未找到项目运行配置"; "Project runtime settings are saved locally for this project." = "项目运行时设置会保存在本机的项目配置中。"; @@ -2948,6 +3005,7 @@ "Push to %@" = "推送到 %@"; "Push…" = "推送…"; "Python Support" = "Python 支持"; +"Query" = "查询"; "Quick Information" = "快速信息"; "Quick responses with basic reasoning" = "快速响应,基础推理"; "Quit" = "退出"; @@ -2965,6 +3023,8 @@ "Read %lld files" = "读取了 %lld 个文件"; "Read Memory at Address" = "读取此地址的内存"; "Read Memory at Value" = "读取此值指向的内存"; +"Read again" = "重新读取"; +"Read result" = "读取结果"; "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" = "只读文件预览"; @@ -2975,6 +3035,7 @@ "Reading memory…" = "正在读取内存…"; "Reading project…" = "正在读取项目…"; "Reading registered checkouts." = "正在读取 Git 中注册的检出目录。"; +"Reading…" = "正在读取…"; "Reads the API URL, model and key from ~/.claude/settings.json and ~/.claude.json. Edit those files to change them, then fetch again." = "从 ~/.claude/settings.json 和 ~/.claude.json 读取 API 地址、模型和密钥。需要修改时请编辑这些文件,然后重新获取。"; "Reads the API URL, model and key from ~/.codex/config.toml and auth.json. Edit those files to change them, then fetch again." = "从 ~/.codex/config.toml 和 auth.json 读取 API 地址、模型和密钥。需要修改时请编辑这些文件,然后重新获取。"; "Ready" = "就绪"; @@ -3146,6 +3207,7 @@ "Replay the current branch onto %@. Conflicts may require terminal resolution." = "将当前分支的提交重放到 %@ 之上。如有冲突,可能需要在终端中解决。"; "Replays every commit onto the base branch without a merge commit." = "将所有提交重放到目标分支,不创建合并提交。"; "Reported change" = "上报的修改"; +"Reports installed versions only" = "只报告已安装的版本"; "Repository" = "仓库"; "Repository Diff" = "仓库差异"; "Repository Diff: %@" = "仓库差异: %@"; @@ -3155,6 +3217,7 @@ "Repository needs attention" = "仓库需要处理"; "Request" = "请求"; "Request changes" = "请求修改"; +"Requested %@ is not installed" = "请求的 %@ 未安装"; "Requested by the plugin “%@”." = "由插件“%@”请求。"; "Required fields are marked with *" = "带 * 的字段为必填项"; "Required plugin %@ cannot be disabled." = "必需的插件 %@ 不能被停用。"; @@ -3321,6 +3384,8 @@ "Runs on %@ shipped with the plugin" = "使用插件自带的 %@ 运行"; "Runs the %@ installed on this Mac through the %@ adapter." = "通过 %2$@ 适配器驱动这台 Mac 上已安装的 %1$@。"; "Runtime" = "运行时长"; +"Runtime Environment" = "运行环境"; +"Runtime Environment — Isolated Preview" = "运行环境 — 隔离预览"; "Runtime discovery failed" = "运行环境探测失败"; "Runtime used by the language server (installed on this Mac)" = "语言服务器使用的运行时(本机已安装)"; "Rust Core cache-retention planning is unavailable." = "Rust Core 的缓存保留规划不可用。"; @@ -3376,6 +3441,7 @@ "Saved in Keychain" = "已保存在钥匙串中"; "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" = "已保存,未验证"; "Saves, connects, and opens the remote folder in this window." = "保存、连接,并在当前窗口打开远程文件夹。"; "Saving build provider run configurations is unavailable." = "当前无法保存构建提供方运行配置。"; "Saving replaces the current GitHub metadata." = "保存后将替换当前 GitHub 元数据。"; @@ -3502,6 +3568,7 @@ "Setting values only fill the places the program templates reserve for them, so changing a setting never changes the programs above." = "设置值只会填入程序模板预留的位置,不能改变上面列出的程序,所以修改设置不需要重新审查。"; "Settings" = "设置"; "Settings…" = "设置…"; +"Several installations have the same version" = "多个安装的版本相同"; "Share project on" = "将项目分享至"; "Shared instructions" = "共享指令"; "Shared instructions cannot contain Lithe synchronization markers." = "共享指令正文不能包含 Lithe 同步标记。"; @@ -3654,6 +3721,7 @@ "Solidity Support" = "Solidity 支持"; "Some MCP configuration files could not be restored: %@. Refresh and check these files before retrying." = "部分 MCP 配置文件未能恢复:%@。请刷新并检查这些文件后重试。"; "Some dependencies are not shown." = "部分依赖未显示。"; +"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 selected files belong to another version control root. Refresh and select them again." = "部分所选文件属于其他版本控制根目录。请刷新后重新选择。"; "Some special files could not be copied" = "部分特殊文件未能复制"; @@ -4002,6 +4070,7 @@ "The downloaded update failed its SHA-256 verification. Do not install it; retry or use the Release page." = "下载的更新未通过 SHA-256 校验,请勿安装。请重试或通过 Release 页面下载。"; "The downloaded update failed its checksum verification." = "下载的更新未通过校验。"; "The edit stop changed. Refresh the session before amending." = "编辑暂停位置已更改。请刷新会话后再修改提交。"; +"The environment snapshot could not be read. Refresh to try again." = "无法读取环境快照,请刷新重试。"; "The expression does not hold an address" = "该表达式的值不是地址"; "The featured MCP catalog could not be loaded." = "无法加载内置 MCP 精选目录。"; "The field value does not match its declaration or exceeds its size limit." = "字段值不符合声明或超过大小限制。"; @@ -4029,6 +4098,7 @@ "The host has no bridge method %@." = "宿主没有桥接方法 %@。"; "The host key was not accepted. If the server was reinstalled, remove its old entry from ~/.ssh/known_hosts." = "主机密钥未被接受。如果服务器重装过,请从 ~/.ssh/known_hosts 中删除它的旧记录。"; "The image destination is outside the current workspace" = "图片目标位置在当前工作区之外"; +"The installed toolchain declarations are invalid." = "已安装插件的工具链声明无效。"; "The installer reported an error, but the new CLI version was verified." = "安装器报告了错误,但已确认新 CLI 版本可用。"; "The instruction directory changed. Reload before saving." = "指令目录已变更,请重新加载后再保存。"; "The instruction path must be a regular file, not a symbolic link or directory." = "指令路径必须为普通文件,不能为符号链接或目录。"; @@ -4044,6 +4114,7 @@ "The local Codex account changed. Reconnect to refresh quota." = "本机 Codex 账号已变更,请重新连接以刷新额度。"; "The local adapter is at or above the verified version." = "本地适配器版本等于或高于已核验版本。"; "The location service did not answer." = "地区查询服务没有响应。"; +"The login shell's PATH has changed. Restart Lithe so that terminals and tools use it." = "登录 Shell 的 PATH 已变化,重启 Lithe 后终端和工具才会使用新值。"; "The message text or its buttons are empty, too long, or too many." = "消息文本或按钮为空、过长或过多。"; "The model is too large; some entries are not shown." = "模型过大,部分条目未显示。"; "The monitored JVM has exited or changed." = "监控的 JVM 已退出或发生变化。"; @@ -4061,6 +4132,7 @@ "The patch changes the base commit's tree into the target commit's tree." = "补丁将基准提交的文件内容变为目标提交的文件内容。"; "The plugin archive expands beyond the allowed size: %@" = "插件压缩包解压后超出允许的大小:%@"; "The plugin archive is larger than %@ MB." = "插件压缩包超过 %@ MB。"; +"The plugin backend did not offer this version manager." = "插件后端没有提供此版本管理器。"; "The plugin backend failed to start." = "插件后端启动失败。"; "The plugin backend is no longer running." = "插件后端已不在运行。"; "The plugin backend stopped before it was ready." = "插件后端在就绪前已停止。"; @@ -4116,6 +4188,7 @@ "The remote terminal closed before it started." = "远程终端在启动完成前已关闭。"; "The remote terminal is busy. Wait before sending more input." = "远程终端正忙,请稍后再发送输入。"; "The remote terminal must be started asynchronously." = "远程终端需要异步启动。"; +"The reply could not be read." = "无法读取答复。"; "The reported file changes have been updated. Review them again before rolling back." = "Agent 上报的文件修改已更新,请重新确认后再回撤。"; "The repository changed or another Git operation is running. Preview the patch again." = "仓库已更改或有其他 Git 操作正在运行。请重新预览补丁。"; "The repository changed or another Git operation is running. Refresh before retrying." = "仓库已更改或有其他 Git 操作正在运行。请刷新后重试。"; @@ -4130,6 +4203,7 @@ "The resource path must be relative, use \"/\" separators, and not contain \".\" or \"..\"." = "资源路径必须是相对路径,使用“/”分隔,且不能包含“.”或“..”。"; "The response stopped at the model's token limit." = "回复已达到模型的 token 上限,已停止。"; "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 地址或模型无效。"; "The selected AI provider has no API key." = "所选 AI 服务商没有 API 密钥。"; "The selected background image authorization is invalid." = "所选背景图片的授权无效。"; @@ -4168,6 +4242,7 @@ "The switch controls the Lithe shared block, not the Agent or its own instructions. Save shared changes before switching. Open a row to edit the complete native file." = "开关控制是否使用 Lithe 共享区块,不会停用 Agent 或它的自有指令。请先保存共享修改再操作开关。点击行可编辑完整原生文件。"; "The terminal directory is outside the remote workspace." = "终端目录不在远程工作区内。"; "The test file is outside the current workspace: %@" = "测试文件在当前工作区之外:%@"; +"The toolchain query could not be built." = "无法构建工具链查询。"; "The update could not be installed. Download the release manually and try again." = "无法安装更新,请手动下载版本后重试。"; "The update package could not be downloaded. Check your internet connection and try again." = "无法下载更新包,请检查网络连接后重试。"; "The update package could not be downloaded. Check your internet, proxy, or VPN connection and try again." = "无法下载更新包。请检查网络、代理或 VPN 连接后重试。"; @@ -4287,6 +4362,7 @@ "This removes the tag from the repository and affects collaborators who reference it. You can restore it from the banner afterwards." = "这会从仓库中删除标签,并影响引用该标签的协作者。删除后可以通过提示栏恢复。"; "This repository" = "当前仓库"; "This response is sent only to the running Git operation." = "此回复仅发送给正在运行的 Git 操作。"; +"This row comes from the tool definition of an enabled plugin. Lithe has no built-in tools." = "这一行来自已启用插件的工具定义,Lithe 不内置任何工具。"; "This runtime is older than the generator requires." = "此运行时版本低于模板要求。"; "This sends the current branch to its configured remote." = "这会将当前分支推送到已配置的远程仓库。"; "This sends the selected local branch to its configured remote." = "这会将选中的本地分支推送到已配置的远程仓库。"; @@ -4299,11 +4375,13 @@ "This value is not allowed." = "该值不被允许。"; "This version control provider does not support this action." = "此版本控制 Provider 不支持该操作。"; "This version is newer than the selected runtime supports." = "该版本高于所选运行时支持的版本。"; +"This version manager comes from an enabled plugin. Lithe has no built-in version managers." = "此版本管理器来自已启用的插件,Lithe 不内置任何版本管理器。"; "This version of Lithe cannot read update manifest schema %@. Open the Release page and update manually." = "此版本的 Lithe 无法读取版本为 %@ 的更新清单格式。请打开 Release 页面手动更新。"; "This will start %lld detected services." = "这将启动检测到的 %lld 个服务。"; "This window" = "此窗口"; "This working copy format is not supported. Upgrade it with the command-line client." = "不支持此工作副本格式。请用命令行客户端升级。"; "This working copy is no longer available. Refresh and try again." = "此工作副本已不可用。请刷新后重试。"; +"This workspace is not open in a window, so only global versions were read." = "此工作区未在窗口中打开,因此只读取了全局版本。"; "This worktree has a detached HEAD. Publish it as a branch before creating a pull request." = "当前工作树处于分离 HEAD 状态。请先将它发布为分支,再创建拉取请求。"; "This worktree has no uncommitted changes." = "此工作树没有未提交的更改。"; "Threads" = "线程"; @@ -4332,6 +4410,7 @@ "Token usage" = "Token 用量"; "Tokens at which the Agent summarizes its context automatically. Lower it to cap what each request costs. Leave empty for the Agent's default. Applies after reconnecting." = "Agent 自动总结上下文的 Token 阈值。调低可限制每次请求的花费。留空使用 Agent 默认值。重新连接后生效。"; "Tokens of context this Agent may use. The context ring reads as full at this size. Codex applies it as its context window; for Claude Code the model decides its own size, so this sets where it compacts when no threshold is given. Leave empty for the Agent's default. Applies after reconnecting." = "该 Agent 可使用的上下文 Token 数,上下文圆环以此为满。Codex 把它作为上下文窗口;Claude Code 的窗口由模型决定,没有设置压缩阈值时,它决定压缩的位置。留空使用 Agent 默认值。重新连接后生效。"; +"Too many files to check for one tool; some installations were skipped." = "单个工具需要检查的文件过多,已跳过部分安装。"; "Tool" = "工具"; "Tool Window" = "工具窗口"; "Tool Windows" = "工具窗口"; @@ -4342,6 +4421,8 @@ "Tool definitions" = "工具定义"; "Tool filter" = "工具筛选"; "Toolbar Actions" = "工具栏操作"; +"Toolchain Sources" = "环境来源"; +"Toolchain settings are not available for remote workspaces yet." = "远程工作区暂不支持工具链设置。"; "Tools" = "工具"; "Tools sent" = "发送的工具"; "Total" = "总计"; @@ -4361,6 +4442,7 @@ "Turn on Model call statistics in Agent settings and reconnect to see the MCP tools sent with each request." = "在 Agent 设置中开启模型调用统计并重新连接后,可查看每次请求携带的 MCP 工具。"; "Turn usage" = "本轮用量"; "Turned off auto-merge" = "已关闭自动合并"; +"Turned off by LITHE_SKIP_SHELL_ENV" = "已由 LITHE_SKIP_SHELL_ENV 关闭"; "Turned on auto-merge" = "已开启自动合并"; "Turning this off stops every Agent process." = "关闭后会停止所有 Agent 进程。"; "Turns" = "轮次"; @@ -4538,6 +4620,8 @@ "Username" = "用户名"; "Uses the claude.ai account signed in to Claude Code. Sign in with /login in Claude Code if the Agent asks." = "使用 Claude Code 中登录的 claude.ai 账号;如提示未登录,请在 Claude Code 中用 /login 登录。"; "Uses your local ChatGPT account." = "使用本机 ChatGPT 账号。"; +"Using the PATH saved last time" = "仍在使用上次保存的 PATH"; +"Using the environment Lithe was launched with" = "仍在使用 Lithe 启动时的环境"; "Usually the reversed domain name of your organization, such as com.example." = "通常是组织域名的反写,例如 com.example。"; "VM arguments" = "VM 参数"; "VM options" = "VM 选项"; @@ -4559,9 +4643,20 @@ "Version %@ is installed." = "已安装版本 %@。"; "Version %@ is installed. Restart Lithe to load it." = "已安装版本 %@,重启 Lithe 后生效。"; "Version Control" = "版本控制"; +"Version check failed" = "版本检查失败"; "Version control integration" = "版本控制集成"; "Version control provider" = "版本控制 Provider"; +"Version file" = "版本文件"; +"Version files" = "版本文件"; +"Version files in this directory or its parents, watched by the version managers of this tool, and the versions those managers report for them." = "此目录或上级目录中、由该工具的版本管理器监视的版本文件,以及版本管理器报告的版本。"; +"Version files were checked in the first %lld directories only." = "只在前 %lld 个目录中检查了版本文件。"; +"Version managers" = "版本管理器"; +"Version managers come from enabled plugins. Lithe asks them what is installed and what this project requires; they never install or trust anything for you." = "版本管理器来自已启用的插件。Lithe 会询问它们已安装的版本和此项目的要求;它们不会替你安装或信任任何内容。"; +"Version managers report different versions" = "版本管理器给出的版本不一致"; +"Version not queried" = "版本未查询"; +"Version not verified" = "版本未验证"; "Version status" = "版本状态"; +"Version too old" = "版本过低"; "Versions & Source" = "版本与来源"; "View" = "查看"; "View Breakpoints" = "查看断点"; @@ -4600,6 +4695,7 @@ "Waiting to quit to complete the update." = "等待退出应用以完成更新。"; "Warning" = "警告"; "Warnings" = "警告"; +"Watched files" = "监视的文件"; "Watches" = "监视"; "Watching pull request checks" = "正在关注拉取请求检查"; "Watching pull request checks and fixing failures" = "正在关注拉取请求检查并修复失败项"; @@ -4608,6 +4704,7 @@ "What Fetch removes or downloads before you start working." = "开始工作前,Fetch 会清理或下载什么。"; "What Fetch will do" = "Fetch 会做什么"; "What does this pull request change?" = "这个拉取请求更改了什么?"; +"What version managers report as global, then the home variables and commands this tool's plugin declares, read from the login shell. A home's version comes from the tool's own version command; a command found on PATH is not run." = "版本管理器报告的全局版本,以及插件为该工具声明的主目录变量和命令(从登录 Shell 中读取)。主目录的版本来自该工具自己的版本命令;在 PATH 中找到的命令不会运行。"; "When a session starts" = "会话开始时"; "When a turn ends" = "一轮结束时"; "When quitting Lithe" = "退出 Lithe 时"; @@ -4619,6 +4716,7 @@ "Where should this apply?" = "应用范围"; "Where the rows below that are backed by Git configuration are written." = "下方由 Git 配置支持的选项写入的位置。"; "Where would you like to open the project ‘%@’?" = "你想在哪里打开项目“%@”?"; +"Which %@ to use could not be determined." = "无法确定要使用哪个 %@。"; "Whitespace comparison" = "空白字符比较"; "Whole Words" = "全字匹配"; "Wildcard" = "通配符"; @@ -9093,6 +9191,105 @@ "native.toolWindow" = "工具窗口"; "native.toolWindows" = "工具窗口"; "native.toolbarActions" = "工具栏操作"; +"native.toolchainSettings.answeredIn" = "%1$lld 毫秒内已回答"; +"native.toolchainSettings.automaticDiscovery" = "自动检测"; +"native.toolchainSettings.automaticStepInfo" = "在插件为该工具声明的位置中找到的安装。每个版本来自该工具自己的版本命令,使用其中最高的版本。项目内的安装永远不会运行。"; +"native.toolchainSettings.backendDidNotOfferProvider" = "插件后端没有提供此版本管理器。"; +"native.toolchainSettings.consumers" = "使用方"; +"native.toolchainSettings.couldNotReadToolchainSettings" = "无法读取工具链设置。"; +"native.toolchainSettings.definition" = "定义"; +"native.toolchainSettings.directoryCount" = "%1$lld 个目录"; +"native.toolchainSettings.directoryTreeInfo" = "列出项目根目录,以及自带版本文件或固定版本的目录。固定版本也作用于其下的子目录。"; +"native.toolchainSettings.discovered" = "自动发现"; +"native.toolchainSettings.discoveryDeclarationsUnchecked" = "无法检查已安装的工具链声明,未进行自动检测。"; +"native.toolchainSettings.exportedEnvironment" = "导出环境变量"; +"native.toolchainSettings.firstDirectoriesQueried" = "只查询了前 %1$lld 个目录。"; +"native.toolchainSettings.firstMatch" = "首个命中"; +"native.toolchainSettings.fixed" = "固定"; +"native.toolchainSettings.fixedInParent" = "上级目录固定"; +"native.toolchainSettings.fixedInfo" = "固定版本只保存在这台电脑的 .lithe/toolchains/local.json 中,并作用于其下的子目录。此页面只读取,不会修改该文件。"; +"native.toolchainSettings.fromDirectory" = "来自 %1$@"; +"native.toolchainSettings.globalDefaults" = "全局默认"; +"native.toolchainSettings.globalStepInfo" = "版本管理器报告的全局版本,以及插件为该工具声明的主目录变量和命令(从登录 Shell 中读取)。主目录的版本来自该工具自己的版本命令;在 PATH 中找到的命令不会运行。"; +"native.toolchainSettings.installationsCouldNotBeChecked" = "无法检查 %1$@ 的安装。"; +"native.toolchainSettings.launchEnvironment" = "启动时环境"; +"native.toolchainSettings.loginShell" = "登录 Shell"; +"native.toolchainSettings.loginShellInfo" = "Lithe 启动时会像终端一样读取登录 Shell 的环境,并提供给它启动的终端、工具和语言服务器。这里只列出 PATH;其他变量可能包含密钥,只显示数量。"; +"native.toolchainSettings.moreInstallations" = "另外 %1$lld 个"; +"native.toolchainSettings.noAnswerWithinSeconds" = "%1$lld 秒内未响应"; +"native.toolchainSettings.noEnabledPluginDefinesTools" = "没有已启用的插件定义工具。"; +"native.toolchainSettings.noEnabledPluginProvidesVersionManager" = "没有已启用的插件提供版本管理器。"; +"native.toolchainSettings.noProjectEnvironmentIsAvailable" = "没有可用的项目环境。"; +"native.toolchainSettings.noVersionFile" = "没有版本文件"; +"native.toolchainSettings.noneUsesParent" = "无,沿用上级目录"; +"native.toolchainSettings.notAValidInstallation" = "不是有效的安装"; +"native.toolchainSettings.notPinned" = "未固定"; +"native.toolchainSettings.notQueried" = "未查询"; +"native.toolchainSettings.notReadStartedFromCache" = "尚未读取,启动时使用了上次保存的 PATH"; +"native.toolchainSettings.notReadYet" = "尚未读取"; +"native.toolchainSettings.notUsable" = "不可用"; +"native.toolchainSettings.notUsedInThisProject" = "此项目未使用"; +"native.toolchainSettings.onlyFirstDirectoriesShown" = "只显示前 %1$lld 个目录。"; +"native.toolchainSettings.onlyFirstEntriesChecked" = "只检查了前 %1$lld 个发现的条目。"; +"native.toolchainSettings.onlyFirstInstallationsChecked" = "每个工具只检查了前 %1$lld 个安装。"; +"native.toolchainSettings.onlyFirstLocationsScanned" = "只扫描了前 %1$lld 个检测位置。"; +"native.toolchainSettings.onlyFirstPinsChecked" = "只检查了前 %1$lld 个固定版本。"; +"native.toolchainSettings.onlyFirstVersionsChecked" = "只检查了前 %1$lld 个安装的版本。"; +"native.toolchainSettings.openAProjectAndEnableAPluginThatDefinesToolsToInspectItsEnvironment" = "打开项目并启用定义工具的插件,即可查看环境。"; +"native.toolchainSettings.openPlugins" = "打开插件页"; +"native.toolchainSettings.pathChangedRestart" = "登录 Shell 的 PATH 已变化,重启 Lithe 后终端和工具才会使用新值。"; +"native.toolchainSettings.pathInfo" = "按查找工具的顺序排列的目录。登录 Shell 的目录在前,其后是只有 Lithe 启动时环境才有的目录。"; +"native.toolchainSettings.pinnedStepInfo" = "在这台电脑上为此目录保存的版本,优先级最高,也作用于其下的子目录。"; +"native.toolchainSettings.projectRequirements" = "项目要求"; +"native.toolchainSettings.projectRootDirectory" = "项目根目录"; +"native.toolchainSettings.projectStepInfo" = "此目录或上级目录中、由该工具的版本管理器监视的版本文件,以及版本管理器报告的版本。"; +"native.toolchainSettings.providerConflict" = "版本管理器给出的版本不一致"; +"native.toolchainSettings.providerDidNotAnswer" = "%1$@ 未回答:%2$@"; +"native.toolchainSettings.providerDidNotRespond" = "%1$@ 未响应"; +"native.toolchainSettings.providerIsUnavailable" = "%1$@ 不可用"; +"native.toolchainSettings.query" = "查询"; +"native.toolchainSettings.queryMilliseconds" = "%1$lld 毫秒"; +"native.toolchainSettings.queryNotBuilt" = "无法构建工具链查询。"; +"native.toolchainSettings.readAgain" = "重新读取"; +"native.toolchainSettings.readResult" = "读取结果"; +"native.toolchainSettings.reading" = "正在读取…"; +"native.toolchainSettings.remoteUnavailable" = "远程工作区暂不支持工具链设置。"; +"native.toolchainSettings.replyUnreadable" = "无法读取答复。"; +"native.toolchainSettings.reportsInstalledVersionsOnly" = "只报告已安装的版本"; +"native.toolchainSettings.requestedNotInstalled" = "请求的 %1$@ 未安装"; +"native.toolchainSettings.resolutionUnavailable" = "无法确定要使用哪个 %1$@。"; +"native.toolchainSettings.resolutionUndetermined" = "无法确定"; +"native.toolchainSettings.runtimeEnvironment" = "运行环境"; +"native.toolchainSettings.runtimeEnvironmentIsolatedPreview" = "运行环境 — 隔离预览"; +"native.toolchainSettings.sameVersionConflict" = "多个安装的版本相同"; +"native.toolchainSettings.savedNotVerified" = "已保存,未验证"; +"native.toolchainSettings.theEnvironmentSnapshotCouldNotBeReadRefreshToTryAgain" = "无法读取环境快照,请刷新重试。"; +"native.toolchainSettings.theInstalledToolchainDeclarationsAreInvalid" = "已安装插件的工具链声明无效。"; +"native.toolchainSettings.theProjectRoot" = "项目根目录"; +"native.toolchainSettings.theSavedToolchainSettingsAreInvalid" = "已保存的工具链设置无效。"; +"native.toolchainSettings.tooManyFilesForOneTool" = "单个工具需要检查的文件过多,已跳过部分安装。"; +"native.toolchainSettings.toolDefinitionInfo" = "这一行来自已启用插件的工具定义,Lithe 不内置任何工具。"; +"native.toolchainSettings.toolchainSources" = "环境来源"; +"native.toolchainSettings.toolsInfo" = "每个工具采用第一个有值的来源,依次为固定版本、项目版本文件、全局默认、自动检测。已安装的版本通过运行该工具自己的版本命令来检查。已启用的版本管理器会报告版本文件所要求的版本。"; +"native.toolchainSettings.turnedOffBySkipVariable" = "已由 LITHE_SKIP_SHELL_ENV 关闭"; +"native.toolchainSettings.usingCachedPath" = "仍在使用上次保存的 PATH"; +"native.toolchainSettings.usingLaunchEnvironment" = "仍在使用 Lithe 启动时的环境"; +"native.toolchainSettings.variableCount" = "%1$lld 个变量"; +"native.toolchainSettings.versionCheckFailed" = "版本检查失败"; +"native.toolchainSettings.versionFile" = "版本文件"; +"native.toolchainSettings.versionFileCount" = "%1$lld 个版本文件"; +"native.toolchainSettings.versionFiles" = "版本文件"; +"native.toolchainSettings.versionFilesCheckedInFirstDirectories" = "只在前 %1$lld 个目录中检查了版本文件。"; +"native.toolchainSettings.versionFilesInfo" = "已启用的版本管理器所声明、且存在于此目录中的文件。没有自己版本文件的目录沿用最近上级目录的。"; +"native.toolchainSettings.versionManagerDefinitionInfo" = "此版本管理器来自已启用的插件,Lithe 不内置任何版本管理器。"; +"native.toolchainSettings.versionManagers" = "版本管理器"; +"native.toolchainSettings.versionManagersInfo" = "版本管理器来自已启用的插件。Lithe 会询问它们已安装的版本和此项目的要求;它们不会替你安装或信任任何内容。"; +"native.toolchainSettings.versionNotQueried" = "版本未查询"; +"native.toolchainSettings.versionNotVerified" = "版本未验证"; +"native.toolchainSettings.versionTooOld" = "版本过低"; +"native.toolchainSettings.versionsNotCheckedInTime" = "部分安装的版本未能及时检查。"; +"native.toolchainSettings.watchedFiles" = "监视的文件"; +"native.toolchainSettings.workspaceNotOpenGlobalOnly" = "此工作区未在窗口中打开,因此只读取了全局版本。"; "native.tools" = "工具"; "native.toolsSent" = "发送的工具"; "native.total" = "总计"; @@ -9515,6 +9712,7 @@ "system Maven" = "系统 Maven"; "test Java" = "测试代码"; "test resources" = "测试资源"; +"the project root" = "项目根目录"; "ui.browse" = "浏览"; "ui.cancel" = "取消"; "ui.choose" = "选择"; diff --git a/macos/Sources/Lithe/Application/Composition/AppModelFeatureGraph.swift b/macos/Sources/Lithe/Application/Composition/AppModelFeatureGraph.swift index dc3930efd2..166960c30b 100644 --- a/macos/Sources/Lithe/Application/Composition/AppModelFeatureGraph.swift +++ b/macos/Sources/Lithe/Application/Composition/AppModelFeatureGraph.swift @@ -18,6 +18,7 @@ final class AppModelFeatureGraph { let workspaceSession: WorkspaceSessionCoordinator let workbenchBackground: WorkbenchBackgroundFeatureModel let runtime: RuntimeSettingsFeatureModel + let toolchainSettings: ToolchainSettingsFeatureModel let languageTooling: LanguageToolingFeatureModel let workspace: WorkspaceFeatureModel let github: GitHubFeatureModel @@ -60,6 +61,20 @@ final class AppModelFeatureGraph { directoryMarkStore: services.directoryMarkStore ) self.workspace = workspace + services.toolchainSettingsDirectories = { [weak workspace] in + guard let root = workspace?.rootNode else { return [] } + let prefix = root.url.standardizedFileURL.path + "/" + var pending = root.children ?? [] + var paths: [String] = [] + while !pending.isEmpty && paths.count < 1_024 { + let node = pending.removeLast() + guard node.isDirectory else { continue } + let path = node.url.standardizedFileURL.path + if path.hasPrefix(prefix) { paths.append(String(path.dropFirst(prefix.count))) } + pending.append(contentsOf: node.children ?? []) + } + return paths + } workspaceSession = WorkspaceSessionCoordinator( recentProjectsStore: services.recentProjectsStore, platformUI: services.platformUI, @@ -71,6 +86,10 @@ final class AppModelFeatureGraph { platform: services.workbenchBackgroundPlatform ) runtime = RuntimeSettingsFeatureModel(service: services.projectRuntimeService) + toolchainSettings = ToolchainSettingsFeatureModel( + loader: services.toolchainSettingsLoader, loginShell: services.loginShellEnvironmentInspector, + inputWatcher: services.toolchainInputWatcher + ) languageTooling = LanguageToolingFeatureModel( catalogSource: services.languageProviderCatalogSource, catalogSnapshot: services.languageProviderCatalogSnapshot, diff --git a/macos/Sources/Lithe/Application/Composition/AppServices.swift b/macos/Sources/Lithe/Application/Composition/AppServices.swift index a2835eef3e..040005e5a0 100644 --- a/macos/Sources/Lithe/Application/Composition/AppServices.swift +++ b/macos/Sources/Lithe/Application/Composition/AppServices.swift @@ -28,6 +28,13 @@ final class AppServices { /// Installed plugins with their enabled state, for the New Project /// catalog and the plugins that provide a generator's requirements. var projectGeneratorSources: (@MainActor () -> [ProjectGeneratorPluginSource])? + /// Read-only plugin toolchain projection; absent until platform composition. + var toolchainSettingsLoader: (any ToolchainSettingsLoading)? + var toolchainInputWatcher: (any ToolchainInputWatching)? + /// Existing workspace tree projection; settings must not start a second filesystem scan. + var toolchainSettingsDirectories: (@MainActor () -> [String])? + /// The startup login-shell read, for settings; absent on platforms that do not read one. + var loginShellEnvironmentInspector: (any LoginShellEnvironmentInspecting)? /// Only enabled, registered plugins inject rows. Existing launch metadata /// can supply names, but never creates rows or triggers executable discovery. var languageServerRegistrations: (@MainActor (LanguageProviderCatalog) -> [LanguageServerRegistration])? diff --git a/macos/Sources/Lithe/Application/Features/ToolchainSettingsFeatureModel.swift b/macos/Sources/Lithe/Application/Features/ToolchainSettingsFeatureModel.swift new file mode 100644 index 0000000000..a4751f8e68 --- /dev/null +++ b/macos/Sources/Lithe/Application/Features/ToolchainSettingsFeatureModel.swift @@ -0,0 +1,178 @@ +import Combine +import Darwin +import Foundation +import LitheCoreContracts + +/// Owns only settings presentation and read lifecycle, not runtime discovery or launch selection. +@MainActor +final class ToolchainSettingsFeatureModel: ObservableObject { + enum State: Equatable { + case idle, loading, ready, failed + } + + @Published private(set) var snapshot: ToolchainSettingsSnapshot? + @Published private(set) var state: State = .idle + @Published private(set) var loadError: (any Error)? + @Published private(set) var workspaceURL: URL? + @Published private(set) var selectedDirectoryID = "." + @Published private(set) var expandedToolIDs: Set = [] + @Published private(set) var loginShell: LoginShellEnvironmentSnapshot? + @Published private(set) var isRereadingLoginShell = false + + private let loader: (any ToolchainSettingsLoading)? + private let loginShellInspector: (any LoginShellEnvironmentInspecting)? + private let inputWatcher: (any ToolchainInputWatching)? + private let reloadDelay: Duration + private var generation = UUID() + private var reloadTask: Task? + private var inputObservation: AnyObject? + + init( + loader: (any ToolchainSettingsLoading)? = nil, + loginShell: (any LoginShellEnvironmentInspecting)? = nil, + inputWatcher: (any ToolchainInputWatching)? = nil, + reloadDelay: Duration = .milliseconds(350) + ) { + self.loader = loader + self.loginShellInspector = loginShell + self.inputWatcher = inputWatcher + self.reloadDelay = reloadDelay + } + + var selectedDirectory: ToolchainSettingsSnapshot.Directory? { + snapshot?.directories.first { $0.id == selectedDirectoryID } + } + + var tools: [ToolchainSettingsSnapshot.Tool] { + snapshot?.tools.filter { $0.directoryID == selectedDirectoryID } ?? [] + } + + var providers: [ToolchainSettingsSnapshot.Provider] { snapshot?.providers ?? [] } + var diagnostics: [String] { snapshot?.diagnostics ?? [] } + var isLoading: Bool { state == .loading } + + /// Each load revokes older results; callers retain and cancel their structured UI task. + func load(workspace: URL?) async { + reloadTask?.cancel() + reloadTask = nil + await loadSnapshot(workspace: workspace) + // A read the app started at launch is still running: show its outcome when it lands. + if loginShell?.state == .reading, !Task.isCancelled { await rereadLoginShell() } + } + + /// Reads the login shell again, then the snapshot, whose candidates use the refreshed variables. + func rereadLoginShell() async { + guard let loginShellInspector, !isRereadingLoginShell else { return } + isRereadingLoginShell = true + let next = await loginShellInspector.reread() + isRereadingLoginShell = false + loginShell = next + guard !Task.isCancelled else { return } + await loadSnapshot(workspace: workspaceURL) + } + + /// Any provider input revokes the whole snapshot, so old installations never mix with + /// new requirements. Nothing runs before the pages first load one. + func workspaceFilesChanged(_ changes: [WorkspaceFileChange]) { + guard let root = snapshot?.workspaceURL?.standardizedFileURL.path else { return } + let patterns = providers.flatMap(\.watchFiles) + let touched = changes.contains { change in + let path = change.fileURL.standardizedFileURL.path + guard path.hasPrefix(root + "/") else { return false } + let parts = path.dropFirst(root.count + 1).split(separator: "/").map(String.init) + return patterns.contains { pattern in + let count = pattern.split(separator: "/").count + return count <= parts.count && fnmatch(pattern, parts.suffix(count).joined(separator: "/"), FNM_PATHNAME) == 0 + } + } + if touched { scheduleReload() } + } + + /// Bursts of file events collapse into one reload; the latest reload replaces earlier ones. + private func scheduleReload() { + guard snapshot != nil else { return } + reloadTask?.cancel() + let delay = reloadDelay + reloadTask = Task { [weak self] in + try? await Task.sleep(for: delay) + guard !Task.isCancelled, let self else { return } + await self.loadSnapshot(workspace: self.workspaceURL) + } + } + + private func observeInputs(of snapshot: ToolchainSettingsSnapshot) { + inputObservation = inputWatcher?.watch(paths: snapshot.watch) { [weak self] in + Task { @MainActor [weak self] in self?.scheduleReload() } + } + } + + private func loadSnapshot(workspace: URL?) async { + let request = UUID() + generation = request + let changedWorkspace = workspaceURL != workspace + workspaceURL = workspace + if changedWorkspace { + snapshot = nil + selectedDirectoryID = "." + expandedToolIDs = [] + } + loginShell = loginShellInspector?.inspect() + loadError = nil + state = .loading + do { + guard let loader else { throw SnapshotError.loaderUnavailable } + let next = try await loader.load(workspace: workspace) + guard generation == request else { return } + try Task.checkCancellation() + guard next.workspaceURL == workspace else { + throw SnapshotError.workspaceMismatch + } + snapshot = next + observeInputs(of: next) + if !next.directories.contains(where: { $0.id == selectedDirectoryID }) { + selectedDirectoryID = next.directories.first?.id ?? "." + } + expandedToolIDs.formIntersection(Set(next.tools.map(\.id))) + state = .ready + } catch is CancellationError { + guard generation == request else { return } + state = .idle + } catch { + guard generation == request else { return } + loadError = error + state = .failed + } + } + + func selectDirectory(_ id: String) { + guard snapshot?.directories.contains(where: { $0.id == id }) == true else { return } + selectedDirectoryID = id + } + + func toggleExpanded(_ toolID: String) { + guard tools.contains(where: { $0.id == toolID }) else { return } + if expandedToolIDs.contains(toolID) { + expandedToolIDs.remove(toolID) + } else { + expandedToolIDs.insert(toolID) + } + } + + /// Revocation happens before any suspended load can return to this actor. + func close() { + generation = UUID() + reloadTask?.cancel() + reloadTask = nil + inputObservation = nil + snapshot = nil + workspaceURL = nil + loadError = nil + state = .idle + selectedDirectoryID = "." + expandedToolIDs = [] + } + + private enum SnapshotError: Error { + case workspaceMismatch, loaderUnavailable + } +} diff --git a/macos/Sources/Lithe/Core/Ports/ToolchainSettings.swift b/macos/Sources/Lithe/Core/Ports/ToolchainSettings.swift new file mode 100644 index 0000000000..3b30ae2cae --- /dev/null +++ b/macos/Sources/Lithe/Core/Ports/ToolchainSettings.swift @@ -0,0 +1,213 @@ +import Foundation + +/// Read-only presentation facts; loading a snapshot does not grant launch authority. +/// Every value is observed or saved; nothing stands in for a provider query that has not run. +struct ToolchainSettingsSnapshot: Equatable, Sendable { + /// A provider-declared version file that exists, relative to the workspace root. + struct VersionFile: Equatable, Sendable { + let path: String + /// Snapshot provider identities that watch this file. + let providerIDs: [String] + } + + struct Directory: Identifiable, Equatable, Sendable { + let id: String + let title: String + var depth: Int = 0 + /// Version files in this directory itself; ancestors' files are not repeated. + var versionFiles: [VersionFile] = [] + } + + /// What a provider says one source asks for. `request` stays the manager's own syntax. + struct Requirement: Equatable, Sendable { + let request: String + /// The absolute file that states it. + let source: String + let providerID: String + /// The installation it resolves to; `nil` means the manager has none installed. + var home: String? = nil + var version: String? = nil + } + + /// What Core's `toolchains.resolve` picks for one directory from the same facts the page + /// shows, so the page never orders the sources itself. It is a reading, not a launch permit. + struct Resolution: Equatable, Sendable { + enum Layer: String, Equatable, Sendable { + case pinned, project, global, automatic + } + + enum Outcome: Equatable, Sendable { + /// `home` is `nil` for a home-less wrapper; `version` when the version command answered. + case resolved(home: String?, executable: String, version: String?, provider: String) + /// No source has a usable value; Core's code says why. + case skipped(reason: String) + /// A fixed value or default that cannot be used; lower sources are not tried. + case failed(reason: String) + } + + /// The layer that decided: the selection's, or the one that failed terminally. + var layer: Layer? + var outcome: Outcome + /// Requests no installation satisfies, in the manager's own syntax; lower sources were tried. + var unmet: [String] = [] + /// Layers whose providers disagree and need an explicit choice. + var conflicts: [Layer] = [] + /// Providers asked about this tool that gave no answer here; lower sources were tried. + var unavailable: [Unavailable] = [] + + struct Unavailable: Equatable, Sendable { + /// The qualified `:` Core names. + let provider: String + /// The query outlived its deadline; otherwise the backend could not answer at all. + let timedOut: Bool + } + } + + /// A machine-local saved choice, validated by Core and checked like any other home. + struct Pin: Equatable, Sendable { + let version: String + let home: String + /// The directory that saved it: this row's or the nearest ancestor's. + var directory: String = "." + } + + /// The first `PATH` match for a declared command, as a child process would find it now. + /// It is an existing executable, not a verified version or a selection. + struct Candidate: Equatable, Sendable { + let executable: String + } + + /// What the global and automatic layers find for one tool, checked by Core against + /// native facts and the definition's own version probe. Nothing here grants launch authority. + struct Detection: Equatable, Sendable { + /// A declared login-shell home variable and Core's check of the layout it names. + struct Shell: Equatable, Sendable { + let variable: String + let home: String + var executable: String? = nil + var version: String? = nil + /// Core's skip code when no version was established. + var reason: String? = nil + } + + /// A discovered home that passed every check, with its probed version. + struct Installation: Equatable, Sendable { + let home: String + let executable: String + let version: String + /// `host:system` for a definition scan, otherwise the qualified provider. + let provider: String + /// The scanned entry the home was found under, when the definition's home layout + /// adds a suffix (`jdk-21.jdk` for `jdk-21.jdk/Contents/Home`); it names the installation. + var location: String? = nil + } + + /// A discovered home that failed a check, with Core's skip code. + struct Skipped: Equatable, Sendable { + let home: String + let reason: String + } + + var shell: [Shell] = [] + /// Highest version first. + var installations: [Installation] = [] + var skipped: [Skipped] = [] + /// The home the automatic layer picks; `nil` with `conflict` means equal highest versions. + var selected: String? = nil + var conflict = false + } + + struct Tool: Identifiable, Equatable, Sendable { + let id: String + var directoryID: String = "." + let displayName: String + let pluginID: String + let pluginName: String + var iconName: String? = nil + var pin: Pin? = nil + /// Version files watched by providers of this tool, nearest directory first. + var versionFiles: [VersionFile] = [] + /// Provider answers for this directory, from the nearest queried one. + var requirements: [Requirement] = [] + var globalRequirements: [Requirement] = [] + var candidate: Candidate? = nil + /// `nil` when discovery did not run or could not check this tool. + var detection: Detection? = nil + /// `nil` when Core could not resolve this directory. + var resolution: Resolution? = nil + + var identity: String { "\(directoryID):\(id)" } + } + + struct Provider: Identifiable, Equatable, Sendable { + enum QueryState: Equatable, Sendable { + case notQueried + case answered(milliseconds: Int) + /// The provider's own message, shown unmodified. + case failed(String) + } + + let id: String + let title: String + let pluginID: String + let pluginName: String + var tools: [String] = [] + var watchFiles: [String] = [] + var queryState: QueryState = .notQueried + } + + var workspaceURL: URL? = nil + var directories: [Directory] = [] + var tools: [Tool] = [] + var providers: [Provider] = [] + var diagnostics: [String] = [] + /// Native inputs providers named; any change revokes this snapshot. + var watch: [String] = [] +} + +/// A platform-owned, cancellable read operation; the application model owns stale-result rejection. +protocol ToolchainSettingsLoading: Sendable { + func load(workspace: URL?) async throws -> ToolchainSettingsSnapshot +} + +/// Reports changes to provider inputs outside the workspace, such as global configuration +/// and installation roots. Dropping the returned object ends the observation. +protocol ToolchainInputWatching: Sendable { + func watch(paths: [String], onChange: @escaping @Sendable () -> Void) -> AnyObject? +} + +/// What the settings pages may show about the login-shell read. Only `PATH`'s +/// directories are exposed; other variables may hold secrets and appear as a count. +struct LoginShellEnvironmentSnapshot: Equatable, Sendable { + enum State: String, Equatable, Sendable { + case notRead, reading, read, failed, disabled + } + + enum PathOrigin: String, Equatable, Sendable { + case loginShell, inherited + } + + struct PathEntry: Equatable, Sendable { + let path: String + let origin: PathOrigin + } + + var shell: String = "" + var state: State = .notRead + var variableCount: Int? = nil + var durationMilliseconds: Int? = nil + var readAt: Date? = nil + /// Startup applied the cached login `PATH` before this launch's read finished. + var startedFromCache = false + /// The `PATH` this process and its children use, in lookup order. + var processPath: [PathEntry] = [] + /// A later read changed `PATH`; running and new children keep the startup value until restart. + var pathChangedSinceLaunch = false +} + +/// Reading never changes this process's `PATH`; only variables children lack are refreshed. +protocol LoginShellEnvironmentInspecting: Sendable { + func inspect() -> LoginShellEnvironmentSnapshot + /// Joins a read already in progress instead of starting a second shell. + func reread() async -> LoginShellEnvironmentSnapshot +} diff --git a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift index f91dd620cc..b3722bfd08 100644 --- a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift +++ b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift @@ -1965,6 +1965,8 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { let workspaceConfiguration: ToolingJSONValue? /// Host services a plugin backend may request; omitted when none. let hostServices: [String]? + /// Manifest providers offered to a plugin backend; omitted when none. + let toolchainProviders: [String]? let runtimeExecutablePath: String? let jdtlsLaunchResources: LspJdtlsLaunchResourcesRequest? let cacheDirectory: String? @@ -2112,6 +2114,12 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { let timeoutMilliseconds: Int } + private struct LspToolchainRequest: Encodable { + let sessionId: String + let params: ToolingJSONValue + let timeoutMilliseconds: Int + } + private struct LspVcsRequest: Encodable { let sessionId: String let vcs: String @@ -3850,6 +3858,7 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { initializationOptions: ToolingJSONValue? = nil, workspaceConfiguration: ToolingJSONValue? = nil, hostServices: [String] = [], + toolchainProviders: [String] = [], runtimeExecutableURL: URL? = nil, jdtlsLaunchResources: JDTLSLaunchResources? = nil, cacheDirectoryURL: URL? = nil, @@ -3873,6 +3882,7 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { initializationOptions: initializationOptions, workspaceConfiguration: workspaceConfiguration, hostServices: hostServices.isEmpty ? nil : hostServices, + toolchainProviders: toolchainProviders.isEmpty ? nil : toolchainProviders, runtimeExecutablePath: runtimeExecutableURL?.standardizedFileURL.path, jdtlsLaunchResources: jdtlsLaunchResources.map { LspJdtlsLaunchResourcesRequest( @@ -4182,6 +4192,20 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { ) } + /// Sends one `toolchains/query` to a session that negotiated its provider. + func lspToolchainRequest( + sessionID: String, + params: ToolingJSONValue, + timeout: TimeInterval + ) -> Result { + executeResult( + command: "lsp.toolchainRequest", + payload: LspToolchainRequest( + sessionId: sessionID, params: params, timeoutMilliseconds: max(1, Self.milliseconds(timeout)) + ) + ) + } + /// Invokes a plugin function. Core refuses it unless the session belongs /// to `pluginID` and registered the function. func lspFunctionRequest( diff --git a/macos/Sources/Lithe/Core/Rust/RustLanguageServerRuntimeAdapter.swift b/macos/Sources/Lithe/Core/Rust/RustLanguageServerRuntimeAdapter.swift index c4d2f955b5..8642a40396 100644 --- a/macos/Sources/Lithe/Core/Rust/RustLanguageServerRuntimeAdapter.swift +++ b/macos/Sources/Lithe/Core/Rust/RustLanguageServerRuntimeAdapter.swift @@ -321,6 +321,49 @@ extension RustCoreBridge: LanguageServerRuntimeCore { }.mapError(Self.runtimeFailure) } + func requestToolchainProvider( + sessionID: String, + params: ToolingJSONValue, + timeout: TimeInterval + ) -> Result { + lspToolchainRequest(sessionID: sessionID, params: params, timeout: timeout) + .map { LanguageServerRuntimeOperation(operationID: $0.operationId) } + .mapError(Self.runtimeFailure) + } + + func startPluginToolchainBackend( + pluginID: String, + providerID: String, + executableURL: URL, + arguments: [String], + environment: [String: String], + rootURL: URL, + initializationOptions: ToolingJSONValue?, + hostServices: [String], + toolchainProviders: [String], + initializeTimeout: TimeInterval, + requestTimeout: TimeInterval, + shutdownTimeout: TimeInterval + ) -> Result { + lspStartServer( + providerID: providerID, + pluginID: pluginID, + executableURL: executableURL, + arguments: arguments, + environment: environment, + rootURL: rootURL, + workingDirectoryURL: rootURL, + initializationOptions: initializationOptions, + hostServices: hostServices, + toolchainProviders: toolchainProviders, + initializeTimeout: initializeTimeout, + requestTimeout: requestTimeout, + shutdownTimeout: shutdownTimeout + ).map { + LanguageServerRuntimeStart(sessionID: $0.sessionId, state: $0.state, processID: $0.processId) + }.mapError(Self.runtimeFailure) + } + func startPluginLanguageServer( pluginID: String, providerID: String, diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+LanguageServerRuntime.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+LanguageServerRuntime.swift index d41b75f627..c35cd7733f 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+LanguageServerRuntime.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+LanguageServerRuntime.swift @@ -389,6 +389,7 @@ extension AppModel { runFeatureIfActive?.markDependencyFilesChanged(changes) handleBuildSystemFileChanges(changes) handleVcsProviderFileChanges(changes) + toolchainSettingsFeature.workspaceFilesChanged(changes) let maven = mavenFeatureIfActive let forwarded = changes.filter { change in if Self.isWorkspaceDependencyMetadata( diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+PluginManagement.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+PluginManagement.swift index 3405c22dde..7dfdf2a9f3 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+PluginManagement.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+PluginManagement.swift @@ -68,6 +68,7 @@ extension AppModel { buildSystemsFeatureIfActive?.pluginsChanged() vcsProvidersFeature?.pluginsChanged() closeStalePluginWebPages() + await toolchainSettingsFeature.load(workspace: workspaceURL) } objectWillChange.send() return appliedPluginIDs diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index a35e667164..d50c60f84f 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -206,6 +206,7 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { var notificationFeature: WorkbenchNotificationFeatureModel { featureGraph.notification } var workbenchBackgroundFeature: WorkbenchBackgroundFeatureModel { featureGraph.workbenchBackground } var runtimeFeature: RuntimeSettingsFeatureModel { featureGraph.runtime } + var toolchainSettingsFeature: ToolchainSettingsFeatureModel { featureGraph.toolchainSettings } var languageToolingFeature: LanguageToolingFeatureModel { featureGraph.languageTooling } let debugLaunchConfigurationResolver: DebugLaunchConfigurationResolver let debugPortAvailabilityChecker: any DebugPortAvailabilityChecking @@ -721,6 +722,7 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { func shutdownProjectSession() async { isProjectSessionClosed = true + toolchainSettingsFeature.close() commitFileDeletion.reset() // Native window close can retain this model through Settings without // closing its workspace. Revoke page requests before teardown can yield. @@ -1042,6 +1044,7 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { languageToolingSessionsIfActive?.stopAll() executionModuleCoordinator.resetTests(languageTestServiceIfActive) runtimeFeature.closeProject() + toolchainSettingsFeature.close() executionModuleCoordinator.resetFeatures( maven: mavenFeatureIfActive, run: runFeatureIfActive, diff --git a/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift b/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift index 971368ed8a..637c7fd9fc 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift @@ -6,6 +6,8 @@ enum SettingsCategory: String, CaseIterable, Identifiable { case general = "General" case editor = "Editor" case keymap = "Keymap" + case toolchainEnvironment = "Runtime Environment" + case toolchainSources = "Toolchain Sources" case project = "Project" case run = "Run configurations" case terminal = "Terminal" @@ -49,6 +51,8 @@ enum SettingsCategory: String, CaseIterable, Identifiable { case .editor: "textformat" case .keymap: "keyboard" case .project: "shippingbox" + case .toolchainEnvironment: "shippingbox" + case .toolchainSources: "externaldrive" case .run: "play.rectangle" case .terminal: "terminal" case .lsp: "server.rack" @@ -88,7 +92,7 @@ enum SettingsGroup: String, CaseIterable, Identifiable { switch self { case .appearance: [.general, .updates, .network, .editor, .keymap, .processTermination] case .versionControl: [.git] - case .build: [.project, .run] + case .build: [.toolchainEnvironment, .toolchainSources, .project, .run] case .languages: [.lsp] case .tools: [.terminal, .diagnostics] case .ai: [.providers, .agents] + SettingsSubgroup.library.categories + [.ai] diff --git a/macos/Sources/Lithe/Platform/MacOS/MacApplicationEntryPoint.swift b/macos/Sources/Lithe/Platform/MacOS/MacApplicationEntryPoint.swift index 0f8d39ff00..99ec309135 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MacApplicationEntryPoint.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MacApplicationEntryPoint.swift @@ -18,6 +18,10 @@ enum MacApplicationEntryPoint { exit(CommandLine.arguments[1].withCString { lithe_bridge_git_askpass($0) }) } #if DEBUG + if CommandLine.arguments.contains("--toolchain-settings-demo") || CommandLine.arguments.contains("--toolchain-sources-demo") { + ToolchainSettingsDemo.run() + return + } if CommandLine.arguments.contains("--problems-demo") || Bundle.main.object(forInfoDictionaryKey: "LitheProblemsDemo") as? Bool == true { ProblemsDemo.run() diff --git a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index ddb1d500b2..3e73c8aeb3 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -1257,6 +1257,56 @@ final class MacServiceContainer { } ) 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.entry(for: plugin.descriptor.id) != nil + && [.bundled, .marketplace].contains(plugin.installation.origin) + ) + } + }, + 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))) + return await MacToolchainDiscovery( + 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 + } + } + ).discover(request) + } + ) + services.toolchainInputWatcher = MacToolchainInputWatcher() services.projectGeneratorSources = { [pluginManager] in // Enablement is read on every call, so a plugin enabled from the // window shows its entries and satisfies requirements at once. diff --git a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift index 8523b178d8..6798ee444a 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift @@ -210,6 +210,25 @@ final class MacPluginBackendSessions: PluginBackendRequesting, PluginBackendActi ) } + /// Provider IDs a normalized manifest declares, offered during `initialize`. + static func toolchainProviderIDs(ofManifest manifest: Data) -> [String] { + guard let object = try? JSONSerialization.jsonObject(with: manifest) as? [String: Any], + let providers = object["toolchainProviders"] as? [[String: Any]] else { return [] } + return providers.compactMap { $0["id"] as? String } + } + + /// Asks a plugin's provider, starting or reusing its one backend process. + /// Returns `{ id, response }` unvalidated; see `toolchains.decodeReply`. + func queryToolchainProvider( + _ providerID: String, params: ToolingJSONValue, ofPlugin pluginID: String, timeout: TimeInterval + ) async throws -> ToolingJSONValue { + let session = try await session(for: pluginID) + guard session.toolchainProviders.contains(providerID) else { + throw UnavailableError(message: L10n.string("The plugin backend did not offer this version manager.")) + } + return try await session.toolchainRequest(params: params, timeout: timeout) + } + /// Plugins whose backend starts on `event`, such as `onWorkspaceOpen` /// or `onCommand:`. `owner` limits a command to the plugin whose UI /// module declares it. @@ -482,7 +501,8 @@ final class MacPluginBackendSessions: PluginBackendRequesting, PluginBackendActi // are the backend's only callers. moduleID: ModuleID("\(pluginID).ui"), pluginID: pluginID, - hostServices: hostServices + hostServices: hostServices, + toolchainProviders: packages[pluginID].map { Self.toolchainProviderIDs(ofManifest: $0.manifest) } ?? [] ) let endBackendProcesses = endBackendProcesses let hostDispatcher = hostDispatcher diff --git a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacLoginShellEnvironment.swift b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacLoginShellEnvironment.swift index f4e1c13747..0032a7fc2c 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacLoginShellEnvironment.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacLoginShellEnvironment.swift @@ -18,7 +18,7 @@ import Foundation /// cached value immediately and refreshes it in the background for the next launch. /// The rest of the shell's environment may hold secrets, so it stays in memory and /// only fills variables the process does not already define. -final class MacLoginShellEnvironment: @unchecked Sendable { +final class MacLoginShellEnvironment: LoginShellEnvironmentInspecting, @unchecked Sendable { static let shared = MacLoginShellEnvironment() /// Opt-out for tests and for users whose rc files must not run unattended. @@ -39,6 +39,23 @@ final class MacLoginShellEnvironment: @unchecked Sendable { private var logging: (any ApplicationLogging)? /// Outcomes from before the log writer exists; startup runs ahead of it. private var pendingRecords: [(level: String, message: String, fields: [String: String])] = [] + /// Startup facts and the latest outcome, for the settings page; never variable values. + private var launch = Launch() + private var lastRead: (date: Date, duration: Duration, variables: Int?)? + private var latestLoginPath: [String]? + private var isReading = false + private var completedReads = 0 + /// Held for a whole read, so a second request waits for the first shell. + private let readLock = NSLock() + + private struct Launch { + var shell: String? + var cacheURL: URL? + var disabled = false + var fromCache = false + var loginPath: [String] = [] + var processPath: [String] = [] + } init(overlay: [String: String] = [:]) { self.overlay = overlay @@ -75,10 +92,16 @@ final class MacLoginShellEnvironment: @unchecked Sendable { private func recordRead( _ environment: [String: String]?, shell: String, source: String, started: ContinuousClock.Instant ) { + let duration = started.duration(to: .now) + lock.withLock { + lastRead = (Date(), duration, environment?.count) + if let path = environment?["PATH"], !path.isEmpty { latestLoginPath = Self.entries(path) } + completedReads += 1 + } var fields = [ "shell": URL(fileURLWithPath: shell).lastPathComponent, "source": source, - "duration": String(describing: started.duration(to: .now)) + "duration": String(describing: duration) ] if let environment { fields["variables"] = String(environment.count) @@ -105,8 +128,9 @@ final class MacLoginShellEnvironment: @unchecked Sendable { shellTimeout: TimeInterval? = nil, applyPath: (String) -> Void = { setenv("PATH", $0, 1) } ) -> (@Sendable () -> Void)? { - guard environment[skipVariable] == nil else { return nil } let shell = loginShell(environment: environment) + shared.prepare(shell: shell, cacheURL: cacheURL, environment: environment) + guard environment[skipVariable] == nil else { return nil } let cachedPath = (try? String(contentsOf: cacheURL, encoding: .utf8))? .trimmingCharacters(in: .whitespacesAndNewlines) let loginPath: String @@ -122,20 +146,92 @@ final class MacLoginShellEnvironment: @unchecked Sendable { writeCache(path, to: cacheURL) loginPath = path } - applyPath(mergedPath(login: loginPath, inherited: environment["PATH"])) + let processPath = mergedPath(login: loginPath, inherited: environment["PATH"]) + applyPath(processPath) + shared.lock.withLock { + shared.launch.fromCache = cachedPath?.isEmpty == false + shared.launch.loginPath = entries(loginPath) + shared.launch.processPath = entries(processPath) + } guard cachedPath?.isEmpty == false else { return nil } - return { + // Marked before the closure runs, so a settings page opened meanwhile joins this read. + shared.lock.withLock { shared.isReading = true } + return { shared.refresh(source: "refresh", timeout: shellTimeout ?? refreshTimeout, joining: false) } + } + + /// Records the shell and the inherited `PATH` before anything is applied, so an + /// inspector can describe a process that skipped or failed the startup read. + func prepare(shell: String, cacheURL: URL?, environment: [String: String]) { + lock.withLock { + launch = Launch(shell: shell, cacheURL: cacheURL, disabled: environment[Self.skipVariable] != nil, + processPath: Self.entries(environment["PATH"] ?? "")) + } + } + + /// Reads the shell again for children's non-`PATH` variables and the next launch's + /// cached `PATH`. This process keeps its `PATH`: `setenv` is unsafe once threads run. + func rereadSynchronously(timeout: TimeInterval = MacLoginShellEnvironment.refreshTimeout) -> LoginShellEnvironmentSnapshot { + refresh(source: "manual", timeout: timeout, joining: true) + return inspect() + } + + func reread() async -> LoginShellEnvironmentSnapshot { + // The read blocks on a semaphore for up to the timeout; keep it off the cooperative pool. + await withCheckedContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { continuation.resume(returning: self.rereadSynchronously()) } + } + } + + private func refresh(source: String, timeout: TimeInterval, joining: Bool) { + let (shell, cacheURL, disabled, requested) = lock.withLock { + (launch.shell, launch.cacheURL, launch.disabled, completedReads) + } + guard let shell, !disabled else { return } + readLock.withLock { + // A read that finished while this request waited already answers it. + if joining, lock.withLock({ completedReads > requested }) { return } + lock.withLock { isReading = true } + defer { lock.withLock { isReading = false } } let started = ContinuousClock.now - let login = read(shell: shell, timeout: shellTimeout ?? refreshTimeout) - shared.recordRead(login, shell: shell, source: "refresh", started: started) + let login = Self.read(shell: shell, timeout: timeout) + recordRead(login, shell: shell, source: source, started: started) guard let login else { return } - shared.replaceOverlay(login) - if let path = login["PATH"], !path.isEmpty { - writeCache(path, to: cacheURL) + replaceOverlay(login) + if let cacheURL, let path = login["PATH"], !path.isEmpty { + Self.writeCache(path, to: cacheURL) } } } + func inspect() -> LoginShellEnvironmentSnapshot { + lock.withLock { + var snapshot = LoginShellEnvironmentSnapshot(shell: launch.shell ?? "") + if launch.disabled { snapshot.state = .disabled } + else if isReading { snapshot.state = .reading } + else if let lastRead { snapshot.state = lastRead.variables == nil ? .failed : .read } + if let lastRead { + snapshot.readAt = lastRead.date + snapshot.variableCount = lastRead.variables + let (seconds, attoseconds) = lastRead.duration.components + snapshot.durationMilliseconds = Int(seconds) * 1_000 + Int(attoseconds / 1_000_000_000_000_000) + } + snapshot.startedFromCache = launch.fromCache + let login = Set(launch.loginPath) + snapshot.processPath = launch.processPath.map { + .init(path: $0, origin: login.contains($0) ? .loginShell : .inherited) + } + if let latestLoginPath, !launch.disabled { + snapshot.pathChangedSinceLaunch = latestLoginPath != launch.loginPath + } + return snapshot + } + } + + private static func entries(_ path: String) -> [String] { + var seen = Set() + return path.split(separator: ":").map(String.init).filter { !$0.isEmpty && seen.insert($0).inserted } + } + /// The login shell's entries first, as in a terminal; then inherited entries it /// lacks, so a Lithe started from a terminal keeps that session's additions. static func mergedPath(login: String, inherited: String?) -> String { diff --git a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacToolchainDiscovery+Providers.swift b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacToolchainDiscovery+Providers.swift new file mode 100644 index 0000000000..410ec5f822 --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacToolchainDiscovery+Providers.swift @@ -0,0 +1,296 @@ +import Foundation +import LitheCoreContracts +import LitheModuleAPI + +// Note: 查询、失效与过期结果的决策见 .agents/notes/proposed/architecture/2026-10-31-plugin-injected-toolchains.md + +/// Queries the enabled providers for the settings pages. A global query always runs, so +/// an unusable project still lists installations; each directory then gets its own query. +/// Every reply passes `toolchains.decodeReply` with native facts read here, so a provider +/// never decides what a path is. Providers are only asked; nothing here installs or trusts. +extension MacToolchainDiscovery { + private struct Provider { + let pluginID: String + let id: String + let tools: [String] + var qualified: String { "\(pluginID):\(id)" } + } + + /// The host context a workspace query carries; equal for every plugin of one window. + struct RoundContext: Equatable { + let id: String + let generation: Int + var json: [String: Any] { ["contextID": id, "generation": generation] } + } + + /// One directory's query round. Every provider is asked with the round's generation, as + /// the protocol requires, so `toolchains.resolve` can check their replies together. + struct Round { + let generation: UInt64 + /// The context of the first provider that had one; a reply under another is left out. + var context: RoundContext? + /// Complete `decodeReply` inputs without `filesystem`: resolve gives them one shared snapshot. + var queries: [[String: Any]] = [] + /// Native paths the queries named, read again into that snapshot. + var paths: [String] = [] + } + + struct Answers { + /// Accepted installations, each `{tool, home, provider}`. + var installations: [[String: Any]] = [] + /// Rounds by workspace-relative directory; global-only queries are not a round. + var rounds: [String: Round] = [:] + + /// Adds one query to its directory round unless the round already holds another context. + @discardableResult + mutating func join(_ directory: String, context: WorkspaceContext, input: [String: Any], paths: [String]) -> Bool { + guard var round = rounds[directory] else { return false } + let joined = RoundContext(id: context.contextID, generation: context.generation) + if round.context == nil { round.context = joined } + guard round.context == joined else { return false } + round.queries.append(input) + round.paths += paths + rounds[directory] = round + return true + } + } + + /// The directories asked and resolved: the request's within the budget, always with the root. + static func queriedDirectories(_ request: Request) -> [String] { + var directories = Array(request.directories.prefix(queryDirectoryBudget)) + if !directories.contains(".") { directories.insert(".", at: 0) } + return directories + } + + func queryProviders( + _ request: Request, registry: [String: Any], tools: [[String: Any]], root: String, caseSensitive: Bool, + into result: inout Result + ) async -> Answers { + var answers = Answers() + guard queryProvider != nil else { return answers } + let providers = Self.providers(of: tools) + let directories = Self.queriedDirectories(request) + if request.directories.count > Self.queryDirectoryBudget, !providers.isEmpty { + result.diagnostics.append(L10n.string("Only the first \(Self.queryDirectoryBudget) directories were queried.")) + } + let global = Self.nextGeneration() + for directory in directories { answers.rounds[directory] = Round(generation: Self.nextGeneration()) } + var installations: [String: [String: Any]] = [:] + var watch: Set = [] + for (index, provider) in providers.enumerated() { + let context = await workspaceContext(request.workspace, provider.pluginID) + var failure: String? + let started = ContinuousClock.now + for directory in [nil] + directories.map(Optional.some) where directory == nil || context != nil { + if Task.isCancelled { return Answers() } + let scope = directory.flatMap { directory in context.map { (directory, $0) } } + let generation = directory.flatMap { answers.rounds[$0]?.generation } ?? global + switch await query(provider, operation: "toolchain-\(generation)-\(index)", generation: generation, + scope: scope, registry: registry, tools: tools, root: root, + caseSensitive: caseSensitive) { + case .failure(let message): + failure = failure ?? message + case .unavailable(let input, let paths, let message): + // Core keeps the provider as `providerUnavailable` while lower sources resolve. + failure = failure ?? message + if let directory, let context { + answers.join(directory, context: context, input: input, paths: paths) + } + case .success(let snapshot, let input, let paths): + // A reply under another context stays out of its round, so Core never + // resolves it; its project requirements are not shown either. + var inRound = false + if let directory, let context { + inRound = answers.join(directory, context: context, input: input, paths: paths) + } + for row in snapshot["installations"] as? [[String: Any]] ?? [] { + guard let tool = row["tool"] as? String, let home = row["home"] as? String else { continue } + installations["\(tool)\n\(home)"] = ["tool": tool, "home": home, "provider": provider.qualified] + } + let versions = Self.versions(snapshot) + for row in snapshot["statements"] as? [[String: Any]] ?? [] { + guard let requirement = Self.requirement(row, versions: versions, provider: provider) else { continue } + let tool = row["tool"] as? String ?? "" + if row["scope"] as? String == "global" { + if !(result.globalRequirements[tool] ?? []).contains(requirement) { + result.globalRequirements[tool, default: []].append(requirement) + } + } else if let directory, inRound { + result.requirements[directory, default: [:]][tool, default: []].append(requirement) + } + } + watch.formUnion(snapshot["watch"] as? [String] ?? []) + } + } + if context == nil { + failure = failure ?? L10n.string("This workspace is not open in a window, so only global versions were read.") + } + let elapsed = started.duration(to: .now).components + let milliseconds = Int(elapsed.seconds) * 1_000 + Int(elapsed.attoseconds / 1_000_000_000_000_000) + result.providerStates[provider.qualified] = failure.map { .failed($0) } ?? .answered(milliseconds: milliseconds) + if let failure { result.diagnostics.append(L10n.string("\(provider.id) did not answer: \(failure)")) } + } + result.watch = watch.sorted() + answers.installations = installations.keys.sorted().compactMap { installations[$0] } + return answers + } + + /// A monotonic round generation, unique in this process. + static func nextGeneration() -> UInt64 { + generations.withLock { value -> UInt64 in + value += 1 + return value + } + } + + private 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, + /// which `toolchains.resolve` reads as this provider being unavailable. + case unavailable(input: [String: Any], paths: [String], message: String) + case failure(String) + } + + /// One query and its validation. `scope` is `nil` for a global-only query. + private func query( + _ provider: Provider, operation: String, generation: UInt64, + scope: (directory: String, context: WorkspaceContext)?, registry: [String: Any], + tools: [[String: Any]], root: String, caseSensitive: Bool + ) async -> Outcome { + guard let queryProvider else { return .failure("") } + let params: [String: Any] = [ + "operationID": operation, "generation": generation, "providerID": provider.id, + "authority": ["kind": "local"], + "context": scope.map { ["contextID": $0.context.contextID, "generation": $0.context.generation] as [String: Any] } + ?? NSNull(), + "workspace": scope.map { ["rootID": $0.context.rootID, "root": root, "directory": $0.directory] as [String: Any] } + ?? NSNull(), + "tools": provider.tools, + ] + guard let wire = ToolingJSONValue.fromFoundation(params) else { + return .failure(L10n.string("The toolchain query could not be built.")) + } + let reply: [String: Any] + // Why the host answered for the provider; a provider's own error keeps its message. + 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, + let response = object["response"] as? [String: Any] + else { return .failure(L10n.string("The reply could not be read.")) } + reply = ["id": object["id"]!, "response": response] + } catch { + // A cancelled discovery discards its results; it is not a provider that could not answer. + if error is CancellationError || Task.isCancelled { return .failure(error.localizedDescription) } + let timedOut = error is QueryTimeout + guard let failure = core("toolchains.failureReply", [ + "params": params, + "code": timedOut ? "plugin.toolchain.timeout" : "plugin.toolchain.providerUnavailable", + ])?["reply"] as? [String: Any], failure["id"] is NSNumber, failure["response"] is [String: Any] + else { return .failure(error.localizedDescription) } + reply = failure + hostFailure = timedOut ? L10n.string("No answer within \(Int(Self.queryTimeout)) s") + : error.localizedDescription + } + var facts = Self.FactList(caseSensitive: caseSensitive) + facts.add(root) + if let directory = scope?.directory, directory != "." { facts.add(root + "/" + directory) } + Self.addFacts(of: reply["response"] as? [String: Any] ?? [:], tools: tools, to: &facts) + let input: [String: Any] = [ + "registry": registry, "pluginID": provider.pluginID, + "request": ["jsonrpc": "2.0", "id": reply["id"]!, "method": "toolchains/query", "params": params], + "response": reply["response"]!, "currentGeneration": generation, + "platform": "macos", "filesystemCaseSensitive": caseSensitive, "workspaceRoots": [root], + ] + let outcome = decodeReply(input.merging(["filesystem": facts.values]) { $1 }) + if outcome?["status"] as? String == "ok", let snapshot = outcome?["snapshot"] as? [String: Any] { + return .success(snapshot, input: input, paths: facts.paths) + } + let error = outcome?["error"] as? [String: Any] + let message = error?["message"] as? String ?? L10n.string("The reply could not be read.") + // Only these two codes leave lower sources usable; every other refusal stays a failure. + if ["plugin.toolchain.timeout", "plugin.toolchain.providerUnavailable"].contains(error?["code"] as? String) { + return .unavailable(input: input, paths: facts.paths, message: hostFailure ?? message) + } + return .failure(message) + } + + private struct FactList { + let caseSensitive: Bool + var values: [[String: Any]] = [] + var paths: [String] = [] + private var keys: Set = [] + + init(caseSensitive: Bool) { self.caseSensitive = caseSensitive } + + mutating func add(_ path: String) { + guard path.hasPrefix("/"), path.utf8.count <= 4_096, values.count < MacToolchainDiscovery.factBudget, + keys.insert(caseSensitive ? path : path.lowercased()).inserted + else { return } + paths.append(path) + values.append(MacToolchainDiscovery.fact(of: path)) + } + } + + /// Reads the paths a reply names. An unreadable shape adds nothing, and Core refuses it. + private static func addFacts(of response: [String: Any], tools: [[String: Any]], to facts: inout FactList) { + guard let result = response["result"] as? [String: Any] else { return } + // Executable layouts and `${home}` prepends: what resolving a provider home reads. + let layouts = Dictionary(tools.compactMap { tool -> (String, [String])? in + guard let id = tool["tool"] as? String else { return nil } + let executables = (tool["executableLayouts"] as? [[String: Any]] ?? []).filter(applies) + .compactMap { $0["path"] as? String } + let prepends = (tool["pathPrepend"] as? [String] ?? []).compactMap { template -> String? in + guard template.hasPrefix("${home}/") else { return nil } + return String(template.dropFirst("${home}/".count)) + } + return (id, executables + prepends) + }, uniquingKeysWith: { first, _ in first }) + for row in result["installations"] as? [[String: Any]] ?? [] { + guard let home = row["home"] as? String else { continue } + facts.add(home) + for layout in layouts[row["tool"] as? String ?? ""] ?? [] { facts.add(home + "/" + layout) } + } + for row in result["statements"] as? [[String: Any]] ?? [] { + if let source = row["source"] as? String { facts.add(source) } + } + } + + /// Providers of enabled definitions, each with the sorted tools it may be asked about. + private static func providers(of tools: [[String: Any]]) -> [Provider] { + var byProvider: [String: Set] = [:] + for tool in tools { + guard let id = tool["tool"] as? String else { continue } + for provider in tool["providers"] as? [String] ?? [] { byProvider[provider, default: []].insert(id) } + } + return byProvider.keys.sorted().compactMap { qualified in + guard let (plugin, provider) = qualified.lastColonSplit, let tools = byProvider[qualified] else { return nil } + return Provider(pluginID: plugin, id: provider, tools: tools.sorted()) + } + } + + private static func versions(_ snapshot: [String: Any]) -> [String: String] { + var versions: [String: String] = [:] + for row in snapshot["installations"] as? [[String: Any]] ?? [] { + if let home = row["home"] as? String, let version = row["version"] as? String { versions[home] = version } + } + return versions + } + + private static func requirement( + _ row: [String: Any], versions: [String: String], provider: Provider + ) -> ToolchainSettingsSnapshot.Requirement? { + guard let request = row["request"] as? String, let source = row["source"] as? String else { return nil } + let home = row["installation"] as? String + return .init(request: request, source: source, providerID: provider.qualified, home: home, + version: home.flatMap { versions[$0] }) + } +} + +private extension String { + var lastColonSplit: (String, String)? { + guard let index = lastIndex(of: ":") else { return nil } + return (String(self[.. String = { NSHomeDirectory() } + /// One `toolchains.*` command; `nil` when Core refuses the input. + var core: @Sendable (String, [String: Any]) -> [String: Any]? = Self.callCore + /// The output of a successful probe; failures are not cached and Core reports them. + var probe: @Sendable (_ executable: String, _ arguments: [String]) -> String? = Self.runProbe + /// Asks a plugin's provider and returns `{ id, response }`; `nil` leaves providers unqueried. + var queryProvider: (@Sendable (_ pluginID: String, _ providerID: String, _ params: ToolingJSONValue, + _ timeout: TimeInterval) async throws -> ToolingJSONValue)? = nil + var workspaceContext: @Sendable (_ workspace: URL, _ pluginID: String) async -> WorkspaceContext? = { _, _ in nil } + /// `toolchains.decodeReply`, keeping a refusal's code and message. + var decodeReply: @Sendable ([String: Any]) -> [String: Any]? = Self.callDecodeReply + + func discover(_ request: Request) async -> Result { + var result = Result() + let plugins: [[String: Any]] = request.sources.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] + } + guard !plugins.isEmpty else { return result } + 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 { + result.diagnostics.append(L10n.string("Installed toolchain declarations could not be checked for discovery.")) + return result + } + guard let root = Self.realpath(request.workspace.path) else { return result } + let caseSensitive = (try? request.workspace.resourceValues(forKeys: [.volumeSupportsCaseSensitiveNamesKey]))? + .volumeSupportsCaseSensitiveNames ?? false + var scanner = Scanner(home: homeDirectory(), environment: request.environment, workspace: root, + caseSensitive: caseSensitive) + let answers = await queryProviders( + request, registry: registry, tools: tools, root: root, caseSensitive: caseSensitive, into: &result) + if Task.isCancelled { return result } + var pins = Self.pins(request.local) + if pins.count > Self.pinBudget { + pins = Array(pins.prefix(Self.pinBudget)) + result.diagnostics.append(L10n.string("Only the first \(Self.pinBudget) fixed versions were checked.")) + } + var inputs: [Input] = [] + for tool in tools { + guard let id = tool["tool"] as? String else { continue } + var homes: [String] = [] + for pin in pins where pin.tool == id && !homes.contains(pin.home) { homes.append(pin.home) } + inputs.append(scanner.input(tool: id, plan: tool, + reported: answers.installations.filter { $0["tool"] as? String == id }, + pinned: homes)) + } + result.diagnostics += scanner.diagnostics + + var checked: [String: [String: Any]] = [:] + var targets: [Target] = [] + for input in inputs { + if Task.isCancelled { return result } + guard let value = candidates(input, registry: registry, root: root, caseSensitive: caseSensitive, probes: []) + else { + result.unchecked.append(input.tool) + continue + } + checked[input.tool] = value + targets += Self.probeTargets(value).map { Target(tool: input.tool, executable: $0, arguments: input.arguments) } + // Core checks saved homes like scanned ones and names their executables. These rows + // only choose what to probe, so a pin never becomes an automatic candidate. + if !input.pinned.isEmpty, + let saved = candidates(input, registry: registry, root: root, caseSensitive: caseSensitive, probes: [], + savedOnly: true) { + targets += Self.probeTargets(saved).map { Target(tool: input.tool, executable: $0, arguments: input.arguments) } + } + } + var distinct: Set = [] + targets = targets.filter { distinct.insert($0).inserted } + if targets.count > Self.probeBudget { + targets = Array(targets.prefix(Self.probeBudget)) + result.diagnostics.append(L10n.string("Only the first \(Self.probeBudget) installation versions were checked.")) + } + let (outputs, expired) = await probe(targets) + if expired { result.diagnostics.append(L10n.string("Some installation versions were not checked in time.")) } + var probed: [String: [[String: Any]]] = [:] + for input in inputs { + if Task.isCancelled { return result } + let probes = outputs.filter { $0.key.tool == input.tool } + .map { ["executable": $0.key.executable, "output": $0.value, "exitCode": 0] as [String: Any] } + probed[input.tool] = probes + if !probes.isEmpty { + checked[input.tool] = candidates(input, registry: registry, root: root, caseSensitive: caseSensitive, + probes: probes) + if checked[input.tool] == nil, !result.unchecked.contains(input.tool) { result.unchecked.append(input.tool) } + } + if let value = checked[input.tool] { result.detections[input.tool] = Self.detection(value, locations: input.locations) } + } + // Every queried directory resolves every tool. A fixed version decides alone, so its + // own directory resolves only the tool it fixes and needs no provider round. + let queried = Self.queriedDirectories(request) + var jobs = queried.flatMap { directory in inputs.map { (directory, $0) } } + for pin in pins where !queried.contains(pin.directory) { + guard let input = inputs.first(where: { $0.tool == pin.tool }), + !jobs.contains(where: { $0.0 == pin.directory && $0.1.tool == pin.tool }) + else { continue } + jobs.append((pin.directory, input)) + } + let local = request.local.flatMap { try? JSONSerialization.jsonObject(with: $0) } + for (index, (directory, input)) in jobs.enumerated() { + if Task.isCancelled { return result } + let round = answers.rounds[directory] + let generation = round?.generation ?? Self.nextGeneration() + // The queries and the resolver must see one identical snapshot. + var facts = FactSet(caseSensitive: caseSensitive) + for fact in input.facts { facts.add(fact) } + for path in round?.paths ?? [] { facts.add(scanner.fact(path)) } + let filesystem = facts.values + var payload: [String: Any] = [ + "registry": registry, "tool": input.tool, "directory": directory, + "operationID": "toolchain-settings-\(generation)-\(index)", "generation": generation, + "context": round?.context?.json ?? NSNull(), "platform": "macos", + "filesystemCaseSensitive": caseSensitive, "workspaceRoot": root, "workspaceRoots": [root], + "filesystem": filesystem, "probes": probed[input.tool] ?? [], + "queries": (round?.queries ?? []).map { $0.merging(["filesystem": filesystem]) { $1 } }, + "systemInstallations": input.installations, "environment": input.environment, + ] + if let local { payload["local"] = local } + if let value = core("toolchains.resolve", payload)?["resolution"] as? [String: Any], + let resolution = Self.resolution(value) { + result.resolutions[directory, default: [:]][input.tool] = resolution + } else if !result.unresolved.contains(input.tool) { + result.unresolved.append(input.tool) + } + } + return result + } + + /// Saved pins in a stable order, read only to collect their facts and choose what to + /// resolve; Core validates the document and decides which pin applies. + private static func pins(_ local: Data?) -> [(directory: String, tool: String, home: String)] { + guard let local, let document = try? JSONSerialization.jsonObject(with: local) as? [String: Any], + let pins = document["pins"] as? [String: Any] + else { return [] } + return pins.keys.sorted().flatMap { directory in + (pins[directory] as? [String: Any] ?? [:]).keys.sorted().compactMap { tool in + guard let home = ((pins[directory] as? [String: Any])?[tool] as? [String: Any])?["home"] as? String, + home.hasPrefix("/") + else { return nil } + return (directory, tool, home) + } + } + } + + /// Reads Core's outcome; the layer that failed terminally is the one its trace names. + private static func resolution(_ value: [String: Any]) -> ToolchainSettingsSnapshot.Resolution? { + typealias Resolution = ToolchainSettingsSnapshot.Resolution + guard let outcome = value["outcome"] as? [String: Any] else { return nil } + let reason = (outcome["reason"] as? [String: Any])?["code"] as? String ?? "" + var resolution: Resolution + switch outcome["kind"] as? String { + case "resolved": + guard let selection = outcome["selection"] as? [String: Any], + let executable = selection["executable"] as? String, let provider = selection["provider"] as? String + else { return nil } + resolution = .init(layer: (selection["layer"] as? String).flatMap(Resolution.Layer.init(rawValue:)), + outcome: .resolved(home: selection["home"] as? String, executable: executable, + version: selection["version"] as? String, provider: provider)) + case "terminalFailure": + let failed = (value["trace"] as? [[String: Any]] ?? []).last { $0["state"] as? String == "terminalFailure" } + resolution = .init(layer: (failed?["layer"] as? String).flatMap(Resolution.Layer.init(rawValue:)), + outcome: .failed(reason: reason)) + case "skip": + resolution = .init(layer: nil, outcome: .skipped(reason: reason)) + default: + return nil + } + for row in value["unmet"] as? [[String: Any]] ?? [] { + if row["code"] as? String == "providerUnavailable", let provider = row["provider"] as? String { + let unavailable = Resolution.Unavailable( + provider: provider, timedOut: row["cause"] as? String == "plugin.toolchain.timeout") + if !resolution.unavailable.contains(unavailable) { resolution.unavailable.append(unavailable) } + continue + } + guard row["code"] as? String == "requestedNotInstalled", let request = row["request"] as? String, + !resolution.unmet.contains(request) + else { continue } + resolution.unmet.append(request) + } + for row in value["conflicts"] as? [[String: Any]] ?? [] { + guard let layer = (row["scope"] as? String).flatMap(Resolution.Layer.init(rawValue:)), + !resolution.conflicts.contains(layer) + else { continue } + resolution.conflicts.append(layer) + } + return resolution + } + + /// Native evidence for one tool: its scanned homes, the declared login-shell variables + /// and the facts Core needs to check both. + private struct Input { + let tool: String + let arguments: [String] + let installations: [[String: Any]] + /// Homes `toolchains.decodeReply` accepted, with their qualified provider. + let reported: [[String: Any]] + /// Saved homes, `{tool, home}`; checked only to probe them, never listed as installations. + let pinned: [[String: Any]] + let environment: [String: String] + let facts: [[String: Any]] + /// Canonical scanned entry by the canonical home Core reports, for homes below their entry. + let locations: [String: String] + } + + private struct Target: Hashable { + let tool: String + let executable: String + let arguments: [String] + } + + /// `savedOnly` checks the saved homes alone, without the shell or other installations. + private func candidates( + _ input: Input, registry: [String: Any], root: String, caseSensitive: Bool, probes: [[String: Any]], + savedOnly: Bool = false + ) -> [String: Any]? { + let payload: [String: Any] = [ + "registry": registry, "tool": input.tool, "platform": "macos", "filesystemCaseSensitive": caseSensitive, + "workspaceRoot": root, "filesystem": input.facts, + "systemInstallations": savedOnly ? input.pinned : input.installations, + "providerInstallations": savedOnly ? [] : input.reported, + "environment": savedOnly ? [:] : input.environment, "probes": probes + ] + return core("toolchains.candidates", payload)?["candidates"] as? [String: Any] + } + + /// Executables Core checked but could not version without a probe. + private 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 + guard row["reason"] as? String == "probeFailed", let executable = row["executable"] as? String, + seen.insert(executable).inserted + else { return nil } + return executable + } + } + + /// 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) { + guard !targets.isEmpty else { return ([:], false) } + let deadline = ContinuousClock.now + Self.probeTimeBudget + let probe = self.probe + return await withTaskGroup(of: (Target, String?).self) { group in + var pending = targets[...] + var outputs: [Target: String] = [:] + var expired = false + func start() { + guard !Task.isCancelled, let target = pending.popFirst() else { return } + group.addTask { (target, probe(target.executable, target.arguments)) } + } + for _ in 0..= deadline { + expired = expired || !pending.isEmpty + pending.removeAll() + } + start() + } + return (outputs, expired) + } + } + + private static func detection(_ value: [String: Any], locations: [String: String]) -> ToolchainSettingsSnapshot.Detection { + .init( + shell: (value["shell"] as? [[String: Any]] ?? []).compactMap { row in + guard let variable = row["variable"] as? String, let home = row["home"] as? String else { return nil } + return .init(variable: variable, home: home, executable: row["executable"] as? String, + version: row["version"] as? String, reason: row["reason"] as? String) + }, + installations: (value["installations"] as? [[String: Any]] ?? []).compactMap { row in + guard let home = row["home"] as? String, let executable = row["executable"] as? String, + let version = row["version"] as? String, let provider = row["provider"] as? String + else { return nil } + return .init(home: home, executable: executable, version: version, provider: provider, + location: locations[home]) + }, + skipped: (value["skipped"] as? [[String: Any]] ?? []).compactMap { row in + guard let home = row["home"] as? String, let reason = row["reason"] as? String else { return nil } + return .init(home: home, reason: reason) + }, + selected: value["selected"] as? String, + conflict: !(value["conflicts"] as? [Any] ?? []).isEmpty) + } + + /// Expands scans under shared budgets and records each path's native metadata once. + private struct Scanner { + let home: String + let environment: [String: String] + let workspace: String + let caseSensitive: Bool + var roots = 0 + var entries = 0 + var diagnostics: [String] = [] + private var known: [String: [String: Any]] = [:] + + init(home: String, environment: [String: String], workspace: String, caseSensitive: Bool) { + self.home = home + self.environment = environment + self.workspace = workspace + self.caseSensitive = caseSensitive + } + + mutating func input(tool: String, plan: [String: Any], reported: [[String: Any]], pinned: [String] = []) -> Input { + var facts = FactSet(caseSensitive: caseSensitive) + facts.add(fact(workspace)) + let layouts = (plan["executableLayouts"] as? [[String: Any]] ?? []).filter(MacToolchainDiscovery.applies) + .compactMap { $0["path"] as? String } + let prepend = (plan["pathPrepend"] as? [String] ?? []).compactMap { template -> String? in + template.hasPrefix("${home}") ? String(template.dropFirst("${home}".count)) : nil + } + var homes: [String] = [] + var seen: Set = [] + var locations: [String: String] = [:] + let homeLayouts = (plan["homeLayouts"] as? [[String: Any]] ?? []).filter(MacToolchainDiscovery.applies) + for scan in plan["scans"] as? [[String: Any]] ?? [] { + for entry in expand(scan) { + guard let home = Self.home(of: entry, layouts: homeLayouts), seen.insert(home).inserted else { continue } + homes.append(home) + if home != entry, let canonical = MacToolchainDiscovery.realpath(home), + let named = MacToolchainDiscovery.realpath(entry) { + locations[canonical] = named + } + } + } + if homes.count > MacToolchainDiscovery.installationBudget { + homes = Array(homes.prefix(MacToolchainDiscovery.installationBudget)) + diagnostics.append(L10n.string("Only the first \(MacToolchainDiscovery.installationBudget) installations of a tool were checked.")) + } + var variables: [String: String] = [:] + for candidate in plan["environment"] as? [[String: Any]] ?? [] { + guard let variable = candidate["variable"] as? String, let value = environment[variable], + value.hasPrefix("/"), value.utf8.count <= 4_096 + else { continue } + 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) { + let found = fact(home) + facts.add(found) + let canonical = found["kind"] as? String == "directory" ? found["canonicalPath"] as? String : nil + for layout in layouts { + facts.add(self.fact(Self.join(home, layout))) + if let canonical, canonical != home { facts.add(self.fact(Self.join(canonical, layout))) } + } + for suffix in prepend { + if let canonical { facts.add(self.fact(Self.normalized(canonical + suffix))) } + } + } + if facts.truncated { + diagnostics.append(L10n.string("Too many files to check for one tool; some installations were skipped.")) + } + let version = plan["version"] as? [String: Any] + return Input(tool: tool, arguments: version?["arguments"] as? [String] ?? [], + installations: homes.map { ["tool": tool, "home": $0] }, + reported: reported.map { ["tool": tool, "home": $0["home"] ?? "", "provider": $0["provider"] ?? ""] }, + pinned: pinned.map { ["tool": tool, "home": $0] }, + environment: variables, + facts: facts.values, locations: locations) + } + + /// The directories one scan names, following links; entries inside the workspace are skipped. + private mutating func expand(_ scan: [String: Any]) -> [String] { + guard let root = root(scan["root"] as? [String: Any]), let pattern = scan["pattern"] as? String else { + return [] + } + roots += 1 + if roots > MacToolchainDiscovery.rootBudget { + if roots == MacToolchainDiscovery.rootBudget + 1 { + diagnostics.append(L10n.string("Only the first \(MacToolchainDiscovery.rootBudget) discovery locations were scanned.")) + } + return [] + } + var current = [root] + for component in pattern.split(separator: "/").map(String.init) { + var next: [String] = [] + for directory in current { + if component.contains("*") { + next += names(in: directory) + .filter { (!$0.hasPrefix(".") || component.hasPrefix(".")) && MacToolchainDiscovery.matches(component, $0) } + .map { Self.join(directory, $0) } + } else { + next.append(Self.join(directory, component)) + } + } + current = next + } + return current.filter { entry in + guard let canonical = MacToolchainDiscovery.realpath(entry), fact(entry)["kind"] as? String == "directory" else { return false } + return !Self.within(canonical, workspace, caseSensitive: caseSensitive) + } + } + + private func root(_ value: [String: Any]?) -> String? { + let path: String? = switch value?["kind"] as? String { + case "home": home + case "environment": (value?["variable"] as? String).flatMap { environment[$0] } + case "absolute": value?["path"] as? String + default: nil + } + guard let path, path.hasPrefix("/") else { return nil } + return Self.normalized(path) + } + + private mutating func names(in directory: String) -> [String] { + let remaining = MacToolchainDiscovery.entryBudget - entries + guard remaining > 0, let names = try? FileManager.default.contentsOfDirectory(atPath: directory) else { + return [] + } + entries += min(names.count, remaining) + if names.count > remaining { + diagnostics.append(L10n.string("Only the first \(MacToolchainDiscovery.entryBudget) discovered entries were checked.")) + } + return Array(names.sorted().prefix(remaining)) + } + + /// Metadata of the object a path resolves to, as `execve` and Core's canonical form see it. + /// Each path is read once, so every snapshot built from this scanner agrees. + mutating func fact(_ path: String) -> [String: Any] { + if let fact = known[path] { return fact } + let fact = MacToolchainDiscovery.fact(of: path) + known[path] = fact + return fact + } + + /// The home inside a scanned entry: the first applicable `match`, or the entry itself + /// when the definition declares no layouts. An unmatched entry is not an installation. + static func home(of entry: String, layouts: [[String: Any]]) -> String? { + guard !layouts.isEmpty else { return entry } + let name = (entry as NSString).lastPathComponent + guard let layout = layouts.first(where: { MacToolchainDiscovery.matches($0["match"] as? String ?? "", name) }), + let home = layout["home"] as? String + else { return nil } + return home == "." ? entry : join(entry, home) + } + + static func join(_ directory: String, _ path: String) -> String { + normalized(directory == "/" ? "/" + path : directory + "/" + path) + } + + static func normalized(_ path: String) -> String { + URL(fileURLWithPath: path).standardizedFileURL.path + } + + static func within(_ path: String, _ root: String, caseSensitive: Bool) -> Bool { + let (path, root) = caseSensitive ? (path, root) : (path.lowercased(), root.lowercased()) + return path == root || path.hasPrefix(root == "/" ? "/" : root + "/") + } + } + + /// Metadata of the object a path resolves to, as `execve` and Core's canonical form see it. + static func fact(of path: String) -> [String: Any] { + var status = stat() + guard stat(path, &status) == 0, let canonical = realpath(path) else { + return ["path": path, "canonicalPath": path, "kind": "missing"] + } + return status.st_mode & S_IFMT == S_IFDIR + ? ["path": path, "canonicalPath": canonical, "kind": "directory"] + : ["path": path, "canonicalPath": canonical, "kind": "file", + "executable": status.st_mode & S_IFMT == S_IFREG && access(path, X_OK) == 0] + } + + /// Facts sent to Core: one per path under the platform's case policy, within its limit. + private struct FactSet { + let caseSensitive: Bool + var values: [[String: Any]] = [] + var truncated = false + private var keys: Set = [] + + init(caseSensitive: Bool) { self.caseSensitive = caseSensitive } + + mutating func add(_ fact: [String: Any]) { + guard let path = fact["path"] as? String, + keys.insert(caseSensitive ? path : path.lowercased()).inserted + else { return } + guard values.count < MacToolchainDiscovery.factBudget else { + truncated = true + return + } + values.append(fact) + } + } + + static func applies(_ value: [String: Any]) -> Bool { + (value["platforms"] as? [String]).map { $0.contains("macos") } ?? true + } + + /// A single path component against a `*` glob, as Core matches declared patterns. + static func matches(_ pattern: String, _ value: String) -> Bool { + let parts = pattern.components(separatedBy: "*") + var remaining = Substring(value) + for (index, part) in parts.enumerated() { + if index == 0 { + guard remaining.hasPrefix(part) else { return false } + remaining = remaining.dropFirst(part.count) + } else if index == parts.count - 1 { + return remaining.hasSuffix(part) + } else if let range = remaining.range(of: part) { + remaining = remaining[range.upperBound...] + } else { + return false + } + } + return remaining.isEmpty + } + + private static func realpath(_ path: String) -> String? { + guard let resolved = Darwin.realpath(path, nil) else { return nil } + defer { free(resolved) } + return String(cString: resolved) + } + + @Sendable static func callCore(_ command: String, _ payload: [String: Any]) -> [String: Any]? { + guard let value = ToolingJSONValue.fromFoundation(payload) else { return nil } + let outcome: Swift.Result = RustCoreBridge() + .executeResult(command: command, payload: value) + guard case let .success(data) = outcome, let object = data.foundationObject as? [String: Any], + object["status"] as? String == "ok" + else { return nil } + return object + } + + @Sendable static func callDecodeReply(_ payload: [String: Any]) -> [String: Any]? { + guard let value = ToolingJSONValue.fromFoundation(payload) else { return nil } + let outcome: Swift.Result = RustCoreBridge() + .executeResult(command: "toolchains.decodeReply", payload: value) + guard case let .success(data) = outcome else { return nil } + return data.foundationObject as? [String: Any] + } + + /// Runs the definition's version arguments without a shell, outside the workspace, with + /// a five-second deadline. The cache key includes the arguments and the file identity. + @Sendable static func runProbe(_ executable: String, _ arguments: [String]) -> String? { + let identity = MacRuntimeProbeCache.key(executable: URL(fileURLWithPath: executable)) + let key = MacRuntimeProbeCache.Key(executablePath: identity.executablePath, + identity: identity.identity + "\n" + arguments.joined(separator: "\u{1F}")) + return probeCache.value(for: key) { + let result = MacProcessRunner().run(ProcessRequest( + executablePath: executable, arguments: arguments, timeoutMilliseconds: 5_000)) + guard result.succeeded else { return nil } + // Core accepts 128 KiB; a cut scalar becomes one replacement character. + return result.output.utf8.count <= probeOutputLimit ? result.output + : String(decoding: result.output.utf8.prefix(probeOutputLimit - 3), as: UTF8.self) + } + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacToolchainInputWatcher.swift b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacToolchainInputWatcher.swift new file mode 100644 index 0000000000..7866fbce3a --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacToolchainInputWatcher.swift @@ -0,0 +1,68 @@ +import CoreServices +import Foundation + +/// Watches provider inputs outside the workspace with one FSEvents stream. A named file +/// is watched through its parent, so creating an absent input is reported too. +struct MacToolchainInputWatcher: ToolchainInputWatching { + /// Bounds the stream; providers may name at most 256 paths per reply. + static let pathBudget = 1_024 + + func watch(paths: [String], onChange: @escaping @Sendable () -> Void) -> AnyObject? { + let roots = Self.roots(of: paths) + return roots.isEmpty ? nil : Observation(roots: roots, onChange: onChange) + } + + /// The nearest existing directory of each path, without repeats. + static func roots(of paths: [String]) -> [String] { + var roots: Set = [] + for path in paths.prefix(pathBudget) where path.hasPrefix("/") { + var current = path + var isDirectory: ObjCBool = false + while current != "/" && !(FileManager.default.fileExists(atPath: current, isDirectory: &isDirectory) && isDirectory.boolValue) { + current = (current as NSString).deletingLastPathComponent + } + if current != "/" { roots.insert(current) } + } + return roots.sorted() + } + + private final class Observation { + private final class Callback { + let onChange: @Sendable () -> Void + init(_ onChange: @escaping @Sendable () -> Void) { self.onChange = onChange } + } + + private let queue = DispatchQueue(label: "app.lithe.toolchain-inputs", qos: .utility) + private let callback: Callback + private var stream: FSEventStreamRef? + + init(roots: [String], onChange: @escaping @Sendable () -> Void) { + callback = Callback(onChange) + var context = FSEventStreamContext( + version: 0, info: Unmanaged.passUnretained(callback).toOpaque(), + retain: nil, release: nil, copyDescription: nil + ) + guard let stream = FSEventStreamCreate( + nil, + { _, info, _, _, _, _ in + guard let info else { return } + Unmanaged.fromOpaque(info).takeUnretainedValue().onChange() + }, + &context, roots as CFArray, FSEventStreamEventId(kFSEventStreamEventIdSinceNow), 0.35, + FSEventStreamCreateFlags(kFSEventStreamCreateFlagNoDefer) + ) else { return } + FSEventStreamSetDispatchQueue(stream, queue) + FSEventStreamStart(stream) + self.stream = stream + } + + deinit { + guard let stream else { return } + FSEventStreamStop(stream) + FSEventStreamInvalidate(stream) + FSEventStreamRelease(stream) + // Invalidation stops new callbacks; this drains one already on the queue. + queue.sync {} + } + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacToolchainSettingsLoader.swift b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacToolchainSettingsLoader.swift new file mode 100644 index 0000000000..c5e4aea56a --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacToolchainSettingsLoader.swift @@ -0,0 +1,437 @@ +import Foundation +import Darwin +import LitheCoreContracts +import LitheModuleAPI + +/// Sources must come from the host's validated installed-package registry, not project files. +struct ToolchainSettingsPluginSource: Equatable, Sendable { + let pluginID: String + let name: String + let manifest: Data + let isEnabled: Bool + /// Host-verified official origin; Core requires it for short tool definition IDs. + var verifiedOfficial = false +} + +struct ToolchainSettingsSavedPin: Equatable, Sendable { + let home: String + let version: String + /// The directory whose entry applies: the requested one or its nearest ancestor. + var directory = "." +} + +enum ToolchainSettingsLoadError: LocalizedError, Equatable { + case readFailed, invalidLocal, invalidDeclarations + + 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.") + } + } +} + +/// Reads inert declarations, saved pins and filesystem facts. The injected discovery +/// expands definition scans, queries providers, probes versions Core asks for and shows +/// Core's resolution; this loader never writes a file or selects a runtime for a launch. +struct MacToolchainSettingsLoader: ToolchainSettingsLoading { + /// Bounds the existence checks for version files; the tree projection can hold 1,024 directories. + static let versionFileCheckBudget = 16_384 + /// Bounds Core pin resolutions, one per listed directory and tool. + static let rowBudget = 4_096 + + private let readLocalData: @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] + private let isRemoteWorkspace: @Sendable (URL) -> Bool + private let environment: @Sendable () -> [String: String] + private let fileExists: @Sendable (URL) -> Bool + private let isExecutable: @Sendable (String) -> Bool + private let discover: @Sendable (MacToolchainDiscovery.Request) async -> MacToolchainDiscovery.Result + + init( + sources: @escaping @MainActor @Sendable () -> [ToolchainSettingsPluginSource], + directories: @escaping @MainActor @Sendable (URL?) -> [String] = { _ in [] }, + resolvePin: @escaping @Sendable (Data, String, String) throws -> ToolchainSettingsSavedPin? = Self.resolvePinUsingCore, + isRemoteWorkspace: @escaping @Sendable (URL) -> Bool = { _ in false }, + environment: @escaping @Sendable () -> [String: String] = { + MacLoginShellEnvironment.shared.merged(ProcessInfo.processInfo.environment) + }, + fileExists: @escaping @Sendable (URL) -> Bool = Self.regularFileExists, + isExecutable: @escaping @Sendable (String) -> Bool = Self.executableExists, + readLocalData: (@Sendable (URL) throws -> Data?)? = nil, + discover: @escaping @Sendable (MacToolchainDiscovery.Request) async -> MacToolchainDiscovery.Result = { + await MacToolchainDiscovery().discover($0) + } + ) { + // FileStorage's generic prefix reader may open special files; production + // reads use a checked nonblocking native descriptor instead. + self.readLocalData = readLocalData ?? Self.safeNativeRead + self.sources = sources + self.directories = directories + self.resolvePin = resolvePin + self.isRemoteWorkspace = isRemoteWorkspace + self.environment = environment + self.fileExists = fileExists + self.isExecutable = isExecutable + self.discover = discover + } + + func load(workspace: URL?) async throws -> ToolchainSettingsSnapshot { + try Task.checkCancellation() + if let workspace, isRemoteWorkspace(workspace) { + return .init(workspaceURL: workspace, + diagnostics: [L10n.string("Toolchain settings are not available for remote workspaces yet.")]) + } + let installed = await sources() + let directoryIDs = await directories(workspace) + let io = IO(readLocalData: readLocalData, 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) + } + return try await withTaskCancellationHandler { + let snapshot = try await worker.value + try Task.checkCancellation() + return snapshot + } onCancel: { + worker.cancel() + } + } + + private struct IO: Sendable { + let readLocalData: @Sendable (URL) throws -> Data? + let resolvePin: @Sendable (Data, String, String) throws -> ToolchainSettingsSavedPin? + let environment: @Sendable () -> [String: String] + let fileExists: @Sendable (URL) -> Bool + let isExecutable: @Sendable (String) -> Bool + let discover: @Sendable (MacToolchainDiscovery.Request) async -> MacToolchainDiscovery.Result + } + + private struct Definition { + let id: String + let name: String + let pluginID: String + let pluginName: String + let icon: String? + /// Declared `PATH` commands, in declaration order. + let commands: [String] + } + + private static func snapshot( + workspace: URL?, sources: [ToolchainSettingsPluginSource], directoryIDs: [String], io: IO + ) async throws -> ToolchainSettingsSnapshot { + try Task.checkCancellation() + guard sources.count <= 128 else { throw ToolchainSettingsLoadError.invalidDeclarations } + var definitions: [Definition] = [] + var providers: [ToolchainSettingsSnapshot.Provider] = [] + var knownTools: Set = [] + var knownPlugins: Set = [] + let enabled = sources.filter(\.isEnabled).sorted(by: { $0.pluginID < $1.pluginID }) + for source in enabled { + try Task.checkCancellation() + guard source.manifest.count <= 1_048_576, knownPlugins.insert(source.pluginID).inserted, + let manifest = try? JSONSerialization.jsonObject(with: source.manifest) as? [String: Any], + manifest["id"] as? String == source.pluginID + else { throw ToolchainSettingsLoadError.invalidDeclarations } + for tool in try contribution(manifest, key: "toolDefinitions", maximum: 32) { + guard let id = text(tool["id"], maximum: 192), let name = text(tool["displayName"], maximum: 128), + knownTools.insert(id).inserted + else { throw ToolchainSettingsLoadError.invalidDeclarations } + let icon = (tool["icon"] as? [String: Any])?["host"] as? String + definitions.append(Definition(id: id, name: name, pluginID: source.pluginID, + pluginName: source.name, icon: icon, commands: commands(tool))) + } + var providerIDs: Set = [] + for provider in try contribution(manifest, key: "toolchainProviders", maximum: 8) { + // An installation-only provider omits `watchFiles`; a present non-array is still invalid. + let declaredWatches = provider["watchFiles"].map { $0 as? [[String: Any]] } ?? [] + guard let id = text(provider["id"], maximum: 64), providerIDs.insert(id).inserted, + let title = text(provider["displayName"], maximum: 128), + let tools = provider["tools"] as? [String], tools.count <= 64, + let watches = declaredWatches, watches.count <= 32 + else { throw ToolchainSettingsLoadError.invalidDeclarations } + let patterns = try watches.map { watch in + guard let pattern = text(watch["pattern"], maximum: 256) else { + throw ToolchainSettingsLoadError.invalidDeclarations + } + return pattern + } + providers.append(.init(id: "\(source.pluginID):\(id)", title: title, pluginID: source.pluginID, + pluginName: source.name, tools: tools, watchFiles: patterns)) + } + } + guard definitions.count <= 256 else { throw ToolchainSettingsLoadError.invalidDeclarations } + definitions.sort { $0.id < $1.id } + // Disabled definitions also disappear from provider tool capsules; no provider is invoked. + providers = providers.map { provider in + var result = provider + result.tools = provider.tools.filter { knownTools.contains($0) }.sorted() + return result + } + guard let workspace else { + return .init(providers: providers) + } + let document = try localDocument(workspace: workspace, readLocalData: io.readLocalData) + // 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 { + guard validDirectory(id) else { throw ToolchainSettingsLoadError.invalidLocal } + } + var diagnostics: [String] = [] + let found = try versionFiles(workspace: workspace, directories: ["."] + directoryIDs, providers: providers, + 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 { + var current = id + while current != "." { + listed.insert(current) + current = parent(current) + } + } + var ordered = listed.sorted(by: treeOrder) + let limit = max(1, rowBudget / max(definitions.count, 1)) + if ordered.count > limit { + ordered = Array(ordered.prefix(limit)) + diagnostics.append(L10n.string("Only the first \(limit) directories are shown.")) + } + let directories = ordered.map { id in + ToolchainSettingsSnapshot.Directory(id: id, + title: id == "." ? workspace.lastPathComponent : String(id.split(separator: "/").last ?? ""), + depth: id == "." ? 0 : id.split(separator: "/").count, + versionFiles: found[id] ?? []) + } + let environment = io.environment() + let candidates = definitions.map { candidate(for: $0, environment: environment, isExecutable: io.isExecutable) } + let discovered = definitions.isEmpty ? MacToolchainDiscovery.Result() + : await io.discover(.init(workspace: workspace, sources: enabled, environment: environment, + directories: ["."] + found.keys.filter { $0 != "." }.sorted(by: treeOrder), + local: document)) + try Task.checkCancellation() + 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.")) + } + for definition in definitions where discovered.unresolved.contains(definition.id) { + diagnostics.append(L10n.string("Which \(definition.name) to use could not be determined.")) + } + var rows: [ToolchainSettingsSnapshot.Tool] = [] + for directory in directories { + try Task.checkCancellation() + for (definition, candidate) in zip(definitions, candidates) { + try Task.checkCancellation() + let saved = try document.flatMap { try io.resolvePin($0, directory.id, definition.id) } + let watching = Set(providers.filter { $0.tools.contains(definition.id) }.map(\.id)) + var files: [ToolchainSettingsSnapshot.VersionFile] = [] + var current: String? = directory.id + while let id = current { + files += (found[id] ?? []).filter { !watching.isDisjoint(with: $0.providerIDs) } + current = id == "." ? nil : parent(id) + } + // A directory without its own version file answers as its nearest queried ancestor. + var queried = directory.id + while queried != ".", found[queried] == nil { queried = parent(queried) } + // A fixed version decides alone, so the directory that saved it was resolved for it. + let resolved = saved?.directory ?? queried + rows.append(.init(id: definition.id, directoryID: directory.id, displayName: definition.name, + pluginID: definition.pluginID, pluginName: definition.pluginName, iconName: definition.icon, + pin: saved.map { .init(version: $0.version, home: $0.home, directory: $0.directory) }, + versionFiles: files, requirements: discovered.requirements[queried]?[definition.id] ?? [], + globalRequirements: discovered.globalRequirements[definition.id] ?? [], + candidate: candidate, detection: discovered.detections[definition.id], + resolution: discovered.resolutions[resolved]?[definition.id])) + } + } + let queried = providers.map { provider in + var result = provider + result.queryState = discovered.providerStates[provider.id] ?? .notQueried + return result + } + return .init(workspaceURL: workspace, directories: directories, tools: rows, providers: queried, + diagnostics: diagnostics, watch: discovered.watch) + } + + /// Literal provider watch files that exist as regular files; glob patterns stay with the provider. + private static func versionFiles( + workspace: URL, directories: [String], providers: [ToolchainSettingsSnapshot.Provider], + fileExists: @Sendable (URL) -> Bool, diagnostics: inout [String] + ) throws -> [String: [ToolchainSettingsSnapshot.VersionFile]] { + var watchers: [String: [String]] = [:] + for provider in providers { + for pattern in provider.watchFiles where !pattern.contains("*") && validDirectory(pattern) { + watchers[pattern, default: []].append(provider.id) + } + } + let patterns = watchers.keys.sorted() + guard !patterns.isEmpty else { return [:] } + // Shallow directories first, so a budget cut keeps the files that most rows inherit. + let ordered = Set(directories).sorted { left, right in + let (l, r) = (left == "." ? 0 : left.split(separator: "/").count, + right == "." ? 0 : right.split(separator: "/").count) + return l == r ? treeOrder(left, right) : l < r + } + let reachable = max(1, versionFileCheckBudget / patterns.count) + if ordered.count > reachable { + diagnostics.append(L10n.string("Version files were checked in the first \(reachable) directories only.")) + } + var found: [String: [ToolchainSettingsSnapshot.VersionFile]] = [:] + for directory in ordered.prefix(reachable) { + try Task.checkCancellation() + for pattern in patterns { + let path = directory == "." ? pattern : directory + "/" + pattern + guard fileExists(workspace.appendingPathComponent(path)) else { continue } + found[directory, default: []].append(.init(path: path, providerIDs: watchers[pattern] ?? [])) + } + } + return found + } + + private static func commands(_ tool: [String: Any]) -> [String] { + guard let executables = tool["executables"] as? [[String: Any]] else { return [] } + return executables.prefix(32).compactMap { candidate in + if let platforms = candidate["platforms"] as? [String], !platforms.contains("macos") { return nil } + guard candidate["kind"] as? String == "command", let command = text(candidate["command"], maximum: 64), + !command.contains("/") + else { return nil } + return command + } + } + + /// The first declared command found on `PATH`, in declaration order. Login-shell home + /// variables are checked by Core through discovery instead. + private static func candidate( + for definition: Definition, environment: [String: String], isExecutable: @Sendable (String) -> Bool + ) -> ToolchainSettingsSnapshot.Candidate? { + let searchPath = (environment["PATH"] ?? "").split(separator: ":").prefix(256) + .map(String.init).filter { $0.hasPrefix("/") } + for command in definition.commands { + for directory in searchPath { + let executable = (directory.hasSuffix("/") ? String(directory.dropLast()) : directory) + "/" + command + if isExecutable(executable) { return .init(executable: executable) } + } + } + return nil + } + + private static func parent(_ id: String) -> String { + id.lastIndex(of: "/").map { String(id[..<$0]) } ?? "." + } + + private static func treeOrder(_ left: String, _ right: String) -> Bool { + if left == "." { return right != "." } + if right == "." { return false } + return left.split(separator: "/").lexicographicallyPrecedes(right.split(separator: "/")) + } + + /// A regular file at `url`, without following a final symbolic link or opening it. + static func regularFileExists(_ url: URL) -> Bool { + var status = stat() + return url.withUnsafeFileSystemRepresentation { path in + guard let path else { return false } + return lstat(path, &status) == 0 && status.st_mode & S_IFMT == S_IFREG + } + } + + /// An executable regular file, following links as `execve` does. + static func executableExists(_ path: String) -> Bool { + var status = stat() + return stat(path, &status) == 0 && status.st_mode & S_IFMT == S_IFREG && access(path, X_OK) == 0 + } + + private static func contribution(_ manifest: [String: Any], key: String, maximum: Int) throws -> [[String: Any]] { + guard let value = manifest[key] else { return [] } + guard let values = value as? [[String: Any]], values.count <= maximum else { + throw ToolchainSettingsLoadError.invalidDeclarations + } + return values + } + + private static func text(_ value: Any?, maximum: Int) -> String? { + guard let value = value as? String, !value.isEmpty, value.utf8.count <= maximum, + !value.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }) + else { return nil } + return value + } + + private static func validDirectory(_ value: String) -> Bool { + value == "." || (text(value, maximum: 2_048) != nil && value.split(separator: "/").count <= 64 + && !value.contains("\\") && !value.contains(":") + && value.split(separator: "/", omittingEmptySubsequences: false).allSatisfy { + !$0.isEmpty && $0 != "." && $0 != ".." + }) + } + + private static func localDocument(workspace: URL?, readLocalData: @Sendable (URL) throws -> Data?) throws -> Data? { + guard let workspace else { return nil } + let url = workspace.appendingPathComponent(".lithe/toolchains/local.json") + let data: Data + do { + guard let captured = try readLocalData(url) else { return nil } + data = captured + } + catch is CancellationError { throw CancellationError() } + catch let error as CocoaError where error.code == .fileReadNoSuchFile || error.code == .fileNoSuchFile { return nil } + catch { throw ToolchainSettingsLoadError.readFailed } + guard data.count <= 1_048_576 else { throw ToolchainSettingsLoadError.invalidLocal } + return data + } + + /// Refuses links and special files before any read, including FIFO targets. + static func safeNativeRead(_ url: URL) throws -> Data? { + try Task.checkCancellation() + let descriptor = url.withUnsafeFileSystemRepresentation { path -> Int32 in + guard let path else { return -1 } + return Darwin.open(path, O_RDONLY | O_NONBLOCK | O_NOFOLLOW | O_CLOEXEC) + } + guard descriptor >= 0 else { + if errno == ENOENT { return nil } + throw ToolchainSettingsLoadError.readFailed + } + defer { Darwin.close(descriptor) } + var status = stat() + 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 } + var data = Data() + var buffer = [UInt8](repeating: 0, count: 65_536) + while data.count <= 1_048_576 { + try Task.checkCancellation() + let wanted = min(buffer.count, 1_048_577 - data.count) + let count = buffer.withUnsafeMutableBytes { Darwin.read(descriptor, $0.baseAddress, wanted) } + if count < 0 { + if errno == EINTR { continue } + throw ToolchainSettingsLoadError.readFailed + } + if count == 0 { return data } + data.append(contentsOf: buffer[.. ToolchainSettingsSavedPin? { + struct Payload: Encodable { + let document: ToolingJSONValue + let directory: String + let tool: String + } + struct Saved: Decodable { let home: String; let version: String } + struct Resolution: Decodable { let pin: Saved?; let sourceDirectory: String? } + struct Outcome: Decodable { let status: String; let resolution: Resolution? } + do { + let document = try JSONDecoder().decode(ToolingJSONValue.self, from: data) + let result: Outcome = try RustCoreBridge().executeResult(command: "toolchains.resolvePins", + payload: Payload(document: document, directory: directory, tool: tool)).get() + guard result.status == "ok", let resolution = result.resolution else { throw ToolchainSettingsLoadError.invalidLocal } + return resolution.pin.map { .init(home: $0.home, version: $0.version, directory: resolution.sourceDirectory ?? directory) } + } catch { throw ToolchainSettingsLoadError.invalidLocal } + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/ToolchainSettingsDemo.swift b/macos/Sources/Lithe/Platform/MacOS/ToolchainSettingsDemo.swift new file mode 100644 index 0000000000..8356a24c5a --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/ToolchainSettingsDemo.swift @@ -0,0 +1,267 @@ +#if DEBUG +import AppKit +import Combine +import SwiftUI +import LitheModuleAPI + +/// Debug preview of the toolchain settings pages driven by the production loader. +/// +/// Only the plugin manifests are samples: the workspace is real files (a generated +/// sample under the temporary directory, or `--toolchain-demo-workspace `), +/// pins and version files are read by the real loader and Core, and the login shell +/// is read like a first launch with its cache in the same temporary directory. +@MainActor +final class ToolchainSettingsDemo: NSObject, NSApplicationDelegate { + private var window: NSWindow? + private var model: AppModel? + private var quitMonitor: Any? + private var presetObservation: AnyCancellable? + private let domain = "Lithe.ToolchainSettingsDemo.\(UUID().uuidString)" + private let scratch = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-toolchain-demo-\(UUID().uuidString)", isDirectory: true) + + static func run() { + let app = NSApplication.shared + let delegate = ToolchainSettingsDemo() + // Same point in startup as production: before any other thread reads the environment. + MacLoginShellEnvironment.activate(cacheURL: delegate.scratch.appendingPathComponent("login-path")) + app.delegate = delegate + app.setActivationPolicy(.regular) + withExtendedLifetime(delegate) { app.run() } + } + + func applicationDidFinishLaunching(_ notification: Notification) { + guard let defaults = UserDefaults(suiteName: domain), let workspace = Self.workspace(scratch: scratch) else { + NSApp.terminate(nil) + return + } + let store = MacUserDefaultsStore(defaults: defaults) + let settings = AppSettings(store: store, logDirectoryProvider: MacServiceContainer.makeLogDirectoryProvider()) + settings.language = .simplifiedChinese + settings.themePreference = .light + defaults.set(226.0, forKey: "lithe.settings.categorySidebarWidth") + InterfaceLanguage.select(settings.language.rawValue) + MacBundledFontRegistry.registerFonts() + let container = MacServiceContainer(store: store, settings: settings, moduleLaunchMode: .safeMode) + let directories = Self.directories(in: workspace) + container.services.toolchainSettingsLoader = MacToolchainSettingsLoader( + sources: { Self.samplePlugins }, + directories: { $0 == workspace ? directories : [] }) + let model = AppModel(settings: settings, services: container.services) + // The settings pages load whatever workspace the model has open, as in the app. + model.workspaceSessionCoordinator.beginWorkspace(at: workspace) + self.model = model + applyPresetOnFirstLoad(model.toolchainSettingsFeature) + let category: SettingsCategory = CommandLine.arguments.contains("--toolchain-sources-demo") ? .toolchainSources : .toolchainEnvironment + let viewState = SettingsViewState(initialCategory: category) + let root = SettingsView(settings: settings, viewState: viewState, initialCategory: category) + .environmentObject(model) + .environmentObject(settings) + .environmentObject(SettingsModelContext(model: model)) + .environmentObject(UpdateChecker(preferences: defaults)) + .environment(\.locale, settings.language.locale) + .environment(\.colorScheme, .light) + .environment(\.lithePointingHandCursorEnabled, false) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 1180, height: 820), + styleMask: [.titled, .closable, .miniaturizable, .resizable], backing: .buffered, defer: false) + window.title = L10n.string("Runtime Environment — Isolated Preview") + SettingsWindowChrome.configure(window, title: window.title, themePreference: .light) + window.appearance = NSAppearance(named: .aqua) + window.isReleasedWhenClosed = false + let hosting = NSHostingView(rootView: SettingsAppearanceContainer(themePreference: .light) { root }) + hosting.sizingOptions = [] + // The SwiftUI production scene already owns its content safe area. + // The AppKit preview instead opts out of full-size content after sharing + // the production titlebar appearance, so controls cannot sit underneath it. + window.styleMask.remove(.fullSizeContentView) + window.contentView = hosting + let titlebarHeight = window.frame.height - window.contentLayoutRect.height + window.contentMinSize = NSSize(width: 900, height: 668 + titlebarHeight) + self.window = window + quitMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in + if event.modifierFlags.intersection(.deviceIndependentFlagsMask) == .command, + event.charactersIgnoringModifiers == "q" { + NSApp.terminate(nil) + return nil + } + return event + } + window.center() + window.makeKeyAndOrderFront(nil) + let size = Self.size(default: NSSize(width: 1180, height: 820)) + window.setContentSize(NSSize(width: size.width, height: size.height + titlebarHeight)) + NSApp.activate(ignoringOtherApps: true) + } + + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true } + + func applicationWillTerminate(_ notification: Notification) { + model?.toolchainSettingsFeature.close() + model?.workspaceSessionCoordinator.clearSession() + if let quitMonitor { NSEvent.removeMonitor(quitMonitor) } + UserDefaults.standard.removePersistentDomain(forName: domain) + try? FileManager.default.removeItem(at: scratch) + } + + /// `--toolchain-demo-directory ` and `--toolchain-demo-expand ` open a state to look at, + /// applied once the first snapshot has loaded. + private func applyPresetOnFirstLoad(_ feature: ToolchainSettingsFeatureModel) { + let directory = Self.argument("--toolchain-demo-directory") + let expanded = Self.argument("--toolchain-demo-expand")?.split(separator: ",").map(String.init) ?? [] + guard directory != nil || !expanded.isEmpty else { return } + presetObservation = feature.$snapshot.compactMap { $0 }.first().receive(on: RunLoop.main).sink { [weak feature] _ in + if let directory { feature?.selectDirectory(directory) } + expanded.forEach { feature?.toggleExpanded($0) } + } + } + + private static func argument(_ name: String) -> String? { + let arguments = CommandLine.arguments + guard let index = arguments.firstIndex(of: name), arguments.indices.contains(index + 1) else { return nil } + return arguments[index + 1] + } + + /// `--toolchain-demo-size x` for checking narrower and wider windows. + private static func size(default fallback: NSSize) -> NSSize { + let parts = argument("--toolchain-demo-size")?.split(separator: "x").compactMap { Double($0) } ?? [] + return parts.count == 2 ? NSSize(width: parts[0], height: parts[1]) : fallback + } + + /// The workspace named on the command line, or a generated sample of real files. + private static func workspace(scratch: URL) -> URL? { + if let path = argument("--toolchain-demo-workspace") { + return URL(fileURLWithPath: path, isDirectory: true).standardizedFileURL + } + let root = scratch.appendingPathComponent("sample-workspace", isDirectory: true) + let files = [ + "mise.toml": "[tools]\njava = \"21\"\nnode = \"22\"\n", + "packages/web/.nvmrc": "22\n", + "services/api/.python-version": "3.12\n", + ".lithe/toolchains/local.json": """ + {"version": 1, "toolchains": {}, "pins": {"services/api": {"python": { + "home": "/opt/homebrew/opt/python@3.12", "version": "3.12", + "provider": "dev.lithe.sample.python:uv"}}}} + """ + ] + do { + for (path, contents) in files { + let url = root.appendingPathComponent(path) + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data(contents.utf8).write(to: url) + } + try FileManager.default.createDirectory(at: root.appendingPathComponent("docs"), withIntermediateDirectories: true) + } catch { + return nil + } + return root + } + + /// Visible workspace-relative directories, breadth-first and bounded like the project tree projection. + private static func directories(in workspace: URL) -> [String] { + var result: [String] = [] + var queue = [(url: workspace, id: ".")] + while !queue.isEmpty, result.count < 1_024 { + let (url, id) = queue.removeFirst() + let children = (try? FileManager.default.contentsOfDirectory( + at: url, includingPropertiesForKeys: [.isDirectoryKey, .isPackageKey], options: [.skipsHiddenFiles])) ?? [] + for child in children.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) { + let values = try? child.resourceValues(forKeys: [.isDirectoryKey, .isPackageKey]) + guard values?.isDirectory == true, values?.isPackage != true, result.count < 1_024 else { continue } + let childID = id == "." ? child.lastPathComponent : id + "/" + child.lastPathComponent + result.append(childID) + queue.append((child, childID)) + } + } + return result + } + + /// Sample manifests standing in for installed official plugins; the host itself knows none of these tools. + /// They are complete production manifests, so Core admits them and discovery scans this Mac for real. + static let samplePlugins: [ToolchainSettingsPluginSource] = [ + sample("dev.lithe.sample.jvm", "JVM (sample)", definitions: [ + tool("java", "JDK", layout: "bin/java", version: (["-version"], #"version \"(?:1\.)?(\d+(?:\.\d+)*)"#), + variable: "JAVA_HOME", command: "java", + homeLayouts: [["match": "*.jdk", "home": "Contents/Home"], ["match": "*", "home": "."]], + discovery: [absolute("/Library/Java/JavaVirtualMachines", "*.jdk"), + home("Library/Java/JavaVirtualMachines/*.jdk"), home(".sdkman/candidates/java/*"), + absolute("/opt/homebrew/Cellar", "openjdk*/*")]), + tool("maven", "Maven", layout: "bin/mvn", version: (["--version"], #"Apache Maven (\d+(?:\.\d+)*)"#), + variable: "MAVEN_HOME", command: "mvn", + discovery: [absolute("/opt/homebrew/Cellar", "maven/*"), home(".sdkman/candidates/maven/*")]) + ]), + sample("dev.lithe.sample.node", "Node.js (sample)", definitions: [ + tool("node", "Node.js", layout: "bin/node", version: (["--version"], #"v(\d+(?:\.\d+)*)"#), command: "node", + discovery: [home(".nvm/versions/node/*"), absolute("/opt/homebrew/Cellar", "node/*"), + absolute("/opt/homebrew/Cellar", "node@*/*")]) + ], providers: [provider("nvm", "nvm", tools: ["node"], watch: [".nvmrc"])]), + sample("dev.lithe.sample.python", "Python (sample)", definitions: [ + tool("python", "Python", layout: "bin/python3", version: (["--version"], #"Python (\d+(?:\.\d+)*)"#), + command: "python3", discovery: [home(".local/share/uv/python/cpython-*")]) + ], providers: [provider("uv", "uv", tools: ["python"], watch: [".python-version"])]), + sample("dev.lithe.sample.mise", "mise (sample)", providers: [ + provider("mise", "mise", tools: ["java", "maven", "node", "python"], watch: ["mise.toml", ".tool-versions"]) + ]) + ] + + private static func sample( + _ id: String, _ name: String, definitions: [[String: Any]] = [], providers: [[String: Any]] = [] + ) -> ToolchainSettingsPluginSource { + var manifest: [String: Any] = [ + "schemaVersion": 2, "id": id, "displayName": name, "version": "1.0.0", "pluginAPIVersion": 3, + "vendor": ["id": "dev.lithe.sample", "displayName": "Lithe samples", "signatureRequirement": "publisherPackage"] + ] + var requires: [String: Int] = [:] + var permissions: [String] = [] + if !definitions.isEmpty { + manifest["toolDefinitions"] = definitions + requires.merge(["toolDefinitions": 1, "executableResolution": 2]) { $1 } + } + if !providers.isEmpty { + manifest["toolchainProviders"] = providers + manifest["backend"] = ["launch": ["kind": "packaged", "path": "bin/provider"], "arguments": ["--stdio"]] + requires.merge(["toolchainProviders": 1, "backend": 2, "serverProcess": 1]) { $1 } + permissions = ["backend.launch", "toolchains.provide"] + } + manifest["requires"] = requires + manifest["permissions"] = permissions + let data = (try? JSONSerialization.data(withJSONObject: manifest, options: [.sortedKeys])) ?? Data() + return ToolchainSettingsPluginSource(pluginID: id, name: name, manifest: data, isEnabled: true, verifiedOfficial: true) + } + + private static func tool( + _ id: String, _ name: String, layout: String, version: ([String], String), variable: String? = nil, + command: String, homeLayouts: [[String: String]] = [], discovery: [[String: Any]] + ) -> [String: Any] { + var executables: [[String: Any]] = [ + ["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"] + ] + if let variable { executables.append(["id": "home", "kind": "environment", "variable": variable, "path": layout]) } + executables += [["id": "shell", "kind": "command", "command": command], + ["id": "detected", "kind": "toolchain", "scope": "installed"]] + var definition: [String: Any] = [ + "id": id, "displayName": name, "icon": ["host": "plugin"], + "executableLayouts": [["path": layout]], "executables": executables, + "version": ["arguments": version.0, "pattern": version.1], + "homeLayouts": homeLayouts, "discovery": discovery, + "commands": [command], "wrappers": [], "pathPrepend": ["${home}/bin"], + "exports": variable.map { [["name": $0, "value": "${home}"]] } ?? [], + "launch": ["${executable}", "${arguments}"] + ] + if let variable { definition["homeVariable"] = variable } + return definition + } + + private static func provider(_ id: String, _ name: String, tools: [String], watch: [String]) -> [String: Any] { + ["id": id, "displayName": name, "tools": tools, "watchFiles": watch.map { ["pattern": $0, "reader": "native"] }] + } + + private static func home(_ pattern: String) -> [String: Any] { ["root": ["kind": "home"], "pattern": pattern] } + + private static func absolute(_ path: String, _ pattern: String) -> [String: Any] { + ["root": ["kind": "absolute", "path": path], "pattern": pattern] + } +} +#endif diff --git a/macos/Sources/Lithe/Views/App/SettingsSearchVocabulary.swift b/macos/Sources/Lithe/Views/App/SettingsSearchVocabulary.swift index 32ea4e49a9..d4bf5161a4 100644 --- a/macos/Sources/Lithe/Views/App/SettingsSearchVocabulary.swift +++ b/macos/Sources/Lithe/Views/App/SettingsSearchVocabulary.swift @@ -23,6 +23,10 @@ enum SettingsSearchVocabulary { ["Keymap", "Keyboard shortcuts", "Shortcuts", "Actions"] case .project: ["Project", "Java SDK", "JDK", "Project JDK", "Maven", "Maven Home", "Maven Wrapper", "Maven JDK"] + case .toolchainEnvironment: + ["Runtime Environment", "Tools", "Directories", "Version files", "Fixed", "Project requirements", "Global defaults", "Automatic discovery"] + case .toolchainSources: + ["Toolchain Sources", "Login shell", "Shell", "PATH", "Version managers", "Watched files"] case .run: ["Run configurations", "Program arguments", "VM options", "Environment variables", "Working directory", "Services"] case .terminal: diff --git a/macos/Sources/Lithe/Views/App/SettingsView.swift b/macos/Sources/Lithe/Views/App/SettingsView.swift index 2dd7b00b86..f5ad354aa6 100644 --- a/macos/Sources/Lithe/Views/App/SettingsView.swift +++ b/macos/Sources/Lithe/Views/App/SettingsView.swift @@ -392,6 +392,18 @@ struct SettingsView: View { } else if viewState.selection == .project { ProjectRuntimeSettingsView(feature: model.runtimeFeature) .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if viewState.selection == .toolchainEnvironment { + ToolchainEnvironmentSettingsView(feature: model.toolchainSettingsFeature, onOpenPlugins: { + viewState.selection = .plugins + }) + .task(id: model.workspaceURL) { await model.toolchainSettingsFeature.load(workspace: model.workspaceURL) } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if viewState.selection == .toolchainSources { + ToolchainSourcesSettingsView(feature: model.toolchainSettingsFeature, onOpenPlugins: { + viewState.selection = .plugins + }) + .task(id: model.workspaceURL) { await model.toolchainSettingsFeature.load(workspace: model.workspaceURL) } + .frame(maxWidth: .infinity, maxHeight: .infinity) } else if viewState.selection == .keymap { KeyboardShortcutSettingsView( feature: model.keyboardShortcutFeature, @@ -417,6 +429,7 @@ struct SettingsView: View { case .terminal: terminalSettings case .lsp: EmptyView() case .project: EmptyView() + case .toolchainEnvironment, .toolchainSources: EmptyView() case .run: EmptyView() case .ai: aiSettings case .instructions: diff --git a/macos/Sources/Lithe/Views/App/ToolchainEnvironmentSettingsView.swift b/macos/Sources/Lithe/Views/App/ToolchainEnvironmentSettingsView.swift new file mode 100644 index 0000000000..928323ff50 --- /dev/null +++ b/macos/Sources/Lithe/Views/App/ToolchainEnvironmentSettingsView.swift @@ -0,0 +1,1105 @@ +import LitheModuleAPI +import SwiftUI + +/// Presents the feature snapshot. Nothing here launches a tool, queries a provider or writes a file. +struct ToolchainEnvironmentSettingsView: View { + @ObservedObject var feature: ToolchainSettingsFeatureModel + var onOpenPlugins: () -> Void + @State private var directoryWidth: CGFloat = 210 + @State private var collapsedDirectoryIDs = Set() + @State private var refreshTask: Task? + + var body: some View { + Group { + if let snapshot = feature.snapshot, !snapshot.directories.isEmpty { + // A project with only its root needs no directory tree. + if snapshot.directories.count > 1 { + LitheSplitPaneView( + axis: .horizontal, placement: .leading, + defaultSize: directoryWidth, minimum: 150, maximum: 300, + flexibleMinimum: 340, + idealSize: CGSize(width: 666, height: 617), + clipsSizedPane: true, + onCommit: { directoryWidth = $0 }, + sized: { directoryTree(snapshot) }, + flexible: { detail(snapshot) } + ) + } else { + detail(snapshot) + } + } else if feature.snapshot == nil, feature.isLoading { + Color.clear + } else { + ToolchainSettingsEmptyState(feature: feature, openPlugins: onOpenPlugins, refresh: refresh) + } + } + .background(LitheTheme.settingsPage) + .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .topLeading) + .clipped() + .onDisappear { refreshTask?.cancel(); refreshTask = nil } + } + + private func refresh() { + refreshTask?.cancel() + refreshTask = Task { await feature.load(workspace: feature.workspaceURL) } + } + + private func directoryTree(_ snapshot: ToolchainSettingsSnapshot) -> some View { + let pinned = Set(snapshot.tools.compactMap(\.pin?.directory)) + return VStack(alignment: .leading, spacing: 0) { + HStack(spacing: LitheInfoTip.labelSpacing) { + 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.") + Spacer(minLength: 0) + } + .foregroundStyle(LitheTheme.secondaryText) + .padding(.horizontal, 12) + .frame(height: 36) + Rectangle().fill(LitheTheme.settingsCardDivider).frame(height: 1) + LitheTreeList( + visibleDirectories(snapshot), id: \.id, + selection: Binding(get: { feature.selectedDirectoryID }, set: { if let id = $0 { feature.selectDirectory(id) } }), + hierarchy: { directory in + .init(depth: directory.depth, isBranch: hasChildren(directory, in: snapshot), + isExpanded: !collapsedDirectoryIDs.contains(directory.id)) + }, + onExpansion: { directory, expanded in + if expanded { collapsedDirectoryIDs.remove(directory.id) } + else { collapsedDirectoryIDs.insert(directory.id) } + } + ) { directory in + HStack(spacing: 6) { + LitheIDEAIcon(resourcePath: "nodes/folder.svg", size: 15, fallbackSystemImage: "folder", preservesOriginalColors: true) + Text(verbatim: directory.title) + .font(LitheTheme.uiFont(size: 12, weight: directory.id == "." ? .semibold : .regular)) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 4) + if pinned.contains(directory.id) { + ToolchainSettingsTag("Fixed", kind: .project) + } + } + .padding(.vertical, 3) + } + .accessibilityIdentifier("toolchain-directory-list") + } + .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .topLeading) + .background(LitheTheme.settingsCard) + } + + private func visibleDirectories(_ snapshot: ToolchainSettingsSnapshot) -> [ToolchainSettingsSnapshot.Directory] { + var hiddenBelow: Int? + return snapshot.directories.filter { directory in + if let depth = hiddenBelow, directory.depth > depth { return false } + hiddenBelow = nil + if collapsedDirectoryIDs.contains(directory.id) { hiddenBelow = directory.depth } + return true + } + } + + private func hasChildren(_ directory: ToolchainSettingsSnapshot.Directory, in snapshot: ToolchainSettingsSnapshot) -> Bool { + guard let index = snapshot.directories.firstIndex(where: { $0.id == directory.id }), + snapshot.directories.indices.contains(index + 1) else { return false } + return snapshot.directories[index + 1].depth > directory.depth + } + + private func detail(_ snapshot: ToolchainSettingsSnapshot) -> some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + ToolchainSettingsSnapshotNotice(feature: feature) + if let directory = feature.selectedDirectory { + directoryGroup(directory, in: snapshot) + } + toolsGroup(snapshot) + } + .padding(.horizontal, 24) + .padding(.top, 18) + .padding(.bottom, 24) + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + } + .litheScrollViewChrome(alwaysShowVertical: true, usesCompactScrollers: true) + .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .topLeading) + .background(LitheTheme.settingsPage) + .clipped() + .accessibilityElement(children: .contain) + .accessibilityIdentifier("environment-detail-viewport") + } + + private func directoryGroup( + _ directory: ToolchainSettingsSnapshot.Directory, in snapshot: ToolchainSettingsSnapshot + ) -> some View { + let tools = feature.tools + let pinned = tools.filter { $0.pin != nil } + let path = ToolchainSettingsText.abbreviated( + snapshot.workspaceURL.map { directory.id == "." ? $0.path : $0.appendingPathComponent(directory.id).path } + ?? directory.id + ) + let parentHasFiles = ToolchainSettingsText.ancestors(of: directory.id).contains { id in + snapshot.directories.first { $0.id == id }?.versionFiles.isEmpty == false + } + return ToolchainSettingsGroup(directory.id == "." ? Text("Project root directory") : Text(verbatim: directory.id)) { + ToolchainSettingsLine("Path") { + Text(verbatim: path) + .font(LitheTheme.uiFont(size: 11.5, design: .monospaced)) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.enabled) + .help(path) + } + ToolchainSettingsLine("Version files", info: "Files declared by enabled version managers that exist in this directory. A directory without its own uses the nearest parent's.") { + ForEach(directory.versionFiles, id: \.path) { file in + HStack(spacing: 5) { + LitheSettingsPill(verbatim: ToolchainSettingsText.relative(file.path, to: directory.id), kind: .value, wraps: true) + Text(verbatim: snapshot.providers.filter { file.providerIDs.contains($0.id) }.map(\.title).joined(separator: ", ")) + .font(LitheTheme.uiFont(size: 11)) + .foregroundStyle(LitheTheme.tertiaryText) + .lineLimit(1) + } + } + if directory.versionFiles.isEmpty { + (parentHasFiles ? Text("None; uses the parent directory's") : Text("None")) + .foregroundStyle(LitheTheme.tertiaryText) + } + } + ToolchainSettingsLine("Fixed", info: "Fixed versions are saved on this computer in .lithe/toolchains/local.json and also apply to the directories below. This page reads them without changing the file.") { + ForEach(pinned, id: \.identity) { tool in + if let pin = tool.pin { + if pin.directory == directory.id { + LitheSettingsPill(verbatim: tool.displayName + " " + pin.version, kind: .value, wraps: true) + } else { + HStack(spacing: 5) { + LitheSettingsPill(verbatim: tool.displayName + " " + pin.version, wraps: true) + Text("From \(ToolchainSettingsText.directory(pin.directory))") + .font(LitheTheme.uiFont(size: 11)) + .foregroundStyle(LitheTheme.tertiaryText) + .lineLimit(1) + } + } + } + } + if pinned.isEmpty { + Text("Not pinned").foregroundStyle(LitheTheme.tertiaryText) + } + } + } + } + + private func toolsGroup(_ snapshot: ToolchainSettingsSnapshot) -> some View { + let tools = feature.tools + return ToolchainSettingsGroup(Text("Tools"), accessory: { + LitheInfoTip("Each tool uses the first source that has a value: a fixed version, then project version files, then global defaults, then automatic discovery. Installed versions are checked by running each tool's own version command. Enabled version managers report what version files ask for.") + Spacer(minLength: 8) + Button(action: refresh) { + HStack(spacing: 4) { + if feature.isLoading { + ProgressView().controlSize(.mini) + } else { + Image(systemName: "arrow.clockwise").font(LitheTheme.uiFont(size: 10.5, weight: .semibold)) + } + Text("Refresh") + } + .font(LitheTheme.uiFont(size: 12)) + .foregroundStyle(LitheTheme.accent) + .contentShape(Rectangle()) + } + .buttonStyle(.litheNoPress) + .disabled(feature.isLoading) + }) { + if tools.isEmpty { + HStack(spacing: 10) { + Text("No enabled plugin defines tools.") + .foregroundStyle(LitheTheme.secondaryText) + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 8) + Button("Open Plugins", action: onOpenPlugins).buttonStyle(LitheActionButtonStyle()) + } + .font(LitheTheme.uiFont(size: 12)) + .frame(minHeight: 36) + } else { + ForEach(tools, id: \.identity) { tool in + ToolchainToolRow( + tool: tool, + providers: snapshot.providers.filter { $0.tools.contains(tool.id) }, + isExpanded: feature.expandedToolIDs.contains(tool.id), + toggle: { feature.toggleExpanded(tool.id) }, + openPlugins: onOpenPlugins + ) + } + } + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("toolchain-tools-section") + } +} + +/// One tool in the selected directory: Core's resolution on one line, all four sources +/// and the definition when expanded. +private struct ToolchainToolRow: View { + let tool: ToolchainSettingsSnapshot.Tool + /// Providers that declare this tool. + let providers: [ToolchainSettingsSnapshot.Provider] + let isExpanded: Bool + let toggle: () -> Void + let openPlugins: () -> Void + + private static let indent: CGFloat = 26 + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + ToolchainEdgeLayout(minimumLeadingWidth: 240, trailingIndent: Self.indent) { + HStack(spacing: 10) { + nameButton + summary.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + } + badge + } + .frame(minHeight: 32) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("toolchain-tool-header:" + tool.id) + if isExpanded { + ToolchainStepGridLayout(minimumColumnWidth: 150, spacing: 8) { + pinnedStep + projectStep + globalStep + automaticStep + } + .padding(.leading, Self.indent) + .padding(.top, 8) + .padding(.bottom, 8) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("toolchain-resolution:" + tool.id) + VStack(alignment: .leading, spacing: 2) { + ToolchainSettingsMetaLine("Version managers") { + if providers.isEmpty { + Text("None").foregroundStyle(LitheTheme.tertiaryText) + } + ForEach(providers) { LitheSettingsPill(verbatim: $0.title, wraps: true) } + } + ToolchainSettingsMetaLine("Definition", info: "This row comes from the tool definition of an enabled plugin. Lithe has no built-in tools.") { + LitheSettingsPill(verbatim: tool.pluginName, wraps: true) + Text(verbatim: tool.pluginID + " · " + tool.id) + .font(LitheTheme.uiFont(size: 10.5, design: .monospaced)) + .foregroundStyle(LitheTheme.tertiaryText) + .lineLimit(1) + .truncationMode(.middle) + Button("Open Plugins", action: openPlugins) + .buttonStyle(.litheNoPress) + .foregroundStyle(LitheTheme.accent) + } + } + .padding(.leading, Self.indent) + .padding(.bottom, 6) + } + } + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + } + + private var nameButton: some View { + Button(action: toggle) { + HStack(spacing: 6) { + LitheTreeDisclosureIcon(isExpanded: isExpanded) + ToolchainSettingsIcon(name: tool.displayName, key: tool.pluginID + ":" + tool.id, token: tool.iconName) + Text(verbatim: tool.displayName) + .font(LitheTheme.uiFont(size: 12.5, weight: .semibold)) + .lineLimit(1) + } + .frame(width: 150, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.litheNoPress) + .accessibilityLabel(Text(verbatim: tool.displayName)) + .accessibilityValue(isExpanded ? Text("Expanded") : Text("Collapsed")) + } + + /// What Core resolves this directory to, or why nothing is used, and which version manager + /// gave no answer. Core orders the sources; this row only reads its outcome. + private var summary: some View { + HStack(spacing: 8) { + outcome + if let notice = unavailableNotices.first { + note(verbatim: notice, color: LitheTheme.settingsEffectPillText) + .help(unavailableNotices.joined(separator: "\n")) + } + } + } + + @ViewBuilder private var outcome: some View { + switch tool.resolution?.outcome { + case .resolved(let home, let executable, let version, _): + HStack(spacing: 6) { + if let version { LitheSettingsPill(verbatim: version, kind: .value) } + monospaced(ToolchainSettingsText.abbreviated(home ?? executable), size: 11, color: LitheTheme.tertiaryText) + } + case .failed(let reason): + note(verbatim: ToolchainSettingsText.skipReason(reason), color: LitheTheme.settingsEffectPillText) + case .skipped: + if let conflict = tool.resolution?.conflicts.first { + note(verbatim: ToolchainSettingsText.conflict(conflict), color: LitheTheme.settingsEffectPillText) + } else if let request = tool.resolution?.unmet.first { + note(verbatim: L10n.string("Requested \(request) is not installed"), color: LitheTheme.secondaryText) + } else if let candidate = tool.candidate { + // Core does not run a command found on `PATH`, so it is shown without a version. + monospaced(ToolchainSettingsText.abbreviated(candidate.executable), size: 11.5, color: LitheTheme.secondaryText) + } else { + note(verbatim: L10n.string("Not found"), color: LitheTheme.tertiaryText) + } + case nil: + note(verbatim: L10n.string("Could not be determined"), color: LitheTheme.tertiaryText) + } + } + + @ViewBuilder private var badge: some View { + switch tool.resolution?.layer { + case .pinned: + if (tool.pin?.directory ?? tool.directoryID) == tool.directoryID { + LitheSettingsPill("Fixed", kind: .project) + } else { + LitheSettingsPill("Fixed in parent") + } + case .project: + LitheSettingsPill("Version file", kind: .project) + case .global: + if let source = globalSource { + LitheSettingsPill(verbatim: source) + } else { + LitheSettingsPill("Global defaults") + } + case .automatic: + LitheSettingsPill("Discovered") + case nil: + if tool.candidate != nil, case .skipped? = tool.resolution?.outcome { + LitheSettingsPill(verbatim: "PATH") + } + } + } + + private var detection: ToolchainSettingsSnapshot.Detection? { tool.detection } + + /// Version managers asked about this tool that gave no answer here: none within the + /// deadline, or no backend to answer. A failed fixed value does not depend on them. + private var unavailableNotices: [String] { + guard let resolution = tool.resolution else { return [] } + if case .failed = resolution.outcome { return [] } + return resolution.unavailable.map { unavailable in + let title = providers.first { $0.id == unavailable.provider }?.title ?? unavailable.provider + return unavailable.timedOut ? L10n.string("\(title) did not respond") : L10n.string("\(title) is unavailable") + } + } + + /// The global source Core selected: the home variable that names it, or the version manager. + private var globalSource: String? { + guard case .resolved(let home, _, _, let provider)? = tool.resolution?.outcome else { return nil } + if let title = providers.first(where: { $0.id == provider })?.title { return title } + let shell = detection?.shell.first { $0.home == home } ?? detection?.shell.first { $0.version != nil } + return shell.map { ToolchainSettingsText.variable($0.variable) } + } + + /// The step Core's resolution decided in; nothing is outlined when no source has a value. + private var firstMatch: Int? { + switch tool.resolution?.layer { + case .pinned: 1 + case .project: 2 + case .global: 3 + case .automatic: 4 + case nil: nil + } + } + + private func note(verbatim text: String, color: Color) -> some View { + Text(verbatim: text) + .font(LitheTheme.uiFont(size: 12)) + .foregroundStyle(color) + .lineLimit(1) + } + + /// A conflict Core reported for one layer: the outcome needs an explicit choice there. + @ViewBuilder + private func conflictNote(_ layer: ToolchainSettingsSnapshot.Resolution.Layer) -> some View { + if tool.resolution?.conflicts.contains(layer) == true { + Text(verbatim: ToolchainSettingsText.conflict(layer)) + .font(LitheTheme.uiFont(size: 10.5)).foregroundStyle(LitheTheme.settingsEffectPillText) + .fixedSize(horizontal: false, vertical: true) + } + } + + private var pinnedStep: some View { + ToolchainResolutionStep( + number: 1, title: "Fixed", + info: "A version saved for this directory on this computer. It applies first, also in the directories below.", + isFirstMatch: firstMatch == 1, hasFact: tool.pin != nil + ) { + if let pin = tool.pin { + Text(verbatim: pin.version) + .font(LitheTheme.uiFont(size: 12, weight: .medium, design: .monospaced)) + monospaced(ToolchainSettingsText.abbreviated(pin.home), size: 10.5, color: LitheTheme.secondaryText, truncation: .head) + if pin.directory != tool.directoryID { + Text("From \(ToolchainSettingsText.directory(pin.directory))") + .foregroundStyle(LitheTheme.secondaryText) + } + switch (tool.resolution?.layer, tool.resolution?.outcome) { + case (.pinned?, .resolved?): + EmptyView() + case (.pinned?, .failed(let reason)?): + Text(verbatim: ToolchainSettingsText.skipReason(reason)) + .font(LitheTheme.uiFont(size: 10.5)).foregroundStyle(LitheTheme.settingsEffectPillText) + default: + Text("Saved, not verified").font(LitheTheme.uiFont(size: 10.5)).foregroundStyle(LitheTheme.tertiaryText) + } + } else { + Text("None").foregroundStyle(LitheTheme.tertiaryText) + } + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("toolchain-step:" + tool.id + ":pinned") + } + + private var projectStep: some View { + ToolchainResolutionStep( + number: 2, title: "Project requirements", + info: "Version files in this directory or its parents, watched by the version managers of this tool, and the versions those managers report for them.", + isFirstMatch: firstMatch == 2, hasFact: !tool.versionFiles.isEmpty || !tool.requirements.isEmpty + ) { + if tool.versionFiles.isEmpty && tool.requirements.isEmpty { + Text("No version file").foregroundStyle(LitheTheme.tertiaryText) + } else { + ForEach(tool.versionFiles, id: \.path) { file in + HStack(spacing: 4) { + if let title = providers.first(where: { file.providerIDs.contains($0.id) })?.title { + ToolchainSettingsTag(verbatim: title) + } + monospaced(file.path, size: 11, color: LitheTheme.primaryText, truncation: .head) + } + } + if tool.requirements.isEmpty { + if let notice = unavailableNotices.first { + Text(verbatim: notice).font(LitheTheme.uiFont(size: 10.5)) + .foregroundStyle(LitheTheme.settingsEffectPillText) + } else { + Text("Version not queried").font(LitheTheme.uiFont(size: 10.5)).foregroundStyle(LitheTheme.tertiaryText) + } + } + } + requirementRows(tool.requirements) + conflictNote(.project) + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("toolchain-step:" + tool.id + ":project") + } + + /// A provider's answer: the manager's own request text and what it resolves to. + @ViewBuilder + private func requirementRows(_ requirements: [ToolchainSettingsSnapshot.Requirement]) -> some View { + ForEach(requirements, id: \.source) { requirement in + VStack(alignment: .leading, spacing: 1) { + HStack(spacing: 4) { + if let title = providers.first(where: { $0.id == requirement.providerID })?.title { + ToolchainSettingsTag(verbatim: title) + } + monospaced(requirement.request, size: 11, color: LitheTheme.primaryText, truncation: .tail) + if let version = requirement.version { + Text(verbatim: version).font(LitheTheme.uiFont(size: 10.5)).foregroundStyle(LitheTheme.secondaryText) + } else if requirement.home == nil { + Text("Not installed").font(LitheTheme.uiFont(size: 10.5)).foregroundStyle(LitheTheme.tertiaryText) + } + } + monospaced(ToolchainSettingsText.abbreviated(requirement.source), size: 10.5, + color: LitheTheme.tertiaryText, truncation: .head) + } + } + } + + private var globalStep: some View { + let shell = detection?.shell ?? [] + return ToolchainResolutionStep( + number: 3, title: "Global defaults", + info: "What version managers report as global, then the home variables and commands this tool's plugin declares, read from the login shell. A home's version comes from the tool's own version command; a command found on PATH is not run.", + isFirstMatch: firstMatch == 3, hasFact: !shell.isEmpty || tool.candidate != nil || !tool.globalRequirements.isEmpty + ) { + requirementRows(tool.globalRequirements) + conflictNote(.global) + ForEach(shell, id: \.variable) { row in + // A long variable name leaves no room for the version beside it in a narrow card. + ViewThatFits(in: .horizontal) { + HStack(spacing: 4) { shellVariable(row) } + VStack(alignment: .leading, spacing: 2) { shellVariable(row) } + } + monospaced(ToolchainSettingsText.abbreviated(row.home), size: 10.5, color: LitheTheme.secondaryText, truncation: .head) + if row.version == nil { + Text(verbatim: ToolchainSettingsText.skipReason(row.reason)) + .font(LitheTheme.uiFont(size: 10.5)).foregroundStyle(LitheTheme.tertiaryText) + } + } + if let candidate = tool.candidate { + HStack(spacing: 4) { + ToolchainSettingsTag(verbatim: "PATH") + monospaced(ToolchainSettingsText.abbreviated(candidate.executable), size: 11, color: LitheTheme.primaryText, truncation: .head) + } + Text("Version not verified").font(LitheTheme.uiFont(size: 10.5)).foregroundStyle(LitheTheme.tertiaryText) + } + if shell.isEmpty && tool.candidate == nil { + Text("Not found").foregroundStyle(LitheTheme.tertiaryText) + } + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("toolchain-step:" + tool.id + ":global") + } + + /// Discovered installations, highest version first; the step card keeps a few and counts the rest. + private var automaticStep: some View { + let installations = detection?.installations ?? [] + let skipped = detection?.skipped ?? [] + let shown = Array(installations.prefix(Self.shownInstallations)) + return ToolchainResolutionStep( + number: 4, title: "Automatic discovery", + info: "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.", + isFirstMatch: firstMatch == 4, hasFact: !installations.isEmpty + ) { + // Version above home: beside a version, a narrow card keeps only a home's shared + // tail (`…/Contents/Home`), which cannot tell installations apart. + ForEach(shown, id: \.home) { installation in + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 4) { + version(installation.version, emphasized: installation.home == detection?.selected) + if installation.home == detection?.selected { + Image(systemName: "checkmark") + .font(LitheTheme.uiFont(size: 9.5, weight: .bold)) + .foregroundStyle(LitheTheme.accent) + .accessibilityLabel(Text("Selected")) + } + } + monospaced(ToolchainSettingsText.abbreviated(installation.location ?? installation.home), size: 10.5, + color: LitheTheme.secondaryText, truncation: .head, help: installation.home) + } + } + if installations.count > shown.count { + Text("\(installations.count - shown.count) more") + .font(LitheTheme.uiFont(size: 10.5)).foregroundStyle(LitheTheme.tertiaryText) + .help(installations.dropFirst(shown.count).map { $0.version + " " + $0.home }.joined(separator: "\n")) + } + if detection?.conflict == true { + Text("Several installations have the same version") + .font(LitheTheme.uiFont(size: 10.5)).foregroundStyle(LitheTheme.settingsEffectPillText) + .fixedSize(horizontal: false, vertical: true) + } + if !skipped.isEmpty { + Text("\(skipped.count) skipped") + .font(LitheTheme.uiFont(size: 10.5)).foregroundStyle(LitheTheme.tertiaryText) + .help(skipped.map { ToolchainSettingsText.skipReason($0.reason) + " " + $0.home }.joined(separator: "\n")) + } + if installations.isEmpty && skipped.isEmpty { + Text("Not found").foregroundStyle(LitheTheme.tertiaryText) + } + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("toolchain-step:" + tool.id + ":automatic") + } + + private static let shownInstallations = 3 + + @ViewBuilder + private func shellVariable(_ row: ToolchainSettingsSnapshot.Detection.Shell) -> some View { + ToolchainSettingsTag(verbatim: ToolchainSettingsText.variable(row.variable)) + if let found = row.version { + version(found, emphasized: true) + } + } + + /// A version keeps its full width; the path beside or below it is what truncates. + private func version(_ text: String, emphasized: Bool) -> some View { + Text(verbatim: text) + .font(LitheTheme.uiFont(size: 11.5, weight: emphasized ? .semibold : .regular, design: .monospaced)) + .foregroundStyle(emphasized ? LitheTheme.primaryText : LitheTheme.secondaryText) + .lineLimit(1) + .fixedSize() + .textSelection(.enabled) + } + + /// Step cards are narrow, so they cut from the front to keep the file or executable name. + private func monospaced(_ text: String, size: CGFloat, color: Color, truncation: Text.TruncationMode = .middle, + help: String? = nil) -> some View { + Text(verbatim: text) + .font(LitheTheme.uiFont(size: size, design: .monospaced)) + .foregroundStyle(color) + .lineLimit(1) + .truncationMode(truncation) + .textSelection(.enabled) + .help(help ?? text) + } +} + +/// One source in the order a tool is resolved. The source Core resolved from is outlined; +/// sources without a value are dimmed rather than hidden, so the order stays readable. +private struct ToolchainResolutionStep: View { + let number: Int + let title: LocalizedStringKey + let info: LocalizedStringKey + let isFirstMatch: Bool + let hasFact: Bool + @ViewBuilder let content: Content + + var body: some View { + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 5) { + Text(verbatim: String(number)) + .font(LitheTheme.uiFont(size: 10, weight: .semibold)) + .foregroundStyle(isFirstMatch ? LitheTheme.accent : LitheTheme.secondaryText) + .frame(width: 16, height: 16) + .background(Circle().fill(isFirstMatch ? LitheTheme.accent.opacity(0.14) : LitheTheme.settingsPill)) + Text(title) + .font(LitheTheme.uiFont(size: 12, weight: .semibold)) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + LitheInfoTip(info) + Spacer(minLength: 0) + } + VStack(alignment: .leading, spacing: 3) { content } + .font(LitheTheme.uiFont(size: 11.5)) + .padding(.leading, 21) + } + .padding(EdgeInsets(top: 8, leading: 9, bottom: 9, trailing: 9)) + .frame(minWidth: 0, maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(isFirstMatch ? LitheTheme.accent.opacity(0.06) : Color.clear) + ) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .strokeBorder(isFirstMatch ? LitheTheme.accent.opacity(0.45) : LitheTheme.settingsCardDivider, lineWidth: 1) + ) + // The mark sits on the border, so it never takes the title's width in a narrow column. + .overlay(alignment: .topTrailing) { + if isFirstMatch { + Text("First match") + .font(LitheTheme.uiFont(size: 9.5, weight: .semibold)) + .foregroundStyle(Color.white) + .lineLimit(1) + .fixedSize() + .padding(.horizontal, 6) + .padding(.vertical, 1.5) + .background(Capsule().fill(LitheTheme.accent)) + .offset(x: -8, y: -7) + } + } + .opacity(hasFact ? 1 : 0.6) + } +} + +// MARK: - Shared by both toolchain settings pages + +/// A settings section whose title is any `Text`, so a directory path is shown verbatim +/// instead of being looked up as a localization key. +struct ToolchainSettingsGroup: View { + private let title: Text + private let accessory: Accessory + private let content: Content + + init(_ title: Text, @ViewBuilder accessory: () -> Accessory, @ViewBuilder content: () -> Content) { + self.title = title + self.accessory = accessory() + self.content = content() + } + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: LitheInfoTip.labelSpacing) { + title + .font(LitheTheme.uiFont(size: 12, weight: .semibold)) + .foregroundStyle(LitheTheme.secondaryText) + .lineLimit(1) + .truncationMode(.middle) + accessory + } + .padding(.horizontal, 6) + .frame(maxWidth: .infinity, minHeight: 18, alignment: .leading) + LitheSettingsCard { content } + } + .font(LitheTheme.settingsFont) + .foregroundStyle(LitheTheme.primaryText) + .padding(.bottom, 16) + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +extension ToolchainSettingsGroup where Accessory == EmptyView { + init(_ title: Text, @ViewBuilder content: () -> Content) { + self.init(title, accessory: { EmptyView() }, content: content) + } +} + +/// A compact card line: a short label with an optional ⓘ, then values that wrap as chips. +struct ToolchainSettingsLine: View { + private let title: Text + private let info: LocalizedStringKey? + private let content: Content + + init(_ title: LocalizedStringKey, info: LocalizedStringKey? = nil, @ViewBuilder content: () -> Content) { + self.init(Text(title), info: info, content: content) + } + + init(verbatim title: String, info: LocalizedStringKey? = nil, @ViewBuilder content: () -> Content) { + self.init(Text(verbatim: title), info: info, content: content) + } + + private init(_ title: Text, info: LocalizedStringKey?, @ViewBuilder content: () -> Content) { + self.title = title + self.info = info + self.content = content() + } + + var body: some View { + HStack(alignment: .top, spacing: 10) { + HStack(spacing: LitheInfoTip.labelSpacing) { + title.lineLimit(1) + if let info { LitheInfoTip(info) } + } + .frame(minWidth: 0, idealWidth: 120, maxWidth: 120, alignment: .leading) + .padding(.top, 1) + LitheChipFlowLayout(spacing: 6, clampsToWidth: true) { content } + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + } + .font(LitheTheme.uiFont(size: 12)) + .frame(minHeight: 30) + } +} + +/// A secondary line under an expanded row, in smaller type than a card line. +struct ToolchainSettingsMetaLine: View { + private let title: LocalizedStringKey + private let info: LocalizedStringKey? + private let content: Content + + init(_ title: LocalizedStringKey, info: LocalizedStringKey? = nil, @ViewBuilder content: () -> Content) { + self.title = title + self.info = info + self.content = content() + } + + var body: some View { + HStack(alignment: .top, spacing: 8) { + HStack(spacing: LitheInfoTip.labelSpacing) { + Text(title).lineLimit(1) + if let info { LitheInfoTip(info) } + } + .foregroundStyle(LitheTheme.secondaryText) + .frame(width: 108, alignment: .leading) + .padding(.top, 2) + LitheChipFlowLayout(spacing: 6, clampsToWidth: true) { content } + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + } + .font(LitheTheme.uiFont(size: 11.5)) + .frame(minHeight: 22) + } +} + +/// The small square-cornered label that names where a value comes from. +struct ToolchainSettingsTag: View { + private let title: Text + private let kind: LitheSettingsPill.Kind + + init(_ title: LocalizedStringKey, kind: LitheSettingsPill.Kind = .neutral) { + self.title = Text(title) + self.kind = kind + } + + init(verbatim title: String, kind: LitheSettingsPill.Kind = .neutral) { + self.title = Text(verbatim: title) + self.kind = kind + } + + var body: some View { + title + .font(LitheTheme.uiFont(size: 10, weight: .medium)) + .lineLimit(1) + .fixedSize() + .foregroundStyle(foreground) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(RoundedRectangle(cornerRadius: 3, style: .continuous).fill(background)) + } + + private var foreground: Color { + switch kind { + case .neutral: LitheTheme.settingsPillText + case .project: LitheTheme.settingsProjectPillText + case .effect: LitheTheme.settingsEffectPillText + case .value: LitheTheme.settingsValuePillText + } + } + + private var background: Color { + switch kind { + case .neutral: LitheTheme.settingsPill + case .project: LitheTheme.settingsProjectPill + case .effect: LitheTheme.settingsEffectPill + case .value: LitheTheme.settingsValuePill + } + } +} + +/// A plugin-declared host icon, or the plugin-style letter avatar when there is none. +struct ToolchainSettingsIcon: View { + let name: String + /// Stable key for the avatar color. + let key: String + let token: String? + var size: CGFloat = 18 + + var body: some View { + Group { + if let path = token.flatMap({ WorkbenchContributionIcon.Asset.hostPaths[$0] }) { + LitheIDEAIcon(resourcePath: path, size: size, fallbackSystemImage: "puzzlepiece.extension", preservesOriginalColors: true) + } else { + ProjectAvatarBadge(name: name, colorIndex: ProjectIdentityAppearance.colorIndex(forKey: key), + size: size, text: PluginMarketplacePresentation.avatarText(for: name)) + } + } + .frame(width: size, height: size) + .accessibilityHidden(true) + } +} + +enum ToolchainSettingsText { + static func abbreviated(_ path: String) -> String { + (path as NSString).abbreviatingWithTildeInPath + } + + /// A workspace-relative directory as a reader names it. + static func directory(_ id: String) -> String { + id == "." ? L10n.string("the project root") : id + } + + static func relative(_ path: String, to directory: String) -> String { + directory == "." ? path : String(path.dropFirst(directory.count + 1)) + } + + /// Parents of a workspace-relative directory, nearest first, ending with the root. + static func ancestors(of id: String) -> [String] { + guard id != "." else { return [] } + var parts = id.split(separator: "/").dropLast() + var result: [String] = [] + while !parts.isEmpty { + result.append(parts.joined(separator: "/")) + parts = parts.dropLast() + } + return result + ["."] + } + + static func variable(_ name: String) -> String { + "$" + name + } + + /// Core's skip code for a home it checked, as a reader understands it. + static func skipReason(_ code: String?) -> String { + switch code { + case "probeFailed": L10n.string("Version check failed") + case "versionUnsatisfied": L10n.string("Version too old") + case "unsafePath", "notExecutable": L10n.string("Not a valid installation") + case "requestedNotInstalled": L10n.string("Not installed") + case "providerConflict": L10n.string("Version managers report different versions") + case "ambiguousInstallation": L10n.string("Several installations have the same version") + case "notFound": L10n.string("Not found") + default: L10n.string("Not usable") + } + } + + /// A layer whose sources disagree, as Core reports it. + static func conflict(_ layer: ToolchainSettingsSnapshot.Resolution.Layer) -> String { + layer == .automatic ? L10n.string("Several installations have the same version") + : L10n.string("Version managers report different versions") + } +} + +/// Leading content with trailing badges on the same line while the leading part keeps +/// `minimumLeadingWidth`; on a narrower line the badges move under it. +struct ToolchainEdgeLayout: Layout { + var minimumLeadingWidth: CGFloat = 220 + var spacing: CGFloat = 10 + var lineSpacing: CGFloat = 4 + var trailingIndent: CGFloat = 0 + + private struct Arrangement { + var frames: [CGRect] + var size: CGSize + } + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + arrange(width: proposal.width, subviews: subviews).size + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + let arrangement = arrange(width: bounds.width, subviews: subviews) + let offset = max(0, (bounds.height - arrangement.size.height) / 2) + for (index, frame) in arrangement.frames.enumerated() where subviews.indices.contains(index) { + subviews[index].place( + at: CGPoint(x: bounds.minX + frame.minX, y: bounds.minY + offset + frame.minY), + proposal: ProposedViewSize(frame.size) + ) + } + } + + private func arrange(width: CGFloat?, subviews: Subviews) -> Arrangement { + guard let leading = subviews.first else { return Arrangement(frames: [], size: .zero) } + guard subviews.count > 1 else { + let size = leading.sizeThatFits(ProposedViewSize(width: width, height: nil)) + let line = CGSize(width: width ?? size.width, height: size.height) + return Arrangement(frames: [CGRect(origin: .zero, size: line)], size: line) + } + let ideal = leading.sizeThatFits(.unspecified) + let badge = subviews[1].sizeThatFits(.unspecified) + let room = (width ?? .infinity) - badge.width - spacing + if room >= min(ideal.width, minimumLeadingWidth) { + let lineWidth = width ?? ideal.width + spacing + badge.width + let leadingWidth = width == nil ? ideal.width : room + let size = width == nil ? ideal : leading.sizeThatFits(ProposedViewSize(width: room, height: nil)) + let height = max(size.height, badge.height) + return Arrangement(frames: [ + CGRect(x: 0, y: (height - size.height) / 2, width: leadingWidth, height: size.height), + CGRect(x: lineWidth - badge.width, y: (height - badge.height) / 2, width: badge.width, height: badge.height) + ], size: CGSize(width: lineWidth, height: height)) + } + let lineWidth = width ?? ideal.width + let size = leading.sizeThatFits(ProposedViewSize(width: lineWidth, height: nil)) + let badgeWidth = min(badge.width, max(0, lineWidth - trailingIndent)) + let badgeHeight = subviews[1].sizeThatFits(ProposedViewSize(width: badgeWidth, height: nil)).height + return Arrangement(frames: [ + CGRect(x: 0, y: 0, width: lineWidth, height: size.height), + CGRect(x: trailingIndent, y: size.height + lineSpacing, width: badgeWidth, height: badgeHeight) + ], size: CGSize(width: lineWidth, height: size.height + lineSpacing + badgeHeight)) + } +} + +/// Equal columns for the resolution steps: as many as fit, reduced to a divisor of the +/// count so no row is left half empty; every step in a row is as tall as the tallest. +struct ToolchainStepGridLayout: Layout { + var minimumColumnWidth: CGFloat = 150 + var spacing: CGFloat = 8 + + private struct Grid { + var columns: Int + var columnWidth: CGFloat + var rowHeights: [CGFloat] + } + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let grid = grid(width: proposal.width, subviews: subviews) + let columns = CGFloat(grid.columns) + let width = proposal.width ?? grid.columnWidth * columns + spacing * max(columns - 1, 0) + let height = grid.rowHeights.reduce(0, +) + spacing * CGFloat(max(grid.rowHeights.count - 1, 0)) + return CGSize(width: width, height: height) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + let grid = grid(width: bounds.width, subviews: subviews) + var y = bounds.minY + for (row, height) in grid.rowHeights.enumerated() { + for column in 0.. Grid { + let count = subviews.count + guard count > 0 else { return Grid(columns: 1, columnWidth: 0, rowHeights: []) } + let columns: Int + let columnWidth: CGFloat + if let width, width.isFinite { + let fitting = max(1, Int((width + spacing) / (minimumColumnWidth + spacing))) + columns = (1...min(fitting, count)).last(where: { count.isMultiple(of: $0) }) ?? 1 + columnWidth = max(0, (width - spacing * CGFloat(columns - 1)) / CGFloat(columns)) + } else { + columns = count + columnWidth = max(minimumColumnWidth, subviews.map { $0.sizeThatFits(.unspecified).width }.max() ?? 0) + } + let rowHeights = stride(from: 0, to: count, by: columns).map { start in + subviews[start..(color: Color, @ViewBuilder content: () -> Content) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 7) { + Image(systemName: "exclamationmark.triangle.fill") + .font(LitheTheme.uiFont(size: 11)) + VStack(alignment: .leading, spacing: 2) { content() } + .fixedSize(horizontal: false, vertical: true) + } + .foregroundStyle(color) + } +} + +/// Shown when there is no project directory to inspect: no project, a remote one, or a failed read. +struct ToolchainSettingsEmptyState: View { + @ObservedObject var feature: ToolchainSettingsFeatureModel + var openPlugins: () -> Void + var refresh: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + ToolchainSettingsSnapshotNotice(feature: feature) + if feature.loadError != nil { + Button("Refresh", action: refresh) + .buttonStyle(LitheActionButtonStyle()) + .disabled(feature.isLoading) + } else if feature.workspaceURL == nil { + LitheSettingsCard { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 3) { + Text("No project environment is available.") + .font(LitheTheme.uiFont(size: 12.5, weight: .semibold)) + Text("Open a project and enable a plugin that defines tools to inspect its environment.") + .font(LitheTheme.uiFont(size: 11.5)) + .foregroundStyle(LitheTheme.secondaryText) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 8) + Button("Open Plugins", action: openPlugins).buttonStyle(LitheActionButtonStyle()) + } + .padding(.vertical, 6) + } + } + } + .padding(.horizontal, 24) + .padding(.top, 18) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } +} diff --git a/macos/Sources/Lithe/Views/App/ToolchainSourcesSettingsView.swift b/macos/Sources/Lithe/Views/App/ToolchainSourcesSettingsView.swift new file mode 100644 index 0000000000..65c8f25a03 --- /dev/null +++ b/macos/Sources/Lithe/Views/App/ToolchainSourcesSettingsView.swift @@ -0,0 +1,276 @@ +import LitheModuleAPI +import SwiftUI + +/// Where tools come from on this computer: the login-shell read and the plugin-declared version managers. +struct ToolchainSourcesSettingsView: View { + @ObservedObject var feature: ToolchainSettingsFeatureModel + var onOpenPlugins: () -> Void + @State private var showsPath = false + @State private var expandedProviderIDs = Set() + @State private var rereadTask: Task? + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + ToolchainSettingsSnapshotNotice(feature: feature) + if feature.loginShell?.pathChangedSinceLaunch == true { + LitheSettingsNotice("The login shell's PATH has changed. Restart Lithe so that terminals and tools use it.") { + RestartLitheButton(prominent: false) + } + } + if let shell = feature.loginShell { + loginShellGroup(shell) + } + providersGroup + } + .padding(.horizontal, 24) + .padding(.top, 18) + .padding(.bottom, 24) + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + } + .litheScrollViewChrome(alwaysShowVertical: true, usesCompactScrollers: true) + .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .topLeading) + .background(LitheTheme.settingsPage) + .clipped() + .onDisappear { rereadTask?.cancel(); rereadTask = nil } + } + + // MARK: Login shell + + private func loginShellGroup(_ shell: LoginShellEnvironmentSnapshot) -> some View { + ToolchainSettingsGroup(Text("Login shell"), accessory: { + LitheInfoTip("At launch Lithe reads your login shell's environment, as a terminal does, and gives it to the terminals, tools and language servers it starts. Only PATH is listed; other variables can hold secrets and are only counted.") + }) { + ToolchainSettingsLine("Shell") { + if shell.shell.isEmpty { + Text("Not found").foregroundStyle(LitheTheme.tertiaryText) + } else { + LitheSettingsPill(verbatim: shell.shell, kind: .value) + } + } + ToolchainSettingsLine("Read result") { + readResult(shell) + if shell.state != .disabled, shell.state != .reading, !shell.shell.isEmpty { + Button("Read again") { + rereadTask?.cancel() + rereadTask = Task { await feature.rereadLoginShell() } + } + .buttonStyle(.litheNoPress) + .foregroundStyle(LitheTheme.accent) + .disabled(feature.isRereadingLoginShell) + } + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("toolchain-login-shell-read") + ToolchainSettingsLine(verbatim: "PATH", info: "Directories in the order tools are looked up. The login shell's directories come first, then any that only the environment Lithe was launched with had.") { + Text("\(shell.processPath.count) directories") + .foregroundStyle(LitheTheme.secondaryText) + if !shell.processPath.isEmpty { + Button { showsPath.toggle() } label: { showsPath ? Text("Show less") : Text("Show") } + .buttonStyle(.litheNoPress) + .foregroundStyle(LitheTheme.accent) + } + } + if showsPath, !shell.processPath.isEmpty { + pathList(shell.processPath) + } + } + } + + @ViewBuilder private func readResult(_ shell: LoginShellEnvironmentSnapshot) -> some View { + switch shell.state { + case .disabled: + Text("Turned off by LITHE_SKIP_SHELL_ENV") + .foregroundStyle(LitheTheme.secondaryText) + case .reading: + HStack(spacing: 6) { + ProgressView().controlSize(.mini) + Text("Reading…").foregroundStyle(LitheTheme.secondaryText) + } + case .notRead: + (shell.startedFromCache ? Text("Not read yet; started with the PATH saved last time") : Text("Not read yet")) + .foregroundStyle(LitheTheme.tertiaryText) + case .failed: + HStack(spacing: 6) { + LitheSettingsPill("Failed", kind: .effect) + (shell.startedFromCache ? Text("Using the PATH saved last time") : Text("Using the environment Lithe was launched with")) + .foregroundStyle(LitheTheme.secondaryText) + .lineLimit(1) + } + case .read: + HStack(spacing: 5) { + if let count = shell.variableCount { + Text("\(count) variables") + } + if let milliseconds = shell.durationMilliseconds { + Text(verbatim: "·").foregroundStyle(LitheTheme.tertiaryText) + Text("\(milliseconds) ms") + } + if let date = shell.readAt { + Text(verbatim: "·").foregroundStyle(LitheTheme.tertiaryText) + Text(date, style: .time) + } + } + .foregroundStyle(LitheTheme.secondaryText) + .lineLimit(1) + } + } + + private func pathList(_ entries: [LoginShellEnvironmentSnapshot.PathEntry]) -> some View { + VStack(alignment: .leading, spacing: 1) { + ForEach(Array(entries.enumerated()), id: \.offset) { index, entry in + HStack(spacing: 8) { + Text(verbatim: String(index + 1)) + .font(LitheTheme.uiFont(size: 10.5, design: .monospaced)) + .foregroundStyle(LitheTheme.tertiaryText) + .frame(width: 20, alignment: .trailing) + Text(verbatim: ToolchainSettingsText.abbreviated(entry.path)) + .font(LitheTheme.uiFont(size: 11.5, design: .monospaced)) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.enabled) + .help(entry.path) + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + if entry.origin == .inherited { + ToolchainSettingsTag("Launch environment") + } + } + .frame(minHeight: 20) + } + } + .padding(.vertical, 4) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("toolchain-login-shell-path") + } + + // MARK: Version managers + + private var providersGroup: some View { + let providers = feature.providers + return ToolchainSettingsGroup(Text("Version managers"), accessory: { + LitheInfoTip("Version managers come from enabled plugins. Lithe asks them what is installed and what this project requires; they never install or trust anything for you.") + }) { + if providers.isEmpty { + HStack(spacing: 10) { + Text("No enabled plugin provides a version manager.") + .foregroundStyle(LitheTheme.secondaryText) + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 8) + Button("Open Plugins", action: onOpenPlugins).buttonStyle(LitheActionButtonStyle()) + } + .font(LitheTheme.uiFont(size: 12)) + .frame(minHeight: 36) + } else { + ForEach(providers) { provider in + providerRow(provider) + } + } + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("toolchain-providers-section") + } + + private func providerRow(_ provider: ToolchainSettingsSnapshot.Provider) -> some View { + let isExpanded = expandedProviderIDs.contains(provider.id) + let found = foundFiles(provider) + let hasWorkspace = feature.snapshot?.workspaceURL != nil + return VStack(alignment: .leading, spacing: 0) { + ToolchainEdgeLayout(minimumLeadingWidth: 220, trailingIndent: 26) { + HStack(spacing: 6) { + Button { + if isExpanded { expandedProviderIDs.remove(provider.id) } else { expandedProviderIDs.insert(provider.id) } + } label: { + HStack(spacing: 6) { + LitheTreeDisclosureIcon(isExpanded: isExpanded) + ToolchainSettingsIcon(name: provider.title, key: provider.id, token: nil) + Text(verbatim: provider.title) + .font(LitheTheme.uiFont(size: 12.5, weight: .semibold)) + .lineLimit(1) + } + .contentShape(Rectangle()) + } + .buttonStyle(.litheNoPress) + .accessibilityLabel(Text(verbatim: provider.title)) + .accessibilityValue(isExpanded ? Text("Expanded") : Text("Collapsed")) + ForEach(toolNames(provider), id: \.self) { ToolchainSettingsTag(verbatim: $0) } + } + if provider.watchFiles.isEmpty { + Text("Reports installed versions only") + .font(LitheTheme.uiFont(size: 11.5)) + .foregroundStyle(LitheTheme.tertiaryText) + } else if hasWorkspace { + if found.isEmpty { + Text("Not used in this project") + .font(LitheTheme.uiFont(size: 11.5)) + .foregroundStyle(LitheTheme.tertiaryText) + } else { + LitheSettingsPill("\(found.count) version files", kind: .project) + } + } + } + .frame(minHeight: 32) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("toolchain-provider:" + provider.id) + if isExpanded { + VStack(alignment: .leading, spacing: 2) { + ToolchainSettingsMetaLine("Watched files") { + if provider.watchFiles.isEmpty { + Text("None").foregroundStyle(LitheTheme.tertiaryText) + } + ForEach(provider.watchFiles, id: \.self) { ToolchainSettingsTag(verbatim: $0, kind: .value) } + } + if hasWorkspace, !provider.watchFiles.isEmpty { + ToolchainSettingsMetaLine("This project") { + if found.isEmpty { + Text("None").foregroundStyle(LitheTheme.tertiaryText) + } + ForEach(found, id: \.self) { path in + Text(verbatim: path) + .font(LitheTheme.uiFont(size: 11, design: .monospaced)) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.enabled) + } + } + } + ToolchainSettingsMetaLine("Query") { + switch provider.queryState { + case .notQueried: + Text("Not queried").foregroundStyle(LitheTheme.tertiaryText) + case .answered(let milliseconds): + Text("Answered in \(milliseconds) ms").foregroundStyle(LitheTheme.secondaryText) + case .failed(let message): + Text(verbatim: message).foregroundStyle(LitheTheme.tertiaryText).textSelection(.enabled) + } + } + ToolchainSettingsMetaLine("Definition", info: "This version manager comes from an enabled plugin. Lithe has no built-in version managers.") { + LitheSettingsPill(verbatim: provider.pluginName, wraps: true) + Text(verbatim: provider.id) + .font(LitheTheme.uiFont(size: 10.5, design: .monospaced)) + .foregroundStyle(LitheTheme.tertiaryText) + .lineLimit(1) + .truncationMode(.middle) + Button("Open Plugins", action: onOpenPlugins) + .buttonStyle(.litheNoPress) + .foregroundStyle(LitheTheme.accent) + } + } + .padding(.leading, 26) + .padding(.bottom, 6) + } + } + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + } + + /// Workspace-relative version files this provider watches that exist in the listed directories. + private func foundFiles(_ provider: ToolchainSettingsSnapshot.Provider) -> [String] { + (feature.snapshot?.directories ?? []).flatMap { directory in + directory.versionFiles.filter { $0.providerIDs.contains(provider.id) }.map(\.path) + } + } + + private func toolNames(_ provider: ToolchainSettingsSnapshot.Provider) -> [String] { + let tools = feature.snapshot?.tools ?? [] + return provider.tools.map { id in tools.first { $0.id == id }?.displayName ?? id } + } +} diff --git a/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift b/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift index 0a218bddfe..98ee96ecf9 100644 --- a/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift +++ b/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift @@ -489,6 +489,29 @@ package protocol LanguageServerRuntimeCore: Sendable { arguments: ToolingJSONValue, timeout: TimeInterval ) -> Result + /// Sends one `toolchains/query`. Its completion is a `requestCompleted` + /// event whose `result.value` is `{ id, response }`, the raw reply the + /// caller passes to `toolchains.decodeReply` with the same JSON-RPC ID. + func requestToolchainProvider( + sessionID: String, + params: ToolingJSONValue, + timeout: TimeInterval + ) -> Result + /// Starts a plugin backend and offers it the manifest's toolchain providers. + func startPluginToolchainBackend( + pluginID: String, + providerID: String, + executableURL: URL, + arguments: [String], + environment: [String: String], + rootURL: URL, + initializationOptions: ToolingJSONValue?, + hostServices: [String], + toolchainProviders: [String], + initializeTimeout: TimeInterval, + requestTimeout: TimeInterval, + shutdownTimeout: TimeInterval + ) -> Result /// Starts a process a plugin owns. Only such a session negotiates plugin /// functions, and Core lets it serve that plugin's functions alone. func startPluginLanguageServer( @@ -730,6 +753,41 @@ package extension LanguageServerRuntimeCore { )) } + func requestToolchainProvider( + sessionID _: String, + params _: ToolingJSONValue, + timeout _: TimeInterval + ) -> Result { + .failure(LanguageServerRuntimeFailure( + code: "not_supported", + message: "Toolchain-provider requests are not supported by this runtime." + )) + } + + /// Runtimes without the protocol start the backend without the offer, so + /// it negotiates no provider and receives no query. + func startPluginToolchainBackend( + pluginID: String, + providerID: String, + executableURL: URL, + arguments: [String], + environment: [String: String], + rootURL: URL, + initializationOptions: ToolingJSONValue?, + hostServices: [String], + toolchainProviders _: [String], + initializeTimeout: TimeInterval, + requestTimeout: TimeInterval, + shutdownTimeout: TimeInterval + ) -> Result { + startPluginLanguageServer( + pluginID: pluginID, providerID: providerID, executableURL: executableURL, arguments: arguments, + environment: environment, rootURL: rootURL, initializationOptions: initializationOptions, + hostServices: hostServices, + initializeTimeout: initializeTimeout, requestTimeout: requestTimeout, shutdownTimeout: shutdownTimeout + ) + } + /// Runtimes without plugin ownership start a host-owned session, which /// serves no plugin functions. func startPluginLanguageServer( diff --git a/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift b/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift index 2d8db78c25..2f80efff28 100644 --- a/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift +++ b/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift @@ -53,6 +53,8 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { private let pluginID: String? /// Host services Core lets this plugin backend request. private let hostServices: [String] + /// Manifest providers this backend is offered during `initialize`. + private let offeredToolchainProviders: [String] private var sessionID: String? private var startupGeneration: UUID? @@ -132,6 +134,8 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { /// The VCS provider the process negotiated in `initialize`, as Core's /// `vcsProvider` event reported it; nil for every other session. package private(set) var vcsNegotiation: VcsProviderNegotiation? + /// Providers the backend selected, from the `toolchainProviders` event. + package private(set) var toolchainProviders: [String] = [] package var onServerInfoChange: ((LanguageServerInfo?) -> Void)? package var javaTestRunnerURL: URL? { hostIntegrationLaunch?.coreLaunch?.jdtlsLaunchResources?.javaTestRunnerURL @@ -154,7 +158,8 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { processRegistry: (any LanguageServerProcessRegistry)? = nil, moduleID: ModuleID = .languageIntelligence, pluginID: String? = nil, - hostServices: [String] = [] + hostServices: [String] = [], + toolchainProviders: [String] = [] ) { self.providerID = providerID self.executableURL = executableURL @@ -173,6 +178,7 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { self.moduleID = moduleID self.pluginID = pluginID self.hostServices = hostServices + self.offeredToolchainProviders = toolchainProviders } /// Derived from the last lifecycle state Rust published: there is no local @@ -283,6 +289,7 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { let core = core let pluginID = pluginID let hostServices = hostServices + let offeredToolchainProviders = offeredToolchainProviders let providerID = providerID let executableURL = executableURL let environment = environment @@ -295,7 +302,22 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { let resolvedOptions = initializationOptions let resolvedConfiguration = workspaceConfiguration let launch: @Sendable () -> Result = { - if let pluginID { + if let pluginID, !offeredToolchainProviders.isEmpty { + core.startPluginToolchainBackend( + pluginID: pluginID, + providerID: providerID, + executableURL: executableURL, + arguments: resolvedArguments, + environment: environment, + rootURL: normalizedRoot, + initializationOptions: resolvedOptions, + hostServices: hostServices, + toolchainProviders: offeredToolchainProviders, + initializeTimeout: initializeTimeout, + requestTimeout: requestTimeout, + shutdownTimeout: shutdownTimeout + ) + } else if let pluginID { core.startPluginLanguageServer( pluginID: pluginID, providerID: providerID, @@ -904,6 +926,40 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { } } + /// Sends one `toolchains/query` and returns `{ id, response }`, the raw + /// reply `toolchains.decodeReply` validates. Cancelling the task sends + /// `$/cancelRequest`; a late reply then finds no pending request. + package func toolchainRequest(params: ToolingJSONValue, timeout: TimeInterval) async throws -> ToolingJSONValue { + guard let sessionID, state == .ready else { + throw LanguageServerRuntimeSessionError.notReady + } + let started: LanguageServerRuntimeOperation + switch await send(timeout: timeout, { core in + core.requestToolchainProvider(sessionID: sessionID, params: params, timeout: timeout) + }) { + case .success(let operation): started = operation + case .failure(let error): + sendsInFlight -= 1 + try Task.checkCancellation() + throw LanguageServerRuntimeSessionError.requestRejected(error.userMessage) + } + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + registerPendingOperation(started, name: "toolchains/query") { result in + continuation.resume(with: result.flatMap { event in + guard case .object(let object)? = event.result, let value = object["value"] else { + return .failure(LanguageServerRuntimeSessionError.missingResult) + } + return .success(value) + }) + } + sendsInFlight -= 1 + } + } onCancel: { + Task { @MainActor [weak self] in self?.cancelOffMain(started.operationID) } + } + } + /// Invokes a plugin function of the plugin that owns this session. Core /// checks ownership and registration; a refusal or a failed call throws /// `PluginFunctionFailure`, and cancelling the task cancels the call. @@ -1483,6 +1539,13 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { case .failure(let error): onLog?(.error, "Invalid VCS provider event", error.localizedDescription, nil) } return false + case "toolchainProviders": + if case .object(let object)? = event.result, case .array(let providers)? = object["providers"] { + toolchainProviders = providers.compactMap { + if case .string(let provider) = $0 { provider } else { nil } + } + } + return false case "featuresChanged": updateFeatures(capabilityNames: event.capabilities ?? []) return false @@ -1580,6 +1643,7 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { onFunctionsChange?([]) } vcsNegotiation = nil + toolchainProviders = [] let released = onReleased onReleased = nil released?() diff --git a/macos/Tests/LitheTests/MacLoginShellEnvironmentTests.swift b/macos/Tests/LitheTests/MacLoginShellEnvironmentTests.swift index 2f4e336d4f..0758eac3c6 100644 --- a/macos/Tests/LitheTests/MacLoginShellEnvironmentTests.swift +++ b/macos/Tests/LitheTests/MacLoginShellEnvironmentTests.swift @@ -141,6 +141,125 @@ struct MacLoginShellEnvironmentTests { #expect(refresh == nil) #expect(!applied) } + + // MARK: Settings inspector + + @Test + func inspectorMarksWhichPathEntriesCameFromTheLoginShell() throws { + let fixture = try ShellFixture() + defer { fixture.remove() } + try fixture.writeRC(""" + export PATH=/fixture/login/bin:/usr/bin:/bin + export LITHE_TEST_SECRET=hunter2 + """) + let shared = MacLoginShellEnvironment() + MacLoginShellEnvironment.activate( + cacheURL: fixture.root.appendingPathComponent("login-path"), + environment: ["SHELL": fixture.shell, "PATH": "/usr/bin:/fixture/launch-only/bin"], + shared: shared, shellTimeout: Self.shellTimeout, applyPath: { _ in } + ) + + let snapshot = shared.inspect() + + #expect(snapshot.shell == fixture.shell) + #expect(snapshot.state == .read) + #expect((snapshot.variableCount ?? 0) > 0) + #expect(snapshot.durationMilliseconds != nil && snapshot.readAt != nil) + #expect(!snapshot.startedFromCache && !snapshot.pathChangedSinceLaunch) + #expect(snapshot.processPath == [ + .init(path: "/fixture/login/bin", origin: .loginShell), + .init(path: "/usr/bin", origin: .loginShell), + .init(path: "/bin", origin: .loginShell), + .init(path: "/fixture/launch-only/bin", origin: .inherited) + ]) + } + + @Test + func warmStartIsReadingUntilItsRefreshFinishes() throws { + let fixture = try ShellFixture() + defer { fixture.remove() } + try fixture.writeRC("export PATH=/fixture/cached/bin:/usr/bin") + let cache = fixture.root.appendingPathComponent("login-path") + try "/fixture/cached/bin:/usr/bin".write(to: cache, atomically: true, encoding: .utf8) + let shared = MacLoginShellEnvironment() + + let refresh = try #require(MacLoginShellEnvironment.activate( + cacheURL: cache, environment: ["SHELL": fixture.shell, "PATH": "/usr/bin"], + shared: shared, shellTimeout: Self.shellTimeout, applyPath: { _ in } + )) + let before = shared.inspect() + #expect(before.state == .reading && before.startedFromCache) + #expect(before.variableCount == nil) + + refresh() + let after = shared.inspect() + #expect(after.state == .read && after.startedFromCache) + #expect(!after.pathChangedSinceLaunch) + } + + @Test + func rereadKeepsThisProcessPathAndReportsThatTheLoginPathChanged() throws { + let fixture = try ShellFixture() + defer { fixture.remove() } + try fixture.writeRC("export PATH=/fixture/first/bin:/usr/bin") + let cache = fixture.root.appendingPathComponent("login-path") + let shared = MacLoginShellEnvironment() + var applied: [String] = [] + MacLoginShellEnvironment.activate( + cacheURL: cache, environment: ["SHELL": fixture.shell, "PATH": "/usr/bin"], + shared: shared, shellTimeout: Self.shellTimeout, applyPath: { applied.append($0) } + ) + try fixture.writeRC("export PATH=/fixture/second/bin:/usr/bin") + + let snapshot = shared.rereadSynchronously(timeout: Self.shellTimeout) + + #expect(snapshot.state == .read && snapshot.pathChangedSinceLaunch) + #expect(snapshot.processPath.first?.path == "/fixture/first/bin") + #expect(applied == ["/fixture/first/bin:/usr/bin"], "A reread must not change this process's PATH") + #expect(try String(contentsOf: cache, encoding: .utf8).hasPrefix("/fixture/second/bin:")) + } + + @Test + func failedReadKeepsTheLaunchPathAndHasNoVariableCount() throws { + let fixture = try ShellFixture() + defer { fixture.remove() } + try fixture.writeRC("exit 3") + let shared = MacLoginShellEnvironment() + let refresh = MacLoginShellEnvironment.activate( + cacheURL: fixture.root.appendingPathComponent("login-path"), + environment: ["SHELL": fixture.shell, "PATH": "/usr/bin:/bin"], + shared: shared, shellTimeout: Self.shellTimeout, applyPath: { _ in } + ) + + let snapshot = shared.inspect() + + #expect(refresh == nil) + #expect(snapshot.state == .failed && snapshot.variableCount == nil) + #expect(snapshot.processPath == [.init(path: "/usr/bin", origin: .inherited), .init(path: "/bin", origin: .inherited)]) + } + + @Test + func optOutAndUnreadShellsAreDescribedWithoutRunningTheShell() throws { + let fixture = try ShellFixture() + defer { fixture.remove() } + let ran = fixture.root.appendingPathComponent("ran") + try fixture.writeRC("touch '\(ran.path)'") + + let prepared = MacLoginShellEnvironment() + prepared.prepare(shell: fixture.shell, cacheURL: nil, environment: ["PATH": "/usr/bin"]) + #expect(prepared.inspect().state == .notRead) + #expect(prepared.inspect().processPath == [.init(path: "/usr/bin", origin: .inherited)]) + + let disabled = MacLoginShellEnvironment() + MacLoginShellEnvironment.activate( + cacheURL: fixture.root.appendingPathComponent("login-path"), + environment: ["SHELL": fixture.shell, "PATH": "/usr/bin", MacLoginShellEnvironment.skipVariable: "1"], + shared: disabled, shellTimeout: Self.shellTimeout, applyPath: { _ in } + ) + #expect(disabled.inspect().state == .disabled) + #expect(disabled.rereadSynchronously(timeout: Self.shellTimeout).state == .disabled) + #expect(!FileManager.default.fileExists(atPath: ran.path), "The opt-out must not run the shell, even on reread") + } } private final class RecordingLog: ApplicationLogging, @unchecked Sendable { diff --git a/macos/Tests/LitheTests/MacToolchainDiscoveryTests.swift b/macos/Tests/LitheTests/MacToolchainDiscoveryTests.swift new file mode 100644 index 0000000000..7ac8ff6e8a --- /dev/null +++ b/macos/Tests/LitheTests/MacToolchainDiscoveryTests.swift @@ -0,0 +1,461 @@ +import Darwin +import Foundation +import Testing +@testable import Lithe +import LitheCoreContracts +import LitheModuleAPI + +/// Real directories and the real Core; only the home directory and version probes are injected, +/// so no installed developer tool is read or run. +@Suite("macOS toolchain discovery") +struct MacToolchainDiscoveryTests { + private static let pluginID = "dev.lithe.fixture.discovery" + + private static func manifest(complete: Bool = true) -> Data { + let definition: [String: Any] = [ + "id": "compiler", "displayName": "Fixture compiler", "icon": ["host": "plugin"], + "executableLayouts": [["path": "bin/compiler"]], + "executables": [ + ["id": "home", "kind": "environment", "variable": "FIXTURE_HOME", "path": "bin/compiler"], + ["id": "shell", "kind": "command", "command": "compiler"], + ["id": "detected", "kind": "toolchain", "scope": "installed"] + ], + "version": ["arguments": ["--version"], "pattern": #"compiler (\d+(?:\.\d+)*)"#], + "homeLayouts": [["match": "*.kit", "home": "Contents/Home"]], + "discovery": [["root": ["kind": "home"], "pattern": "Kits/*"]], + "homeVariable": "FIXTURE_HOME", "commands": ["compiler"], "wrappers": [], "exports": [], + "pathPrepend": ["${home}/bin"], "launch": ["${executable}", "${arguments}"] + ] + var manifest: [String: Any] = [ + "schemaVersion": 2, "id": pluginID, "displayName": "Discovery fixture", "version": "1.0.0", + "pluginAPIVersion": 3, + "vendor": ["id": "org.example", "displayName": "Fixture publisher", "signatureRequirement": "publisherPackage"], + "permissions": [], "toolDefinitions": [definition] + ] + if complete { manifest["requires"] = ["toolDefinitions": 1, "executableResolution": 2] } + return (try? JSONSerialization.data(withJSONObject: manifest)) ?? Data() + } + + private static func source(complete: Bool = true) -> ToolchainSettingsPluginSource { + .init(pluginID: pluginID, name: "Discovery fixture", manifest: manifest(complete: complete), isEnabled: true, + verifiedOfficial: true) + } + + @Test func scannedHomesAreCheckedProbedAndTheHighestVersionIsSelected() async throws { + let root = try DiscoveryFixtureRoot() + defer { root.remove() } + let newer = try root.installation("home/Kits/newer.kit/Contents/Home") + let older = try root.installation("home/Kits/older.kit/Contents/Home") + let broken = try root.installation("home/Kits/broken.kit/Contents/Home") + let shell = try root.installation("shell-home") + _ = try root.installation("home/Kits/plain/Contents/Home") + _ = try root.installation("home/Kits/.hidden.kit/Contents/Home") + try Data().write(to: root.url("home/Kits/notes.kit")) + let inside = try root.installation("workspace/inside.kit/Contents/Home") + try FileManager.default.createSymbolicLink(at: root.url("home/Kits/linked.kit"), + withDestinationURL: root.url("workspace/inside.kit")) + let probes = DiscoveryProbes(outputs: [ + newer + "/bin/compiler": "compiler 21.0.1", older + "/bin/compiler": "compiler 17.0.2", + shell + "/bin/compiler": "compiler 23.1" + ]) + let result = await discovery(root: root, probes: probes).discover(.init( + workspace: root.url("workspace"), sources: [Self.source()], + environment: ["FIXTURE_HOME": shell, "UNDECLARED": "/private"])) + + let detection = try #require(result.detections["compiler"]) + #expect(result.unchecked.isEmpty && result.diagnostics.isEmpty) + #expect(Set(detection.installations.map(\.home)) == [newer, older]) + #expect(detection.installations.allSatisfy { $0.provider == "host:system" }) + #expect(detection.installations.first { $0.home == newer }?.version == "21.0.1") + #expect(detection.installations.first { $0.home == newer }?.executable == newer + "/bin/compiler") + // The home layout's suffix is shared by every kit; the scanned entry names the installation. + #expect(detection.installations.first { $0.home == newer }?.location == root.path + "/home/Kits/newer.kit") + #expect(detection.selected == newer && !detection.conflict) + #expect(detection.skipped == [.init(home: broken, reason: "probeFailed")]) + #expect(detection.shell == [.init(variable: "FIXTURE_HOME", home: shell, executable: shell + "/bin/compiler", + version: "23.1")]) + // Only homes Core checked are probed: never the project, an unmatched or hidden entry. + let probed = probes.calls + #expect(Set(probed.map(\.executable)) == Set([newer, older, broken, shell].map { $0 + "/bin/compiler" })) + #expect(probed.allSatisfy { $0.arguments == ["--version"] }) + #expect(!probed.contains { $0.executable.hasPrefix(inside) }) + } + + @Test func equalHighestVersionsInDifferentHomesAreAConflict() async throws { + let root = try DiscoveryFixtureRoot() + defer { root.remove() } + let first = try root.installation("home/Kits/first.kit/Contents/Home") + let second = try root.installation("home/Kits/second.kit/Contents/Home") + let probes = DiscoveryProbes(outputs: [first + "/bin/compiler": "compiler 21", second + "/bin/compiler": "compiler 21.0"]) + let result = await discovery(root: root, probes: probes).discover(.init( + workspace: root.url("workspace"), sources: [Self.source()], environment: [:])) + let detection = try #require(result.detections["compiler"]) + #expect(detection.installations.count == 2) + #expect(detection.selected == nil && detection.conflict) + } + + @Test func refusedDeclarationsAreReportedWithoutScanningOrProbing() async throws { + let root = try DiscoveryFixtureRoot() + defer { root.remove() } + let home = try root.installation("home/Kits/only.kit/Contents/Home") + let probes = DiscoveryProbes(outputs: [home + "/bin/compiler": "compiler 1"]) + let result = await discovery(root: root, probes: probes).discover(.init( + workspace: root.url("workspace"), sources: [Self.source(complete: false)], environment: [:])) + #expect(result.detections.isEmpty && result.unchecked.isEmpty) + #expect(result.diagnostics == [L10n.string("Installed toolchain declarations could not be checked for discovery.")]) + #expect(probes.calls.isEmpty) + } + + /// The definition plugin as its own provider, as a version-manager plugin declares one. Its + /// definition gains the project and global layers, where provider statements decide. + private static func providerSource(providers: [String] = ["kits"]) throws -> ToolchainSettingsPluginSource { + var manifest = try #require(try JSONSerialization.jsonObject(with: manifest()) as? [String: Any]) + var definitions = try #require(manifest["toolDefinitions"] as? [[String: Any]]) + let executables = try #require(definitions.first?["executables"] as? [[String: Any]]) + definitions[0]["executables"] = [["id": "project", "kind": "toolchain", "scope": "project"], + ["id": "global", "kind": "toolchain", "scope": "global"]] + executables + manifest["toolDefinitions"] = definitions + manifest["requires"] = ["toolDefinitions": 1, "executableResolution": 2, "toolchainProviders": 1, + "backend": 2, "serverProcess": 1] + manifest["permissions"] = ["backend.launch", "toolchains.provide"] + manifest["toolchainProviders"] = providers.map { id in + ["id": id, "displayName": id.capitalized, "tools": ["compiler"], + "watchFiles": [["pattern": ".compiler-version", "reader": "native"]]] as [String: Any] + } + manifest["backend"] = ["launch": ["kind": "packaged", "path": "bin/kits"]] + return .init(pluginID: pluginID, name: "Discovery fixture", + manifest: try JSONSerialization.data(withJSONObject: manifest), isEnabled: true, verifiedOfficial: true) + } + + /// A provider reports an alias link; Core keeps the canonical home, the project + /// requirement and the global one apart, and the installation joins the candidates. + @Test func providerAnswersAreValidatedAndJoinTheCandidates() async throws { + let root = try DiscoveryFixtureRoot() + defer { root.remove() } + let installed = try root.installation("manager/installs/compiler/21.0.1") + try FileManager.default.createSymbolicLink(atPath: root.path + "/manager/installs/compiler/latest", + withDestinationPath: installed) + let alias = root.path + "/manager/installs/compiler/latest" + let versionFile = root.path + "/workspace/.compiler-version" + try Data("21\n".utf8).write(to: URL(fileURLWithPath: versionFile)) + let globalFile = root.path + "/home/.compiler-version" + try Data("21\n".utf8).write(to: URL(fileURLWithPath: globalFile)) + let queries = ProviderQueries { params in + let project = params["workspace"] is [String: Any] + let statements: [[String: Any]] = [ + ["tool": "compiler", "scope": "global", "source": globalFile, "request": "21", "installation": alias, + "kind": "declaration", "reader": "native"] + ] + (project ? [["tool": "compiler", "scope": "project", "source": versionFile, "request": "21", + "installation": alias, "kind": "declaration", "reader": "native"]] : []) + return ["installations": [["tool": "compiler", "version": "21.0.1", "home": alias]], + "statements": statements, "watch": [root.path + "/manager/installs"]] + } + let probes = DiscoveryProbes(outputs: [installed + "/bin/compiler": "compiler 21.0.1"]) + var discovery = discovery(root: root, probes: probes) + discovery.queryProvider = { plugin, provider, params, _ in try queries.answer(pluginID: plugin, providerID: provider, params: params) } + discovery.workspaceContext = { _, _ in .init(contextID: "context-1", generation: 3, rootID: "root-1") } + let result = await discovery.discover(.init( + workspace: root.url("workspace"), sources: [try Self.providerSource()], environment: [:])) + + let provider = Self.pluginID + ":kits" + #expect(result.diagnostics.isEmpty) + let detection = try #require(result.detections["compiler"]) + #expect(detection.installations.map(\.home) == [installed]) + #expect(detection.installations.first?.provider == provider) + #expect(result.requirements["."]?["compiler"] == [.init(request: "21", source: versionFile, providerID: provider, + home: installed, version: "21.0.1")]) + #expect(result.globalRequirements["compiler"] == [.init(request: "21", source: globalFile, providerID: provider, + home: installed, version: "21.0.1")]) + #expect(result.watch == [root.path + "/manager/installs"]) + guard case .answered? = result.providerStates[provider] else { + Issue.record("the provider should have answered") + return + } + // Core resolves the same replies: the project requirement decides, not the page. + #expect(result.unresolved.isEmpty) + #expect(result.resolutions["."]?["compiler"] == .init(layer: .project, outcome: .resolved( + home: installed, executable: installed + "/bin/compiler", version: "21.0.1", provider: provider))) + // A global query, then one round per directory. Every provider in a round shares its + // generation, and every request has its own operation. + let sent = queries.sent + #expect(sent.count == 2) + #expect(sent.first?["workspace"] is NSNull && sent.first?["context"] is NSNull) + let workspace = try #require(sent.last?["workspace"] as? [String: Any]) + #expect(workspace["rootID"] as? String == "root-1" && workspace["directory"] as? String == ".") + #expect(Set(sent.compactMap { $0["operationID"] as? String }).count == 2) + #expect(Set(sent.compactMap { $0["generation"] as? Int }).count == 2) + } + + /// A requirement nothing installs is reported and the next layer decides. + @Test func anUninstalledRequirementIsUnmetAndTheNextLayerDecides() async throws { + let root = try DiscoveryFixtureRoot() + defer { root.remove() } + let installed = try root.installation("manager/installs/compiler/21.0.1") + let versionFile = root.path + "/workspace/.compiler-version" + try Data("22\n".utf8).write(to: URL(fileURLWithPath: versionFile)) + let queries = ProviderQueries { params in + let project = params["workspace"] is [String: Any] + return ["installations": [["tool": "compiler", "version": "21.0.1", "home": installed]], + "statements": project ? [["tool": "compiler", "scope": "project", "source": versionFile, + "request": "22", "installation": NSNull(), "kind": "declaration", + "reader": "native"]] : [], + "watch": []] + } + let probes = DiscoveryProbes(outputs: [installed + "/bin/compiler": "compiler 21.0.1"]) + var discovery = discovery(root: root, probes: probes) + discovery.queryProvider = { plugin, provider, params, _ in try queries.answer(pluginID: plugin, providerID: provider, params: params) } + discovery.workspaceContext = { _, _ in .init(contextID: "context-1", generation: 3, rootID: "root-1") } + let result = await discovery.discover(.init( + workspace: root.url("workspace"), sources: [try Self.providerSource()], environment: [:])) + + let provider = Self.pluginID + ":kits" + #expect(result.requirements["."]?["compiler"] == [.init(request: "22", source: versionFile, providerID: provider)]) + #expect(result.resolutions["."]?["compiler"] == .init(layer: .automatic, outcome: .resolved( + home: installed, executable: installed + "/bin/compiler", version: "21.0.1", provider: provider), + unmet: ["22"])) + } + + /// The window's context changed during one load: the round keeps the context it opened + /// with, and a reply under the newer one is neither resolved nor shown for that directory. + @Test func aReplyUnderAChangedContextIsLeftOutOfItsDirectory() async throws { + let root = try DiscoveryFixtureRoot() + defer { root.remove() } + let installed = try root.installation("manager/installs/compiler/21.0.1") + let versionFile = root.path + "/workspace/.compiler-version" + try Data("21\n".utf8).write(to: URL(fileURLWithPath: versionFile)) + let queries = ProviderQueries { params in + let project = params["workspace"] is [String: Any] + return ["installations": [["tool": "compiler", "version": "21.0.1", "home": installed]], + "statements": project ? [["tool": "compiler", "scope": "project", "source": versionFile, + "request": "21", "installation": installed, "kind": "declaration", + "reader": "native"]] : [], + "watch": []] + } + let contexts = ContextGenerations(first: 3) + let probes = DiscoveryProbes(outputs: [installed + "/bin/compiler": "compiler 21.0.1"]) + var discovery = discovery(root: root, probes: probes) + discovery.queryProvider = { plugin, provider, params, _ in try queries.answer(pluginID: plugin, providerID: provider, params: params) } + discovery.workspaceContext = { _, _ in .init(contextID: "context-1", generation: contexts.next(), rootID: "root-1") } + let result = await discovery.discover(.init( + workspace: root.url("workspace"), sources: [try Self.providerSource(providers: ["kits", "other"])], + environment: [:])) + + // Providers are asked in name order, so `kits` opened the round and `other` came under generation 4. + let kits = Self.pluginID + ":kits" + #expect(queries.sent.contains { ($0["context"] as? [String: Any])?["generation"] as? Int == 4 }) + #expect(result.requirements["."]?["compiler"] == [.init(request: "21", source: versionFile, providerID: kits, + home: installed, version: "21.0.1")]) + #expect(result.resolutions["."]?["compiler"] == .init(layer: .project, outcome: .resolved( + home: installed, executable: installed + "/bin/compiler", version: "21.0.1", provider: kits))) + } + + /// A saved pin decides alone in its directory, even one no provider was asked about. Its home + /// is checked and probed like a scanned one, yet never listed as an installation. + @Test func pinsDecideTheirDirectoryAndAnUnusableOneFails() async throws { + let root = try DiscoveryFixtureRoot() + defer { root.remove() } + let pinned = try root.installation("pinned/compiler-17") + try FileManager.default.createDirectory(at: root.url("workspace/apps"), withIntermediateDirectories: true) + let missing = root.path + "/pinned/removed" + let local: [String: Any] = ["version": 1, "toolchains": [:], "pins": [ + ".": ["compiler": ["home": pinned, "version": "17.0.2", "provider": "host:manual"]], + "apps": ["compiler": ["home": missing, "version": "18", "provider": "host:manual"]] + ]] + let probes = DiscoveryProbes(outputs: [pinned + "/bin/compiler": "compiler 17.0.2"]) + let result = await discovery(root: root, probes: probes).discover(.init( + workspace: root.url("workspace"), sources: [Self.source()], environment: [:], + local: try JSONSerialization.data(withJSONObject: local))) + + #expect(result.unresolved.isEmpty) + #expect(result.detections["compiler"]?.installations.isEmpty == true) + #expect(probes.calls.map(\.executable) == [pinned + "/bin/compiler"]) + #expect(result.resolutions["."]?["compiler"] == .init(layer: .pinned, outcome: .resolved( + home: pinned, executable: pinned + "/bin/compiler", version: "17.0.2", provider: "host:manual"))) + let failed = try #require(result.resolutions["apps"]?["compiler"]) + #expect(failed.layer == .pinned) + guard case .failed = failed.outcome else { + Issue.record("an unusable pin must fail instead of falling through: \(failed.outcome)") + return + } + } + + /// A refused reply names the failure and contributes nothing, not even its valid parts. + @Test func aRejectedReplyAddsNoInstallationAndIsReported() async throws { + let root = try DiscoveryFixtureRoot() + defer { root.remove() } + let inside = try root.installation("workspace/vendor/compiler") + let queries = ProviderQueries { _ in + ["installations": [["tool": "compiler", "version": "1", "home": inside]], "statements": [], "watch": []] + } + var discovery = discovery(root: root, probes: DiscoveryProbes(outputs: [:])) + discovery.queryProvider = { plugin, provider, params, _ in try queries.answer(pluginID: plugin, providerID: provider, params: params) } + let result = await discovery.discover(.init( + workspace: root.url("workspace"), sources: [try Self.providerSource()], environment: [:])) + #expect(result.detections["compiler"]?.installations.isEmpty == true) + guard case .failed? = result.providerStates[Self.pluginID + ":kits"] else { + Issue.record("a workspace home must be refused") + return + } + // Without an open context only the global query is sent. + #expect(queries.sent.count == 1) + // The refusal is the reported reason; a missing context does not hide it. + #expect(result.diagnostics.count == 1) + #expect(result.diagnostics.first?.contains("kits") == true) + } + + @Test func globsMatchOneComponentLikeCore() { + #expect(MacToolchainDiscovery.matches("*.kit", "a.kit")) + #expect(MacToolchainDiscovery.matches("*", "anything")) + #expect(MacToolchainDiscovery.matches("a*b*c", "aXXbYYc")) + #expect(MacToolchainDiscovery.matches("kit", "kit")) + #expect(!MacToolchainDiscovery.matches("*.kit", "a.kit.bak")) + #expect(!MacToolchainDiscovery.matches("a*b*c", "aXXc")) + #expect(!MacToolchainDiscovery.matches("kit", "kits")) + } + + #if DEBUG + /// The settings preview scans this Mac for real, so its sample manifests must pass Core's admission. + @MainActor @Test func previewSamplesAreAdmittedByCore() throws { + let plugins: [[String: Any]] = try ToolchainSettingsDemo.samplePlugins.map { source in + ["manifest": try JSONSerialization.jsonObject(with: source.manifest), "verifiedOfficial": source.verifiedOfficial] + } + let plan = MacToolchainDiscovery.callCore("toolchains.discoveryPlan", + ["registry": ["plugins": plugins], "platform": "macos"])?["plan"] + let tools = try #require((plan as? [String: Any])?["tools"] as? [[String: Any]]) + #expect(tools.compactMap { $0["tool"] as? String }.sorted() == ["java", "maven", "node", "python"]) + #expect(tools.allSatisfy { !($0["scans"] as? [Any] ?? []).isEmpty }) + } + #endif + + /// 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 { + struct BackendDown: LocalizedError { + var errorDescription: String? { "fixture backend stopped" } + } + let root = try DiscoveryFixtureRoot() + defer { root.remove() } + let scanned = try root.installation("home/Kits/scanned.kit/Contents/Home") + try Data("21\n".utf8).write(to: root.url("workspace/.compiler-version")) + let probes = DiscoveryProbes(outputs: [scanned + "/bin/compiler": "compiler 21.0.1"]) + var discovery = discovery(root: root, probes: probes) + discovery.queryProvider = { _, provider, _, _ in + if provider == "kits" { throw MacToolchainDiscovery.QueryTimeout() } + throw BackendDown() + } + discovery.workspaceContext = { _, _ in .init(contextID: "context-1", generation: 3, rootID: "root-1") } + let result = await discovery.discover(.init( + workspace: root.url("workspace"), sources: [try Self.providerSource(providers: ["kits", "other"])], + environment: [:])) + + let kits = Self.pluginID + ":kits" + let other = Self.pluginID + ":other" + #expect(result.requirements["."] == nil) + #expect(result.resolutions["."]?["compiler"] == .init(layer: .automatic, outcome: .resolved( + home: scanned, executable: scanned + "/bin/compiler", version: "21.0.1", provider: "host:system"), + unavailable: [.init(provider: kits, timedOut: true), .init(provider: other, timedOut: false)])) + #expect(result.providerStates[kits] == .failed(L10n.string("No answer within \(Int(MacToolchainDiscovery.queryTimeout)) s"))) + #expect(result.providerStates[other] == .failed("fixture backend stopped")) + #expect(result.diagnostics.count == 2) + } + + private func discovery(root: DiscoveryFixtureRoot, probes: DiscoveryProbes) -> MacToolchainDiscovery { + var discovery = MacToolchainDiscovery() + let home = root.url("home").path + discovery.homeDirectory = { home } + discovery.probe = { probes.run($0, $1) } + return discovery + } +} + +/// A canonical temporary directory with a home and a workspace, removed by the test. +private struct DiscoveryFixtureRoot { + let path: String + + init() throws { + let base = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-toolchain-discovery-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) + guard let resolved = Darwin.realpath(base.path, nil) else { throw CocoaError(.fileNoSuchFile) } + defer { free(resolved) } + path = String(cString: resolved) + try FileManager.default.createDirectory(at: url("home"), withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: url("workspace"), withIntermediateDirectories: true) + } + + func url(_ relative: String) -> URL { URL(fileURLWithPath: path + "/" + relative) } + + /// A home with an executable `bin/compiler`; returns the home path. + func installation(_ relative: String) throws -> String { + let executable = url(relative + "/bin/compiler") + try FileManager.default.createDirectory(at: executable.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data("#!/bin/sh\n".utf8).write(to: executable) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path) + return path + "/" + relative + } + + func remove() { try? FileManager.default.removeItem(atPath: path) } +} + +/// Canned probe outputs keyed by executable; a missing entry is a failed probe. +private final class DiscoveryProbes: @unchecked Sendable { + struct Call: Equatable { + let executable: String + let arguments: [String] + } + + private let lock = NSLock() + private let outputs: [String: String] + private var recorded: [Call] = [] + + init(outputs: [String: String]) { self.outputs = outputs } + + var calls: [Call] { lock.withLock { recorded } } + + func run(_ executable: String, _ arguments: [String]) -> String? { + lock.withLock { + recorded.append(.init(executable: executable, arguments: arguments)) + return outputs[executable] + } + } +} + +/// A window context whose generation advances on every lookup, as one changing mid-load would. +private final class ContextGenerations: @unchecked Sendable { + private let lock = NSLock() + private var value: Int + + init(first: Int) { value = first } + + func next() -> Int { + lock.withLock { () -> Int in + defer { value += 1 } + return value + } + } +} + +/// Answers queries like a backend would; each result echoes the request's identity. +private final class ProviderQueries: @unchecked Sendable { + private let lock = NSLock() + private let result: @Sendable ([String: Any]) -> [String: Any] + private var recorded: [[String: Any]] = [] + private var nextID = 0 + + init(result: @escaping @Sendable ([String: Any]) -> [String: Any]) { self.result = result } + + var sent: [[String: Any]] { lock.withLock { recorded } } + + func answer(pluginID _: String, providerID _: String, params: ToolingJSONValue) throws -> ToolingJSONValue { + let params = params.foundationObject as? [String: Any] ?? [:] + let id = lock.withLock { () -> Int in + recorded.append(params) + nextID += 1 + return nextID + } + var body = result(params) + for key in ["operationID", "generation", "providerID"] { body[key] = params[key] } + let reply: [String: Any] = ["id": id, "response": ["jsonrpc": "2.0", "id": id, "result": body]] + return try #require(ToolingJSONValue.fromFoundation(reply)) + } +} diff --git a/macos/Tests/LitheTests/ProjectRuntimeSettingsTests.swift b/macos/Tests/LitheTests/ProjectRuntimeSettingsTests.swift index 2f03ac928a..ba39508104 100644 --- a/macos/Tests/LitheTests/ProjectRuntimeSettingsTests.swift +++ b/macos/Tests/LitheTests/ProjectRuntimeSettingsTests.swift @@ -139,17 +139,19 @@ struct ProjectRuntimeSettingsTests { } @Test - func settingsCategoryPlacesProjectAndRunBetweenKeymapAndTerminal() { - // Run configurations sit next to the project environment they inherit. - let titles = SettingsCategory.allCases.map(\.rawValue) - let keymap = titles.firstIndex(of: "Keymap") - let project = titles.firstIndex(of: "Project") - let run = titles.firstIndex(of: "Run configurations") - let terminal = titles.firstIndex(of: "Terminal") - #expect(keymap != nil && project != nil && run != nil && terminal != nil) - #expect(project == keymap.map { $0 + 1 }) - #expect(run == project.map { $0 + 1 }) - #expect(terminal == run.map { $0 + 1 }) + func settingsCategoryPlacesBuildPagesBetweenKeymapAndTerminal() { + // The build group (toolchain pages, project environment, run configurations) + // stays contiguous in declaration order, so search results list the pages in + // the same order as the sidebar and run configurations still follow the + // project environment they inherit. + let cases = SettingsCategory.allCases + guard let keymap = cases.firstIndex(of: .keymap), + let terminal = cases.firstIndex(of: .terminal) else { + Issue.record("Keymap and Terminal must both be settings pages") + return + } + #expect(Array(cases[(keymap + 1).. ToolchainSettingsSnapshot { + let provider = "dev.lithe.fixture.provider:fixture" + let file = ToolchainSettingsSnapshot.VersionFile(path: "apps/web/.fixture-version", providerIDs: [provider]) + return ToolchainSettingsSnapshot( + workspaceURL: root, + directories: [.init(id: ".", title: "Workspace"), .init(id: "apps", title: "apps", depth: 1), + .init(id: "apps/web", title: "web", depth: 2, versionFiles: [file])], + tools: [ + .init(id: "compiler", displayName: "Fixture compiler", pluginID: "dev.lithe.fixture.compiler", pluginName: "Compiler", + pin: .init(version: "1", home: "/fixture/compiler")), + .init(id: "compiler", directoryID: "apps/web", displayName: "Fixture compiler", pluginID: "dev.lithe.fixture.compiler", + pluginName: "Compiler", pin: .init(version: "1", home: "/fixture/compiler"), versionFiles: [file]), + .init(id: "renderer", directoryID: "apps/web", displayName: "Fixture renderer", pluginID: "dev.lithe.fixture.renderer", + pluginName: "Renderer", candidate: .init(executable: "/fixture/bin/renderer")) + ], + providers: [.init(id: provider, title: "Fixture provider", pluginID: "dev.lithe.fixture.provider", + pluginName: "Provider", tools: ["compiler"], watchFiles: [".fixture-version"])]) + } +} + +private enum SettingsReadFailure: Error { case unavailable } + +/// Returns finite snapshots; optional reentrancy controls completion order without waiting. +@MainActor +private final class SettingsSnapshotLoader: ToolchainSettingsLoading { + let snapshots: [ToolchainSettingsSnapshot] + var workspaces: [URL?] = [] + var failure: SettingsReadFailure? + var beforeReturn: (@MainActor (Int) async -> Void)? + + init(snapshots: [ToolchainSettingsSnapshot]) { self.snapshots = snapshots } + + func load(workspace: URL?) async throws -> ToolchainSettingsSnapshot { + let index = workspaces.count + workspaces.append(workspace) + if let failure { throw failure } + guard snapshots.indices.contains(index) else { throw SettingsReadFailure.unavailable } + let snapshot = snapshots[index] + await beforeReturn?(index) + return snapshot + } +} + +/// A login shell whose reads finish immediately; `reread` publishes the next state. +private final class LoginShellStub: LoginShellEnvironmentInspecting, @unchecked Sendable { + private let lock = NSLock() + private var snapshot: LoginShellEnvironmentSnapshot + private let next: LoginShellEnvironmentSnapshot + private var count = 0 + + init(current: LoginShellEnvironmentSnapshot, afterReread: LoginShellEnvironmentSnapshot) { + snapshot = current + next = afterReread + } + + var rereads: Int { lock.withLock { count } } + + func inspect() -> LoginShellEnvironmentSnapshot { lock.withLock { snapshot } } + + func reread() async -> LoginShellEnvironmentSnapshot { + lock.withLock { + count += 1 + snapshot = next + return next + } + } +} + +/// Opened once by the loader; a test waits for it with its own deadline. +@MainActor +private final class AsyncGate { + private var isOpen = false + private var waiter: CheckedContinuation? + + func open() { + isOpen = true + waiter?.resume(returning: true) + waiter = nil + } + + func wait(timeout: Duration) async -> Bool { + if isOpen { return true } + let deadline = Task { @MainActor [weak self] in + // test-stability: allow(swift-real-sleep) reason: this only bounds the wait; the loader opens the gate. + try? await Task.sleep(for: timeout) + self?.waiter?.resume(returning: false) + self?.waiter = nil + } + defer { deadline.cancel() } + return await withCheckedContinuation { waiter = $0 } + } +} + +private final class InputWatcherStub: ToolchainInputWatching, @unchecked Sendable { + private final class Observation { + weak var owner: InputWatcherStub? + init(owner: InputWatcherStub) { self.owner = owner } + deinit { owner?.lock.withLock { owner?.live -= 1 } } + } + + private let lock = NSLock() + private var recorded: [[String]] = [] + private var live = 0 + private var latest: (@Sendable () -> Void)? + + var paths: [[String]] { lock.withLock { recorded } } + var liveObservations: Int { lock.withLock { live } } + + func watch(paths: [String], onChange: @escaping @Sendable () -> Void) -> AnyObject? { + lock.withLock { + recorded.append(paths) + live += 1 + latest = onChange + } + return Observation(owner: self) + } + + func fire() { lock.withLock { latest }?() } +} diff --git a/macos/Tests/LitheTests/ToolchainSettingsLayoutTests.swift b/macos/Tests/LitheTests/ToolchainSettingsLayoutTests.swift new file mode 100644 index 0000000000..89f3bac0de --- /dev/null +++ b/macos/Tests/LitheTests/ToolchainSettingsLayoutTests.swift @@ -0,0 +1,363 @@ +import AppKit +import SwiftUI +import Testing +@testable import Lithe + +/// Keeps one native settings hierarchy mounted across shrink, grow and divider drags. +@Suite("Toolchain settings native resize", .serialized) +@MainActor +struct ToolchainSettingsLayoutTests { + @Test + func nestedSettingsPanelsKeepTheirBoundsAndDocumentOrderAcrossRepeatedResize() async throws { + let fixture = try await ToolchainLayoutFixture() + defer { fixture.close() } + let sizes: [NSSize] = [ + .init(width: 900, height: 668), .init(width: 1180, height: 820), + .init(width: 1400, height: 900), .init(width: 900, height: 668), + .init(width: 1180, height: 820) + ] + var firstNarrow: ToolchainLayoutGeometry? + for size in sizes { + fixture.resize(to: size) + let geometry = try await fixture.settledGeometry() + geometry.assertValid(host: fixture.host, size: size) + #expect(fixture.model.selectedDirectoryID == ".") + #expect(fixture.model.expandedToolIDs == ["compiler"]) + if size.width == 900 { + if let previous = firstNarrow { + #expect(abs(previous.viewport.minX - geometry.viewport.minX) <= 1) + #expect(abs(previous.viewport.width - geometry.viewport.width) <= 1) + #expect(abs(previous.firstHeader.minY - geometry.firstHeader.minY) <= 1, + "Returning to the same width must not accumulate a vertical layout offset") + } else { firstNarrow = geometry } + } + } + } + + @Test + func nativeDirectoryDividerDragReflowsCardsWithoutChangingDirectoryOrSelection() async throws { + let fixture = try await ToolchainLayoutFixture() + defer { fixture.close() } + fixture.resize(to: .init(width: 1180, height: 820)) + let before = try await fixture.settledGeometry() + let handles = fixture.nativeViews.compactMap { $0 as? SplitHandleInteractionView } + .filter { $0.axis == .horizontal && !$0.isHiddenOrHasHiddenAncestor } + .sorted { $0.convert($0.bounds, to: fixture.host).midX < $1.convert($1.bounds, to: fixture.host).midX } + let inner = try #require(handles.last, "The environment directory divider must be mounted") + #expect(handles.count >= 2, "The fixture must exercise nested settings and directory splitters") + let origin = inner.convert(NSPoint(x: inner.bounds.midX, y: inner.bounds.midY), to: nil) + for (type, delta) in [(NSEvent.EventType.leftMouseDown, CGFloat(0)), (.leftMouseDragged, CGFloat(60)), (.leftMouseUp, CGFloat(60))] { + let event = try #require(NSEvent.mouseEvent(with: type, + location: NSPoint(x: origin.x + delta, y: origin.y), modifierFlags: [], timestamp: 0, + windowNumber: fixture.window.windowNumber, context: nil, eventNumber: 0, + clickCount: 1, pressure: type == .leftMouseUp ? 0 : 1)) + fixture.window.sendEvent(event) + } + let after = try await fixture.settledGeometry() + #expect(after.viewport.minX > before.viewport.minX + 20, + "Window-dispatched drag must move the actual native pane boundary") + after.assertValid(host: fixture.host, size: .init(width: 1180, height: 820)) + fixture.resize(to: .init(width: 900, height: 668)) + let narrow = try await fixture.settledGeometry() + narrow.assertValid(host: fixture.host, size: .init(width: 900, height: 668)) + #expect(fixture.model.selectedDirectoryID == ".") + #expect(fixture.model.expandedToolIDs == ["compiler"]) + #expect(fixture.model.tools.first?.pin?.version == "24.10.2") + } + + @Test + func sourcesPageKeepsLoginShellAndProviderRowsInsideRepeatedViewportWidths() async throws { + let fixture = try await ToolchainLayoutFixture(sourcesPage: true) + defer { fixture.close() } + let original = fixture.model.snapshot + var firstNarrow: [NSRect]? + for width: CGFloat in [500, 666, 946, 500] { + fixture.resize(to: .init(width: width, height: 1400)) + let frames = try await fixture.settledSourceFrames() + #expect(frames.count >= 10, "The actual source labels and native controls must be mounted") + for frame in frames { + #expect(frame.minX >= -1 && frame.maxX <= width + 1, + "Source controls and tool/provider labels overflow the \(width)pt viewport: \(frame)") + } + #expect(fixture.model.snapshot == original, "Resizing must not modify provider or tool facts") + if width == 500 { + if let previous = firstNarrow { + #expect(previous == frames, "Returning to the same source viewport must not accumulate flow offsets") + } else { firstNarrow = frames } + } + } + } + + @Test + func sourcesPageCountsOnlyVersionFilesTheLoaderFound() async throws { + let fixture = try await ToolchainLayoutFixture(sourcesPage: true) + defer { fixture.close() } + fixture.resize(to: .init(width: 666, height: 1400)) + _ = try await fixture.settledSourceFrames() + let values = fixture.sourceTextValues + #expect(values.contains("1 version files")) + #expect(values.contains("Not used in this project")) + #expect(values.contains("/bin/zsh")) + #expect(values.contains("2 directories")) + } +} + +@MainActor +private final class ToolchainLayoutFixture { + let model: ToolchainSettingsFeatureModel + let host: NSHostingView + let window: NSWindow + private let application = NSApplication.shared + private let accessibilityAttribute = NSAccessibility.Attribute(rawValue: "AXEnhancedUserInterface") + private let previousAccessibility: Any? + + init(sourcesPage: Bool = false) async throws { + var snapshot = Self.snapshot() + if sourcesPage { + snapshot.providers = [ + .init(id: "dev.lithe.fixture.provider:first", title: "Fixture version manager with a long provider title", + pluginID: "dev.lithe.fixture.provider", pluginName: "Provider support", tools: ["compiler", "renderer"], + watchFiles: ["configuration-with-a-long-filename/tools.fixture", ".fixture-versions"]), + .init(id: "dev.lithe.fixture.alternative:second", title: "Another fixture provider with a long descriptive title", + pluginID: "dev.lithe.fixture.alternative", pluginName: "Alternative support", tools: ["renderer", "compiler"], + watchFiles: ["nested-configuration/location/long-tool-versions.fixture"]) + ] + snapshot.directories[0].versionFiles = [.init(path: ".fixture-versions", providerIDs: ["dev.lithe.fixture.provider:first"])] + } + let feature = ToolchainSettingsFeatureModel(loader: ToolchainLayoutLoader(snapshot: snapshot), + loginShell: ToolchainLayoutLoginShell()) + await feature.load(workspace: snapshot.workspaceURL) + feature.toggleExpanded("compiler") + let app = NSApplication.shared + let attribute = NSAccessibility.Attribute(rawValue: "AXEnhancedUserInterface") + let previous = app.accessibilityAttributeValue(attribute) + app.accessibilitySetValue(true, forAttribute: attribute) + let content: AnyView = sourcesPage + ? AnyView(ToolchainSourcesSettingsView(feature: feature, onOpenPlugins: {}) + .environment(\.colorScheme, .light).environment(\.locale, Locale(identifier: "en"))) + : AnyView(ToolchainLayoutRoot(model: feature)) + let hosting = NSHostingView(rootView: content) + hosting.sizingOptions = [] + let nativeWindow = NSWindow(contentRect: .init(x: 0, y: 0, width: 900, height: 668), + styleMask: [.borderless], backing: .buffered, defer: false) + nativeWindow.isReleasedWhenClosed = false + nativeWindow.contentView = hosting + nativeWindow.orderFront(nil) + model = feature + host = hosting + window = nativeWindow + previousAccessibility = previous + } + + var nativeViews: [NSView] { + func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) } + return descendants(host) + } + + func resize(to size: NSSize) { + window.setContentSize(size) + host.frame = NSRect(origin: .zero, size: size) + host.needsLayout = true + } + + /// SwiftUI exposes no AX layout callback, so observe native geometry with a local deadline. + func settledGeometry() async throws -> ToolchainLayoutGeometry { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(2)) + var previous: ToolchainLayoutGeometry? + var stableTurns = 0 + repeat { + host.layoutSubtreeIfNeeded() + if let geometry = geometry() { + stableTurns = geometry == previous ? stableTurns + 1 : 0 + previous = geometry + if stableTurns >= 2 { return geometry } + } + await Task.yield() + } while clock.now < deadline + Issue.record("Toolchain native layout did not publish stable pane/card frames within two seconds") + throw LayoutFailure.missingGeometry + } + + /// Observe all on-screen source labels/buttons, including clipped horizontal overflows. + func settledSourceFrames() async throws -> [NSRect] { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(2)) + var previous: [NSRect] = [] + var stableTurns = 0 + repeat { + host.layoutSubtreeIfNeeded() + let nodes = accessibleElements(host) + let frames = nodes.compactMap { node -> NSRect? in + guard node.accessibilityRole?() == .button || node.accessibilityRole?() == .staticText, + let screen = node.accessibilityFrame?(), !screen.isEmpty else { return nil } + let native = host.convert(window.convertFromScreen(screen), from: nil) + let frame = host.isFlipped ? native : NSRect(x: native.minX, y: host.bounds.height - native.maxY, + width: native.width, height: native.height) + guard frame.maxY > 0 && frame.minY < host.bounds.height else { return nil } + return frame + }.sorted { left, right in + left.minY == right.minY ? left.minX < right.minX : left.minY < right.minY + } + if frames.count >= 10 { + stableTurns = frames == previous ? stableTurns + 1 : 0 + previous = frames + if stableTurns >= 2 { return frames } + } + await Task.yield() + } while clock.now < deadline + Issue.record("Source labels and native buttons did not settle within two seconds") + throw LayoutFailure.missingGeometry + } + + var sourceTextValues: [String] { + accessibleElements(host).flatMap { node -> [String] in + let value: String? = node.accessibilityValue?() + return [node.accessibilityLabel?(), node.accessibilityTitle?(), value].compactMap { $0 } + } + } + + private func geometry() -> ToolchainLayoutGeometry? { + let elements = accessibleElements(host) + func frame(of node: AnyObject?) -> NSRect? { + guard let node, let screen = node.accessibilityFrame?(), !screen.isEmpty else { return nil } + let native = host.convert(window.convertFromScreen(screen), from: nil) + // Normalize native hosts to top-origin coordinates for vertical order assertions. + return host.isFlipped ? native : .init(x: native.minX, y: host.bounds.height - native.maxY, + width: native.width, height: native.height) + } + func frame(_ id: String) -> NSRect? { + frame(of: elements.first(where: { $0.accessibilityIdentifier?() == id })) + } + let toolsHeading = elements.first { element in + guard element.accessibilityRole?() == .staticText else { return false } + let value: String? = element.accessibilityValue?() + return element.accessibilityLabel?() == "Tools" + || element.accessibilityTitle?() == "Tools" + || value == "Tools" + } + guard let directories = frame("toolchain-directory-list"), + let viewport = frame("environment-detail-viewport"), + let heading = frame(of: toolsHeading), + let first = frame("toolchain-tool-header:compiler"), + let resolution = frame("toolchain-resolution:compiler"), + let second = frame("toolchain-tool-header:renderer") else { return nil } + let steps = ["pinned", "project", "global", "automatic"].compactMap { frame("toolchain-step:compiler:\($0)") } + guard steps.count == 4 else { return nil } + return .init(directories: directories, viewport: viewport, heading: heading, + firstHeader: first, resolution: resolution, secondHeader: second, steps: steps) + } + + private func accessibleElements(_ value: Any, depth: Int = 0) -> [AnyObject] { + guard depth < 48 else { return [] } + let object = value as AnyObject + let children = object.accessibilityChildren?() ?? [] + let descendants: [Any] = children.isEmpty ? ((value as? NSView)?.subviews ?? []) : children + return [object] + descendants.flatMap { accessibleElements($0, depth: depth + 1) } + } + + func close() { + model.close() + window.contentView = nil + window.orderOut(nil) + window.close() + application.accessibilitySetValue(previousAccessibility, forAttribute: accessibilityAttribute) + } + + private static func snapshot() -> ToolchainSettingsSnapshot { + let root = URL(fileURLWithPath: "/fixture/very-long-workspace-name/services/rendering-and-compilation") + let installation = "/fixture/installations/a-long-vendor-directory/complete-toolchain-with-a-long-home-name/24.10.2" + let provider = "dev.lithe.fixture.provider:first" + let file = ToolchainSettingsSnapshot.VersionFile(path: "configuration-with-a-long-name/tools/versions.fixture", providerIDs: [provider]) + return .init(workspaceURL: root, directories: [.init(id: ".", title: "Fixture workspace"), + .init(id: "modules", title: "modules", depth: 1), .init(id: "modules/renderer", title: "renderer", depth: 2)], + tools: [ + .init(id: "compiler", displayName: "Fixture compiler", pluginID: "dev.lithe.fixture.compiler", pluginName: "Compiler support", + pin: .init(version: "24.10.2", home: installation), versionFiles: [file], + candidate: .init(executable: "/fixture/system/a-long-bin-directory/compiler"), + detection: .init( + shell: [.init(variable: "FIXTURE_HOME", home: "/fixture/system/a-long-home-directory-name", + executable: "/fixture/system/a-long-home-directory-name/bin/compiler", version: "23.4.1")], + installations: (0..<5).map { index in + .init(home: installation + "-\(index)", executable: installation + "-\(index)/bin/compiler", + version: "24.10.\(5 - index)", provider: "host:system") + }, + skipped: [.init(home: "/fixture/installations/broken-home", reason: "probeFailed")], + selected: installation + "-0")), + .init(id: "renderer", displayName: "Fixture renderer", pluginID: "dev.lithe.fixture.renderer", pluginName: "Renderer support", + candidate: .init(executable: "/fixture/renderers/12.3.0/bin/renderer")) + ], + providers: [.init(id: provider, title: "Fixture provider", pluginID: "dev.lithe.fixture.provider", + pluginName: "Provider support", tools: ["compiler"], watchFiles: ["versions.fixture"])]) + } + + private enum LayoutFailure: Error { case missingGeometry } +} + +private struct ToolchainLayoutLoader: ToolchainSettingsLoading { + let snapshot: ToolchainSettingsSnapshot + func load(workspace: URL?) async throws -> ToolchainSettingsSnapshot { snapshot } +} + +/// A finished login-shell read with a mixed `PATH`; nothing runs a shell. +private struct ToolchainLayoutLoginShell: LoginShellEnvironmentInspecting { + private let snapshot = LoginShellEnvironmentSnapshot( + shell: "/bin/zsh", state: .read, variableCount: 42, durationMilliseconds: 120, + processPath: [.init(path: "/fixture/login-shell/a-long-directory-name/bin", origin: .loginShell), + .init(path: "/fixture/launch/bin", origin: .inherited)]) + + func inspect() -> LoginShellEnvironmentSnapshot { snapshot } + func reread() async -> LoginShellEnvironmentSnapshot { snapshot } +} + +private struct ToolchainLayoutRoot: View { + @ObservedObject var model: ToolchainSettingsFeatureModel + @State private var sidebarWidth: CGFloat = 234 + + var body: some View { + VStack(spacing: 0) { + LitheSplitPaneView(axis: .horizontal, placement: .leading, + defaultSize: sidebarWidth, minimum: 234, maximum: 480, flexibleMinimum: 500, + onCommit: { sidebarWidth = $0 }, + sized: { Color.clear }, + flexible: { ToolchainEnvironmentSettingsView(feature: model, onOpenPlugins: {}) }) + Rectangle().fill(Color.clear).frame(height: 46) + } + .environment(\.colorScheme, .light) + .environment(\.locale, Locale(identifier: "en")) + } +} + +private struct ToolchainLayoutGeometry: Equatable { + let directories: NSRect + let viewport: NSRect + let heading: NSRect + let firstHeader: NSRect + let resolution: NSRect + let secondHeader: NSRect + let steps: [NSRect] + + @MainActor + func assertValid(host: NSView, size: NSSize) { + #expect(directories.width >= 149 && viewport.width >= 339, "directories \(directories), detail \(viewport)") + #expect(directories.maxX <= viewport.minX + 1, "Directory and detail panes must not overlap") + #expect(viewport.maxX <= size.width + 1 && viewport.maxY <= size.height - 45) + #expect(directories.minY >= -1 && viewport.minY >= -1) + #expect(heading.maxY <= firstHeader.minY + 1, "Tools heading must remain above its first row") + #expect(firstHeader.maxY <= resolution.minY + 1, "Expanded resolution must remain below its header") + #expect(resolution.maxY <= secondHeader.minY + 1, "The following tool row must not overlap the expanded resolution") + for frame in [heading, firstHeader, resolution, secondHeader] + steps { + #expect(frame.width > 0 && frame.height > 0 && frame.minX.isFinite && frame.minY.isFinite) + #expect(frame.minX >= viewport.minX - 1 && frame.maxX <= viewport.maxX + 1, + "Cards must reflow inside the native detail viewport rather than overflow when narrowed") + } + for (index, first) in steps.enumerated() { + for (offset, second) in steps.dropFirst(index + 1).enumerated() { + let overlap = first.intersection(second) + #expect(overlap.isNull || overlap.width <= 1 || overlap.height <= 1, + "Adaptive steps \(index)/\(index + offset + 1) overlap: \(first), \(second), viewport: \(viewport)") + } + } + #expect(abs(host.bounds.width - size.width) <= 1 && abs(host.bounds.height - size.height) <= 1) + } +} diff --git a/macos/Tests/LitheTests/ToolchainSettingsLoaderTests.swift b/macos/Tests/LitheTests/ToolchainSettingsLoaderTests.swift new file mode 100644 index 0000000000..1875fe99b1 --- /dev/null +++ b/macos/Tests/LitheTests/ToolchainSettingsLoaderTests.swift @@ -0,0 +1,401 @@ +import Foundation +import Darwin +import Testing +@testable import Lithe +import LitheModuleAPI + +@Suite("Toolchain settings loader") +struct ToolchainSettingsLoaderTests { + private let workspace = URL(fileURLWithPath: "/fixture/toolchain-settings/project", isDirectory: true) + private var localPath: String { workspace.appendingPathComponent(".lithe/toolchains/local.json").path } + + private func source( + pluginID: String = "dev.lithe.fixture.tools", enabled: Bool = true, + executables: [[String: Any]] = [], watchFiles: [String]? = [".fixture-version"] + ) throws -> ToolchainSettingsPluginSource { + var provider: [String: Any] = ["id": "manager", "displayName": "Fixture manager", "tools": ["compiler", "disabled-tool"]] + if let watchFiles { provider["watchFiles"] = watchFiles.map { ["pattern": $0, "reader": "native"] } } + let manifest: [String: Any] = [ + "schemaVersion": 2, "id": pluginID, "displayName": "Fixture support", + "version": "1.0.0", "pluginAPIVersion": 3, + "toolDefinitions": [["id": "compiler", "displayName": "Fixture compiler", "icon": ["host": "plugin"], + "executables": executables]], + "toolchainProviders": [provider] + ] + return .init(pluginID: pluginID, name: "Fixture support", + manifest: try JSONSerialization.data(withJSONObject: manifest), isEnabled: enabled) + } + + private func local(_ pins: [String: Any]) throws -> Data { + try JSONSerialization.data(withJSONObject: ["version": 1, "toolchains": [:], "pins": pins]) + } + + private func pin(_ version: String) -> [String: String] { + ["home": "/fixture/installed/compiler/\(version)", "version": version, "provider": "host:manual"] + } + + private func loader( + files: ToolchainLoaderFiles = ToolchainLoaderFiles(), + sources: @escaping @MainActor @Sendable () -> [ToolchainSettingsPluginSource], + directories: @escaping @MainActor @Sendable (URL?) -> [String] = { _ in [] }, + resolvePin: @escaping @Sendable (Data, String, String) throws -> ToolchainSettingsSavedPin? = MacToolchainSettingsLoader.resolvePinUsingCore, + isRemoteWorkspace: @escaping @Sendable (URL) -> Bool = { _ in false }, + environment: [String: String] = [:], + discover: @escaping @Sendable (MacToolchainDiscovery.Request) async -> MacToolchainDiscovery.Result = { _ in .init() } + ) -> MacToolchainSettingsLoader { + MacToolchainSettingsLoader(sources: sources, directories: directories, resolvePin: resolvePin, + isRemoteWorkspace: isRemoteWorkspace, environment: { environment }, + fileExists: { files.exists($0) }, isExecutable: { files.executable($0) }, + readLocalData: { try files.read($0) }, discover: discover) + } + + @Test func missingLocalFileListsDeclarationsWithoutClaimingAnyValue() async throws { + let source = try source() + let files = ToolchainLoaderFiles() + let snapshot = try await loader(files: files, sources: { [source] }).load(workspace: workspace) + #expect(snapshot.workspaceURL == workspace) + #expect(snapshot.directories.map(\.id) == ["."]) + #expect(snapshot.directories.first?.title == "project") + let tool = try #require(snapshot.tools.first) + #expect(tool.displayName == "Fixture compiler") + #expect(tool.pluginID == source.pluginID && tool.pluginName == "Fixture support") + #expect(tool.iconName == "plugin") + #expect(tool.pin == nil && tool.versionFiles.isEmpty && tool.candidate == nil) + #expect(snapshot.providers.first?.id == "dev.lithe.fixture.tools:manager") + #expect(snapshot.providers.first?.tools == ["compiler"]) + #expect(snapshot.providers.first?.watchFiles == [".fixture-version"]) + #expect(snapshot.diagnostics.isEmpty) + #expect(files.readPaths == [localPath]) + } + + @Test func versionFilesListTheirDirectoriesAndRowsInheritTheNearestFirst() async throws { + let source = try source(watchFiles: [".fixture-version", "*.fixture"]) + let files = ToolchainLoaderFiles(existing: [ + workspace.appendingPathComponent(".fixture-version").path, + workspace.appendingPathComponent("apps/web/.fixture-version").path + ]) + let snapshot = try await loader(files: files, sources: { [source] }, + directories: { _ in ["apps", "apps/web", "apps/web/src", "docs"] }).load(workspace: workspace) + // Directories without their own facts are listed only to connect a deeper one. + #expect(snapshot.directories.map(\.id) == [".", "apps", "apps/web"]) + #expect(snapshot.directories.map(\.versionFiles.count) == [1, 0, 1]) + let provider = "dev.lithe.fixture.tools:manager" + let web = try #require(snapshot.tools.first { $0.directoryID == "apps/web" }) + #expect(web.versionFiles == [.init(path: "apps/web/.fixture-version", providerIDs: [provider]), + .init(path: ".fixture-version", providerIDs: [provider])]) + let apps = try #require(snapshot.tools.first { $0.directoryID == "apps" }) + #expect(apps.versionFiles.map(\.path) == [".fixture-version"]) + // Glob patterns stay with the provider; the loader never expands them. + #expect(!files.checkedPaths.contains { $0.contains("*") }) + } + + @Test func coreResolvesSavedPinsAndTheirSourceDirectories() async throws { + let source = try source() + let files = ToolchainLoaderFiles(contents: [localPath: try local([ + ".": ["compiler": pin("1")], "apps/web": ["compiler": pin("2")] + ])]) + let snapshot = try await loader(files: files, sources: { [source] }, + directories: { _ in ["apps/web/src", "apps/website"] }).load(workspace: workspace) + #expect(snapshot.directories.map(\.id) == [".", "apps", "apps/web"]) + let pins = snapshot.tools.map { [$0.directoryID, $0.pin?.version ?? "", $0.pin?.directory ?? ""] } + #expect(pins == [[".", "1", "."], ["apps", "1", "."], ["apps/web", "2", "apps/web"]]) + #expect(snapshot.tools.last?.pin?.home == "/fixture/installed/compiler/2") + } + + @Test func pathCandidateFollowsDeclarationOrderAndSkipsOtherPlatforms() async throws { + let source = try source(executables: [ + ["kind": "command", "command": "linuxc", "platforms": ["linux"]], + ["kind": "environment", "variable": "FIXTURE_HOME", "path": "bin/fixturec"], + ["kind": "command", "command": "fixturec"], + ["kind": "command", "command": "otherc"] + ]) + let environment = ["FIXTURE_HOME": "/fixture/home/", "PATH": "relative/bin:/fixture/first:/fixture/second"] + let onPath = ToolchainLoaderFiles(executables: ["/fixture/second/fixturec", "relative/bin/fixturec", + "/fixture/first/linuxc", "/fixture/home/bin/fixturec"]) + let found = try await loader(files: onPath, sources: { [source] }, environment: environment).load(workspace: workspace) + // Home variables are checked by Core through discovery, never by this existence test. + #expect(found.tools.first?.candidate == .init(executable: "/fixture/second/fixturec")) + // An earlier command wins even when a later one is earlier on PATH. + let both = ToolchainLoaderFiles(executables: ["/fixture/second/fixturec", "/fixture/first/otherc"]) + let preferred = try await loader(files: both, sources: { [source] }, environment: environment).load(workspace: workspace) + #expect(preferred.tools.first?.candidate == .init(executable: "/fixture/second/fixturec")) + let missing = try await loader(sources: { [source] }, environment: environment).load(workspace: workspace) + #expect(missing.tools.first?.candidate == nil) + } + + @Test func discoveryReceivesEnabledSourcesAndEveryDirectoryRowSharesItsDetection() async throws { + let enabled = try source() + let disabled = try source(pluginID: "dev.lithe.fixture.disabled", enabled: false) + let requests = ToolchainDiscoveryRequests() + let detection = ToolchainSettingsSnapshot.Detection( + shell: [.init(variable: "FIXTURE_HOME", home: "/fixture/home", executable: "/fixture/home/bin/fixturec", + version: "3.1")], + installations: [.init(home: "/fixture/installed/4.0", executable: "/fixture/installed/4.0/bin/fixturec", + version: "4.0", provider: "host:system")], + skipped: [.init(home: "/fixture/installed/broken", reason: "probeFailed")], + selected: "/fixture/installed/4.0") + let files = ToolchainLoaderFiles(existing: [workspace.appendingPathComponent("apps/.fixture-version").path]) + let snapshot = try await loader(files: files, sources: { [disabled, enabled] }, directories: { _ in ["apps"] }, + environment: ["FIXTURE_HOME": "/fixture/home"], + discover: { request in + requests.record(request) + return .init(detections: ["compiler": detection], diagnostics: ["Fixture discovery note"]) + }).load(workspace: workspace) + let request = try #require(requests.values.first) + #expect(requests.values.count == 1) + #expect(request.workspace == workspace) + #expect(request.sources.map(\.pluginID) == [enabled.pluginID]) + #expect(request.environment == ["FIXTURE_HOME": "/fixture/home"]) + #expect(snapshot.tools.map(\.directoryID) == [".", "apps"]) + #expect(snapshot.tools.allSatisfy { $0.detection == detection }) + #expect(snapshot.diagnostics == ["Fixture discovery note"]) + } + + @Test func toolsCoreCouldNotCheckAreNamedWithoutADetection() async throws { + let source = try source() + let snapshot = try await loader(sources: { [source] }, + discover: { _ in .init(unchecked: ["compiler"]) }).load(workspace: workspace) + #expect(snapshot.tools.first?.detection == nil) + #expect(snapshot.diagnostics == [L10n.string("Installations of \("Fixture compiler") could not be checked.")]) + } + + /// A row shows what Core resolved for the directory that decides it: a saved pin's own + /// directory, otherwise the nearest one with a version file of its own. + @Test func rowsShowCoreResolutionOfTheirPinOrNearestQueriedDirectory() async throws { + let source = try source() + let document = try local(["apps/web": ["compiler": pin("2")]]) + let files = ToolchainLoaderFiles(contents: [localPath: document], + existing: [workspace.appendingPathComponent("apps/.fixture-version").path]) + typealias Resolution = ToolchainSettingsSnapshot.Resolution + let root = Resolution(layer: .automatic, outcome: .resolved(home: "/fixture/installed/1", executable: + "/fixture/installed/1/bin/fixturec", version: "1", provider: "host:system")) + let apps = Resolution(layer: .project, outcome: .skipped(reason: "requestedNotInstalled"), unmet: ["3"]) + let web = Resolution(layer: .pinned, outcome: .failed(reason: "probeFailed")) + let requests = ToolchainDiscoveryRequests() + let snapshot = try await loader(files: files, sources: { [source] }, directories: { _ in ["apps", "apps/web"] }, + discover: { request in + requests.record(request) + return .init(resolutions: [".": ["compiler": root], "apps": ["compiler": apps], + "apps/web": ["compiler": web]], + unresolved: ["compiler"]) + }).load(workspace: workspace) + let request = try #require(requests.values.first) + #expect(request.directories == [".", "apps"]) + #expect(request.local == document) + #expect(snapshot.tools.map(\.directoryID) == [".", "apps", "apps/web"]) + #expect(snapshot.tools.map(\.resolution) == [root, apps, web]) + #expect(snapshot.diagnostics == [L10n.string("Which \("Fixture compiler") to use could not be determined.")]) + } + + @Test func discoveryDoesNotRunWithoutAWorkspaceOrDefinitions() async throws { + let requests = ToolchainDiscoveryRequests() + let discover: @Sendable (MacToolchainDiscovery.Request) async -> MacToolchainDiscovery.Result = { request in + requests.record(request) + return .init() + } + let source = try source() + _ = try await loader(sources: { [source] }, discover: discover).load(workspace: nil) + let manifest = try JSONSerialization.data(withJSONObject: ["id": "dev.lithe.fixture.empty"]) + let empty = ToolchainSettingsPluginSource(pluginID: "dev.lithe.fixture.empty", name: "Empty", manifest: manifest, + isEnabled: true) + _ = try await loader(sources: { [empty] }, discover: discover).load(workspace: workspace) + #expect(requests.values.isEmpty) + } + + @Test func truncatedListsSayHowManyDirectoriesWereCovered() async throws { + let definitions = (0..<32).map { ["id": "tool-\($0)", "displayName": "Tool \($0)"] } + let manifest: [String: Any] = ["id": "dev.lithe.fixture.many", "toolDefinitions": definitions, + "toolchainProviders": [["id": "manager", "displayName": "Manager", "tools": ["tool-0"], + "watchFiles": (0..<32).map { ["pattern": ".file-\($0)"] }]]] + let source = ToolchainSettingsPluginSource(pluginID: "dev.lithe.fixture.many", name: "Many", + manifest: try JSONSerialization.data(withJSONObject: manifest), isEnabled: true) + let directories = (0..<600).map { String(format: "dir-%03d", $0) } + let files = ToolchainLoaderFiles(existing: Set(directories.map { workspace.appendingPathComponent($0 + "/.file-0").path })) + let snapshot = try await loader(files: files, sources: { [source] }, directories: { _ in directories }) + .load(workspace: workspace) + // 16,384 checks over 32 patterns reach 512 directories; 4,096 rows over 32 tools list 128. + #expect(files.checkedPaths.count == 16_384) + #expect(snapshot.directories.count == 128) + #expect(snapshot.tools.count == 128 * 32) + #expect(snapshot.diagnostics.count == 2) + } + + @Test func disabledDefinitionsDisappearAndTheirPinsReturnWithThem() async throws { + let files = ToolchainLoaderFiles(contents: [localPath: try local([".": ["compiler": pin("1")]])]) + let (disabled, enabled) = (try source(enabled: false), try source()) + let hidden = try await loader(files: files, sources: { [disabled] }).load(workspace: workspace) + #expect(hidden.tools.isEmpty && hidden.providers.isEmpty) + let restored = try await loader(files: files, sources: { [enabled] }).load(workspace: workspace) + #expect(restored.tools.first?.pin?.version == "1") + } + + @Test func malformedDisabledPluginIsNotParsed() async throws { + let disabled = ToolchainSettingsPluginSource(pluginID: "disabled", name: "Disabled", manifest: Data("invalid".utf8), isEnabled: false) + let snapshot = try await loader(sources: { [disabled] }).load(workspace: nil) + #expect(snapshot.tools.isEmpty && snapshot.providers.isEmpty) + } + + @Test func sharedCoreRejectionOfMalformedDocumentsIsNotTreatedAsMissing() async throws { + for data in [Data("invalid".utf8), try local(["../parent": ["compiler": pin("1")]]), + try local(["src//nested": ["compiler": pin("1")]])] { + let loader = loader(files: ToolchainLoaderFiles(contents: [localPath: data]), sources: { [] }) + await #expect(throws: ToolchainSettingsLoadError.invalidLocal) { + try await loader.load(workspace: workspace) + } + } + } + + @Test func unreadableExistingLocalFileIsNotTreatedAsMissing() async throws { + let files = ToolchainLoaderFiles() + files.failReads = true + await #expect(throws: ToolchainSettingsLoadError.readFailed) { + try await loader(files: files, sources: { [] }).load(workspace: workspace) + } + } + + @Test func duplicateToolOwnersAndMalformedEnabledDeclarationsAreRefused() async throws { + let (first, second) = (try source(), try source(pluginID: "dev.lithe.fixture.other")) + let malformed = ToolchainSettingsPluginSource(pluginID: "invalid", name: "Invalid", manifest: Data("invalid".utf8), isEnabled: true) + for sources in [[first, second], [first, first], [malformed]] { + await #expect(throws: ToolchainSettingsLoadError.invalidDeclarations) { + try await loader(sources: { sources }).load(workspace: nil) + } + } + } + + @Test func installationOnlyProvidersAreListedWithoutCheckingVersionFiles() async throws { + let source = try source(watchFiles: nil) + let files = ToolchainLoaderFiles() + let snapshot = try await loader(files: files, sources: { [source] }, + directories: { _ in ["apps"] }).load(workspace: workspace) + #expect(snapshot.providers.map(\.id) == ["dev.lithe.fixture.tools:manager"]) + #expect(snapshot.providers.first?.watchFiles == []) + #expect(snapshot.tools.allSatisfy { $0.versionFiles.isEmpty }) + #expect(files.checkedPaths.isEmpty) + // Omitting `watchFiles` is allowed; declaring something other than a list is not. + var manifest = try #require(try JSONSerialization.jsonObject(with: source.manifest) as? [String: Any]) + manifest["toolchainProviders"] = [["id": "manager", "displayName": "Fixture manager", + "tools": ["compiler"], "watchFiles": ".fixture-version"]] + let malformed = ToolchainSettingsPluginSource(pluginID: source.pluginID, name: source.name, + manifest: try JSONSerialization.data(withJSONObject: manifest), isEnabled: true) + await #expect(throws: ToolchainSettingsLoadError.invalidDeclarations) { + try await loader(sources: { [malformed] }).load(workspace: nil) + } + } + + @Test func nilWorkspaceListsProvidersWithoutReadingProjectFiles() async throws { + let source = try source() + let files = ToolchainLoaderFiles() + let snapshot = try await loader(files: files, sources: { [source] }).load(workspace: nil) + #expect(snapshot.workspaceURL == nil) + #expect(snapshot.directories.isEmpty && snapshot.tools.isEmpty) + #expect(snapshot.providers.map(\.title) == ["Fixture manager"]) + #expect(files.readPaths.isEmpty && files.checkedPaths.isEmpty) + } + + @Test func remoteWorkspaceNeverReadsPlaceholderOrEnumeratesLocalSources() async throws { + let files = ToolchainLoaderFiles() + files.failReads = true + let loader = loader(files: files, sources: { + Issue.record("A remote workspace must not enumerate local plugin sources.") + return [] + }, directories: { _ in + Issue.record("A remote workspace must not enumerate local directories.") + return [] + }, isRemoteWorkspace: { _ in true }) + let snapshot = try await loader.load(workspace: workspace) + #expect(snapshot.workspaceURL == workspace) + #expect(snapshot.tools.isEmpty && snapshot.providers.isEmpty && snapshot.directories.isEmpty) + #expect(snapshot.diagnostics.count == 1) + #expect(files.readPaths.isEmpty && files.checkedPaths.isEmpty) + } + + @Test func directoryProjectionKeepsEveryBranchTogetherBeforeItsSibling() async throws { + let directories = ["a/b/nested", "a-b", ".config/tools", "a/z"] + let files = ToolchainLoaderFiles(existing: Set(directories.map { + workspace.appendingPathComponent($0 + "/.fixture-version").path + })) + let source = try source() + let snapshot = try await loader(files: files, sources: { [source] }, directories: { _ in directories }) + .load(workspace: workspace) + #expect(snapshot.directories.map(\.id) == [".", ".config", ".config/tools", "a", "a/b", "a/b/nested", "a/z", "a-b"]) + #expect(snapshot.directories.map(\.depth) == [0, 1, 2, 1, 2, 3, 2, 1]) + } + + @Test func nativeReaderRefusesFifoAndSymlinkWithoutWaitingForWriter() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let fifo = root.appendingPathComponent("local.json") + let result = fifo.withUnsafeFileSystemRepresentation { path in + path.map { Darwin.mkfifo($0, 0o600) } ?? -1 + } + #expect(result == 0) + #expect(throws: ToolchainSettingsLoadError.readFailed) { + try MacToolchainSettingsLoader.safeNativeRead(fifo) + } + let link = root.appendingPathComponent("link.json") + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: fifo) + #expect(throws: ToolchainSettingsLoadError.readFailed) { + try MacToolchainSettingsLoader.safeNativeRead(link) + } + let regular = root.appendingPathComponent("regular.json") + let bytes = Data("{}".utf8) + try bytes.write(to: regular) + #expect(try MacToolchainSettingsLoader.safeNativeRead(regular) == bytes) + #expect(try MacToolchainSettingsLoader.safeNativeRead(root.appendingPathComponent("missing")) == nil) + #expect(!MacToolchainSettingsLoader.regularFileExists(link)) + #expect(MacToolchainSettingsLoader.regularFileExists(regular)) + } +} + +/// In-memory files: tests never discover developer tools or touch a real project. +private final class ToolchainLoaderFiles: @unchecked Sendable { + private let lock = NSLock() + private let contents: [String: Data] + private let existing: Set + private let executables: Set + private var reads: [String] = [] + private var checks: [String] = [] + private var denyReads = false + + init(contents: [String: Data] = [:], existing: Set = [], executables: Set = []) { + self.contents = contents + self.existing = existing + self.executables = executables + } + + var readPaths: [String] { lock.withLock { reads } } + var checkedPaths: [String] { lock.withLock { checks } } + var failReads: Bool { + get { lock.withLock { denyReads } } + set { lock.withLock { denyReads = newValue } } + } + + func read(_ url: URL) throws -> Data? { + try lock.withLock { + reads.append(url.path) + if denyReads { throw CocoaError(.fileReadNoPermission) } + return contents[url.path] + } + } + + func exists(_ url: URL) -> Bool { + lock.withLock { checks.append(url.path) } + return existing.contains(url.path) + } + + func executable(_ path: String) -> Bool { executables.contains(path) } +} + +/// Records discovery requests without scanning or running anything. +private final class ToolchainDiscoveryRequests: @unchecked Sendable { + private let lock = NSLock() + private var recorded: [MacToolchainDiscovery.Request] = [] + + var values: [MacToolchainDiscovery.Request] { lock.withLock { recorded } } + + func record(_ request: MacToolchainDiscovery.Request) { + lock.withLock { recorded.append(request) } + } +} diff --git a/rust/lithe-core/src/lib.rs b/rust/lithe-core/src/lib.rs index 3da952089f..ac816bb647 100644 --- a/rust/lithe-core/src/lib.rs +++ b/rust/lithe-core/src/lib.rs @@ -23,6 +23,8 @@ mod project_generators; mod protocol; mod remote; mod runtime; +/// Shared toolchain policy, independent of plugin transport and native discovery. +pub mod toolchain; mod vcs; pub use protocol::{ diff --git a/rust/lithe-core/src/lsp/interface/client.rs b/rust/lithe-core/src/lsp/interface/client.rs index 0f731fa9bc..369a4243d6 100644 --- a/rust/lithe-core/src/lsp/interface/client.rs +++ b/rust/lithe-core/src/lsp/interface/client.rs @@ -130,7 +130,7 @@ pub(super) fn client_initialize_for_plugin( "window": { "workDoneProgress": true }, - "experimental": experimental_capabilities(plugin_owned) + "experimental": experimental_capabilities(plugin_owned, &state.offered_toolchain_providers) }, "initializationOptions": request.initialization_options }), @@ -663,6 +663,27 @@ pub(super) fn client_apply_server_message_for_plugin( .server_capabilities .retain(|feature| feature != super::vcs_provider::VCS_PROVIDER_FEATURE), } + state.toolchain_providers = plugin_owned + .then(|| { + super::toolchain_provider::negotiate( + result.get("capabilities").unwrap_or(&Value::Null), + &state.offered_toolchain_providers, + ) + }) + .flatten() + .unwrap_or_default(); + if !state.toolchain_providers.is_empty() { + events.push(LspClientEvent { + kind: super::toolchain_provider::NEGOTIATED_EVENT.to_string(), + request_id: None, + method: Some("initialize".to_string()), + uri: None, + version: None, + diagnostics: None, + result: Some(json!({ "providers": state.toolchain_providers })), + error: None, + }); + } state.text_document_sync = text_document_sync_kind(result.get("capabilities").unwrap_or(&Value::Null)); state.text_document_save_include_text = match result @@ -804,13 +825,21 @@ pub(super) fn json_rpc_result(id: &Value, result: Value) -> Result Value { +fn experimental_capabilities( + plugin_owned: bool, + toolchain_providers: &std::collections::BTreeSet, +) -> Value { let mut experimental = super::build_provider::client_experimental_capabilities(); if plugin_owned { let (functions_key, functions_offer) = plugin_function::client_experimental_capabilities(); experimental[functions_key] = functions_offer; let (vcs_key, vcs_offer) = super::vcs_provider::client_experimental_capabilities(); experimental[vcs_key] = vcs_offer; + if let Some((toolchain_key, toolchain_offer)) = + super::toolchain_provider::client_experimental_capabilities(toolchain_providers) + { + experimental[toolchain_key] = toolchain_offer; + } } experimental } diff --git a/rust/lithe-core/src/lsp/interface/engine.rs b/rust/lithe-core/src/lsp/interface/engine.rs index a9165c5a51..dedd2b199c 100644 --- a/rust/lithe-core/src/lsp/interface/engine.rs +++ b/rust/lithe-core/src/lsp/interface/engine.rs @@ -68,6 +68,8 @@ const MAX_BUILD_PROVIDER_TIMEOUT_MS: u64 = 60 * 60_000; /// working copy over a slow link can take long, but never stays pending /// without a deadline. const MAX_VCS_PROVIDER_TIMEOUT_MS: u64 = 60 * 60_000; +/// Longest toolchain query a host may ask for; queries only read manager state. +const MAX_TOOLCHAIN_QUERY_TIMEOUT_MS: u64 = 60_000; const MONITOR_INTERVAL_MS: u64 = 10; /// JSON-RPC method carrying Java Debug Server build commands. const JAVA_BUILD_METHOD: &str = "workspace/executeCommand"; @@ -110,6 +112,10 @@ pub struct StartServerRequest { /// requests to the host. #[serde(default)] pub host_services: Vec, + /// Provider IDs of the plugin's verified manifest. Only a plugin-owned + /// session with some is offered `litheToolchains`. + #[serde(default)] + pub toolchain_providers: Vec, pub executable_path: String, #[serde(default)] pub arguments: Vec, @@ -435,6 +441,23 @@ pub struct BuildProviderRequest { pub timeout_milliseconds: Option, } +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +/// One `toolchains/query`, addressed to a session that negotiated the provider +/// named in `params`. The result arrives as a `requestCompleted` event whose +/// `result.value` is `{ id, response }`, the complete untrusted JSON-RPC response, +/// which the host passes to `toolchains.decodeReply`. +pub struct ToolchainProviderRequest { + pub session_id: String, + #[serde(default)] + pub operation_id: Option, + /// Query parameters; `toolchains.decodeReply` validates them with the reply. + pub params: Value, + /// Deadline of this request, capped at `MAX_TOOLCHAIN_QUERY_TIMEOUT_MS`. + #[serde(default)] + pub timeout_milliseconds: Option, +} + #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] /// One VCS-provider request. Core plans the method and params from the @@ -1398,6 +1421,20 @@ pub fn build_provider_request( Ok(OperationResponse { operation_id }) } +/// Sends one `toolchains/query` to a session that negotiated its provider. +pub fn toolchain_provider_request( + request: ToolchainProviderRequest, +) -> Result { + let operation_id = request + .operation_id + .clone() + .unwrap_or_else(|| engine().next_operation_id()); + engine() + .session(&request.session_id)? + .toolchain_request(request, operation_id.clone())?; + Ok(OperationResponse { operation_id }) +} + /// Sends one VCS-provider request to a session that negotiated the protocol. pub fn vcs_provider_request(request: VcsProviderRequest) -> Result { let operation_id = request @@ -1728,7 +1765,14 @@ impl LspEngine { checked_workspace_configuration(request.workspace_configuration)?; let initialize = client_initialize_for_plugin( ClientInitializeRequest { - state: LspClientState::default(), + state: LspClientState { + offered_toolchain_providers: if request.plugin_id.is_some() { + request.toolchain_providers.iter().cloned().collect() + } else { + Default::default() + }, + ..LspClientState::default() + }, root_uri: request.root_uri.clone(), process_id: (!crate::remote::workspace::is_remote()) .then_some(std::process::id() as i64), @@ -2440,6 +2484,75 @@ impl RuntimeSession { /// Writes a VCS-provider request planned by `crate::vcs::plan`, which /// checks the operation's arguments and negotiated capability. + fn toolchain_request( + &self, + request: ToolchainProviderRequest, + operation_id: String, + ) -> Result<(), CoreError> { + let outbound_order = self.lock_outbound_order()?; + let (messages, request_id) = { + let mut state = self.lock_state()?; + if state.lifecycle != LspLifecycleState::Ready || !state.client.initialized { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Language server is not ready.", + )); + } + if state.request_by_operation.contains_key(&operation_id) + || state.java_builds.contains(&operation_id) + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "The language-server operation ID is already pending.", + )); + } + let provider = request.params["providerID"].as_str().unwrap_or_default(); + if !state + .client + .toolchain_providers + .iter() + .any(|negotiated| negotiated == provider) + { + return Err(CoreError::new( + ErrorCode::NotSupported, + "The process did not negotiate this toolchain provider.", + ) + .with_details("plugin.toolchain.providerUnavailable")); + } + let timeout = request + .timeout_milliseconds + .map(|milliseconds| { + Duration::from_millis(milliseconds.clamp(1, MAX_TOOLCHAIN_QUERY_TIMEOUT_MS)) + }) + .unwrap_or(state.request_timeout); + let response = allocate_raw_request( + state.client.clone(), + super::toolchain_provider::QUERY_METHOD, + request.params, + )?; + let request_id = (response.state.next_request_id - 1).to_string(); + let now = Instant::now(); + state.client = response.state; + state.pending.insert( + request_id.clone(), + PendingRequest { + kind: PendingKind::Feature, + operation_id: Some(operation_id.clone()), + method: super::toolchain_provider::QUERY_METHOD.to_string(), + document_uri: None, + document_version: None, + created_at: now, + deadline: now + timeout, + }, + ); + state + .request_by_operation + .insert(operation_id, request_id.clone()); + (response.messages, request_id) + }; + self.send_request_or_fail(&outbound_order, messages, &request_id) + } + fn vcs_request( &self, request: VcsProviderRequest, @@ -3588,6 +3701,25 @@ impl RuntimeSession { } } } + Some(PendingKind::Feature) + if pending_before.as_ref().is_some_and(|pending| { + pending.method == super::toolchain_provider::QUERY_METHOD + }) => + { + if let Some(pending) = pending_before.as_ref() { + if let Some(operation_id) = &pending.operation_id { + let result = super::toolchain_provider::reply_outcome(&value); + push_request_event( + self, + &mut state, + operation_id, + &pending.method, + Some(result), + None, + ); + } + } + } Some(PendingKind::Feature) => { if let Some(pending) = pending_before.as_ref() { if let Some(operation_id) = &pending.operation_id { @@ -4221,6 +4353,12 @@ impl RuntimeSession { )?); } else if event.kind == super::vcs_provider::NEGOTIATED_EVENT { push_vcs_provider_event(self, &mut state, event.result.unwrap_or_default()); + } else if event.kind == super::toolchain_provider::NEGOTIATED_EVENT { + push_toolchain_provider_event( + self, + &mut state, + event.result.unwrap_or_default(), + ); } else if event.kind == "diagnostics" { if let Some(uri) = event.uri.as_deref() { push_diagnostics_event( @@ -6168,6 +6306,40 @@ fn push_vcs_provider_event(session: &RuntimeSession, state: &mut SessionState, r ); } +/// Publishes the providers a backend selected, so the host queries only those. +fn push_toolchain_provider_event( + session: &RuntimeSession, + state: &mut SessionState, + result: Value, +) { + let sequence = take_sequence(state); + enqueue_runtime_event( + session, + state, + LspRuntimeEvent { + kind: "toolchainProviders".to_string(), + sequence, + provider_id: session.provider_id.clone(), + session_id: session.id.clone(), + state: None, + operation_id: None, + method: None, + uri: None, + version: None, + diagnostics: None, + result: Some(result), + error: None, + capabilities: None, + server_info: None, + level: None, + message: None, + detail: None, + maven_profile_project: None, + maven_profile_task: None, + }, + ); +} + /// Handles a backend's `$/cancelRequest` for one of its own pending host /// requests: the backend gets `plugin.host.cancelled` at once, so the /// pending slot frees without waiting for the host, and the host gets a @@ -6882,6 +7054,7 @@ fn clear_runtime_state(session: &RuntimeSession, state: &mut SessionState) { state.function_by_operation.clear(); clear_function_registry(session, state); state.client.vcs_provider = None; + state.client.toolchain_providers.clear(); state.linked_document_paths = LinkedDocumentPaths::default(); state.java_navigation_marker_batches.clear(); state.java_navigation_marker_cache.clear(); @@ -7009,6 +7182,7 @@ mod tests { provider_id: "gopls".to_string(), plugin_id: None, host_services: Vec::new(), + toolchain_providers: Vec::new(), executable_path: "/usr/bin/scripted-server".to_string(), arguments: vec!["--stdio".to_string()], environment: BTreeMap::new(), @@ -10936,6 +11110,7 @@ public class Main { provider_id: "java".to_string(), plugin_id: None, host_services: Vec::new(), + toolchain_providers: Vec::new(), executable_path, arguments: Vec::new(), environment: BTreeMap::from([( @@ -11678,6 +11853,7 @@ public class Main { provider_id: String::new(), plugin_id: None, host_services: Vec::new(), + toolchain_providers: Vec::new(), executable_path: "/bin/server".to_string(), arguments: Vec::new(), environment: BTreeMap::new(), @@ -12632,6 +12808,126 @@ public class Main { assert!(harness.server.await_notification("$/cancelRequest")); } + // Toolchain providers against a scripted backend. Only a plugin-owned + // session launched with declared providers is offered `litheToolchains`, + // and only a provider its process selected receives `toolchains/query`. + + fn toolchain_backend(answer: Value) -> Harness { + let mut harness = Harness::start(|request| { + request.plugin_id = Some("dev.lithe.fixture.toolchain-provider".to_string()); + request.toolchain_providers = vec!["mise".to_string()]; + }); + harness + .server + .complete_initialize(json!({ "experimental": { "litheToolchains": answer } })); + harness.await_state(LspLifecycleState::Ready); + harness + } + + fn toolchain_query(harness: &Harness, provider: &str) -> Result { + let operation_id = harness.engine.next_operation_id(); + harness + .session() + .toolchain_request( + ToolchainProviderRequest { + session_id: harness.session_id.clone(), + operation_id: Some(operation_id.clone()), + params: json!({ "operationID": "query-1", "generation": 1, "providerID": provider }), + timeout_milliseconds: None, + }, + operation_id.clone(), + ) + .map(|()| operation_id) + } + + fn no_toolchain_query_written(harness: &Harness) -> bool { + harness + .server + .messages() + .iter() + .all(|message| message["method"] != "toolchains/query") + } + + #[test] + fn host_sessions_are_not_offered_the_toolchain_protocol() { + let harness = Harness::start(|request| { + request.toolchain_providers = vec!["mise".to_string()]; + }); + harness.server.complete_initialize(json!({})); + let initialize = harness + .server + .messages() + .into_iter() + .find(|message| message["method"] == "initialize") + .expect("initialize should have been written"); + assert!(initialize["params"]["capabilities"]["experimental"] + .get("litheToolchains") + .is_none()); + } + + #[test] + fn a_query_reaches_only_a_selected_provider_and_returns_the_raw_response() { + let mut harness = toolchain_backend(json!({ "version": 1, "providers": ["mise"] })); + let initialize = harness + .server + .messages() + .into_iter() + .find(|message| message["method"] == "initialize") + .expect("initialize should have been written"); + assert_eq!( + initialize["params"]["capabilities"]["experimental"]["litheToolchains"], + json!({ "versions": [1], "providers": ["mise"] }) + ); + let event = harness + .await_event(|event| event.kind == "toolchainProviders") + .clone(); + assert_eq!(event.result, Some(json!({ "providers": ["mise"] }))); + + let refused = toolchain_query(&harness, "asdf").expect_err("not selected"); + assert_eq!( + refused.details.as_deref(), + Some("plugin.toolchain.providerUnavailable") + ); + assert!(no_toolchain_query_written(&harness)); + + let operation = toolchain_query(&harness, "mise").expect("written"); + let id = harness + .server + .await_request("toolchains/query") + .expect("the query is written"); + let wire_id: u64 = id.parse().expect("a numeric wire ID"); + // Even a provider error stays a raw reply: decodeReply owns its mapping. + let response = json!({ + "jsonrpc": "2.0", "id": wire_id, + "error": { "code": -32073, "message": "untrusted", "data": { "code": "x" } } + }); + harness.server.send(response.clone()); + let event = harness + .await_event(|event| { + event.kind == "requestCompleted" + && event.operation_id.as_deref() == Some(operation.as_str()) + }) + .clone(); + assert!(event.error.is_none()); + assert_eq!( + event.result, + Some(json!({ "value": { "id": wire_id, "response": response } })) + ); + } + + #[test] + fn an_undeclared_toolchain_provider_enables_nothing() { + let mut harness = toolchain_backend(json!({ "version": 1, "providers": ["mise", "asdf"] })); + harness.poll(); + assert!(harness + .events + .iter() + .all(|event| event.kind != "toolchainProviders")); + let error = toolchain_query(&harness, "mise").expect_err("refused"); + assert!(matches!(error.code, ErrorCode::NotSupported)); + assert!(no_toolchain_query_written(&harness)); + } + // VCS providers against a scripted backend. Only a plugin-owned session // is offered `litheVcs`, and only one whose process answered with a VCS // ID and every required capability receives requests, each planned by diff --git a/rust/lithe-core/src/lsp/interface/engine_real_jdt_tests.rs b/rust/lithe-core/src/lsp/interface/engine_real_jdt_tests.rs index 30b26e08f2..a0f9ea8fda 100644 --- a/rust/lithe-core/src/lsp/interface/engine_real_jdt_tests.rs +++ b/rust/lithe-core/src/lsp/interface/engine_real_jdt_tests.rs @@ -340,6 +340,7 @@ fn real_start_request( provider_id: "java".to_string(), plugin_id: None, host_services: Vec::new(), + toolchain_providers: Vec::new(), executable_path: toolchain .root .join("bin") diff --git a/rust/lithe-core/src/lsp/interface/mod.rs b/rust/lithe-core/src/lsp/interface/mod.rs index a31b8219e5..dacfa9ac19 100644 --- a/rust/lithe-core/src/lsp/interface/mod.rs +++ b/rust/lithe-core/src/lsp/interface/mod.rs @@ -8,6 +8,7 @@ pub(crate) mod process; pub(crate) mod remote_process; #[cfg(test)] mod scripted; +mod toolchain_provider; mod transport; mod types; mod vcs_provider; diff --git a/rust/lithe-core/src/lsp/interface/toolchain_provider.rs b/rust/lithe-core/src/lsp/interface/toolchain_provider.rs new file mode 100644 index 0000000000..52fed5a606 --- /dev/null +++ b/rust/lithe-core/src/lsp/interface/toolchain_provider.rs @@ -0,0 +1,120 @@ +//! The toolchain-provider protocol a plugin backend speaks next to its functions. +//! +//! The manifest lists the providers; `initialize` offers them under +//! `capabilities.experimental.litheToolchains`, and the backend selects a version +//! and a subset of the offer. `toolchains/query` is written only to a session whose +//! answer passed [`negotiate`]. Replies stay untrusted here: Core transports them +//! unchanged, and `toolchains.decodeReply` validates them against native facts. +//! The rules live in `shared/contracts/plugins/v1/toolchains.md`. + +use serde_json::{json, Value}; +use std::collections::BTreeSet; + +/// Key under `capabilities.experimental` on both sides of the exchange. +const EXPERIMENTAL_KEY: &str = "litheToolchains"; + +/// Toolchain-provider protocol versions this client speaks. +const SUPPORTED_VERSIONS: [u64; 1] = [1]; + +/// The only method this protocol writes. +pub(crate) const QUERY_METHOD: &str = "toolchains/query"; + +/// Client event that carries the negotiated providers to the engine. +pub(crate) const NEGOTIATED_EVENT: &str = "toolchainProvidersNegotiated"; + +/// The offer for a session launched with `providers`; `None` when it declares none. +pub(crate) fn client_experimental_capabilities( + providers: &BTreeSet, +) -> Option<(&'static str, Value)> { + (!providers.is_empty()).then(|| { + ( + EXPERIMENTAL_KEY, + json!({ "versions": SUPPORTED_VERSIONS, "providers": providers }), + ) + }) +} + +/// The providers a backend selected, or `None` unless it chose an offered version +/// and a non-empty, duplicate-free subset of `offered`. A partly valid answer +/// enables nothing, so no query reaches a provider the manifest did not declare. +pub(crate) fn negotiate(capabilities: &Value, offered: &BTreeSet) -> Option> { + let answer = capabilities.pointer(&format!("/experimental/{EXPERIMENTAL_KEY}"))?; + let version = answer.get("version").and_then(Value::as_u64)?; + if !SUPPORTED_VERSIONS.contains(&version) { + return None; + } + let mut selected = BTreeSet::new(); + for provider in answer.get("providers")?.as_array()? { + let provider = provider.as_str()?; + if !offered.contains(provider) || !selected.insert(provider.to_string()) { + return None; + } + } + (!selected.is_empty()).then(|| selected.into_iter().collect()) +} + +/// Wraps the complete JSON-RPC response for `toolchains.decodeReply`, which owns +/// every success and error rule, with the wire ID the request was written under. +pub(crate) fn reply_outcome(response: &Value) -> Value { + json!({ "value": { "id": response.get("id"), "response": response } }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn offered() -> BTreeSet { + BTreeSet::from(["mise".to_string(), "asdf".to_string()]) + } + + fn answer(value: Value) -> Value { + json!({ "experimental": { "litheToolchains": value } }) + } + + #[test] + fn only_a_session_with_declared_providers_is_offered_the_protocol() { + assert_eq!(client_experimental_capabilities(&BTreeSet::new()), None); + let (key, offer) = client_experimental_capabilities(&offered()).expect("offered"); + assert_eq!(key, "litheToolchains"); + assert_eq!( + offer, + json!({ "versions": [1], "providers": ["asdf", "mise"] }) + ); + } + + #[test] + fn negotiation_accepts_only_a_complete_subset_of_the_offer() { + assert_eq!( + negotiate( + &answer(json!({ "version": 1, "providers": ["mise"], "future": true })), + &offered() + ), + Some(vec!["mise".to_string()]) + ); + for rejected in [ + json!({ "version": 2, "providers": ["mise"] }), + json!({ "version": 1 }), + json!({ "version": 1, "providers": [] }), + json!({ "version": 1, "providers": "mise" }), + json!({ "version": 1, "providers": ["mise", "mise"] }), + json!({ "version": 1, "providers": ["mise", "undeclared"] }), + json!({ "version": 1, "providers": ["mise", 7] }), + ] { + assert_eq!( + negotiate(&answer(rejected.clone()), &offered()), + None, + "{rejected}" + ); + } + assert_eq!(negotiate(&json!({}), &offered()), None); + } + + #[test] + fn replies_keep_the_whole_response_and_its_wire_id() { + let response = json!({ "jsonrpc": "2.0", "id": 7, "error": { "code": -32073 } }); + assert_eq!( + reply_outcome(&response), + json!({ "value": { "id": 7, "response": response } }) + ); + } +} diff --git a/rust/lithe-core/src/lsp/interface/types.rs b/rust/lithe-core/src/lsp/interface/types.rs index 1123a2175e..0687f265b3 100644 --- a/rust/lithe-core/src/lsp/interface/types.rs +++ b/rust/lithe-core/src/lsp/interface/types.rs @@ -155,6 +155,13 @@ pub struct LspClientState { /// `litheVcs`; `None` when the protocol was not negotiated. #[serde(default, skip_serializing_if = "Option::is_none")] pub vcs_provider: Option, + /// Manifest providers `initialize` offers under `litheToolchains`, from + /// `lsp.startServer.toolchainProviders`. + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + pub offered_toolchain_providers: BTreeSet, + /// The offered providers the backend selected; empty until negotiated. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub toolchain_providers: Vec, } impl Default for LspClientState { @@ -176,6 +183,8 @@ impl Default for LspClientState { plugin_function_version: None, host_services: BTreeSet::new(), vcs_provider: None, + offered_toolchain_providers: BTreeSet::new(), + toolchain_providers: Vec::new(), } } } diff --git a/rust/lithe-core/src/plugins/backend.rs b/rust/lithe-core/src/plugins/backend.rs index 1e6a38d38c..3521c3bdb0 100644 --- a/rust/lithe-core/src/plugins/backend.rs +++ b/rust/lithe-core/src/plugins/backend.rs @@ -66,6 +66,8 @@ pub(crate) struct DeclarationContext<'a> { pub(crate) has_command: bool, /// Whether the manifest lists `onWorkspaceOpen`. pub(crate) activates_on_workspace_open: bool, + /// A provider capability may start this backend for a host-owned query. + pub(crate) toolchain_query_trigger: bool, } impl BackendDeclaration { @@ -78,7 +80,7 @@ impl BackendDeclaration { } // Only the plugin's own pages call a backend in version 1, so one // without a web window could never start. Version 2 may also start - // with a workspace or a command, but still needs one of the three. + // with a workspace, a command or an admitted toolchain query trigger. if context.version < WORKSPACE_CONTEXT_VERSION { if !context.has_web_window { return Err(invalid( @@ -89,10 +91,11 @@ impl BackendDeclaration { } else if !context.has_web_window && !context.has_command && !context.activates_on_workspace_open + && !context.toolchain_query_trigger { return Err(invalid( "backend", - "A plugin backend needs a way to start: a web tool window, a command, or onWorkspaceOpen.", + "A plugin backend needs an admitted page, command, workspace or toolchain query trigger.", )); } self.process.validate( diff --git a/rust/lithe-core/src/plugins/capabilities.rs b/rust/lithe-core/src/plugins/capabilities.rs index e17d2f2b1c..5814d9a5b6 100644 --- a/rust/lithe-core/src/plugins/capabilities.rs +++ b/rust/lithe-core/src/plugins/capabilities.rs @@ -69,6 +69,10 @@ pub(crate) const DEBUG_ADAPTERS_API_VERSION: u32 = 4; /// Shared executable candidate order and strict explicit selection. pub(crate) const EXECUTABLE_RESOLUTION: &str = "executableResolution"; +/// Plugin-owned tool identities, discovery policy and executable candidates. +pub(crate) const TOOL_DEFINITIONS: &str = "toolDefinitions"; +/// Backend facts that can influence the executable selected for a defined tool. +pub(crate) const TOOLCHAIN_PROVIDERS: &str = "toolchainProviders"; /// Capability versions this Core build validates and plans. Version 1 of a /// capability keeps the meaning its section had at API level 2. @@ -79,13 +83,15 @@ pub const SUPPORTED_CAPABILITIES: &[(&str, &[u32])] = &[ (BUILD_SYSTEMS, &[1, 2]), (DEBUG_ADAPTERS, &[1, 2]), (EXECUTION_PROVIDERS, &[1]), - (EXECUTABLE_RESOLUTION, &[1]), + (EXECUTABLE_RESOLUTION, &[1, 2]), (LANGUAGE_SERVERS, &[1, 2]), (LANGUAGES, &[1, 2]), (PACKAGE_TRUST, &[1]), (PROJECT_GENERATORS, &[1]), (SERVER_PROCESS, &[1, 2]), (SETTINGS, &[1, 2]), + (TOOL_DEFINITIONS, &[1]), + (TOOLCHAIN_PROVIDERS, &[1]), (UI, &[1, 2, 3, 4]), (VCS_PROVIDERS, &[1]), ]; diff --git a/rust/lithe-core/src/plugins/declarative.rs b/rust/lithe-core/src/plugins/declarative.rs index a13ab4897a..9b7d3406fa 100644 --- a/rust/lithe-core/src/plugins/declarative.rs +++ b/rust/lithe-core/src/plugins/declarative.rs @@ -52,6 +52,8 @@ const PERMISSION_VCS: &str = "process.vcs"; const PERMISSION_PROJECT_GENERATE: &str = "project.generate"; /// Starting the plugin's own backend process. `backend` 1. const PERMISSION_BACKEND: &str = "backend.launch"; +/// Supplying facts that can change the executable selected for a defined tool. +const PERMISSION_TOOLCHAINS: &str = "toolchains.provide"; /// Letting the backend keep secrets in the host's credential store, private /// to the plugin. `backend` 1. const PERMISSION_CREDENTIALS: &str = "credentials.store"; @@ -72,6 +74,7 @@ const KNOWN_PERMISSIONS: &[&str] = &[ PERMISSION_PROJECT_GENERATE, PERMISSION_RUN, PERMISSION_TEST, + PERMISSION_TOOLCHAINS, PERMISSION_UI, PERMISSION_UI_WEB, PERMISSION_VCS, @@ -207,6 +210,12 @@ pub struct DeclarativePluginManifest { /// `super::backend`. #[serde(default, skip_serializing_if = "Option::is_none")] backend: Option, + /// Tool policies owned by plugins, never a host-maintained definition table. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + tool_definitions: Vec, + /// Fact providers served by the plugin's existing backend process. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + toolchain_providers: Vec, } /// UI supplied by plugins. The host owns every window shell: docking, title, @@ -800,6 +809,18 @@ pub fn validate_manifest_value(value: Value) -> Validation Validation<()> { + if self.tool_definitions.is_empty() + && self.requires.get(capabilities::EXECUTABLE_RESOLUTION) == Some(&2) + { + return Err(invalid( + "requires.executableResolution", + "Executable resolution 2 is reserved to tool definitions.", + )); + } + if self.tool_definitions.is_empty() && self.toolchain_providers.is_empty() { + return Ok(()); + } + if self.plugin_api_version < CAPABILITIES_API_VERSION { + return Err(invalid( + "toolDefinitions", + "Toolchain declarations require plugin API level 3.", + )); + } + if !self.tool_definitions.is_empty() + && self.requires.get(capabilities::EXECUTABLE_RESOLUTION) != Some(&2) + { + return Err(invalid( + "requires.executableResolution", + "Tool definitions require executable resolution 2.", + )); + } + crate::toolchain::definitions::validate_declarations( + &self.id, + &self.tool_definitions, + &self.toolchain_providers, + )?; + if !self.toolchain_providers.is_empty() { + if self.backend_version() != 2 { + return Err(invalid( + "requires.backend", + "Toolchain providers require backend 2.", + )); + } + let backend = self.backend.as_ref().ok_or_else(|| { + invalid( + "backend", + "A toolchain provider requires the plugin backend.", + ) + })?; + let value = serde_json::to_value(backend) + .map_err(|_| invalid("backend", "The backend cannot be encoded."))?; + let launch = &value["launch"]; + let packaged = launch["kind"] == "packaged" + || (launch["kind"] == "runtime" && launch["packagedRuntime"].as_str().is_some()); + if !packaged + || value["runtimeDependencies"] + .as_array() + .is_some_and(|values| !values.is_empty()) + { + return Err(invalid("backend.launch", "Provider backends must bootstrap from packaged executables or packaged runtimes without discovered dependencies.")); + } + } + Ok(()) + } + fn validate_ui(&mut self) -> Validation<()> { let Some(ui) = &mut self.ui else { return Ok(()); @@ -1375,6 +1471,18 @@ impl DeclarativePluginManifest { if !self.build_systems.is_empty() { required.insert(PERMISSION_BUILD); } + if !self.toolchain_providers.is_empty() { + required.insert(PERMISSION_TOOLCHAINS); + } else if self + .permissions + .iter() + .any(|permission| permission == PERMISSION_TOOLCHAINS) + { + return Err(invalid( + "permissions", + "toolchains.provide requires a toolchain provider.", + )); + } if !self.vcs_providers.is_empty() { required.insert(PERMISSION_VCS); } diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index be4d3964e8..83c57738e4 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -108,6 +108,22 @@ pub enum CoreCommand { PluginsDeclarativeDecodeGrammar, /// Validates independent editor action registration or results. PluginsEditorActionsDecode, + /// Registers plugin-owned tools with verified package provenance. + ToolchainsRegistry, + /// Validates provider answers using native evidence supplied by the host. + ToolchainsDecodeReply, + /// Builds the error reply for a provider query that timed out or found no backend. + ToolchainsFailureReply, + /// Resolves the nearest machine-local directory pin. + ToolchainsResolvePins, + /// Resolves the four toolchain layers from a captured value snapshot. + ToolchainsResolve, + /// Checks a plugin-defined version probe without per-tool parsing. + ToolchainsCheckVersion, + /// Plans system scans solely from enabled tool definitions. + ToolchainsDiscoveryPlan, + /// Lists checked installations and the automatic choice for one tool. + ToolchainsCandidates, /// Finds the build-system instances of a workspace (`build.detect`). BuildDetect, /// Lists a build system's executable candidates and version probes (`build.invocationPlan`). @@ -351,6 +367,8 @@ pub enum CoreCommand { LspBuildRequest, /// Queues one VCS-provider request (`lsp.vcsRequest`). LspVcsRequest, + /// Queues one toolchain-provider query (`lsp.toolchainRequest`). + LspToolchainRequest, /// Invokes one plugin function (`lsp.functionRequest`). LspFunctionRequest, /// Answers a plugin backend's host-service request (`lsp.hostResponse`). @@ -588,6 +606,14 @@ impl CoreCommand { } "plugins.declarative.decodeGrammar" => Some(Self::PluginsDeclarativeDecodeGrammar), "plugins.editorActions.decode" => Some(Self::PluginsEditorActionsDecode), + "toolchains.registry" => Some(Self::ToolchainsRegistry), + "toolchains.decodeReply" => Some(Self::ToolchainsDecodeReply), + "toolchains.failureReply" => Some(Self::ToolchainsFailureReply), + "toolchains.resolvePins" => Some(Self::ToolchainsResolvePins), + "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.invocationPlan" => Some(Self::BuildInvocationPlan), "build.checkToolVersion" => Some(Self::BuildCheckToolVersion), @@ -707,6 +733,7 @@ impl CoreCommand { "lsp.request" => Some(Self::LspRequest), "lsp.buildRequest" => Some(Self::LspBuildRequest), "lsp.vcsRequest" => Some(Self::LspVcsRequest), + "lsp.toolchainRequest" => Some(Self::LspToolchainRequest), "lsp.functionRequest" => Some(Self::LspFunctionRequest), "lsp.hostResponse" => Some(Self::LspHostResponse), "lsp.hostNotify" => Some(Self::LspHostNotify), @@ -823,6 +850,7 @@ mod tests { "lsp.request", "lsp.buildRequest", "lsp.vcsRequest", + "lsp.toolchainRequest", "lsp.functionRequest", "lsp.hostResponse", "lsp.hostNotify", diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index 026c2bf001..8ce21922f9 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -276,6 +276,30 @@ fn execute(request: &str) -> CoreResponse { parsed.payload, crate::plugins::editor_actions::decode_command, ), + CoreCommand::ToolchainsRegistry => { + plugin_response(id, parsed.payload, crate::toolchain::registry_command) + } + CoreCommand::ToolchainsDecodeReply => { + plugin_response(id, parsed.payload, crate::toolchain::decode_reply_command) + } + CoreCommand::ToolchainsFailureReply => { + plugin_response(id, parsed.payload, crate::toolchain::failure_reply_command) + } + CoreCommand::ToolchainsResolvePins => { + plugin_response(id, parsed.payload, crate::toolchain::resolve_pins_command) + } + CoreCommand::ToolchainsResolve => { + plugin_response(id, parsed.payload, crate::toolchain::resolve_command) + } + CoreCommand::ToolchainsCheckVersion => { + plugin_response(id, parsed.payload, crate::toolchain::check_version_command) + } + CoreCommand::ToolchainsDiscoveryPlan => { + plugin_response(id, parsed.payload, crate::toolchain::discovery_plan_command) + } + CoreCommand::ToolchainsCandidates => { + plugin_response(id, parsed.payload, crate::toolchain::candidates_command) + } CoreCommand::BuildDetect => { plugin_response(id, parsed.payload, crate::build::build_detect_command) } @@ -1893,6 +1917,24 @@ fn execute(request: &str) -> CoreResponse { Err(error) => CoreResponse::failure(id, error), } } + CoreCommand::LspToolchainRequest => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid toolchain-provider request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::lsp::toolchain_provider_request) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Toolchain response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } CoreCommand::LspVcsRequest => { match serde_json::from_value::(parsed.payload) .map_err(|error| { diff --git a/rust/lithe-core/src/tests/mod.rs b/rust/lithe-core/src/tests/mod.rs index 673fe8e091..4f75a57437 100644 --- a/rust/lithe-core/src/tests/mod.rs +++ b/rust/lithe-core/src/tests/mod.rs @@ -28,3 +28,7 @@ pub(crate) mod support; mod vcs; mod plugin_editor_resources; +mod toolchain_definitions; +mod toolchain_pins; +mod toolchain_queries; +mod toolchain_resolution; diff --git a/rust/lithe-core/src/tests/toolchain_definitions.rs b/rust/lithe-core/src/tests/toolchain_definitions.rs new file mode 100644 index 0000000000..f305bda9fb --- /dev/null +++ b/rust/lithe-core/src/tests/toolchain_definitions.rs @@ -0,0 +1,261 @@ +use crate::plugins::declarative::{validate_command, validate_manifest_value}; +use crate::toolchain::definitions::{registry, validate_declarations}; +use serde_json::{json, Value}; + +fn definition(id: &str) -> Value { + json!({ + "id":id,"displayName":"Fixture compiler", + "executableLayouts":[{"path":"bin/compiler"}], + "executables":[ + {"id":"pin","kind":"setting","settingID":"executable","layer":"project"}, + {"id":"project","kind":"toolchain","scope":"project"}, + {"id":"default","kind":"setting","settingID":"executable","layer":"global"}, + {"id":"global","kind":"toolchain","scope":"global"}, + {"id":"shell","kind":"command","command":"compiler"}, + {"id":"automatic","kind":"toolchain","scope":"installed"} + ], + "version":{"arguments":["--version"],"pattern":"([0-9]+(?:\\.[0-9]+)*)"}, + "commands":["compiler"],"exports":[{"name":"COMPILER_HOME","value":"${home}"}], + "pathPrepend":["${home}/bin"],"launch":["${executable}","${arguments}"] + }) +} + +fn manifest(id: &str, definition: Value) -> Value { + json!({"schemaVersion":2,"id":id,"displayName":"Fixture tool","version":"1.0.0", + "pluginAPIVersion":3,"requires":{"toolDefinitions":1,"executableResolution":2}, + "vendor":{"id":"org.example","displayName":"Fixture publisher","signatureRequirement":"publisherPackage"}, + "toolDefinitions":[definition]}) +} + +#[test] +fn tool_definitions_are_complete_contributions_without_languages_or_backend() { + let plugin = manifest( + "dev.lithe.fixture.compiler", + definition("dev.lithe.fixture.compiler:compiler"), + ); + assert!(validate_manifest_value(plugin.clone()).is_ok()); + let result = + registry(&json!({"plugins":[{"manifest":plugin,"verifiedOfficial":false}]})).unwrap(); + assert_eq!( + result["definitions"][0]["definition"]["id"], + "dev.lithe.fixture.compiler:compiler" + ); + assert_eq!(result["providers"], json!([])); +} + +#[test] +fn tool_definitions_second_tool_needs_only_plugin_data() { + for (plugin, tool, command) in [ + ("dev.lithe.fixture.compiler", "compiler", "compiler"), + ("dev.lithe.fixture.renderer", "renderer", "render-engine"), + ] { + let mut definition = definition(&format!("{plugin}:{tool}")); + definition["commands"] = json!([command]); + definition["executables"][4]["command"] = json!(command); + assert!(validate_manifest_value(manifest(plugin, definition)).is_ok()); + } +} + +#[test] +fn registry_short_ids_require_independent_origin_not_vendor_or_namespace() { + let plugin = manifest("dev.lithe.fixture.compiler", definition("compiler")); + assert!(validate_manifest_value(plugin.clone()).is_ok()); + assert!(registry(&json!({"plugins":[{"manifest":plugin,"verifiedOfficial":false}]})).is_err()); + assert!(registry(&json!({"plugins":[{"manifest":plugin,"verifiedOfficial":true}]})).is_ok()); + assert!(registry(&json!({"plugins":[{"manifest":plugin}]})).is_err()); +} + +#[test] +fn registry_never_replaces_existing_tool_owner() { + let first = manifest("dev.lithe.fixture.first", definition("compiler")); + let second = manifest("dev.lithe.fixture.second", definition("compiler")); + assert!(registry(&json!({"plugins":[{"manifest":first,"verifiedOfficial":true},{"manifest":second,"verifiedOfficial":true}]})).is_err()); + assert!(validate_manifest_value(manifest( + "dev.lithe.fixture.first", + definition("dev.lithe.fixture.second:compiler") + )) + .is_err()); +} + +#[test] +fn definition_nested_objects_fail_closed_and_enforce_safe_exports() { + let base = definition("dev.lithe.fixture.compiler:compiler"); + for (pointer, value) in [ + ("/unexpected", json!(true)), + ("/version/unexpected", json!(true)), + ("/exports/0/name", json!("PATH")), + ("/exports/0/name", json!("Path")), + ("/exports/0/name", json!("DYLD_INSERT_LIBRARIES")), + ("/pathPrepend/0", json!("${home}/../escape")), + ("/version/pattern", json!("no capture")), + ("/id", json!("dev..lithe.fixture:compiler")), + ("/version/arguments", json!(["-c", "script"])), + ("/launch/1", json!("--args=${arguments}")), + ( + "/discovery", + json!([{"root":{"kind":"home"},"pattern":"**/compiler"}]), + ), + ] { + let mut changed = base.clone(); + if pointer == "/unexpected" { + changed["unexpected"] = value; + } else if pointer == "/version/unexpected" { + changed["version"]["unexpected"] = value; + } else if pointer == "/discovery" { + changed["discovery"] = value; + } else { + *changed.pointer_mut(pointer).unwrap() = value; + } + assert!( + validate_declarations("dev.lithe.fixture.compiler", &[changed], &[]).is_err(), + "{pointer}" + ); + } +} + +#[test] +fn definition_metadata_cannot_disagree_with_executable_plan_or_cross_layers() { + let base = definition("dev.lithe.fixture.compiler:compiler"); + for candidate in [ + json!({"kind":"command","command":"another"}), + json!({"kind":"projectFile","path":"wrapper"}), + json!({"kind":"environment","variable":"COMPILER_HOME","path":"bin/compiler"}), + json!({"kind":"toolchain","scope":"installed","insertBefore":"pin"}), + json!({"kind":"command","command":"compiler","insertBefore":"missing"}), + json!({"kind":"runtime","runtime":"compiler"}), + json!({"id":null,"kind":"command","command":"compiler"}), + ] { + let mut changed = base.clone(); + changed["executables"] + .as_array_mut() + .unwrap() + .push(candidate); + assert!(validate_declarations("dev.lithe.fixture.compiler", &[changed], &[]).is_err()); + } +} + +#[test] +fn definitions_reuse_shared_candidate_graph_ordering() { + let mut value = definition("dev.lithe.fixture.compiler:compiler"); + value["commands"] = json!(["compiler", "compiler-alt"]); + value["executables"].as_array_mut().unwrap().insert( + 4, + json!({"id":"alternative","kind":"command","command":"compiler-alt","insertAfter":"shell"}), + ); + let plugin = manifest("dev.lithe.fixture.compiler", value); + let result = + registry(&json!({"plugins":[{"manifest":plugin,"verifiedOfficial":false}]})).unwrap(); + assert_eq!( + result["definitions"][0]["definition"]["executables"][4]["id"], + "shell" + ); + assert_eq!( + result["definitions"][0]["definition"]["executables"][5]["id"], + "alternative" + ); +} + +#[test] +fn providers_use_existing_packaged_backend_and_new_query_trigger_only() { + let plugin = json!({"schemaVersion":2,"id":"dev.lithe.fixture.provider","displayName":"Provider","version":"1.0.0", + "pluginAPIVersion":3,"requires":{"toolchainProviders":1,"backend":2,"serverProcess":1}, + "vendor":{"id":"org.example","displayName":"Fixture","signatureRequirement":"publisherPackage"}, + "permissions":["backend.launch","toolchains.provide"], + "toolchainProviders":[{"id":"manager","displayName":"Manager","tools":["compiler"],"watchFiles":[{"pattern":".compiler-version","reader":"native"}]}], + "backend":{"launch":{"kind":"packaged","path":"bin/provider"}}}); + assert!(validate_manifest_value(plugin.clone()).is_ok()); + let mut changed = plugin.clone(); + changed["backend"]["launch"] = + json!({"kind":"runtime","tool":"node","entry":"provider/main.mjs"}); + assert!(validate_manifest_value(changed).is_err()); + let mut changed = plugin.clone(); + changed["permissions"] = json!(["backend.launch"]); + assert_eq!( + validate_manifest_value(changed).unwrap_err().code, + "plugin.permission.undeclared" + ); + let mut changed = plugin.clone(); + changed + .as_object_mut() + .unwrap() + .remove("toolchainProviders"); + changed["requires"] + .as_object_mut() + .unwrap() + .remove("toolchainProviders"); + changed["permissions"] = json!(["backend.launch"]); + assert!(validate_manifest_value(changed).is_err()); +} + +#[test] +fn providers_without_watch_files_report_installations_only() { + let plugin = json!({"schemaVersion":2,"id":"dev.lithe.fixture.provider","displayName":"Provider","version":"1.0.0", + "pluginAPIVersion":3,"requires":{"toolchainProviders":1,"backend":2,"serverProcess":1}, + "vendor":{"id":"org.example","displayName":"Fixture","signatureRequirement":"publisherPackage"}, + "permissions":["backend.launch","toolchains.provide"], + "toolchainProviders":[{"id":"installs","displayName":"Installs","tools":["compiler"]}], + "backend":{"launch":{"kind":"packaged","path":"bin/provider"}}}); + assert!(validate_manifest_value(plugin.clone()).is_ok()); + let mut changed = plugin; + changed["toolchainProviders"][0]["watchFiles"] = json!([]); + assert!(validate_manifest_value(changed).is_err()); +} + +#[test] +fn a_definition_plugin_may_provide_installations_of_its_own_tool() { + let id = "dev.lithe.fixture.compiler"; + let mut plugin = manifest(id, definition(&format!("{id}:compiler"))); + plugin["requires"] = json!({"toolDefinitions":1,"executableResolution":2, + "toolchainProviders":1,"backend":2,"serverProcess":1}); + plugin["permissions"] = json!(["backend.launch", "toolchains.provide"]); + plugin["toolchainProviders"] = + json!([{"id":"installs","displayName":"Installs","tools":[format!("{id}:compiler")]}]); + plugin["backend"] = json!({"launch":{"kind":"packaged","path":"bin/provider"}}); + assert!(validate_manifest_value(plugin.clone()).is_ok()); + let result = + registry(&json!({"plugins":[{"manifest":plugin,"verifiedOfficial":false}]})).unwrap(); + assert_eq!(result["definitions"][0]["pluginID"], id); + assert_eq!(result["providers"][0]["pluginID"], id); +} + +#[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!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/contracts/plugins/v1/fixtures/build-tool-path.plugin.json" + ))) + .unwrap(); + plugin["requires"]["executableResolution"] = json!(2); + assert!(validate_manifest_value(plugin.clone()).is_err()); + plugin["requires"]["toolDefinitions"] = json!(1); + plugin["toolDefinitions"] = json!([definition("dev.lithe.fixture.build-tool-path:compiler")]); + plugin["buildSystems"][0]["invocation"]["executables"][0]["layer"] = json!("project"); + assert!(validate_manifest_value(plugin).is_err()); +} + +#[test] +fn production_toolchain_fixtures_require_explicit_host_capabilities() { + let sources = [ + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/contracts/plugins/v1/fixtures/tool-definition.plugin.json" + )), + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/contracts/plugins/v1/fixtures/toolchain-provider.plugin.json" + )), + ]; + let old_host = json!({"backend":[1,2],"serverProcess":[1],"executableResolution":[1]}); + let capable_host = json!({"backend":[1,2],"serverProcess":[1],"executableResolution":[1,2],"toolDefinitions":[1],"toolchainProviders":[1]}); + for source in sources { + let manifest: Value = serde_json::from_str(source).unwrap(); + assert!(validate_manifest_value(manifest.clone()).is_ok()); + let rejected = + validate_command(json!({"manifest":manifest,"hostCapabilities":old_host})).unwrap(); + assert_eq!(rejected["status"], "rejected", "{rejected}"); + assert_eq!(rejected["error"]["code"], "plugin.api.unsupported"); + let accepted = + validate_command(json!({"manifest":manifest,"hostCapabilities":capable_host})).unwrap(); + assert_eq!(accepted["status"], "ok", "{accepted}"); + } +} diff --git a/rust/lithe-core/src/tests/toolchain_pins.rs b/rust/lithe-core/src/tests/toolchain_pins.rs new file mode 100644 index 0000000000..e03aa36310 --- /dev/null +++ b/rust/lithe-core/src/tests/toolchain_pins.rs @@ -0,0 +1,269 @@ +//! Deterministic local-pin validation and component-wise inheritance regressions. + +use crate::toolchain::pins; +use serde_json::{json, Value}; + +/// Loads inert contract data; none of its fictional paths are probed. +fn fixture() -> Value { + serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/contracts/plugins/v1/proposals/toolchains/fixtures/local.json" + ))) + .unwrap() +} + +/// Exercises the pure input boundary with a selected directory and arbitrary tool. +fn resolve(document: &Value, directory: &str, tool: &str) -> Value { + pins::resolve(&json!({"document":document,"directory":directory,"tool":tool})).unwrap() +} + +/// Produces a valid inert pin for independent validation scenarios. +fn pin(home: &str) -> Value { + json!({"home":home,"version":"21.0.4","provider":"host:manual"}) +} + +#[test] +fn toolchain_pins_contract_fixture_inherits_the_nearest_ancestor() { + let document = fixture(); + pins::validate(&document).unwrap(); + for (directory, expected) in [ + (".", "."), + ("apps", "."), + ("apps/web", "apps/web"), + ("apps/web/src/main", "apps/web"), + (".config/tools/settings", ".config/tools"), + ] { + let result = resolve(&document, directory, "java"); + assert_eq!(result["sourceDirectory"], expected, "{directory}"); + assert_eq!(result["pin"], document["pins"][expected]["java"]); + } +} + +#[test] +fn toolchain_pins_do_not_match_sibling_prefixes_or_other_tools() { + let document = fixture(); + assert_eq!( + resolve(&document, "apps/website/src", "java")["sourceDirectory"], + "." + ); + assert_eq!( + resolve(&document, "apps/web/src", "node"), + json!({"pin":null,"sourceDirectory":null}) + ); + assert_eq!( + resolve(&json!({"version":1,"toolchains":{}}), ".", "java"), + json!({"pin":null,"sourceDirectory":null}) + ); + assert_eq!( + resolve(&json!({"version":1,"toolchains":{},"pins":{}}), ".", "java"), + json!({"pin":null,"sourceDirectory":null}) + ); +} + +#[test] +fn toolchain_pins_retain_dormant_namespaced_values_and_legacy_data() { + let document = json!({ + "version":1, + "toolchains":{"project-jdk":{"home":"relative legacy home","javaHome":"legacy JDK"},"custom old key":{"executable":"legacy-command"}}, + "pins":{".":{"dev.lithe.fixture.disabled:compiler":pin("/opt/lithe-fixtures/compiler")}} + }); + let before = document.clone(); + let result = resolve(&document, "src", "dev.lithe.fixture.disabled:compiler"); + assert_eq!( + result["pin"], + document["pins"]["."]["dev.lithe.fixture.disabled:compiler"] + ); + assert_eq!(document, before); + assert!(resolve(&document, ".", "java")["pin"].is_null()); +} + +#[test] +fn toolchain_pins_validate_every_entry_not_only_the_requested_tool() { + let mut document = fixture(); + document["pins"]["unrelated"] = + json!({"node":{"home":"relative","version":"22","provider":"host:manual"}}); + let error = + pins::resolve(&json!({"document":document,"directory":".","tool":"java"})).unwrap_err(); + assert_eq!(error.code(), "plugin.toolchain.invalidParams"); + assert_eq!(error.field(), Some("document.pins.unrelated.node.home")); +} + +#[test] +fn toolchain_pins_reject_non_normalized_directories_but_allow_dotted_names() { + for directory in [ + "", + "/root", + "../parent", + "src/../lib", + "src/./lib", + "src//lib", + "src/", + "src\\lib", + "C:/root", + "src\nlib", + ] { + let mut document = fixture(); + document["pins"][directory] = json!({"java":pin("/opt/lithe-fixtures/java")}); + assert!( + pins::validate(&document).is_err(), + "document key {directory:?}" + ); + assert!( + pins::resolve(&json!({"document":fixture(),"directory":directory,"tool":"java"})) + .is_err(), + "requested directory {directory:?}" + ); + } + for directory in [".config/tools", "foo.bar/.hidden", "a..b", "."] { + assert!( + pins::resolve(&json!({"document":fixture(),"directory":directory,"tool":"java"})) + .is_ok(), + "{directory}" + ); + } +} + +#[test] +fn toolchain_pins_accept_cross_platform_absolute_paths_without_io() { + for home in [ + "/opt/lithe-fixtures/java", + "/", + "C:\\Toolchains\\Java", + "D:/Tools/Java", + "C:\\", + "\\\\server\\share\\tools", + "\\\\server\\share", + "\\\\server\\share\\", + "//server/share/tools", + ] { + let document = json!({"version":1,"toolchains":{},"pins":{".":{"java":pin(home)}}}); + assert_eq!(resolve(&document, ".", "java")["pin"]["home"], home); + } + for home in [ + "relative", + "~/java", + "C:relative", + "\\rooted", + "\\\\server", + "\\\\?\\C:\\tools", + "\\\\.\\tools", + "C:/tools/../java", + "/opt/../java", + "/opt//java", + "//", + "C://", + "/opt/./java", + "C:/tools:stream", + "C:/tools./java", + "C:/tools /java", + "/opt/ja\nva", + ] { + let document = json!({"version":1,"toolchains":{},"pins":{".":{"java":pin(home)}}}); + assert!(pins::validate(&document).is_err(), "{home:?}"); + } +} + +#[test] +fn toolchain_pins_reject_invalid_tools_providers_and_unknown_fields() { + for tool in [ + "", + "Java", + "dev..fixture:tool", + "dev.fixture:", + "dev.fixture:Tool", + "a:b:c", + "a/b", + ] { + assert!( + pins::resolve(&json!({"document":fixture(),"directory":".","tool":tool})).is_err(), + "{tool:?}" + ); + let document = json!({"version":1,"toolchains":{},"pins":{".":{tool:pin("/opt/lithe-fixtures/java")}}}); + assert!(pins::validate(&document).is_err(), "{tool:?}"); + } + for provider in [ + "mise", + "host:", + "host:Manual", + "Host:manual", + "1host:manual", + "dev..fixture:mise", + "host:manual:extra", + ] { + let mut document = fixture(); + document["pins"]["."]["java"]["provider"] = json!(provider); + assert!(pins::validate(&document).is_err(), "{provider}"); + } + let mut document = fixture(); + document["extra"] = json!(true); + assert!(pins::validate(&document).is_err()); + let mut document = fixture(); + document["pins"]["."]["java"]["extra"] = json!(true); + assert!(pins::validate(&document).is_err()); + let document = json!({"version":1,"toolchains":{"old":{"unknown":"path"}}}); + assert!(pins::validate(&document).is_err()); + assert!(pins::resolve( + &json!({"document":fixture(),"directory":".","tool":"java","unknown":true}) + ) + .is_err()); +} + +#[test] +fn toolchain_pins_reject_missing_fields_and_wrong_shapes() { + for document in [ + Value::Null, + json!({}), + json!({"version":2,"toolchains":{}}), + json!({"version":1,"toolchains":[]}), + json!({"version":1,"toolchains":{},"pins":null}), + json!({"version":1,"toolchains":{},"pins":{".":[]}}), + ] { + assert!(pins::validate(&document).is_err(), "{document}"); + } + for property in ["home", "version", "provider"] { + let mut document = fixture(); + document["pins"]["."]["java"] + .as_object_mut() + .unwrap() + .remove(property); + assert!(pins::validate(&document).is_err(), "missing {property}"); + let mut document = fixture(); + document["pins"]["."]["java"][property] = json!(42); + assert!(pins::validate(&document).is_err(), "numeric {property}"); + } +} + +#[test] +fn toolchain_pins_enforce_collection_and_text_limits_before_resolution() { + let mut document = json!({"version":1,"toolchains":{},"pins":{}}); + for index in 0..1025 { + document["pins"][format!("dir-{index}")] = json!({}); + } + assert_eq!( + pins::validate(&document).unwrap_err().code(), + "plugin.toolchain.limitExceeded" + ); + let mut document = json!({"version":1,"toolchains":{},"pins":{".":{}}}); + for index in 0..65 { + document["pins"]["."][format!("tool-{index}")] = pin("/opt/lithe-fixtures/tool"); + } + assert_eq!( + pins::validate(&document).unwrap_err().code(), + "plugin.toolchain.limitExceeded" + ); + for (property, length) in [("home", 4097), ("version", 129), ("provider", 257)] { + let mut document = fixture(); + document["pins"]["."]["java"][property] = json!("x".repeat(length)); + assert_eq!( + pins::validate(&document).unwrap_err().code(), + "plugin.toolchain.limitExceeded", + "{property}" + ); + } + let mut document = fixture(); + document["pins"]["x".repeat(2049)] = json!({}); + assert_eq!( + pins::validate(&document).unwrap_err().code(), + "plugin.toolchain.limitExceeded" + ); +} diff --git a/rust/lithe-core/src/tests/toolchain_queries.rs b/rust/lithe-core/src/tests/toolchain_queries.rs new file mode 100644 index 0000000000..f479213a81 --- /dev/null +++ b/rust/lithe-core/src/tests/toolchain_queries.rs @@ -0,0 +1,360 @@ +//! Pure query validation with inert native filesystem facts and cross-platform paths. + +use crate::toolchain::{paths, query}; +use serde_json::{json, Value}; + +const HOME: &str = "/opt/lithe-fixtures/installs/java/21.0.4"; +const WORKSPACE: &str = "/opt/lithe-fixtures/workspace"; + +fn contract(name: &str) -> Value { + let source = match name { + "java" => include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/contracts/plugins/v1/fixtures/tool-definition.plugin.json" + )), + "mise" => include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/contracts/plugins/v1/fixtures/toolchain-provider.plugin.json" + )), + "queries" => include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/contracts/plugins/v1/proposals/toolchains/fixtures/query-cases.json" + )), + _ => panic!("unknown contract fixture"), + }; + serde_json::from_str(source).unwrap() +} + +fn manifest(fragment: &Value) -> Value { + fragment.clone() +} + +fn fact(path: &str, kind: &str, executable: bool) -> Value { + json!({"path":path,"canonicalPath":path,"kind":kind,"executable":executable}) +} + +fn input() -> Value { + let fixture = contract("queries"); + json!({"registry":{"plugins":[{"manifest":manifest(&contract("java")),"verifiedOfficial":true},{"manifest":manifest(&contract("mise")),"verifiedOfficial":false}]}, + "pluginID":"dev.lithe.fixture.mise","request":fixture["cases"][0]["request"],"response":fixture["cases"][0]["response"],"currentGeneration":1,"platform":"macos", + "filesystem":[fact(WORKSPACE,"directory",false),fact(HOME,"directory",false),fact(&format!("{HOME}/bin/java"),"file",true),fact(&format!("{WORKSPACE}/mise.toml"),"file",false)]}) +} + +#[test] +fn toolchain_queries_decode_installed_contract_reply_without_native_io() { + let outcome = query::decode(&input()).unwrap(); + assert_eq!(outcome["installations"][0]["home"], HOME); + assert_eq!( + outcome["installations"][0]["executable"], + format!("{HOME}/bin/java") + ); + assert_eq!( + outcome["installations"][0]["provider"], + "dev.lithe.fixture.mise:mise" + ); + assert_eq!(outcome["statements"][0]["request"], "21"); + assert_eq!(outcome["diagnostics"], json!([])); +} + +#[test] +fn toolchain_queries_uninstalled_request_is_structured_unmet_data() { + let mut value = input(); + value["response"]["result"]["installations"] = json!([]); + value["response"]["result"]["statements"][0]["installation"] = Value::Null; + value["response"]["result"]["statements"][0]["request"] = json!("99"); + let outcome = query::decode(&value).unwrap(); + assert_eq!(outcome["diagnostics"][0]["code"], "requestedNotInstalled"); + assert_eq!(outcome["diagnostics"][0]["request"], "99"); + assert!(outcome["statements"][0]["installation"].is_null()); +} + +#[test] +fn toolchain_queries_unknown_definitions_retain_no_path_or_execution_authority() { + let mut value = input(); + value["registry"]["plugins"] + .as_array_mut() + .unwrap() + .remove(0); + value["response"]["result"]["installations"][0]["home"] = json!("unusable-relative-home"); + value["response"]["result"]["statements"][0]["installation"] = json!("unusable-relative-home"); + let outcome = query::decode(&value).unwrap(); + assert_eq!(outcome["installations"], json!([])); + assert_eq!(outcome["statements"], json!([])); + assert_eq!(outcome["diagnostics"][0]["code"], "definitionUnavailable"); + assert!(outcome["diagnostics"][0].get("installation").is_none()); + assert!(outcome["diagnostics"][0].get("home").is_none()); +} + +#[test] +fn toolchain_queries_filter_unsolicited_tools_before_native_path_validation() { + let mut value = input(); + value["response"]["result"]["installations"] + .as_array_mut() + .unwrap() + .push(json!({"tool":"git","version":"1","home":"unusable-relative-home"})); + value["response"]["result"]["statements"].as_array_mut().unwrap().push(json!({"tool":"git","scope":"project","source":"unusable-relative-source","request":"1","installation":null,"kind":"declaration","reader":"native"})); + assert_eq!( + query::decode(&value).unwrap()["installations"] + .as_array() + .unwrap() + .len(), + 1 + ); +} + +#[test] +fn toolchain_queries_refuse_workspace_homes_and_canonical_symlink_targets() { + for target in [format!("{WORKSPACE}/vendor/jdk"), WORKSPACE.to_owned()] { + let mut value = input(); + value["filesystem"][1]["canonicalPath"] = json!(target); + assert_eq!( + query::decode(&value).unwrap_err().code(), + "plugin.toolchain.unsafePath" + ); + } + let mut value = input(); + value["filesystem"][2]["canonicalPath"] = json!(format!("{WORKSPACE}/malicious-java")); + assert_eq!( + query::decode(&value).unwrap_err().code(), + "plugin.toolchain.unsafePath" + ); +} + +#[test] +fn toolchain_queries_require_executable_and_same_tool_installation_reference() { + let mut value = input(); + value["filesystem"][2]["executable"] = json!(false); + assert_eq!( + query::decode(&value).unwrap_err().code(), + "plugin.toolchain.unsafePath" + ); + value = input(); + value["response"]["result"]["statements"][0]["installation"] = + json!("/opt/lithe-fixtures/unlisted"); + assert_eq!( + query::decode(&value).unwrap_err().code(), + "plugin.toolchain.invalidReply" + ); +} + +#[test] +fn toolchain_queries_deduplicate_identical_source_facts_but_reject_conflicting_choices() { + let mut value = input(); + let duplicate = value["response"]["result"]["statements"][0].clone(); + value["response"]["result"]["statements"] = json!([duplicate.clone(), duplicate]); + assert_eq!( + query::decode(&value).unwrap()["statements"] + .as_array() + .unwrap() + .len(), + 1 + ); + for (field, conflicting) in [("request", json!("17")), ("installation", Value::Null)] { + let mut conflict = value.clone(); + conflict["response"]["result"]["statements"][1][field] = conflicting; + assert_eq!( + query::decode(&conflict).unwrap_err().code(), + "plugin.toolchain.invalidReply", + "{field}" + ); + } +} + +#[test] +fn toolchain_queries_refuse_mismatched_rpc_and_operation_generations() { + for path in ["operationID", "providerID", "generation"] { + let mut value = input(); + value["response"]["result"][path] = if path == "generation" { + json!(2) + } else { + json!("other") + }; + assert_eq!( + query::decode(&value).unwrap_err().code(), + "plugin.toolchain.operationExpired" + ); + } + let mut value = input(); + value["response"]["id"] = json!(2); + assert_eq!( + query::decode(&value).unwrap_err().code(), + "plugin.toolchain.invalidReply" + ); + value = input(); + value["request"]["params"]["workspace"] = Value::Null; + value["request"]["params"]["context"] = Value::Null; + value["currentGeneration"] = json!(2); + assert_eq!( + query::decode(&value).unwrap_err().code(), + "plugin.toolchain.operationExpired" + ); +} + +#[test] +fn toolchain_queries_keep_every_error_mapping_unique_and_validate_the_pair() { + let mut numbers = std::collections::BTreeSet::new(); + for response in contract("queries")["errorResponses"].as_array().unwrap() { + let mut value = input(); + let mut response = response.clone(); + response["id"] = value["request"]["id"].clone(); + response["error"]["data"]["operationID"] = + value["request"]["params"]["operationID"].clone(); + value["response"] = response; + let error = query::decode(&value).unwrap_err(); + assert_eq!( + Some(error.code()), + value["response"]["error"]["data"]["code"].as_str() + ); + let number = query::rpc_code(error.code()).unwrap(); + assert!(numbers.insert(number)); + value["response"]["error"]["code"] = json!(-1); + assert_eq!( + query::decode(&value).unwrap_err().code(), + "plugin.toolchain.invalidReply" + ); + } +} + +#[test] +fn toolchain_queries_validate_declared_reader_and_project_ancestor() { + let mut value = input(); + value["response"]["result"]["statements"][0]["reader"] = json!("compatible"); + assert_eq!( + query::decode(&value).unwrap_err().code(), + "plugin.toolchain.invalidReply" + ); + value = input(); + value["filesystem"].as_array_mut().unwrap().push(fact( + "/opt/lithe-fixtures/sibling/mise.toml", + "file", + false, + )); + value["response"]["result"]["statements"][0]["source"] = + json!("/opt/lithe-fixtures/sibling/mise.toml"); + assert_eq!( + query::decode(&value).unwrap_err().code(), + "plugin.toolchain.unsafePath" + ); + value = input(); + value["request"]["params"]["workspace"] = Value::Null; + value["request"]["params"]["context"] = Value::Null; + assert_eq!( + query::decode(&value).unwrap_err().code(), + "plugin.toolchain.invalidReply" + ); +} + +#[test] +fn toolchain_queries_nested_source_pattern_uses_declared_anchor_not_parent() { + let mut value = input(); + let source = format!("{WORKSPACE}/.mise/config.toml"); + value["filesystem"] + .as_array_mut() + .unwrap() + .push(fact(&source, "file", false)); + value["response"]["result"]["statements"][0]["source"] = json!(source); + let result = query::decode(&value).unwrap(); + assert_eq!(result["statements"][0]["sourceDirectory"], WORKSPACE); +} + +#[test] +fn toolchain_queries_reject_unknown_fields_missing_null_and_duplicate_identities() { + let mut value = input(); + value["request"]["params"]["arbitrary"] = json!(true); + assert_eq!( + query::decode(&value).unwrap_err().code(), + "plugin.toolchain.invalidParams" + ); + value = input(); + value["response"]["result"]["statements"][0] + .as_object_mut() + .unwrap() + .remove("installation"); + assert_eq!( + query::decode(&value).unwrap_err().code(), + "plugin.toolchain.invalidReply" + ); + value = input(); + value["request"]["params"]["tools"] = json!(["java", "java"]); + assert_eq!( + query::decode(&value).unwrap_err().code(), + "plugin.toolchain.invalidParams" + ); + value = input(); + let duplicate = value["filesystem"][0].clone(); + value["filesystem"].as_array_mut().unwrap().push(duplicate); + assert_eq!( + query::decode(&value).unwrap_err().code(), + "plugin.toolchain.invalidParams" + ); +} + +#[test] +fn toolchain_queries_refuse_remote_authority_without_local_fallback() { + let mut value = input(); + value["request"]["params"]["authority"] = json!({"kind":"remote","id":"ssh-1"}); + assert_eq!( + query::decode(&value).unwrap_err().code(), + "plugin.toolchain.authorityUnsupported" + ); +} + +#[test] +fn toolchain_queries_keep_missing_watch_inputs_and_enforce_reply_limits() { + let mut value = input(); + value["response"]["result"]["watch"] = json!(["/opt/lithe-fixtures/missing.toml"]); + assert_eq!( + query::decode(&value).unwrap()["watch"], + value["response"]["result"]["watch"] + ); + value["response"]["result"]["watch"] = json!(vec!["/opt/lithe-fixtures/missing.toml"; 257]); + assert_eq!( + query::decode(&value).unwrap_err().code(), + "plugin.toolchain.limitExceeded" + ); +} + +#[test] +fn toolchain_queries_apply_windows_drive_unc_and_case_rules_without_host_paths() { + assert!(paths::path_within( + r"C:\Workspace\bin\java.exe", + "c:/workspace", + "windows" + )); + assert!(!paths::path_within( + r"C:\WorkspaceOther\java.exe", + "c:/workspace", + "windows" + )); + assert!(paths::path_within( + r"\\server\share\workspace\java.exe", + "//SERVER/share/workspace", + "windows" + )); + for path in [ + r"C:relative", + r"C:\root\..\jdk", + r"\\?\C:\jdk", + r"C:\jdk\java.exe:stream", + ] { + assert!(paths::normalize(path, "windows", false).is_none(), "{path}"); + } + assert!(!paths::within("C:/Work/java", "C:/work", "windows", true)); + let definition = contract("java")["toolDefinitions"][0].clone(); + let filesystem = vec![ + fact("C:/Tools/Jdk", "directory", false), + fact("C:/Tools/Jdk/bin/java.exe", "file", true), + fact("C:/Workspace", "directory", false), + ]; + let selected = query::validate_installation( + "c:/tools/jdk", + &definition, + &filesystem, + &["C:/Workspace".to_owned()], + "windows", + false, + None, + ) + .unwrap(); + assert_eq!(selected["executable"], "C:/Tools/Jdk/bin/java.exe"); +} diff --git a/rust/lithe-core/src/tests/toolchain_resolution.rs b/rust/lithe-core/src/tests/toolchain_resolution.rs new file mode 100644 index 0000000000..002445a0e8 --- /dev/null +++ b/rust/lithe-core/src/tests/toolchain_resolution.rs @@ -0,0 +1,879 @@ +//! Exercises shared tool selection through production commands and inert native facts. + +use crate::toolchain::{ + candidates_command, check_version_command, discovery_plan_command, failure_reply_command, + resolve_command, +}; +use serde_json::{json, Value}; + +const WORKSPACE: &str = "/opt/lithe-fixtures/workspace"; +const HOME: &str = "/opt/lithe-fixtures/installs/java/21.0.4"; +const OLD: &str = "/opt/lithe-fixtures/installs/java/17.0.12"; + +/// Reads review data as test inputs, never as an installed plugin or live path. +fn contract(name: &str) -> Value { + let source = match name { + "java" => include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/contracts/plugins/v1/fixtures/tool-definition.plugin.json" + )), + "mise" => include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/contracts/plugins/v1/fixtures/toolchain-provider.plugin.json" + )), + "query" => include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/contracts/plugins/v1/proposals/toolchains/fixtures/query-cases.json" + )), + "local" => include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/contracts/plugins/v1/proposals/toolchains/fixtures/local.json" + )), + _ => panic!("unknown fixture"), + }; + serde_json::from_str(source).unwrap() +} + +/// Retains the complete production manifest used by the shared contract fixture. +fn manifest(fragment: &Value) -> Value { + fragment.clone() +} + +/// Records only host-observed filesystem metadata; no native file is created. +fn fact(path: &str, kind: &str, executable: bool) -> Value { + json!({"path":path,"canonicalPath":path,"kind":kind,"executable":executable}) +} + +/// Starts with a verified definition and provider but no selected installation. +fn input() -> Value { + json!({"registry":{"plugins":[{"manifest":manifest(&contract("java")),"verifiedOfficial":true},{"manifest":manifest(&contract("mise")),"verifiedOfficial":false}]}, + "operationID":"resolve-1","generation":1,"context":{"contextID":"context-1","generation":1},"tool":"java","directory":".","platform":"macos","workspaceRoot":WORKSPACE, + "filesystem":[fact(WORKSPACE,"directory",false)],"probes":[],"queries":[],"systemInstallations":[],"environment":{},"commandHomes":{}}) +} + +/// Adds a usable installation and its bounded version-probe result. +fn installation(input: &mut Value, home: &str, version: &str) { + let facts = input["filesystem"].as_array_mut().unwrap(); + facts.extend([ + fact(home, "directory", false), + fact(&format!("{home}/bin"), "directory", false), + fact(&format!("{home}/bin/java"), "file", true), + ]); + input["probes"].as_array_mut().unwrap().push(json!({"executable":format!("{home}/bin/java"),"output":format!("openjdk version \"{version}\""),"exitCode":0})); +} + +/// Resolves through the exported command envelope, preserving rejection diagnostics. +fn resolve(input: &Value) -> Value { + let result = resolve_command(input.clone()).unwrap(); + assert_eq!(result["status"], "ok", "{result}"); + result["resolution"].clone() +} + +/// Captures a fake backend exchange that the resolver must decode itself. +fn query(input: &Value) -> Value { + let case = &contract("query")["cases"][0]; + json!({"registry":input["registry"],"pluginID":"dev.lithe.fixture.mise","request":case["request"],"response":case["response"], + "currentGeneration":1,"platform":"macos","filesystem":input["filesystem"]}) +} + +#[test] +fn toolchain_resolution_pins_inherit_nearest_ancestor_without_sibling_prefixes() { + let mut value = input(); + let manual = "/opt/lithe-fixtures/manual/java/17.0.12"; + installation(&mut value, HOME, "21.0.4"); + installation(&mut value, manual, "17.0.12"); + value["local"] = contract("local"); + value["directory"] = json!("apps/web/src"); + let result = resolve(&value); + assert_eq!(result["outcome"]["selection"]["home"], manual); + assert_eq!(result["outcome"]["selection"]["layer"], "pinned"); + assert_eq!( + result["outcome"]["selection"]["environment"]["JAVA_HOME"], + manual + ); + value["directory"] = json!("apps/website"); + assert_eq!(resolve(&value)["outcome"]["selection"]["home"], HOME); +} + +#[test] +fn toolchain_resolution_invalid_pin_stops_otherwise_usable_fallback() { + let mut value = input(); + installation(&mut value, HOME, "21.0.4"); + value["local"] = json!({"version":1,"toolchains":{},"pins":{".":{"java":{"home":"/opt/lithe-fixtures/missing","version":"99","provider":"host:manual"}}}}); + value["commandHomes"]["java"] = json!(HOME); + let result = resolve(&value); + assert_eq!(result["outcome"]["kind"], "terminalFailure"); + assert!(result["outcome"].get("selection").is_none()); + assert!(result["trace"] + .as_array() + .unwrap() + .iter() + .skip(1) + .all(|row| row["state"] == "shadowed")); +} + +#[test] +fn toolchain_resolution_valid_pin_does_not_depend_on_provider_availability() { + let mut value = input(); + installation(&mut value, HOME, "21.0.4"); + value["local"] = json!({"version":1,"toolchains":{},"pins":{".":{"java":{"home":HOME,"version":"21.0.4","provider":"host:manual"}}}}); + // A selected pin does not require querying, decoding or probing lower layers. + value["queries"] = json!([{"response":{"error":{"code":-32072}}}]); + let result = resolve(&value); + assert_eq!(result["outcome"]["selection"]["home"], HOME); + assert_eq!(result["outcome"]["selection"]["layer"], "pinned"); +} + +#[test] +fn toolchain_resolution_optional_provider_timeout_keeps_shell_fallback_visible() { + let mut value = input(); + installation(&mut value, HOME, "21.0.4"); + value["commandHomes"]["java"] = json!(HOME); + let mut backend = query(&value); + backend["response"] = json!({"jsonrpc":"2.0","id":backend["request"]["id"],"error":{"code":-32072,"message":"Fixture timeout.","data":{"code":"plugin.toolchain.timeout","operationID":backend["request"]["params"]["operationID"],"providerID":"mise"}}}); + value["queries"] = json!([backend]); + let result = resolve(&value); + assert_eq!(result["outcome"]["selection"]["provider"], "host:shell"); + assert_eq!(result["unmet"][0]["code"], "providerUnavailable"); +} + +#[test] +fn toolchain_resolution_host_failure_reply_names_the_provider_and_its_cause() { + let mut value = input(); + installation(&mut value, HOME, "21.0.4"); + value["commandHomes"]["java"] = json!(HOME); + for code in [ + "plugin.toolchain.timeout", + "plugin.toolchain.providerUnavailable", + ] { + let mut backend = query(&value); + let reply = + failure_reply_command(json!({"params":backend["request"]["params"],"code":code})) + .unwrap(); + assert_eq!(reply["status"], "ok", "{reply}"); + backend["request"]["id"] = reply["reply"]["id"].clone(); + backend["response"] = reply["reply"]["response"].clone(); + value["queries"] = json!([backend]); + let result = resolve(&value); + assert_eq!(result["outcome"]["selection"]["provider"], "host:shell"); + assert_eq!( + result["unmet"], + json!([{"code":"providerUnavailable","tool":"java", + "provider":"dev.lithe.fixture.mise:mise","cause":code}]) + ); + // A provider that was not asked about this tool leaves it nothing to report. + backend["request"]["params"]["tools"] = json!(["node"]); + value["queries"] = json!([backend]); + assert_eq!(resolve(&value)["unmet"], json!([])); + } + let params = query(&value)["request"]["params"].clone(); + let mut nameless = params.clone(); + nameless["providerID"] = json!(""); + // Cancellation is not a provider that could not answer, and an identity is required. + for input in [ + json!({"params":params,"code":"plugin.toolchain.cancelled"}), + json!({"params":nameless,"code":"plugin.toolchain.timeout"}), + json!({"params":params,"code":"plugin.toolchain.timeout","message":"extra"}), + ] { + let rejected = failure_reply_command(input).unwrap(); + assert_eq!(rejected["status"], "rejected"); + assert_eq!(rejected["error"]["code"], "plugin.toolchain.invalidParams"); + } +} + +#[test] +fn toolchain_resolution_uninstalled_project_requirement_survives_shell_fallback() { + let mut value = input(); + installation(&mut value, HOME, "21.0.4"); + value["filesystem"].as_array_mut().unwrap().push(fact( + &format!("{WORKSPACE}/mise.toml"), + "file", + false, + )); + value["commandHomes"]["java"] = json!(HOME); + let mut backend = query(&value); + backend["response"]["result"]["installations"] = json!([]); + backend["response"]["result"]["statements"][0]["installation"] = Value::Null; + backend["response"]["result"]["statements"][0]["request"] = json!("99"); + value["queries"] = json!([backend]); + let result = resolve(&value); + assert_eq!(result["outcome"]["selection"]["home"], HOME); + assert_eq!(result["outcome"]["selection"]["layer"], "global"); + assert!(result["unmet"] + .as_array() + .unwrap() + .iter() + .any(|reason| reason["code"] == "requestedNotInstalled" && reason["request"] == "99")); +} + +#[test] +fn toolchain_resolution_provider_reply_goes_through_real_decoder() { + let mut value = input(); + installation(&mut value, HOME, "21.0.4"); + value["filesystem"].as_array_mut().unwrap().push(fact( + &format!("{WORKSPACE}/mise.toml"), + "file", + false, + )); + value["queries"] = json!([query(&value)]); + let result = resolve(&value); + assert_eq!(result["outcome"]["selection"]["home"], HOME); + assert_eq!(result["outcome"]["selection"]["layer"], "project"); + assert_eq!( + result["outcome"]["selection"]["provider"], + "dev.lithe.fixture.mise:mise" + ); + value["queries"][0]["response"]["result"]["operationID"] = json!("expired-query"); + let rejected = resolve_command(value).unwrap(); + assert_eq!(rejected["status"], "rejected"); + assert_eq!( + rejected["error"]["code"], + "plugin.toolchain.operationExpired" + ); +} + +#[test] +fn toolchain_resolution_query_cannot_replace_outer_generation_or_context_policy() { + let mut value = input(); + installation(&mut value, HOME, "21.0.4"); + value["filesystem"].as_array_mut().unwrap().push(fact( + &format!("{WORKSPACE}/mise.toml"), + "file", + false, + )); + value["queries"] = json!([query(&value)]); + value["generation"] = json!(2); + let rejected = resolve_command(value.clone()).unwrap(); + assert_eq!(rejected["status"], "rejected"); + assert_eq!(rejected["error"]["code"], "plugin.toolchain.invalidParams"); + value["generation"] = json!(1); + value["context"]["contextID"] = json!("replacement-context"); + let rejected = resolve_command(value).unwrap(); + assert_eq!(rejected["status"], "rejected"); + assert_eq!(rejected["error"]["code"], "plugin.toolchain.invalidParams"); +} + +#[test] +fn toolchain_resolution_conflicting_global_providers_follow_shell_not_plugin_id() { + let mut value = input(); + installation(&mut value, HOME, "21.0.4"); + installation(&mut value, OLD, "17.0.12"); + let mut second = manifest(&contract("mise")); + second["id"] = json!("dev.lithe.fixture.alternative"); + value["registry"]["plugins"] + .as_array_mut() + .unwrap() + .push(json!({"manifest":second,"verifiedOfficial":false})); + for source in [ + "/opt/lithe-fixtures/global/mise.toml", + "/opt/lithe-fixtures/alternative/mise.toml", + ] { + value["filesystem"] + .as_array_mut() + .unwrap() + .push(fact(source, "file", false)); + } + let mut first = query(&value); + first["response"]["result"]["statements"][0]["scope"] = json!("global"); + first["response"]["result"]["statements"][0]["source"] = + json!("/opt/lithe-fixtures/global/mise.toml"); + let mut second = first.clone(); + second["pluginID"] = json!("dev.lithe.fixture.alternative"); + second["response"]["result"]["installations"][0]["home"] = json!(OLD); + second["response"]["result"]["statements"][0]["installation"] = json!(OLD); + second["response"]["result"]["statements"][0]["source"] = + json!("/opt/lithe-fixtures/alternative/mise.toml"); + value["queries"] = json!([first, second]); + value["commandHomes"]["java"] = json!(HOME); + let result = resolve(&value); + assert_eq!(result["outcome"]["selection"]["provider"], "host:shell"); + assert_eq!(result["conflicts"][0]["scope"], "global"); + assert_eq!(result["conflicts"][0]["action"], "chooseProvider"); + value["providerChoices"]["global"] = json!("dev.lithe.fixture.alternative:mise"); + let chosen = resolve(&value); + assert_eq!(chosen["outcome"]["selection"]["home"], OLD); + assert_eq!(chosen["conflicts"], json!([])); +} + +#[test] +fn toolchain_commands_cross_the_json_dispatch_boundary() { + for (command, payload, key) in [ + ( + "toolchains.registry", + input()["registry"].clone(), + "registry", + ), + ( + "toolchains.resolvePins", + json!({"document":contract("local"),"directory":"apps/web/src","tool":"java"}), + "resolution", + ), + ( + "toolchains.checkVersion", + json!({"registry":input()["registry"],"tool":"java","output":"openjdk version \"21.0.4\""}), + "version", + ), + ( + "toolchains.discoveryPlan", + json!({"registry":input()["registry"],"platform":"macos"}), + "plan", + ), + ("toolchains.resolve", input(), "resolution"), + ("toolchains.candidates", listing(&input()), "candidates"), + ] { + let response: Value = serde_json::from_str(&crate::execute_json( + &json!({"id":"toolchains","command":command,"payload":payload}).to_string(), + )) + .unwrap(); + assert_eq!(response["ok"], true, "{response}"); + assert_eq!(response["data"]["status"], "ok", "{response}"); + assert!(response["data"].get(key).is_some(), "{response}"); + } +} + +#[test] +fn toolchain_resolution_automatic_uses_numeric_probe_not_display_order() { + let mut value = input(); + installation(&mut value, HOME, "21.0.4"); + installation(&mut value, OLD, "9.0.99"); + value["systemInstallations"] = json!([{"tool":"java","home":OLD},{"tool":"java","home":HOME}]); + let result = resolve(&value); + assert_eq!(result["outcome"]["selection"]["home"], HOME); + assert_eq!(result["outcome"]["selection"]["layer"], "automatic"); + value["minimumVersion"] = json!("22"); + assert_eq!(resolve(&value)["outcome"]["kind"], "skip"); +} + +#[test] +fn toolchain_resolution_equal_versions_are_ambiguous_and_manual_facts_not_automatic() { + let mut value = input(); + installation(&mut value, HOME, "21.0.4"); + installation(&mut value, OLD, "21.0.4"); + assert_eq!(resolve(&value)["outcome"]["kind"], "skip"); + value["systemInstallations"] = json!([{"tool":"java","home":OLD},{"tool":"java","home":HOME}]); + let result = resolve(&value); + assert_eq!(result["outcome"]["kind"], "skip"); + assert_eq!(result["conflicts"][0]["scope"], "automatic"); + assert_eq!(result["conflicts"][0]["action"], "chooseInstallation"); + assert_eq!( + result["conflicts"][0]["installations"] + .as_array() + .unwrap() + .len(), + 2 + ); +} + +#[test] +fn toolchain_resolution_version_capture_is_definition_driven_for_java8_and_custom_tool() { + let value = input(); + let checked=check_version_command(json!({"registry":value["registry"],"tool":"java","output":"java version \"1.8.0_422\"","minimumVersion":"8"})).unwrap(); + assert_eq!(checked["status"], "ok", "{checked}"); + assert_eq!(checked["version"]["version"], "8.0"); + assert_eq!(checked["version"]["satisfied"], true); + let mut registry = value["registry"].clone(); + registry["plugins"].as_array_mut().unwrap().truncate(1); + let definition = &mut registry["plugins"][0]["manifest"]["toolDefinitions"][0]; + definition["id"] = json!("dev.lithe.fixture.java-support:renderer"); + definition["version"]["pattern"] = json!("release=([0-9]+(?:\\.[0-9]+)*)"); + let checked=check_version_command(json!({"registry":registry,"tool":"dev.lithe.fixture.java-support:renderer","output":"render release=10.2","minimumVersion":"9.99"})).unwrap(); + assert_eq!(checked["status"], "ok", "{checked}"); + assert_eq!(checked["version"]["version"], "10.2"); + assert_eq!(checked["version"]["satisfied"], true); +} + +#[test] +fn toolchain_resolution_discovery_comes_only_from_enabled_definition_data() { + let mut value = input(); + let scans = json!([{"root":{"kind":"home"},"pattern":"runtimes/*.fixture","platforms":["macos"]},{"root":{"kind":"home"},"pattern":"other/*.fixture","platforms":["windows"]}]); + value["registry"]["plugins"][0]["manifest"]["toolDefinitions"][0]["discovery"] = scans.clone(); + let result = + discovery_plan_command(json!({"registry":value["registry"],"platform":"macos"})).unwrap(); + assert_eq!(result["status"], "ok", "{result}"); + assert_eq!(result["plan"]["tools"][0]["scans"], json!([scans[0]])); + assert_eq!( + result["plan"]["tools"][0]["providers"], + json!(["dev.lithe.fixture.mise:mise"]) + ); + value["registry"]["plugins"] + .as_array_mut() + .unwrap() + .remove(0); + let result = + discovery_plan_command(json!({"registry":value["registry"],"platform":"macos"})).unwrap(); + assert_eq!(result["plan"]["tools"], json!([])); +} + +#[test] +fn toolchain_discovery_plan_carries_path_entries_and_platform_home_variables() { + let result = + discovery_plan_command(json!({"registry":input()["registry"],"platform":"macos"})).unwrap(); + let tool = &result["plan"]["tools"][0]; + assert_eq!(tool["pathPrepend"], json!(["${home}/bin"])); + assert_eq!( + tool["environment"], + json!([{"variable":"JAVA_HOME","path":"bin/java"}]) + ); + let result = + discovery_plan_command(json!({"registry":input()["registry"],"platform":"windows"})) + .unwrap(); + assert_eq!( + result["plan"]["tools"][0]["environment"], + json!([{"variable":"JAVA_HOME","path":"bin/java.exe"}]) + ); +} + +/// Narrows a resolver input to the settings listing's directory-free fields. +fn listing(input: &Value) -> Value { + let mut value = json!({"tool":"java"}); + for key in [ + "registry", + "platform", + "workspaceRoot", + "filesystem", + "probes", + "systemInstallations", + "environment", + ] { + value[key] = input[key].clone(); + } + if let Some(reported) = input.get("providerInstallations") { + value["providerInstallations"] = reported.clone(); + } + value +} + +fn candidates(input: &Value) -> Value { + let result = candidates_command(listing(input)).unwrap(); + assert_eq!(result["status"], "ok", "{result}"); + result["candidates"].clone() +} + +#[test] +fn toolchain_candidates_return_probe_targets_before_versions_are_known() { + let mut value = input(); + installation(&mut value, HOME, "21.0.4"); + installation(&mut value, OLD, "17.0.12"); + value["systemInstallations"] = json!([{"tool":"java","home":OLD},{"tool":"java","home":HOME}, + {"tool":"java","home":"/opt/lithe-fixtures/missing"}]); + value["environment"]["JAVA_HOME"] = json!(OLD); + value["probes"] = json!([]); + let listed = candidates(&value); + assert_eq!(listed["installations"], json!([])); + assert_eq!(listed["selected"], Value::Null); + let skipped = listed["skipped"].as_array().unwrap(); + assert_eq!(skipped.len(), 3); + let probe_targets: Vec<_> = skipped + .iter() + .filter(|row| row["reason"] == "probeFailed") + .map(|row| row["executable"].clone()) + .collect(); + assert_eq!( + probe_targets, + vec![ + json!(format!("{OLD}/bin/java")), + json!(format!("{HOME}/bin/java")) + ] + ); + let missing = skipped + .iter() + .find(|row| row["reason"] == "unsafePath") + .unwrap(); + assert!(missing.get("executable").is_none()); + assert_eq!(listed["shell"][0]["executable"], format!("{OLD}/bin/java")); + assert_eq!(listed["shell"][0]["reason"], "probeFailed"); +} + +#[test] +fn toolchain_candidates_list_versions_and_share_the_resolver_choice() { + let mut value = input(); + installation(&mut value, HOME, "21.0.4"); + installation(&mut value, OLD, "17.0.12"); + value["systemInstallations"] = json!([{"tool":"java","home":OLD},{"tool":"java","home":HOME}]); + value["environment"]["JAVA_HOME"] = json!(OLD); + let listed = candidates(&value); + assert_eq!( + listed["installations"], + json!([ + {"home":HOME,"executable":format!("{HOME}/bin/java"),"version":"21.0.4","provider":"host:system"}, + {"home":OLD,"executable":format!("{OLD}/bin/java"),"version":"17.0.12","provider":"host:system"} + ]) + ); + assert_eq!(listed["selected"], HOME); + assert_eq!(listed["skipped"], json!([])); + assert_eq!( + listed["shell"], + json!([{"candidateID":"home","variable":"JAVA_HOME","home":OLD, + "executable":format!("{OLD}/bin/java"),"version":"17.0.12"}]) + ); + // The login shell wins in resolution; the listing still reports the automatic choice. + assert_eq!(resolve(&value)["outcome"]["selection"]["home"], OLD); + value["environment"] = json!({}); + assert_eq!(resolve(&value)["outcome"]["selection"]["home"], HOME); +} + +#[test] +fn toolchain_candidates_keep_provider_attribution_and_count_one_home_once() { + let mut value = input(); + self_provided(&mut value); + installation(&mut value, HOME, "21.0.4"); + installation(&mut value, OLD, "17.0.12"); + let provider = "dev.lithe.fixture.java-support:installs"; + value["providerInstallations"] = json!([{"tool":"java","home":HOME,"provider":provider}]); + value["systemInstallations"] = json!([{"tool":"java","home":HOME},{"tool":"java","home":OLD}]); + let listed = candidates(&value); + let installations = listed["installations"].as_array().unwrap(); + assert_eq!(installations.len(), 2); + assert_eq!(installations[0]["home"], HOME); + assert_eq!(installations[0]["provider"], provider); + assert_eq!(installations[1]["provider"], "host:system"); + assert_eq!(listed["selected"], HOME); +} + +#[test] +fn toolchain_candidates_refuse_a_provider_outside_the_registry() { + let mut value = input(); + self_provided(&mut value); + for provider in ["dev.example.other:installs", "installs", "host:system"] { + value["providerInstallations"] = json!([{"tool":"java","home":HOME,"provider":provider}]); + let result = candidates_command(listing(&value)).unwrap(); + assert_eq!(result["status"], "rejected", "{provider}"); + } +} + +#[test] +fn toolchain_candidates_report_equal_versions_as_a_conflict() { + let mut value = input(); + installation(&mut value, HOME, "21.0.4"); + installation(&mut value, OLD, "21.0.4"); + value["systemInstallations"] = json!([{"tool":"java","home":OLD},{"tool":"java","home":HOME}]); + let listed = candidates(&value); + assert_eq!(listed["selected"], Value::Null); + assert_eq!(listed["conflicts"][0]["action"], "chooseInstallation"); + assert_eq!(listed["installations"].as_array().unwrap().len(), 2); + let mut unknown = listing(&value); + unknown["tool"] = json!("dev.lithe.fixture.java-support:missing"); + assert_eq!(candidates_command(unknown).unwrap()["status"], "rejected"); + let mut directory = listing(&value); + directory["directory"] = json!("."); + assert_eq!(candidates_command(directory).unwrap()["status"], "rejected"); +} + +/// Turns the definition plugin into its own installation-only provider, without scans. +fn self_provided(input: &mut Value) { + let manifest = &mut input["registry"]["plugins"][0]["manifest"]; + manifest["toolDefinitions"][0] + .as_object_mut() + .unwrap() + .remove("discovery"); + manifest["requires"] = json!({"toolDefinitions":1,"executableResolution":2, + "toolchainProviders":1,"backend":2,"serverProcess":1}); + manifest["permissions"] = json!(["backend.launch", "toolchains.provide"]); + manifest["toolchainProviders"] = + json!([{"id":"installs","displayName":"Installed releases","tools":["java"]}]); + manifest["backend"] = json!({"launch":{"kind":"packaged","path":"bin/installs"}}); + input["registry"]["plugins"] + .as_array_mut() + .unwrap() + .truncate(1); +} + +/// Captures an installation-only answer from the self-provider for `homes`. +fn installs_query(input: &Value, homes: &[(&str, &str)]) -> Value { + let mut value = query(input); + value["pluginID"] = json!("dev.lithe.fixture.java-support"); + value["request"]["params"]["providerID"] = json!("installs"); + let result = &mut value["response"]["result"]; + result["providerID"] = json!("installs"); + result["installations"] = json!(homes + .iter() + .map(|(home, version)| json!({"tool":"java","version":version,"home":home})) + .collect::>()); + result["statements"] = json!([]); + result["watch"] = json!([]); + value +} + +/// A provider may name a version manager's alias link. The host reads only the paths the +/// reply and definition name, under the link; Core derives the canonical target facts. +#[test] +fn toolchain_resolution_accepts_a_provider_home_reached_through_an_alias_link() { + let mut value = input(); + self_provided(&mut value); + installation(&mut value, HOME, "21.0.4"); + let alias = "/opt/lithe-fixtures/installs/java/latest"; + let facts = value["filesystem"].as_array_mut().unwrap(); + facts.retain(|fact| !fact["path"].as_str().unwrap().starts_with(HOME)); + facts.push(json!({"path":alias,"canonicalPath":HOME,"kind":"directory"})); + facts.push(json!({"path":format!("{alias}/bin"),"canonicalPath":format!("{HOME}/bin"),"kind":"directory"})); + facts.push( + json!({"path":format!("{alias}/bin/java"),"canonicalPath":format!("{HOME}/bin/java"), + "kind":"file","executable":true}), + ); + value["queries"] = json!([installs_query(&value, &[(alias, "21.0.4")])]); + let result = resolve(&value); + assert_eq!(result["outcome"]["selection"]["home"], HOME, "{result}"); + assert_eq!( + result["outcome"]["selection"]["executable"], + format!("{HOME}/bin/java") + ); +} + +#[test] +fn toolchain_resolution_definition_plugin_can_be_its_own_installation_provider() { + let mut value = input(); + self_provided(&mut value); + installation(&mut value, HOME, "21.0.4"); + let plan = + discovery_plan_command(json!({"registry":value["registry"],"platform":"macos"})).unwrap(); + assert_eq!(plan["plan"]["tools"][0]["scans"], json!([])); + assert_eq!( + plan["plan"]["tools"][0]["providers"], + json!(["dev.lithe.fixture.java-support:installs"]) + ); + value["queries"] = json!([installs_query(&value, &[(HOME, "21.0.4")])]); + let result = resolve(&value); + assert_eq!(result["outcome"]["selection"]["home"], HOME); + assert_eq!(result["outcome"]["selection"]["layer"], "automatic"); + assert_eq!( + result["outcome"]["selection"]["provider"], + "dev.lithe.fixture.java-support:installs" + ); + // Without watched files no reader can anchor a requirement statement. + value["filesystem"].as_array_mut().unwrap().push(fact( + &format!("{WORKSPACE}/mise.toml"), + "file", + false, + )); + value["queries"][0]["filesystem"] = value["filesystem"].clone(); + value["queries"][0]["response"]["result"]["statements"] = + query(&value)["response"]["result"]["statements"].clone(); + let rejected = resolve_command(value).unwrap(); + assert_eq!(rejected["status"], "rejected"); + assert_eq!(rejected["error"]["code"], "plugin.toolchain.invalidReply"); + assert_eq!( + rejected["error"]["field"], + "response.result.statements.reader" + ); +} + +#[test] +fn toolchain_resolution_scan_and_provider_routes_merge_by_canonical_home() { + let mut value = input(); + self_provided(&mut value); + installation(&mut value, HOME, "21.0.4"); + installation(&mut value, OLD, "21.0.4"); + value["queries"] = json!([installs_query(&value, &[(HOME, "21.0.4")])]); + value["systemInstallations"] = json!([{"tool":"java","home":HOME}]); + let result = resolve(&value); + assert_eq!(result["outcome"]["selection"]["home"], HOME); + assert_eq!( + result["outcome"]["selection"]["provider"], + "dev.lithe.fixture.java-support:installs" + ); + assert_eq!(result["conflicts"], json!([])); + value["systemInstallations"] = json!([{"tool":"java","home":OLD},{"tool":"java","home":HOME}]); + let result = resolve(&value); + assert_eq!(result["outcome"]["kind"], "skip"); + assert_eq!( + result["conflicts"][0]["providers"], + json!(["dev.lithe.fixture.java-support:installs", "host:system"]) + ); + value["providerChoices"]["automatic"] = json!(OLD); + let chosen = resolve(&value); + assert_eq!(chosen["outcome"]["selection"]["home"], OLD); + assert_eq!(chosen["outcome"]["selection"]["provider"], "host:system"); +} + +#[test] +fn toolchain_resolution_linked_provider_home_resolves_against_the_same_snapshot() { + let mut value = input(); + self_provided(&mut value); + let link = "/opt/lithe-fixtures/installs/java/latest"; + let alias = |path: &str, target: &str, kind: &str, executable: bool| json!({"path":path,"canonicalPath":target,"kind":kind,"executable":executable}); + // Only the link spellings are observed; the resolved target has no fact of its own. + value["filesystem"].as_array_mut().unwrap().extend([ + alias(link, HOME, "directory", false), + alias( + &format!("{link}/bin"), + &format!("{HOME}/bin"), + "directory", + false, + ), + alias( + &format!("{link}/bin/java"), + &format!("{HOME}/bin/java"), + "file", + true, + ), + ]); + value["probes"] = json!([{"executable":format!("{HOME}/bin/java"),"output":"openjdk version \"21.0.4\"","exitCode":0}]); + value["queries"] = json!([installs_query(&value, &[(link, "21.0.4")])]); + let result = resolve(&value); + let selection = &result["outcome"]["selection"]; + assert_eq!(selection["home"], HOME); + assert_eq!(selection["executable"], format!("{HOME}/bin/java")); + assert_eq!(selection["pathPrepend"], json!([format!("{HOME}/bin")])); + // Facts that disagree about the resolved target are not reconciled. + value["filesystem"] + .as_array_mut() + .unwrap() + .push(fact(HOME, "file", false)); + value["queries"][0]["filesystem"] = value["filesystem"].clone(); + assert_eq!(resolve_command(value).unwrap()["status"], "rejected"); +} + +#[test] +fn toolchain_resolution_environment_candidate_uses_its_declared_layout() { + let mut value = input(); + let definition = &mut value["registry"]["plugins"][0]["manifest"]["toolDefinitions"][0]; + definition["executableLayouts"] + .as_array_mut() + .unwrap() + .push(json!({"path":"alt/bin/java","platforms":["macos","linux"]})); + definition["executables"][4]["path"] = json!("alt/bin/java"); + installation(&mut value, HOME, "21.0.4"); + value["filesystem"].as_array_mut().unwrap().extend([ + fact(&format!("{HOME}/alt"), "directory", false), + fact(&format!("{HOME}/alt/bin"), "directory", false), + fact(&format!("{HOME}/alt/bin/java"), "file", true), + ]); + value["probes"].as_array_mut().unwrap().push(json!({"executable":format!("{HOME}/alt/bin/java"),"output":"openjdk version \"17.0.12\"","exitCode":0})); + value["environment"]["JAVA_HOME"] = json!(HOME); + let result = resolve(&value); + let selection = &result["outcome"]["selection"]; + assert_eq!(selection["provider"], "host:shell"); + assert_eq!(selection["executable"], format!("{HOME}/alt/bin/java")); + assert_eq!(selection["version"], "17.0.12"); +} + +#[test] +fn toolchain_resolution_disabled_definition_cannot_reactivate_a_saved_pin() { + let mut value = input(); + installation(&mut value, HOME, "21.0.4"); + value["local"] = contract("local"); + value["registry"]["plugins"] + .as_array_mut() + .unwrap() + .remove(0); + value["commandHomes"]["java"] = json!(HOME); + let before = value["local"].clone(); + let result = resolve(&value); + assert_eq!(result["outcome"]["kind"], "skip"); + assert_eq!(result["outcome"]["reason"]["code"], "definitionUnavailable"); + assert!(result["outcome"].get("selection").is_none()); + assert_eq!(value["local"], before); +} + +#[test] +fn toolchain_resolution_nearest_unmet_project_source_masks_parent_requirement() { + let mut value = input(); + installation(&mut value, HOME, "21.0.4"); + installation(&mut value, OLD, "17.0.12"); + value["directory"] = json!("apps/web"); + for (path, kind) in [ + (format!("{WORKSPACE}/apps/web"), "directory"), + (format!("{WORKSPACE}/apps/web/mise.toml"), "file"), + (format!("{WORKSPACE}/mise.toml"), "file"), + ] { + value["filesystem"] + .as_array_mut() + .unwrap() + .push(fact(&path, kind, false)); + } + let mut backend = query(&value); + backend["request"]["params"]["workspace"]["directory"] = json!("apps/web"); + backend["response"]["result"]["statements"][0]["installation"] = json!(OLD); + backend["response"]["result"]["installations"][0]["home"] = json!(OLD); + backend["response"]["result"]["statements"] + .as_array_mut() + .unwrap() + .push(json!({"tool":"java","scope":"project","source":format!("{WORKSPACE}/apps/web/mise.toml"), + "request":"99","installation":null,"kind":"declaration","reader":"native"})); + value["queries"] = json!([backend]); + value["commandHomes"]["java"] = json!(HOME); + let result = resolve(&value); + assert_eq!(result["outcome"]["selection"]["home"], HOME); + assert_eq!(result["outcome"]["selection"]["layer"], "global"); + assert!(result["unmet"] + .as_array() + .unwrap() + .iter() + .any(|reason| reason["request"] == "99")); +} + +#[test] +fn toolchain_resolution_definition_cannot_omit_away_an_explicit_invalid_pin() { + let mut value = input(); + value["registry"]["plugins"][0]["manifest"]["toolDefinitions"][0]["executables"] + .as_array_mut() + .unwrap() + .remove(0); + installation(&mut value, HOME, "21.0.4"); + value["local"] = json!({"version":1,"toolchains":{},"pins":{".":{"java":{ + "home":"/opt/lithe-fixtures/unavailable","version":"99","provider":"host:manual"}}}}); + value["commandHomes"]["java"] = json!(HOME); + let result = resolve(&value); + assert_eq!(result["outcome"]["kind"], "terminalFailure"); + assert!(result["outcome"].get("selection").is_none()); +} + +#[test] +fn toolchain_resolution_wrapper_requires_action_and_never_invents_installation_home() { + let mut value = input(); + let definition = &mut value["registry"]["plugins"][0]["manifest"]["toolDefinitions"][0]; + definition["wrappers"] = json!([{"path":"fixture-wrapper","platforms":["macos"]}]); + definition["executables"].as_array_mut().unwrap().insert( + 1, + json!({"id":"wrapper","kind":"projectFile","path":"fixture-wrapper","platforms":["macos"]}), + ); + let wrapper = format!("{WORKSPACE}/fixture-wrapper"); + value["filesystem"] + .as_array_mut() + .unwrap() + .push(fact(&wrapper, "file", true)); + let blocked = resolve(&value); + assert_eq!(blocked["outcome"]["kind"], "skip"); + assert!(blocked["trace"] + .as_array() + .unwrap() + .iter() + .any(|row| row["reason"]["code"] == "projectCodeRequiresAction")); + // No probe observation is supplied: passive resolution cannot require running it. + assert_eq!(value["probes"], json!([])); + value["projectCodeAllowed"] = json!(true); + let allowed = resolve(&value); + let selection = &allowed["outcome"]["selection"]; + assert_eq!(allowed["outcome"]["kind"], "resolved"); + assert_eq!(selection["home"], Value::Null); + assert_eq!(selection["version"], Value::Null); + assert_eq!(selection["projectCode"], true); + assert!(selection["environment"].get("JAVA_HOME").is_none()); + assert_eq!(selection["environment"]["LITHE_JAVA_PATH"], wrapper); + assert_eq!(selection["pathPrepend"], json!([])); +} + +#[test] +fn toolchain_resolution_project_environment_home_cannot_symlink_out_of_workspace() { + let mut value = input(); + let definition = &mut value["registry"]["plugins"][0]["manifest"]["toolDefinitions"][0]; + definition["executables"].as_array_mut().unwrap().insert( + 1, + json!({"id":"project-env","kind":"projectFile","projectKind":"environment","home":".venv","path":".venv/bin/java","platforms":["macos","linux"]}), + ); + let home = format!("{WORKSPACE}/.venv"); + let executable = format!("{home}/bin/java"); + value["filesystem"].as_array_mut().unwrap().extend([ + json!({"path":home,"canonicalPath":"/opt/lithe-fixtures/outside-environment","kind":"directory","executable":false}), + fact(&executable,"file",true), + ]); + value["projectCodeAllowed"] = json!(true); + let result = resolve(&value); + assert_eq!(result["outcome"]["kind"], "skip"); + assert!(result["outcome"].get("selection").is_none()); + assert!(result["trace"] + .as_array() + .unwrap() + .iter() + .any(|row| row["reason"]["code"] == "unsafePath")); +} diff --git a/rust/lithe-core/src/toolchain/definitions.rs b/rust/lithe-core/src/toolchain/definitions.rs new file mode 100644 index 0000000000..c5d443c373 --- /dev/null +++ b/rust/lithe-core/src/toolchain/definitions.rs @@ -0,0 +1,793 @@ +//! Validates plugin-owned tool policies and constructs an origin-checked registry. +//! Filesystem evidence and native execution remain host-owned responsibilities. + +use crate::plugins::declarative::{invalid, validate_manifest_value, UIIcon, Validation}; +use crate::plugins::executables::{ordered_indices, ExecutableCandidate}; +use regex::Regex; +use serde_json::{json, Map, Value}; +use std::collections::BTreeSet; + +/// Validates a short tool ID or a plugin-qualified tool ID, without granting ownership. +pub(crate) fn tool_id(value: &str) -> bool { + let local = |s: &str| { + s.len() <= 64 + && s.as_bytes().first().is_some_and(u8::is_ascii_lowercase) + && s.bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') + }; + value.len() <= 192 + && match value.split_once(':') { + Some((plugin, id)) => { + !id.contains(':') + && !plugin.is_empty() + && plugin.split(['.', '-']).all(|part| { + !part.is_empty() + && part + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()) + }) + && local(id) + } + None => local(value), + } +} + +/// Checks declared export names; native process-environment filtering still applies. +pub(crate) fn safe_environment_name(value: &str) -> bool { + !value.is_empty() + && value.len() <= 64 + && value != "PATH" + && !value.starts_with("DYLD_") + && !value.starts_with("LD_") + && (value.as_bytes()[0].is_ascii_uppercase() || value.as_bytes()[0] == b'_') + && value + .bytes() + .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'_') +} + +fn object<'a>( + value: &'a Value, + fields: &[&str], + required: &[&str], + field: &str, +) -> Validation<&'a Map> { + let map = value + .as_object() + .ok_or_else(|| invalid(field, "An object is required."))?; + if map.keys().any(|key| !fields.contains(&key.as_str())) + || required.iter().any(|key| !map.contains_key(*key)) + { + return Err(invalid( + field, + "The object contains unknown fields or omits required fields.", + )); + } + Ok(map) +} + +fn text<'a>(value: &'a Value, max: usize, field: &str) -> Validation<&'a str> { + value + .as_str() + .filter(|s| !s.is_empty() && s.len() <= max && !s.chars().any(char::is_control)) + .ok_or_else(|| { + invalid( + field, + "A bounded nonempty string without controls is required.", + ) + }) +} + +fn list<'a>(value: &'a Value, min: usize, max: usize, field: &str) -> Validation<&'a [Value]> { + value + .as_array() + .filter(|v| (min..=max).contains(&v.len())) + .map(Vec::as_slice) + .ok_or_else(|| invalid(field, "The array exceeds its declared bounds.")) +} + +fn optional_list<'a>(value: &'a Value, key: &str, max: usize) -> Validation<&'a [Value]> { + match value.get(key) { + Some(value) => list(value, 0, max, key), + None => Ok(&[]), + } +} + +fn relative(value: &str, glob: bool) -> bool { + !value.is_empty() + && value.len() <= 256 + && !value.chars().any(char::is_control) + && !value.contains(['\\', ':', '?', '[', ']', '{', '}']) + && (glob || !value.contains('*')) + && !value.contains("**") + && value + .split('/') + .all(|part| !part.is_empty() && part != "." && part != "..") + && (!glob || value.split('/').count() <= 8) +} + +fn path(value: &Value, glob: bool, field: &str) -> Validation<()> { + if !relative(text(value, 256, field)?, glob) { + return Err(invalid( + field, + "A bounded relative path without traversal is required.", + )); + } + Ok(()) +} + +fn platforms(value: &Value) -> Validation> { + let Some(items) = value.get("platforms") else { + return Ok(["macos", "linux", "windows"].map(str::to_owned).into()); + }; + let mut result = BTreeSet::new(); + for item in list(items, 1, 3, "platforms")? { + let name = text(item, 16, "platforms")?; + if !["macos", "windows", "linux"].contains(&name) || !result.insert(name.to_owned()) { + return Err(invalid( + "platforms", + "Platforms must be unique supported operating systems.", + )); + } + } + Ok(result) +} + +fn strings( + value: &Value, + min: usize, + max: usize, + field: &str, + check: impl Fn(&str) -> bool, +) -> Validation<()> { + let mut seen = BTreeSet::new(); + for item in list(value, min, max, field)? { + let item = text(item, 256, field)?; + if !check(item) || !seen.insert(item) { + return Err(invalid(field, "Values must be valid and unique.")); + } + } + Ok(()) +} + +fn command(value: &str) -> bool { + value.len() <= 64 + && value + .as_bytes() + .first() + .is_some_and(u8::is_ascii_alphanumeric) + && value + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b)) +} + +fn same_platforms(left: &Value, right: &Value) -> Validation { + Ok(platforms(left)?.is_subset(&platforms(right)?)) +} + +fn matching_wrapper(candidate: &Value, wrapper: &Value) -> Validation { + Ok(candidate["path"] == wrapper["path"] + && candidate.get("requires").unwrap_or(&json!([])) + == wrapper.get("requires").unwrap_or(&json!([])) + && same_platforms(candidate, wrapper)?) +} + +fn rank(candidate: &Value) -> Validation { + match candidate["kind"].as_str() { + Some("setting") => match candidate["layer"].as_str() { + Some("project") => Ok(0), + Some("global") => Ok(2), + _ => Err(invalid( + "executables.layer", + "A setting layer is project or global.", + )), + }, + Some("projectFile") => Ok(1), + Some("toolchain") => match candidate["scope"].as_str() { + Some("project") => Ok(1), + Some("global") => Ok(3), + Some("installed") => Ok(5), + _ => Err(invalid( + "executables.scope", + "A toolchain scope is project, global or installed.", + )), + }, + Some("environment" | "command") => Ok(4), + _ => Err(invalid( + "executables.kind", + "This tool source is not supported.", + )), + } +} + +fn candidates(definition: &Value) -> Validation> { + let values = list(&definition["executables"], 1, 32, "executables")?; + let mut metadata = Vec::new(); + for value in values { + let kind = text(&value["kind"], 32, "executables.kind")?; + let mut allowed = vec![ + "kind", + "id", + "priority", + "insertBefore", + "insertAfter", + "platforms", + ]; + let required: &[&str] = match kind { + "setting" => &["kind", "settingID", "layer"], + "toolchain" => &["kind", "scope"], + "projectFile" => &["kind", "path"], + "environment" => &["kind", "variable", "path"], + "command" => &["kind", "command"], + // Dependent tool source graphs require the consumer adapter, not this Core slice. + _ => { + return Err(invalid( + "executables.kind", + "This candidate source is not implemented for tool definitions.", + )) + } + }; + allowed.extend_from_slice(required); + if kind == "projectFile" { + allowed.extend(["requires", "projectKind", "home"]); + } + object(value, &allowed, required, "executables")?; + for key in ["id", "insertBefore", "insertAfter"] { + if let Some(id) = value.get(key) { + if !text(id, 64, key).is_ok_and(|id| tool_id(id) && !id.contains(':')) { + return Err(invalid( + "executables", + "Candidate IDs and anchors must be bounded local identifiers.", + )); + } + } + } + platforms(value)?; + match kind { + "setting" if value["settingID"] != "executable" => { + return Err(invalid( + "executables.settingID", + "Tool settings use the framework executable slot.", + )) + } + "projectFile" => { + path(&value["path"], false, "executables.path")?; + if value + .get("projectKind") + .is_some_and(|kind| !kind.is_string()) + { + return Err(invalid( + "executables.projectKind", + "The project source category must be a string.", + )); + } + if let Some(markers) = value.get("requires") { + strings(markers, 0, 8, "executables.requires", |s| { + relative(s, false) + })?; + } + match value + .get("projectKind") + .and_then(Value::as_str) + .unwrap_or("wrapper") + { + "wrapper" => { + if value.get("home").is_some() { + return Err(invalid( + "executables.home", + "An executable-only wrapper cannot claim a home.", + )); + } + let wrappers = optional_list(definition, "wrappers", 8)?; + if !wrappers + .iter() + .any(|wrapper| matching_wrapper(value, wrapper).unwrap_or(false)) + { + return Err(invalid( + "executables", + "A wrapper candidate must match its declared wrapper metadata.", + )); + } + } + "environment" => { + let home = text(&value["home"], 256, "executables.home")?; + if home != "." && !relative(home, false) { + return Err(invalid( + "executables.home", + "A project environment home must be relative.", + )); + } + let layouts = + list(&definition["executableLayouts"], 1, 8, "executableLayouts")?; + if !layouts.iter().any(|layout| { + let expected = if home == "." { + layout["path"].as_str().unwrap_or("").to_owned() + } else { + format!("{home}/{}", layout["path"].as_str().unwrap_or("")) + }; + value["path"].as_str() == Some(expected.as_str()) + && same_platforms(value, layout).unwrap_or(false) + }) { + return Err(invalid("executables.home", "A project environment executable must match its declared home layout.")); + } + } + _ => { + return Err(invalid( + "executables.projectKind", + "Project sources are wrappers or environments.", + )) + } + } + } + "environment" => { + path(&value["path"], false, "executables.path")?; + if value["variable"] != definition["homeVariable"] + || !value["variable"] + .as_str() + .is_some_and(safe_environment_name) + || !definition["executableLayouts"] + .as_array() + .is_some_and(|layouts| { + layouts.iter().any(|layout| { + layout["path"] == value["path"] + && same_platforms(value, layout).unwrap_or(false) + }) + }) + { + return Err(invalid("executables", "A shell environment candidate must match the home variable and executable layout.")); + } + } + "command" => { + if !definition["commands"] + .as_array() + .is_some_and(|commands| commands.contains(&value["command"])) + { + return Err(invalid( + "executables.command", + "A command must belong to its tool definition.", + )); + } + } + _ => (), + } + // Reuse the shared graph validator without admitting new source fields to legacy sets. + let mut projected = value.as_object().expect("validated object").clone(); + projected.retain(|key, _| { + ["id", "priority", "insertBefore", "insertAfter", "platforms"].contains(&key.as_str()) + }); + projected.insert("kind".into(), json!("command")); + projected.insert("command".into(), json!("toolchain-metadata")); + metadata.push( + serde_json::from_value::(Value::Object(projected)) + .map_err(|_| invalid("executables", "Candidate ordering metadata is invalid."))?, + ); + rank(value)?; + } + let indices = ordered_indices(&metadata, "executables")?; + if indices + .windows(2) + .any(|pair| rank(&values[pair[0]]).unwrap_or(255) > rank(&values[pair[1]]).unwrap_or(0)) + { + return Err(invalid( + "executables", + "Candidate ordering cannot cross the fixed four-layer boundaries.", + )); + } + for wrapper in optional_list(definition, "wrappers", 8)? { + let mut covered = BTreeSet::new(); + for candidate in values { + if candidate["kind"] == "projectFile" + && candidate + .get("projectKind") + .and_then(Value::as_str) + .unwrap_or("wrapper") + == "wrapper" + && matching_wrapper(candidate, wrapper)? + { + covered.extend(platforms(candidate)?); + } + } + if !platforms(wrapper)?.is_subset(&covered) { + return Err(invalid( + "wrappers", + "Every wrapper needs a matching resolver candidate.", + )); + } + } + for cmd in list(&definition["commands"], 1, 8, "commands")? { + if !values + .iter() + .any(|candidate| candidate["kind"] == "command" && candidate["command"] == *cmd) + { + return Err(invalid( + "commands", + "Every shell command needs a resolver candidate.", + )); + } + } + if definition.get("homeVariable").is_some() + && !values + .iter() + .any(|candidate| candidate["kind"] == "environment") + { + return Err(invalid( + "homeVariable", + "The home variable needs a resolver candidate.", + )); + } + Ok(indices) +} + +fn definition(plugin_id: &str, value: &Value) -> Validation<()> { + object( + value, + &[ + "id", + "displayName", + "icon", + "executableLayouts", + "executables", + "version", + "homeLayouts", + "discovery", + "homeVariable", + "commands", + "wrappers", + "exports", + "pathPrepend", + "launch", + ], + &[ + "id", + "displayName", + "executableLayouts", + "executables", + "version", + "commands", + "launch", + ], + "toolDefinitions", + )?; + let id = text(&value["id"], 192, "toolDefinitions.id")?; + if !tool_id(id) + || id + .split_once(':') + .is_some_and(|(owner, _)| owner != plugin_id) + { + return Err(invalid( + "toolDefinitions.id", + "A tool ID must be short or owned by this plugin's namespace.", + )); + } + text(&value["displayName"], 64, "toolDefinitions.displayName")?; + if let Some(icon) = value.get("icon") { + let icon: UIIcon = serde_json::from_value(icon.clone()) + .map_err(|_| invalid("toolDefinitions.icon", "The icon shape is invalid."))?; + icon.validate_at("toolDefinitions.icon")?; + } + for layout in list(&value["executableLayouts"], 1, 8, "executableLayouts")? { + object( + layout, + &["path", "platforms"], + &["path"], + "executableLayouts", + )?; + path(&layout["path"], false, "executableLayouts.path")?; + platforms(layout)?; + } + for layout in optional_list(value, "homeLayouts", 8)? { + object( + layout, + &["match", "home", "platforms"], + &["match", "home"], + "homeLayouts", + )?; + path(&layout["match"], true, "homeLayouts.match")?; + if layout["match"].as_str().is_some_and(|s| s.contains('/')) { + return Err(invalid( + "homeLayouts.match", + "A home layout matches one directory name.", + )); + } + if layout["home"] != "." { + path(&layout["home"], false, "homeLayouts.home")?; + } + platforms(layout)?; + } + for scan in optional_list(value, "discovery", 16)? { + object( + scan, + &["root", "pattern", "platforms"], + &["root", "pattern"], + "discovery", + )?; + path(&scan["pattern"], true, "discovery.pattern")?; + platforms(scan)?; + let root = &scan["root"]; + match root["kind"].as_str() { + Some("home") => { + object(root, &["kind"], &["kind"], "discovery.root")?; + } + Some("environment") => { + object( + root, + &["kind", "variable"], + &["kind", "variable"], + "discovery.root", + )?; + if !text(&root["variable"], 64, "discovery.root.variable") + .is_ok_and(safe_environment_name) + { + return Err(invalid( + "discovery.root.variable", + "The environment variable is unsafe.", + )); + } + } + Some("absolute") => { + object(root, &["kind", "path"], &["kind", "path"], "discovery.root")?; + let root = text(&root["path"], 256, "discovery.root.path")?; + if !(root.starts_with('/') + || (root.len() >= 3 + && root.as_bytes()[0].is_ascii_alphabetic() + && root.as_bytes()[1] == b':' + && matches!(root.as_bytes()[2], b'/' | b'\\'))) + || root + .split(['/', '\\']) + .any(|part| part == "." || part == "..") + { + return Err(invalid( + "discovery.root.path", + "Discovery roots must be normalized absolute paths.", + )); + } + } + _ => return Err(invalid("discovery.root", "Unknown discovery root kind.")), + } + } + strings(&value["commands"], 1, 8, "commands", command)?; + if let Some(variable) = value.get("homeVariable") { + if !text(variable, 64, "homeVariable").is_ok_and(safe_environment_name) { + return Err(invalid("homeVariable", "The home variable is unsafe.")); + } + } + for wrapper in optional_list(value, "wrappers", 8)? { + object( + wrapper, + &["path", "requires", "platforms"], + &["path"], + "wrappers", + )?; + path(&wrapper["path"], false, "wrappers.path")?; + platforms(wrapper)?; + if let Some(markers) = wrapper.get("requires") { + strings(markers, 0, 8, "wrappers.requires", |s| relative(s, false))?; + } + } + let version = &value["version"]; + object( + version, + &["arguments", "pattern"], + &["arguments", "pattern"], + "version", + )?; + strings(&version["arguments"], 1, 8, "version.arguments", |arg| { + let flag = arg + .strip_prefix("--") + .or_else(|| arg.strip_prefix('-')) + .unwrap_or(""); + arg.len() <= 64 + && flag.as_bytes().first().is_some_and(u8::is_ascii_alphabetic) + && flag.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') + })?; + let pattern = text(&version["pattern"], 512, "version.pattern")?; + if Regex::new(pattern).map_or(true, |regex| regex.captures_len() < 2) { + return Err(invalid( + "version.pattern", + "A valid bounded version expression needs a first capture.", + )); + } + let mut export_names = BTreeSet::new(); + for export in optional_list(value, "exports", 16)? { + object(export, &["name", "value"], &["name", "value"], "exports")?; + let name = text(&export["name"], 64, "exports.name")?; + if !safe_environment_name(name) + || !export_names.insert(name) + || ![json!("${home}"), json!("${executable}")].contains(&export["value"]) + { + return Err(invalid( + "exports", + "Export names and templates must be safe and unique.", + )); + } + } + if let Some(prepend) = value.get("pathPrepend") { + strings(prepend, 0, 8, "pathPrepend", |s| { + s == "${home}" + || s.strip_prefix("${home}/") + .is_some_and(|suffix| relative(suffix, false) && !suffix.contains('$')) + })?; + } + let launch = list(&value["launch"], 1, 32, "launch")?; + if launch.first() != Some(&json!("${executable}")) { + return Err(invalid( + "launch", + "The first argv token must be the selected executable.", + )); + } + let mut lists = BTreeSet::new(); + for token in launch { + let token = text(token, 256, "launch")?; + if token.contains('$') + && ![ + "${home}", + "${executable}", + "${entry}", + "${runtimeArguments}", + "${arguments}", + ] + .contains(&token) + { + return Err(invalid( + "launch", + "Only whole-token launch variables are permitted.", + )); + } + if ["${runtimeArguments}", "${arguments}"].contains(&token) && !lists.insert(token) { + return Err(invalid( + "launch", + "An argument list may be expanded only once.", + )); + } + } + candidates(value)?; + Ok(()) +} + +/// Without `watchFiles` a provider can report installations only; the decoder then +/// rejects every requirement statement because no declared reader can anchor it. +fn provider(value: &Value) -> Validation<()> { + object( + value, + &["id", "displayName", "tools", "watchFiles"], + &["id", "displayName", "tools"], + "toolchainProviders", + )?; + let id = text(&value["id"], 64, "toolchainProviders.id")?; + if !tool_id(id) || id.contains(':') { + return Err(invalid( + "toolchainProviders.id", + "A provider ID is local to its plugin.", + )); + } + text(&value["displayName"], 64, "toolchainProviders.displayName")?; + strings(&value["tools"], 1, 32, "toolchainProviders.tools", tool_id)?; + let mut patterns = BTreeSet::new(); + let watches = match value.get("watchFiles") { + Some(watches) => list(watches, 1, 32, "watchFiles")?, + None => &[], + }; + for watch in watches { + object( + watch, + &["pattern", "reader"], + &["pattern", "reader"], + "watchFiles", + )?; + path(&watch["pattern"], true, "watchFiles.pattern")?; + let pattern = text(&watch["pattern"], 256, "watchFiles.pattern")?; + if !patterns.insert(pattern) + || ![json!("native"), json!("compatible")].contains(&watch["reader"]) + { + return Err(invalid( + "watchFiles", + "Watch patterns must be unique and declare native or compatible ownership.", + )); + } + } + Ok(()) +} + +/// Checks declaration shape and deterministic policy without asserting native trust. +pub(crate) fn validate_declarations( + plugin_id: &str, + definitions: &[Value], + providers: &[Value], +) -> Validation<()> { + if definitions.len() > 32 || providers.len() > 8 { + return Err(invalid( + "toolDefinitions", + "The plugin exceeds toolchain contribution limits.", + )); + } + let mut ids = BTreeSet::new(); + for value in definitions { + definition(plugin_id, value)?; + if !ids.insert(value["id"].as_str()) { + return Err(invalid( + "toolDefinitions.id", + "Tool definition IDs must be unique.", + )); + } + } + ids.clear(); + for value in providers { + provider(value)?; + if !ids.insert(value["id"].as_str()) { + return Err(invalid( + "toolchainProviders.id", + "Provider IDs must be unique.", + )); + } + } + Ok(()) +} + +/// Revalidates complete enabled manifests and host-attested origin before registration. +/// A conflicting batch is rejected atomically; an existing definition is never replaced. +pub(crate) fn registry(input: &Value) -> Validation { + object(input, &["plugins"], &["plugins"], "registry")?; + let plugins = list(&input["plugins"], 0, 128, "plugins")?; + let mut definitions = Vec::new(); + let mut providers = Vec::new(); + let mut tool_ids = BTreeSet::new(); + let mut plugin_ids = BTreeSet::new(); + for entry in plugins { + object( + entry, + &["manifest", "verifiedOfficial"], + &["manifest", "verifiedOfficial"], + "plugins", + )?; + let official = entry["verifiedOfficial"].as_bool().ok_or_else(|| { + invalid( + "verifiedOfficial", + "Verified origin is host-supplied boolean evidence.", + ) + })?; + let manifest = validate_manifest_value(entry["manifest"].clone())?; + let manifest = serde_json::to_value(manifest) + .map_err(|_| invalid("manifest", "The validated manifest cannot be encoded."))?; + let plugin_id = text(&manifest["id"], 128, "id")?; + if !plugin_ids.insert(plugin_id.to_owned()) { + return Err(invalid( + "plugins", + "Plugin IDs must be unique in the enabled registry.", + )); + } + for definition in optional_list(&manifest, "toolDefinitions", 32)? { + let id = text(&definition["id"], 192, "toolDefinitions.id")?; + if (!id.contains(':') && !official) || !tool_ids.insert(id.to_owned()) { + return Err(invalid("toolDefinitions.id", "A short ID needs verified official provenance and no ID may replace an existing owner.")); + } + let mut normalized = definition.clone(); + let indices = candidates(definition)?; + normalized["executables"] = json!(indices + .iter() + .map(|index| definition["executables"][*index].clone()) + .collect::>()); + definitions.push(json!({"pluginID":plugin_id,"definition":normalized})); + } + for provider in optional_list(&manifest, "toolchainProviders", 8)? { + providers.push(json!({"pluginID":plugin_id,"provider":provider})); + } + if definitions.len() > 512 || providers.len() > 256 { + return Err(invalid( + "plugins", + "The registry exceeds aggregate contribution limits.", + )); + } + } + definitions.sort_by_key(|entry| { + ( + entry["definition"]["id"].as_str().unwrap_or("").to_owned(), + entry["pluginID"].as_str().unwrap_or("").to_owned(), + ) + }); + providers.sort_by_key(|entry| { + ( + entry["pluginID"].as_str().unwrap_or("").to_owned(), + entry["provider"]["id"].as_str().unwrap_or("").to_owned(), + ) + }); + Ok(json!({"definitions":definitions,"providers":providers})) +} diff --git a/rust/lithe-core/src/toolchain/mod.rs b/rust/lithe-core/src/toolchain/mod.rs new file mode 100644 index 0000000000..f07fae1b5a --- /dev/null +++ b/rust/lithe-core/src/toolchain/mod.rs @@ -0,0 +1,72 @@ +//! Plugin-owned toolchain policy; hosts supply trust, filesystem and probe facts. +//! +//! No manager configuration, native scanning or process lifecycle belongs here. + +pub(crate) mod definitions; +pub(crate) mod paths; +pub(crate) mod pins; +pub(crate) mod query; +mod resolution; + +use crate::plugins::declarative::{PluginRejection, Validation}; +use crate::protocol::CoreError; +use serde_json::{json, Value}; + +fn command( + input: Value, + operation: fn(&Value) -> Validation, + key: &str, +) -> Result { + let result = if input.to_string().len() > 4 * 1024 * 1024 { + Err(PluginRejection::new( + "plugin.toolchain.limitExceeded", + "Toolchain input exceeds its byte budget.", + )) + } else { + operation(&input) + }; + Ok(match result { + Ok(value) => json!({"status":"ok",key:value}), + Err(error) => json!({"status":"rejected","error":error}), + }) +} + +/// Registers definitions using independently verified host package identities. +pub fn registry_command(input: Value) -> Result { + command(input, definitions::registry, "registry") +} + +/// Validates one provider reply against its request and host filesystem facts. +pub fn decode_reply_command(input: Value) -> Result { + command(input, query::decode, "snapshot") +} + +/// Builds the error reply for a query that timed out or found no backend. +pub fn failure_reply_command(input: Value) -> Result { + command(input, query::failure_reply, "reply") +} + +/// Reads the nearest directory pin without opening or writing native files. +pub fn resolve_pins_command(input: Value) -> Result { + command(input, pins::resolve, "resolution") +} + +/// Resolves one tool using the same validated registry and candidate snapshot. +pub fn resolve_command(input: Value) -> Result { + command(input, resolution::resolve, "resolution") +} + +/// Extracts and compares a version using only its enabled plugin definition. +pub fn check_version_command(input: Value) -> Result { + command(input, resolution::check_version, "version") +} + +/// Produces bounded scan declarations, not a native scan or tool-name table. +pub fn discovery_plan_command(input: Value) -> Result { + command(input, resolution::discovery_plan, "plan") +} + +/// Lists checked installations, versions and the automatic choice for a settings page. +pub fn candidates_command(input: Value) -> Result { + command(input, resolution::candidates, "candidates") +} diff --git a/rust/lithe-core/src/toolchain/paths.rs b/rust/lithe-core/src/toolchain/paths.rs new file mode 100644 index 0000000000..d7c8df6bd5 --- /dev/null +++ b/rust/lithe-core/src/toolchain/paths.rs @@ -0,0 +1,103 @@ +//! Lexical native-path policy independent of the machine running Core. + +/// Normalizes a bounded absolute path using the target platform's separators. +/// Native adapters must supply canonical filesystem facts separately. +pub(crate) fn normalize(path: &str, platform: &str, case_sensitive: bool) -> Option { + if path.is_empty() || path.len() > 4096 || path.chars().any(char::is_control) { + return None; + } + let windows = platform == "windows"; + let text = if windows { + path.replace('\\', "/") + } else { + if path.contains('\\') { + return None; + } + path.to_owned() + }; + let (prefix, remainder) = if windows { + let bytes = text.as_bytes(); + if bytes.len() >= 3 + && bytes[0].is_ascii_alphabetic() + && bytes[1] == b':' + && bytes[2] == b'/' + { + (text[..3].to_owned(), &text[3..]) + } else if let Some(unc) = text.strip_prefix("//") { + let mut components = unc.splitn(3, '/'); + let server = components.next()?; + let share = components.next()?; + if server.is_empty() + || share.is_empty() + || [server, share].iter().any(|part| { + part.contains([':', '?', '*', '<', '>', '"', '|']) + || matches!(*part, "." | "..") + }) + { + return None; + } + ( + format!("//{server}/{share}/"), + components.next().unwrap_or(""), + ) + } else { + return None; + } + } else { + ("/".to_owned(), text.strip_prefix('/')?) + }; + let remainder = remainder.strip_suffix('/').unwrap_or(remainder); + if !remainder.is_empty() + && remainder.split('/').any(|part| { + part.is_empty() + || matches!(part, "." | "..") + || (windows + && (part.contains([':', '?', '*', '<', '>', '"', '|']) + || part.ends_with([' ', '.']))) + }) + { + return None; + } + let result = format!("{prefix}{remainder}"); + Some(if case_sensitive { + result + } else { + // This is an identity key, never a rewritten executable path. + result.to_lowercase() + }) +} + +/// Compares complete native path components with target-platform case rules. +pub(crate) fn within(path: &str, root: &str, platform: &str, case_sensitive: bool) -> bool { + let Some(path) = normalize(path, platform, case_sensitive) else { + return false; + }; + let Some(root) = normalize(root, platform, case_sensitive) else { + return false; + }; + path == root || path.starts_with(&format!("{}/", root.trim_end_matches('/'))) +} + +/// Uses the default target-platform case rules for native containment. +#[cfg(test)] +pub(crate) fn path_within(path: &str, root: &str, platform: &str) -> bool { + within(path, root, platform, platform != "windows") +} + +/// Joins a package-independent relative executable layout beneath a native home. +pub(crate) fn join(home: &str, relative: &str, platform: &str) -> Option { + if relative.is_empty() + || relative.contains(['\\', ':']) + || relative.chars().any(char::is_control) + || relative + .split('/') + .any(|part| part.is_empty() || matches!(part, "." | "..")) + { + return None; + } + normalize( + &format!("{}/{relative}", home.trim_end_matches(['/', '\\'])), + platform, + true, + ) +} diff --git a/rust/lithe-core/src/toolchain/pins.rs b/rust/lithe-core/src/toolchain/pins.rs new file mode 100644 index 0000000000..c6ce6e3602 --- /dev/null +++ b/rust/lithe-core/src/toolchain/pins.rs @@ -0,0 +1,298 @@ +//! Validates inert machine-local pins and resolves directory inheritance without I/O. + +use crate::plugins::declarative::{PluginRejection, Validation}; +use serde_json::{json, Map, Value}; + +const MAX_DIRECTORIES: usize = 1024; +const MAX_TOOLS_PER_DIRECTORY: usize = 64; +const MAX_DIRECTORY_BYTES: usize = 2048; +const MAX_PATH_BYTES: usize = 4096; + +/// Resolves a stored pin without granting execution authority or modifying the document. +/// Callers must independently require an active definition and validate native files. +pub(crate) fn resolve(input: &Value) -> Validation { + let request = object(input, "input", &["document", "directory", "tool"])?; + let document = required(request, "document", "input")?; + validate(document)?; + let directory = text( + required(request, "directory", "input")?, + "directory", + MAX_DIRECTORY_BYTES, + )?; + if !valid_directory(directory) { + return Err(invalid( + "directory", + "Expected a normalized workspace-relative directory.", + )); + } + let tool = text(required(request, "tool", "input")?, "tool", 192)?; + if !valid_tool_id(tool) { + return Err(invalid("tool", "Expected a valid tool identifier.")); + } + let pins = document.get("pins").and_then(Value::as_object); + let mut ancestor = directory; + loop { + if let Some(pin) = pins + .and_then(|entries| entries.get(ancestor)) + .and_then(|tools| tools.get(tool)) + { + return Ok(json!({"pin": pin, "sourceDirectory": ancestor})); + } + if ancestor == "." { + return Ok(json!({"pin": null, "sourceDirectory": null})); + } + ancestor = ancestor.rsplit_once('/').map_or(".", |(parent, _)| parent); + } +} + +/// Checks the complete local document, retaining legacy entries and dormant tool IDs. +/// This validation is lexical only; paths are never probed, canonicalized, or launched. +pub(crate) fn validate(document: &Value) -> Validation<()> { + let root = object(document, "document", &["version", "toolchains", "pins"])?; + if required(root, "version", "document")?.as_u64() != Some(1) { + return Err(invalid( + "document.version", + "Expected local toolchain document version 1.", + )); + } + let legacy = object( + required(root, "toolchains", "document")?, + "document.toolchains", + &[], + )?; + count(legacy.len(), MAX_DIRECTORIES, "document.toolchains")?; + for (id, value) in legacy { + text(&Value::String(id.clone()), "document.toolchains", 192)?; + let field = format!("document.toolchains.{id}"); + let entry = object(value, &field, &["home", "executable", "javaHome"])?; + // Legacy paths retain their original meaning; validation must not migrate them. + for (property, value) in entry { + text(value, &format!("{field}.{property}"), MAX_PATH_BYTES)?; + } + } + let Some(pins) = root.get("pins") else { + return Ok(()); + }; + let pins = object(pins, "document.pins", &[])?; + count(pins.len(), MAX_DIRECTORIES, "document.pins")?; + for (directory, tools) in pins { + let field = format!("document.pins.{directory}"); + if directory.len() > MAX_DIRECTORY_BYTES { + return Err(limit(&field)); + } + if !valid_directory(directory) { + return Err(invalid( + &field, + "Expected a normalized workspace-relative directory.", + )); + } + let tools = object(tools, &field, &[])?; + count(tools.len(), MAX_TOOLS_PER_DIRECTORY, &field)?; + for (tool, pin) in tools { + let field = format!("{field}.{tool}"); + if !valid_tool_id(tool) { + return Err(invalid(&field, "Expected a valid tool identifier.")); + } + validate_pin(pin, &field)?; + } + } + Ok(()) +} + +/// Validates one inert stored installation reference without looking up its owner. +fn validate_pin(value: &Value, field: &str) -> Validation<()> { + let pin = object(value, field, &["home", "version", "provider"])?; + let home = text( + required(pin, "home", field)?, + &format!("{field}.home"), + MAX_PATH_BYTES, + )?; + if !valid_native_path(home) { + return Err(invalid( + format!("{field}.home"), + "Expected an absolute native installation path without traversal.", + )); + } + text( + required(pin, "version", field)?, + &format!("{field}.version"), + 128, + )?; + let provider = text( + required(pin, "provider", field)?, + &format!("{field}.provider"), + 256, + )?; + let valid = provider.split_once(':').is_some_and(|(plugin, id)| { + plugin.starts_with(|character: char| character.is_ascii_lowercase()) + && valid_namespace(plugin) + && valid_local_id(id) + }); + if !valid { + return Err(invalid( + format!("{field}.provider"), + "Expected a qualified provider identifier.", + )); + } + Ok(()) +} + +/// Accepts the root marker or normalized portable directory components. +fn valid_directory(value: &str) -> bool { + value == "." + || (!value.is_empty() + && !value + .chars() + .any(|character| character.is_control() || matches!(character, '\\' | ':')) + && value + .split('/') + .all(|part| !part.is_empty() && part != "." && part != "..")) +} + +/// Checks both reserved short IDs and plugin-qualified IDs, not registry membership. +fn valid_tool_id(value: &str) -> bool { + value.len() <= 192 + && match value.split_once(':') { + Some((namespace, id)) => valid_namespace(namespace) && valid_local_id(id), + None => valid_local_id(value), + } +} + +/// Checks the lowercase dotted/dashed namespace grammar used by tool IDs. +fn valid_namespace(value: &str) -> bool { + !value.is_empty() + && value.split(['.', '-']).all(|part| { + !part.is_empty() + && part + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()) + }) +} + +/// Checks the bounded local portion of a tool or provider ID. +fn valid_local_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= 64 + && value.as_bytes()[0].is_ascii_lowercase() + && value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') +} + +/// Recognizes POSIX, drive-rooted Windows, and UNC paths on either build platform. +fn valid_native_path(value: &str) -> bool { + if value.chars().any(char::is_control) { + return false; + } + if let Some(path) = value + .strip_prefix("\\\\") + .or_else(|| value.strip_prefix("//")) + { + let path = path.strip_suffix(['/', '\\']).unwrap_or(path); + let parts: Vec<_> = path.split(['/', '\\']).collect(); + return parts.len() >= 2 && valid_native_components(&parts, true); + } + let bytes = value.as_bytes(); + if bytes.len() >= 3 + && bytes[0].is_ascii_alphabetic() + && bytes[1] == b':' + && matches!(bytes[2], b'/' | b'\\') + { + if value.len() == 3 { + return true; + } + let path = value[3..].strip_suffix(['/', '\\']).unwrap_or(&value[3..]); + return !path.is_empty() + && valid_native_components(&path.split(['/', '\\']).collect::>(), true); + } + let Some(path) = value.strip_prefix('/') else { + return false; + }; + if path.contains('\\') { + return false; + } + if path.is_empty() { + return true; + } + let path = path.strip_suffix('/').unwrap_or(path); + !path.is_empty() && valid_native_components(&path.split('/').collect::>(), false) +} + +/// Rejects traversal and Windows device/stream spellings without touching disk. +fn valid_native_components(parts: &[&str], windows: bool) -> bool { + parts.iter().all(|part| { + !part.is_empty() + && *part != "." + && *part != ".." + && (!windows + || (!part.ends_with([' ', '.']) + && !part.chars().any(|character| { + matches!(character, ':' | '*' | '?' | '"' | '<' | '>' | '|') + }))) + }) +} + +/// Checks record fields while allowing callers to explicitly select keyed-map mode. +fn object<'a>( + value: &'a Value, + field: &str, + allowed: &[&str], +) -> Validation<&'a Map> { + let entries = value + .as_object() + .ok_or_else(|| invalid(field, "Expected an object."))?; + // An empty allowlist denotes an explicitly keyed map, not an open record. + if !allowed.is_empty() { + if let Some(key) = entries.keys().find(|key| !allowed.contains(&key.as_str())) { + return Err(invalid(format!("{field}.{key}"), "Unknown field.")); + } + } + Ok(entries) +} + +/// Reports missing fields at their stable request path. +fn required<'a>(entries: &'a Map, key: &str, field: &str) -> Validation<&'a Value> { + entries + .get(key) + .ok_or_else(|| invalid(format!("{field}.{key}"), "Required field is missing.")) +} + +/// Bounds stored text by UTF-8 bytes before any further lexical interpretation. +fn text<'a>(value: &'a Value, field: &str, max: usize) -> Validation<&'a str> { + let text = value + .as_str() + .ok_or_else(|| invalid(field, "Expected a string."))?; + if text.len() > max { + return Err(limit(field)); + } + if text.is_empty() || text.chars().any(char::is_control) { + return Err(invalid( + field, + "Expected a nonempty string without control characters.", + )); + } + Ok(text) +} + +/// Applies collection budgets independently of whether a pin will be selected. +fn count(actual: usize, max: usize, field: &str) -> Validation<()> { + if actual > max { + Err(limit(field)) + } else { + Ok(()) + } +} + +/// Produces the common malformed-input error used by pure toolchain operations. +fn invalid(field: impl Into, message: impl Into) -> PluginRejection { + PluginRejection::at("plugin.toolchain.invalidParams", field, message) +} + +/// Keeps resource-limit failures distinct from malformed storage. +fn limit(field: impl Into) -> PluginRejection { + PluginRejection::at( + "plugin.toolchain.limitExceeded", + field, + "Local toolchain storage exceeds its bounded limits.", + ) +} diff --git a/rust/lithe-core/src/toolchain/query.rs b/rust/lithe-core/src/toolchain/query.rs new file mode 100644 index 0000000000..264959499d --- /dev/null +++ b/rust/lithe-core/src/toolchain/query.rs @@ -0,0 +1,1033 @@ +//! Validates provider query replies against host-owned identity and filesystem facts. + +use super::paths; +use crate::plugins::declarative::{PluginRejection, Validation}; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, BTreeSet}; + +const INVALID: &str = "plugin.toolchain.invalidParams"; +const REPLY: &str = "plugin.toolchain.invalidReply"; +const UNSAFE: &str = "plugin.toolchain.unsafePath"; +const LIMIT: &str = "plugin.toolchain.limitExceeded"; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Request { + jsonrpc: String, + id: u64, + method: String, + params: Params, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Params { + #[serde(rename = "operationID")] + operation_id: String, + generation: u64, + #[serde(rename = "providerID")] + provider_id: String, + authority: Authority, + context: Option, + workspace: Option, + tools: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Authority { + kind: String, + id: Option, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Context { + #[serde(rename = "contextID")] + context_id: String, + generation: u64, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Workspace { + #[serde(rename = "rootID")] + root_id: String, + root: String, + directory: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Reply { + #[serde(rename = "operationID")] + operation_id: String, + generation: u64, + #[serde(rename = "providerID")] + provider_id: String, + installations: Vec, + statements: Vec, + watch: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Installation { + tool: String, + version: String, + home: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Statement { + tool: String, + scope: String, + source: String, + request: String, + installation: Option, + kind: String, + reader: String, +} + +/// Host-observed filesystem state; providers cannot populate this evidence. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct FileFact { + path: String, + canonical_path: String, + kind: String, + #[serde(default)] + executable: bool, +} + +struct Facts { + // Keys use the target volume's case policy; returned paths retain native spelling. + values: BTreeMap, + platform: String, + case_sensitive: bool, +} + +impl Facts { + fn key(&self, path: &str) -> Validation { + paths::normalize(path, &self.platform, self.case_sensitive).ok_or_else(|| { + reject( + UNSAFE, + "path", + "An absolute normalized native path is required.", + ) + }) + } + + fn canonical(&self, path: &str, kind: Option<&str>) -> Validation { + let key = self.key(path)?; + let fact = self.values.get(&key).ok_or_else(|| { + reject( + UNSAFE, + "filesystem", + "Required native filesystem evidence is missing.", + ) + })?; + if kind.is_some_and(|expected| fact.kind != expected) { + return Err(reject( + UNSAFE, + "filesystem.kind", + "The native path has an invalid file type.", + )); + } + paths::normalize(&fact.canonical_path, &self.platform, true).ok_or_else(|| { + reject( + UNSAFE, + "filesystem.canonicalPath", + "Invalid canonical path evidence.", + ) + }) + } + + fn executable(&self, path: &str) -> Validation { + let canonical = self.canonical(path, Some("file"))?; + if !self.values[&self.key(path)?].executable { + return Err(reject( + UNSAFE, + "filesystem.executable", + "The selected program is not executable.", + )); + } + Ok(canonical) + } + + fn within(&self, path: &str, root: &str) -> bool { + paths::within(path, root, &self.platform, self.case_sensitive) + } +} + +fn reject(code: &'static str, field: &str, message: &str) -> PluginRejection { + PluginRejection::at(code, field, message) +} + +fn bounded(value: &str, maximum: usize) -> bool { + !value.is_empty() && value.len() <= maximum && !value.chars().any(char::is_control) +} + +fn identifier(value: &str) -> bool { + bounded(value, 128) + && value.as_bytes()[0].is_ascii_alphanumeric() + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"._:-".contains(&byte)) +} + +fn provider_identifier(value: &str) -> bool { + bounded(value, 64) + && value.as_bytes()[0].is_ascii_lowercase() + && value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') +} + +fn directory(value: &str) -> bool { + value == "." + || (bounded(value, 2048) + && !value.contains(['\\', ':']) + && value + .split('/') + .all(|part| !part.is_empty() && !matches!(part, "." | ".."))) +} + +fn object_keys( + value: &Value, + allowed: &[&str], + required: &[&str], + code: &'static str, +) -> Validation<()> { + let object = value + .as_object() + .ok_or_else(|| reject(code, "response", "An object is required."))?; + if object.keys().any(|key| !allowed.contains(&key.as_str())) + || required.iter().any(|key| !object.contains_key(*key)) + { + return Err(reject( + code, + "response", + "The object contains missing or unsupported fields.", + )); + } + Ok(()) +} + +/// Maps each query error to one stable JSON-RPC number without transport work. +pub(crate) fn rpc_code(code: &str) -> Option { + Some(match code { + "plugin.toolchain.methodUnavailable" => -32601, + INVALID => -32602, + "plugin.toolchain.cancelled" => -32800, + "plugin.toolchain.permissionDenied" => -32070, + "plugin.toolchain.authorityUnsupported" => -32071, + "plugin.toolchain.timeout" => -32072, + "plugin.toolchain.providerUnavailable" => -32073, + REPLY => -32074, + LIMIT => -32075, + UNSAFE => -32076, + "plugin.toolchain.operationExpired" => -32077, + _ => return None, + }) +} + +fn error_reply(response: &Value, params: &Params) -> Validation { + let error = &response["error"]; + object_keys( + error, + &["code", "message", "data"], + &["code", "message", "data"], + REPLY, + )?; + let data = &error["data"]; + object_keys( + data, + &["code", "operationID", "providerID", "details"], + &["code", "operationID", "providerID"], + REPLY, + )?; + let code = data["code"].as_str().unwrap_or(""); + if rpc_code(code) != error["code"].as_i64() + || data["operationID"] != params.operation_id + || data["providerID"] != params.provider_id + || !error["message"] + .as_str() + .is_some_and(|value| bounded(value, 1024)) + || data + .get("details") + .is_some_and(|value| !value.is_object() || value.to_string().len() > 4096) + { + return Err(reject( + REPLY, + "response.error", + "The provider error mapping or request identity is invalid.", + )); + } + // Borrowed provider strings cannot become stable static error identifiers. + let stable = match error["code"].as_i64() { + Some(-32601) => "plugin.toolchain.methodUnavailable", + Some(-32602) => INVALID, + Some(-32800) => "plugin.toolchain.cancelled", + Some(-32070) => "plugin.toolchain.permissionDenied", + Some(-32071) => "plugin.toolchain.authorityUnsupported", + Some(-32072) => "plugin.toolchain.timeout", + Some(-32073) => "plugin.toolchain.providerUnavailable", + Some(-32074) => REPLY, + Some(-32075) => LIMIT, + Some(-32076) => UNSAFE, + Some(-32077) => "plugin.toolchain.operationExpired", + _ => { + return Err(reject( + REPLY, + "response.error.code", + "Unknown provider error code.", + )) + } + }; + Err(PluginRejection::new( + stable, + error["message"].as_str().unwrap_or_default(), + )) +} + +/// Builds the error reply for a query the provider never answered, so the host hands +/// `decodeReply` the same `{id, response}` a backend error would produce. Only a missed +/// deadline or an unavailable backend qualifies; cancellation and safety failures stay +/// operation failures and are not reported as a provider that could not answer. +pub(crate) fn failure_reply(input: &Value) -> Validation { + object_keys(input, &["params", "code"], &["params", "code"], INVALID)?; + let code = match input["code"].as_str() { + Some("plugin.toolchain.timeout") => "plugin.toolchain.timeout", + Some("plugin.toolchain.providerUnavailable") => "plugin.toolchain.providerUnavailable", + _ => { + return Err(reject( + INVALID, + "code", + "Only a timeout or an unavailable provider has a failure reply.", + )) + } + }; + let params = &input["params"]; + let operation = params["operationID"].as_str().unwrap_or(""); + let provider = params["providerID"].as_str().unwrap_or(""); + if !identifier(operation) || !provider_identifier(provider) { + return Err(reject( + INVALID, + "params", + "Invalid query operation or provider identity.", + )); + } + let message = if code == "plugin.toolchain.timeout" { + "The provider did not answer before the query deadline." + } else { + "The provider backend is not available." + }; + Ok(json!({"id":1,"response":{"jsonrpc":"2.0","id":1,"error":{ + "code":rpc_code(code),"message":message, + "data":{"code":code,"operationID":operation,"providerID":provider}}}})) +} + +fn glob_component(pattern: &str, value: &str) -> bool { + let parts: Vec<&str> = pattern.split('*').collect(); + let mut remaining = value; + for (index, part) in parts.iter().enumerate() { + if index == 0 { + let Some(rest) = remaining.strip_prefix(part) else { + return false; + }; + remaining = rest; + } else if index == parts.len() - 1 { + return remaining.ends_with(part); + } else if let Some(position) = remaining.find(part) { + remaining = &remaining[position + part.len()..]; + } else { + return false; + } + } + remaining.is_empty() +} + +fn source_anchor(source: &str, pattern: &str, case_sensitive: bool) -> Option { + let source = if case_sensitive { + source.to_owned() + } else { + source.to_lowercase() + }; + let pattern = if case_sensitive { + pattern.to_owned() + } else { + pattern.to_lowercase() + }; + let segments: Vec<&str> = source.split('/').collect(); + let patterns: Vec<&str> = pattern.split('/').collect(); + if segments.len() <= patterns.len() + || !patterns + .iter() + .zip(&segments[segments.len() - patterns.len()..]) + .all(|(pattern, value)| glob_component(pattern, value)) + { + return None; + } + let anchor = segments[..segments.len() - patterns.len()].join("/"); + Some(if anchor.is_empty() { + "/".to_owned() + } else { + anchor + }) +} + +fn file_values(input: &Value) -> Validation<&[Value]> { + input["filesystem"] + .as_array() + .map(Vec::as_slice) + .ok_or_else(|| { + reject( + INVALID, + "filesystem", + "Native filesystem evidence is required.", + ) + }) +} + +fn read_facts(file_values: &[Value], platform: &str, case_sensitive: bool) -> Validation { + let mut facts = explicit_facts(file_values, platform, case_sensitive)?; + let targets = link_targets(&facts)?; + facts.values.extend(targets); + Ok(facts) +} + +fn explicit_facts( + file_values: &[Value], + platform: &str, + case_sensitive: bool, +) -> Validation { + let mut facts = Facts { + values: BTreeMap::new(), + platform: platform.to_owned(), + case_sensitive, + }; + if file_values.len() > 8192 { + return Err(reject( + LIMIT, + "filesystem", + "Too many native filesystem facts.", + )); + } + for value in file_values { + let fact: FileFact = serde_json::from_value(value.clone()) + .map_err(|_| reject(INVALID, "filesystem", "Malformed native filesystem fact."))?; + if !matches!(fact.kind.as_str(), "file" | "directory" | "missing") + || paths::normalize(&fact.canonical_path, platform, true).is_none() + { + return Err(reject( + UNSAFE, + "filesystem", + "Invalid native filesystem evidence.", + )); + } + if facts.values.insert(facts.key(&fact.path)?, fact).is_some() { + return Err(reject( + INVALID, + "filesystem", + "Duplicate native filesystem path.", + )); + } + } + Ok(facts) +} + +/// A link fact also attests its resolved target, so a canonical result found through +/// an alias can be checked again against the same snapshot. Returns only targets +/// without their own fact; facts that disagree about one target reject the snapshot. +fn link_targets(facts: &Facts) -> Validation> { + let mut targets: BTreeMap = BTreeMap::new(); + for fact in facts.values.values().filter(|fact| fact.kind != "missing") { + let target = FileFact { + path: fact.canonical_path.clone(), + canonical_path: fact.canonical_path.clone(), + kind: fact.kind.clone(), + executable: fact.executable, + }; + let key = facts.key(&target.path)?; + let known = facts.values.get(&key).or_else(|| targets.get(&key)); + if known.is_some_and(|known| !same_target(known, &target, facts)) { + return Err(reject( + UNSAFE, + "filesystem.canonicalPath", + "Native filesystem facts disagree about one canonical path.", + )); + } + if known.is_none() { + targets.insert(key, target); + } + } + Ok(targets) +} + +/// Lists the resolved targets implied by link facts, for resolvers that look facts up +/// by path after decoding has already canonicalized a provider home. +pub(crate) fn implied_targets( + filesystem: &[Value], + platform: &str, + case_sensitive: bool, +) -> Validation> { + let facts = explicit_facts(filesystem, platform, case_sensitive)?; + Ok(link_targets(&facts)? + .into_values() + .map(|fact| { + json!({"path":fact.path,"canonicalPath":fact.canonical_path,"kind":fact.kind, + "executable":fact.executable}) + }) + .collect()) +} + +/// Two facts describe one resolved target only if identity, type and mode agree. +fn same_target(known: &FileFact, target: &FileFact, facts: &Facts) -> bool { + facts.key(&known.canonical_path).ok() == facts.key(&target.canonical_path).ok() + && known.kind == target.kind + && known.executable == target.executable +} + +/// `layout_path` limits the search to one declared executable layout, as an environment +/// candidate names; otherwise the first present platform layout is selected. +fn checked_installation( + home: &str, + definition: &Value, + facts: &Facts, + roots: &[String], + layout_path: Option<&str>, +) -> Validation { + let canonical_home = facts.canonical(home, Some("directory"))?; + if roots.iter().any(|root| facts.within(&canonical_home, root)) { + return Err(reject( + UNSAFE, + "installation.home", + "A provider installation cannot be workspace code.", + )); + } + for layout in definition["executableLayouts"] + .as_array() + .into_iter() + .flatten() + { + if layout.get("platforms").is_some_and(|platforms| { + !platforms + .as_array() + .is_some_and(|platforms| platforms.iter().any(|value| value == &facts.platform)) + }) || layout_path.is_some_and(|wanted| layout["path"] != wanted) + { + continue; + } + let path = paths::join(home, layout["path"].as_str().unwrap_or(""), &facts.platform) + .ok_or_else(|| reject(UNSAFE, "executableLayouts", "Invalid executable layout."))?; + let Some(fact) = facts.values.get(&facts.key(&path)?) else { + continue; + }; + if fact.kind == "missing" { + continue; + } + let executable = facts.executable(&path)?; + if !facts.within(&executable, &canonical_home) + || roots.iter().any(|root| facts.within(&executable, root)) + { + return Err(reject( + UNSAFE, + "installation.executable", + "The executable escapes its installation or enters the workspace.", + )); + } + return Ok(json!({"home":canonical_home,"executable":executable})); + } + Err(reject( + UNSAFE, + "installation.executable", + "No validated executable matches the tool definition.", + )) +} + +/// Validates an external installation against trusted, authority-local native facts. +/// Returns canonical home/executable paths without touching the native filesystem. +/// Project-code candidates require their separate consumer trust policy. +pub(crate) fn validate_installation( + home: &str, + definition: &Value, + filesystem: &[Value], + workspace_roots: &[String], + platform: &str, + case_sensitive: bool, + layout_path: Option<&str>, +) -> Validation { + if !matches!(platform, "macos" | "windows" | "linux") { + return Err(reject( + INVALID, + "platform", + "A supported target platform is required.", + )); + } + let facts = read_facts(filesystem, platform, case_sensitive)?; + let roots = workspace_roots + .iter() + .map(|root| facts.canonical(root, Some("directory"))) + .collect::>>()?; + checked_installation(home, definition, &facts, &roots, layout_path) +} + +/// Decodes one provider reply using only verified registry and native facts. +/// No filesystem, process, environment or JSON-RPC transport is accessed here. +pub(crate) fn decode(input: &Value) -> Validation { + object_keys( + input, + &[ + "registry", + "pluginID", + "request", + "response", + "currentGeneration", + "filesystem", + "platform", + "filesystemCaseSensitive", + "workspaceRoots", + ], + &[ + "registry", + "pluginID", + "request", + "response", + "currentGeneration", + "filesystem", + "platform", + ], + INVALID, + )?; + let registry = super::definitions::registry(&input["registry"])?; + let request_value = &input["request"]; + let request: Request = serde_json::from_value(request_value.clone()) + .map_err(|_| reject(INVALID, "request", "Malformed toolchain query request."))?; + let params = &request.params; + if request.jsonrpc != "2.0" + || request.id == 0 + || !identifier(¶ms.operation_id) + || params.generation == 0 + || !provider_identifier(¶ms.provider_id) + || params.tools.is_empty() + || params.tools.len() > 64 + || params + .tools + .iter() + .any(|tool| !super::definitions::tool_id(tool)) + || params.tools.iter().collect::>().len() != params.tools.len() + || !params.tools.windows(2).all(|pair| pair[0] < pair[1]) + || !request_value["params"].as_object().is_some_and(|object| { + object.contains_key("workspace") && object.contains_key("context") + }) + { + return Err(reject( + INVALID, + "request.params", + "Invalid query identities or tool bounds.", + )); + } + if request.method != "toolchains/query" { + return Err(reject( + "plugin.toolchain.methodUnavailable", + "request.method", + "Unsupported toolchain method.", + )); + } + if input["currentGeneration"].as_u64() != Some(params.generation) { + return Err(reject( + "plugin.toolchain.operationExpired", + "request.params.generation", + "The toolchain query generation is no longer current.", + )); + } + if params.authority.kind == "remote" && params.authority.id.as_deref().is_some_and(identifier) { + return Err(reject( + "plugin.toolchain.authorityUnsupported", + "request.params.authority", + "No remote toolchain adapter is available.", + )); + } + if params.authority.kind != "local" + || params.authority.id.is_some() + || params + .context + .as_ref() + .is_some_and(|context| !identifier(&context.context_id) || context.generation == 0) + || params.workspace.is_some() && params.context.is_none() + { + return Err(reject( + INVALID, + "request.params.authority", + "Invalid query authority or workspace context.", + )); + } + object_keys( + &request_value["params"]["authority"], + &["kind"], + &["kind"], + INVALID, + )?; + let platform = input["platform"].as_str().unwrap_or(""); + if !matches!(platform, "macos" | "windows" | "linux") { + return Err(reject( + INVALID, + "platform", + "A supported target platform is required.", + )); + } + let case_sensitive = match input.get("filesystemCaseSensitive") { + Some(value) => value.as_bool().ok_or_else(|| { + reject( + INVALID, + "filesystemCaseSensitive", + "Filesystem case sensitivity must be boolean.", + ) + })?, + None => platform != "windows", + }; + let facts = read_facts(file_values(input)?, platform, case_sensitive)?; + let mut roots = Vec::new(); + let mut requested_directory = None; + if let Some(workspace) = ¶ms.workspace { + if !identifier(&workspace.root_id) || !directory(&workspace.directory) { + return Err(reject( + INVALID, + "request.params.workspace", + "Invalid workspace identity or directory.", + )); + } + let root = facts.canonical(&workspace.root, Some("directory"))?; + let native_directory = if workspace.directory == "." { + root.clone() + } else { + let path = + paths::join(&workspace.root, &workspace.directory, platform).ok_or_else(|| { + reject( + UNSAFE, + "request.params.workspace.directory", + "Invalid workspace directory.", + ) + })?; + facts.canonical(&path, Some("directory"))? + }; + if !facts.within(&native_directory, &root) { + return Err(reject( + UNSAFE, + "request.params.workspace.directory", + "The queried directory escapes its workspace root.", + )); + } + requested_directory = Some(native_directory); + roots.push(root); + } + if let Some(extra) = input.get("workspaceRoots") { + let extra = extra + .as_array() + .filter(|values| values.len() <= 64) + .ok_or_else(|| reject(INVALID, "workspaceRoots", "Invalid workspace roots."))?; + for root in extra { + roots.push(facts.canonical( + root.as_str().ok_or_else(|| { + reject(INVALID, "workspaceRoots", "Workspace roots must be paths.") + })?, + Some("directory"), + )?); + } + } + let plugin_id = input["pluginID"] + .as_str() + .ok_or_else(|| reject(INVALID, "pluginID", "A bound plugin identity is required."))?; + let provider = registry["providers"] + .as_array() + .and_then(|providers| { + providers.iter().find(|entry| { + entry["pluginID"] == plugin_id && entry["provider"]["id"] == params.provider_id + }) + }) + .ok_or_else(|| { + reject( + "plugin.toolchain.permissionDenied", + "pluginID", + "The enabled provider is not in the verified registry.", + ) + })?; + let provider = &provider["provider"]; + let declared: BTreeSet<&str> = provider["tools"] + .as_array() + .into_iter() + .flatten() + .filter_map(Value::as_str) + .collect(); + if params + .tools + .iter() + .any(|tool| !declared.contains(tool.as_str())) + { + return Err(reject( + INVALID, + "request.params.tools", + "The query names an undeclared provider tool.", + )); + } + let requested: BTreeSet<&str> = params.tools.iter().map(String::as_str).collect(); + let definitions: BTreeMap<&str, &Value> = registry["definitions"] + .as_array() + .into_iter() + .flatten() + .filter_map(|entry| Some((entry["definition"]["id"].as_str()?, &entry["definition"]))) + .collect(); + let response = &input["response"]; + if response.to_string().len() > 1024 * 1024 { + return Err(reject( + LIMIT, + "response", + "The provider reply exceeds the byte limit.", + )); + } + object_keys( + response, + &["jsonrpc", "id", "result", "error"], + &["jsonrpc", "id"], + REPLY, + )?; + if response["jsonrpc"] != "2.0" + || response["id"].as_u64() != Some(request.id) + || response.get("result").is_some() == response.get("error").is_some() + { + return Err(reject( + REPLY, + "response", + "The response envelope or JSON-RPC identity is invalid.", + )); + } + if response.get("error").is_some() { + return error_reply(response, params); + } + let reply: Reply = serde_json::from_value(response["result"].clone()).map_err(|_| { + reject( + REPLY, + "response.result", + "Malformed provider toolchain reply.", + ) + })?; + for statement in response["result"]["statements"] + .as_array() + .into_iter() + .flatten() + { + if statement.get("installation").is_none() { + return Err(reject( + REPLY, + "response.result.statements.installation", + "A statement must explicitly identify an installation or null.", + )); + } + } + if reply.operation_id != params.operation_id + || reply.provider_id != params.provider_id + || reply.generation != params.generation + { + return Err(reject( + "plugin.toolchain.operationExpired", + "response.result", + "The reply belongs to a different toolchain operation.", + )); + } + if reply.installations.len() > 512 || reply.statements.len() > 256 || reply.watch.len() > 256 { + return Err(reject( + LIMIT, + "response.result", + "The provider reply exceeds collection limits.", + )); + } + let qualified = format!("{plugin_id}:{}", params.provider_id); + let mut installations = BTreeMap::new(); + let mut references = BTreeMap::new(); + for installation in &reply.installations { + if !super::definitions::tool_id(&installation.tool) + || !bounded(&installation.version, 128) + || !bounded(&installation.home, 4096) + { + return Err(reject( + REPLY, + "response.result.installations", + "Invalid installation string bounds.", + )); + } + let Some(definition) = definitions + .get(installation.tool.as_str()) + .filter(|_| requested.contains(installation.tool.as_str())) + else { + continue; + }; + let checked = checked_installation(&installation.home, definition, &facts, &roots, None)?; + let home = checked["home"] + .as_str() + .expect("checked home is a string") + .to_owned(); + let executable = checked["executable"] + .as_str() + .expect("checked executable is a string"); + let home_key = facts.key(&home)?; + references.insert( + (installation.tool.clone(), facts.key(&installation.home)?), + home.clone(), + ); + let identity = (installation.tool.clone(), home_key); + if installations + .get(&identity) + .is_some_and(|value: &Value| value["version"] != installation.version) + { + return Err(reject( + REPLY, + "response.result.installations.version", + "One canonical installation has inconsistent versions.", + )); + } + installations.entry(identity).or_insert_with(|| json!({"tool":installation.tool,"version":installation.version,"home":home,"executable":executable,"provider":qualified})); + } + let mut statements = BTreeMap::new(); + let mut statement_facts = BTreeMap::new(); + let mut diagnostics = BTreeMap::new(); + for statement in &reply.statements { + if !super::definitions::tool_id(&statement.tool) + || !bounded(&statement.source, 4096) + || !bounded(&statement.request, 512) + || statement + .installation + .as_ref() + .is_some_and(|value| !bounded(value, 4096)) + || !matches!(statement.scope.as_str(), "project" | "global") + || !matches!(statement.kind.as_str(), "environment" | "declaration") + || !matches!(statement.reader.as_str(), "native" | "compatible") + { + return Err(reject( + REPLY, + "response.result.statements", + "Invalid statement shape or string bounds.", + )); + } + if statement.scope == "project" && params.workspace.is_none() { + return Err(reject( + REPLY, + "response.result.statements.scope", + "A global query cannot contain project statements.", + )); + } + if !requested.contains(statement.tool.as_str()) { + continue; + } + facts.key(&statement.source)?; + if !definitions.contains_key(statement.tool.as_str()) { + // Preserve only display facts; even malformed native homes are never inspected. + let diagnostic = json!({"code":"definitionUnavailable","tool":statement.tool,"scope":statement.scope,"source":statement.source,"request":statement.request,"provider":qualified}); + diagnostics.insert(diagnostic.to_string(), diagnostic); + continue; + } + let source = facts.canonical(&statement.source, Some("file"))?; + let anchors: Vec = provider["watchFiles"] + .as_array() + .into_iter() + .flatten() + .filter(|watch| watch["reader"] == statement.reader) + .filter_map(|watch| source_anchor(&source, watch["pattern"].as_str()?, case_sensitive)) + .collect(); + if anchors.is_empty() { + return Err(reject( + REPLY, + "response.result.statements.reader", + "The source or reader is not declared by the provider.", + )); + } + if statement.scope == "project" + && !anchors.iter().any(|anchor| { + requested_directory.as_ref().is_some_and(|directory| { + facts.within(directory, anchor) + && directory + .split('/') + .count() + .saturating_sub(anchor.split('/').count()) + < 64 + }) + }) + { + return Err(reject( + UNSAFE, + "response.result.statements.source", + "The project source is not a bounded ancestor input.", + )); + } + let home = statement + .installation + .as_ref() + .map(|path| { + references + .get(&(statement.tool.clone(), facts.key(path)?)) + .cloned() + .ok_or_else(|| { + reject( + REPLY, + "response.result.statements.installation", + "The statement names an unlisted installation.", + ) + }) + }) + .transpose()?; + let identity = ( + statement.tool.clone(), + statement.scope.clone(), + facts.key(&source)?, + statement.kind.clone(), + statement.reader.clone(), + ); + let choice = ( + statement.request.clone(), + home.as_deref().map(|path| facts.key(path)).transpose()?, + ); + if let Some(previous) = statement_facts.insert(identity, choice.clone()) { + if previous != choice { + return Err(reject( + REPLY, + "response.result.statements", + "One provider source reports inconsistent requirements or installations.", + )); + } + continue; + } + let source_directory = anchors + .iter() + .filter(|anchor| { + statement.scope == "global" + || requested_directory + .as_ref() + .is_some_and(|directory| facts.within(directory, anchor)) + }) + .max_by_key(|anchor| anchor.split('/').count()) + .cloned() + .unwrap_or_default(); + let normalized = json!({"tool":statement.tool,"scope":statement.scope,"source":source,"sourceDirectory":source_directory,"request":statement.request,"installation":home,"kind":statement.kind,"reader":statement.reader,"provider":qualified}); + if home.is_none() { + let diagnostic = json!({"code":"requestedNotInstalled","tool":statement.tool,"source":source,"request":statement.request,"provider":qualified}); + diagnostics.insert(diagnostic.to_string(), diagnostic); + } + statements.insert(normalized.to_string(), normalized); + } + let mut watch = BTreeMap::new(); + for path in &reply.watch { + let key = facts.key(path)?; + let canonical = if facts.values.contains_key(&key) { + facts.canonical(path, None)? + } else { + paths::normalize(path, platform, true) + .ok_or_else(|| reject(UNSAFE, "response.result.watch", "Invalid watch path."))? + }; + watch.insert(facts.key(&canonical)?, canonical); + } + Ok( + json!({"installations":installations.into_values().collect::>(),"statements":statements.into_values().collect::>(),"watch":watch.into_values().collect::>(),"diagnostics":diagnostics.into_values().collect::>()}), + ) +} diff --git a/rust/lithe-core/src/toolchain/resolution.rs b/rust/lithe-core/src/toolchain/resolution.rs new file mode 100644 index 0000000000..463bcfc5b1 --- /dev/null +++ b/rust/lithe-core/src/toolchain/resolution.rs @@ -0,0 +1,1403 @@ +//! Resolves captured toolchain candidates without native I/O or manager semantics. + +use super::{definitions, paths, pins, query}; +use crate::plugins::declarative::{PluginRejection, Validation}; +use regex::RegexBuilder; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, BTreeSet}; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Probe { + executable: String, + output: String, + exit_code: i32, +} + +fn fields(input: &Value, allowed: &[&str]) -> Validation<()> { + if !input + .as_object() + .is_some_and(|object| object.keys().all(|key| allowed.contains(&key.as_str()))) + { + return Err(reject("input")); + } + Ok(()) +} + +/// Numeric releases are tool-neutral; display aliases never enter comparison. +fn components(value: &str) -> Option> { + if value.is_empty() || value.len() > 64 { + return None; + } + let parts: Vec<_> = value.split('.').collect(); + if parts.len() > 8 { + return None; + } + let mut numbers = parts + .iter() + .map(|part| { + (!part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit())) + .then(|| part.parse().ok()) + .flatten() + }) + .collect::>>()?; + while numbers.len() > 1 && numbers.last() == Some(&0) { + numbers.pop(); + } + Some(numbers) +} + +fn reject(field: &str) -> PluginRejection { + PluginRejection::at( + "plugin.toolchain.invalidParams", + field, + "Invalid toolchain snapshot or native evidence.", + ) +} + +fn bounded(value: &str, max: usize) -> bool { + !value.is_empty() && value.len() <= max && !value.chars().any(char::is_control) +} + +fn array<'a>(input: &'a Value, key: &str, max: usize) -> Validation<&'a [Value]> { + match input.get(key) { + None => Ok(&[]), + Some(value) => value + .as_array() + .filter(|items| items.len() <= max) + .map(Vec::as_slice) + .ok_or_else(|| reject(key)), + } +} + +fn enabled<'a>(registry: &'a Value, tool: &str) -> Option<&'a Value> { + registry["definitions"] + .as_array()? + .iter() + .find(|entry| entry["definition"]["id"] == tool) + .map(|entry| &entry["definition"]) +} + +fn capture(definition: &Value, output: &str, minimum: Option<&str>) -> Validation { + if output.len() > 128 * 1024 { + return Err(reject("output")); + } + let minimum = minimum + .map(|value| components(value).ok_or_else(|| reject("minimumVersion"))) + .transpose()?; + let expression = RegexBuilder::new(definition["version"]["pattern"].as_str().unwrap_or("")) + .size_limit(1024 * 1024) + .build() + .map_err(|_| reject("version.pattern"))?; + let captured = expression + .captures(output) + .and_then(|captures| captures.get(1)) + .map(|value| value.as_str()); + let version = captured.filter(|value| components(value).is_some()); + let satisfied = version.is_some_and(|version| { + minimum + .as_ref() + .is_none_or(|minimum| components(version).as_ref() >= Some(minimum)) + }); + Ok(json!({"satisfied":satisfied,"version":version})) +} + +/// Uses only the installed definition's regular expression, never a tool-name branch. +pub(crate) fn check_version(input: &Value) -> Validation { + fields(input, &["registry", "tool", "output", "minimumVersion"])?; + let registry = definitions::registry(&input["registry"])?; + let tool = input["tool"].as_str().ok_or_else(|| reject("tool"))?; + let definition = enabled(®istry, tool).ok_or_else(|| reject("tool"))?; + let output = input["output"].as_str().ok_or_else(|| reject("output"))?; + let minimum = input + .get("minimumVersion") + .map(|value| value.as_str().ok_or_else(|| reject("minimumVersion"))) + .transpose()?; + capture(definition, output, minimum) +} + +/// Exposes both installation routes per tool: definition-owned scans the host expands, +/// and enabled providers the host queries. Native expansion and checks stay in hosts. +pub(crate) fn discovery_plan(input: &Value) -> Validation { + fields(input, &["registry", "platform"])?; + let registry = definitions::registry(&input["registry"])?; + let platform = input["platform"] + .as_str() + .ok_or_else(|| reject("platform"))?; + if !matches!(platform, "macos" | "windows" | "linux") { + return Err(reject("platform")); + } + let tools = registry["definitions"] + .as_array() + .into_iter() + .flatten() + .map(|entry| { + let definition = &entry["definition"]; + let scans: Vec<_> = definition["discovery"] + .as_array() + .into_iter() + .flatten() + .filter(|scan| applicable(scan, platform)) + .cloned() + .collect(); + let providers: Vec<_> = registry["providers"] + .as_array() + .into_iter() + .flatten() + .filter(|provider| { + provider["provider"]["tools"] + .as_array() + .is_some_and(|tools| tools.contains(&definition["id"])) + }) + .map(|provider| { + format!( + "{}:{}", + provider["pluginID"].as_str().unwrap_or(""), + provider["provider"]["id"].as_str().unwrap_or("") + ) + }) + .collect(); + // Login-shell home variables the host reads, with the layout each one selects. + let environment: Vec<_> = definition["executables"] + .as_array() + .into_iter() + .flatten() + .filter(|value| value["kind"] == "environment" && applicable(value, platform)) + .map(|value| json!({"variable":value["variable"],"path":value.get("path")})) + .collect(); + json!({"tool":definition["id"],"pluginID":entry["pluginID"],"scans":scans,"providers":providers, + "homeLayouts":definition.get("homeLayouts").cloned().unwrap_or(json!([])), + "executableLayouts":definition["executableLayouts"],"version":definition["version"], + "pathPrepend":definition.get("pathPrepend").cloned().unwrap_or(json!([])), + "environment":environment}) + }) + .collect::>(); + Ok(json!({"tools":tools,"limits":{"roots":256,"entries":4096,"ancestors":64}})) +} + +/// Provenance of homes found by expanding definition-owned `discovery` scans. +const SYSTEM_PROVIDER: &str = "host:system"; + +/// One canonical home reported by several routes stays one installation. A provider +/// answer outranks the host scan, then qualified provider IDs give a stable label; +/// the label never decides between different homes. +fn attribution(selection: &Value) -> (bool, &str) { + let provider = selection["provider"].as_str().unwrap_or(""); + (provider == SYSTEM_PROVIDER, provider) +} + +fn applicable(value: &Value, platform: &str) -> bool { + value.get("platforms").is_none_or(|values| { + values + .as_array() + .is_some_and(|values| values.iter().any(|value| value == platform)) + }) +} + +/// A host-side value snapshot is not a provider reply or a persisted launch permit. +struct Snapshot<'a> { + input: &'a Value, + definition: &'a Value, + platform: &'a str, + case_sensitive: bool, + roots: Vec, + facts: &'a [Value], + /// Resolved targets implied by link facts; canonical selections are looked up here. + targets: Vec, + probes: BTreeMap, + minimum: Option<&'a str>, +} + +impl Snapshot<'_> { + fn key(&self, path: &str) -> Validation { + paths::normalize(path, self.platform, self.case_sensitive).ok_or_else(|| reject("path")) + } + + fn fact(&self, path: &str, kind: &str) -> Validation<&Value> { + let key = self.key(path)?; + self.facts + .iter() + .chain(&self.targets) + .find(|fact| { + fact["path"] + .as_str() + .and_then(|path| self.key(path).ok()) + .as_ref() + == Some(&key) + && fact["kind"] == kind + }) + .ok_or_else(|| reject("filesystem")) + } + + fn validate_facts(&self) -> Validation<()> { + let mut identities = BTreeSet::new(); + for fact in self.facts { + fields(fact, &["path", "canonicalPath", "kind", "executable"])?; + let path = fact["path"] + .as_str() + .ok_or_else(|| reject("filesystem.path"))?; + let canonical = fact["canonicalPath"] + .as_str() + .ok_or_else(|| reject("filesystem.canonicalPath"))?; + self.key(canonical)?; + if !identities.insert(self.key(path)?) + || !matches!( + fact["kind"].as_str(), + Some("file" | "directory" | "missing") + ) + || fact + .get("executable") + .is_some_and(|value| !value.is_boolean()) + { + return Err(reject("filesystem")); + } + } + for root in &self.roots { + self.fact(root, "directory")?; + } + Ok(()) + } + + fn checked(&self, home: &str, layout: Option<&str>) -> Validation { + query::validate_installation( + home, + self.definition, + self.facts, + &self.roots, + self.platform, + self.case_sensitive, + layout, + ) + } + + fn version(&self, executable: &str) -> Result { + let key = self.key(executable).map_err(|_| "unsafePath")?; + let probe = self.probes.get(&key).ok_or("probeFailed")?; + if probe.exit_code != 0 { + return Err("probeFailed"); + } + let checked = + capture(self.definition, &probe.output, self.minimum).map_err(|_| "probeFailed")?; + if checked["satisfied"] != true { + return Err("versionUnsatisfied"); + } + checked["version"] + .as_str() + .map(str::to_owned) + .ok_or("probeFailed") + } + + fn selection(&self, home: &str, provider: &str, layer: &str) -> Result { + self.layout_selection(home, None, provider, layer) + } + + /// Selects `home` through one declared executable layout, or the first present one. + fn layout_selection( + &self, + home: &str, + layout: Option<&str>, + provider: &str, + layer: &str, + ) -> Result { + let checked = self.checked(home, layout).map_err(|_| "unsafePath")?; + let executable = checked["executable"].as_str().ok_or("unsafePath")?; + let version = self.version(executable)?; + self.environment_selection( + checked["home"].as_str(), + executable, + Some(&version), + provider, + layer, + false, + ) + } + + fn environment_selection( + &self, + home: Option<&str>, + executable: &str, + version: Option<&str>, + provider: &str, + layer: &str, + project: bool, + ) -> Result { + let mut environment = serde_json::Map::new(); + for export in self.definition["exports"].as_array().into_iter().flatten() { + let value = match export["value"].as_str() { + Some("${home}") => home, + Some("${executable}") => Some(executable), + _ => None, + }; + if let Some(value) = value { + environment.insert( + export["name"].as_str().ok_or("unsafePath")?.to_owned(), + json!(value), + ); + } + } + let mut prepend = Vec::new(); + if let Some(home) = home { + for template in self.definition["pathPrepend"] + .as_array() + .into_iter() + .flatten() + { + let suffix = template + .as_str() + .and_then(|template| template.strip_prefix("${home}")) + .ok_or("unsafePath")?; + let path = format!("{home}{suffix}"); + let fact = self.fact(&path, "directory").map_err(|_| "unsafePath")?; + let canonical = fact["canonicalPath"].as_str().ok_or("unsafePath")?; + if !paths::within(canonical, home, self.platform, self.case_sensitive) { + return Err("unsafePath"); + } + prepend.push(canonical.to_owned()); + } + } + Ok( + json!({"home":home,"executable":executable,"version":version,"provider":provider, + "layer":layer,"projectCode":project,"environment":environment,"pathPrepend":prepend}), + ) + } +} + +fn reason(code: &str, tool: &str) -> Value { + json!({"code":code,"tool":tool}) +} + +fn trace(trace: &mut Vec, id: &str, layer: &str, state: &str, reason: Option) { + let mut row = json!({"candidateID":id,"layer":layer,"state":state}); + if let Some(reason) = reason { + row["reason"] = reason; + } + trace.push(row); +} + +fn output( + input: &Value, + outcome: Value, + trace: Vec, + unmet: Vec, + conflicts: Vec, +) -> Value { + json!({"operationID":input["operationID"],"tool":input["tool"],"directory":input["directory"], + "outcome":outcome,"trace":trace,"unmet":unmet,"conflicts":conflicts}) +} + +/// Project sources are merged before attempting a candidate; distance wins over kind. +struct ProjectChoice { + anchor: String, + category: u8, + native: bool, + value: Value, + candidate: bool, +} + +fn ancestors(directory: &str) -> Vec<&str> { + let mut result = vec![directory]; + let mut current = directory; + while current != "." && result.len() < 64 { + current = current.rsplit_once('/').map_or(".", |(parent, _)| parent); + result.push(current); + } + result +} + +fn project_selection( + snapshot: &Snapshot<'_>, + choice: &ProjectChoice, +) -> Result { + if !choice.candidate { + let home = choice.value["installation"] + .as_str() + .ok_or("requestedNotInstalled")?; + return snapshot.selection( + home, + choice.value["provider"].as_str().unwrap_or(""), + "project", + ); + } + if snapshot.input["projectCodeAllowed"] != true { + return Err("projectCodeRequiresAction"); + } + let path = choice.value["nativePath"].as_str().ok_or("unsafePath")?; + let file = snapshot.fact(path, "file").map_err(|_| "notFound")?; + if file["executable"] != true { + return Err("notExecutable"); + } + let canonical = file["canonicalPath"].as_str().ok_or("unsafePath")?; + if snapshot.key(canonical).map_err(|_| "unsafePath")? + != snapshot.key(path).map_err(|_| "unsafePath")? + { + // Native link-chain identities are not available in this Core-only slice. + return Err("unsafePath"); + } + let home = choice.value["nativeHome"].as_str(); + if let Some(home) = home { + let fact = snapshot.fact(home, "directory").map_err(|_| "unsafePath")?; + let canonical_home = fact["canonicalPath"].as_str().ok_or("unsafePath")?; + if snapshot.key(canonical_home).map_err(|_| "unsafePath")? + != snapshot.key(home).map_err(|_| "unsafePath")? + || !snapshot.roots.iter().any(|root| { + paths::within( + canonical_home, + root, + snapshot.platform, + snapshot.case_sensitive, + ) + }) + { + return Err("unsafePath"); + } + } + // A lexical project path must never turn into a provider-owned installation. + if !snapshot + .roots + .iter() + .any(|root| paths::within(path, root, snapshot.platform, snapshot.case_sensitive)) + { + return Err("unsafePath"); + } + if home.is_none() + && snapshot.definition["launch"] + .as_array() + .into_iter() + .flatten() + .any(|token| token == "${home}") + { + return Err("homeUnavailable"); + } + // Preserve the lexical interpreter path for project environments (including venv). + let version = match snapshot.version(path) { + Ok(version) => Some(version), + Err(code) if snapshot.minimum.is_some() => return Err(code), + Err(_) => None, + }; + snapshot.environment_selection( + home, + path, + version.as_deref(), + "host:project", + "project", + true, + ) +} + +/// Validates the native evidence shared by resolution and candidate listing: platform +/// and case policy, workspace roots, filesystem facts and bounded probe outputs. +fn prepare<'a>(input: &'a Value, definition: &'a Value) -> Validation<(Snapshot<'a>, String)> { + let platform = input["platform"] + .as_str() + .filter(|platform| matches!(*platform, "macos" | "windows" | "linux")) + .ok_or_else(|| reject("platform"))?; + let case_sensitive = input + .get("filesystemCaseSensitive") + .map(|value| { + value + .as_bool() + .ok_or_else(|| reject("filesystemCaseSensitive")) + }) + .transpose()? + .unwrap_or(platform != "windows"); + let root = input["workspaceRoot"] + .as_str() + .and_then(|root| paths::normalize(root, platform, true)) + .ok_or_else(|| reject("workspaceRoot"))?; + let mut roots = vec![root.clone()]; + for value in array(input, "workspaceRoots", 64)? { + roots.push( + value + .as_str() + .ok_or_else(|| reject("workspaceRoots"))? + .to_owned(), + ); + } + let facts = array(input, "filesystem", 16384)?; + let minimum = input + .get("minimumVersion") + .map(|value| { + value + .as_str() + .filter(|value| components(value).is_some()) + .ok_or_else(|| reject("minimumVersion")) + }) + .transpose()?; + let mut probes = BTreeMap::new(); + for value in array(input, "probes", 1024)? { + let probe: Probe = serde_json::from_value(value.clone()).map_err(|_| reject("probes"))?; + if probe.output.len() > 128 * 1024 { + return Err(reject("probes.output")); + } + let key = paths::normalize(&probe.executable, platform, case_sensitive) + .ok_or_else(|| reject("probes.executable"))?; + if probes.insert(key, probe).is_some() { + return Err(reject("probes")); + } + } + let mut snapshot = Snapshot { + input, + definition, + platform, + case_sensitive, + roots, + facts, + targets: Vec::new(), + probes, + minimum, + }; + snapshot.validate_facts()?; + snapshot.targets = query::implied_targets(facts, platform, case_sensitive) + .map_err(|_| reject("filesystem"))?; + if snapshot.key( + snapshot.fact(&root, "directory")?["canonicalPath"] + .as_str() + .ok_or_else(|| reject("workspaceRoot"))?, + )? != snapshot.key(&root)? + { + return Err(reject("workspaceRoot")); + } + Ok((snapshot, root)) +} + +/// Host-observed string maps (login-shell variables, command homes, explicit choices). +fn string_maps(input: &Value, keys: &[&str]) -> Validation<()> { + for key in keys { + if let Some(value) = input.get(*key) { + let map = value + .as_object() + .filter(|map| map.len() <= 256) + .ok_or_else(|| reject(key))?; + if map.values().any(|value| { + !value + .as_str() + .is_some_and(|value| value.is_empty() || bounded(value, 4096)) + }) { + return Err(reject(key)); + } + } + } + Ok(()) +} + +/// Homes the host found by expanding this tool's `discovery` scans. +fn system_installations(input: &Value, tool: &str) -> Validation> { + let mut installations = Vec::new(); + for value in array(input, "systemInstallations", 512)? { + fields(value, &["tool", "home"])?; + if value["tool"] == tool { + let home = value["home"] + .as_str() + .ok_or_else(|| reject("systemInstallations"))?; + installations.push(json!({"tool":tool,"home":home,"provider":SYSTEM_PROVIDER})); + } + } + Ok(installations) +} + +/// Homes `toolchains.decodeReply` accepted from a provider declaring `tool`, kept +/// under that provider's identity; one the registry does not list is refused. +fn provider_installations(input: &Value, tool: &str, registry: &Value) -> Validation> { + let mut installations = Vec::new(); + for value in array(input, "providerInstallations", 512)? { + fields(value, &["tool", "home", "provider"])?; + if value["tool"] != tool { + continue; + } + let home = value["home"] + .as_str() + .ok_or_else(|| reject("providerInstallations"))?; + let provider = value["provider"] + .as_str() + .and_then(|provider| provider.rsplit_once(':')) + .filter(|(plugin, provider)| { + registry["providers"].as_array().is_some_and(|entries| { + entries.iter().any(|entry| { + entry["pluginID"] == *plugin + && entry["provider"]["id"] == *provider + && entry["provider"]["tools"] + .as_array() + .is_some_and(|tools| tools.iter().any(|known| known == tool)) + }) + }) + }) + .ok_or_else(|| reject("providerInstallations"))?; + installations.push( + json!({"tool":tool,"home":home,"provider":format!("{}:{}", provider.0, provider.1)}), + ); + } + Ok(installations) +} + +/// The automatic layer's candidate set, keyed by canonical home. +struct Automatic { + eligible: BTreeMap, + /// Reported installations that failed a check, with the executable to probe when + /// the layout itself was valid. + skipped: BTreeMap, + failure: Option<&'static str>, +} + +fn automatic_set(snapshot: &Snapshot<'_>, installations: &[Value]) -> Validation { + let mut set = Automatic { + eligible: BTreeMap::new(), + skipped: BTreeMap::new(), + failure: None, + }; + for installation in installations { + let Some(home) = installation["home"].as_str() else { + continue; + }; + let provider = installation["provider"].as_str().unwrap_or(""); + match snapshot.selection(home, provider, "automatic") { + Ok(value) => { + let key = snapshot.key(value["home"].as_str().unwrap_or(""))?; + if set + .eligible + .get(&key) + .is_none_or(|kept: &Value| attribution(&value) < attribution(kept)) + { + set.eligible.insert(key, value); + } + } + Err(code) => { + set.failure = Some(code); + let checked = snapshot.checked(home, None).ok(); + let canonical = checked + .as_ref() + .and_then(|value| value["home"].as_str()) + .unwrap_or(home); + let mut row = json!({"home":canonical,"provider":provider,"reason":code}); + if let Some(executable) = checked.as_ref().map(|value| &value["executable"]) { + row["executable"] = executable.clone(); + } + let key = snapshot + .key(canonical) + .unwrap_or_else(|_| canonical.to_owned()); + if set + .skipped + .get(&key) + .is_none_or(|kept: &Value| attribution(&row) < attribution(kept)) + { + set.skipped.insert(key, row); + } + } + } + } + set.skipped.retain(|key, _| !set.eligible.contains_key(key)); + Ok(set) +} + +/// Picks the highest version; equal highest versions in different homes need `chosen`, +/// otherwise they become an `automatic` conflict naming every route. +fn automatic_choice( + snapshot: &Snapshot<'_>, + eligible: BTreeMap, + chosen: Option<&str>, +) -> Validation<(Option, Option)> { + let newest = eligible + .values() + .filter_map(|value| components(value["version"].as_str()?)) + .max(); + let mut best: Vec<_> = eligible + .into_values() + .filter(|value| value["version"].as_str().and_then(components) == newest) + .collect(); + if best.len() <= 1 { + return Ok((best.pop(), None)); + } + if let Some(chosen) = chosen { + let identity = snapshot.key(chosen)?; + let selected = best + .iter() + .find(|value| { + value["home"] + .as_str() + .and_then(|home| snapshot.key(home).ok()) + .as_ref() + == Some(&identity) + }) + .cloned(); + if selected.is_some() { + return Ok((selected, None)); + } + } + let providers: BTreeSet<_> = best + .iter() + .filter_map(|value| value["provider"].as_str()) + .collect(); + let installations: Vec<_> = best.iter().map(|value| value["home"].clone()).collect(); + Ok(( + None, + Some( + json!({"scope":"automatic","providers":providers,"installations":installations, + "action":"chooseInstallation"}), + ), + )) +} + +/// Lists what a settings page shows for one tool's global and automatic layers without +/// resolving a directory: login-shell homes and the discovered installations with their +/// checked executables, versions or skip reasons, and Core's automatic choice. Hosts +/// probe only the executables returned here, then call again with `probes`. +pub(crate) fn candidates(input: &Value) -> Validation { + fields( + input, + &[ + "registry", + "tool", + "platform", + "filesystemCaseSensitive", + "workspaceRoot", + "workspaceRoots", + "filesystem", + "minimumVersion", + "probes", + "systemInstallations", + "providerInstallations", + "environment", + ], + )?; + let registry = definitions::registry(&input["registry"])?; + let tool = input["tool"] + .as_str() + .filter(|tool| definitions::tool_id(tool)) + .ok_or_else(|| reject("tool"))?; + let definition = enabled(®istry, tool).ok_or_else(|| reject("tool"))?; + let (snapshot, _) = prepare(input, definition)?; + string_maps(input, &["environment"])?; + let mut shell = Vec::new(); + for candidate in definition["executables"] + .as_array() + .into_iter() + .flatten() + .filter(|value| value["kind"] == "environment" && applicable(value, snapshot.platform)) + { + let Some(variable) = candidate["variable"].as_str() else { + continue; + }; + let Some(home) = input["environment"][variable] + .as_str() + .filter(|value| !value.is_empty()) + else { + continue; + }; + let layout = candidate["path"].as_str(); + let mut row = json!({"candidateID":candidate["id"],"variable":variable,"home":home}); + if let Ok(checked) = snapshot.checked(home, layout) { + row["home"] = checked["home"].clone(); + row["executable"] = checked["executable"].clone(); + } + match snapshot.layout_selection(home, layout, "host:shell", "global") { + Ok(value) => row["version"] = value["version"].clone(), + Err(code) => row["reason"] = json!(code), + } + shell.push(row); + } + let mut reported = provider_installations(input, tool, ®istry)?; + reported.extend(system_installations(input, tool)?); + let set = automatic_set(&snapshot, &reported)?; + let mut installations: Vec<_> = set + .eligible + .values() + .map(|value| { + json!({"home":value["home"],"executable":value["executable"], + "version":value["version"],"provider":value["provider"]}) + }) + .collect(); + installations.sort_by(|left, right| { + let version = |value: &Value| value["version"].as_str().and_then(components); + version(right) + .cmp(&version(left)) + .then_with(|| left["home"].as_str().cmp(&right["home"].as_str())) + }); + let skipped: Vec<_> = set.skipped.into_values().collect(); + let (selected, conflict) = automatic_choice(&snapshot, set.eligible, None)?; + Ok( + json!({"tool":tool,"shell":shell,"installations":installations,"skipped":skipped, + "selected":selected.map(|value| value["home"].clone()), + "conflicts":conflict.into_iter().collect::>()}), + ) +} + +/// Resolves definitions, provider replies, pins and trusted native observations once. +pub(crate) fn resolve(input: &Value) -> Validation { + fields( + input, + &[ + "registry", + "tool", + "directory", + "operationID", + "local", + "platform", + "filesystemCaseSensitive", + "workspaceRoot", + "workspaceRoots", + "filesystem", + "minimumVersion", + "probes", + "queries", + "systemInstallations", + "globalDefault", + "environment", + "commandHomes", + "projectCodeAllowed", + "providerChoices", + "generation", + "context", + ], + )?; + let registry = definitions::registry(&input["registry"])?; + let tool = input["tool"] + .as_str() + .filter(|tool| definitions::tool_id(tool)) + .ok_or_else(|| reject("tool"))?; + let directory = input["directory"] + .as_str() + .ok_or_else(|| reject("directory"))?; + input["operationID"] + .as_str() + .filter(|value| bounded(value, 128)) + .ok_or_else(|| reject("operationID"))?; + let document = input + .get("local") + .cloned() + .unwrap_or(json!({"version":1,"toolchains":{}})); + let pin = pins::resolve(&json!({"document":document,"directory":directory,"tool":tool}))?; + let Some(definition) = enabled(®istry, tool) else { + return Ok(output( + input, + json!({"kind":"skip","reason":reason("definitionUnavailable",tool)}), + vec![], + vec![], + vec![], + )); + }; + let generation = input["generation"] + .as_u64() + .filter(|value| *value > 0) + .ok_or_else(|| reject("generation"))?; + let (snapshot, root) = prepare(input, definition)?; + let platform = snapshot.platform; + let case_sensitive = snapshot.case_sensitive; + if input + .get("projectCodeAllowed") + .is_some_and(|value| !value.is_boolean()) + { + return Err(reject("projectCodeAllowed")); + } + string_maps(input, &["environment", "commandHomes", "providerChoices"])?; + if let Some(choices) = input.get("providerChoices") { + fields(choices, &["project", "global", "automatic"])?; + } + // 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( + home, + pin["pin"]["provider"].as_str().unwrap_or(""), + "pinned", + ); + return Ok(match result { + Ok(selection) => output( + input, + json!({"kind":"resolved","selection":selection}), + vec![json!({"candidateID":"pinned","layer":"pinned","state":"resolved"})], + vec![], + vec![], + ), + Err(code) => output( + input, + json!({"kind":"terminalFailure","reason":reason(code,tool)}), + vec![ + json!({"candidateID":"pinned","layer":"pinned","state":"terminalFailure","reason":reason(code,tool)}), + ], + vec![], + vec![], + ), + }); + } + let mut installations = Vec::new(); + let mut statements = Vec::new(); + let mut unmet = Vec::new(); + let mut query_providers = BTreeSet::new(); + for value in array(input, "queries", 256)? { + if value["registry"] != input["registry"] { + return Err(reject("queries.registry")); + } + if value["platform"] != platform { + return Err(reject("queries.platform")); + } + if value["request"]["params"]["workspace"]["root"] + .as_str() + .and_then(|path| snapshot.key(path).ok()) + != Some(snapshot.key(&root)?) + { + return Err(reject("queries.workspace")); + } + let requested_directory = value["request"]["params"]["workspace"]["directory"] + .as_str() + .unwrap_or(""); + if requested_directory != directory { + return Err(reject("queries.directory")); + } + if value["filesystem"] != input["filesystem"] { + return Err(reject("queries.filesystem")); + } + let query_case = value + .get("filesystemCaseSensitive") + .and_then(Value::as_bool) + .unwrap_or(platform != "windows"); + if query_case != case_sensitive + || value.get("workspaceRoots") != input.get("workspaceRoots") + { + return Err(reject("queries.filesystemPolicy")); + } + if value["currentGeneration"].as_u64() != Some(generation) { + return Err(reject("queries.generation")); + } + if value["request"]["params"]["context"] != input["context"] { + return Err(reject("queries.context")); + } + if !query_providers.insert(( + value["pluginID"].to_string(), + value["request"]["params"]["providerID"].to_string(), + )) { + return Err(reject("queries.providerID")); + } + let decoded = match query::decode(value) { + Ok(decoded) => decoded, + Err(error) + if matches!( + error.code, + "plugin.toolchain.timeout" | "plugin.toolchain.providerUnavailable" + ) => + { + // Only a provider asked about this tool leaves it without an answer; `cause` + // keeps a missed deadline apart from a backend that could not answer at all. + if value["request"]["params"]["tools"] + .as_array() + .is_some_and(|tools| tools.iter().any(|asked| asked == tool)) + { + unmet.push(json!({"code":"providerUnavailable","tool":tool, + "provider":format!("{}:{}",value["pluginID"].as_str().unwrap_or(""),value["request"]["params"]["providerID"].as_str().unwrap_or("")), + "cause":error.code})); + } + continue; + } + Err(error) => return Err(error), + }; + installations.extend( + decoded["installations"] + .as_array() + .into_iter() + .flatten() + .filter(|value| value["tool"] == tool) + .cloned(), + ); + statements.extend( + decoded["statements"] + .as_array() + .into_iter() + .flatten() + .filter(|value| value["tool"] == tool) + .cloned(), + ); + unmet.extend( + decoded["diagnostics"] + .as_array() + .into_iter() + .flatten() + .filter(|value| value["tool"] == tool && value["code"] != "requestedNotInstalled") + .cloned(), + ); + } + installations.extend(system_installations(input, tool)?); + let candidates: Vec<_> = definition["executables"] + .as_array() + .into_iter() + .flatten() + .filter(|value| applicable(value, platform)) + .collect(); + let mut trace_rows = Vec::new(); + let mut conflicts = Vec::new(); + let mut outcome = None; + let mut project_done = false; + if let Some(default) = input.get("globalDefault").filter(|value| !value.is_null()) { + fields(default, &["home"])?; + default["home"] + .as_str() + .filter(|value| bounded(value, 4096)) + .ok_or_else(|| reject("globalDefault.home"))?; + } + // Explicit application defaults obey the same terminal policy without a plugin slot. + let default_preflight = input + .get("globalDefault") + .filter(|value| !value.is_null()) + .is_some() + && !candidates + .iter() + .any(|value| value["kind"] == "setting" && value["layer"] == "global"); + let mut default_checked = !default_preflight; + for candidate in &candidates { + let kind = candidate["kind"].as_str().unwrap_or(""); + let id = candidate["id"].as_str().unwrap_or(kind); + let layer = match kind { + "setting" if candidate["layer"] == "project" => "pinned", + "projectFile" => "project", + "toolchain" if candidate["scope"] == "project" => "project", + "toolchain" if candidate["scope"] == "installed" => "automatic", + _ => "global", + }; + if !default_checked && matches!(layer, "global" | "automatic") && outcome.is_none() { + default_checked = true; + let home = input["globalDefault"]["home"] + .as_str() + .ok_or_else(|| reject("globalDefault"))?; + let result = snapshot.selection(home, "host:default", "global"); + outcome = Some(match result { + Ok(selection) => json!({"kind":"resolved","selection":selection}), + Err(code) => json!({"kind":"terminalFailure","reason":reason(code,tool)}), + }); + } + if outcome.is_some() { + trace(&mut trace_rows, id, layer, "shadowed", None); + continue; + } + let mut selected = None; + let mut failure = None; + let mut terminal = false; + match (kind, layer) { + ("setting", "pinned") => { + if let Some(home) = pin["pin"]["home"].as_str() { + match snapshot.selection( + home, + pin["pin"]["provider"].as_str().unwrap_or(""), + layer, + ) { + Ok(value) => selected = Some(value), + Err(code) => { + failure = Some(reason(code, tool)); + terminal = true; + } + } + } + } + ("setting", _) => { + if let Some(value) = input.get("globalDefault").filter(|value| !value.is_null()) { + let home = value["home"] + .as_str() + .ok_or_else(|| reject("globalDefault"))?; + match snapshot.selection(home, "host:default", layer) { + Ok(value) => selected = Some(value), + Err(code) => { + failure = Some(reason(code, tool)); + terminal = true; + } + } + } + } + (_, "project") if !project_done => { + project_done = true; + let mut choices = Vec::new(); + for source in &candidates { + if source["kind"] != "projectFile" { + continue; + } + for ancestor in ancestors(directory) { + let anchor = if ancestor == "." { + root.clone() + } else { + paths::join(&root, ancestor, platform) + .ok_or_else(|| reject("directory"))? + }; + let native = + paths::join(&anchor, source["path"].as_str().unwrap_or(""), platform) + .ok_or_else(|| reject("projectFile"))?; + if snapshot.fact(&native, "file").is_err() { + continue; + } + let markers = + source["requires"] + .as_array() + .into_iter() + .flatten() + .all(|marker| { + marker + .as_str() + .and_then(|marker| paths::join(&anchor, marker, platform)) + .is_some_and(|path| snapshot.fact(&path, "file").is_ok()) + }); + if !markers { + continue; + } + let mut value = (*source).clone(); + value["nativePath"] = json!(native); + if let Some(home) = source["home"].as_str() { + value["nativeHome"] = json!(if home == "." { + anchor.clone() + } else { + paths::join(&anchor, home, platform) + .ok_or_else(|| reject("projectFile.home"))? + }); + } + choices.push(ProjectChoice { + anchor, + category: if source["projectKind"] == "environment" { + 1 + } else { + 0 + }, + native: true, + value, + candidate: true, + }); + } + } + if candidates + .iter() + .any(|value| value["kind"] == "toolchain" && value["scope"] == "project") + { + for statement in statements + .iter() + .filter(|value| value["scope"] == "project") + { + let anchor = statement["sourceDirectory"] + .as_str() + .ok_or_else(|| reject("statements.sourceDirectory"))?; + choices.push(ProjectChoice { + anchor: anchor.to_owned(), + category: if statement["kind"] == "environment" { + 1 + } else { + 2 + }, + native: statement["reader"] == "native", + value: statement.clone(), + candidate: false, + }); + } + } + choices.sort_by_key(|value| { + ( + std::cmp::Reverse(value.anchor.split('/').count()), + value.category, + ) + }); + // The nearest declaration masks inherited declarations even when unmet. + if let Some(nearest) = choices.first().map(|value| value.anchor.split('/').count()) + { + choices.retain(|value| value.anchor.split('/').count() == nearest); + } + while let Some(first) = choices.first() { + let depth = first.anchor.split('/').count(); + let category = first.category; + let count = choices + .iter() + .take_while(|value| { + value.anchor.split('/').count() == depth && value.category == category + }) + .count(); + let mut group: Vec<_> = choices.drain(..count).collect(); + let native_sources: BTreeSet<_> = group + .iter() + .filter(|value| value.native && !value.candidate) + .map(|value| value.value["source"].clone().to_string()) + .collect(); + group.retain(|value| { + value.native || !native_sources.contains(&value.value["source"].to_string()) + }); + let homes: BTreeSet<_> = group + .iter() + .filter_map(|value| value.value["installation"].as_str().map(str::to_owned)) + .collect(); + let requests: BTreeSet<_> = group + .iter() + .filter(|value| !value.candidate) + .map(|value| { + ( + value.value["request"].to_string(), + value.value["installation"].to_string(), + ) + }) + .collect(); + let choice = input["providerChoices"]["project"].as_str(); + if let Some(chosen) = choice { + group.retain(|value| value.candidate || value.value["provider"] == chosen); + } + let retained: BTreeSet<_> = group + .iter() + .filter(|value| !value.candidate) + .map(|value| { + ( + value.value["request"].to_string(), + value.value["installation"].to_string(), + ) + }) + .collect(); + if (choice.is_none() && (homes.len() > 1 || requests.len() > 1)) + || retained.len() > 1 + { + let providers: BTreeSet<_> = group + .iter() + .filter_map(|value| value.value["provider"].as_str()) + .collect(); + conflicts.push(json!({"scope":"project","providers":providers,"action":"chooseProvider"})); + failure = Some(reason("providerConflict", tool)); + break; + } + for choice in &group { + match project_selection(&snapshot, choice) { + Ok(value) => { + selected = Some(value); + break; + } + Err(code) => { + let mut why = reason(code, tool); + for key in ["provider", "source", "request"] { + if let Some(value) = choice.value.get(key) { + why[key] = value.clone(); + } + } + if code == "requestedNotInstalled" && !unmet.contains(&why) { + unmet.push(why.clone()); + } + failure = Some(why); + } + } + } + if selected.is_some() { + break; + } + } + } + (_, "project") => continue, + ("toolchain", "global") => { + let globals: Vec<_> = statements + .iter() + .filter(|value| value["scope"] == "global") + .collect(); + let chosen = input["providerChoices"]["global"].as_str(); + let globals: Vec<_> = globals + .into_iter() + .filter(|value| chosen.is_none_or(|chosen| value["provider"] == chosen)) + .collect(); + let homes: BTreeSet<_> = globals + .iter() + .filter_map(|value| value["installation"].as_str()) + .collect(); + let requests: BTreeSet<_> = globals + .iter() + .map(|value| { + ( + value["request"].to_string(), + value["installation"].to_string(), + ) + }) + .collect(); + if homes.len() > 1 || requests.len() > 1 { + let providers: BTreeSet<_> = globals + .iter() + .filter_map(|value| value["provider"].as_str()) + .collect(); + conflicts.push( + json!({"scope":"global","providers":providers,"action":"chooseProvider"}), + ); + failure = Some(reason("providerConflict", tool)); + } else if let Some(value) = globals.first() { + if let Some(home) = value["installation"].as_str() { + match snapshot.selection( + home, + value["provider"].as_str().unwrap_or(""), + layer, + ) { + Ok(value) => selected = Some(value), + Err(code) => failure = Some(reason(code, tool)), + } + } else { + let why = json!({"code":"requestedNotInstalled","tool":tool,"provider":value["provider"],"source":value["source"],"request":value["request"]}); + unmet.push(why.clone()); + failure = Some(why); + } + } + } + ("toolchain", "automatic") => { + let set = automatic_set(&snapshot, &installations)?; + failure = set.failure.map(|code| reason(code, tool)); + let (choice, conflict) = automatic_choice( + &snapshot, + set.eligible, + input["providerChoices"]["automatic"].as_str(), + )?; + selected = choice; + if let Some(conflict) = conflict { + conflicts.push(conflict); + failure = Some(reason("ambiguousInstallation", tool)); + } + } + ("environment", _) => { + if let Some(home) = candidate["variable"] + .as_str() + .and_then(|name| input["environment"][name].as_str()) + .filter(|value| !value.is_empty()) + { + match snapshot.layout_selection( + home, + candidate["path"].as_str(), + "host:shell", + layer, + ) { + Ok(value) => selected = Some(value), + Err(code) => failure = Some(reason(code, tool)), + } + } + } + ("command", _) => { + if let Some(home) = candidate["command"] + .as_str() + .and_then(|name| input["commandHomes"][name].as_str()) + { + match snapshot.selection(home, "host:shell", layer) { + Ok(value) => selected = Some(value), + Err(code) => failure = Some(reason(code, tool)), + } + } + } + _ => return Err(reject("executables.kind")), + } + if let Some(selection) = selected { + trace(&mut trace_rows, id, layer, "resolved", None); + outcome = Some(json!({"kind":"resolved","selection":selection})); + } else if let Some(why) = failure { + trace( + &mut trace_rows, + id, + layer, + if terminal { "terminalFailure" } else { "skip" }, + Some(why.clone()), + ); + if terminal { + outcome = Some(json!({"kind":"terminalFailure","reason":why})); + } + } else { + trace(&mut trace_rows, id, layer, "empty", None); + } + } + if outcome.is_none() && !default_checked { + let home = input["globalDefault"]["home"] + .as_str() + .ok_or_else(|| reject("globalDefault"))?; + outcome = Some(match snapshot.selection(home, "host:default", "global") { + Ok(selection) => json!({"kind":"resolved","selection":selection}), + Err(code) => json!({"kind":"terminalFailure","reason":reason(code,tool)}), + }); + } + Ok(output( + input, + outcome.unwrap_or_else(|| json!({"kind":"skip","reason":reason("notFound",tool)})), + trace_rows, + unmet, + conflicts, + )) +} diff --git a/shared/contracts/plugins/v1/README.md b/shared/contracts/plugins/v1/README.md index 0a99c04483..bd32e08157 100644 --- a/shared/contracts/plugins/v1/README.md +++ b/shared/contracts/plugins/v1/README.md @@ -12,6 +12,7 @@ This directory is the cross-platform source of truth: | --- | --- | | `plugin-manifest.schema.json` | JSON shape of `plugin.json` | | `fixtures/*.plugin.json` | Valid example manifests, named after the capability they exercise rather than a published plugin: `language-*` (packaged, system-command, and host-integration language servers; `node-runtime` for a discovered runtime), `hello-lithe` and `web-panel` (UI), `settings-v2`, and `build-*` (level 3 build systems and the two forms of a build provider) | +| `fixtures/tool-definition.plugin.json`, `fixtures/toolchain-provider.plugin.json` | Production-shape Core fixtures for tool definitions and a packaged toolchain provider; require explicit new host capabilities, not yet supplied by native products | | `fixtures/invalid-manifests.json` | Edits to a valid manifest and the rejection code every host must report | | `fixtures/command-plans.json` | Golden run/test command expansions and language-server launch plans | | `fixtures/runtime-versions.json` | Golden outcomes of the launch runtime version check | @@ -21,6 +22,8 @@ This directory is the cross-platform source of truth: | `fixtures/project-plans.json` | Golden `project.*` form evaluations, generation plans, and name checks | | `plugin-index.schema.json` | JSON shape of a marketplace source index | | `fixtures/marketplace/*.json` | Marketplace index, availability, source URL, and install review golden cases | +| `toolchains.md` | Implemented Core foundation: plugin definitions/provider declarations, verified-origin registry, reply validation, directory pins, snapshot resolution, version matching and discovery plans; native hosts are not connected | +| `proposals/toolchains/` | Broader design draft, including unimplemented query transport, native lifecycle/persistence, consumer migration, settings UI and manager plugins; examples are review projections, not installable manifests | Validation and template expansion are implemented once in Rust Core (`rust/lithe-core/src/plugins/`, with build systems in @@ -37,6 +40,30 @@ be synced to that repository, so links in this file that leave this directory are absolute. `plugin.json` may not carry a `$schema` key, because unknown fields are rejected; editors associate the schema by file name instead. +## Plugin-owned toolchains (Core foundation) + +The [toolchains draft](proposals/toolchains/README.md) records the replacement +design discussed in [PR #1581](https://github.com/1lck/Lithe-IDEA/pull/1581#issuecomment-6541366112). +All tool definitions belong to enabled plugins; the host supplies only generic +query, validation, resolution, persistence, and presentation mechanisms. It +does not add a built-in tool-definition table or interpret version-manager +configuration formats. + +The [implemented Core contract](toolchains.md) now accepts `toolDefinitions: 1` +and `toolchainProviders: 1` declarations and provides six `toolchains.*` commands. +`executableResolution: 2` applies to tool-definition candidates only; it does not +admit new sources into existing Build candidate sets. Definitions are entirely +plugin-owned, with reserved short IDs checked against host-verified official +provenance when constructing the registry. + +Neither native host advertises or integrates this foundation yet. Core consumes +captured host facts; it does not start a provider, run a probe, watch files, scan +the system, persist pins, or change current Java/Maven selection. The broader +proposal remains unsupported where it exceeds this implemented slice. In +particular, `toolchainConsumers`, generic runtime launches, settings pages, mise +and SDKMAN providers, and live backend activation are not implemented here. +Proposal envelopes remain separate from production manifests. + ## Package layout ```text @@ -69,6 +96,8 @@ symbolic links. A host treats an installed package as read-only. | `settings` | User-adjustable plugin options (level 2 and later). | | `ui` | Host-rendered tool windows and commands (level 2 and later). | | `buildSystems` | Project-level build tools (level 3). | +| `toolDefinitions` | Plugin-owned tool policy ([Core foundation](toolchains.md)); requires `toolDefinitions: 1` and `executableResolution: 2`; native adapters are not connected. | +| `toolchainProviders` | Backend fact-provider declarations ([Core foundation](toolchains.md)); requires `toolchainProviders: 1`, backend/server-process capabilities and `toolchains.provide`. | Unknown fields are refused (`plugin.manifest.invalid`) so an older host rejects a package instead of ignoring part of it. At levels 1 and 2 a new @@ -484,6 +513,9 @@ hosts. Level 3 versions each section on its own. A level 3 manifest lists in | `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` | | `backend` | [`backend`](#plugin-backend): the plugin's own long-lived process | `1`, `2` (adds [start without a page and workspace contexts](#backend-2-workspace-contexts)) | +| `toolDefinitions` | [Tool identities, layouts, discovery and four-layer candidates](toolchains.md) | `1` (Core only) | +| `toolchainProviders` | [Provider declarations and permission-bound answer validation](toolchains.md) | `1` (Core only) | +| `executableResolution` | [Shared executable candidates](#shared-executable-candidates-executableresolution-1); v2 adds tool-definition candidates without changing existing Build candidate sets | `1`, `2` (v2 Core only) | Version 1 of every capability means what its section means at level 2 (runtime launches, launch templates, `initializationOptions`, and so on), so diff --git a/shared/contracts/plugins/v1/fixtures/tool-definition.plugin.json b/shared/contracts/plugins/v1/fixtures/tool-definition.plugin.json new file mode 100644 index 0000000000..532821f8ce --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/tool-definition.plugin.json @@ -0,0 +1,159 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.fixture.java-support", + "displayName": "Tool definition fixture", + "version": "1.0.0", + "pluginAPIVersion": 3, + "requires": { + "toolDefinitions": 1, + "executableResolution": 2 + }, + "vendor": { + "id": "org.example", + "displayName": "Fixture publisher", + "signatureRequirement": "publisherPackage" + }, + "permissions": [], + "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}" + ] + } + ] +} diff --git a/shared/contracts/plugins/v1/fixtures/toolchain-provider.plugin.json b/shared/contracts/plugins/v1/fixtures/toolchain-provider.plugin.json new file mode 100644 index 0000000000..ee9bb19802 --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/toolchain-provider.plugin.json @@ -0,0 +1,69 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.fixture.mise", + "displayName": "Toolchain provider fixture", + "version": "1.0.0", + "pluginAPIVersion": 3, + "requires": { + "toolchainProviders": 1, + "backend": 2, + "serverProcess": 1 + }, + "vendor": { + "id": "org.example", + "displayName": "Fixture publisher", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "backend.launch", + "toolchains.provide" + ], + "toolchainProviders": [ + { + "id": "mise", + "displayName": "mise", + "tools": [ + "java", + "node", + "maven", + "pnpm", + "go" + ], + "watchFiles": [ + { + "pattern": "mise.toml", + "reader": "native" + }, + { + "pattern": "mise.local.toml", + "reader": "native" + }, + { + "pattern": ".mise.toml", + "reader": "native" + }, + { + "pattern": ".tool-versions", + "reader": "compatible" + }, + { + "pattern": ".mise/config.toml", + "reader": "native" + }, + { + "pattern": ".mise/config.local.toml", + "reader": "native" + } + ] + } + ], + "backend": { + "launch": { + "kind": "packaged", + "path": "bin/mise-provider" + }, + "arguments": [ + "--stdio" + ] + } +} diff --git a/shared/contracts/plugins/v1/plugin-manifest.schema.json b/shared/contracts/plugins/v1/plugin-manifest.schema.json index e108cc36ab..3c692b1979 100644 --- a/shared/contracts/plugins/v1/plugin-manifest.schema.json +++ b/shared/contracts/plugins/v1/plugin-manifest.schema.json @@ -13,7 +13,9 @@ { "if": { "required": ["pluginAPIVersion"], "properties": { "pluginAPIVersion": { "maximum": 3 } } }, "then": { "properties": { "debugAdapters": { "maxItems": 0 } } } - } + }, + {"if": {"required": ["toolDefinitions"]}, "then": {"properties": {"pluginAPIVersion": {"minimum": 3}, "requires": {"required": ["toolDefinitions", "executableResolution"], "properties": {"executableResolution": {"const": 2}}}}}}, + {"if": {"required": ["toolchainProviders"]}, "then": {"required": ["backend"], "properties": {"pluginAPIVersion": {"minimum": 3}, "requires": {"required": ["toolchainProviders", "backend", "serverProcess"], "properties": {"backend": {"const": 2}}}, "permissions": {"allOf": [{"contains": {"const": "toolchains.provide"}}, {"contains": {"const": "backend.launch"}}]}}}} ], "properties": { "schemaVersion": { "const": 2 }, @@ -32,7 +34,7 @@ "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] }, "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], "description": "Shared executable candidate ordering and strict selection." }, + "executableResolution": { "enum": [1, 2], "description": "Shared executable candidate ordering and strict selection." }, "executionProviders": { "enum": [1] }, "languageServers": { "enum": [1] }, "languages": { "description": "Version 2 adds languages[].grammar and languages[].configuration and is required exactly when one of them is used.", "enum": [1, 2] }, @@ -40,6 +42,8 @@ "projectGenerators": { "description": "Entries of the New Project window: fields, requirements, and steps.", "enum": [1] }, "serverProcess": { "description": "The launch fields of language servers, of build providers that start their own process, of VCS providers, of debug adapters, and of the plugin backend. Version 2 adds runSelectedRuntime and is required exactly when a debug adapter uses it (pluginAPIVersion 4).", "enum": [1, 2] }, "settings": { "enum": [1] }, + "toolDefinitions": { "const": 1 }, + "toolchainProviders": { "const": 1 }, "ui": { "description": "Version 2 adds web content; version 3 adds native controls; version 4 adds backend-only commands.", "enum": [1, 2, 3, 4] }, "vcsProviders": { "description": "Required exactly when vcsProviders is non-empty. The provider protocol itself is negotiated in initialize (shared/contracts/vcs-provider-v1.md).", "enum": [1] } } @@ -69,6 +73,7 @@ "editor.actions", "editor.open", "preview.open", + "toolchains.provide", "browser.open", "window.messages"] } }, @@ -80,6 +85,8 @@ "pattern": "^((onLanguage|onRun|onTest|onView|onCommand|onBuildSystem|onVcsRoot|onDebug):[a-z0-9.+_-]+|onWorkspaceOpen)$" } }, + "toolDefinitions": { "type": "array", "minItems": 1, "maxItems": 32, "items": { "$ref": "#/$defs/toolchainToolDefinition" } }, + "toolchainProviders": { "type": "array", "minItems": 1, "maxItems": 8, "items": { "$ref": "#/$defs/toolchainProvider" } }, "languages": { "type": "array", "minItems": 0, @@ -2922,6 +2929,586 @@ } } } + }, + "toolchainIdentifier": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]{0,63}$" + }, + "toolchainToolID": { + "type": "string", + "pattern": "^(?:[a-z0-9]+(?:[.-][a-z0-9]+)*:)?[a-z][a-z0-9-]{0,63}$", + "maxLength": 192 + }, + "toolchainLabel": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[^\\u0000-\\u001f\\u007f]+$" + }, + "toolchainRelativePath": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "^(?!/)(?!.*(?:^|/)(?:\\.|\\.\\.)(?:/|$))[^/\\\\:*?\\u0000-\\u001f\\u007f]+(?:/[^/\\\\:*?\\u0000-\\u001f\\u007f]+)*$" + }, + "toolchainBoundedGlob": { + "description": "At most eight segments; only single-segment * is supported, never **, ?, character classes, or traversal. Semantic validation also bounds expansion.", + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "^(?!/)(?!.*\\*\\*)(?!.*(?:^|/)(?:\\.|\\.\\.)(?:/|$))[^/\\\\:?\\[\\]{}\\u0000-\\u001f\\u007f]+(?:/[^/\\\\:?\\[\\]{}\\u0000-\\u001f\\u007f]+){0,7}$" + }, + "toolchainCommand": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" + }, + "toolchainSafeEnvironmentName": { + "type": "string", + "maxLength": 64, + "pattern": "^(?!PATH$)(?!DYLD_)(?!LD_)[A-Z_][A-Z0-9_]*$" + }, + "toolchainPlatforms": { + "type": "array", + "minItems": 1, + "maxItems": 3, + "uniqueItems": true, + "items": { + "enum": [ + "macos", + "windows", + "linux" + ] + } + }, + "toolchainVersion": { + "type": "object", + "additionalProperties": false, + "required": [ + "arguments", + "pattern" + ], + "properties": { + "arguments": { + "type": "array", + "minItems": 1, + "maxItems": 8, + "items": { + "type": "string", + "pattern": "^--?[A-Za-z][A-Za-z0-9-]*$", + "maxLength": 64 + } + }, + "pattern": { + "description": "Rust-regex-compatible, first capture is a dotted numeric version. Validity, capture count and runtime bounds require semantic checks.", + "type": "string", + "minLength": 1, + "maxLength": 512 + } + } + }, + "toolchainExecutableLayout": { + "type": "object", + "additionalProperties": false, + "required": [ + "path" + ], + "properties": { + "path": { + "$ref": "#/$defs/toolchainRelativePath" + }, + "platforms": { + "$ref": "#/$defs/toolchainPlatforms" + } + } + }, + "toolchainHomeLayout": { + "type": "object", + "additionalProperties": false, + "required": [ + "match", + "home" + ], + "properties": { + "match": { + "allOf": [ + { + "$ref": "#/$defs/toolchainBoundedGlob" + }, + { + "pattern": "^[^/]+$" + } + ] + }, + "home": { + "oneOf": [ + { + "const": "." + }, + { + "$ref": "#/$defs/toolchainRelativePath" + } + ] + }, + "platforms": { + "$ref": "#/$defs/toolchainPlatforms" + } + } + }, + "toolchainDiscovery": { + "type": "object", + "additionalProperties": false, + "required": [ + "root", + "pattern" + ], + "properties": { + "root": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "kind" + ], + "properties": { + "kind": { + "const": "home" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "variable" + ], + "properties": { + "kind": { + "const": "environment" + }, + "variable": { + "$ref": "#/$defs/toolchainSafeEnvironmentName" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "path" + ], + "properties": { + "kind": { + "const": "absolute" + }, + "path": { + "type": "string", + "minLength": 2, + "maxLength": 256, + "pattern": "^(?:/|[A-Za-z]:[\\\\/])[^\\u0000-\\u001f\\u007f]*$" + } + } + } + ] + }, + "pattern": { + "$ref": "#/$defs/toolchainBoundedGlob" + }, + "platforms": { + "$ref": "#/$defs/toolchainPlatforms" + } + } + }, + "toolchainWrapper": { + "type": "object", + "additionalProperties": false, + "required": [ + "path" + ], + "properties": { + "path": { + "$ref": "#/$defs/toolchainRelativePath" + }, + "requires": { + "type": "array", + "maxItems": 8, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/toolchainRelativePath" + } + }, + "platforms": { + "$ref": "#/$defs/toolchainPlatforms" + } + } + }, + "toolchainCandidateMetadata": { + "type": "object", + "properties": { + "id": { + "$ref": "#/$defs/toolchainIdentifier" + }, + "priority": { + "type": "integer", + "minimum": -2147483648, + "maximum": 2147483647 + }, + "insertBefore": { + "$ref": "#/$defs/toolchainIdentifier" + }, + "insertAfter": { + "$ref": "#/$defs/toolchainIdentifier" + }, + "platforms": { + "$ref": "#/$defs/toolchainPlatforms" + } + }, + "not": { + "anyOf": [ + { + "required": [ + "priority", + "insertBefore" + ] + }, + { + "required": [ + "priority", + "insertAfter" + ] + }, + { + "required": [ + "insertBefore", + "insertAfter" + ] + } + ] + } + }, + "toolchainCandidate": { + "$ref": "#/$defs/toolchainCandidateMetadata", + "unevaluatedProperties": false, + "oneOf": [ + { + "required": [ + "kind", + "settingID", + "layer" + ], + "properties": { + "kind": { + "const": "setting" + }, + "settingID": { + "const": "executable" + }, + "layer": { + "enum": [ + "project", + "global" + ] + } + } + }, + { + "required": [ + "kind", + "scope" + ], + "properties": { + "kind": { + "const": "toolchain" + }, + "scope": { + "enum": [ + "project", + "global", + "installed" + ] + } + } + }, + { + "required": [ + "kind", + "path" + ], + "properties": { + "kind": { + "const": "projectFile" + }, + "path": { + "$ref": "#/$defs/toolchainRelativePath" + }, + "requires": { + "type": "array", + "maxItems": 8, + "items": { + "$ref": "#/$defs/toolchainRelativePath" + } + }, + "projectKind": { + "enum": [ + "wrapper", + "environment" + ], + "default": "wrapper" + }, + "home": { + "oneOf": [ + { + "const": "." + }, + { + "$ref": "#/$defs/toolchainRelativePath" + } + ] + } + }, + "allOf": [ + { + "if": { + "required": [ + "projectKind" + ], + "properties": { + "projectKind": { + "const": "environment" + } + } + }, + "then": { + "required": [ + "home" + ] + }, + "else": { + "not": { + "required": [ + "home" + ] + } + } + } + ] + }, + { + "required": [ + "kind", + "variable", + "path" + ], + "properties": { + "kind": { + "const": "environment" + }, + "variable": { + "$ref": "#/$defs/toolchainSafeEnvironmentName" + }, + "path": { + "$ref": "#/$defs/toolchainRelativePath" + } + } + }, + { + "required": [ + "kind", + "command" + ], + "properties": { + "kind": { + "const": "command" + }, + "command": { + "$ref": "#/$defs/toolchainCommand" + } + } + } + ] + }, + "toolchainLaunchTokens": { + "type": "array", + "minItems": 1, + "maxItems": 32, + "prefixItems": [ + { + "const": "${executable}" + } + ], + "items": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "^(?:\\$\\{(?:home|executable|entry|runtimeArguments|arguments)\\}|[^$\\u0000-\\u001f\\u007f]+)$" + } + }, + "toolchainToolDefinition": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "displayName", + "executableLayouts", + "executables", + "version", + "commands", + "launch" + ], + "properties": { + "id": { + "$ref": "#/$defs/toolchainToolID" + }, + "displayName": { + "$ref": "#/$defs/toolchainLabel" + }, + "icon": { + "$ref": "#/$defs/uiIcon" + }, + "executableLayouts": { + "type": "array", + "minItems": 1, + "maxItems": 8, + "items": { + "$ref": "#/$defs/toolchainExecutableLayout" + } + }, + "executables": { + "type": "array", + "minItems": 1, + "maxItems": 32, + "items": { + "$ref": "#/$defs/toolchainCandidate" + } + }, + "version": { + "$ref": "#/$defs/toolchainVersion" + }, + "homeLayouts": { + "type": "array", + "maxItems": 8, + "items": { + "$ref": "#/$defs/toolchainHomeLayout" + } + }, + "discovery": { + "type": "array", + "maxItems": 16, + "items": { + "$ref": "#/$defs/toolchainDiscovery" + } + }, + "homeVariable": { + "$ref": "#/$defs/toolchainSafeEnvironmentName" + }, + "commands": { + "type": "array", + "minItems": 1, + "maxItems": 8, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/toolchainCommand" + } + }, + "wrappers": { + "type": "array", + "maxItems": 8, + "items": { + "$ref": "#/$defs/toolchainWrapper" + } + }, + "exports": { + "type": "array", + "maxItems": 16, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "value" + ], + "properties": { + "name": { + "$ref": "#/$defs/toolchainSafeEnvironmentName" + }, + "value": { + "enum": [ + "${home}", + "${executable}" + ] + } + } + } + }, + "pathPrepend": { + "type": "array", + "maxItems": 8, + "uniqueItems": true, + "items": { + "type": "string", + "maxLength": 256, + "pattern": "^\\$\\{home\\}(?:/(?!\\.{1,2}(?:/|$))[^/\\\\:$*?\\u0000-\\u001f\\u007f]+)*$" + } + }, + "launch": { + "$ref": "#/$defs/toolchainLaunchTokens" + } + } + }, + "toolchainProvider": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "displayName", + "tools" + ], + "properties": { + "id": { + "$ref": "#/$defs/toolchainIdentifier" + }, + "displayName": { + "$ref": "#/$defs/toolchainLabel" + }, + "tools": { + "type": "array", + "minItems": 1, + "maxItems": 32, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/toolchainToolID" + } + }, + "watchFiles": { + "type": "array", + "minItems": 1, + "maxItems": 32, + "uniqueItems": true, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "pattern", + "reader" + ], + "properties": { + "pattern": { + "$ref": "#/$defs/toolchainBoundedGlob" + }, + "reader": { + "enum": [ + "native", + "compatible" + ] + } + } + } + } + } } } } diff --git a/shared/contracts/plugins/v1/proposals/toolchains/README.md b/shared/contracts/plugins/v1/proposals/toolchains/README.md new file mode 100644 index 0000000000..27b5fccdb0 --- /dev/null +++ b/shared/contracts/plugins/v1/proposals/toolchains/README.md @@ -0,0 +1,137 @@ +# Plugin-injected toolchains (draft) + +> **Status: broader proposal; a Core-only foundation is now implemented.** +> The implemented subset is specified in [the active contract](../../toolchains.md). +> This directory remains a review artifact for +> [PR #1581's replacement plan](https://github.com/1lck/Lithe-IDEA/pull/1581#issuecomment-6541366112). +> It is not an extension of the currently accepted `plugin.json` format. +> The active manifest admits the implemented definition/provider fields, not +> these projection envelopes or the whole proposal. Native runtime selection, +> consumer migration, query transport, persistence and UI remain unimplemented. + +## Ownership + +A **tool definition** describes a tool's identity, executable layouts, version +probe, discovery locations, launch template, and environment exports. Only an +enabled plugin contributes a definition. The host has no built-in definitions, +reserved-name alias table, or tool-name-dependent launch logic in the proposed +path. Officially signed plugins may define short IDs; community plugins define +IDs in their own plugin namespace and may provide installations for any enabled +definition, including official short IDs. Verified package identity, not a +manifest assertion or ID prefix, establishes official status. + +A **toolchain provider** reports installed tools and, when it declares watched +files, the project's or user's requirements. It covers installation shapes the +frozen declarative `discovery` primitives cannot express; a plugin may provide +installations of a tool it defines. Both routes feed one automatic candidate set. +Version-manager configuration, version aliases, and file precedence +remain upstream/provider-owned. The host never interprets opaque version +requests such as `lts`. A **consumer** references a defined tool and declares +whether a changed selection needs a restart, resynchronization, or the next run. +These are independent contributions, not additional language/build requirements. + +The host owns a single generic resolver and query client, directory-scoped pins, +and the environment/sources presentation skeleton. Provider-specific settings +are injected through that provider plugin's existing settings page. Definitions +and provider replies are untrusted input; permission to provide toolchains does +not authorize replacing unrelated host commands. + +## Review Artifacts + +| File | Purpose | +| --- | --- | +| [manifest.md](manifest.md) | Definition, provider, consumer, trust, and capability contribution rules | +| [manifest-contributions.schema.json](manifest-contributions.schema.json) | Standalone draft contribution projection, not a complete plugin manifest | +| [protocol.md](protocol.md) | Query lifecycle, validation, four-layer selection, storage, and stable failures | +| [query.schema.json](query.schema.json) | Query request/reply shapes | +| [resolution.schema.json](resolution.schema.json) | Resolver outcomes and structured reasons | +| [local.schema.json](local.schema.json) | Proposed extension of the machine-local document with directory pins | +| [fixtures/](fixtures/) | Illustrative contributions, queries, resolution outcomes, pins, and rejections | + +Schemas describe JSON shape. Signature verification, duplicate-definition +registration, enabled-plugin ownership, path containment, executable evidence, +and lifecycle state require separate semantic validation during implementation. +A shape-valid reply is never sufficient permission to launch a program. +Example filesystem paths are fictional and must not be probed on the reader's +machine; filesystem observations in cases are inputs for future test doubles. + +## Required Behavior + +Every tool uses the existing shared executable-candidate mechanism, extended +with `toolchain` candidates and structured `skip` reasons. There is no parallel +Java, Maven, or generic-command selection chain in the proposed path. + +| Layer | Selection rule | +| --- | --- | +| Pin | Nearest ancestor directory's explicit pin; an unusable pin is a terminal failure, never automatic fallback | +| Project | Nearest declaring directory; at equal depth wrapper before environment before version statement; for the same file native before compatible reader | +| Global | Lithe's explicit global default, then provider defaults; conflicting provider defaults require a user choice, using login-shell discovery until chosen | +| Automatic | Newest eligible provider installation satisfying the minimum version; manually added installations are excluded | + +An uninstalled project requirement skips with an explicit reason and stays +visible as unmet even if a lower layer succeeds. Provider installations cannot +come from the workspace; project wrappers remain project code and require the +consumer's existing explicit-action/consent policy. Queries, discovery, settings +inspection, and passive project import must not execute project code. + +The built-in **system provider** is a mechanism, not a contributor of definitions: +it scans only enabled definitions' declared locations. Adding a manager or tool +must not require a new parser, directory constant, or tool-name branch in Core. +The first implementation must prove this with two different providers. + +Each query has an operation identity, cancellation, a bounded deadline, and a +snapshot generation. New, changed, or deleted declared watch inputs invalidate +the entire generation and requery all enabled providers. Late replies cannot +repopulate revoked state. Disabling/uninstalling the defining plugin removes its +tool rows, consumers, settings, and exports, but retains stored pins for a later +reenable. Unknown/disabled definitions grant no executable authority; only bounded +display diagnostics may remain to explain the missing plugin. + +Reading `.lithe/toolchains/local.json` never creates it. Explicitly confirmed pin +edits use conflict-protected atomic replacement, then reread the result; the first +write preserves `.lithe/.gitignore` and adds `/toolchains/local.json`. Manually +added installations live in platform-owned application storage and are pin-only. +Installed app bundles and plugin packages remain read-only throughout. + +## Staged Landing And Compatibility + +1. **Proposal:** review schemas, examples, trust boundaries, and + the decision record. No host capability is enabled and no production format + is relaxed merely by this draft. Only the active contract allocates supported + Core capability versions; native hosts have not advertised them. +2. **Core and query client:** implement definition/answer validation, candidate + extensions, pin inheritance, full-generation invalidation, and macOS query + lifecycle. Preserve strict rejection by unsupported hosts. System discovery + consumes plugin declarations, never a built-in definition data file. +3. **Selection and UI:** integrate consumers and the environment/sources pages, + move Java definitions into Java Support and coordinate Maven definitions with + [PR #1558](https://github.com/1lck/Lithe-IDEA/pull/1558). Remove migrated legacy + special cases in the same slice. Other tools keep existing behavior until + their official plugins declare definitions; do not add new compatibility + aliases or tool-specific branches. +4. **Provider acceptance:** mise delegates to its upstream machine-readable CLI; + SDKMAN supplies the second, different provider without Core changes. Each + owns its own settings and bootstrap runtime. Backend-bearing providers still + require restart in the initial implementation; this proposal does not relax + live activation. + +The existing executable-resolution v1 contract does not permit project tool +overrides and supports only the documented consumer positions. These new +semantics need explicit capability admission and implemented adapters; do not +retroactively change v1 or advertise support based on schema changes alone. +Runtime minimum-version checking must use the definition's version probe rather +than a new per-tool branch. No current API is removed by this proposal. + +The Chinese architecture decision and acceptance criteria are recorded in the +[proposed Agent Note](https://github.com/1lck/Lithe-IDEA/blob/preview/.agents/notes/proposed/architecture/2026-10-31-plugin-injected-toolchains.md). +The UI reference is the +[interactive prototype](https://gist.github.com/xiaoyumuxi/2ae889a6592599b13998bc811c647942). +This directory specifies host-readable contracts, not a plugin-author tutorial +or SDK implementation; those belong in `lithe-plugin-docs`. + +After the proposal merges, sync the byte-identical contract mirror in +`Lithe-IDEA/lithe-plugin-docs` using +`node scripts/contract.mjs sync --revision ` and commit +there. Draft status must survive the sync. SDK protocol compatibility and both +SDK end-to-end suites become implementation-stage gates; this proposal does not +claim that either SDK already implements these messages. diff --git a/shared/contracts/plugins/v1/proposals/toolchains/fixtures/java-tool-definition.json b/shared/contracts/plugins/v1/proposals/toolchains/fixtures/java-tool-definition.json new file mode 100644 index 0000000000..501ec9f844 --- /dev/null +++ b/shared/contracts/plugins/v1/proposals/toolchains/fixtures/java-tool-definition.json @@ -0,0 +1,50 @@ +{ + "proposalVersion": 1, + "pluginID": "dev.lithe.fixture.java-support", + "requires": { "toolDefinitions": 1, "executableResolution": 2, "toolchainConsumers": 1 }, + "permissions": [], + "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}"] + } + ], + "consumers": [ + { + "id": "fixture-language-server", + "requiresTools": [ { "tool": "java", "minimumVersion": "21" } ], + "onToolchainChange": "restart", + "launch": { "kind": "runtime", "tool": "java", "entry": "server/fixture-language-server.jar" } + } + ] +} diff --git a/shared/contracts/plugins/v1/proposals/toolchains/fixtures/local.json b/shared/contracts/plugins/v1/proposals/toolchains/fixtures/local.json new file mode 100644 index 0000000000..7bf8520535 --- /dev/null +++ b/shared/contracts/plugins/v1/proposals/toolchains/fixtures/local.json @@ -0,0 +1,27 @@ +{ + "version": 1, + "toolchains": {}, + "pins": { + ".": { + "java": { + "home": "/opt/lithe-fixtures/installs/java/21.0.4", + "version": "21.0.4", + "provider": "dev.lithe.fixture.mise:mise" + } + }, + "apps/web": { + "java": { + "home": "/opt/lithe-fixtures/manual/java/17.0.12", + "version": "17.0.12", + "provider": "host:manual" + } + }, + ".config/tools": { + "java": { + "home": "/opt/lithe-fixtures/installs/java/21.0.4", + "version": "21.0.4", + "provider": "dev.lithe.fixture.mise:mise" + } + } + } +} diff --git a/shared/contracts/plugins/v1/proposals/toolchains/fixtures/manifest-rejections.json b/shared/contracts/plugins/v1/proposals/toolchains/fixtures/manifest-rejections.json new file mode 100644 index 0000000000..89adc06e6e --- /dev/null +++ b/shared/contracts/plugins/v1/proposals/toolchains/fixtures/manifest-rejections.json @@ -0,0 +1,114 @@ +{ + "proposalVersion": 1, + "description": "Proposed rejection scenarios, not current Core tests. Apply RFC 6902 patch to base; schema checks shape, registry checks enabled definition references and manifest semantics, hostTrust checks independently verified installation provenance. Context is supplied by the test host, never trusted plugin JSON.", + "cases": [ + { + "name": "provider capability requires effective permission", + "base": "mise-provider.json", + "phase": "schema", + "patch": [{ "op": "replace", "path": "/permissions", "value": ["backend.launch"] }], + "expected": { "status": "rejected", "code": "plugin.permission.undeclared" } + }, + { + "name": "export cannot replace PATH", + "base": "java-tool-definition.json", + "phase": "schema", + "patch": [{ "op": "replace", "path": "/toolDefinitions/0/exports/0/name", "value": "PATH" }], + "expected": { "status": "rejected", "code": "plugin.manifest.invalid" } + }, + { + "name": "export cannot set loader injection variable", + "base": "java-tool-definition.json", + "phase": "schema", + "patch": [{ "op": "replace", "path": "/toolDefinitions/0/exports/0/name", "value": "DYLD_INSERT_LIBRARIES" }], + "expected": { "status": "rejected", "code": "plugin.manifest.invalid" } + }, + { + "name": "PATH prepend cannot escape home", + "base": "java-tool-definition.json", + "phase": "schema", + "patch": [{ "op": "replace", "path": "/toolDefinitions/0/pathPrepend/0", "value": "${home}/../bin" }], + "expected": { "status": "rejected", "code": "plugin.manifest.invalid" } + }, + { + "name": "watch glob cannot recurse without a bound", + "base": "mise-provider.json", + "phase": "schema", + "patch": [{ "op": "replace", "path": "/toolchainProviders/0/watchFiles/0/pattern", "value": "**/mise.toml" }], + "expected": { "status": "rejected", "code": "plugin.manifest.invalid" } + }, + { + "name": "probe cannot pass an arbitrary script argument", + "base": "java-tool-definition.json", + "phase": "schema", + "patch": [{ "op": "replace", "path": "/toolDefinitions/0/version/arguments", "value": ["-c", "project-code"] }], + "expected": { "status": "rejected", "code": "plugin.manifest.invalid" } + }, + { + "name": "launch interpolation is argv not shell text", + "base": "java-tool-definition.json", + "phase": "schema", + "patch": [{ "op": "replace", "path": "/toolDefinitions/0/launch/1", "value": "--args=${arguments}" }], + "expected": { "status": "rejected", "code": "plugin.template.invalid" } + }, + { + "name": "provider backend cannot bootstrap from discovered runtime", + "base": "mise-provider.json", + "phase": "schema", + "patch": [{ "op": "replace", "path": "/backend/launch", "value": { "kind": "runtime", "tool": "node", "entry": "provider/main.mjs" } }], + "expected": { "status": "rejected", "code": "plugin.manifest.invalid" } + }, + { + "name": "consumer reference to undefined tool is not a syntax error", + "base": "java-tool-definition.json", + "phase": "registry", + "patch": [{ "op": "replace", "path": "/consumers/0/requiresTools", "value": [{ "tool": "java", "minimumVersion": "21" }, { "tool": "dev.lithe.fixture.java-support:absent" }] }], + "context": { "enabledToolIDs": ["java"] }, + "expected": { "status": "rejected", "code": "plugin.manifest.invalid", "reason": "undefinedTool" } + }, + { + "name": "community plugin cannot claim reserved short definition", + "base": "java-tool-definition.json", + "phase": "hostTrust", + "patch": [], + "context": { "verifiedOrigin": "communityPackage" }, + "expected": { "status": "rejected", "code": "plugin.manifest.invalid", "reason": "reservedToolID" } + }, + { + "name": "community definition namespace belongs to its plugin", + "base": "java-tool-definition.json", + "phase": "registry", + "patch": [{ "op": "replace", "path": "/toolDefinitions/0/id", "value": "dev.lithe.fixture.other:java" }], + "expected": { "status": "rejected", "code": "plugin.manifest.invalid", "reason": "foreignToolNamespace" } + }, + { + "name": "later definition cannot replace existing owner", + "base": "java-tool-definition.json", + "phase": "registry", + "patch": [], + "context": { "registeredTools": [{ "id": "java", "pluginID": "dev.lithe.fixture.first-java" }] }, + "expected": { "status": "rejected", "code": "plugin.manifest.invalid", "reason": "duplicateToolID" } + }, + { + "name": "capability list cannot advertise unused provider", + "base": "java-tool-definition.json", + "phase": "registry", + "patch": [{ "op": "add", "path": "/requires/toolchainProviders", "value": 1 }], + "expected": { "status": "rejected", "code": "plugin.manifest.invalid", "reason": "unusedCapability" } + }, + { + "name": "pattern needs a numeric version capture", + "base": "java-tool-definition.json", + "phase": "registry", + "patch": [{ "op": "replace", "path": "/toolDefinitions/0/version/pattern", "value": "version.*" }], + "expected": { "status": "rejected", "code": "plugin.manifest.invalid", "reason": "missingVersionCapture" } + }, + { + "name": "ordering cannot reference another contribution set", + "base": "java-tool-definition.json", + "phase": "registry", + "patch": [{ "op": "add", "path": "/toolDefinitions/0/executables/0/insertBefore", "value": "another-set" }], + "expected": { "status": "rejected", "code": "plugin.manifest.invalid", "reason": "unknownCandidateAnchor" } + } + ] +} diff --git a/shared/contracts/plugins/v1/proposals/toolchains/fixtures/mise-provider.json b/shared/contracts/plugins/v1/proposals/toolchains/fixtures/mise-provider.json new file mode 100644 index 0000000000..4ec3e07b90 --- /dev/null +++ b/shared/contracts/plugins/v1/proposals/toolchains/fixtures/mise-provider.json @@ -0,0 +1,25 @@ +{ + "proposalVersion": 1, + "pluginID": "dev.lithe.fixture.mise", + "requires": { "toolchainProviders": 1, "backend": 2, "serverProcess": 1 }, + "permissions": ["backend.launch", "toolchains.provide"], + "toolchainProviders": [ + { + "id": "mise", + "displayName": "mise", + "tools": ["java", "node", "maven", "pnpm", "go"], + "watchFiles": [ + { "pattern": "mise.toml", "reader": "native" }, + { "pattern": "mise.local.toml", "reader": "native" }, + { "pattern": ".mise.toml", "reader": "native" }, + { "pattern": ".tool-versions", "reader": "compatible" }, + { "pattern": ".mise/config.toml", "reader": "native" }, + { "pattern": ".mise/config.local.toml", "reader": "native" } + ] + } + ], + "backend": { + "launch": { "kind": "packaged", "path": "bin/mise-provider" }, + "arguments": ["--stdio"] + } +} diff --git a/shared/contracts/plugins/v1/proposals/toolchains/fixtures/query-cases.json b/shared/contracts/plugins/v1/proposals/toolchains/fixtures/query-cases.json new file mode 100644 index 0000000000..e876e4d082 --- /dev/null +++ b/shared/contracts/plugins/v1/proposals/toolchains/fixtures/query-cases.json @@ -0,0 +1,852 @@ +{ + "proposalVersion": 1, + "status": "review-only", + "cases": [ + { + "name": "installed project requirement", + "request": { + "jsonrpc": "2.0", + "id": 1, + "method": "toolchains/query", + "params": { + "operationID": "query-1", + "generation": 1, + "providerID": "mise", + "authority": { + "kind": "local" + }, + "context": { + "contextID": "context-1", + "generation": 1 + }, + "workspace": { + "rootID": "root-1", + "root": "/opt/lithe-fixtures/workspace", + "directory": "." + }, + "tools": [ + "java" + ] + } + }, + "response": { + "jsonrpc": "2.0", + "id": 1, + "result": { + "operationID": "query-1", + "generation": 1, + "providerID": "mise", + "installations": [ + { + "tool": "java", + "version": "21.0.4", + "home": "/opt/lithe-fixtures/installs/java/21.0.4" + } + ], + "statements": [ + { + "tool": "java", + "scope": "project", + "source": "/opt/lithe-fixtures/workspace/mise.toml", + "request": "21", + "installation": "/opt/lithe-fixtures/installs/java/21.0.4", + "kind": "declaration", + "reader": "native" + } + ], + "watch": [ + "/opt/lithe-fixtures/workspace/mise.toml", + "/opt/lithe-fixtures/installs/java" + ] + } + }, + "filesystemEvidence": [ + { + "path": "/opt/lithe-fixtures/installs/java/21.0.4", + "canonical": "/opt/lithe-fixtures/installs/java/21.0.4", + "kind": "directory" + }, + { + "path": "/opt/lithe-fixtures/installs/java/21.0.4/bin/java", + "canonical": "/opt/lithe-fixtures/installs/java/21.0.4/bin/java", + "kind": "file", + "executable": true + } + ], + "expected": { + "validation": "accepted", + "selectedHome": "/opt/lithe-fixtures/installs/java/21.0.4" + } + }, + { + "name": "uninstalled request is retained as unmet", + "request": { + "jsonrpc": "2.0", + "id": 2, + "method": "toolchains/query", + "params": { + "operationID": "query-2", + "generation": 1, + "providerID": "mise", + "authority": { + "kind": "local" + }, + "context": { + "contextID": "context-1", + "generation": 1 + }, + "workspace": { + "rootID": "root-1", + "root": "/opt/lithe-fixtures/workspace", + "directory": "." + }, + "tools": [ + "node" + ] + } + }, + "response": { + "jsonrpc": "2.0", + "id": 2, + "result": { + "operationID": "query-2", + "generation": 1, + "providerID": "mise", + "installations": [], + "statements": [ + { + "tool": "node", + "scope": "project", + "source": "/opt/lithe-fixtures/workspace/mise.toml", + "request": "99", + "installation": null, + "kind": "declaration", + "reader": "native" + } + ], + "watch": [ + "/opt/lithe-fixtures/workspace/mise.toml" + ] + } + }, + "expected": { + "validation": "accepted", + "reason": { + "code": "requestedNotInstalled", + "tool": "node", + "provider": "dev.lithe.fixture.mise:mise", + "source": "/opt/lithe-fixtures/workspace/mise.toml", + "request": "99" + }, + "continueToLowerLayer": true + } + }, + { + "name": "provider home inside workspace is unsafe", + "request": { + "jsonrpc": "2.0", + "id": 3, + "method": "toolchains/query", + "params": { + "operationID": "query-3", + "generation": 1, + "providerID": "mise", + "authority": { + "kind": "local" + }, + "context": { + "contextID": "context-1", + "generation": 1 + }, + "workspace": { + "rootID": "root-1", + "root": "/opt/lithe-fixtures/workspace", + "directory": "." + }, + "tools": [ + "java" + ] + } + }, + "response": { + "jsonrpc": "2.0", + "id": 3, + "result": { + "operationID": "query-3", + "generation": 1, + "providerID": "mise", + "installations": [ + { + "tool": "java", + "version": "21.0.4", + "home": "/opt/lithe-fixtures/workspace/vendor/jdk" + } + ], + "statements": [ + { + "tool": "java", + "scope": "project", + "source": "/opt/lithe-fixtures/workspace/mise.toml", + "request": "21", + "installation": "/opt/lithe-fixtures/workspace/vendor/jdk", + "kind": "declaration", + "reader": "native" + } + ], + "watch": [] + } + }, + "expected": { + "validation": "rejected", + "code": "plugin.toolchain.unsafePath", + "rpcCode": -32076 + } + }, + { + "name": "cancelled query ignores late success", + "request": { + "jsonrpc": "2.0", + "id": 4, + "method": "toolchains/query", + "params": { + "operationID": "query-4", + "generation": 1, + "providerID": "mise", + "authority": { + "kind": "local" + }, + "context": { + "contextID": "context-1", + "generation": 1 + }, + "workspace": { + "rootID": "root-1", + "root": "/opt/lithe-fixtures/workspace", + "directory": "." + }, + "tools": [ + "java" + ] + } + }, + "response": { + "jsonrpc": "2.0", + "id": 4, + "result": { + "operationID": "query-4", + "generation": 1, + "providerID": "mise", + "installations": [ + { + "tool": "java", + "version": "21.0.4", + "home": "/opt/lithe-fixtures/installs/java/21.0.4" + } + ], + "statements": [], + "watch": [] + } + }, + "events": [ + "dispatched", + "cancelRequested", + "localCompletion", + "lateReply" + ], + "expected": { + "completionCount": 1, + "code": "plugin.toolchain.cancelled", + "rpcCode": -32800, + "publishReply": false, + "cacheReply": false + } + }, + { + "name": "global query generation rejects old result", + "request": { + "jsonrpc": "2.0", + "id": 5, + "method": "toolchains/query", + "params": { + "operationID": "query-5", + "generation": 1, + "providerID": "mise", + "authority": { + "kind": "local" + }, + "context": null, + "workspace": null, + "tools": [ + "java" + ] + } + }, + "response": { + "jsonrpc": "2.0", + "id": 5, + "result": { + "operationID": "query-5", + "generation": 1, + "providerID": "mise", + "installations": [ + { + "tool": "java", + "version": "21.0.4", + "home": "/opt/lithe-fixtures/installs/java/21.0.4" + } + ], + "statements": [], + "watch": [] + } + }, + "events": [ + "dispatchedGeneration1", + "watchChanged", + "dispatchedGeneration2", + "replyGeneration1" + ], + "expected": { + "publishReply": false, + "cacheReply": false, + "requery": "allProviders" + } + }, + { + "name": "remote query never falls back to local", + "request": { + "jsonrpc": "2.0", + "id": 6, + "method": "toolchains/query", + "params": { + "operationID": "query-6", + "generation": 1, + "providerID": "mise", + "authority": { + "kind": "remote", + "id": "ssh-1" + }, + "context": { + "contextID": "context-1", + "generation": 1 + }, + "workspace": { + "rootID": "root-1", + "root": "/srv/lithe-fixtures/workspace", + "directory": "." + }, + "tools": [ + "java" + ] + } + }, + "expected": { + "validation": "rejected", + "code": "plugin.toolchain.authorityUnsupported", + "rpcCode": -32071, + "localProcessesStarted": 0 + } + }, + { + "name": "missing definition retains display-only requirement", + "request": { + "jsonrpc": "2.0", + "id": 7, + "method": "toolchains/query", + "params": { + "operationID": "query-7", + "generation": 1, + "providerID": "mise", + "authority": { + "kind": "local" + }, + "context": { + "contextID": "context-1", + "generation": 1 + }, + "workspace": { + "rootID": "root-1", + "root": "/opt/lithe-fixtures/workspace", + "directory": "." + }, + "tools": [ + "maven" + ] + } + }, + "response": { + "jsonrpc": "2.0", + "id": 7, + "result": { + "operationID": "query-7", + "generation": 1, + "providerID": "mise", + "installations": [ + { + "tool": "maven", + "version": "3.9.9", + "home": "/opt/lithe-fixtures/installs/maven/3.9.9" + } + ], + "statements": [ + { + "tool": "maven", + "scope": "project", + "source": "/opt/lithe-fixtures/workspace/mise.toml", + "request": "3.9.9", + "installation": "/opt/lithe-fixtures/installs/maven/3.9.9", + "kind": "declaration", + "reader": "native" + } + ], + "watch": [] + } + }, + "enabledDefinitions": [ + "java" + ], + "expected": { + "validation": "acceptedWithFilteredAuthority", + "installations": [], + "reason": { + "code": "definitionUnavailable", + "tool": "maven", + "source": "/opt/lithe-fixtures/workspace/mise.toml", + "request": "3.9.9" + }, + "enableActionSource": "verifiedInstalledRegistry" + } + }, + { + "name": "external spelling cannot hide workspace symlink", + "request": { + "jsonrpc": "2.0", + "id": 8, + "method": "toolchains/query", + "params": { + "operationID": "query-8", + "generation": 1, + "providerID": "mise", + "authority": { + "kind": "local" + }, + "context": { + "contextID": "context-1", + "generation": 1 + }, + "workspace": { + "rootID": "root-1", + "root": "/opt/lithe-fixtures/workspace", + "directory": "." + }, + "tools": [ + "java" + ] + } + }, + "response": { + "jsonrpc": "2.0", + "id": 8, + "result": { + "operationID": "query-8", + "generation": 1, + "providerID": "mise", + "installations": [ + { + "tool": "java", + "version": "21.0.4", + "home": "/opt/lithe-fixtures/linked-jdk" + } + ], + "statements": [], + "watch": [] + } + }, + "filesystemEvidence": [ + { + "path": "/opt/lithe-fixtures/linked-jdk", + "canonical": "/opt/lithe-fixtures/workspace/vendor/jdk", + "kind": "directory" + } + ], + "expected": { + "validation": "rejected", + "code": "plugin.toolchain.unsafePath", + "rpcCode": -32076 + } + }, + { + "name": "statement cannot reference an unlisted home", + "request": { + "jsonrpc": "2.0", + "id": 9, + "method": "toolchains/query", + "params": { + "operationID": "query-9", + "generation": 1, + "providerID": "mise", + "authority": { + "kind": "local" + }, + "context": { + "contextID": "context-1", + "generation": 1 + }, + "workspace": { + "rootID": "root-1", + "root": "/opt/lithe-fixtures/workspace", + "directory": "." + }, + "tools": [ + "java" + ] + } + }, + "response": { + "jsonrpc": "2.0", + "id": 9, + "result": { + "operationID": "query-9", + "generation": 1, + "providerID": "mise", + "installations": [ + { + "tool": "java", + "version": "21.0.4", + "home": "/opt/lithe-fixtures/installs/java/21.0.4" + } + ], + "statements": [ + { + "tool": "java", + "scope": "project", + "source": "/opt/lithe-fixtures/workspace/mise.toml", + "request": "21", + "installation": "/opt/lithe-fixtures/other-jdk", + "kind": "declaration", + "reader": "native" + } + ], + "watch": [] + } + }, + "expected": { + "validation": "rejected", + "code": "plugin.toolchain.invalidReply", + "rpcCode": -32074 + } + }, + { + "name": "query timeout remains different from cancellation", + "request": { + "jsonrpc": "2.0", + "id": 10, + "method": "toolchains/query", + "params": { + "operationID": "query-10", + "generation": 1, + "providerID": "mise", + "authority": { + "kind": "local" + }, + "context": { + "contextID": "context-1", + "generation": 1 + }, + "workspace": { + "rootID": "root-1", + "root": "/opt/lithe-fixtures/workspace", + "directory": "." + }, + "tools": [ + "java" + ] + } + }, + "events": [ + "dispatched", + "advanceManualClockToDeadline", + "lateReply" + ], + "expected": { + "completionCount": 1, + "code": "plugin.toolchain.timeout", + "rpcCode": -32072, + "publishReply": false + } + }, + { + "name": "create missing watched file re-queries all providers", + "request": { + "jsonrpc": "2.0", + "id": 11, + "method": "toolchains/query", + "params": { + "operationID": "query-11", + "generation": 1, + "providerID": "mise", + "authority": { + "kind": "local" + }, + "context": { + "contextID": "context-1", + "generation": 1 + }, + "workspace": { + "rootID": "root-1", + "root": "/opt/lithe-fixtures/workspace", + "directory": "." + }, + "tools": [ + "java" + ] + } + }, + "events": [ + "fingerprintMissingMiseToml", + "createMiseToml", + "watchEvent" + ], + "enabledProviders": [ + "host:system", + "dev.lithe.fixture.mise:mise", + "dev.lithe.fixture.sdkman:sdkman" + ], + "expected": { + "requery": "allProviders", + "incremental": false + } + }, + { + "name": "delete watched input re-queries all providers", + "request": { + "jsonrpc": "2.0", + "id": 12, + "method": "toolchains/query", + "params": { + "operationID": "query-12", + "generation": 1, + "providerID": "mise", + "authority": { + "kind": "local" + }, + "context": { + "contextID": "context-1", + "generation": 1 + }, + "workspace": { + "rootID": "root-1", + "root": "/opt/lithe-fixtures/workspace", + "directory": "." + }, + "tools": [ + "java" + ] + } + }, + "events": [ + "fingerprintExistingToolVersions", + "deleteToolVersions", + "watchEvent" + ], + "expected": { + "requery": "allProviders", + "incremental": false + } + }, + { + "name": "provider disable invalidates pending reply", + "request": { + "jsonrpc": "2.0", + "id": 13, + "method": "toolchains/query", + "params": { + "operationID": "query-13", + "generation": 1, + "providerID": "mise", + "authority": { + "kind": "local" + }, + "context": { + "contextID": "context-1", + "generation": 1 + }, + "workspace": { + "rootID": "root-1", + "root": "/opt/lithe-fixtures/workspace", + "directory": "." + }, + "tools": [ + "java" + ] + } + }, + "response": { + "jsonrpc": "2.0", + "id": 13, + "result": { + "operationID": "query-13", + "generation": 1, + "providerID": "mise", + "installations": [], + "statements": [], + "watch": [] + } + }, + "events": [ + "dispatched", + "providerDisabled", + "lateReply" + ], + "expected": { + "publishReply": false, + "cacheReply": false, + "ownedListenersRemaining": 0 + } + } + ], + "errorResponses": [ + { + "jsonrpc": "2.0", + "id": 100, + "error": { + "code": -32601, + "message": "Fixture diagnostic.", + "data": { + "code": "plugin.toolchain.methodUnavailable", + "operationID": "query-100", + "providerID": "mise" + } + } + }, + { + "jsonrpc": "2.0", + "id": 101, + "error": { + "code": -32602, + "message": "Fixture diagnostic.", + "data": { + "code": "plugin.toolchain.invalidParams", + "operationID": "query-101", + "providerID": "mise" + } + } + }, + { + "jsonrpc": "2.0", + "id": 102, + "error": { + "code": -32800, + "message": "Fixture diagnostic.", + "data": { + "code": "plugin.toolchain.cancelled", + "operationID": "query-102", + "providerID": "mise" + } + } + }, + { + "jsonrpc": "2.0", + "id": 103, + "error": { + "code": -32070, + "message": "Fixture diagnostic.", + "data": { + "code": "plugin.toolchain.permissionDenied", + "operationID": "query-103", + "providerID": "mise" + } + } + }, + { + "jsonrpc": "2.0", + "id": 104, + "error": { + "code": -32071, + "message": "Fixture diagnostic.", + "data": { + "code": "plugin.toolchain.authorityUnsupported", + "operationID": "query-104", + "providerID": "mise" + } + } + }, + { + "jsonrpc": "2.0", + "id": 105, + "error": { + "code": -32072, + "message": "Fixture diagnostic.", + "data": { + "code": "plugin.toolchain.timeout", + "operationID": "query-105", + "providerID": "mise" + } + } + }, + { + "jsonrpc": "2.0", + "id": 106, + "error": { + "code": -32073, + "message": "Fixture diagnostic.", + "data": { + "code": "plugin.toolchain.providerUnavailable", + "operationID": "query-106", + "providerID": "mise" + } + } + }, + { + "jsonrpc": "2.0", + "id": 107, + "error": { + "code": -32074, + "message": "Fixture diagnostic.", + "data": { + "code": "plugin.toolchain.invalidReply", + "operationID": "query-107", + "providerID": "mise" + } + } + }, + { + "jsonrpc": "2.0", + "id": 108, + "error": { + "code": -32075, + "message": "Fixture diagnostic.", + "data": { + "code": "plugin.toolchain.limitExceeded", + "operationID": "query-108", + "providerID": "mise" + } + } + }, + { + "jsonrpc": "2.0", + "id": 109, + "error": { + "code": -32076, + "message": "Fixture diagnostic.", + "data": { + "code": "plugin.toolchain.unsafePath", + "operationID": "query-109", + "providerID": "mise" + } + } + }, + { + "jsonrpc": "2.0", + "id": 110, + "error": { + "code": -32077, + "message": "Fixture diagnostic.", + "data": { + "code": "plugin.toolchain.operationExpired", + "operationID": "query-110", + "providerID": "mise" + } + } + } + ] +} diff --git a/shared/contracts/plugins/v1/proposals/toolchains/fixtures/resolution-cases.json b/shared/contracts/plugins/v1/proposals/toolchains/fixtures/resolution-cases.json new file mode 100644 index 0000000000..9dd4a35a53 --- /dev/null +++ b/shared/contracts/plugins/v1/proposals/toolchains/fixtures/resolution-cases.json @@ -0,0 +1,571 @@ +{ + "proposalVersion": 1, + "status": "review-only", + "cases": [ + { + "name": "nearest ancestor pin applies component-wise", + "input": { + "directory": "apps/web/src", + "localFixture": "local.json" + }, + "expected": { + "operationID": "resolve-1", + "tool": "java", + "directory": "apps/web/src", + "outcome": { + "kind": "resolved", + "selection": { + "home": "/opt/lithe-fixtures/manual/java/17.0.12", + "executable": "/opt/lithe-fixtures/manual/java/17.0.12/bin/java", + "version": "17.0.12", + "provider": "host:manual", + "layer": "pinned", + "projectCode": false, + "environment": { + "JAVA_HOME": "/opt/lithe-fixtures/manual/java/17.0.12" + }, + "pathPrepend": [ + "/opt/lithe-fixtures/manual/java/17.0.12/bin" + ] + } + }, + "trace": [ + { + "candidateID": "pinned", + "layer": "pinned", + "state": "resolved", + "source": "apps/web" + } + ], + "unmet": [], + "conflicts": [] + } + }, + { + "name": "similarly prefixed sibling does not inherit child pin", + "input": { + "directory": "apps/website", + "localFixture": "local.json" + }, + "expected": { + "operationID": "resolve-1", + "tool": "java", + "directory": "apps/website", + "outcome": { + "kind": "resolved", + "selection": { + "home": "/opt/lithe-fixtures/installs/java/21.0.4", + "executable": "/opt/lithe-fixtures/installs/java/21.0.4/bin/java", + "version": "21.0.4", + "provider": "dev.lithe.fixture.mise:mise", + "layer": "pinned", + "projectCode": false, + "environment": { + "JAVA_HOME": "/opt/lithe-fixtures/installs/java/21.0.4" + }, + "pathPrepend": [ + "/opt/lithe-fixtures/installs/java/21.0.4/bin" + ] + } + }, + "trace": [ + { + "candidateID": "pinned", + "layer": "pinned", + "state": "resolved", + "source": "." + } + ], + "unmet": [], + "conflicts": [] + } + }, + { + "name": "nonempty unavailable pin stops fallback", + "input": { + "pinHome": "/opt/lithe-fixtures/missing", + "installedHomes": [ + "/opt/lithe-fixtures/installs/java/21.0.4" + ] + }, + "expected": { + "operationID": "resolve-1", + "tool": "java", + "directory": ".", + "outcome": { + "kind": "terminalFailure", + "reason": { + "code": "notFound" + } + }, + "trace": [ + { + "candidateID": "pinned", + "layer": "pinned", + "state": "terminalFailure", + "reason": { + "code": "notFound" + } + } + ], + "unmet": [], + "conflicts": [] + } + }, + { + "name": "uninstalled project version remains visible after fallback", + "input": { + "statement": { + "tool": "java", + "scope": "project", + "source": "/opt/lithe-fixtures/workspace/mise.toml", + "request": "99", + "installation": null, + "kind": "declaration", + "reader": "native" + }, + "loginShellHome": "/opt/lithe-fixtures/installs/java/21.0.4" + }, + "expected": { + "operationID": "resolve-1", + "tool": "java", + "directory": ".", + "outcome": { + "kind": "resolved", + "selection": { + "home": "/opt/lithe-fixtures/installs/java/21.0.4", + "executable": "/opt/lithe-fixtures/installs/java/21.0.4/bin/java", + "version": "21.0.4", + "provider": "host:system", + "layer": "global", + "projectCode": false, + "environment": { + "JAVA_HOME": "/opt/lithe-fixtures/installs/java/21.0.4" + }, + "pathPrepend": [ + "/opt/lithe-fixtures/installs/java/21.0.4/bin" + ] + } + }, + "trace": [ + { + "candidateID": "project-request", + "layer": "project", + "state": "skip", + "reason": { + "code": "requestedNotInstalled", + "tool": "java", + "provider": "dev.lithe.fixture.mise:mise", + "source": "/opt/lithe-fixtures/workspace/mise.toml", + "request": "99" + } + }, + { + "candidateID": "shell-command", + "layer": "global", + "state": "resolved" + } + ], + "unmet": [ + { + "code": "requestedNotInstalled", + "tool": "java", + "provider": "dev.lithe.fixture.mise:mise", + "source": "/opt/lithe-fixtures/workspace/mise.toml", + "request": "99" + } + ], + "conflicts": [] + } + }, + { + "name": "conflicting globals skip providers and use login shell", + "input": { + "providerGlobals": [ + { + "provider": "dev.lithe.fixture.mise:mise", + "home": "/opt/lithe-fixtures/installs/java/21.0.4" + }, + { + "provider": "dev.lithe.fixture.sdkman:sdkman", + "home": "/opt/lithe-fixtures/sdkman/java/17.0.12" + } + ], + "loginShellHome": "/opt/lithe-fixtures/installs/java/21.0.4" + }, + "expected": { + "operationID": "resolve-1", + "tool": "java", + "directory": ".", + "outcome": { + "kind": "resolved", + "selection": { + "home": "/opt/lithe-fixtures/installs/java/21.0.4", + "executable": "/opt/lithe-fixtures/installs/java/21.0.4/bin/java", + "version": "21.0.4", + "provider": "host:system", + "layer": "global", + "projectCode": false, + "environment": { + "JAVA_HOME": "/opt/lithe-fixtures/installs/java/21.0.4" + }, + "pathPrepend": [ + "/opt/lithe-fixtures/installs/java/21.0.4/bin" + ] + } + }, + "trace": [ + { + "candidateID": "global-request", + "layer": "global", + "state": "skip", + "reason": { + "code": "providerConflict" + } + }, + { + "candidateID": "shell-command", + "layer": "global", + "state": "resolved" + } + ], + "unmet": [], + "conflicts": [ + { + "scope": "global", + "providers": [ + "dev.lithe.fixture.mise:mise", + "dev.lithe.fixture.sdkman:sdkman" + ], + "action": "chooseProvider" + } + ] + } + }, + { + "name": "automatic compares numeric components not text", + "input": { + "probedVersions": [ + "9.0.9", + "21.0.4", + "17.0.12" + ], + "minimumVersion": "17" + }, + "expected": { + "operationID": "resolve-1", + "tool": "java", + "directory": ".", + "outcome": { + "kind": "resolved", + "selection": { + "home": "/opt/lithe-fixtures/installs/java/21.0.4", + "executable": "/opt/lithe-fixtures/installs/java/21.0.4/bin/java", + "version": "21.0.4", + "provider": "dev.lithe.fixture.mise:mise", + "layer": "automatic", + "projectCode": false, + "environment": { + "JAVA_HOME": "/opt/lithe-fixtures/installs/java/21.0.4" + }, + "pathPrepend": [ + "/opt/lithe-fixtures/installs/java/21.0.4/bin" + ] + } + }, + "trace": [], + "unmet": [], + "conflicts": [] + } + }, + { + "name": "manual-only newer installation is not automatic", + "input": { + "installedHomes": [ + "/opt/lithe-fixtures/installs/java/21.0.4" + ], + "manualInstallations": [ + { + "home": "/opt/lithe-fixtures/manual/java/99", + "version": "99" + } + ] + }, + "expected": { + "operationID": "resolve-1", + "tool": "java", + "directory": ".", + "outcome": { + "kind": "resolved", + "selection": { + "home": "/opt/lithe-fixtures/installs/java/21.0.4", + "executable": "/opt/lithe-fixtures/installs/java/21.0.4/bin/java", + "version": "21.0.4", + "provider": "dev.lithe.fixture.mise:mise", + "layer": "automatic", + "projectCode": false, + "environment": { + "JAVA_HOME": "/opt/lithe-fixtures/installs/java/21.0.4" + }, + "pathPrepend": [ + "/opt/lithe-fixtures/installs/java/21.0.4/bin" + ] + } + }, + "trace": [], + "unmet": [], + "conflicts": [] + } + }, + { + "name": "child declaration beats more distant wrapper", + "input": { + "directory": "apps/web", + "projectCandidates": [ + { + "directory": ".", + "kind": "wrapper", + "path": "mvnw" + }, + { + "directory": "apps/web", + "kind": "declaration", + "home": "/opt/lithe-fixtures/installs/maven/3.9.9" + } + ] + }, + "expected": { + "operationID": "resolve-1", + "tool": "maven", + "directory": "apps/web", + "outcome": { + "kind": "resolved", + "selection": { + "home": "/opt/lithe-fixtures/installs/maven/3.9.9", + "executable": "/opt/lithe-fixtures/installs/maven/3.9.9/bin/mvn", + "version": "3.9.9", + "provider": "dev.lithe.fixture.mise:mise", + "layer": "project", + "projectCode": false, + "environment": {}, + "pathPrepend": [ + "/opt/lithe-fixtures/installs/maven/3.9.9/bin" + ] + } + }, + "trace": [], + "unmet": [], + "conflicts": [] + } + }, + { + "name": "same-directory wrapper precedes manager statement after user action", + "input": { + "directory": ".", + "userActionAuthorized": true, + "wrapper": "/opt/lithe-fixtures/workspace/mvnw", + "providerHome": "/opt/lithe-fixtures/installs/maven/3.9.9" + }, + "expected": { + "operationID": "resolve-1", + "tool": "maven", + "directory": ".", + "outcome": { + "kind": "resolved", + "selection": { + "home": null, + "executable": "/opt/lithe-fixtures/workspace/mvnw", + "version": null, + "provider": "dev.lithe.fixture.maven:wrapper", + "layer": "project", + "projectCode": true, + "environment": {}, + "pathPrepend": [] + } + }, + "trace": [], + "unmet": [], + "conflicts": [] + } + }, + { + "name": "native reader beats compatible reader for the same file", + "input": { + "source": "/opt/lithe-fixtures/workspace/.nvmrc", + "readers": [ + { + "provider": "dev.lithe.fixture.mise:mise", + "reader": "compatible", + "home": "/opt/lithe-fixtures/installs/node/22.0.0" + }, + { + "provider": "dev.lithe.fixture.nvm:nvm", + "reader": "native", + "home": "/opt/lithe-fixtures/nvm/node/20.0.0" + } + ] + }, + "expected": { + "operationID": "resolve-1", + "tool": "node", + "directory": ".", + "outcome": { + "kind": "resolved", + "selection": { + "home": "/opt/lithe-fixtures/nvm/node/20.0.0", + "executable": "/opt/lithe-fixtures/nvm/node/20.0.0/bin/node", + "version": "20.0.0", + "provider": "dev.lithe.fixture.nvm:nvm", + "layer": "project", + "projectCode": false, + "environment": {}, + "pathPrepend": [ + "/opt/lithe-fixtures/nvm/node/20.0.0/bin" + ] + } + }, + "trace": [], + "unmet": [], + "conflicts": [] + } + }, + { + "name": "disabled definition leaves pin but no executable authority", + "input": { + "localFixture": "local.json", + "enabledDefinitions": [] + }, + "expected": { + "operationID": "resolve-1", + "tool": "java", + "directory": ".", + "outcome": { + "kind": "skip", + "reason": { + "code": "definitionUnavailable", + "tool": "java" + } + }, + "trace": [], + "unmet": [], + "conflicts": [] + } + }, + { + "name": "unknown probed version cannot satisfy automatic minimum", + "input": { + "installedHomes": [ + "/opt/lithe-fixtures/installs/java/21.0.4" + ], + "probeOutput": "unknown", + "minimumVersion": "17" + }, + "expected": { + "operationID": "resolve-1", + "tool": "java", + "directory": ".", + "outcome": { + "kind": "skip", + "reason": { + "code": "versionUnsatisfied" + } + }, + "trace": [], + "unmet": [], + "conflicts": [] + } + }, + { + "name": "equal newest versions at distinct homes require explicit choice", + "input": { + "installations": [ + { + "provider": "dev.lithe.fixture.mise:mise", + "home": "/opt/lithe-fixtures/installs/java/21.0.4", + "normalizedVersion": "21.0.4" + }, + { + "provider": "dev.lithe.fixture.mise:mise", + "home": "/opt/lithe-fixtures/other-installs/java/21.0.4", + "normalizedVersion": "21.0.4" + } + ] + }, + "expected": { + "operationID": "resolve-1", + "tool": "java", + "directory": ".", + "outcome": { + "kind": "skip", + "reason": { + "code": "ambiguousInstallation", + "tool": "java" + } + }, + "trace": [ + { + "candidateID": "installed", + "layer": "automatic", + "state": "skip", + "reason": { + "code": "ambiguousInstallation", + "tool": "java" + } + } + ], + "unmet": [], + "conflicts": [ + { + "scope": "automatic", + "providers": [ + "dev.lithe.fixture.mise:mise" + ], + "installations": [ + "/opt/lithe-fixtures/installs/java/21.0.4", + "/opt/lithe-fixtures/other-installs/java/21.0.4" + ], + "action": "chooseInstallation" + } + ] + } + } + ], + "invalidLocalDocuments": [ + { + "name": "parent segment", + "value": { + "version": 1, + "toolchains": {}, + "pins": { + "apps/../other": {} + } + } + }, + { + "name": "absolute directory key", + "value": { + "version": 1, + "toolchains": {}, + "pins": { + "/apps": {} + } + } + }, + { + "name": "relative home", + "value": { + "version": 1, + "toolchains": {}, + "pins": { + ".": { + "java": { + "home": "vendor/jdk", + "version": "21", + "provider": "host:manual" + } + } + } + } + } + ] +} diff --git a/shared/contracts/plugins/v1/proposals/toolchains/local.schema.json b/shared/contracts/plugins/v1/proposals/toolchains/local.schema.json new file mode 100644 index 0000000000..0727b88cee --- /dev/null +++ b/shared/contracts/plugins/v1/proposals/toolchains/local.schema.json @@ -0,0 +1,107 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/Lithe-IDEA/lithe-plugin-docs/main/contract/v1/proposals/toolchains/local.schema.json", + "title": "Proposed local toolchain pins extension (not implemented)", + "description": "Review-only complete document shape for .lithe/toolchains/local.json. The active toolchain-local-v1.schema.json remains unchanged and does not accept pins.", + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "toolchains" + ], + "properties": { + "version": { + "const": 1 + }, + "toolchains": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "properties": { + "home": { + "type": "string", + "minLength": 1 + }, + "executable": { + "type": "string", + "minLength": 1 + }, + "javaHome": { + "type": "string", + "minLength": 1 + } + } + }, + "description": "Existing v1 entries are preserved byte-semantically; new resolution does not manufacture aliases to legacy project-jdk/project-maven IDs." + }, + "pins": { + "type": "object", + "maxProperties": 1024, + "propertyNames": { + "$ref": "#/$defs/directory" + }, + "additionalProperties": { + "type": "object", + "maxProperties": 64, + "propertyNames": { + "$ref": "#/$defs/toolID" + }, + "additionalProperties": { + "$ref": "#/$defs/pin" + } + } + } + }, + "$defs": { + "directory": { + "type": "string", + "maxLength": 2048, + "pattern": "^(?:\\.|(?!\\.\\.?(?:/|$))[^/\\\\:]+(?:/(?!\\.\\.?(?:/|$))[^/\\\\:]+)*)$", + "not": { + "pattern": "[\\u0000-\\u001f\\u007f]" + } + }, + "toolID": { + "$ref": "manifest-contributions.schema.json#/$defs/toolID" + }, + "nativePath": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "pattern": "^(?:/|[A-Za-z]:[\\\\/]|\\\\\\\\)", + "not": { + "pattern": "[\\u0000-\\u001f\\u007f]" + } + }, + "pin": { + "type": "object", + "additionalProperties": false, + "required": [ + "home", + "version", + "provider" + ], + "properties": { + "home": { + "$ref": "#/$defs/nativePath" + }, + "version": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "not": { + "pattern": "[\\u0000-\\u001f\\u007f]" + } + }, + "provider": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "^[a-z][a-z0-9.-]*:[a-z][a-z0-9-]*$", + "description": "Qualified pluginID:providerID, or reserved host:manual / host:system; not an activation instruction." + } + } + } + } +} diff --git a/shared/contracts/plugins/v1/proposals/toolchains/manifest-contributions.schema.json b/shared/contracts/plugins/v1/proposals/toolchains/manifest-contributions.schema.json new file mode 100644 index 0000000000..20c890679d --- /dev/null +++ b/shared/contracts/plugins/v1/proposals/toolchains/manifest-contributions.schema.json @@ -0,0 +1,260 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/Lithe-IDEA/lithe-plugin-docs/main/contract/v1/proposals/toolchains/manifest-contributions.schema.json", + "title": "PROPOSAL: toolchain contribution projections, not a plugin manifest", + "description": "A standalone review envelope for proposed manifest fields. Passing this schema does not make a plugin installable and does not prove signature, registry, filesystem or executable safety. See manifest.md.", + "type": "object", + "additionalProperties": false, + "required": ["proposalVersion", "pluginID", "requires", "permissions"], + "properties": { + "proposalVersion": { "const": 1 }, + "pluginID": { "type": "string", "pattern": "^[a-z0-9]+(?:[.-][a-z0-9]+)*$", "maxLength": 128 }, + "requires": { + "type": "object", "additionalProperties": false, "minProperties": 1, + "properties": { + "toolDefinitions": { "const": 1 }, + "toolchainProviders": { "const": 1 }, + "toolchainConsumers": { "const": 1 }, + "executableResolution": { "const": 2 }, + "backend": { "const": 2 }, + "serverProcess": { "const": 1 } + } + }, + "permissions": { + "type": "array", "maxItems": 16, "uniqueItems": true, + "items": { "enum": ["toolchains.provide", "backend.launch"] } + }, + "toolDefinitions": { "type": "array", "minItems": 1, "maxItems": 32, "items": { "$ref": "#/$defs/toolDefinition" } }, + "toolchainProviders": { "type": "array", "minItems": 1, "maxItems": 8, "items": { "$ref": "#/$defs/provider" } }, + "backend": { "$ref": "#/$defs/backend" }, + "consumers": { + "description": "Review projection of fields on existing language/build/run/debug contributions; NOT a proposed top-level manifest field.", + "type": "array", "minItems": 1, "maxItems": 64, "items": { "$ref": "#/$defs/consumer" } + } + }, + "anyOf": [ { "required": ["toolDefinitions"] }, { "required": ["toolchainProviders"] }, { "required": ["consumers"] } ], + "allOf": [ + { + "if": { "required": ["toolDefinitions"] }, + "then": { "properties": { "requires": { "required": ["toolDefinitions", "executableResolution"] } } } + }, + { + "if": { "required": ["toolchainProviders"] }, + "then": { + "required": ["backend"], + "properties": { + "requires": { "required": ["toolchainProviders", "backend", "serverProcess"] }, + "permissions": { "allOf": [ { "contains": { "const": "toolchains.provide" } }, { "contains": { "const": "backend.launch" } } ] } + } + } + }, + { + "if": { "required": ["consumers"] }, + "then": { "properties": { "requires": { "required": ["toolchainConsumers"] } } } + } + ], + "$defs": { + "identifier": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,63}$" }, + "toolID": { "type": "string", "pattern": "^(?:[a-z0-9]+(?:[.-][a-z0-9]+)*:)?[a-z][a-z0-9-]{0,63}$", "maxLength": 192 }, + "label": { "type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[^\\u0000-\\u001f\\u007f]+$" }, + "relativePath": { + "type": "string", "minLength": 1, "maxLength": 256, + "pattern": "^(?!/)(?!.*(?:^|/)(?:\\.|\\.\\.)(?:/|$))[^/\\\\:*?\\u0000-\\u001f\\u007f]+(?:/[^/\\\\:*?\\u0000-\\u001f\\u007f]+)*$" + }, + "boundedGlob": { + "description": "At most eight segments; only single-segment * is supported, never **, ?, character classes, or traversal. Semantic validation also bounds expansion.", + "type": "string", "minLength": 1, "maxLength": 256, + "pattern": "^(?!/)(?!.*\\*\\*)(?!.*(?:^|/)(?:\\.|\\.\\.)(?:/|$))[^/\\\\:?\\[\\]{}\\u0000-\\u001f\\u007f]+(?:/[^/\\\\:?\\[\\]{}\\u0000-\\u001f\\u007f]+){0,7}$" + }, + "command": { "type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" }, + "safeEnvironmentName": { "type": "string", "maxLength": 64, "pattern": "^(?!PATH$)(?!DYLD_)(?!LD_)[A-Z_][A-Z0-9_]*$" }, + "platforms": { "type": "array", "minItems": 1, "maxItems": 3, "uniqueItems": true, "items": { "enum": ["macos", "windows", "linux"] } }, + "version": { + "type": "object", "additionalProperties": false, "required": ["arguments", "pattern"], + "properties": { + "arguments": { "type": "array", "minItems": 1, "maxItems": 8, "items": { "type": "string", "pattern": "^--?[A-Za-z][A-Za-z0-9-]*$", "maxLength": 64 } }, + "pattern": { "description": "Rust-regex-compatible, first capture is a dotted numeric version. Validity, capture count and runtime bounds require semantic checks.", "type": "string", "minLength": 1, "maxLength": 512 } + } + }, + "executableLayout": { + "type": "object", "additionalProperties": false, "required": ["path"], + "properties": { "path": { "$ref": "#/$defs/relativePath" }, "platforms": { "$ref": "#/$defs/platforms" } } + }, + "homeLayout": { + "type": "object", "additionalProperties": false, "required": ["match", "home"], + "properties": { + "match": { "allOf": [ { "$ref": "#/$defs/boundedGlob" }, { "pattern": "^[^/]+$" } ] }, + "home": { "oneOf": [ { "const": "." }, { "$ref": "#/$defs/relativePath" } ] }, + "platforms": { "$ref": "#/$defs/platforms" } + } + }, + "discovery": { + "type": "object", "additionalProperties": false, "required": ["root", "pattern"], + "properties": { + "root": { + "oneOf": [ + { "type": "object", "additionalProperties": false, "required": ["kind"], "properties": { "kind": { "const": "home" } } }, + { "type": "object", "additionalProperties": false, "required": ["kind", "variable"], "properties": { "kind": { "const": "environment" }, "variable": { "$ref": "#/$defs/safeEnvironmentName" } } }, + { "type": "object", "additionalProperties": false, "required": ["kind", "path"], "properties": { "kind": { "const": "absolute" }, "path": { "type": "string", "minLength": 2, "maxLength": 256, "pattern": "^(?:/|[A-Za-z]:[\\\\/])[^\\u0000-\\u001f\\u007f]*$" } } } + ] + }, + "pattern": { "$ref": "#/$defs/boundedGlob" }, + "platforms": { "$ref": "#/$defs/platforms" } + } + }, + "wrapper": { + "type": "object", "additionalProperties": false, "required": ["path"], + "properties": { + "path": { "$ref": "#/$defs/relativePath" }, + "requires": { "type": "array", "maxItems": 8, "uniqueItems": true, "items": { "$ref": "#/$defs/relativePath" } }, + "platforms": { "$ref": "#/$defs/platforms" } + } + }, + "candidateMetadata": { + "type": "object", + "properties": { + "id": { "$ref": "#/$defs/identifier" }, + "priority": { "type": "integer", "minimum": -2147483648, "maximum": 2147483647 }, + "insertBefore": { "$ref": "#/$defs/identifier" }, + "insertAfter": { "$ref": "#/$defs/identifier" }, + "platforms": { "$ref": "#/$defs/platforms" } + }, + "not": { "anyOf": [ { "required": ["priority", "insertBefore"] }, { "required": ["priority", "insertAfter"] }, { "required": ["insertBefore", "insertAfter"] } ] } + }, + "candidate": { + "$ref": "#/$defs/candidateMetadata", "unevaluatedProperties": false, + "oneOf": [ + { + "required": ["kind", "settingID", "layer"], + "properties": { "kind": { "const": "setting" }, "settingID": { "const": "executable" }, "layer": { "enum": ["project", "global"] } } + }, + { + "required": ["kind", "scope"], + "properties": { "kind": { "const": "toolchain" }, "scope": { "enum": ["project", "global", "installed"] } } + }, + { + "required": ["kind", "path"], + "properties": { + "kind": { "const": "projectFile" }, + "path": { "$ref": "#/$defs/relativePath" }, + "requires": { "type": "array", "maxItems": 8, "items": { "$ref": "#/$defs/relativePath" } }, + "projectKind": { "enum": ["wrapper", "environment"], "default": "wrapper" }, + "home": { "oneOf": [ { "const": "." }, { "$ref": "#/$defs/relativePath" } ] } + }, + "allOf": [ + { + "if": { "required": ["projectKind"], "properties": { "projectKind": { "const": "environment" } } }, + "then": { "required": ["home"] }, + "else": { "not": { "required": ["home"] } } + } + ] + }, + { + "required": ["kind", "variable", "path"], + "properties": { "kind": { "const": "environment" }, "variable": { "$ref": "#/$defs/safeEnvironmentName" }, "path": { "$ref": "#/$defs/relativePath" } } + }, + { + "required": ["kind", "command"], + "properties": { "kind": { "const": "command" }, "command": { "$ref": "#/$defs/command" } } + }, + { + "required": ["kind", "runtime"], + "properties": { "kind": { "const": "runtime" }, "runtime": { "$ref": "#/$defs/toolID" } } + } + ] + }, + "launchTokens": { + "type": "array", "minItems": 1, "maxItems": 32, + "prefixItems": [ { "const": "${executable}" } ], + "items": { "type": "string", "minLength": 1, "maxLength": 256, "pattern": "^(?:\\$\\{(?:home|executable|entry|runtimeArguments|arguments)\\}|[^$\\u0000-\\u001f\\u007f]+)$" } + }, + "toolDefinition": { + "type": "object", "additionalProperties": false, + "required": ["id", "displayName", "executableLayouts", "executables", "version", "commands", "launch"], + "properties": { + "id": { "$ref": "#/$defs/toolID" }, + "displayName": { "$ref": "#/$defs/label" }, + "icon": { "$ref": "../../plugin-manifest.schema.json#/$defs/uiIcon" }, + "executableLayouts": { "type": "array", "minItems": 1, "maxItems": 8, "items": { "$ref": "#/$defs/executableLayout" } }, + "executables": { "type": "array", "minItems": 1, "maxItems": 32, "items": { "$ref": "#/$defs/candidate" } }, + "version": { "$ref": "#/$defs/version" }, + "homeLayouts": { "type": "array", "maxItems": 8, "items": { "$ref": "#/$defs/homeLayout" } }, + "discovery": { "type": "array", "maxItems": 16, "items": { "$ref": "#/$defs/discovery" } }, + "homeVariable": { "$ref": "#/$defs/safeEnvironmentName" }, + "commands": { "type": "array", "minItems": 1, "maxItems": 8, "uniqueItems": true, "items": { "$ref": "#/$defs/command" } }, + "wrappers": { "type": "array", "maxItems": 8, "items": { "$ref": "#/$defs/wrapper" } }, + "exports": { + "type": "array", "maxItems": 16, + "items": { + "type": "object", "additionalProperties": false, "required": ["name", "value"], + "properties": { "name": { "$ref": "#/$defs/safeEnvironmentName" }, "value": { "enum": ["${home}", "${executable}"] } } + } + }, + "pathPrepend": { + "type": "array", "maxItems": 8, "uniqueItems": true, + "items": { "type": "string", "maxLength": 256, "pattern": "^\\$\\{home\\}(?:/(?!\\.{1,2}(?:/|$))[^/\\\\:$*?\\u0000-\\u001f\\u007f]+)*$" } + }, + "launch": { "$ref": "#/$defs/launchTokens" } + } + }, + "provider": { + "type": "object", "additionalProperties": false, "required": ["id", "displayName", "tools"], + "properties": { + "id": { "$ref": "#/$defs/identifier" }, + "displayName": { "$ref": "#/$defs/label" }, + "tools": { "type": "array", "minItems": 1, "maxItems": 32, "uniqueItems": true, "items": { "$ref": "#/$defs/toolID" } }, + "watchFiles": { + "type": "array", "minItems": 1, "maxItems": 32, "uniqueItems": true, + "items": { + "type": "object", "additionalProperties": false, "required": ["pattern", "reader"], + "properties": { "pattern": { "$ref": "#/$defs/boundedGlob" }, "reader": { "enum": ["native", "compatible"] } } + } + } + } + }, + "backend": { + "type": "object", "additionalProperties": false, "required": ["launch"], + "properties": { + "launch": { + "oneOf": [ + { + "type": "object", "additionalProperties": false, "required": ["kind", "path"], + "properties": { "kind": { "const": "packaged" }, "path": { "$ref": "#/$defs/relativePath" } } + }, + { + "type": "object", "additionalProperties": false, "required": ["kind", "tool", "packagedRuntime", "entry"], + "properties": { + "kind": { "const": "runtime" }, "tool": { "$ref": "#/$defs/toolID" }, + "packagedRuntime": { "$ref": "#/$defs/relativePath" }, "entry": { "$ref": "#/$defs/relativePath" }, + "runtimeArguments": { "type": "array", "maxItems": 16, "items": { "type": "string", "maxLength": 256, "pattern": "^[^$\\u0000-\\u001f\\u007f]*$" } } + } + } + ] + }, + "arguments": { "type": "array", "maxItems": 16, "items": { "type": "string", "maxLength": 256, "pattern": "^[^$\\u0000-\\u001f\\u007f]*$" } } + } + }, + "consumer": { + "type": "object", "additionalProperties": false, "required": ["id", "requiresTools", "onToolchainChange"], + "properties": { + "id": { "$ref": "#/$defs/identifier" }, + "requiresTools": { + "type": "array", "minItems": 1, "maxItems": 16, + "items": { "type": "object", "additionalProperties": false, "required": ["tool"], "properties": { "tool": { "$ref": "#/$defs/toolID" }, "minimumVersion": { "type": "string", "pattern": "^[0-9]+(?:\\.[0-9]+){0,7}$", "maxLength": 64 } } } + }, + "onToolchainChange": { "enum": ["restart", "resync", "nextRun"] }, + "launch": { + "type": "object", "additionalProperties": false, "required": ["kind", "tool", "entry"], + "properties": { + "kind": { "const": "runtime" }, "tool": { "$ref": "#/$defs/toolID" }, "entry": { "$ref": "#/$defs/relativePath" }, + "runtimeArguments": { "type": "array", "maxItems": 16, "items": { "type": "string", "maxLength": 256 } } + } + }, + "runtimeDependencies": { + "type": "array", "maxItems": 16, + "items": { "type": "object", "additionalProperties": false, "required": ["tool"], "properties": { "tool": { "$ref": "#/$defs/toolID" } } } + } + } + } + } +} diff --git a/shared/contracts/plugins/v1/proposals/toolchains/manifest.md b/shared/contracts/plugins/v1/proposals/toolchains/manifest.md new file mode 100644 index 0000000000..fae3ab1a2f --- /dev/null +++ b/shared/contracts/plugins/v1/proposals/toolchains/manifest.md @@ -0,0 +1,318 @@ +# Toolchain manifest contributions (proposal) + +**Status: full proposal, not a claim of native host support.** The implemented +Core subset is specified by [the active contract](../../toolchains.md). This +document follows the +[final scope of PR #1581](https://github.com/1lck/Lithe-IDEA/pull/1581#issuecomment-6541366112). +It is a host contract draft, not a plugin author tutorial. The current manifest +schema now admits only that subset; generic consumer/runtime migration remains +unsupported. Do not install these projection examples as `plugin.json`. + +[`manifest-contributions.schema.json`](manifest-contributions.schema.json) is a +standalone **review projection**, not a replacement manifest schema and not an +`allOf` extension of the current closed schema. Its `proposalVersion`, `pluginID` +and `consumers` envelope fields organize fixtures; they are not new production +manifest fields. `toolDefinitions`, `toolchainProviders`, and the consumer fields +below would be integrated into the existing manifest only when Core and the +corresponding host adapters implement and advertise the capabilities. The +production `schemaVersion: 2` and existing API levels are not changed by this +proposal. A future implementation must not silently accept these fields under an +older capability. + +## Three contributors, one tool registry + +| Contributor | Owns | Does not own | +| --- | --- | --- | +| Tool definition | Identity, executable layout, version probe, bounded discovery, candidate order, exported environment, argv launch template | Version-manager aliases, private configuration formats, provider priority rules | +| Toolchain provider | Installed tools and project/global requirements, computed by its own backend or upstream manager | Tool definitions, arbitrary command replacement, host UI or resolver code | +| Consumer | Explicit tool references and minimum versions, directory, response to a changed selection | Discovery paths, alternate secret selection chains | + +There are **no host-owned tool definitions, built-in definition data files, +short-name aliases, or tool-name switches**. Java support defines `java`; a Maven +plugin defines `maven`. Node, Python and other official plugins migrate only when +they supply their own definitions. This draft does not remove existing behavior +or authorize new hardcoded fallbacks during that staged migration. + +The enabled registry is populated from installed, enabled plugins. A namespaced +definition ID is `:`; a plugin cannot define another +plugin's namespace. Short IDs such as `java` are reserved for packages whose +official identity the **host independently verified during installation**. A +manifest's vendor or signature-policy text is not evidence of that identity. +Community providers may answer short IDs; defining and answering are different +authorities. The first registered owner of an ID remains the owner; a later +duplicate is rejected, never used as an override. Stable enumeration order makes +diagnostics reproducible, not a priority policy for conflicting runtime facts. + +## Proposed capability admission + +The names and versions here are review assignments, not host advertisements: + +| Capability | Required exactly when | +| --- | --- | +| `toolDefinitions: 1` | The plugin contributes definitions | +| `toolchainProviders: 1` | The plugin contributes providers | +| `executableResolution: 2` | A definition uses the new shared candidate projection, including `toolchain` or `setting.layer` | +| `toolchainConsumers: 1` | A consumer uses generic tool references and change policy | +| Existing `backend: 2`, `serverProcess: 1` | A provider uses the plugin's backend | + +Each actual contribution still declares its existing language/build/run/debug +capabilities and permissions. The review envelope lists only capabilities relevant +to this proposal. Missing **or unused** capabilities are rejected, just as the +current manifest contract requires. There is no broad new API-level increment to +pretend that an unimplemented launch adapter supports the draft. + +## Tool definitions + +The [Java-support-style projection](fixtures/java-tool-definition.json) supplies +the tool name and every executable policy. Its short ID is valid only in an +independently official installation context; the fixture itself is not signed. + +| Field | Draft meaning and limits | +| --- | --- | +| `id`, `displayName`, `icon` | Tool ID, one-line label of at most 64 characters, and existing shared `uiIcon` format; normal icon package checks still apply | +| `executableLayouts` | 1-8 exact tool-home-relative executable paths, optionally filtered by platform, in fallback order; native executable checks apply | +| `executables` | 1-32 **resolver candidates**, not executable filenames; see below | +| `version` | 1-8 flag-shaped literal probe arguments and a Rust-regex-compatible expression of at most 512 bytes; first capture yields the normalized version | +| `homeLayouts` | At most 8 ordered, single-component name matches mapping a selected installation directory to `.` or a relative home suffix; for example `*.jdk` maps to `Contents/Home` | +| `discovery` | At most 16 bounded scans with root `home`, named `environment`, or an absolute system directory; the host executes data, not a tool-specific scanner. This primitive set is frozen; shapes it cannot express use a provider | +| `homeVariable`, `commands` | Metadata identifying the login-shell input and 1-8 bare commands belonging to this tool | +| `wrappers` | At most 8 exact project-relative wrapper paths, required marker files, and platform filters | +| `exports` | At most 16 unique environment names, each assigned exactly `${home}` or `${executable}` | +| `pathPrepend` | At most 8 paths under `${home}`; no traversal, shell interpolation, or replacement of PATH | +| `launch` | At most 32 argv tokens; the first is exactly `${executable}` | + +`homeLayouts` applies only when normalizing a manual selection or a system +discovery match. A provider's `installations[].home` is already the final tool +home; applying a layout again could append the same suffix twice. + +`executableLayouts` and `executables` deliberately have different roles. The first +locates the executable **within an already chosen installation**. The second +selects which source may supply that installation or executable. A candidate does +not gain permission to launch a same-named command for another tool. + +`homeVariable`, `commands`, and `wrappers` are checked metadata for the same +candidate plan, **not alternate discovery or launch paths**: every `environment` +candidate must use the declared home variable and one declared executable layout; +every `command` must belong to `commands`; every wrapper `projectFile` must match +one wrapper, including its `requires` markers and platform applicability. A +`projectFile` with `projectKind: "environment"` instead declares its own exact +`path`, `requires` markers and mandatory project-relative `home`; its path must +equal that home plus an applicable executable layout. It is not a wrapper or +an external provider installation. Conversely, +declared wrappers and shell inputs must have their corresponding candidate(s). +Any mismatch rejects the definition, so the UI and resolver cannot drift. Only +the candidate plan is executed. An implementation may normalize these references +to one internal representation; it must not maintain two independent pipelines. + +### Version probing and argv safety + +The first version capture must match `[0-9]+(?:\.[0-9]+){0,7}`, at most 64 bytes. +Core compares unsigned numeric components, padding absent trailing components +with zeros. The Java fixture's optional `1.` prefix is declared by that plugin: +`1.8.0_422` produces `8.0`, while `21.0.4` produces `21.0.4`. The host has no Java +8 branch. Provider display text is provenance; auto-selection uses the validated +probe capture, not a parsed alias such as `temurin-21`. + +The host caps probe stdout and stderr at 64 KiB each, applies a 5-second monotonic +deadline, and cancels the child tree on timeout or cancellation. A failed probe +does not provide a sortable version. It cannot satisfy a consumer's minimum +version by assumption. Equal normalized versions with distinct homes are shown +as an explicit ambiguity requiring a choice; no plugin ID or directory name wins. + +Arguments match `-x`/`--long-flag` shape only; no positional commands, `=value`, +scripts, or environment templates. This is a syntax restriction, **not proof that +an executable is side-effect-free**. Installation review must list each executable, +probe arguments, scan roots, export names, and provider backend the package can +run. A future tool whose upstream version command cannot satisfy this restriction +needs a reviewed generic extension; do not add a tool-name exception. + +Launch is an argv vector, never a shell command. `${home}`, `${executable}` and +`${entry}` are whole scalar tokens; `${runtimeArguments}` and `${arguments}` are +whole-token list expansions, at most once each. No inline interpolation, unknown +variable, shell evaluation, shell launcher, or empty executable is allowed. +`${entry}` is independently validated inside the consumer package. Empty argument +lists expand to zero tokens. Arguments and paths with spaces remain single argv +elements. On Windows the existing platform argv-quoting adapter owns native +serialization; the plugin cannot supply shell quoting. + +Exports reject `PATH`, `DYLD_*`, and `LD_*` names. Existing host process-environment +filtering still applies; the permission does not authorize injecting credentials, +loader variables or arbitrary environment expressions. `pathPrepend` is checked +after canonicalization: symlinks may not escape the selected home. Exports from a +rejected candidate never leak into the next candidate's environment. + +## One candidate resolver, four product layers + +The draft extends the +[existing candidate model](../../README.md#shared-executable-candidates-executableresolution-1) +rather than building a second runtime resolver: + +- `setting` gains mandatory `layer: "project" | "global"` in this projection. + `settingID: "executable"` names a generic framework slot, not a plugin private + path-setting ID. Project values come from nearest-ancestor `pins`; global values + are Lithe-owned application preferences. An invalid nonempty fixed/default + value is `terminalFailure`, never an automatic fallback. +- `toolchain` gains `scope: "project" | "global" | "installed"`. The tool is the + containing definition; a candidate cannot inject facts for a different ID. +- Existing `projectFile`, `environment`, `command`, and `runtime` source kinds + retain their source meanings; `runtime` references an explicit tool ID and + dependency cycles are rejected. A `projectFile` only runs through the existing + consumer trust/explicit-user-action boundary, never passive discovery/probing. +- Under the new capability only, `projectFile.projectKind` distinguishes + `wrapper` (default, executable-only) from `environment` (explicit `home`). A + wrapper produces `home: null`; it never invents a tool home from the workspace + root. The environment's home and program are relative to the candidate's + source-directory anchor, which also determines project specificity. The + existing `environment` kind still means an external login-shell variable, + not a project environment. Old executable-resolution v1 is unchanged. +- IDs, signed priority, insertion anchors, deterministic topological ordering, + platform filtering, and validation of the whole set remain the current model. + Anchors cannot cross contributions or plugins. Consumer adjustments apply only + to their own candidate set, not to another tool's definition or global registry. + +The normalized candidate list must preserve these four barriers: + +1. Fixed: nearest ancestor pin in the chosen directory, invalid means terminal. +2. Project: nearest applicable directory, then wrapper > environment > declaration; + for the same source file native reader > compatible reader. Equal claims are + an explicit unresolved conflict, not an ID-based tie-break. Uninstalled requests + return `skip` **with structured reason**, and remain visible as unsatisfied. +3. Global: Lithe default > unambiguous provider global > login shell. Conflicting + provider globals require a user choice; until then, skip them with a conflict + reason and use login-shell candidates. No hidden provider-ID precedence. +4. Automatic: eligible installations from both routes, provider answers and the + generic system scanner driven only by `discovery`; one canonical home counts + once. Select the highest valid normalized version satisfying the consumer + minimum, excluding manually added pin-only homes. + +Array order is the default source order inside these barriers. Ordering metadata +must not move candidates across a barrier or reverse project specificity/reader +precedence; such a definition is rejected. Tool definitions may omit unavailable +sources, but cannot silently redefine the four-layer policy. Absence is `skip`, +not a fabricated value. See the adjacent protocol/selection proposal for exact +query results and reason shapes; no current `skip` consumer changes in this stage. + +## Providers, file watching, and bootstrap + +The [mise projection](fixtures/mise-provider.json) declares `id`, `displayName`, +`tools`, and `watchFiles`. `watchFiles` is optional: without it a provider reports +installations only and any returned statement is rejected. That shape lets the +plugin defining a tool answer for its own installations when declarative +`discovery` cannot express them. Provider IDs are local to the plugin/session; registry +identity is the pair `(verified pluginID, provider id)`. There are at most eight +providers, each naming 1-32 tool IDs. The query set is the intersection of requested +and declared IDs; this may include dormant IDs solely for requirement diagnostics. +Only its subset with enabled definitions can supply executable candidates. +Dormant unknown IDs do not inject commands; the host may preserve the requirements +as diagnostics so it can offer enabling the installed definition plugin. A +consumer with a missing required definition cannot launch. + +`toolchains.provide` means the backend may supply candidates which influence the +program Lithe launches, **not merely read directories**. Its installation review +must say so explicitly. It does not authorize installation/download, network, +trusting workspace code, reading secrets, writing native configuration, or +replacing arbitrary commands. Normal backend permission and sandbox restrictions +continue to apply. + +`watchFiles` entries are `{pattern, reader: "native" | "compatible"}`. The host +validates each project statement's source and reader against the declaration; +returned facts cannot promote a compatible reader to native. Native reader +ownership conflicts are explicit and not resolved by plugin registration order. +Patterns are relative, at most eight path components and 256 bytes. Only `*` +inside a single component is supported; no `**`, `?`, classes, braces, traversal, +absolute paths or following a symlink outside the watched root. The host applies +each pattern directly at the selected directory and at most 64 ancestor +directories, including enclosing configuration above the opened workspace; no +recursive `**` scan is implied. Providers also return potential external/global +inputs, including missing paths, in `watch`. Watching an enclosing source does +not expand the workspace identity or editable pin scope. Expansion across the +whole query round is bounded to 256 watch roots and 4,096 enumerated entries, +not just successful matches, as well as the existing workspace scan budget. +Limit hits are reported, never treated as a complete snapshot. The host observes +the parent of missing matches so creation and deletion also invalidate the snapshot. + +Any declared watched file or returned absolute input change cancels the generation +and re-queries **all enabled providers**; no mixed-generation incremental result +is published. Remote workspaces query their own authority or are explicitly +unsupported, never the local machine's managers. Providers own private version-file +formats, `lts`, `latest`, `system`, configuration hierarchy and manager semantics. +The host neither parses those files nor independently guesses their precedence. + +The provider uses the plugin's existing `backend`; no per-tool process framework +and no LSP language feature is required. It must negotiate the typed toolchain +capability over the existing base JSON-RPC transport before receiving queries. +`toolchainProviders: 1` proposes an additional **host-query startup trigger** for +that backend. Existing `backend: 2` alone does not start the fixture today: its +current view/command/`onWorkspaceOpen` admission rules remain unchanged until this +new capability is implemented. The future host starts at most one backend for +the plugin when it needs a query, not one process per tool or directory; after +startup it negotiates support before dispatch. A query is not an implicit +`onWorkspaceOpen` activation declaration. +The backend is either `launch.kind: "packaged"` (a signed/package-checked native +executable), or `launch.kind: "runtime"` with mandatory `packagedRuntime`. The +latter starts exactly that packaged executable and uses the referenced active +tool definition's argv launch template; it never calls the resolver for its own +runtime. This also holds when a plugin provides installations of a tool it +defines: the backend never runs on the tool it is discovering. The launch definition must be registered independently of provider +queries and must be available before backend start. Missing definitions or +dependency cycles fail admission/start explicitly. A packaged runtime is not a +host fallback if a definition is disabled. Native packaged executables are the +recommended bootstrap, and are what the mise fixture demonstrates. + +Provider backends stay within the current restart-required activation policy. +This proposal does not relax live activation. Disabling/uninstalling cancels +queries, invalidates generations, stops the owned backend, and removes active +definitions, dependent consumer rows, exports, and plugin settings together. +Local pins remain stored for re-enable; dormant pins are not allowed to re-create +an executable definition or export. + +## Consumer field projections + +`consumers[]` exists only in this review envelope. Its fields project additions +onto existing language-server, build-provider, execution-provider, debug-adapter +or run-configuration contributions: + +- `launch.kind: "runtime"` accepts any registered `tool` ID; no `java | node` + enum or inferred filename mapping remains on the migrated path. +- `runtimeDependencies[].tool` likewise accepts any registered ID, with existing + uniqueness/no-duplicate-launch-runtime checks retained. +- `requiresTools: [{tool, minimumVersion?}]` explicitly lists every tool a + consumer uses. `launch.tool` and dependencies must be included, each once. +- `onToolchainChange` is `restart`, `resync`, or `nextRun`; an absent corresponding + lifecycle adapter makes that contribution unsupported, not silently ignored. + +Definitions must be known for a required consumer to launch. A syntactically valid +ID alone grants no authority. If its installed defining plugin is disabled, show +the requirement, known owner, and plugin-page enable action; do not launch a stale +pin. Optional provider facts for missing definitions remain diagnostic data only. + +The environment/source page skeleton renders only enabled registry data, scoped +to each directory/module. Tool/plugin-specific settings are contributed on that +plugin's settings page. Manually added homes live in platform application storage, +can only be pinned, never enter auto-selection, and are validated through the +same declared layouts/probe before a confirmed pin is written. + +## Validation boundaries and fixtures + +The standalone schema proves shape and bounded syntax only. Registry semantics +also check capability exactness, ownership, references, candidate graph/layers, +regular-expression validity, version captures, metadata consistency and template +usage. The native host separately proves verified signature provenance, package +containment, canonical paths, executable status, workspace exclusion, cancellation +and limits. None of those checks can be replaced by JSON Schema. + +[`manifest-rejections.json`](fixtures/manifest-rejections.json) separates these +phases. Each case applies RFC 6902 `patch` to its `base`; `context` is **host test +input**, never a plugin field. `phase: "schema"` must fail schema validation; +`registry` and `hostTrust` cases intentionally remain valid JSON shape and describe +the later semantic rejection. Codes reuse existing `plugin.manifest.invalid`, +`plugin.permission.undeclared`, and `plugin.template.invalid` categories; `reason` +is structured diagnostic detail, not a second numeric JSON-RPC error mapping. +These are proposed acceptance/rejection vectors, not tests of current Core. + +The implementation stage must add executable Core and host tests before declaring +support, then run both SDK end-to-end suites for actual backend protocol changes. +After merging contract changes, synchronize the byte-for-byte mirror using +`node scripts/contract.mjs sync --revision ` in +[lithe-plugin-docs](https://github.com/Lithe-IDEA/lithe-plugin-docs); do not hand-edit +the mirror or place SDK parsing helpers in the Lithe host. diff --git a/shared/contracts/plugins/v1/proposals/toolchains/protocol.md b/shared/contracts/plugins/v1/proposals/toolchains/protocol.md new file mode 100644 index 0000000000..dd806536d5 --- /dev/null +++ b/shared/contracts/plugins/v1/proposals/toolchains/protocol.md @@ -0,0 +1,371 @@ +# Proposed toolchain query, resolution and local pins + +**Status: broader proposal; the macOS settings pages query providers read-only, and SDK support is not implemented.** +The Core-only validation/resolution subset is specified by +[the active contract](../../toolchains.md). These files remain review vectors, +not an advertised native capability or an SDK guide. The existing local-toolchain +writer/schema is unchanged. The design follows the +[latest #1581 review](https://github.com/1lck/Lithe-IDEA/pull/1581#issuecomment-6541366112). + +## Ownership and transport + +A tool definition says how to recognize, probe and use a tool. A provider +answers which installations and requirements exist. A consumer explicitly +references a tool ID. The host supplies query orchestration and one resolver; +it never parses a manager's version files, aliases or requested-version syntax. +There are no built-in tool definitions, short-name aliases or tool-name +branches. The generic system scanner is a host provider whose entire scan plan +comes from enabled definitions' `discovery` contributions. Plugin providers +cover installation shapes those frozen scan primitives cannot express; both +routes feed one automatic candidate set, and one canonical home counts once. + +The logical **and wire** method is `toolchains/query`. This independent typed +extension reuses the existing LSP base JSON-RPC transport and ServerProcess +lifecycle, not the language-intelligence domain. A toolchain backend need not +declare a language, language server, Build provider or private callable +function. Initialization offers `experimental.litheToolchains: {versions: [1], +providers: ["mise"]}`; the backend selects `{version: 1, providers: ["mise"]}`. +The host intersects the returned local provider IDs with the verified manifest. +An unoffered version, duplicate/undeclared provider ID, or malformed answer fails +negotiation rather than enabling a partial registry; no query is sent before +negotiation succeeds. +This draft does not upgrade or relax any existing `litheFunctions` version. + +An installed, enabled provider requires `toolchains.provide` and explicit +installation review of the programs it may invoke. This permission can affect +which external program the host launches; it is not described as a harmless +filesystem read. Plugin identity, signature/trust, package digest and enabled +state come from the host registry, never from a reply. The provider's stable +identity is `pluginID:providerID`; the unqualified `providerID` on the wire is +bound to that already authenticated session. + +## Query and reply + +[`query.schema.json`](query.schema.json) defines JSON-RPC envelopes. A request +contains `operationID`, `generation`, `providerID`, `authority`, `context`, +`workspace`, and `tools`. The host round generation also applies to global-only +queries, which have no workspace context. Tools are unique and sorted by ID. `workspace: null` asks only about +global state. Otherwise `workspace.root` is the absolute native root in the +query's authority, `rootID` is the host's opaque identity, and `directory` is +the directory being resolved relative to that root (`.` for the root). A +non-null workspace requires the existing `{contextID, generation}` host context +reference. These native paths are transient machine-local bindings, not portable +project identities. The opened workspace, not its containing repository, sets +the default scope. + +The host queries the intersection of requested tool IDs and the provider's +declared `tools`. IDs with no enabled definition may still be queried for +display-only requirement diagnostics. They cannot create installations, exports, +launches or tool rows. IDs outside that intersection are discarded entirely. +A failed or empty provider reply is not proof that there is no requirement. + +A successful result echoes `operationID`, `generation` and `providerID`, then returns: + +| Field | Meaning | +| --- | --- | +| `installations` | `{tool, version, home}` for external installations; `home` is absolute, `version` is provider display data, not a manager selector interpreted by Core. | +| `statements` | `{tool, scope, source, request, installation, kind, reader}` requirements; `request` is opaque, `installation` is a listed home or `null` for unmet. | +| `watch` | Absolute native input paths to fingerprint, including absent inputs and installation roots. This does not authorize reading contents, executing or writing these paths. | + +`scope` is `project` or `global`; `source` is the absolute originating file. +`kind` is `environment` or `declaration`. Wrappers are separate `projectFile` +candidates supplied by the tool definition, never provider installations. +`reader` is `native` or `compatible`, matching that file's declared +`watchFiles[].reader`; a backend cannot claim native ownership arbitrarily. +If more than one enabled provider declares native ownership of the same source, +the host reports an unresolved conflict rather than sorting plugin IDs. + +For a project statement, match the declared watch pattern relative to each +candidate source-directory anchor, with at most 64 ancestors of the requested +native directory considered. The matching anchor determines project specificity, +not the source file's physical parent: `.mise/config.toml` at `/project` belongs +to `/project`, not `/project/.mise`. If more than one pattern/anchor matches the +same canonical source, use the nearest matching anchor and then native-reader +precedence; indistinguishable conflicting claims remain explicit conflicts. +An upstream may identify an enclosing project file above the opened workspace; +its absolute source is evidence only and never expands the workspace identity +or the editable pins scope. Global sources need not be ancestors. A global-only +query cannot return project statements. `installation: null` means the manager +knows the request but cannot supply a usable installed choice. The host does not +translate `lts`, `system`, `ref:`, `prefix:` or any other request syntax. + +## Validation and limits + +The host passes untrusted replies through Core's deterministic shape and policy +validation before publishing them. Native adapters supply canonical paths, +file identities, existence, type and executable evidence; Core must not assume a +string prefix or JSON Schema proves any of these facts. Every candidate is +revalidated before launch to close stale-cache and symlink replacement windows. +The eventual launch adapter retains its existing atomic/native identity checks. + +- A reply is at most 1 MiB of UTF-8 JSON: at most 512 installations, 256 + statements and 256 watch paths. Each native path is at most 4096 characters; + operation/tool/provider identifiers and request strings have schema bounds. + Oversized replies fail as a whole. Deduplication cannot hide a limit breach. +- Installation homes are absolute directories. Executables come only from the + enabled tool definition's `executableLayouts`; the provider cannot replace + them with arbitrary argv, executables or environment variables. The selected + executable must exist, be a regular executable in the current authority, and + remain inside the canonical home. Providers return the final tool home; + `homeLayouts` applies to manual/system discovery inputs, not a second rewrite + of a provider home. +- Neither a provider installation's canonical home nor its executable may be + inside any root of the current workspace, including a symlink into a root. + An unsafe installation rejects the provider reply with `unsafePath`; it is + not silently promoted to a project wrapper. A provider can report an unmet + requirement instead of returning a project-contained home. +- A non-null statement installation must reference a validated installation of + the same tool in this reply. Unknown or disabled definition IDs lose all + installation authority. Their statements retain only bounded diagnostic facts + (`tool`, `scope`, `source`, `request`), never a usable path. +- Apply byte/count/string bounds and structural validation before filtering. + Then discard installation entries without requested, declared and active + definition IDs **before native filesystem validation**. Only remaining + installations undergo existence/executable/containment checks. Consequently, + an unknown tool's unusable home cannot invalidate an otherwise valid reply; + malformed JSON or a bounds violation still can. +- The host's installed-plugin registry supplies any missing-definition plugin + name and Enable action. Provider data cannot invent plugin names, install + links, enable targets or arbitrary commands. Enabling requires user action. +- Paths cannot contain controls, unnormalized parent traversal or mismatched + authority. Native case and volume rules determine equality and containment; + do not lowercase all paths. Canonical duplicates are collapsed after bounds + checking. Missing watch paths are valid; missing installations are not. +- Lists are normalized deterministically: installations by tool, canonical + home, then version; statements by tool, scope, canonical source, kind, reader, + request, then installation; watch paths by canonical identity. Sorting is not + a tie-breaking rule for conflicting provider requirements. + +Project wrappers and environments are **project code**, not exceptions to the +provider boundary. A tool definition's `projectFile`/wrapper candidate may +resolve a workspace-contained program only through the consumer's existing +explicit user-action/consent flow (there is no persistent workspace trust state +in the current product). Passive import or opening settings does not +execute wrappers or probe project environments. All resulting selections carry +`projectCode: true`, and automatic background consumers must reject them unless +their existing trust boundary explicitly permits execution. Returning +`kind: environment` from a provider cannot waive this rule. + +The new capability's `projectFile.projectKind` is `wrapper` by default or +`environment` with an explicit source-directory-relative `home`. The latter's +`path` must equal that home plus an applicable declared executable layout; no +tool-name or `bin`-parent heuristic supplies a home. Specificity uses the +candidate's source-directory anchor, not the executable's parent directory. +A project-environment candidate can be declared as +`{"kind":"projectFile","projectKind":"environment","home":".venv","path":".venv/bin/python"}`; +the tool's executable layout supplies `bin/python`, not the host. +A wrapper is executable-only and returns `selection.home: null`. It emits no +`${home}` exports or PATH entries; a launch template requiring `${home}` skips +with `homeUnavailable` rather than pretending the repository is an installation. + +Project environments may contain executable symlinks (for example a venv's +interpreter link to an external interpreter). This is a distinct project-code +policy, not a provider exception: the lexical home must remain within the +workspace, the lexical program must match its declared layout, and the adapter +must validate the resolved executable and the complete link chain's identities +at the authorized launch boundary. Preserve the declared lexical launch path; +substituting the canonical target can change environment semantics. A changed +link or missing native identity check rejects the launch. No link grants +background execution authority. Without the consumer's explicit-action/consent +admission, skip with `projectCodeRequiresAction`, never `requestedNotInstalled`. + +## Query lifecycle, cache and watching + +Each query round captures authority, workspace/root/context generation, enabled +definition/provider digests, tool set, environment snapshot and a monotonic +round generation. It queries **every enabled provider** relevant to that round; +each request owns a unique operation ID and JSON-RPC ID. Replies must match +both IDs, round generation and the bound provider. A round publishes one atomic snapshot after +all requests finish, fail, or reach their deadlines. It can preserve a previous +valid snapshot while visibly loading, but cannot label old state as current. +Partial failures stay visible; absence of a reply never means an empty provider. + +The default per-query deadline is 5 seconds including backend startup; +host configuration can choose 100-30000 milliseconds. Cancellation sends +`$/cancelRequest` with the JSON-RPC ID and completes local ownership immediately. +An owned backend unresponsive to cancellation gets a bounded 2-second cleanup +grace before the existing process-tree termination path. A shared backend's +unrelated operations are not stopped to cancel one query. Session shutdown, +disable, uninstall or workspace replacement cancels all owned operations and +listeners; callbacks recheck generation before changing any state. Every +operation completes once. A reply after cancellation, timeout, generation +change or provider removal is discarded, never cached or published. + +The cache key includes the captured identities above. Its input fingerprint +combines returned `watch` paths with the manifest's `watchFiles` declarations. +The host watches parent directories and the bounded set of matching inputs so +creation, deletion, atomic replacement and rename are observable even when a +file did not exist in the previous reply. Missing/existing, type, identity, +size, modification time and canonical target are fingerprinted; native watcher +events invalidate even if a replacement happens to preserve metadata. A lost +watcher event/overflow invalidates the round rather than claiming the cache is +fresh. No file contents or manager syntax are parsed by this cache. + +Manifest patterns have at most eight components and no recursive `**`; matching +is bounded to workspace/context ancestor directories and explicitly returned +absolute watch roots. The host caps each round at 256 watch roots and 4096 +enumerated directory entries, not only successful matches; exceeding this +produces a visible limit failure, not a recursive scan +of the user's home. Multiple file events may coalesce in one scheduling turn. +**Any** matching file change invalidates the entire snapshot, cancels a pending +round, and re-queries **all** providers. There are no per-provider or per-tool +incremental updates. Providers remain responsible for returning every external +global/configuration input they read or would consult if created, including +absent inputs during a global-only query. Returning only existing files is not +sufficient to detect the first creation of a higher-priority configuration. + +Local and remote authorities never share caches, shell environment, native +paths or providers. The first implementation is local only. A remote request +fails with `authorityUnsupported` until that authority has an actual query and +filesystem adapter. It never probes the local placeholder, shell, local pins +or local installations as a fallback. Static definitions may be reused only as +inert data, not as permission to run a local scanner for a remote workspace. + +## One four-layer resolver + +[`resolution.schema.json`](resolution.schema.json) describes normalized output, +not a second resolver implementation. Tool definitions supply their ordered +`executables` candidates; the reusable executable resolver extends its existing +`skip`, `resolved`, `terminalFailure` result. `setting.layer` distinguishes +project pins from application defaults; `toolchain.scope` is `project`, +`global`, or `installed`. Existing per-owner candidate ordering and insertion +rules remain; a consumer cannot reorder another plugin's candidate set or move +a lower layer above a pin. The following layer order is invariant: + +| Layer | Selection and failure behavior | +| --- | --- | +| 1 Fixed | Nearest ancestor pin for this directory and tool; invalid/missing/unexecutable/probe-failing/incompatible nonempty pin is `terminalFailure`, never fallback. | +| 2 Project | Nearest originating directory first; within it wrapper > environment > version declaration. For the same source, native reader > compatible reader. Uninstalled requirements are `skip` with a reason, retained as unmet while lower layers continue. | +| 3 Global | Lithe's explicitly saved default > selected provider global statement > login-shell home variable/commands. Conflicting provider globals require user choice; before choosing, skip provider globals and follow login Shell, not plugin-ID order. | +| 4 Automatic | Highest compatible probed installed release from all providers plus the definition-driven system scanner; excludes manual-only registrations. | + +Project distance is compared **before** project candidate category: a child +version file beats a wrapper in a more distant parent. After native/compatible +precedence, differing equally specific project statements are an explicit +conflict requiring user choice; while unresolved they skip with a conflict +reason. Equal normalized installations are deduplicated, not conflicts. Chosen +provider identities are application-owned preferences scoped by authority, +workspace/directory and tool, never implicit ID ordering. + +Consequently, project sources must expand into one bounded project-layer plan +before its first candidate is attempted. A sequential implementation that +searches every ancestor for the wrapper candidate before considering the next +`toolchain` candidate violates this contract. The existing resolver consumes +the normalized plan; source enumeration is read-only and must not execute or +probe shadowed wrappers/environments to establish their precedence. + +The `skip.reason` object is mandatory. `requestedNotInstalled` includes `tool`, +`provider`, `source`, and opaque `request`; `definitionUnavailable` always includes +`tool` and also includes `source` and `request` when it describes an observed +statement rather than an inactive definition or dormant pin. `providerConflict` is accompanied by a +`conflicts` entry listing qualified identities. Reasons are structured facts +rendered through shared i18n, not English sentences assembled in Core. A +successful fallback retains all applicable unmet requirements and conflicts. +Empty layers remain `empty` trace rows; layers masked by success are `shadowed` +and must not be probed just to populate the settings display. Expanders show +only the effective layer by default; Show All exposes the trace without +changing execution behavior. No selection means `skip`, not invented success. + +The version probe's declared regular expression supplies a normalized numeric +dotted release in its first capture (at most eight unsigned 32-bit components, +64 characters). Compare components numerically with absent trailing components +as zero; there is no Java-8, `v` prefix, vendor, alias or pre-release special +case. No valid comparable capture means not eligible for automatic selection; +an explicit choice still cannot claim to satisfy a consumer's declared minimum +without a successful probe. The provider's `version` is preserved as display +provenance, never trusted instead of the definition's probe. A wrapper may have +`selection.version: null` until the existing user-authorized execution flow can +probe it; consumers requiring a version cannot treat `null` as compatible. +If the newest normalized release has different canonical homes, automatic +resolution reports `ambiguousInstallation`, an `automatic` conflict listing +the distinct canonical homes, and a `chooseInstallation` action; it requires an +explicit choice; sorting homes or plugin IDs must not silently decide which binary runs. +Multiple reports of the same canonical home are one installation. + +Environment exports and PATH prepend entries are generated solely by enabled +definitions using the selected validated home/executable. A failed candidate's +environment is discarded before trying the next. Consumer `requiresTools`, +`launch.tool` and `onToolchainChange` bind directory-scoped usage and explain +whether a switch needs restart, resync or applies next run. Disabling a +definition withdraws its rows, consumers, exports and injected settings page; +it does not delete saved pins. Existing processes do not silently restart. +An executable-only wrapper can export `${executable}`, but cannot export or +prepend paths based on an absent home. Its consumer still owns wrapper-specific +launch arguments; it cannot silently reuse a runtime entry template requiring +installation facts that the wrapper does not provide. + +## Local pins and manual installations + +[`local.schema.json`](local.schema.json) is an isolated proposed extension of +the machine-local `.lithe/toolchains/local.json`, retaining `version: 1` and +legacy `toolchains` while adding `pins`. It does not modify the active v1 +schema or promise that an old host accepts new fields. The implementation phase +must gate writers on the new capability and migrate the corresponding readers +together; it must not write this draft shape for an old reader. + +`pins` maps normalized workspace-relative directory keys to tool-ID maps. +`.` is the opened root; absolute keys, backslashes, empty segments, `.`, `..` +segments and trailing separators are rejected. Dotted directory names are valid. +For `apps/web/src`, check that directory, `apps/web`, `apps`, then `.`, never +`apps/website` by string prefix. Each pin stores `{home, version, provider}`; +provider is a qualified identity or the reserved generic `host:manual` / +`host:system`, not a backend activation instruction. An inherited pin applies +until a nearer explicit value replaces it. Removing an override reveals the +ancestor; it does not delete that ancestor or the whole local file. + +Opening a project or settings only reads. An edit is a draft until explicit +Save/Apply confirms the target directory, tool and machine-local path. The +platform adapter checks the read revision and rejects stale writes, preserves +unrelated legacy entries/pins and permissions, atomically replaces the file, +then reads it again to display the effective choice. No write on focus loss, +navigation, startup, cancellation or unchanged inherited values. Read failures +are errors, not empty defaults. Reject symlinked local targets and any write +escaping the selected workspace's machine-local store. + +The first explicit save adds `/toolchains/local.json` exactly once to +`.lithe/.gitignore`, preserving other content and using conflict protection. +There is no transaction spanning two files: secure the ignore entry before +publishing the new local file; an ignore-write failure aborts the save. If local +file publication fails afterward, the harmless ignore entry may remain and the +draft remains unsaved. Neither target is created merely by opening a project. + +Manual Add validates a user-selected directory via `homeLayouts`, +`executableLayouts`, and the declared bounded version probe. It registers the +installation only in platform application storage, outside app bundles, +installed plugin packages and the project. Such registrations can be explicitly +pinned but do not participate in automatic discovery. A workspace-contained +directory is not converted into a trusted external installation by manual Add; +project wrappers/environments retain their separate project-code boundary. +Pins survive plugin disable/uninstall and reactivate only when that definition +is enabled again. No tool is downloaded, installed, trusted or repaired by query. + +## Stable failure mapping + +These are proposed additions, not assignments in the current runtime. Existing +host-service codes `-32010` through `-32021` keep their meanings. Toolchain +custom failures reserve `-32070` through `-32077` in the server-error range; +each numeric code has exactly one semantic code. JSON-RPC error `data` includes +`code`, `operationID`, and `providerID`; optional details are bounded to 4 KiB +serialized and contain no credentials. Native errors are translated to shared +i18n at the host boundary; plugin diagnostic text remains unmodified but is +not a policy selector. Timeout is not cancellation, and stale replies do not +generate a second completion. + +| JSON-RPC | Semantic code | Meaning | +| --- | --- | --- | +| `-32601` | `plugin.toolchain.methodUnavailable` | Query method/capability not negotiated. | +| `-32602` | `plugin.toolchain.invalidParams` | Malformed request or invalid declared input. | +| `-32800` | `plugin.toolchain.cancelled` | Caller cancelled the owned request. | +| `-32070` | `plugin.toolchain.permissionDenied` | Permission/review/enabled-state prerequisite absent. | +| `-32071` | `plugin.toolchain.authorityUnsupported` | No adapter for this authority. | +| `-32072` | `plugin.toolchain.timeout` | Monotonic local deadline exceeded. | +| `-32073` | `plugin.toolchain.providerUnavailable` | Backend failed to start, disconnected, or failed its upstream query. | +| `-32074` | `plugin.toolchain.invalidReply` | Malformed reply or inconsistent same-tool installation reference. | +| `-32075` | `plugin.toolchain.limitExceeded` | Reply/scan/watch budget exceeded. | +| `-32076` | `plugin.toolchain.unsafePath` | Invalid absolute path, containment or executable safety evidence. | +| `-32077` | `plugin.toolchain.operationExpired` | Context/generation was already invalid when a query was dispatched. | + +Request-local failures (missing installation, incompatible version, conflicting +requirements) are resolver reasons, not JSON-RPC transport errors. The query +fixture includes the numeric mapping; runtime tests, SDK end-to-end tests and +both native host lifecycle tests are required in later implementation phases. diff --git a/shared/contracts/plugins/v1/proposals/toolchains/query.schema.json b/shared/contracts/plugins/v1/proposals/toolchains/query.schema.json new file mode 100644 index 0000000000..4f1fa55cd1 --- /dev/null +++ b/shared/contracts/plugins/v1/proposals/toolchains/query.schema.json @@ -0,0 +1,615 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/Lithe-IDEA/lithe-plugin-docs/main/contract/v1/proposals/toolchains/query.schema.json", + "title": "Proposed toolchain query protocol v1 (not implemented)", + "description": "Review-only JSON-RPC request, successful response or error response. Semantic validation and authority checks are specified in protocol.md.", + "oneOf": [ + { + "$ref": "#/$defs/request" + }, + { + "$ref": "#/$defs/response" + }, + { + "$ref": "#/$defs/errorResponse" + } + ], + "$defs": { + "identifier": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$" + }, + "toolID": { + "$ref": "manifest-contributions.schema.json#/$defs/toolID" + }, + "providerID": { + "$ref": "manifest-contributions.schema.json#/$defs/identifier" + }, + "nativePath": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "pattern": "^(?:/|[A-Za-z]:[\\\\/]|\\\\\\\\)", + "not": { + "pattern": "[\\u0000-\\u001f\\u007f]" + }, + "description": "Absolute native path in this query's authority; canonical containment requires native filesystem evidence, not this lexical pattern." + }, + "directory": { + "type": "string", + "maxLength": 2048, + "pattern": "^(?:\\.|(?!\\.\\.?(?:/|$))[^/\\\\:]+(?:/(?!\\.\\.?(?:/|$))[^/\\\\:]+)*)$", + "not": { + "pattern": "[\\u0000-\\u001f\\u007f]" + }, + "description": "Workspace-relative directory using /; . means the opened root. Each other component must not be . or ..; control characters are forbidden semantically." + }, + "authority": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "kind" + ], + "properties": { + "kind": { + "const": "local" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "id" + ], + "properties": { + "kind": { + "const": "remote" + }, + "id": { + "$ref": "#/$defs/identifier" + } + } + } + ] + }, + "context": { + "type": "object", + "additionalProperties": false, + "required": [ + "contextID", + "generation" + ], + "properties": { + "contextID": { + "$ref": "#/$defs/identifier" + }, + "generation": { + "type": "integer", + "minimum": 1 + } + } + }, + "workspace": { + "type": "object", + "additionalProperties": false, + "required": [ + "rootID", + "root", + "directory" + ], + "properties": { + "rootID": { + "$ref": "#/$defs/identifier" + }, + "root": { + "$ref": "#/$defs/nativePath" + }, + "directory": { + "$ref": "#/$defs/directory" + } + } + }, + "params": { + "type": "object", + "additionalProperties": false, + "required": [ + "operationID", + "generation", + "providerID", + "authority", + "context", + "workspace", + "tools" + ], + "properties": { + "operationID": { + "$ref": "#/$defs/identifier" + }, + "providerID": { + "$ref": "#/$defs/providerID" + }, + "authority": { + "$ref": "#/$defs/authority" + }, + "context": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/context" + } + ] + }, + "workspace": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/workspace" + } + ] + }, + "tools": { + "type": "array", + "minItems": 1, + "maxItems": 64, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/toolID" + } + }, + "generation": { + "type": "integer", + "minimum": 1, + "description": "Host query-round generation, including workspace:null queries; echoed unchanged." + } + }, + "allOf": [ + { + "if": { + "properties": { + "workspace": { + "type": "object" + } + } + }, + "then": { + "properties": { + "context": { + "$ref": "#/$defs/context" + } + } + } + } + ] + }, + "installation": { + "type": "object", + "additionalProperties": false, + "required": [ + "tool", + "version", + "home" + ], + "properties": { + "tool": { + "$ref": "#/$defs/toolID" + }, + "version": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "not": { + "pattern": "[\\u0000-\\u001f\\u007f]" + } + }, + "home": { + "$ref": "#/$defs/nativePath" + } + } + }, + "statement": { + "type": "object", + "additionalProperties": false, + "required": [ + "tool", + "scope", + "source", + "request", + "installation", + "kind", + "reader" + ], + "properties": { + "tool": { + "$ref": "#/$defs/toolID" + }, + "scope": { + "enum": [ + "project", + "global" + ] + }, + "source": { + "$ref": "#/$defs/nativePath" + }, + "request": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "not": { + "pattern": "[\\u0000-\\u001f\\u007f]" + } + }, + "installation": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/nativePath" + } + ] + }, + "kind": { + "enum": [ + "environment", + "declaration" + ] + }, + "reader": { + "enum": [ + "native", + "compatible" + ] + } + } + }, + "result": { + "type": "object", + "additionalProperties": false, + "required": [ + "operationID", + "generation", + "providerID", + "installations", + "statements", + "watch" + ], + "properties": { + "operationID": { + "$ref": "#/$defs/identifier" + }, + "providerID": { + "$ref": "#/$defs/providerID" + }, + "installations": { + "type": "array", + "maxItems": 512, + "items": { + "$ref": "#/$defs/installation" + } + }, + "statements": { + "type": "array", + "maxItems": 256, + "items": { + "$ref": "#/$defs/statement" + } + }, + "watch": { + "type": "array", + "maxItems": 256, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/nativePath" + } + }, + "generation": { + "type": "integer", + "minimum": 1, + "description": "Host query-round generation, including workspace:null queries; echoed unchanged." + } + } + }, + "request": { + "type": "object", + "additionalProperties": false, + "required": [ + "jsonrpc", + "id", + "method", + "params" + ], + "properties": { + "jsonrpc": { + "const": "2.0" + }, + "id": { + "type": "integer", + "minimum": 1 + }, + "method": { + "const": "toolchains/query" + }, + "params": { + "$ref": "#/$defs/params" + } + } + }, + "response": { + "type": "object", + "additionalProperties": false, + "required": [ + "jsonrpc", + "id", + "result" + ], + "properties": { + "jsonrpc": { + "const": "2.0" + }, + "id": { + "type": "integer", + "minimum": 1 + }, + "result": { + "$ref": "#/$defs/result" + } + } + }, + "errorResponse": { + "type": "object", + "additionalProperties": false, + "required": [ + "jsonrpc", + "id", + "error" + ], + "properties": { + "jsonrpc": { + "const": "2.0" + }, + "id": { + "type": "integer", + "minimum": 1 + }, + "error": { + "type": "object", + "additionalProperties": false, + "required": [ + "code", + "message", + "data" + ], + "properties": { + "code": { + "enum": [ + -32601, + -32602, + -32800, + -32070, + -32071, + -32072, + -32073, + -32074, + -32075, + -32076, + -32077 + ] + }, + "message": { + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "data": { + "type": "object", + "additionalProperties": false, + "required": [ + "code", + "operationID", + "providerID" + ], + "properties": { + "code": { + "enum": [ + "plugin.toolchain.methodUnavailable", + "plugin.toolchain.invalidParams", + "plugin.toolchain.cancelled", + "plugin.toolchain.permissionDenied", + "plugin.toolchain.authorityUnsupported", + "plugin.toolchain.timeout", + "plugin.toolchain.providerUnavailable", + "plugin.toolchain.invalidReply", + "plugin.toolchain.limitExceeded", + "plugin.toolchain.unsafePath", + "plugin.toolchain.operationExpired" + ] + }, + "operationID": { + "$ref": "#/$defs/identifier" + }, + "providerID": { + "$ref": "#/$defs/providerID" + }, + "details": { + "type": "object", + "maxProperties": 8 + } + } + } + }, + "allOf": [ + { + "oneOf": [ + { + "properties": { + "code": { + "const": -32601 + }, + "data": { + "properties": { + "code": { + "const": "plugin.toolchain.methodUnavailable" + } + } + } + } + }, + { + "properties": { + "code": { + "const": -32602 + }, + "data": { + "properties": { + "code": { + "const": "plugin.toolchain.invalidParams" + } + } + } + } + }, + { + "properties": { + "code": { + "const": -32800 + }, + "data": { + "properties": { + "code": { + "const": "plugin.toolchain.cancelled" + } + } + } + } + }, + { + "properties": { + "code": { + "const": -32070 + }, + "data": { + "properties": { + "code": { + "const": "plugin.toolchain.permissionDenied" + } + } + } + } + }, + { + "properties": { + "code": { + "const": -32071 + }, + "data": { + "properties": { + "code": { + "const": "plugin.toolchain.authorityUnsupported" + } + } + } + } + }, + { + "properties": { + "code": { + "const": -32072 + }, + "data": { + "properties": { + "code": { + "const": "plugin.toolchain.timeout" + } + } + } + } + }, + { + "properties": { + "code": { + "const": -32073 + }, + "data": { + "properties": { + "code": { + "const": "plugin.toolchain.providerUnavailable" + } + } + } + } + }, + { + "properties": { + "code": { + "const": -32074 + }, + "data": { + "properties": { + "code": { + "const": "plugin.toolchain.invalidReply" + } + } + } + } + }, + { + "properties": { + "code": { + "const": -32075 + }, + "data": { + "properties": { + "code": { + "const": "plugin.toolchain.limitExceeded" + } + } + } + } + }, + { + "properties": { + "code": { + "const": -32076 + }, + "data": { + "properties": { + "code": { + "const": "plugin.toolchain.unsafePath" + } + } + } + } + }, + { + "properties": { + "code": { + "const": -32077 + }, + "data": { + "properties": { + "code": { + "const": "plugin.toolchain.operationExpired" + } + } + } + } + } + ] + } + ] + } + } + } + } +} diff --git a/shared/contracts/plugins/v1/proposals/toolchains/resolution.schema.json b/shared/contracts/plugins/v1/proposals/toolchains/resolution.schema.json new file mode 100644 index 0000000000..623c67c623 --- /dev/null +++ b/shared/contracts/plugins/v1/proposals/toolchains/resolution.schema.json @@ -0,0 +1,362 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/Lithe-IDEA/lithe-plugin-docs/main/contract/v1/proposals/toolchains/resolution.schema.json", + "title": "Proposed shared toolchain resolution result v1 (not implemented)", + "description": "Review-only normalized resolver output. Host-owned snapshots and native filesystem evidence are inputs; providers never produce this object directly.", + "type": "object", + "additionalProperties": false, + "required": [ + "operationID", + "tool", + "directory", + "outcome", + "trace", + "unmet", + "conflicts" + ], + "properties": { + "operationID": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "tool": { + "$ref": "#/$defs/toolID" + }, + "directory": { + "$ref": "#/$defs/directory" + }, + "outcome": { + "$ref": "#/$defs/outcome" + }, + "trace": { + "type": "array", + "maxItems": 256, + "items": { + "$ref": "#/$defs/trace" + } + }, + "unmet": { + "type": "array", + "maxItems": 256, + "items": { + "$ref": "#/$defs/reason" + } + }, + "conflicts": { + "type": "array", + "maxItems": 64, + "items": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "scope", + "providers", + "action" + ], + "properties": { + "scope": { + "enum": [ + "project", + "global" + ] + }, + "providers": { + "type": "array", + "minItems": 2, + "maxItems": 32, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "action": { + "const": "chooseProvider" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "scope", + "providers", + "installations", + "action" + ], + "properties": { + "scope": { + "const": "automatic" + }, + "providers": { + "type": "array", + "minItems": 1, + "maxItems": 32, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "installations": { + "type": "array", + "minItems": 2, + "maxItems": 512, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/nativePath" + } + }, + "action": { + "const": "chooseInstallation" + } + } + } + ] + } + } + }, + "$defs": { + "directory": { + "type": "string", + "maxLength": 2048, + "pattern": "^(?:\\.|(?!\\.\\.?(?:/|$))[^/\\\\:]+(?:/(?!\\.\\.?(?:/|$))[^/\\\\:]+)*)$", + "not": { + "pattern": "[\\u0000-\\u001f\\u007f]" + } + }, + "toolID": { + "$ref": "manifest-contributions.schema.json#/$defs/toolID" + }, + "layer": { + "enum": [ + "pinned", + "project", + "global", + "automatic" + ] + }, + "reason": { + "type": "object", + "additionalProperties": false, + "required": [ + "code" + ], + "properties": { + "code": { + "enum": [ + "notConfigured", + "notFound", + "notExecutable", + "probeFailed", + "versionUnsatisfied", + "requestedNotInstalled", + "definitionUnavailable", + "providerConflict", + "unsafePath", + "providerUnavailable", + "ambiguousInstallation", + "homeUnavailable", + "projectCodeRequiresAction" + ] + }, + "tool": { + "$ref": "#/$defs/toolID" + }, + "provider": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "source": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "request": { + "type": "string", + "minLength": 1, + "maxLength": 512 + } + }, + "description": "Structured facts, not assembled English. The host renders its shared i18n message with these arguments. Required facts per code are specified in protocol.md." + }, + "selection": { + "type": "object", + "additionalProperties": false, + "required": [ + "home", + "executable", + "version", + "provider", + "layer", + "projectCode", + "environment", + "pathPrepend" + ], + "properties": { + "home": { + "oneOf": [ + { "type": "null" }, + { "$ref": "#/$defs/nativePath" } + ] + }, + "executable": { + "$ref": "#/$defs/nativePath" + }, + "version": { + "type": [ + "string", + "null" + ], + "minLength": 1, + "maxLength": 128 + }, + "provider": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "layer": { + "$ref": "#/$defs/layer" + }, + "projectCode": { + "type": "boolean" + }, + "environment": { + "type": "object", + "maxProperties": 32, + "additionalProperties": { + "type": "string", + "maxLength": 4096 + }, + "propertyNames": { + "$ref": "manifest-contributions.schema.json#/$defs/safeEnvironmentName" + } + }, + "pathPrepend": { + "type": "array", + "maxItems": 16, + "items": { + "$ref": "#/$defs/nativePath" + } + } + } + }, + "outcome": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "selection" + ], + "properties": { + "kind": { + "const": "resolved" + }, + "selection": { + "$ref": "#/$defs/selection" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "reason" + ], + "properties": { + "kind": { + "const": "skip" + }, + "reason": { + "$ref": "#/$defs/reason" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "reason" + ], + "properties": { + "kind": { + "const": "terminalFailure" + }, + "reason": { + "$ref": "#/$defs/reason" + } + } + } + ] + }, + "trace": { + "type": "object", + "additionalProperties": false, + "required": [ + "candidateID", + "layer", + "state" + ], + "properties": { + "candidateID": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "layer": { + "$ref": "#/$defs/layer" + }, + "state": { + "enum": [ + "skip", + "resolved", + "terminalFailure", + "shadowed", + "empty" + ] + }, + "reason": { + "$ref": "#/$defs/reason" + }, + "source": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + } + }, + "allOf": [ + { + "if": { + "properties": { + "state": { + "enum": [ + "skip", + "terminalFailure" + ] + } + } + }, + "then": { + "required": [ + "reason" + ] + } + } + ] + }, + "nativePath": { + "$ref": "query.schema.json#/$defs/nativePath" + } + } +} diff --git a/shared/contracts/plugins/v1/toolchains.md b/shared/contracts/plugins/v1/toolchains.md new file mode 100644 index 0000000000..af255a173d --- /dev/null +++ b/shared/contracts/plugins/v1/toolchains.md @@ -0,0 +1,264 @@ +# Plugin-owned toolchains: Core foundation + +**Status: shared Core implemented; the macOS settings pages run both routes +read-only.** Core validates data and resolves captured facts; it does not launch +providers, probe programs, parse manager files, watch paths, write pins, or +download tools. The macOS settings host expands `discoveryPlan` scans, starts or +reuses each enabled provider's plugin backend, sends `toolchains/query` through +`lsp.toolchainRequest`, passes every raw reply with native facts to `decodeReply`, +runs the definition's version probe on executables Core returns from +`candidates`, and shows the result. Any change to a watched input reloads the +whole snapshot. Nothing is saved, and no build, run or language-server launch +uses the result yet. Windows is not connected. +The [broader proposal](proposals/toolchains/README.md) still describes unsupported +consumer migration, native lifecycle, persistence and manager-plugin work. +The active shape is [plugin-manifest.schema.json](plugin-manifest.schema.json), +not the proposal envelopes. + +## Manifest admission and registry + +`toolDefinitions[]` needs `requires.toolDefinitions: 1` and +`requires.executableResolution: 2`. `toolchainProviders[]` needs +`requires.toolchainProviders: 1`, `backend: 2`, `serverProcess: 1`, and permissions +`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. + +Definitions own IDs, labels/icons, `executableLayouts`, ordered `executables`, +`version`, `homeLayouts`, `discovery`, `homeVariable`, `commands`, `wrappers`, +`exports`, `pathPrepend` and argv `launch`. Counts, relative paths, bounded globs, +platforms, templates and version expressions are validated recursively; unknown +fields fail closed. Candidate graph ordering reuses the existing shared graph +validator and cannot cross layer barriers. This slice does not support a +definition candidate of `kind: runtime`, or `toolchainConsumers`. + +Provider entries contain `{id, displayName, tools, watchFiles?}`; each watch entry +is `{pattern, reader: "native" | "compatible"}`. A present `watchFiles` lists 1-32 +unique patterns; an absent one makes an installation-only provider, whose +replies may not contain statements. Providers share the plugin's +backend; admission permits a host-query trigger without a page or startup event, +and the macOS host starts the backend on the first query. Bootstrap must be `packaged`, or +`runtime` with `packagedRuntime`, without discovered runtime dependencies. Existing +runtime launch tool restrictions remain; generic consumer launches are not added. + +`toolchains.provide` may influence which program a defined tool launches. It does +not grant arbitrary command replacement, installation, network or workspace-code +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. + +## Two installation routes + +A definition receives installations through either route, or both. Neither is +required: a tool may rely only on pins, project files, global defaults and +login-shell commands. + +| Route | Declared by | Fits | Runs | +| --- | --- | --- | --- | +| Declarative discovery | The definition's `discovery`, `homeLayouts`, `homeVariable`, `commands` and `version` | Installations with a fixed directory shape: a single-component glob under the user home, an environment variable or an absolute system directory, an optional home suffix, and a version expression over a probe | The host expands `discoveryPlan` scans; no plugin code runs | +| Provider query | `toolchainProviders[]` answering `toolchains/query` | Locations those primitives cannot express: registries, property lists, manager databases, command output, manager-specific version files | The provider's packaged backend | + +The declarative primitives are frozen: root kinds stay `home`, `environment` +and `absolute`, and patterns stay single-component `*` globs. A new installation +shape is answered by a provider, not by a new root kind or a host parser. The +plugin that defines a tool may also declare a provider for it; omit +`watchFiles` when it only reports installations. A provider backend never runs +on a tool it discovers: the bootstrap rules above apply to a defining plugin's own +provider as well. + +`discoveryPlan` lists both routes per tool. The host expands `scans` into +`systemInstallations`, recorded with provenance `host:system`, and queries every +qualified provider in `providers`. In the automatic layer both routes form one +candidate set. One canonical home counts once: a provider answer labels it +before the host scan, and among providers the smaller qualified ID labels it. +This label is display provenance and never chooses between different homes; +equal highest versions in different homes remain an `automatic` conflict that +names every route. + +## Commands and envelopes + +All commands accept UTF-8 JSON values and have a 4 MiB input budget. Success is +`{status:"ok", :value}`; refusal is `{status:"rejected",error:{code,message, +field?}}`. Errors are structured policy outcomes, not host-localized UI text. +Future hosts render stable codes through shared i18n; English diagnostic messages +are not a substitute for that adapter. These commands perform no native I/O. + +| Command | Input | Success key | +| --- | --- | --- | +| `toolchains.registry` | `{plugins:[{manifest,verifiedOfficial}]}` | `registry` | +| `toolchains.decodeReply` | Query/response and native evidence described below | `snapshot` | +| `toolchains.failureReply` | `{params,code}` described below | `reply` | +| `toolchains.resolvePins` | `{document,directory,tool}` | `resolution` | +| `toolchains.resolve` | Captured resolver input described below | `resolution` | +| `toolchains.checkVersion` | `{registry,tool,output,minimumVersion?}` | `version` | +| `toolchains.discoveryPlan` | `{registry,platform}` | `plan` | +| `toolchains.candidates` | Captured discovery input described below | `candidates` | + +Every `registry` input is the raw `{plugins:[...]}` request, not a previously +normalized result. Core revalidates complete production manifests and the +host-supplied `verifiedOfficial` boolean; a plugin cannot assert that evidence +inside its own manifest. Output is `{definitions:[{pluginID,definition}], +providers:[{pluginID,provider}]}` in stable order. Duplicate plugin/tool ownership +rejects the batch without replacing earlier owners. Limits are 128 plugins, +512 aggregate definitions and 256 aggregate providers. + +## Provider reply validation + +`decodeReply` takes `{registry,pluginID,request,response,currentGeneration, +filesystem,platform,filesystemCaseSensitive?,workspaceRoots?}`. `pluginID` binds +the host-owned session, not a provider claim. `platform` is `macos`, `windows` or +`linux`; native filesystem case policy is explicit where platform defaults differ. + +`request` is `{jsonrpc:"2.0",id,method:"toolchains/query",params:{operationID, +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. +`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. + +Successful `response` echoes JSON-RPC `id` and has `result:{operationID,generation, +providerID,installations,statements,watch}`. Installations are `{tool,version,home}`. +Statements are `{tool,scope,source,request,installation,kind,reader}`: scope is +`project|global`, kind is `environment|declaration`, reader matches the provider's +declared watch pattern; `installation:null` records an unmet requirement. Request +syntax is opaque. Non-null installations must reference the same tool in the +reply. `watch` contains absolute native inputs, including missing paths. + +Each native filesystem fact is `{path,canonicalPath,kind,executable?}` with kind +`file|directory|missing`. These facts come from the authority-local host, never +the provider. Core checks unique paths, actual-kind evidence, definition-owned +executable layouts, canonical home containment and exclusion of all workspace +roots. Provider homes are final tool homes; `homeLayouts` is not reapplied. +A non-missing fact also attests its canonical target with the same kind and mode, +so canonical results found through a link are checked against the same snapshot; +facts that disagree about one canonical path reject it. +Inactive/unknown tools lose installation authority; bounded requirement facts +remain diagnostic only. Lists normalize deterministically; sort order never +chooses between conflicting requirements. A reply is bounded to 1 MiB, 512 +installations, 256 statements and 256 watch paths; mismatched IDs/generations, +remote authority, unsafe paths and malformed error mappings reject it. + +The snapshot contains `{installations,statements,watch,diagnostics}`; normalized +entries carry qualified `:` provenance and project source +directory. JSON-RPC errors are checked against the distinct semantic/numeric map +in the [protocol proposal](proposals/toolchains/protocol.md#stable-failure-mapping). +Cancellation, deadlines, cache invalidation and whole-generation requery remain +native-client implementation work, not behavior performed by this decoder. + +When a query gets no reply at all, the host asks `failureReply` for one. `params` +is the query's own `params` and `code` is `plugin.toolchain.timeout` (the local +deadline passed) or `plugin.toolchain.providerUnavailable` (the backend failed to +start, disconnected or did not offer the provider). The result is +`{id,response}`, the same shape `lsp.toolchainRequest` completes with, holding the +mapped JSON-RPC error for that operation and provider. The host decodes it with +the same request like any provider error. Cancellation and every other code are +rejected, so a cancelled or refused query never becomes a missing provider. + +## Pins and snapshot resolution + +`resolvePins` validates an inert local document `{version:1,toolchains,pins?}`. +`toolchains` retains legacy entries; `pins` maps directory keys to tool maps of +`{home,version,provider}`. It returns `{pin,sourceDirectory}`, both `null` if absent. +Nearest exact ancestor wins; sibling prefixes do not match. Dormant pins survive +missing definitions. No lookup grants execution authority or writes a file. +Saving, conflict protection and `.lithe/.gitignore` updates are not implemented. + +`resolve` requires `{registry,tool,directory,operationID,generation,context, +platform,workspaceRoot,filesystem}` and accepts `workspaceRoots`, +`filesystemCaseSensitive`, `local`, `minimumVersion`, `probes`, `queries`, +`systemInstallations`, `globalDefault`, `environment`, `commandHomes`, +`projectCodeAllowed` and `providerChoices`. `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 +adapter, not command-name guessing. An environment candidate selects only through +its declared `path` layout. + +Each query is a complete `decodeReply` input, bound to the resolver's registry, +generation/context, root/directory, case policy, roots and filesystem snapshot. +The resolver accepts local workspace queries here; global-only decoder inputs +are not a substitute for the workspace-bound query round. `providerChoices` +records explicit project/global provider or automatic-installation choices; it +does not activate providers or bypass their evidence checks. +Its shape is `{project?:"pluginID:providerID",global?:"pluginID:providerID", +automatic?:"absolute-installation-home"}`; choices not present in the current +validated candidate set confer no authority. +Unavailable/timed-out optional providers remain visible as `providerUnavailable` +diagnostics while lower sources may resolve. Each is `{code,tool,provider,cause}`, +recorded only for a query that asked about this tool; `provider` is qualified and +`cause` is the decoded error code, keeping a missed deadline apart from a backend +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. + +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; +highest compatible installed release. Nearest project requirements mask parent +requirements. Invalid fixed values are terminal, even if a definition omits a pin +candidate. Uninstalled requirements retain reasons while resolution falls back. +Conflicts require explicit choices, never plugin-ID or home-path precedence. +Manual pin-only installations are not automatic candidates. + +Outcome is `resolved` with `{home,executable,version,provider,layer,projectCode, +environment,pathPrepend}`, `skip` with a reason, or `terminalFailure` with a reason. +Output also carries operation/tool/directory, `trace`, `unmet` and `conflicts`. +Wrapper homes may be `null`; home exports/PATH are then omitted. Project code +requires an explicit consumer authorization snapshot and is never passively +probed. This foundation rejects project programs or environment homes whose +canonical identity differs from their declared lexical identity; +complete native link-chain validation, including common venv interpreter links, +is deferred rather than silently treated as safe. Each candidate builds a fresh +environment, so rejected exports cannot leak into its successor. + +`checkVersion` returns `{satisfied,version}` from the definition's first regex +capture. Numeric dotted components (at most eight unsigned 32-bit parts, 64 bytes) +compare with trailing zeros ignored; aliases and tool-specific version syntax are +not interpreted. No match gives `version:null,satisfied:false`. `discoveryPlan` +returns `{tools,limits}`. Each tool carries platform-filtered `scans`, the sorted +qualified `providers` that declare it, and the definition's `homeLayouts`, +`executableLayouts`, `version` probe, `pathPrepend` templates and `environment` +login-shell home variables (`{variable,path}` from its platform-applicable +`environment` candidates). It neither scans, queries nor executes them; limits +are 256 roots, 4096 entries and 64 ancestors for the host adapter. + +## Settings candidates + +`candidates` reports what a settings page shows for one tool's global and +automatic layers without resolving a directory. It requires `{registry,tool, +platform,workspaceRoot,filesystem}` and accepts `workspaceRoots`, +`filesystemCaseSensitive`, `minimumVersion`, `probes`, `systemInstallations`, +`providerInstallations` and `environment`, with the same meaning and checks as in +`resolve`. `providerInstallations` (at most 512) are `{tool,home,provider}` rows a +host copies from `decodeReply` snapshots; `provider` must name a registry provider +`pluginID:providerID` declaring the tool, and the row keeps that attribution when +a scan reports the same canonical home. The output is +`{tool,shell,installations,skipped,selected,conflicts}`: + +- `shell` lists each declared home variable present in `environment` as + `{candidateID,variable,home,executable?,version?|reason?}`, checked through + the candidate's own `path` layout. +- `installations` are eligible host-scanned homes `{home,executable,version, + provider}`, highest version first, with the canonical home Core checked. +- `skipped` lists homes that failed a check as `{home,provider,reason, + executable?}`; reasons are stable codes such as `unsafePath`, `probeFailed` + and `versionUnsatisfied`. +- `selected` is the automatic-layer home, or `null`; equal highest versions + in different homes leave it `null` and add an `automatic` entry to `conflicts`. + +A first call without `probes` returns every checked executable as `probeFailed`. +The host runs only those executables with the definition's probe arguments, +then calls again with their `{executable,output,exitCode}`. Homes inside a +workspace root are refused before any probe, so a project program is never run +to read its version. The macOS host bounds probes by count, concurrency and +total time, and reports skipped work as diagnostics rather than a missing tool. + +Production-shape examples are [tool-definition.plugin.json](fixtures/tool-definition.plugin.json) +and [toolchain-provider.plugin.json](fixtures/toolchain-provider.plugin.json). +Core acceptance vectors are in +[toolchain_definitions.rs](https://github.com/1lck/Lithe-IDEA/blob/preview/rust/lithe-core/src/tests/toolchain_definitions.rs), +with sibling pins/query/resolution suites. Passing them is not native product +verification. Contract changes must be mirrored after merge with +`node scripts/contract.mjs sync --revision ` in +[lithe-plugin-docs](https://github.com/Lithe-IDEA/lithe-plugin-docs). diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index cebf85aaf5..74521200db 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -1064,6 +1064,7 @@ package manager owns the download and Lithe does not infer bytes from logs. | `lsp.functionRequest` | Invoke one registered plugin function on a session its plugin started | | `lsp.hostResponse` | Answer one host-service request a plugin backend sent | | `lsp.hostNotify` | Send one host notification to a plugin backend that negotiated it | +| `lsp.toolchainRequest` | Send one `toolchains/query` to a plugin backend that negotiated the provider | | `lsp.vcsRequest` | Send one VCS operation, planned by Core, to a session that negotiated the VCS-provider protocol | | `java.prepareLaunch` | Prepare a saved Java source through shared entry selection, serialized build and runtime path resolution | | `java.navigationMarkers` | Resolve versioned Java gutter markers from bounded JDT LS semantic requests | @@ -1153,6 +1154,14 @@ package manager owns the download and Lithe does not infer bytes from logs. | `plugins.declarative.resolveSettings` | Merge a plugin's stored global and project setting layers into effective values with their source | | `plugins.declarative.workspaceConfiguration` | Expand a language server's `workspaceConfiguration` with the merged setting values | | `plugins.declarative.checkRuntimeVersion` | Decide whether a launch runtime's version output meets the declared minimum version | +| `toolchains.registry` | Revalidate complete enabled plugin manifests and host-verified official origin into a deterministic tool-definition/provider registry; no native host capability claim | +| `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.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 | +| `toolchains.candidates` | List one tool's login-shell homes and host-scanned installations with checked executables, versions or skip reasons, and the automatic-layer choice; never starts a probe | | `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 | @@ -2914,6 +2923,20 @@ are validated as `{exitCode: Int32, output: string}` and progress is mapped from a Core-owned wire request ID to `requestProgress` for the still-pending operation. See the [provider execution contract](plugins/v1/README.md#provider-task-execution-buildproviders-2). +`lsp.toolchainRequest` accepts `{ sessionId, operationId?, params, +timeoutMilliseconds? }` and returns `{ operationId }`. `lsp.startServer` takes +`toolchainProviders`, the manifest's provider IDs; only a plugin-owned session +with some offers `capabilities.experimental.litheToolchains: { versions: [1], +providers }`. A backend that answers `{ version: 1, providers }` with a non-empty, +duplicate-free subset of the offer emits a `toolchainProviders` runtime event +with `result: { providers }`; any other answer enables none. A request whose +`params.providerID` was not negotiated is `not_supported` with details +`plugin.toolchain.providerUnavailable`, and nothing is written. The +`requestCompleted` event carries `result.value: { id, response }`: the JSON-RPC +ID and the complete, unvalidated response, error replies included, which the host +passes to `toolchains.decodeReply` with the same `params`. The timeout is capped +at 60 seconds; cancellation and late results follow `lsp.buildRequest`. + `lsp.vcsRequest` accepts `{ sessionId, operationId?, vcs, operation, root, tool?, arguments?, timeoutMilliseconds? }` and returns `{ operationId }`. `operation` is a [VCS provider contract](vcs-provider-v1.md#lspvcsrequest) diff --git a/shared/i18n/locales/en-US.json b/shared/i18n/locales/en-US.json index 717b7667df..cf61c8c65a 100644 --- a/shared/i18n/locales/en-US.json +++ b/shared/i18n/locales/en-US.json @@ -9752,6 +9752,105 @@ "agent.history.unknownProvider": "Unknown provider", "agent.history.providerHint": "Try another search or provider filter.", "markdown.plantumlRenderFailed": "PlantUML could not render this diagram. Showing its source instead.", + "native.toolchainSettings.runtimeEnvironment": "Runtime Environment", + "native.toolchainSettings.toolchainSources": "Toolchain Sources", + "native.toolchainSettings.couldNotReadToolchainSettings": "Could not read toolchain settings.", + "native.toolchainSettings.theSavedToolchainSettingsAreInvalid": "The saved toolchain settings are invalid.", + "native.toolchainSettings.theInstalledToolchainDeclarationsAreInvalid": "The installed toolchain declarations are invalid.", + "native.toolchainSettings.runtimeEnvironmentIsolatedPreview": "Runtime Environment — Isolated Preview", + "native.toolchainSettings.automaticDiscovery": "Automatic discovery", + "native.toolchainSettings.consumers": "Consumers", + "native.toolchainSettings.exportedEnvironment": "Exported environment", + "native.toolchainSettings.fixed": "Fixed", + "native.toolchainSettings.globalDefaults": "Global defaults", + "native.toolchainSettings.loginShell": "Login shell", + "native.toolchainSettings.noProjectEnvironmentIsAvailable": "No project environment is available.", + "native.toolchainSettings.notQueried": "Not queried", + "native.toolchainSettings.openPlugins": "Open Plugins", + "native.toolchainSettings.openAProjectAndEnableAPluginThatDefinesToolsToInspectItsEnvironment": "Open a project and enable a plugin that defines tools to inspect its environment.", + "native.toolchainSettings.projectRequirements": "Project requirements", + "native.toolchainSettings.savedNotVerified": "Saved, not verified", + "native.toolchainSettings.theEnvironmentSnapshotCouldNotBeReadRefreshToTryAgain": "The environment snapshot could not be read. Refresh to try again.", + "native.toolchainSettings.remoteUnavailable": "Toolchain settings are not available for remote workspaces yet.", + "native.toolchainSettings.projectRootDirectory": "Project root directory", + "native.toolchainSettings.versionFiles": "Version files", + "native.toolchainSettings.notPinned": "Not pinned", + "native.toolchainSettings.definition": "Definition", + "native.toolchainSettings.readResult": "Read result", + "native.toolchainSettings.readAgain": "Read again", + "native.toolchainSettings.versionManagers": "Version managers", + "native.toolchainSettings.queryMilliseconds": "{arg1} ms", + "native.toolchainSettings.directoryTreeInfo": "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.", + "native.toolchainSettings.versionFilesInfo": "Files declared by enabled version managers that exist in this directory. A directory without its own uses the nearest parent's.", + "native.toolchainSettings.noneUsesParent": "None; uses the parent directory's", + "native.toolchainSettings.fixedInfo": "Fixed versions are saved on this computer in .lithe/toolchains/local.json and also apply to the directories below. This page reads them without changing the file.", + "native.toolchainSettings.fromDirectory": "From {arg1}", + "native.toolchainSettings.theProjectRoot": "the project root", + "native.toolchainSettings.toolsInfo": "Each tool uses the first source that has a value: a fixed version, then project version files, then global defaults, then automatic discovery. Installed versions are checked by running each tool's own version command. Enabled version managers report what version files ask for.", + "native.toolchainSettings.noEnabledPluginDefinesTools": "No enabled plugin defines tools.", + "native.toolchainSettings.toolDefinitionInfo": "This row comes from the tool definition of an enabled plugin. Lithe has no built-in tools.", + "native.toolchainSettings.versionNotQueried": "Version not queried", + "native.toolchainSettings.fixedInParent": "Fixed in parent", + "native.toolchainSettings.versionFile": "Version file", + "native.toolchainSettings.pinnedStepInfo": "A version saved for this directory on this computer. It applies first, also in the directories below.", + "native.toolchainSettings.projectStepInfo": "Version files in this directory or its parents, watched by the version managers of this tool, and the versions those managers report for them.", + "native.toolchainSettings.noVersionFile": "No version file", + "native.toolchainSettings.globalStepInfo": "What version managers report as global, then the home variables and commands this tool's plugin declares, read from the login shell. A home's version comes from the tool's own version command; a command found on PATH is not run.", + "native.toolchainSettings.automaticStepInfo": "Installations found in the locations this tool's plugin declares. Each version comes from the tool's own version command, and the highest one is used. Installations inside the project are never run.", + "native.toolchainSettings.versionNotVerified": "Version not verified", + "native.toolchainSettings.firstMatch": "First match", + "native.toolchainSettings.pathChangedRestart": "The login shell's PATH has changed. Restart Lithe so that terminals and tools use it.", + "native.toolchainSettings.loginShellInfo": "At launch Lithe reads your login shell's environment, as a terminal does, and gives it to the terminals, tools and language servers it starts. Only PATH is listed; other variables can hold secrets and are only counted.", + "native.toolchainSettings.pathInfo": "Directories in the order tools are looked up. The login shell's directories come first, then any that only the environment Lithe was launched with had.", + "native.toolchainSettings.directoryCount": "{arg1} directories", + "native.toolchainSettings.turnedOffBySkipVariable": "Turned off by LITHE_SKIP_SHELL_ENV", + "native.toolchainSettings.reading": "Reading…", + "native.toolchainSettings.notReadStartedFromCache": "Not read yet; started with the PATH saved last time", + "native.toolchainSettings.notReadYet": "Not read yet", + "native.toolchainSettings.usingCachedPath": "Using the PATH saved last time", + "native.toolchainSettings.usingLaunchEnvironment": "Using the environment Lithe was launched with", + "native.toolchainSettings.variableCount": "{arg1} variables", + "native.toolchainSettings.launchEnvironment": "Launch environment", + "native.toolchainSettings.versionManagersInfo": "Version managers come from enabled plugins. Lithe asks them what is installed and what this project requires; they never install or trust anything for you.", + "native.toolchainSettings.noEnabledPluginProvidesVersionManager": "No enabled plugin provides a version manager.", + "native.toolchainSettings.notUsedInThisProject": "Not used in this project", + "native.toolchainSettings.reportsInstalledVersionsOnly": "Reports installed versions only", + "native.toolchainSettings.versionFileCount": "{arg1} version files", + "native.toolchainSettings.watchedFiles": "Watched files", + "native.toolchainSettings.versionManagerDefinitionInfo": "This version manager comes from an enabled plugin. Lithe has no built-in version managers.", + "native.toolchainSettings.onlyFirstDirectoriesShown": "Only the first {arg1} directories are shown.", + "native.toolchainSettings.versionFilesCheckedInFirstDirectories": "Version files were checked in the first {arg1} directories only.", + "native.toolchainSettings.discovered": "Discovered", + "native.toolchainSettings.moreInstallations": "{arg1} more", + "native.toolchainSettings.sameVersionConflict": "Several installations have the same version", + "native.toolchainSettings.versionCheckFailed": "Version check failed", + "native.toolchainSettings.versionTooOld": "Version too old", + "native.toolchainSettings.notAValidInstallation": "Not a valid installation", + "native.toolchainSettings.notUsable": "Not usable", + "native.toolchainSettings.installationsCouldNotBeChecked": "Installations of {arg1} could not be checked.", + "native.toolchainSettings.discoveryDeclarationsUnchecked": "Installed toolchain declarations could not be checked for discovery.", + "native.toolchainSettings.onlyFirstVersionsChecked": "Only the first {arg1} installation versions were checked.", + "native.toolchainSettings.versionsNotCheckedInTime": "Some installation versions were not checked in time.", + "native.toolchainSettings.onlyFirstInstallationsChecked": "Only the first {arg1} installations of a tool were checked.", + "native.toolchainSettings.tooManyFilesForOneTool": "Too many files to check for one tool; some installations were skipped.", + "native.toolchainSettings.onlyFirstLocationsScanned": "Only the first {arg1} discovery locations were scanned.", + "native.toolchainSettings.onlyFirstEntriesChecked": "Only the first {arg1} discovered entries were checked.", + "native.toolchainSettings.query": "Query", + "native.toolchainSettings.answeredIn": "Answered in {milliseconds} ms", + "native.toolchainSettings.backendDidNotOfferProvider": "The plugin backend did not offer this version manager.", + "native.toolchainSettings.workspaceNotOpenGlobalOnly": "This workspace is not open in a window, so only global versions were read.", + "native.toolchainSettings.providerDidNotAnswer": "{provider} did not answer: {message}", + "native.toolchainSettings.firstDirectoriesQueried": "Only the first {count} directories were queried.", + "native.toolchainSettings.queryNotBuilt": "The toolchain query could not be built.", + "native.toolchainSettings.replyUnreadable": "The reply could not be read.", + "native.toolchainSettings.resolutionUnavailable": "Which {arg1} to use could not be determined.", + "native.toolchainSettings.onlyFirstPinsChecked": "Only the first {count} fixed versions were checked.", + "native.toolchainSettings.providerConflict": "Version managers report different versions", + "native.toolchainSettings.resolutionUndetermined": "Could not be determined", + "native.toolchainSettings.requestedNotInstalled": "Requested {arg1} is not installed", + "native.toolchainSettings.providerDidNotRespond": "{provider} did not respond", + "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." }, "overrides": { diff --git a/shared/i18n/locales/zh-CN.json b/shared/i18n/locales/zh-CN.json index 96e9eb6fc8..978833c095 100644 --- a/shared/i18n/locales/zh-CN.json +++ b/shared/i18n/locales/zh-CN.json @@ -9752,6 +9752,105 @@ "agent.history.unknownProvider": "未知供应商", "agent.history.providerHint": "试试其他搜索条件或供应商筛选。", "markdown.plantumlRenderFailed": "PlantUML 无法渲染此图表,已显示源码。", + "native.toolchainSettings.runtimeEnvironment": "运行环境", + "native.toolchainSettings.toolchainSources": "环境来源", + "native.toolchainSettings.couldNotReadToolchainSettings": "无法读取工具链设置。", + "native.toolchainSettings.theSavedToolchainSettingsAreInvalid": "已保存的工具链设置无效。", + "native.toolchainSettings.theInstalledToolchainDeclarationsAreInvalid": "已安装插件的工具链声明无效。", + "native.toolchainSettings.runtimeEnvironmentIsolatedPreview": "运行环境 — 隔离预览", + "native.toolchainSettings.automaticDiscovery": "自动检测", + "native.toolchainSettings.consumers": "使用方", + "native.toolchainSettings.exportedEnvironment": "导出环境变量", + "native.toolchainSettings.fixed": "固定", + "native.toolchainSettings.globalDefaults": "全局默认", + "native.toolchainSettings.loginShell": "登录 Shell", + "native.toolchainSettings.noProjectEnvironmentIsAvailable": "没有可用的项目环境。", + "native.toolchainSettings.notQueried": "未查询", + "native.toolchainSettings.openPlugins": "打开插件页", + "native.toolchainSettings.openAProjectAndEnableAPluginThatDefinesToolsToInspectItsEnvironment": "打开项目并启用定义工具的插件,即可查看环境。", + "native.toolchainSettings.projectRequirements": "项目要求", + "native.toolchainSettings.savedNotVerified": "已保存,未验证", + "native.toolchainSettings.theEnvironmentSnapshotCouldNotBeReadRefreshToTryAgain": "无法读取环境快照,请刷新重试。", + "native.toolchainSettings.remoteUnavailable": "远程工作区暂不支持工具链设置。", + "native.toolchainSettings.projectRootDirectory": "项目根目录", + "native.toolchainSettings.versionFiles": "版本文件", + "native.toolchainSettings.notPinned": "未固定", + "native.toolchainSettings.definition": "定义", + "native.toolchainSettings.readResult": "读取结果", + "native.toolchainSettings.readAgain": "重新读取", + "native.toolchainSettings.versionManagers": "版本管理器", + "native.toolchainSettings.queryMilliseconds": "{arg1} 毫秒", + "native.toolchainSettings.directoryTreeInfo": "列出项目根目录,以及自带版本文件或固定版本的目录。固定版本也作用于其下的子目录。", + "native.toolchainSettings.versionFilesInfo": "已启用的版本管理器所声明、且存在于此目录中的文件。没有自己版本文件的目录沿用最近上级目录的。", + "native.toolchainSettings.noneUsesParent": "无,沿用上级目录", + "native.toolchainSettings.fixedInfo": "固定版本只保存在这台电脑的 .lithe/toolchains/local.json 中,并作用于其下的子目录。此页面只读取,不会修改该文件。", + "native.toolchainSettings.fromDirectory": "来自 {arg1}", + "native.toolchainSettings.theProjectRoot": "项目根目录", + "native.toolchainSettings.toolsInfo": "每个工具采用第一个有值的来源,依次为固定版本、项目版本文件、全局默认、自动检测。已安装的版本通过运行该工具自己的版本命令来检查。已启用的版本管理器会报告版本文件所要求的版本。", + "native.toolchainSettings.noEnabledPluginDefinesTools": "没有已启用的插件定义工具。", + "native.toolchainSettings.toolDefinitionInfo": "这一行来自已启用插件的工具定义,Lithe 不内置任何工具。", + "native.toolchainSettings.versionNotQueried": "版本未查询", + "native.toolchainSettings.fixedInParent": "上级目录固定", + "native.toolchainSettings.versionFile": "版本文件", + "native.toolchainSettings.pinnedStepInfo": "在这台电脑上为此目录保存的版本,优先级最高,也作用于其下的子目录。", + "native.toolchainSettings.projectStepInfo": "此目录或上级目录中、由该工具的版本管理器监视的版本文件,以及版本管理器报告的版本。", + "native.toolchainSettings.noVersionFile": "没有版本文件", + "native.toolchainSettings.globalStepInfo": "版本管理器报告的全局版本,以及插件为该工具声明的主目录变量和命令(从登录 Shell 中读取)。主目录的版本来自该工具自己的版本命令;在 PATH 中找到的命令不会运行。", + "native.toolchainSettings.automaticStepInfo": "在插件为该工具声明的位置中找到的安装。每个版本来自该工具自己的版本命令,使用其中最高的版本。项目内的安装永远不会运行。", + "native.toolchainSettings.versionNotVerified": "版本未验证", + "native.toolchainSettings.firstMatch": "首个命中", + "native.toolchainSettings.pathChangedRestart": "登录 Shell 的 PATH 已变化,重启 Lithe 后终端和工具才会使用新值。", + "native.toolchainSettings.loginShellInfo": "Lithe 启动时会像终端一样读取登录 Shell 的环境,并提供给它启动的终端、工具和语言服务器。这里只列出 PATH;其他变量可能包含密钥,只显示数量。", + "native.toolchainSettings.pathInfo": "按查找工具的顺序排列的目录。登录 Shell 的目录在前,其后是只有 Lithe 启动时环境才有的目录。", + "native.toolchainSettings.directoryCount": "{arg1} 个目录", + "native.toolchainSettings.turnedOffBySkipVariable": "已由 LITHE_SKIP_SHELL_ENV 关闭", + "native.toolchainSettings.reading": "正在读取…", + "native.toolchainSettings.notReadStartedFromCache": "尚未读取,启动时使用了上次保存的 PATH", + "native.toolchainSettings.notReadYet": "尚未读取", + "native.toolchainSettings.usingCachedPath": "仍在使用上次保存的 PATH", + "native.toolchainSettings.usingLaunchEnvironment": "仍在使用 Lithe 启动时的环境", + "native.toolchainSettings.variableCount": "{arg1} 个变量", + "native.toolchainSettings.launchEnvironment": "启动时环境", + "native.toolchainSettings.versionManagersInfo": "版本管理器来自已启用的插件。Lithe 会询问它们已安装的版本和此项目的要求;它们不会替你安装或信任任何内容。", + "native.toolchainSettings.noEnabledPluginProvidesVersionManager": "没有已启用的插件提供版本管理器。", + "native.toolchainSettings.notUsedInThisProject": "此项目未使用", + "native.toolchainSettings.reportsInstalledVersionsOnly": "只报告已安装的版本", + "native.toolchainSettings.versionFileCount": "{arg1} 个版本文件", + "native.toolchainSettings.watchedFiles": "监视的文件", + "native.toolchainSettings.versionManagerDefinitionInfo": "此版本管理器来自已启用的插件,Lithe 不内置任何版本管理器。", + "native.toolchainSettings.onlyFirstDirectoriesShown": "只显示前 {arg1} 个目录。", + "native.toolchainSettings.versionFilesCheckedInFirstDirectories": "只在前 {arg1} 个目录中检查了版本文件。", + "native.toolchainSettings.discovered": "自动发现", + "native.toolchainSettings.moreInstallations": "另外 {arg1} 个", + "native.toolchainSettings.sameVersionConflict": "多个安装的版本相同", + "native.toolchainSettings.versionCheckFailed": "版本检查失败", + "native.toolchainSettings.versionTooOld": "版本过低", + "native.toolchainSettings.notAValidInstallation": "不是有效的安装", + "native.toolchainSettings.notUsable": "不可用", + "native.toolchainSettings.installationsCouldNotBeChecked": "无法检查 {arg1} 的安装。", + "native.toolchainSettings.discoveryDeclarationsUnchecked": "无法检查已安装的工具链声明,未进行自动检测。", + "native.toolchainSettings.onlyFirstVersionsChecked": "只检查了前 {arg1} 个安装的版本。", + "native.toolchainSettings.versionsNotCheckedInTime": "部分安装的版本未能及时检查。", + "native.toolchainSettings.onlyFirstInstallationsChecked": "每个工具只检查了前 {arg1} 个安装。", + "native.toolchainSettings.tooManyFilesForOneTool": "单个工具需要检查的文件过多,已跳过部分安装。", + "native.toolchainSettings.onlyFirstLocationsScanned": "只扫描了前 {arg1} 个检测位置。", + "native.toolchainSettings.onlyFirstEntriesChecked": "只检查了前 {arg1} 个发现的条目。", + "native.toolchainSettings.query": "查询", + "native.toolchainSettings.answeredIn": "{milliseconds} 毫秒内已回答", + "native.toolchainSettings.backendDidNotOfferProvider": "插件后端没有提供此版本管理器。", + "native.toolchainSettings.workspaceNotOpenGlobalOnly": "此工作区未在窗口中打开,因此只读取了全局版本。", + "native.toolchainSettings.providerDidNotAnswer": "{provider} 未回答:{message}", + "native.toolchainSettings.firstDirectoriesQueried": "只查询了前 {count} 个目录。", + "native.toolchainSettings.queryNotBuilt": "无法构建工具链查询。", + "native.toolchainSettings.replyUnreadable": "无法读取答复。", + "native.toolchainSettings.resolutionUnavailable": "无法确定要使用哪个 {arg1}。", + "native.toolchainSettings.onlyFirstPinsChecked": "只检查了前 {count} 个固定版本。", + "native.toolchainSettings.providerConflict": "版本管理器给出的版本不一致", + "native.toolchainSettings.resolutionUndetermined": "无法确定", + "native.toolchainSettings.requestedNotInstalled": "请求的 {arg1} 未安装", + "native.toolchainSettings.providerDidNotRespond": "{provider} 未响应", + "native.toolchainSettings.providerIsUnavailable": "{provider} 不可用", + "native.toolchainSettings.noAnswerWithinSeconds": "{seconds} 秒内未响应", "lsp.projectJdkInvalid": "项目 JDK 无效或无法读取其版本。请检查项目设置中的 JDK Home。" }, "overrides": { diff --git a/shared/i18n/messages.json b/shared/i18n/messages.json index 768007f14e..64d0d81c86 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -93393,6 +93393,1207 @@ "arguments": [] } }, + { + "id": "native.toolchainSettings.runtimeEnvironment", + "description": "Native toolchain settings: Runtime Environment", + "platforms": [ + "macos" + ], + "macos": { + "key": "Runtime Environment", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.toolchainSources", + "description": "Native toolchain settings: Toolchain Sources", + "platforms": [ + "macos" + ], + "macos": { + "key": "Toolchain Sources", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.couldNotReadToolchainSettings", + "description": "Native toolchain settings: Could not read toolchain settings.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Could not read toolchain settings.", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.theSavedToolchainSettingsAreInvalid", + "description": "Native toolchain settings: The saved toolchain settings are invalid.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The saved toolchain settings are invalid.", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.theInstalledToolchainDeclarationsAreInvalid", + "description": "Native toolchain settings: The installed toolchain declarations are invalid.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The installed toolchain declarations are invalid.", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.runtimeEnvironmentIsolatedPreview", + "description": "Native toolchain settings: Runtime Environment — Isolated Preview", + "platforms": [ + "macos" + ], + "macos": { + "key": "Runtime Environment — Isolated Preview", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.automaticDiscovery", + "description": "Native toolchain settings: Automatic discovery", + "platforms": [ + "macos" + ], + "macos": { + "key": "Automatic discovery", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.consumers", + "description": "Native toolchain settings: Consumers", + "platforms": [ + "macos" + ], + "macos": { + "key": "Consumers", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.exportedEnvironment", + "description": "Native toolchain settings: Exported environment", + "platforms": [ + "macos" + ], + "macos": { + "key": "Exported environment", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.fixed", + "description": "Native toolchain settings: Fixed", + "platforms": [ + "macos" + ], + "macos": { + "key": "Fixed", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.globalDefaults", + "description": "Native toolchain settings: Global defaults", + "platforms": [ + "macos" + ], + "macos": { + "key": "Global defaults", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.loginShell", + "description": "Native toolchain settings: Login shell", + "platforms": [ + "macos" + ], + "macos": { + "key": "Login shell", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.noProjectEnvironmentIsAvailable", + "description": "Native toolchain settings: No project environment is available.", + "platforms": [ + "macos" + ], + "macos": { + "key": "No project environment is available.", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.notQueried", + "description": "Native toolchain settings: Not queried", + "platforms": [ + "macos" + ], + "macos": { + "key": "Not queried", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.openPlugins", + "description": "Native toolchain settings: Open Plugins", + "platforms": [ + "macos" + ], + "macos": { + "key": "Open Plugins", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.openAProjectAndEnableAPluginThatDefinesToolsToInspectItsEnvironment", + "description": "Native toolchain settings: Open a project and enable a plugin that defines tools to inspect its environment.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Open a project and enable a plugin that defines tools to inspect its environment.", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.projectRequirements", + "description": "Native toolchain settings: Project requirements", + "platforms": [ + "macos" + ], + "macos": { + "key": "Project requirements", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.savedNotVerified", + "description": "Native toolchain settings: Saved, not verified", + "platforms": [ + "macos" + ], + "macos": { + "key": "Saved, not verified", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.theEnvironmentSnapshotCouldNotBeReadRefreshToTryAgain", + "description": "Native toolchain settings: The environment snapshot could not be read. Refresh to try again.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The environment snapshot could not be read. Refresh to try again.", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.remoteUnavailable", + "description": "Native toolchain settings remote boundary", + "platforms": [ + "macos" + ], + "macos": { + "key": "Toolchain settings are not available for remote workspaces yet.", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.projectRootDirectory", + "description": "Native toolchain environment layout: Project root directory", + "platforms": [ + "macos" + ], + "macos": { + "key": "Project root directory", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.versionFiles", + "description": "Native toolchain environment layout: Version files", + "platforms": [ + "macos" + ], + "macos": { + "key": "Version files", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.notPinned", + "description": "Native toolchain environment layout: Not pinned", + "platforms": [ + "macos" + ], + "macos": { + "key": "Not pinned", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.definition", + "description": "Native toolchain environment layout: Definition", + "platforms": [ + "macos" + ], + "macos": { + "key": "Definition", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.readResult", + "description": "Native toolchain environment layout: Read result", + "platforms": [ + "macos" + ], + "macos": { + "key": "Read result", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.readAgain", + "description": "Native toolchain environment layout: Read again", + "platforms": [ + "macos" + ], + "macos": { + "key": "Read again", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.versionManagers", + "description": "Native toolchain environment layout: Version managers", + "platforms": [ + "macos" + ], + "macos": { + "key": "Version managers", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.queryMilliseconds", + "description": "How long the last login shell read took", + "platforms": [ + "macos" + ], + "parameters": { + "arg1": "integer" + }, + "macos": { + "key": "%lld ms", + "arguments": [ + "arg1" + ] + } + }, + { + "id": "native.toolchainSettings.directoryTreeInfo", + "description": "Directory tree help on the runtime environment page", + "platforms": [ + "macos" + ], + "macos": { + "key": "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.", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.versionFilesInfo", + "description": "Help for the version files line of a directory", + "platforms": [ + "macos" + ], + "macos": { + "key": "Files declared by enabled version managers that exist in this directory. A directory without its own uses the nearest parent's.", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.noneUsesParent", + "description": "A directory has no version file of its own and inherits its parent directory", + "platforms": [ + "macos" + ], + "macos": { + "key": "None; uses the parent directory's", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.fixedInfo", + "description": "Help for the fixed versions line of a directory", + "platforms": [ + "macos" + ], + "macos": { + "key": "Fixed versions are saved on this computer in .lithe/toolchains/local.json and also apply to the directories below. This page reads them without changing the file.", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.fromDirectory", + "description": "Names the directory a fixed version is inherited from", + "platforms": [ + "macos" + ], + "parameters": { + "arg1": "string" + }, + "macos": { + "key": "From %@", + "arguments": [ + "arg1" + ] + } + }, + { + "id": "native.toolchainSettings.theProjectRoot", + "description": "Names the workspace root inside a sentence, such as \"From the project root\"", + "platforms": [ + "macos" + ], + "macos": { + "key": "the project root", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.toolsInfo", + "description": "Help for the tools list: the order sources are applied", + "platforms": [ + "macos" + ], + "macos": { + "key": "Each tool uses the first source that has a value: a fixed version, then project version files, then global defaults, then automatic discovery. Installed versions are checked by running each tool's own version command. Enabled version managers report what version files ask for.", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.noEnabledPluginDefinesTools", + "description": "Empty tools list", + "platforms": [ + "macos" + ], + "macos": { + "key": "No enabled plugin defines tools.", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.toolDefinitionInfo", + "description": "Help for where a tool row is defined", + "platforms": [ + "macos" + ], + "macos": { + "key": "This row comes from the tool definition of an enabled plugin. Lithe has no built-in tools.", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.versionNotQueried", + "description": "A version file exists but the version it asks for has not been queried", + "platforms": [ + "macos" + ], + "macos": { + "key": "Version not queried", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.fixedInParent", + "description": "Tool badge: the fixed version was saved for a parent directory", + "platforms": [ + "macos" + ], + "macos": { + "key": "Fixed in parent", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.versionFile", + "description": "Tool badge: a version file applies to this tool", + "platforms": [ + "macos" + ], + "macos": { + "key": "Version file", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.pinnedStepInfo", + "description": "Help for the fixed-version step of tool resolution", + "platforms": [ + "macos" + ], + "macos": { + "key": "A version saved for this directory on this computer. It applies first, also in the directories below.", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.projectStepInfo", + "description": "Help for the project-requirements step of tool resolution", + "platforms": [ + "macos" + ], + "macos": { + "key": "Version files in this directory or its parents, watched by the version managers of this tool, and the versions those managers report for them.", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.noVersionFile", + "description": "No version file applies to a tool", + "platforms": [ + "macos" + ], + "macos": { + "key": "No version file", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.globalStepInfo", + "description": "Help for the global-defaults step of tool resolution", + "platforms": [ + "macos" + ], + "macos": { + "key": "What version managers report as global, then the home variables and commands this tool's plugin declares, read from the login shell. A home's version comes from the tool's own version command; a command found on PATH is not run.", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.automaticStepInfo", + "description": "Help for the automatic-discovery step of tool resolution", + "platforms": [ + "macos" + ], + "macos": { + "key": "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.", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.versionNotVerified", + "description": "An executable was found but its version was not checked", + "platforms": [ + "macos" + ], + "macos": { + "key": "Version not verified", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.firstMatch", + "description": "Marks the first resolution step that has a value", + "platforms": [ + "macos" + ], + "macos": { + "key": "First match", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.pathChangedRestart", + "description": "Notice after a login shell read changed PATH", + "platforms": [ + "macos" + ], + "macos": { + "key": "The login shell's PATH has changed. Restart Lithe so that terminals and tools use it.", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.loginShellInfo", + "description": "Help for the login shell section", + "platforms": [ + "macos" + ], + "macos": { + "key": "At launch Lithe reads your login shell's environment, as a terminal does, and gives it to the terminals, tools and language servers it starts. Only PATH is listed; other variables can hold secrets and are only counted.", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.pathInfo", + "description": "Help for the PATH line of the login shell section", + "platforms": [ + "macos" + ], + "macos": { + "key": "Directories in the order tools are looked up. The login shell's directories come first, then any that only the environment Lithe was launched with had.", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.directoryCount", + "description": "Number of directories in PATH", + "platforms": [ + "macos" + ], + "parameters": { + "arg1": "integer" + }, + "macos": { + "key": "%lld directories", + "arguments": [ + "arg1" + ] + } + }, + { + "id": "native.toolchainSettings.turnedOffBySkipVariable", + "description": "The login shell read is disabled by an environment variable", + "platforms": [ + "macos" + ], + "macos": { + "key": "Turned off by LITHE_SKIP_SHELL_ENV", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.reading", + "description": "The login shell is being read", + "platforms": [ + "macos" + ], + "macos": { + "key": "Reading…", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.notReadStartedFromCache", + "description": "No login shell read finished; startup used the cached PATH", + "platforms": [ + "macos" + ], + "macos": { + "key": "Not read yet; started with the PATH saved last time", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.notReadYet", + "description": "No login shell read has finished", + "platforms": [ + "macos" + ], + "macos": { + "key": "Not read yet", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.usingCachedPath", + "description": "After a failed read, the cached PATH remains in use", + "platforms": [ + "macos" + ], + "macos": { + "key": "Using the PATH saved last time", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.usingLaunchEnvironment", + "description": "After a failed read, the environment Lithe was launched with remains in use", + "platforms": [ + "macos" + ], + "macos": { + "key": "Using the environment Lithe was launched with", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.variableCount", + "description": "Number of variables the login shell read returned", + "platforms": [ + "macos" + ], + "parameters": { + "arg1": "integer" + }, + "macos": { + "key": "%lld variables", + "arguments": [ + "arg1" + ] + } + }, + { + "id": "native.toolchainSettings.launchEnvironment", + "description": "PATH entry that came only from the environment Lithe was launched with", + "platforms": [ + "macos" + ], + "macos": { + "key": "Launch environment", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.versionManagersInfo", + "description": "Help for the version managers section", + "platforms": [ + "macos" + ], + "macos": { + "key": "Version managers come from enabled plugins. Lithe asks them what is installed and what this project requires; they never install or trust anything for you.", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.noEnabledPluginProvidesVersionManager", + "description": "Empty version managers list", + "platforms": [ + "macos" + ], + "macos": { + "key": "No enabled plugin provides a version manager.", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.notUsedInThisProject", + "description": "No watched file of a version manager exists in this project", + "platforms": [ + "macos" + ], + "macos": { + "key": "Not used in this project", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.reportsInstalledVersionsOnly", + "description": "A version manager that declares no watched files and only reports installed versions", + "platforms": [ + "macos" + ], + "macos": { + "key": "Reports installed versions only", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.versionFileCount", + "description": "Number of version files of a version manager found in this project", + "platforms": [ + "macos" + ], + "parameters": { + "arg1": "integer" + }, + "macos": { + "key": "%lld version files", + "arguments": [ + "arg1" + ] + } + }, + { + "id": "native.toolchainSettings.watchedFiles", + "description": "Files a version manager watches", + "platforms": [ + "macos" + ], + "macos": { + "key": "Watched files", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.versionManagerDefinitionInfo", + "description": "Help for where a version manager is defined", + "platforms": [ + "macos" + ], + "macos": { + "key": "This version manager comes from an enabled plugin. Lithe has no built-in version managers.", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.onlyFirstDirectoriesShown", + "description": "Diagnostic when the directory list is truncated", + "platforms": [ + "macos" + ], + "parameters": { + "arg1": "integer" + }, + "macos": { + "key": "Only the first %lld directories are shown.", + "arguments": [ + "arg1" + ] + } + }, + { + "id": "native.toolchainSettings.versionFilesCheckedInFirstDirectories", + "description": "Diagnostic when version files were checked in a bounded set of directories", + "platforms": [ + "macos" + ], + "parameters": { + "arg1": "integer" + }, + "macos": { + "key": "Version files were checked in the first %lld directories only.", + "arguments": [ + "arg1" + ] + } + }, + { + "id": "native.toolchainSettings.discovered", + "description": "A tool resolved from an installation found by automatic discovery", + "platforms": [ + "macos" + ], + "macos": { + "key": "Discovered", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.moreInstallations", + "description": "Number of further discovered installations not listed", + "platforms": [ + "macos" + ], + "parameters": { + "arg1": "integer" + }, + "macos": { + "key": "%lld more", + "arguments": [ + "arg1" + ] + } + }, + { + "id": "native.toolchainSettings.sameVersionConflict", + "description": "Automatic discovery found different installations with the same highest version", + "platforms": [ + "macos" + ], + "macos": { + "key": "Several installations have the same version", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.versionCheckFailed", + "description": "An installation was skipped because its version command failed", + "platforms": [ + "macos" + ], + "macos": { + "key": "Version check failed", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.versionTooOld", + "description": "An installation was skipped because its version is below the required minimum", + "platforms": [ + "macos" + ], + "macos": { + "key": "Version too old", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.notAValidInstallation", + "description": "An installation was skipped because its files do not match the tool definition or it is inside the project", + "platforms": [ + "macos" + ], + "macos": { + "key": "Not a valid installation", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.notUsable", + "description": "An installation was skipped for another reason", + "platforms": [ + "macos" + ], + "macos": { + "key": "Not usable", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.installationsCouldNotBeChecked", + "description": "Installations of a tool could not be checked", + "platforms": [ + "macos" + ], + "parameters": { + "arg1": "string" + }, + "macos": { + "key": "Installations of %@ could not be checked.", + "arguments": [ + "arg1" + ] + } + }, + { + "id": "native.toolchainSettings.discoveryDeclarationsUnchecked", + "description": "Installed tool declarations could not be checked, so automatic discovery did not run", + "platforms": [ + "macos" + ], + "macos": { + "key": "Installed toolchain declarations could not be checked for discovery.", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.onlyFirstVersionsChecked", + "description": "Version checks stopped at a limit", + "platforms": [ + "macos" + ], + "parameters": { + "arg1": "integer" + }, + "macos": { + "key": "Only the first %lld installation versions were checked.", + "arguments": [ + "arg1" + ] + } + }, + { + "id": "native.toolchainSettings.versionsNotCheckedInTime", + "description": "Version checks stopped at the time limit", + "platforms": [ + "macos" + ], + "macos": { + "key": "Some installation versions were not checked in time.", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.onlyFirstInstallationsChecked", + "description": "Installation checks for one tool stopped at a limit", + "platforms": [ + "macos" + ], + "parameters": { + "arg1": "integer" + }, + "macos": { + "key": "Only the first %lld installations of a tool were checked.", + "arguments": [ + "arg1" + ] + } + }, + { + "id": "native.toolchainSettings.tooManyFilesForOneTool", + "description": "File checks for one tool stopped at a limit", + "platforms": [ + "macos" + ], + "macos": { + "key": "Too many files to check for one tool; some installations were skipped.", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.onlyFirstLocationsScanned", + "description": "Discovery locations stopped at a limit", + "platforms": [ + "macos" + ], + "parameters": { + "arg1": "integer" + }, + "macos": { + "key": "Only the first %lld discovery locations were scanned.", + "arguments": [ + "arg1" + ] + } + }, + { + "id": "native.toolchainSettings.onlyFirstEntriesChecked", + "description": "Discovered entries stopped at a limit", + "platforms": [ + "macos" + ], + "parameters": { + "arg1": "integer" + }, + "macos": { + "key": "Only the first %lld discovered entries were checked.", + "arguments": [ + "arg1" + ] + } + }, + { + "id": "native.toolchainSettings.query", + "description": "Toolchain provider query: Query", + "platforms": [ + "macos" + ], + "macos": { + "key": "Query", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.answeredIn", + "description": "Toolchain provider query: Answered in %lld ms", + "platforms": [ + "macos" + ], + "macos": { + "key": "Answered in %lld ms", + "arguments": [ + "milliseconds" + ] + }, + "parameters": { + "milliseconds": "integer" + } + }, + { + "id": "native.toolchainSettings.backendDidNotOfferProvider", + "description": "Toolchain provider query: The plugin backend did not offer this version manager.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The plugin backend did not offer this version manager.", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.workspaceNotOpenGlobalOnly", + "description": "Toolchain provider query: This workspace is not open in a window, so only global versions were read.", + "platforms": [ + "macos" + ], + "macos": { + "key": "This workspace is not open in a window, so only global versions were read.", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.providerDidNotAnswer", + "description": "Toolchain provider query: %@ did not answer: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "%@ did not answer: %@", + "arguments": [ + "provider", + "message" + ] + }, + "parameters": { + "provider": "string", + "message": "string" + } + }, + { + "id": "native.toolchainSettings.firstDirectoriesQueried", + "description": "Toolchain provider query: Only the first %lld directories were queried.", + "platforms": [ + "macos" + ], + "macos": { + "key": "Only the first %lld directories were queried.", + "arguments": [ + "count" + ] + }, + "parameters": { + "count": "integer" + } + }, + { + "id": "native.toolchainSettings.queryNotBuilt", + "description": "Toolchain provider query: The toolchain query could not be built.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The toolchain query could not be built.", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.replyUnreadable", + "description": "Toolchain provider query: The reply could not be read.", + "platforms": [ + "macos" + ], + "macos": { + "key": "The reply could not be read.", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.resolutionUnavailable", + "description": "Core could not resolve which installation of a tool a directory uses", + "platforms": [ + "macos" + ], + "parameters": { + "arg1": "string" + }, + "macos": { + "key": "Which %@ to use could not be determined.", + "arguments": [ + "arg1" + ] + } + }, + { + "id": "native.toolchainSettings.onlyFirstPinsChecked", + "description": "Fixed-version checks stopped at a limit", + "platforms": [ + "macos" + ], + "parameters": { + "count": "integer" + }, + "macos": { + "key": "Only the first %lld fixed versions were checked.", + "arguments": [ + "count" + ] + } + }, + { + "id": "native.toolchainSettings.providerConflict", + "description": "Version managers in one source report different versions, so a choice is needed", + "platforms": [ + "macos" + ], + "macos": { + "key": "Version managers report different versions", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.resolutionUndetermined", + "description": "Tool row summary when Core could not resolve the directory", + "platforms": [ + "macos" + ], + "macos": { + "key": "Could not be determined", + "arguments": [] + } + }, + { + "id": "native.toolchainSettings.requestedNotInstalled", + "description": "Tool row summary: the version a project or global setting requests is not installed", + "platforms": [ + "macos" + ], + "parameters": { + "arg1": "string" + }, + "macos": { + "key": "Requested %@ is not installed", + "arguments": [ + "arg1" + ] + } + }, + { + "id": "native.toolchainSettings.providerDidNotRespond", + "description": "Tool row summary: a version manager asked about this tool did not answer before the query deadline", + "platforms": [ + "macos" + ], + "parameters": { + "provider": "string" + }, + "macos": { + "key": "%@ did not respond", + "arguments": [ + "provider" + ] + } + }, + { + "id": "native.toolchainSettings.providerIsUnavailable", + "description": "Tool row summary: a version manager asked about this tool could not answer because its plugin backend failed to start, disconnected or did not offer it", + "platforms": [ + "macos" + ], + "parameters": { + "provider": "string" + }, + "macos": { + "key": "%@ is unavailable", + "arguments": [ + "provider" + ] + } + }, + { + "id": "native.toolchainSettings.noAnswerWithinSeconds", + "description": "Toolchain provider query: the query reached its deadline without an answer", + "platforms": [ + "macos" + ], + "parameters": { + "seconds": "integer" + }, + "macos": { + "key": "No answer within %lld s", + "arguments": [ + "seconds" + ] + } + }, { "id": "lsp.projectJdkInvalid", "description": "The configured project JDK cannot be used by the Java language service.", diff --git a/shared/platform-feature-matrix/features/plugin-injected-toolchains.json b/shared/platform-feature-matrix/features/plugin-injected-toolchains.json new file mode 100644 index 0000000000..d662efe667 --- /dev/null +++ b/shared/platform-feature-matrix/features/plugin-injected-toolchains.json @@ -0,0 +1,51 @@ +{ + "id": "plugin-injected-toolchains", + "area": "扩展", + "group": "插件与主题", + "capability": "工具链领域框架:共享 Core 校验插件贡献、来源与四层解析;macOS 提供只读的运行环境和环境来源设置页,目录、工具、版本文件、固定值、登录 Shell 与版本管理器均来自已启用插件的声明和磁盘上的真实文件。自动检测按插件声明真实扫描本机安装并运行定义的版本探测;版本管理器通过插件后端的 toolchains/query 报告安装和项目/全局要求,回复经 Core 校验后显示,监视的文件变化时整份重查;每行哪一层生效由 Core toolchains.resolve 按同一份事实决定。Lithe 全局默认、保存与运行时消费者仍未接入,不能把只读页面当作已生效配置。", + "macos": { + "implementationStatus": "partial", + "verificationStatus": "pending", + "evidence": [ + "rust/lithe-core/src/toolchain/mod.rs", + "rust/lithe-core/src/toolchain/definitions.rs", + "rust/lithe-core/src/toolchain/query.rs", + "rust/lithe-core/src/toolchain/pins.rs", + "rust/lithe-core/src/toolchain/resolution.rs", + "rust/lithe-core/src/tests/toolchain_definitions.rs", + "rust/lithe-core/src/tests/toolchain_queries.rs", + "rust/lithe-core/src/tests/toolchain_pins.rs", + "rust/lithe-core/src/tests/toolchain_resolution.rs", + "shared/contracts/plugins/v1/toolchains.md", + "macos/Sources/Lithe/Core/Ports/ToolchainSettings.swift", + "macos/Sources/Lithe/Application/Features/ToolchainSettingsFeatureModel.swift", + "macos/Sources/Lithe/Platform/MacOS/Runtime/MacToolchainSettingsLoader.swift", + "macos/Sources/Lithe/Platform/MacOS/Runtime/MacToolchainDiscovery.swift", + "macos/Sources/Lithe/Views/App/ToolchainEnvironmentSettingsView.swift", + "macos/Sources/Lithe/Views/App/ToolchainSourcesSettingsView.swift", + "macos/Tests/LitheTests/ToolchainSettingsFeatureTests.swift", + "macos/Tests/LitheTests/ToolchainSettingsLoaderTests.swift", + "macos/Tests/LitheTests/ToolchainSettingsLayoutTests.swift", + "macos/Tests/LitheTests/MacToolchainDiscoveryTests.swift", + "rust/lithe-core/src/lsp/interface/toolchain_provider.rs", + "macos/Sources/Lithe/Platform/MacOS/Runtime/MacToolchainDiscovery+Providers.swift", + "macos/Sources/Lithe/Platform/MacOS/Runtime/MacToolchainInputWatcher.swift", + "macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift" + ] + }, + "windows": { + "implementationStatus": "partial", + "verificationStatus": "pending", + "evidence": [ + "rust/lithe-core/src/toolchain/paths.rs", + "rust/lithe-core/src/toolchain/query.rs", + "rust/lithe-core/src/toolchain/resolution.rs", + "rust/lithe-core/src/tests/toolchain_queries.rs", + "shared/contracts/plugins/v1/plugin-manifest.schema.json", + "shared/contracts/plugins/v1/toolchains.md" + ] + }, + "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 准入;不运行本机安装的工具。", + "notes": "本条仅记录 #1581 重设计的 Core 基础切片。两端共享同一确定性模型但均未接原生产品,不广告工具链 hostCapabilities;现有 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/,插件只是声明贡献入口。" +}