diff --git a/.agents/notes/implemented/architecture/2026-10-20-plugin-function-contract.md b/.agents/notes/implemented/architecture/2026-10-20-plugin-function-contract.md index f9e20ad737..5e04e1a462 100644 --- a/.agents/notes/implemented/architecture/2026-10-20-plugin-function-contract.md +++ b/.agents/notes/implemented/architecture/2026-10-20-plugin-function-contract.md @@ -33,7 +33,7 @@ Issue #1351 要把 Maven、Gradle 这类插件从“只给声明”推进到“ | 插件提供插件函数 | 插件 | 插件后端 | `lithe/function/invoke` | | 插件调用宿主能力 | 宿主 | 宿主 | `lithe/host/*`,按协议版本逐个开放 | -开发者判断时只问一句:**结果要不要被 Lithe 理解?** 要被 IDE 工作流、展示层或另一个平台使用的,就是领域能力,必须有类型化契约;只给插件自己界面用的,才是插件函数。 +开发者判断时先问:**宿主需要理解哪个能力的数据?** 参与构建等原生领域工作流的结果走对应领域契约;高亮、文档和编辑器动作走独立扩展点的类型化契约,不因宿主需要展示就强塞进 Build/LSP/Debug。插件私有业务继续用插件函数,独立扩展点可以在用户操作时绑定同一插件的函数;边界见[独立插件能力与领域契约](2026-10-31-plugin-capability-and-domain-boundaries.md)。 ### 2. 禁止回调自己 diff --git a/.agents/notes/implemented/architecture/2026-10-26-plugin-live-activation.md b/.agents/notes/implemented/architecture/2026-10-26-plugin-live-activation.md index f3b39fa508..698d234faa 100644 --- a/.agents/notes/implemented/architecture/2026-10-26-plugin-live-activation.md +++ b/.agents/notes/implemented/architecture/2026-10-26-plugin-live-activation.md @@ -4,7 +4,7 @@ ## 先说结论 -安装、更新、回退、卸载、启用、停用一个插件时,只有“它的每一项贡献在宿主里都有运行期实现”的插件才免重启生效。判断由宿主后端决定,写在 `PluginDescriptor.canActivateWithoutRestart`,不是 `plugin.json` 里的字段,所以插件契约没有变化,也不需要同步 lithe-plugin-docs。这条规则目前只落地了第一阶段:只有“纯调试适配器插件”(只贡献 `debugAdapters`,以及它声明的语言和设置,没有后端、没有其他模块)满足条件。 +安装、更新、回退、卸载、启用、停用一个插件时,只有“它的每一项贡献在宿主里都有运行期实现”的插件才免重启生效。判断由宿主后端决定,写在 `PluginDescriptor.canActivateWithoutRestart`,不是 `plugin.json` 里的字段,所以插件契约没有变化,也不需要同步 lithe-plugin-docs。这条规则目前落地了两类:第一阶段的“纯调试适配器插件”(只贡献 `debugAdapters`,以及它声明的语言和设置,没有后端、没有其他模块),和第二阶段的“纯语法高亮插件”(只贡献带 `grammar` 或编辑配置的语言,见 [独立插件编辑器 API](2026-10-31-plugin-editor-extension-apis.md))。两者也可以出现在同一个插件里。 满足条件的插件不进模块图,而是登记在一个**内存实时注册表**(`MacLivePluginRegistry`)里。注册表用后台线程扫描已安装目录,主线程只读取扫描结果快照。其他插件的路径完全不变:仍然写成 `.updateStaged`,等重启才生效。 @@ -23,7 +23,7 @@ ### 1. 由后端能力决定,不是清单字段 -`canActivateWithoutRestart` 现在的定义是:有调试适配器、没有 `backend` 声明、模块列表恰好是 `[debugAdaptersExtension(<插件 id>)]`,且该模块没有其他贡献。 +`canActivateWithoutRestart` 现在的定义是:没有 `backend` 声明、模块列表非空,且每个模块都是 `debugAdaptersExtension(<插件 id>)` 或 `grammarsExtension(<插件 id>)`,并且没有其他贡献。语法模块只承载启用状态,它的运行期登记、撤回和查找由 `MacPluginEditorLanguages` 完成(编辑器每次变化都收到完整的语言列表,撤回就是从列表里拿掉)。 - 正确做法:新增一种贡献(例如语言服务器)的热激活时,先实现它的运行期登记和撤回,再在这个谓词里放行。 - 错误做法:在 `plugin.json` 里加 `restartRequired: false` 让插件作者声明。作者不了解宿主哪些贡献有运行期实现;声明错了,用户看到的是“装上了但没生效”。 @@ -97,12 +97,13 @@ 5. 传给 Core 的 `registrationGeneration` 是准备调试适配器时注册表的 `generation`,Core 只比较是否相等,不解释数值。 6. `stopSessions(ofPlugin:)` 只停该插件拥有的会话。会话记录的所有者是创建时选中的插件;同一个语言下另一个插件的会话继续运行。 7. debug-only 声明里的文件扩展名会并入语言;扩展名集合变化会让该语言的服务器按原有规则重启。 -8. 目前仍然需要重启的贡献:语言服务器、运行/测试、构建系统、VCS、项目生成器、界面贡献、`backend`、网页窗口。后续阶段按“先有运行期登记和撤回,再放进谓词”的顺序逐类加入,每类单独验证。 +8. 目前仍然需要重启的贡献:语言服务器、运行/测试、编辑器动作后端(因为它依赖 `backend`)、构建系统、VCS、项目生成器、界面贡献、`backend`、网页窗口。后续阶段按“先有运行期登记和撤回,再放进谓词”的顺序逐类加入,每类单独验证。 ## 验证 - `macos/Tests/LitheTests/PluginLiveActivationTests.swift`:谓词的正反例(含契约 fixture)、debug-only 语言与插件 id 排序无关、注册表的收录/代数/启用/撤回/按 id 取第一个、后台扫描不在主线程、扫描失败保留旧登记、另一窗口的通知触发重扫、管理器的安装/更新/回退/启用/卸载流程。等待事件使用带本地期限的 `ChangeLatch`,不用真实 sleep。 - `LanguageProviderCatalogSourceTests`(debug-only 声明不抢语言、顺序无关、不触发扩展提示、每次 load 读取实时声明)、`DebugModuleTests`(`providersSource` 每次调用都查)、`LanguageIntelligenceModuleTests`(出现调试器不会停正在运行的语言服务器)。 +- `macos/Tests/LitheTests/PluginEditorLanguagesTests.swift`:纯语法插件满足谓词、带后端的工具插件不满足;实时语法随插件出现和消失。 - `scripts/macos/verify/verify-runtime-bundle-immutability.sh` 和 `./scripts/agent-notes/verify-agent-notes.sh`。 ## 适用范围 diff --git a/.agents/notes/implemented/architecture/2026-10-31-plugin-capability-and-domain-boundaries.md b/.agents/notes/implemented/architecture/2026-10-31-plugin-capability-and-domain-boundaries.md new file mode 100644 index 0000000000..7e9fa459a1 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-31-plugin-capability-and-domain-boundaries.md @@ -0,0 +1,75 @@ +# Agent 笔记:独立插件能力与领域契约的边界 + +状态:已实现 + +## 先说结论 + +领域契约的目的,是让同一类事实和工作流只有一个稳定模型,而不是要求所有插件都成为语言、构建或调试提供者。普通工具插件可以独立使用高亮、文档和编辑器动作 API;底层复用同一个进程、函数调用或 JSON-RPC 通信机制,不改变它的业务归属。本笔记落地的是设计与审查规则,不表示下文提到的工具链、配置编辑或编辑器扩展提案已经全部实现。 + +## 问题 + +用户在三组讨论中反复指出同一种偏差:实现者为了接入已有模块,把工具专属知识重新写回宿主,或者把独立能力强行套成某个领域操作。 + +- [PR #1581 的方案评论](https://github.com/1lck/Lithe-IDEA/pull/1581#issuecomment-6498462433):版本管理器的别名、版本文件覆盖规则和安装布局属于插件/上游;封闭的 `miseToml | toolVersions` 枚举不等于通用扩展。新增另一种管理器应只改插件,不改 Core。 +- [PR #1581 的后续评论](https://github.com/1lck/Lithe-IDEA/pull/1581#issuecomment-6499617033):查询和观察机制可内置,观察对象仍需声明;相关输入变化应撤销并重载整份查询结果。环境信息可以统一展示,工具专属设置必须由插件注入,而不是固定模板。 +- [PR #1558 的正式评审](https://github.com/1lck/Lithe-IDEA/pull/1558#pullrequestreview-5715250513):不透明测试选择器、统一测试结果和通用运行选项合理;XML 专属配置绑定、`runtime: jvm`、固定 Maven 工具链名、固定 Java 准备任务 ID 不合理。调试应声明适配器与连接目标,不经宿主 Java 特判;临时迁移要单独跟踪退出条件。 +- PR #1617 的本次设计讨论:HTTP 工具插件解析文本并提供高亮和按钮,不因此成为 LSP(语言服务器协议)、Build 或 Debug 插件;播放图标也不是接入原生 Run 工作流的证据。 + +以上评论中的示意字段和拆分顺序是各自提案的讨论材料,不作为已经发布的正式 API。 + +## 决策 + +### 按语义分三类,不按实现文件名分 + +| 类别 | 宿主需要理解什么 | 应放在哪里 | +| --- | --- | --- | +| 领域模型 | 构建目标、测试结果、调试会话等同领域事实 | 对应的类型化领域契约 | +| 独立扩展点 | 文档范围、图标、动作调用目标、语法资源等有限数据 | 独立的编辑器或宿主能力契约 | +| 插件业务 | HTTP 请求结构、版本别名、工具配置格式、框架启动参数 | 插件自身或成熟上游 | + +旧规则“结果要被 IDE 理解就是领域能力”过宽。正确判断应是:宿主理解的是哪个稳定能力的语义?理解编辑器动作的范围和标题,不代表宿主需要理解 HTTP,更不代表它属于构建领域。 + +正确做法:HTTP 插件通过独立编辑器动作 API 绑定自己的函数;宿主只管理显示、明确点击、版本、取消和所有权。不要这样做:新增 `http`/`sql` 运行种类,让 Build Provider 报一个假任务,或者向通用命令通知加入专用 `runMarker` 对象。 + +### 共享机制,不共享不相干的业务模型 + +身份、权限、会话、能力发现、请求 ID、超时、取消、过期结果隔离和清理可以复用。不另建每工具一个进程/RPC 框架;也不因为复用了名字带 `lsp` 的内部传输实现,就要求普通后端提供语言智能。 + +插件参与已有原生领域时必须使用该领域的统一模型。例如向原生 Build 窗口提供工程模型不能改走私有函数逃过 Core 解码。独立 API 同样需要可校验的输入输出;“独立”不等于任意 JSON 直接进入界面或跨插件调用。 + +### 新工具接入不修改宿主的工具知识 + +工具定义负责可执行入口、探测和环境;提供方返回安装与作用域事实;使用方引用工具 ID。工具名、框架名、解析格式、任务用途之间的关联必须显式声明,不靠文件名巧合、魔法字符串或固定私有 ID。 + +配置格式及其优先级由工具/插件负责;宿主只管理已确认来源、版本冲突、安全写入和回读。声明的观察输入变化后撤销相关快照,不能保留一部分来自旧输入的“有效”结果。权限描述必须对应实际影响,发现运行时不能暗中获得替换任意宿主命令的权力。 + +### 版本演进不机械扩大 + +先复用已有 API,新增能力优先用已发布协议允许的能力发现和可选扩展。旧宿主可忽略的可选展示允许降级;不可忽略的启动、安全或必需行为需要明确准入。清单新增资源字段仍按该清单能力版本管理,不放宽严格解码来伪装兼容。所有公共变化照常更新契约、消费者和官方 SDK 验证,独立 API 也不例外。 + +## 考虑过的备选方案 + +- 全部经 LSP/Build/Debug 接入:现成模型多,但普通工具被迫提供假领域对象,宿主逐渐堆积专属分支,拒绝。 +- 全部经无类型函数:接口少,却让宿主无法统一理解真正的构建、测试或编辑器贡献,绕过校验,拒绝。 +- 每种工具单开一套协议:短期直接,但复制生命周期并让新增工具依赖宿主发版,拒绝。 +- 用受限独立扩展点绑定既有函数:宿主只定义自己需要理解的数据,插件保留业务自由度,采用。 + +## 后果 + +收益是独立工具不会污染领域模块,真正参与领域工作流的插件仍共享一个模型;新增同类工具只改插件。代价是实现者必须先说明契约所有者、调用方向和第二个使用场景,而不是沿最方便的模块塞字段。已有历史特判不在一次功能 PR 中无界清理;新代码不能继续扩大,必要迁移记录退出条件。 + +## 验证 + +- `./scripts/agent-notes/verify-agent-notes.sh`:决策引用、路径和笔记结构。 +- `./scripts/architecture/verify-service-boundaries.sh`:职责边界;不能替代人工审查工具专属分支。 +- `./scripts/architecture/verify-shared-contracts.sh`:正式契约与 fixture 一致性。 +- 审查时用两个语义不同的工具走同一扩展点,检查是否需要新增工具名枚举、宿主解析器或固定调用 ID;运行期验证继续遵循所属能力的验收和矩阵要求。 + +## 适用范围 + +- `.agents/skills/develop-lithe/SKILL.md` +- `.agents/skills/lithe-code-review/` +- `shared/contracts/plugins/v1/` +- `rust/lithe-core/src/plugins/` +- `macos/Sources/Lithe/Application/Features/` +- `macos/Sources/Lithe/Platform/MacOS/Plugins/` diff --git a/.agents/notes/implemented/architecture/2026-10-31-plugin-editor-extension-apis.md b/.agents/notes/implemented/architecture/2026-10-31-plugin-editor-extension-apis.md new file mode 100644 index 0000000000..8f3a455a16 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-31-plugin-editor-extension-apis.md @@ -0,0 +1,83 @@ +# Agent 笔记:独立的插件高亮与编辑器动作 API + +状态:已实现 + +## 先说结论 + +普通工具插件可以独立提供文本高亮、编辑器配置和位置动作,不要求实现 LSP(语言服务器协议)、构建、运行或调试提供者。静态资源使用 `languages:2`,位置动作通过函数协议 3 的独立宿主方法注册,再复用原有函数调用执行;没有 `runMarkers` 清单能力,也不修改 `commands/didRun`。当前接线限 macOS,运行和界面验收以功能矩阵为准,不把静态校验当作产品验收。 + +## 问题 + +HTTP 工具插件需要高亮和请求旁的按钮。原方案把按钮建模成专用运行标记,并向通用命令通知加入 `runMarker`,扩大了无关领域的兼容面。用户确认[独立能力与领域边界](2026-10-31-plugin-capability-and-domain-boundaries.md):宿主理解动作的有限展示结构,插件理解 HTTP/SQL 等业务,不能因为按钮像播放图标就归入 Run。 + +## 决策 + +### 静态高亮与后端业务分离 + +语法和注释/括号配置属于安装包内只读资源,通过独立编辑器模块启用;不要求语言服务器或后端。仅配置贡献也应用,编辑器模块不抢占已有语言的运行、调试和服务器所有权。纯静态资源可安装、停用和卸载即时生效,带后端的插件继续遵循既有启动登记规则。没有 UI 或领域模块的独立后端派生应用级启用模块,不能为通过 catalog 检查伪造网页;启动仍检查明确启用、安全模式和隔离状态。 + +复用已固定版本的 Monaco Monarch 引擎,不在 Core 重写词法分析。Core 校验资源大小、状态和规则,并拒绝 include 环及展开后超过预算的图;浏览器中的专用 worker 才编译和执行插件正则,主线程只接受 token。worker 超过本地执行期限时终止,该语言退为普通文本,不影响编辑与保存。现有 TextMate/Oniguruma 已打包,不以“需要新增 WASM”作为否决理由;首版选择 Monarch 是局部数据适配,不改变 Java 的 TextMate 工作流。 + +Monarch 的 `cases` 按声明顺序执行,不能把语法当成无序字典重建。Core 校验后返回原始 JSON 文本,Swift 和浏览器主线程只传递文本,worker 才解析并在缺少 `start` 时显式使用 `root`。仅贡献配置的 `properties`、`bash` 等语言保留独立文档身份,但借用既有 `ini`、`shell` tokenizer;撤回配置只释放借用登记,不销毁上游引擎。 + +### 编辑器动作是小型独立契约 + +后端先注册普通函数,再通过 `editor/registerActions` 注册上下文、文件选择器和收集函数。宿主返回既有 subscription,由会话、上下文或显式释放共同清理。注册时只记录绑定,下一事件轮才请求解析,避免后端等待宿主注册答复、宿主又等待同一后端的循环。 + +宿主向收集函数提供公开文档身份、内容版本和当前脏缓冲。返回范围、标题、受限图标和同插件函数绑定;Core 只校验结构和 UTF-16 边界,不解释请求、查询、框架或任务 ID。多个同一行动作进入同一菜单,不制造测试种类或覆盖为只剩一个动作。 + +正确做法:HTTP 的“发送请求”和 SQL 的“执行查询”都返回同一展示结构,分别绑定自己已有的普通函数。不要这样做:新增 `http`/`sql` 执行类型、把请求包成假 Build 任务,或者要求工具插件提供 LSP CodeLens。 + +点击时浏览器只回传宿主生成的批次和动作键,不能提供函数或业务参数。宿主重新验证内容版本、上下文、注册租约与会话函数,再用已有 invocation 调用;插件收到文档身份、预期版本和自己的不透明数据,执行前核对当前文本。命令通知保持原样,命令面板可调用同一插件业务实现。 + +### 生命周期与兼容 + +动作刷新只确认已调度,不能等待插件返回后才结束编辑器桥接调用。整批收集共享八秒期限,各注册只拿剩余预算;失败不进入编辑、保存同步的致命错误路径。模块状态观察者弱引用运行时,避免事件源持有回调、回调又持有事件源,导致关闭项目后模块图不能释放。 + +编辑立即撤销旧动作,取消旧收集任务,防抖后再请求;关闭、替换和失效结果均不得恢复旧按钮。解析和用户执行分开管理,后者不因重新解析被误取消;撤销注册、关闭上下文或会话结束时取消所属执行,不自动重试 HTTP 等副作用。 + +新宿主方法在已支持增量能力发现的函数协议 3 内新增,不提高整套协议或新增领域版本。新增 `editor.actions` 权限由安装审查明确确认,旧宿主不认识时拒绝安装;不能一边要求新权限、一边宣称同一包能在旧宿主无条件降级。静态清单新增资源仍要求 `languages:2`;契约、fixture 与两个 SDK 的验证和合并后镜像同步不能省略。 + +第一版使用既有 `onWorkspaceOpen` 启动,接受其后台成本。不会通过尚未存在的注册信息发现一个未启动的后端,也不假称已支持按文件懒启动或后端热安装。 + +### 资源生命周期 + +grammar 从只读插件包有界读取,校验结果仅在本次进程内缓存;编译后的状态与文档 token 属于一个 worker,终止/停用时释放,不落盘。worker 代码复用本工作树生成的 `textmate-worker.js`,应用运行时只读;编辑器动作和租约也仅在内存。没有 bundle、安装资源或跨工作树缓存写入,不影响签名及 Sparkle 增量基线。 + +## 考虑过的备选方案 + +- 要求工具插件提供 LSP:标准化但无必要地强加语言服务生命周期,拒绝。 +- 专用 `runMarkers` 和 `didRun.runMarker`:把展示和执行耦合,新增一种动作就扩张执行通知,拒绝。 +- 函数名约定或插件名分支:宿主猜私有协议且无法安全撤回,拒绝。 +- 首版做任意样式装饰/万能工具工作流:超出当前需求;仅开放范围、标题、图标与函数绑定。 +- 直接在主线程运行语法:长度限额不能阻止病态回溯,采用可终止 worker。 + +## 后果 + +收益是工具插件不污染领域模型,执行复用现成函数基础设施。代价是增加一个明确授权的独立编辑器贡献契约,需要跨语言校验与生命周期接线。首版不支持远程、只读预览动作,也不承诺任意大型文档的插件高亮;Windows 产品接入和完整界面验收仍需后续完成。 + +### API-25 当前不是通用 Provider 注册框架 + +当前高亮入口只接受插件清单和包内 JSON 资源;纯静态插件的安装、更新与撤回即时生效,不代表后端可以在运行中直接传 JSON 注册、替换或释放高亮 Provider(能力提供者)。已安装包仍只读,也没有任意语法文件修改后自动重载的承诺。 + +独立补全、悬浮、定义、符号、格式化、重命名和 Code Action 尚无 JSON 到普通插件函数的注册绑定;既有 LSP 实现和独立 `editor/registerActions` 都不能冒充这些接口。新增符合既有 Monarch 结构的文本格式只改插件,不因规则变化升级 API;新增引擎或能力种类则仍需宿主适配与契约,不能靠未知 `kind` 自动获得实现。 + +因此 API-25 只完成静态语法与编辑配置这一部分。当前 worker 对超过 128 Ki UTF-16 单位或 10,000 行的文档保持普通文本,嵌入语言也按普通文本处理;SDK 兼容性测试通过不等于新增能力的封装、发布及真实插件安装验收已经完成。 + +## 验证 + +- `cargo test --manifest-path rust/Cargo.toml -p lithe-core --lib plugin_editor_resources`:静态资源、配置及 include 预算。 +- `cargo test --manifest-path rust/Cargo.toml -p lithe-core --lib plugins::editor_actions`:HTTP/SQL 同构动作、范围、重复身份及不透明数据。 +- `bun test frontend/editor/src`:活跃文件匹配、别名身份、配置独立应用与撤回。 +- `scripts/macos/test/test-macos.sh --filter 'PluginEditorLanguagesTests|PluginEditorActionTests'`:真实 catalog 接纳、独立模块与动作注册清理。 +- `scripts/macos/verify/verify-runtime-bundle-immutability.sh`:发行路径只读静态边界。 + +## 适用范围 + +- `rust/lithe-core/src/plugins/grammar.rs` +- `rust/lithe-core/src/plugins/editor_actions.rs` +- `macos/Sources/Lithe/Application/Features/PluginEditorActionHostService.swift` +- `macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginEditorLanguages.swift` +- `frontend/editor/src/plugin-languages.ts` +- `frontend/editor/src/plugin-editor-actions.ts` +- `frontend/editor/src/plugin-grammar-tokenizer.ts` diff --git a/.agents/skills/develop-lithe/SKILL.md b/.agents/skills/develop-lithe/SKILL.md index d7ca426865..8e6488fefe 100644 --- a/.agents/skills/develop-lithe/SKILL.md +++ b/.agents/skills/develop-lithe/SKILL.md @@ -49,6 +49,28 @@ description: 在 Lithe 仓库进行实现、重构、审查、调试、测试、 局部复用也必须保留上游子系统的正确性边界。验证完整的用户流程, 例如“保存 -> 同步项目状态 -> 构建 -> 解析运行路径 -> 启动”,不能只验证单条上游命令被调用。 +### 插件能力:同领域统一模型,不把独立能力硬塞进领域 + +新增插件 API 或迁移内置工具前,先读 +`.agents/notes/implemented/architecture/2026-10-31-plugin-capability-and-domain-boundaries.md`。 + +- 领域契约用于统一同一领域的事实和工作流,不是插件的强制入口。只有确实参与原生 + Build、Run、Test、LSP 或 Debug 工作流时才使用对应模型;文本高亮、文档读写、 + 编辑器动作和插件自有界面是独立扩展能力。HTTP 请求旁的播放图标不等于 Run Provider。 +- 独立扩展点有自己的最小类型化契约、权限和生命周期,复用已有函数调用与取消等基础设施; + 不把宿主需要理解的数据伪装成无类型私有函数,也不因宿主理解展示数据就强塞进 Build/LSP。 +- 先盘点已有公共 API;缺口按真实能力边界扩展,并优先使用已有运行期协商和能力发现。 + 增量兼容字段不机械升级整套协议,但必须同步契约、消费者和 SDK 验证;不能谎称零 API 改动。 +- 工具或运行时专属的解析、版本别名、框架参数、配置格式和业务选择归插件或成熟上游。 + 宿主/Core 不新增按工具名、工具私有配置格式、私有错误码、固定任务 ID 或约定函数名的分支。 + 通用模型保留不透明 ID,能力关联靠显式声明,不靠名称猜测;不能只把特判改名成封闭枚举。 +- “声明式”要经第二个不同工具验证:已有扩展点能否只改插件就接入?不能时先查是否泄漏 + 插件语义,不以堆叠目录/格式解析器冒充通用性。工具定义、事实提供方和使用方应分清所有权。 +- 原生设置读写不限定某种配置格式;插件负责格式语义,宿主保留确认、版本冲突和安全提交边界。 + 插件专属配置通过插件管理页注入;公共展示外壳不是固定的业务表单。监听对象由声明提供, + 宿主可统一观察和使查询快照失效,但不复制工具的配置发现或优先级语义。 +- 一次性兼容迁移必须标明适用版本、退出条件和跟踪位置,不得被新生产路径当作业务回退复用。 + ### 目录归属 | 路径 | 职责 | diff --git a/.agents/skills/lithe-code-review/references/review-policy.md b/.agents/skills/lithe-code-review/references/review-policy.md index 3c301e0656..94dfd465e0 100644 --- a/.agents/skills/lithe-code-review/references/review-policy.md +++ b/.agents/skills/lithe-code-review/references/review-policy.md @@ -36,6 +36,11 @@ ## 成熟能力复用审查 +插件 API 和迁移审查同时检查 `develop-lithe` 的“插件能力”规则:领域模型统一不等于所有插件必须 +经 LSP/Build/Debug。独立高亮、文档和编辑器动作有自己的小型契约,复用基础设施但不继承无关 +领域语义;真正的构建模型等也不能借私有函数绕过领域校验。用第二个不同工具检查通用性, +重点查宿主新增的工具/运行时/格式枚举、固定任务 ID、名字猜测、配置解析器及无退出条件的迁移回退。 + 当改动新增或扩大语言智能、项目导入、构建模型、依赖解析、编译、 格式化、重构、测试发现、运行/调试计划或 LSP/DAP 行为时,必须检查: diff --git a/docs/ci-builds.md b/docs/ci-builds.md index 6a55479eaa..8dd0acb090 100644 --- a/docs/ci-builds.md +++ b/docs/ci-builds.md @@ -715,6 +715,7 @@ Inter 4.1 的 18 个静态 OTF(内部版本 4.001)及许可、JetBrains Mono 版本绑定,没有可靠构建身份 stamp;各工作树构建时一起生成 JS 并复制许可证,不在任何阶段 跨树复制。运行时只读,不改变签名和 Sparkle delta。官方插件等尚未写入构建身份 stamp 的 生成资源同样不得复用; +- 插件高亮沿用本工作树生成的 `textmate-worker.js`,不新增下载入口;解码语法、worker/token 状态和编辑器动作租约仅在进程内,属于 `editor-theme-assets` 排除路由,无 identity stamp,任何阶段不得跨工作树复制。源插件包只读,运行时不写安装目录或改变 Sparkle 基线。 - `.build` 中的 SwiftPM 构建状态; - `rust/target/` 中与当前源码、编译器或构建参数绑定的构建输出; - LSP workspace `-data`、运行时数据库、测试报告和其他会被进程修改的状态。 diff --git a/docs/development/macos-plugin-api-plan.md b/docs/development/macos-plugin-api-plan.md index 0245ef5aa0..e2cc45715c 100644 --- a/docs/development/macos-plugin-api-plan.md +++ b/docs/development/macos-plugin-api-plan.md @@ -47,11 +47,11 @@ Windows 不在本轮,平台矩阵的 partial/pending 保持真实。 | API-22 | 部分完成 | 既有函数进度与取消;独立 output channel、通用 log/progress API 未做 | | API-23 | 部分完成 | 已有进程声明与运行时解析;通用 toolchains/process 服务未开放 | | API-24 | 未做 | 插件专属终端 create/sendText/onExit 未开放 | -| API-25 | 既有能力 | 语言/LSP 贡献与成熟上游链路;本 PR 不新增语言语义 Provider | +| API-25 | 部分完成 | 既有语言/LSP 贡献;新增 `languages:2` 的清单与包内 JSON 语法/编辑配置,仅支持 Monarch;运行期传入 JSON 动态注册/替换高亮 Provider、独立语言语义 Provider 未做,详见下方范围说明 | | API-26 | 未做 | 公共诊断 collection/events API 未开放;既有 LSP 诊断不等于该 API | -| API-27 | 未做 | 通用主题、片段、装饰贡献未实现;既有 UI 图标不等价于完整主题 API | +| API-27 | 部分完成 | 独立 `editor/registerActions` 绑定普通函数与文档范围,不依赖 LSP/Build/Run;通用主题、片段、其他装饰未做 | | API-28 | 未做 | 公共 custom editor 文档 owner/保存/撤销/恢复未开放 | -| API-29 | 既有能力 | #1517 BuildProvider 1–6/model inputs;本轮旧 SDK E2E 兼容,不代表新指南齐全 | +| API-29 | 部分完成 | #1517 BuildProvider 1–6/model inputs;普通编辑器动作不属于本领域项;新指南未齐全 | | API-30 | 既有能力 | 已有 debugAdapters 1/2 与 DAP 宿主;本 PR 不重建调试协议 | | API-31 | 既有能力 | 已有 vcsProviders:1;不包含内置 Git 公共读取面 | | API-32 | 未做 | 通用 ServiceProvider 仍为提案 | @@ -68,6 +68,29 @@ Windows 不在本轮,平台矩阵的 partial/pending 保持真实。 | API-43 | 未做 | 保存前事件/带超时编辑尚未开放 | | API-44 | 未做 | 插件贡献 Skills/Hooks/MCP 候选及独立授权评审未完成 | +### API-25:JSON 配置的支持范围与局限 + +当前新增的是**静态编辑器资源贡献**,不是任意 Provider(能力提供者)的运行期注册框架。 + +| 场景 | 当前支持范围 | +| --- | --- | +| 新增文本格式或高亮规则 | 通过插件清单 `languages[]` 声明文件类型,`grammar.path` 引用包内 JSON 语法;宿主校验后由 worker 执行,不需要 LSP 或后端 | +| 注释、括号、自动配对 | 可仅贡献 `configuration`,不要求同时提供 grammar | +| 安装、更新、停用或卸载纯静态贡献 | 随已验证插件包的生命周期更新编辑器;这不等于插件可以调用动态 Provider 注册方法,也不授权修改已安装包或监听任意 JSON 文件自动重载 | +| 运行中直接提交 JSON,注册、替换或释放高亮 Provider | 尚未开放公共方法;当前不是接收任意内联 grammar 的注册入口 | +| 用 JSON 将独立补全、悬浮、定义、符号、格式化、重命名或 Code Action 绑定到普通插件函数 | 尚未开放;既有 LSP 能力不等于已有这些独立 Provider 接口,`editor/registerActions` 也不提供它们 | +| 切换语法引擎或新增宿主不认识的能力种类 | `grammar.kind` 当前仅接受 `monarch`;TextMate、tree-sitter 等新引擎及新能力种类仍需宿主适配和明确契约,不能只增加一个 JSON `kind` 值 | + +新增一个使用**已支持结构**的语言实例、语法规则或编辑配置,只改插件内容,不因此升级宿主 API; +只有使用当前新增静态字段时才要求已有的 `languages:2`。不要把“新增 Provider 实例”与 +“新增宿主能力种类”混为一谈,也不要据此把整个 API-25 标记为完成。 + +本项目前只接线 macOS;插件高亮超过 128 Ki UTF-16 单位或 10,000 行时保持普通文本, +嵌入语言在该 worker 适配器中按普通文本处理。Windows 接入、SDK 新能力封装与发布、 +打包后安装真实插件的完整验收仍未完成,功能矩阵保持 partial/pending。 +决策边界见[独立插件编辑器 API](../../.agents/notes/implemented/architecture/2026-10-31-plugin-editor-extension-apis.md), +正式资源契约见[语法与编辑配置](../../shared/contracts/plugins/v1/README.md#grammars-and-editor-configuration-languages-2)。 + ### 当前公共调用面汇总 所有宿主请求以 `lithe/host/` 为前缀;这里的点号是阅读简写,正式方法使用 `/`。 @@ -206,9 +229,9 @@ Mac 当前声明的能力版本为:`backend:1`、`buildModelInputs:1`、`build | API-22 H/E | `output.createChannel/append/dispose`、`log`、`progress.withCancellation`;已有函数进度与输出生命周期 [S2/S11] | 插件/操作归属、日志脱敏与字节预算;取消/超时后不再更新 UI。只复用已有进度状态机,不把 stdout 协议流与业务日志混用 | M1:命令结果/进度;M3:独立输出 | | API-23 H | `toolchains.resolve/list`、受管 `process.run/cancel`;已有 executable/runtime 解析与进程链路 [S1/S11] | 显式执行授权,executable/argv 分离,cwd/环境白名单、输出和超时上限、进程树清理;不提供任意 shell 字符串,也不自动下载运行时 | M4 | | API-24 H/E | `terminal.create/show/sendText/onExit`;已有终端模块 [S12] | 创建与写终端都按执行授权处理;只控制自身终端,不能读用户其他会话或注入命令。禁用释放插件拥有资源,不停止用户独立终端 | 后续 | -| API-25 C/P | 语言/语法/LSP、补全/悬浮/定义/符号/格式化/重命名/Code Action;已有语言服务器贡献 [S1/S13] | 优先接成熟 LSP 的完整能力;自定义 Provider 仅补上游确实缺失项,按文档版本取消旧请求。避免让 SDK 成为第二套语言/工程引擎 | M4:已支持能力文档;后续:新 Provider | +| API-25 C/P | 语言/语法/LSP、补全/悬浮/定义/符号/格式化/重命名/Code Action;已有语言服务器贡献 [S1/S13] | 优先接成熟 LSP 的完整能力;自定义 Provider 仅补上游确实缺失项,按文档版本取消旧请求。避免让 SDK 成为第二套语言/工程引擎 | M4:已支持能力文档;`languages:2` 声明式语法已实现(见 Agent Note 2026-10-31-plugin-editor-extension-apis);后续:新 Provider | | API-26 H/P/E | `diagnostics.createCollection/set/delete/onDidChange`;已有 Problems/诊断 owner [S13] | 只能写本插件 collection,带文档版本和稳定范围;编辑/卸载后旧诊断可撤回。不能覆盖其他 LSP 结果或伪造宿主诊断身份 | M4 | -| API-27 C/H/P | 主题/图标/片段、只读装饰、CodeLens/Inlay;已有内部 theme/editor 能力 [S5/S16] | 数据贡献/资源路径校验、预算与卸载;图标/主题用宿主 token,不允许 CSS/原生视图注入。语言信息优先走 LSP,UI 资源不得执行脚本 | 后续 | +| API-27 C/H/P | 主题/图标/片段、只读装饰、CodeLens/Inlay;已有内部 theme/editor 能力 [S5/S16] | 数据贡献/资源路径校验、预算与卸载;图标/主题用宿主 token,不允许 CSS/原生视图注入。语言信息优先走 LSP,UI 资源不得执行脚本 | 独立编辑器动作 API 已接线(Mac,待运行验收;复用 backend 2/函数协议 3,不升领域版本);主题/片段/其他装饰后续 | | API-28 C/P/H | `customEditors.register`、打开/保存/撤销/恢复;已有特殊文档视图不等于公开扩展点 [S5/S9] | 先说明唯一文档 owner 和 readonly/dirty/backup/undo 语义;可选 Webview 仅做视图。崩溃后可恢复且不丢未保存内容才可发布 | 后续 | 设置分类遵循[macOS 设置层级](../../.agents/notes/implemented/feature/2026-10-15-macos-settings-hierarchy.md),原生配置编辑遵循[真实来源决策](../../.agents/notes/implemented/architecture/2026-10-25-settings-native-configuration-editing.md)。插件提供的文本/上游诊断不伪装成宿主翻译;宿主新增按钮、权限说明和错误必须走 `shared/i18n/`。 @@ -217,7 +240,7 @@ Mac 当前声明的能力版本为:`backend:1`、`buildModelInputs:1`、`build | ID / 方向 | 候选能力与现状/所有者 | 权限、作用域与验收 | 阶段 | | --- | --- | --- | --- | -| API-29 P/C | Build/Task/Test/Run Preparation、只读工程模型输入;#1517 已合并,Mac 已声明 `buildProviders:1–6`、`buildModelInputs:1`、`debugAdapters:2` [S1/S11] | 本项只补文档、SDK 对齐和真实项目端到端验收,不重复定义协议。原生 Build/Test/Run 展示、准备、取消共用宿主;快照带源/版本,不能成为第二份 classpath/工程真相;远程工作区下的 Provider 启动限制见 API-40 | M4 | +| API-29 P/C | Build/Task/Test/Run Preparation、只读工程模型输入;#1517 已合并,Mac 已声明 `buildProviders:1–6`、`buildModelInputs:1`、`debugAdapters:2` [S1/S11] | 本项只补文档、SDK 对齐和真实项目端到端验收,不重复定义协议。原生 Build/Test/Run 展示、准备、取消共用宿主;快照带源/版本,不能成为第二份 classpath/工程真相;远程工作区下的 Provider 启动限制见 API-40。编辑器动作是独立扩展点,不计入领域执行。 | M4 | | API-30 P/C/E | Debug Adapter 注册、配置和会话事件;已有 DAP 宿主 [S14] | 进程/连接授权、launch 与 attach 所有权、终止和断开区别;复用断点/调试 UI。高级内存/反汇编按独立提案,不阻塞基础 API | M4 | | API-31 P/C | VCS 仓库/状态/diff/提交等领域能力;已有 vcsProviders 1 [S14],只覆盖插件提供的 VCS,内置 Git 见 API-42 | 读取与修改仓库分权;危险操作有明确确认,结果有仓库/操作身份。插件私有函数不能代替宿主可理解的 VCS 协议 | M4 | | API-32 P/C/E | Services 的实例/状态/动作/日志;数据库已有自身路径,通用 ServiceProvider 仍为提案 [S17] | 插件拥有的连接/进程与用户外部服务分开;停用只清理前者,不默认停用户数据库/容器。服务接入现有 Services,不再画一套工作台 | 后续 | diff --git a/frontend/editor/src/language-contributions.ts b/frontend/editor/src/language-contributions.ts index ae4ad0ba79..297b258a9f 100644 --- a/frontend/editor/src/language-contributions.ts +++ b/frontend/editor/src/language-contributions.ts @@ -35,6 +35,7 @@ import "monaco-editor/esm/vs/basic-languages/typescript/typescript.contribution. import "monaco-editor/esm/vs/basic-languages/xml/xml.contribution.js"; import "monaco-editor/esm/vs/basic-languages/yaml/yaml.contribution.js"; import { zigMonarchLanguage } from "./zig-language"; +import { hasPluginGrammar } from "./plugin-languages"; type MonarchLanguageModule = { conf: Parameters[1]; @@ -83,6 +84,8 @@ const monarchLanguageLoaders: Record Promise>(); export function ensureMonacoLanguageTokenizer(languageId: string): Promise { + // A plugin grammar replaces the built-in one for its language. + if (hasPluginGrammar(languageId)) return Promise.resolve(true); if (languageId === "json") { const existing = monarchLanguagePromises.get(languageId); if (existing) return existing; diff --git a/frontend/editor/src/monaco-tokenization-internals.d.ts b/frontend/editor/src/monaco-tokenization-internals.d.ts index 4c05931266..18df5623e9 100644 --- a/frontend/editor/src/monaco-tokenization-internals.d.ts +++ b/frontend/editor/src/monaco-tokenization-internals.d.ts @@ -1,5 +1,13 @@ // Narrow declarations for the private tokenization APIs in pinned Monaco 0.55.1. // Keep these with the shared adapter so both platform builds check the same boundary. +declare module "monaco-editor/esm/vs/editor/standalone/common/monarch/monarchCompile.js" { + export function compile(languageID: string, grammar: unknown): unknown; +} +declare module "monaco-editor/esm/vs/editor/standalone/common/monarch/monarchLexer.js" { + export class MonarchTokenizer { + constructor(language: unknown, theme: unknown, languageID: string, lexer: unknown, configuration: unknown); + } +} declare module "monaco-editor/esm/vs/editor/common/languages.js" { import type { IDisposable, editor, languages } from "monaco-editor"; export class Token { @@ -24,6 +32,7 @@ declare module "monaco-editor/esm/vs/editor/common/languages.js" { backgroundTokenizationFinished(): void; } export const TokenizationRegistry: { + get(language: string): any; handleChange(languages: string[]): void; register(language: string, support: { getInitialState(): languages.IState; diff --git a/frontend/editor/src/plugin-editor-actions.ts b/frontend/editor/src/plugin-editor-actions.ts new file mode 100644 index 0000000000..9d6772876f --- /dev/null +++ b/frontend/editor/src/plugin-editor-actions.ts @@ -0,0 +1,107 @@ +import * as monaco from "monaco-editor/esm/vs/editor/editor.api.js"; +import { Action } from "monaco-editor/esm/vs/base/common/actions.js"; +import { StandaloneServices } from "monaco-editor/esm/vs/editor/standalone/browser/standaloneServices.js"; +import { IContextMenuService } from "monaco-editor/esm/vs/platform/contextview/browser/contextView.js"; + +type Entry = { + model: monaco.editor.ITextModel; revision: number; chain: Promise; + readonly: boolean; frozen: boolean; closingHolds?: number; +}; +type EditorAction = { id: string; line: number; endLine: number; title: string; icon: string }; +type State = { + generation: number; version: number; revision: number; batch?: string; + actions: EditorAction[]; decorations: string[]; timer?: ReturnType; +}; + +/** Presentation only: opaque action keys route back to a versioned host list. */ +export function attachPluginEditorActions(host: { + entries(): Map; + editors(): monaco.editor.IStandaloneCodeEditor[]; + request(payload: object): Promise; + current(id: string, entry: E): () => boolean; + lineNumbersFirst: boolean; +}) { + const states = new Map(); + let disposed = false; + const get = (entry: E): State => { + const found = states.get(entry); + if (found) return found; + const state: State = { generation: 0, version: 0, revision: 0, actions: [], decorations: [] }; + states.set(entry, state); + entry.model.onWillDispose(() => { clearTimeout(state.timer); state.generation++; states.delete(entry); }); + return state; + }; + async function refreshOne(id: string, entry: E) { + const state = get(entry), generation = ++state.generation, version = entry.model.getVersionId(); + clearTimeout(state.timer); + await entry.chain; + const current = host.current(id, entry), revision = entry.revision; + if (disposed || entry.model.isDisposed() || !current() || entry.readonly) return; + const result = await host.request({ type: "pluginEditorActions", id, revision }); + if (disposed || !current() || state.generation !== generation || result.cancelled || entry.model.getVersionId() !== version) return; + const actions: EditorAction[] = (Array.isArray(result.actions) ? result.actions : []).filter((item: EditorAction) => + typeof item?.id === "string" && typeof item.title === "string" && + Number.isInteger(item.line) && item.line >= 1 && item.line <= entry.model.getLineCount()); + // Multiple actions on one line share a popup rather than overwriting one another. + const lines = [...new Map(actions.map(item => [item.line, item])).values()]; + state.decorations = entry.model.deltaDecorations(state.decorations, lines.map(item => ({ + range: new monaco.Range(item.line, 1, item.line, 1), options: { + glyphMarginClassName: `lithe-plugin-editor-action lithe-java-run-${item.icon}`, + linesDecorationsClassName: host.lineNumbersFirst ? `lithe-plugin-editor-action lithe-java-run-${item.icon}` : undefined, + linesDecorationsTooltip: item.title, + glyphMargin: { position: monaco.editor.GlyphMarginLane.Right }, + glyphMarginHoverMessage: { value: item.title.replace(/[\\`*_{}\[\]()#+.!<>|]/g, "\\$&"), isTrusted: false }, + }, + }))); + Object.assign(state, { version, revision, batch: result.batch, actions }); + } + function schedule(id: string, entry: E) { + const state = get(entry); + clearTimeout(state.timer); state.generation++; state.actions = []; state.batch = undefined; + if (!entry.model.isDisposed()) state.decorations = entry.model.deltaDecorations(state.decorations, []); + state.timer = setTimeout(() => { void refreshOne(id, entry).catch(console.error); }, 180); + } + return { + schedule, + async refresh() { + for (const [entry, state] of states) { + clearTimeout(state.timer); state.generation++; state.actions = []; state.batch = undefined; + if (!entry.model.isDisposed()) state.decorations = entry.model.deltaDecorations(state.decorations, []); + } + await Promise.all([...host.entries()].filter(([, entry]) => host.editors().some(view => view.getModel() === entry.model)) + .map(([id, entry]) => refreshOne(id, entry))); + }, + attach(view: monaco.editor.IStandaloneCodeEditor) { + const mouse = view.onMouseDown(event => { + const element = event.target.element?.closest(".lithe-plugin-editor-action"); + const pair = [...host.entries()].find(([, entry]) => entry.model === view.getModel()); + if (!element || !pair || !event.target.position || !event.event.leftButton) return; + event.event.preventDefault(); event.event.stopPropagation(); + const [id, entry] = pair, state = states.get(entry), line = event.target.position.lineNumber; + const current = host.current(id, entry); + if (!state || !current() || entry.readonly || entry.frozen || entry.closingHolds || + state.version !== entry.model.getVersionId() || state.revision !== entry.revision) return; + const batch = state.batch; + const items = state.actions.filter(item => item.line === line).map(item => new Action( + `lithe.pluginEditor.${item.id}`, item.title, undefined, true, async () => { + if (!current() || states.get(entry)?.batch !== batch || entry.readonly) return; + await host.request({ type: "pluginEditorAction", id, revision: state.revision, batch, action: item.id }); + })); + if (items.length) StandaloneServices.get(IContextMenuService).showContextMenu({ + getAnchor: () => ({ x: event.event.posx, y: event.event.posy }), getActions: () => items, + }); + }); + const mouseUp = (event: MouseEvent) => { + if ((event.target as Element | null)?.closest(".lithe-plugin-editor-action") && view.getDomNode()?.contains(event.target as Node)) + event.stopPropagation(); + }; + document.addEventListener("mouseup", mouseUp, true); + view.onDidDispose(() => { mouse.dispose(); document.removeEventListener("mouseup", mouseUp, true); }); + }, + dispose() { + disposed = true; + for (const state of states.values()) { clearTimeout(state.timer); state.generation++; } + states.clear(); + }, + }; +} diff --git a/frontend/editor/src/plugin-grammar-tokenizer.ts b/frontend/editor/src/plugin-grammar-tokenizer.ts new file mode 100644 index 0000000000..6443906934 --- /dev/null +++ b/frontend/editor/src/plugin-grammar-tokenizer.ts @@ -0,0 +1,150 @@ +/// +import type { editor, languages } from "monaco-editor"; +import { TokenizationRegistry, Token, TokenizationResult, EncodedTokenizationResult } from "monaco-editor/esm/vs/editor/common/languages.js"; +import { ContiguousMultilineTokensBuilder } from "monaco-editor/esm/vs/editor/common/tokens/contiguousMultilineTokensBuilder.js"; +import { StandaloneServices } from "monaco-editor/esm/vs/editor/standalone/browser/standaloneServices.js"; +import { IStandaloneThemeService } from "monaco-editor/esm/vs/editor/standalone/common/standaloneTheme.js"; +import { ILanguageService } from "monaco-editor/esm/vs/editor/common/languages/language.js"; + +class PlainState implements languages.IState { + clone() { return this; } + equals(other: languages.IState) { return other instanceof PlainState; } +} +const plain = new PlainState(); +let workerSource: Promise | undefined; + +/** Borrow the existing engine without owning or disposing its tokenizer. */ +export function inheritPluginTokenizer(languageID: string, alias: string) { + const support = TokenizationRegistry.get(alias); + if (!support) throw new Error(`Tokenizer unavailable for language alias ${alias}`); + const codec = StandaloneServices.get(ILanguageService).languageIdCodec; + const originalID = codec.encodeLanguageId(alias), inheritedID = codec.encodeLanguageId(languageID); + const languageMask = 0xff; // Monaco 0.55.1 MetadataConsts.LANGUAGEID_MASK. + return TokenizationRegistry.register(languageID, { + getInitialState: () => support.getInitialState(), + tokenize(line, hasEOL, state) { + const result = support.tokenize(line, hasEOL, state); + return new TokenizationResult(result.tokens.map((token: Token) => + new Token(token.offset, token.type, token.language === alias ? languageID : token.language)), result.endState); + }, + tokenizeEncoded(line, hasEOL, state) { + const result = support.tokenizeEncoded(line, hasEOL, state), tokens = result.tokens.slice(); + // Monaco reads comment/bracket configuration from token language IDs. + // Remap only the base language; genuinely embedded tokens keep theirs. + for (let i = 1; i < tokens.length; i += 2) { + if ((tokens[i] & languageMask) === originalID) tokens[i] = (tokens[i] & ~languageMask) | inheritedID; + } + return new EncodedTokenizationResult(tokens, result.endState); + }, + createBackgroundTokenizer() { return undefined; }, + }); +} + +/** Regex execution is isolated behind a terminable worker with a local deadline. */ +export function installPluginGrammar(languageID: string, source: string) { + const previous = TokenizationRegistry.get(languageID); + const theme = StandaloneServices.get(IStandaloneThemeService); + const encodedID = StandaloneServices.get(ILanguageService).languageIdCodec.encodeLanguageId(languageID); + type Binding = { model: editor.ITextModel; store: import("monaco-editor/esm/vs/editor/common/languages.js").BackgroundTokenStore; pending: boolean; debounce?: ReturnType }; + const bindings = new Map(); + let nextID = 0, worker: Worker | undefined, url: string | undefined, stopped = false, ready = false; + let deadline: ReturnType | undefined; + const stop = (error?: unknown) => { + if (stopped) return; + stopped = true; clearTimeout(deadline); worker?.terminate(); if (url) URL.revokeObjectURL(url); + for (const binding of bindings.values()) { + clearTimeout(binding.debounce); binding.pending = false; + if (error && !binding.model.isDisposed()) { + const builder = new ContiguousMultilineTokensBuilder(); + for (let line = 1; line <= binding.model.getLineCount(); line++) { + builder.add(line, new Uint32Array([binding.model.getLineLength(line), + theme.getColorTheme().tokenTheme.match(encodedID, "") | 1024])); + binding.store.setEndState(line, plain); + } + binding.store.setTokens(builder.finalize()); + } + binding.store.backgroundTokenizationFinished(); + } + if (error) console.warn("Plugin tokenizer stopped", languageID, String(error)); + }; + const watch = () => { + clearTimeout(deadline); + if (!stopped) deadline = setTimeout(() => stop("worker exceeded its execution budget"), 2000); + }; + const open = (id: number, binding: Binding) => { + if (!worker || !ready || stopped || binding.model.isDisposed()) return; + // Large buffers remain editable/plain, and cannot flood a worker queue. + if (binding.model.getValueLength() > 128 * 1024 || binding.model.getLineCount() > 10_000) { + binding.pending = false; binding.store.backgroundTokenizationFinished(); return; + } + binding.pending = true; + worker.postMessage({ type: "open", id, version: binding.model.getVersionId(), lines: binding.model.getLinesContent() }); + if (!deadline) watch(); + }; + const registration = TokenizationRegistry.register(languageID, { + getInitialState: () => plain, + tokenize(line) { return new TokenizationResult([new Token(0, "", languageID)], plain); }, + tokenizeEncoded() { return new EncodedTokenizationResult(new Uint32Array([0, theme.getColorTheme().tokenTheme.match(encodedID, "") | 1024]), plain); }, + createBackgroundTokenizer(model, store) { + const id = ++nextID, binding: Binding = { model, store, pending: false }; + bindings.set(id, binding); + const changed = model.onDidChangeContent(() => { + clearTimeout(binding.debounce); + binding.debounce = setTimeout(() => open(id, binding), 80); + }); + queueMicrotask(() => open(id, binding)); + return { + requestTokens() { if (!binding.pending) open(id, binding); }, + dispose() { + changed.dispose(); clearTimeout(binding.debounce); bindings.delete(id); + worker?.postMessage({ type: "dispose", id }); + if (![...bindings.values()].some(value => value.pending)) { clearTimeout(deadline); deadline = undefined; } + }, + }; + }, + }); + workerSource ??= fetch("textmate-worker.js").then(response => { + if (!response.ok) throw new Error("Tokenizer worker unavailable"); + return response.text(); + }); + watch(); + void workerSource.then(code => { + if (stopped) return; + url = URL.createObjectURL(new Blob([code], { type: "text/javascript" })); + worker = new Worker(url); + worker.onerror = event => stop(event.message); + worker.onmessage = ({ data }) => { + if (stopped) return; + if (data.type === "error") { stop(data.message); return; } + clearTimeout(deadline); deadline = undefined; + if (data.type === "ready") { + ready = true; + for (const [id, binding] of bindings) open(id, binding); + } else { + const binding = bindings.get(data.id); + if (binding && !binding.model.isDisposed() && binding.model.getVersionId() === data.version) { + const builder = new ContiguousMultilineTokensBuilder(); + for (const row of data.rows ?? []) { + const tokens = new Uint32Array(row.tokens.length * 2); + for (let i = 0; i < row.tokens.length; i++) { + tokens[i * 2] = row.tokens[i + 1]?.offset ?? binding.model.getLineLength(row.line); + tokens[i * 2 + 1] = theme.getColorTheme().tokenTheme.match(encodedID, row.tokens[i].type) | 1024; + } + builder.add(row.line, tokens); binding.store.setEndState(row.line, plain); + } + binding.store.setTokens(builder.finalize()); + if (data.complete) { binding.pending = false; binding.store.backgroundTokenizationFinished(); } + } else if (binding && data.complete) { binding.pending = false; } + } + if ([...bindings.values()].some(binding => binding.pending) && !deadline) watch(); + }; + worker.postMessage({ type: "pluginGrammar", languageID, source }); + }).catch(stop); + const themeChanges = theme.onDidColorThemeChange(() => { + for (const [id, binding] of bindings) open(id, binding); + }); + return { dispose() { + stop(); registration.dispose(); themeChanges.dispose(); bindings.clear(); + if (previous) TokenizationRegistry.register(languageID, previous); + } }; +} diff --git a/frontend/editor/src/plugin-grammar-worker.ts b/frontend/editor/src/plugin-grammar-worker.ts new file mode 100644 index 0000000000..6f690cc4f7 --- /dev/null +++ b/frontend/editor/src/plugin-grammar-worker.ts @@ -0,0 +1,54 @@ +import { compile } from "monaco-editor/esm/vs/editor/standalone/common/monarch/monarchCompile.js"; +import { MonarchTokenizer } from "monaco-editor/esm/vs/editor/standalone/common/monarch/monarchLexer.js"; + +/** Reuse pinned Monaco's engine, but never compile/run plugin regexes on the UI thread. */ +export function installPluginGrammarWorker() { + let tokenizer: any; + const documents = new Map(); + const queue = new MessageChannel(); + queue.port1.onmessage = ({ data: id }) => { + const document = documents.get(id); + if (!document) return; + try { + const rows = [], started = performance.now(); + while (document.cursor < document.lines.length && rows.length < 64) { + const line = document.lines[document.cursor]; + const result = tokenizer.tokenize(line, true, document.state); + document.state = result.endState; + rows.push({ line: ++document.cursor, tokens: result.tokens }); + if (performance.now() - started > 8) break; + } + const complete = document.cursor === document.lines.length; + postMessage({ type: "tokens", id, version: document.version, rows, complete }); + if (!complete) queue.port2.postMessage(id); + } catch (error) { postMessage({ type: "error", message: String(error) }); } + }; + self.onmessage = ({ data }) => { + try { + if (data.type === "init") { + // Only the worker parses the source: cases retain package order even + // after the host's Rust/Swift dictionary and JSON roundtrips. + const grammar = JSON.parse(data.source); + grammar.start ??= "root"; + // Monarch's public engine needs configuration/language services even + // for lexical-only use. Embedded languages remain plain text here. + tokenizer = new MonarchTokenizer({ + isRegisteredLanguageId: () => false, + getLanguageIdByLanguageName: () => null, getLanguageIdByMimeType: () => null, + languageIdCodec: { encodeLanguageId: () => 1 }, requestBasicLanguageFeatures() {}, + }, {}, data.languageID, compile(data.languageID, grammar), { + getValue: () => 20_000, + onDidChangeConfiguration: () => ({ dispose() {} }), + }); + postMessage({ type: "ready" }); return; + } + if (data.type === "dispose") { documents.delete(data.id); return; } + if (data.type === "open") { + const previous = documents.get(data.id); + if (previous && previous.version > data.version) return; + documents.set(data.id, { lines: data.lines, version: data.version, cursor: 0, state: tokenizer.getInitialState() }); + queue.port2.postMessage(data.id); + } + } catch (error) { postMessage({ type: "error", message: String(error) }); } + }; +} diff --git a/frontend/editor/src/plugin-languages.test.ts b/frontend/editor/src/plugin-languages.test.ts new file mode 100644 index 0000000000..ceec5a3edf --- /dev/null +++ b/frontend/editor/src/plugin-languages.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, expect, test } from "bun:test"; +import { hasPluginGrammar, pluginLanguageForFilename, setPluginLanguages as replace, toLanguageConfiguration, + type MonacoLanguages, type PluginLanguage } from "./plugin-languages"; + +const registered: { id: string }[] = []; +const live = new Map(); +let failCompile = new Set(); +const inherited: [string, string][] = []; +const provider = (id: string) => { + live.set(id, (live.get(id) ?? 0) + 1); + return { dispose: () => live.set(id, (live.get(id) ?? 0) - 1) }; +}; +const monaco = { + getLanguages: () => registered, + register: (language: { id: string }) => registered.push(language), + setLanguageConfiguration: (id: string) => provider(id), +} as unknown as MonacoLanguages; +const install = (id: string) => { + if (failCompile.has(id)) throw new Error(`cannot compile ${id}`); + return provider(id); +}; +const setPluginLanguages = (list: PluginLanguage[]) => replace(monaco, list, install, (id, alias) => { + inherited.push([id, alias]); + return provider(id); +}); +const http: PluginLanguage = { + id: "http", fileExtensions: ["http", ".rest"], + grammar: { kind: "monarch", source: '{"tokenizer":{"root":[]}}' }, + configuration: { lineComment: "#", brackets: [["{", "}"]], autoClosingPairs: [["\"", "\""]] }, +}; +beforeEach(() => { setPluginLanguages([]); failCompile = new Set(); inherited.length = 0; }); + +test("owns providers, matches only active file types and disposes on withdrawal", () => { + try { + expect(setPluginLanguages([http])).toEqual({ failed: [], removed: [] }); + expect(hasPluginGrammar("http")).toBe(true); + expect(live.get("http")).toBe(2); + expect(pluginLanguageForFilename("users.REST")).toBe("http"); + setPluginLanguages([http]); + expect(live.get("http")).toBe(2); + setPluginLanguages([]); + expect(hasPluginGrammar("http")).toBe(false); + expect(pluginLanguageForFilename("users.http")).toBeUndefined(); + expect(live.get("http")).toBe(0); + expect(registered.filter(value => value.id === "http")).toHaveLength(1); + } finally { setPluginLanguages([]); } +}); + +test("updates file matching and keeps plugin IDs distinct from tokenizer aliases", () => { + try { + setPluginLanguages([{ ...http, id: "properties", fileExtensions: ["properties"] }]); + expect(pluginLanguageForFilename("a.properties")).toBe("properties"); + setPluginLanguages([{ ...http, id: "properties", fileExtensions: ["newproperties"] }]); + expect(pluginLanguageForFilename("a.properties")).toBeUndefined(); + expect(pluginLanguageForFilename("a.newproperties")).toBe("properties"); + expect(hasPluginGrammar("ini")).toBe(false); + } finally { setPluginLanguages([]); } +}); + +test("configuration does not require a grammar or backend", () => { + try { + setPluginLanguages([{ ...http, grammar: null }]); + expect(live.get("http")).toBe(1); + expect(hasPluginGrammar("http")).toBe(false); + expect(pluginLanguageForFilename("a.http")).toBe("http"); + } finally { setPluginLanguages([]); } +}); + +test("a refused grammar does not prevent another file type from applying", () => { + try { + failCompile.add("broken"); + expect(setPluginLanguages([{ ...http, id: "broken" }, http]).failed) + .toEqual([{ id: "broken", message: "cannot compile broken" }]); + expect(hasPluginGrammar("broken")).toBe(false); + expect(hasPluginGrammar("http")).toBe(true); + } finally { setPluginLanguages([]); } +}); + +test("configuration-only aliases borrow and withdraw their existing tokenizer", () => { + try { + setPluginLanguages([{ ...http, id: "properties", fileExtensions: ["properties"], grammar: null }, + { ...http, id: "bash", fileExtensions: ["sh"], grammar: null }, + { ...http, id: "systemrdl", fileExtensions: ["rdl"], grammar: null }]); + expect(inherited).toEqual([["properties", "ini"], ["bash", "shell"]]); + expect(pluginLanguageForFilename("a.properties")).toBe("properties"); + expect(hasPluginGrammar("properties")).toBe(false); + expect(live.get("properties")).toBe(2); + expect(live.get("systemrdl")).toBe(1); + setPluginLanguages([]); + expect(live.get("properties")).toBe(0); + expect(live.get("bash")).toBe(0); + } finally { setPluginLanguages([]); } +}); + +test("maps bounded editing configuration", () => { + expect(toLanguageConfiguration(http.configuration)).toEqual({ + comments: { lineComment: "#", blockComment: undefined }, brackets: [["{", "}"]], + autoClosingPairs: [{ open: "\"", close: "\"" }], surroundingPairs: [{ open: "\"", close: "\"" }], + }); +}); diff --git a/frontend/editor/src/plugin-languages.ts b/frontend/editor/src/plugin-languages.ts new file mode 100644 index 0000000000..d04690cd8d --- /dev/null +++ b/frontend/editor/src/plugin-languages.ts @@ -0,0 +1,70 @@ +// Static file types and presentation are independent of language-server ownership. +import type { languages } from "monaco-editor/esm/vs/editor/editor.api.js"; +import { toMonacoLanguageId } from "./language"; + +export interface PluginLanguage { + id: string; + fileExtensions?: string[]; + fileNames?: string[]; + grammar?: { kind: "monarch"; source: string } | null; + configuration?: { + lineComment?: string; + blockComment?: [string, string]; + brackets?: [string, string][]; + autoClosingPairs?: [string, string][]; + } | null; +} +const owned = new Map(); +export function hasPluginGrammar(id: string): boolean { return !!owned.get(id)?.language.grammar; } +export function hasPluginLanguage(id: string): boolean { return owned.has(id); } +export function pluginLanguageForFilename(filename: string): string | undefined { + const name = filename.toLowerCase(); + const list = [...owned.values()].map(value => value.language); + return list.find(language => language.fileNames?.some(file => file.toLowerCase() === name))?.id + ?? list.flatMap(language => (language.fileExtensions ?? []).map(extension => ({ id: language.id, extension: withDot(extension).toLowerCase() }))) + .filter(value => name.endsWith(value.extension)).sort((a, b) => b.extension.length - a.extension.length)[0]?.id; +} +export function toLanguageConfiguration(configuration: PluginLanguage["configuration"]): languages.LanguageConfiguration { + const pairs = (configuration?.autoClosingPairs ?? []).map(([open, close]) => ({ open, close })); + return { + comments: configuration?.lineComment || configuration?.blockComment + ? { lineComment: configuration.lineComment, blockComment: configuration.blockComment } : undefined, + brackets: configuration?.brackets, autoClosingPairs: pairs, surroundingPairs: pairs, + }; +} +const withDot = (extension: string) => extension.startsWith(".") ? extension : `.${extension}`; +export type MonacoLanguages = Pick; + +/** The caller supplies the worker-isolated engine; this pure registry never runs plugin regexes. */ +export function setPluginLanguages(monaco: MonacoLanguages, list: readonly PluginLanguage[], + installGrammar: (id: string, source: string) => { dispose(): void } = () => ({ dispose() {} }), + inheritTokenizer: (id: string, alias: string) => { dispose(): void } = () => ({ dispose() {} }), +): { failed: { id: string; message: string }[]; removed: string[] } { + const failed: { id: string; message: string }[] = [], removed: string[] = []; + const next = new Map(list.filter(language => language?.id).map(language => [language.id, language])); + for (const [id, current] of owned) { + if (next.has(id)) continue; + current.providers.forEach(provider => provider.dispose()); owned.delete(id); removed.push(id); + } + for (const [id, language] of next) { + const key = JSON.stringify(language); + if (owned.get(id)?.key === key) continue; + owned.get(id)?.providers.forEach(provider => provider.dispose()); owned.delete(id); + // Monaco cannot unregister file associations; matching above uses only live contributions. + if (!monaco.getLanguages().some(registered => registered.id === id)) monaco.register({ id }); + const providers: { dispose(): void }[] = []; + try { + if (language.grammar) providers.push(installGrammar(id, language.grammar.source)); + else { + const alias = toMonacoLanguageId(id); + if (alias !== id && alias !== "plaintext") providers.push(inheritTokenizer(id, alias)); + } + if (language.configuration) providers.push(monaco.setLanguageConfiguration(id, toLanguageConfiguration(language.configuration))); + owned.set(id, { key, language, providers }); + } catch (error) { + providers.forEach(provider => provider.dispose()); + failed.push({ id, message: error instanceof Error ? error.message : String(error) }); + } + } + return { failed, removed }; +} diff --git a/frontend/editor/src/textmate-worker.ts b/frontend/editor/src/textmate-worker.ts index 63f2d1a526..e691ea170b 100644 --- a/frontend/editor/src/textmate-worker.ts +++ b/frontend/editor/src/textmate-worker.ts @@ -2,6 +2,7 @@ import { createJavaGrammar, INITIAL } from "./textmate-grammar"; import { tokenizeLineWithinLimit } from "./textmate-line-limit"; import { TextmateDocument } from "./textmate-document"; import { diffStateStacksRefEq } from "vscode-textmate"; +import { installPluginGrammarWorker } from "./plugin-grammar-worker"; type Document = { text: TextmateDocument; visible: boolean; timer?: ReturnType }; const documents = new Map(); @@ -39,6 +40,11 @@ function tokenize(id: number) { } self.onmessage = async ({ data }) => { try { + if (data.type === "pluginGrammar") { + installPluginGrammarWorker(); + self.onmessage!({ data: { type: "init", languageID: data.languageID, source: data.source } } as MessageEvent); + return; + } if (data.type === "init") { grammar = (await createJavaGrammar(data.wasm, data.grammar)).grammar; // vscode-textmate/Oniguruma lazily compiles grammar patterns on the first diff --git a/frontend/editor/src/workbench.ts b/frontend/editor/src/workbench.ts index a52b5fc82e..0d02ad3835 100644 --- a/frontend/editor/src/workbench.ts +++ b/frontend/editor/src/workbench.ts @@ -27,6 +27,9 @@ import { isMacintosh } from "monaco-editor/esm/vs/base/common/platform.js"; import { IContextMenuService } from "monaco-editor/esm/vs/platform/contextview/browser/contextView.js"; import { Action, Separator, type IAction } from "monaco-editor/esm/vs/base/common/actions.js"; import { normalizeRunMarkers, runMarkerForLine, runMarkerIcon, type RunMarker, type RunMarkerIcon } from "./run-markers"; +import { attachPluginEditorActions } from "./plugin-editor-actions"; +import { installPluginGrammar, inheritPluginTokenizer } from "./plugin-grammar-tokenizer"; +import { setPluginLanguages, hasPluginLanguage, pluginLanguageForFilename, type PluginLanguage } from "./plugin-languages"; const escapeHTML = (value: string) => value.replace(/[&<>"']/g, character => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[character]!); @@ -54,8 +57,12 @@ export interface WorkbenchHost { keybindings?: { command: string; label: string; keybinding: number }[]; } +const editorLanguageID = (id: string | undefined) => id && hasPluginLanguage(id) ? id : toMonacoLanguageId(id); + function languageForFilename(filename: string | undefined): string { if (!filename) return "plaintext"; + const plugin = pluginLanguageForFilename(filename); + if (plugin) return plugin; const name = filename.toLowerCase(); const registered = monaco.languages.getLanguages(); const exact = registered.find(language => language.filenames?.some(file => file.toLowerCase() === name)); @@ -213,10 +220,17 @@ export function mountWorkbench(host: WorkbenchHost) { Object.assign(state, { markers, version, revision }); for (const view of allEditors()) navigationContexts.get(view)?.(); } + const pluginActions = attachPluginEditorActions({ + entries: () => entries, editors: allEditors, request: languageRequest, + current: (id, entry) => documentCheckpoint(id, entry), + lineNumbersFirst: host.lineNumbersFirst === true, + }); // Java Run markers: the host answers which `main` methods and tests exist and // their last outcomes; the editor only draws them and routes actions back. type RunMarkerState = { generation: number; version: number; revision: number; markers: RunMarker[]; canDebug: boolean; decorations: string[]; timer?: ReturnType }; + let previousPluginLanguageIDs = new Set(); + let pluginLanguageUpdates: Promise = Promise.resolve(); const runMarkerStates = new WeakMap(); const runMarkerContexts = new WeakMap void>(); async function refreshRunMarkers(id: string, entry: Entry) { @@ -260,6 +274,7 @@ export function mountWorkbench(host: WorkbenchHost) { state.timer = setTimeout(() => { void refreshRunMarkers(id, entry).catch(console.error); }, 180); } function scheduleNavigation(id: string, entry: Entry) { + pluginActions.schedule(id, entry); scheduleRunMarkers(id, entry); if (entry.model.getLanguageId() !== "java" && !navigationStates.has(entry)) return; const state = navigationStates.get(entry); @@ -325,7 +340,7 @@ export function mountWorkbench(host: WorkbenchHost) { let entry = entries.get(payload.id); const languageID = payload.language || (entry && (payload.filename === undefined || payload.filename === entry.filename) ? entry.languageID : languageForFilename(payload.filename)); - const language = toMonacoLanguageId(languageID); + const language = editorLanguageID(languageID); if (!entry) { if (typeof payload.text !== "string") throw new Error("New model requires document text"); await ensureMonacoLanguageTokenizer(language); @@ -929,6 +944,7 @@ export function mountWorkbench(host: WorkbenchHost) { view.onDidChangeModelLanguage(() => syncDefinitionNavigation(view)); view.onDidDispose(() => { definitionNavigation.get(view)?.lifetime.dispose(); definitionNavigation.delete(view); }); attachRunInteractions(view); + pluginActions.attach(view); debugRunContexts.set(view, view.createContextKey("litheCanRunToCursor", false)); function request(type: "toggleBreakpoint" | "editBreakpoint" | "runToCursor", line: number, column = 1) { const model = view.getModel(); @@ -1032,6 +1048,46 @@ export function mountWorkbench(host: WorkbenchHost) { lastMarkdownRatio = Math.min(1, Math.max(0, payload.ratio!)); } finally { applyingMarkdownScroll = false; } }, + /** + * Replaces independent file types, configurations and tokenizers. + */ + setPluginLanguages(payload: { languages?: PluginLanguage[] }) { + pluginLanguageUpdates = pluginLanguageUpdates.then(async () => { + await activation; + const languages = Array.isArray(payload?.languages) ? payload.languages : []; + // Built-in tokenizer setup also writes configuration. Complete it before + // applying a configuration-only contribution, never after the override. + await Promise.all(languages.filter(language => !language.grammar) + .map(language => ensureMonacoLanguageTokenizer(toMonacoLanguageId(language.id)))); + const result = setPluginLanguages(monaco.languages, languages, installPluginGrammar, inheritPluginTokenizer); + // A plugin installed while its files are open: they take its language + // now, and leave it when the plugin goes. + const pluginIDs = new Set([...previousPluginLanguageIDs, ...languages.map(language => editorLanguageID(language?.id))]); + previousPluginLanguageIDs = new Set(languages.map(language => editorLanguageID(language?.id))); + for (const [id, entry] of entries) { + if (entry.filename === undefined || entry.model.isDisposed()) continue; + const languageID = languageForFilename(entry.filename), language = editorLanguageID(languageID); + if (language === entry.model.getLanguageId() && languageID === entry.languageID || !(pluginIDs.has(language) || pluginIDs.has(entry.model.getLanguageId()))) continue; + await ensureMonacoLanguageTokenizer(language); + if (entries.get(id) !== entry || entry.model.isDisposed()) continue; + entry.contextGeneration++; + entry.languageID = languageID; + monaco.editor.setModelLanguage(entry.model, language); + for (const view of allEditors()) { + if (view.getModel() === entry.model) syncDefinitionNavigation(view); + } + } + void pluginActions.refresh().catch(console.error); + return result; + }); + return pluginLanguageUpdates; + }, + async refreshPluginEditorActions() { + await activation; + // Acknowledge scheduling, not plugin completion: optional collectors + // must never hold the native save/edit bridge until its fatal deadline. + void pluginActions.refresh().catch(console.error); + }, async refreshJavaRunMarkers() { await activation; await Promise.all([...entries].filter(([, entry]) => allEditors().some(view => view.getModel() === entry.model)) @@ -1526,14 +1582,14 @@ export function mountWorkbench(host: WorkbenchHost) { .monaco-editor .lithe-java-navigation-down:after,.monaco-editor .lithe-java-navigation-both:after{content:'↓'} .monaco-editor .lithe-java-navigation-both:before,.monaco-editor .lithe-java-navigation-both:after{width:50%;max-width:12px} ${navigationIconStyles} - .monaco-editor .lithe-java-run{cursor:pointer;background-size:14px 14px;background-repeat:no-repeat;background-position:center;opacity:.9} + .monaco-editor .lithe-java-run,.monaco-editor .lithe-plugin-editor-action{cursor:pointer;background-size:14px 14px;background-repeat:no-repeat;background-position:center;opacity:.9} .monaco-editor .lithe-java-run:hover{opacity:1} ${runIconStyles} ${darkRunIconStyles} ${host.lineNumbersFirst ? ` .monaco-editor.lithe-line-numbers-first .line-numbers.lithe-breakpoint{font-size:0;color:var(--vscode-debugIcon-breakpointForeground,#e51400)} .monaco-editor.lithe-line-numbers-first .line-numbers.lithe-breakpoint:before{font-size:16px} - .monaco-editor.lithe-line-numbers-first .cldr.lithe-java-run{width:${nativeGutter.icon}px!important;margin-left:${nativeGutter.gap}px;opacity:1} + .monaco-editor.lithe-line-numbers-first .cldr.lithe-java-run,.monaco-editor.lithe-line-numbers-first .cldr.lithe-plugin-editor-action{width:${nativeGutter.icon}px!important;margin-left:${nativeGutter.gap}px;opacity:1} .monaco-editor.lithe-line-numbers-first .lithe-java-run-run,.monaco-editor.lithe-line-numbers-first .lithe-java-run-run-all{background-size:16px 16px} .monaco-editor.lithe-line-numbers-first .lithe-java-run-passed,.monaco-editor.lithe-line-numbers-first .lithe-java-run-failed{background-size:14px 14px} .monaco-editor.lithe-line-numbers-first .cldr.lithe-java-navigation{width:${nativeGutter.icon}px!important;margin-left:${nativeGutter.gap + nativeGutter.icon}px} @@ -1827,7 +1883,7 @@ export function mountWorkbench(host: WorkbenchHost) { debugStyle.remove(); for (const cancel of openingMeasurements) cancel(); ++nativeFindGeneration; dismissNativeFind(); - review?.dispose(); semanticChanges.dispose(); for (const view of allEditors()) view.dispose(); surfaces.clear(); entries.forEach(entry => entry.release()); textmate?.dispose(); workers.forEach(worker => worker.terminate()); URL.revokeObjectURL(url); + pluginActions.dispose(); setPluginLanguages(monaco.languages, [], installPluginGrammar); review?.dispose(); semanticChanges.dispose(); for (const view of allEditors()) view.dispose(); surfaces.clear(); entries.forEach(entry => entry.release()); textmate?.dispose(); workers.forEach(worker => worker.terminate()); URL.revokeObjectURL(url); }); await send({ type: "ready" }); } diff --git a/macos/Experiments/Monaco/main.swift b/macos/Experiments/Monaco/main.swift index 0d2eee7754..19f75cf166 100644 --- a/macos/Experiments/Monaco/main.swift +++ b/macos/Experiments/Monaco/main.swift @@ -88,6 +88,9 @@ final class Probe: NSObject, NSApplicationDelegate, WKScriptMessageHandlerWithRe var holdFormat = false var heldFormat: ((Any?, String?) -> Void)? var formatWaiter: ((Any?, String?) -> Void)? + var holdPluginActions = false + var heldPluginActions: [((Any?, String?) -> Void)] = [] + var pluginActionsWaiter: ((Any?, String?) -> Void)? let formatResult: [String: Any] = ["edits": [["range": ["startLineNumber": 1, "startColumn": 1, "endLineNumber": 1, "endColumn": 15], "text": "class Probe { }"]]] var holdSemantic = false @@ -187,6 +190,21 @@ final class Probe: NSObject, NSApplicationDelegate, WKScriptMessageHandlerWithRe case "javaRunMarkerAction": javaRunActionCount += 1 replyHandler(["ok": true], nil) + case "pluginEditorActions": + if holdPluginActions { + heldPluginActions.append(replyHandler) + pluginActionsWaiter?(["ok": true], nil); pluginActionsWaiter = nil + } else { replyHandler(["actions": []], nil) } + case "holdPluginActions": holdPluginActions = true; replyHandler(["ok": true], nil) + case "awaitPluginActions": + if !heldPluginActions.isEmpty { replyHandler(["ok": true], nil) } + else { pluginActionsWaiter = replyHandler } + case "releasePluginActions": + holdPluginActions = false + let replies = heldPluginActions; heldPluginActions.removeAll() + replies.forEach { $0(["actions": []], nil) } + pluginActionsWaiter?(["cancelled": true], nil); pluginActionsWaiter = nil + replyHandler(["ok": true], nil) case "javaRunActionCount": replyHandler(["count": javaRunActionCount], nil) case "javaNavigation": if holdNavigation { @@ -537,6 +555,8 @@ final class Probe: NSObject, NSApplicationDelegate, WKScriptMessageHandlerWithRe func windowWillClose(_ notification: Notification) { finish(error: automated && !completed ? "Closed before completion" : nil) } func finish(error: String?) { + heldPluginActions.forEach { $0(nil, "Probe finished") }; heldPluginActions.removeAll() + pluginActionsWaiter?(nil, "Probe finished"); pluginActionsWaiter = nil closedDocumentNotificationWaiter?(nil, "Probe finished"); closedDocumentNotificationWaiter = nil closedFixtureDocuments.removeAll() markdownScrollWaiter?(nil, "Probe finished"); markdownScrollWaiter = nil diff --git a/macos/Experiments/Monaco/workbench.integration.ts b/macos/Experiments/Monaco/workbench.integration.ts index d2ba137b40..e219a63a86 100644 --- a/macos/Experiments/Monaco/workbench.integration.ts +++ b/macos/Experiments/Monaco/workbench.integration.ts @@ -23,6 +23,9 @@ import { editor as monacoEditor, languages, Range, Selection, Uri } from "monaco import { mouseInputCases } from "./mouse-input.integration"; import { contextMenuCases } from "./context-menu.integration"; import { IContextMenuService } from "monaco-editor/esm/vs/platform/contextview/browser/contextView.js"; +import { installPluginGrammar } from "@lithe/editor/plugin-grammar-tokenizer"; +import { attachPluginEditorActions } from "@lithe/editor/plugin-editor-actions"; +import httpGrammarSource from "../../../shared/contracts/plugins/v1/fixtures/grammars/http.monarch.json" with { type: "text" }; import { imeInputCases } from "./ime-input.integration"; // Real WebKit integration, using the exact workbench bundle and the existing @@ -54,6 +57,142 @@ async function verify() { await operation(); cases.push({ name, durationMs: performance.now() - started }); } + await check("ordinary plugin grammar runs in a worker and preserves Monaco editing", async () => { + if (!languages.getLanguages().some(language => language.id === "fixture-http")) languages.register({ id: "fixture-http" }); + const installation = installPluginGrammar("fixture-http", JSON.stringify({ tokenizer: { root: [["#.*$", "comment"]] } })); + const previous = initialEditor.getModel(); + const model = monacoEditor.createModel("# request\nGET /orders", "fixture-http"); + let listener: { dispose(): void } | undefined; + try { + const colored = new Promise(resolve => { + listener = model.onDidChangeTokens(() => { + const tokens = (model as any).tokenization.getLineTokens(1); + if (tokens.getStandardTokenType(0) === 1) resolve(); + }); + }); + initialEditor.setModel(model); + await withDeadline(colored, "plugin worker never published comment tokens"); + initialEditor.executeEdits("plugin-worker-fixture", [{ range: new Range(2, 5, 2, 5), text: " /new" }]); + assert(model.getValue().includes("/new"), "plugin tokenization blocked editing"); + } finally { listener?.dispose(); initialEditor.setModel(previous); model.dispose(); installation.dispose(); } + }); + await check("worker grammar preserves ordered cases and defaults to root after a host roundtrip", async () => { + const id = "fixture-ordered-http"; + if (!languages.getLanguages().some(language => language.id === id)) languages.register({ id }); + // Sorted host dictionaries must not reorder anything inside the source string. + const source = '{"tokenizer":{"comment":[[".*","comment"]],"root":[["[A-Z]+",{"cases":{"GET":"string","[A-Z]+":"comment","@default":"identifier"}}]]}}'; + const payload = JSON.parse(JSON.stringify({ grammar: { kind: "monarch", source } })); + const installation = installPluginGrammar(id, payload.grammar.source); + const previous = initialEditor.getModel(), model = monacoEditor.createModel("GET", id); + let listener: { dispose(): void } | undefined; + try { + const colored = new Promise(resolve => { + listener = model.onDidChangeTokens(() => { + if ((model as any).tokenization.getLineTokens(1).getStandardTokenType(0) === 2) resolve(); + }); + }); + initialEditor.setModel(model); + await withDeadline(colored, "worker lost root or the first matching case"); + } finally { listener?.dispose(); initialEditor.setModel(previous); model.dispose(); installation.dispose(); } + }); + await check("configuration-only aliases retain syntax and withdraw without changing their sibling language", async () => { + const previous = initialEditor.getModel(); + try { + await window.lithe.setPluginLanguages({ languages: [{ id: "properties", fileExtensions: ["properties"], + configuration: { lineComment: "!" } }] }); + await send({ type: "open", id: "plugin-properties", text: "# comment\nkey=value" }); + await window.lithe.activate({ id: "plugin-properties", filename: "example.properties", text: "# comment\nkey=value", revision: 0, readonly: false }); + const model = initialEditor.getModel()!; + assert(model.getLanguageId() === "properties", "plugin identity was replaced by its tokenizer alias"); + (model as any).tokenization.forceTokenization(1); + assert((model as any).tokenization.getLineTokens(1).getStandardTokenType(0) === 1, + "configuration-only properties lost the ini tokenizer"); + initialEditor.setPosition(new Position(2, 1)); + await initialEditor.getAction("editor.action.commentLine")!.run(); + assert(model.getLineContent(2).startsWith("!"), "alias did not use its independent comment configuration"); + await window.lithe.setPluginLanguages({ languages: [] }); + assert(model.getLanguageId() === "ini", "withdrawing the configuration did not restore the tokenizer alias"); + (model as any).tokenization.forceTokenization(1); + assert((model as any).tokenization.getLineTokens(1).getStandardTokenType(0) === 1, "withdrawing alias disposed the base engine"); + } finally { + await window.lithe.setPluginLanguages({ languages: [] }); + await window.lithe.retain([]); + await send({ type: "closeFixtureDocument", documentID: "plugin-properties" }); + initialEditor.setModel(previous); + } + }); + await check("plugin action refresh acknowledges scheduling while collection is pending and editing still drains", async () => { + const previous = initialEditor.getModel(); + await send({ type: "open", id: "plugin-slow-actions", text: "GET /orders" }); + await window.lithe.activate({ id: "plugin-slow-actions", filename: "orders.http", text: "GET /orders", revision: 0, readonly: false }); + await send({ type: "holdPluginActions" }); + try { + await withDeadline(window.lithe.refreshPluginEditorActions(), "refresh waited for plugin collection"); + await withDeadline(send({ type: "awaitPluginActions" }), "collector did not start"); + // Grammar/config updates must not wait for pending actions either. + await withDeadline(window.lithe.setPluginLanguages({ languages: [{ id: "fixture-source", fileExtensions: ["orderedhttp"], + grammar: { kind: "monarch", source: httpGrammarSource } }] }), "static language update waited for plugin collection"); + initialEditor.executeEdits("slow-plugin", [{ range: new Range(1, 1, 1, 1), text: "# edited\n" }]); + const snapshot = await withDeadline(window.lithe.freeze("plugin-slow-actions"), "slow plugin blocked save barrier"); + assert(snapshot.text.startsWith("# edited"), "save barrier lost input while plugin was pending"); + window.lithe.unlock("plugin-slow-actions"); + } finally { + await send({ type: "releasePluginActions" }); + await window.lithe.setPluginLanguages({ languages: [] }); + await window.lithe.retain([]); + await send({ type: "closeFixtureDocument", documentID: "plugin-slow-actions" }); + initialEditor.setModel(previous); + } + }); + await check("tool actions share a gutter menu and reject superseded batches", async () => { + const previous = initialEditor.getModel(); + const model = monacoEditor.createModel("GET /orders", "plaintext"); + const entry = { model, revision: 0, chain: Promise.resolve(), readonly: false, frozen: false, closingHolds: 0 }; + let active = true, collections = 0; + const calls: any[] = []; + const service = StandaloneServices.get(IContextMenuService), originalMenu = service.showContextMenu; + let menu: any[] = []; + service.showContextMenu = delegate => { menu = [...delegate.getActions()]; }; + const entries = new Map([["tool", entry]]); + const binding = attachPluginEditorActions({ + entries: () => entries, editors: () => [initialEditor], lineNumbersFirst: true, + current: () => { const version = model.getVersionId(); return () => active && !model.isDisposed() && model.getVersionId() === version; }, + request: async payload => { + if ((payload as any).type === "pluginEditorAction") { calls.push(payload); return { ok: true }; } + return { batch: String(++collections), actions: [ + { id: "send", line: 1, endLine: 1, title: "Send request", icon: "run" }, + { id: "preview", line: 1, endLine: 1, title: "Preview request", icon: "run" }, + ] }; + }, + }); + try { + initialEditor.setModel(model); + binding.attach(initialEditor); + await binding.refresh(); + assert(model.getAllDecorations().filter(value => value.options.linesDecorationsClassName?.includes("lithe-plugin-editor-action")).length === 1, + "same-line actions created competing gutter decorations"); + const target = document.createElement("span"); + target.className = "lithe-plugin-editor-action"; + (initialEditor as any)._onMouseDown.fire({ + target: { element: target, position: new Position(1, 1) }, + event: { leftButton: true, posx: 10, posy: 10, preventDefault() {}, stopPropagation() {} }, + }); + assert(menu.length === 2 && menu[0].label === "Send request" && menu[1].label === "Preview request", + "same-line plugin actions lost their separate menu labels"); + await menu[0].run(); + assert(calls.length === 1 && calls[0].action === "send" && calls[0].batch === "1", + "explicit click did not preserve the opaque action/batch identity"); + entry.revision++; + binding.schedule("tool", entry); + assert(!model.getAllDecorations().some(value => value.options.linesDecorationsClassName?.includes("lithe-plugin-editor-action")), + "edited document kept its old action clickable"); + await menu[0].run(); + assert(calls.length === 1, "retained menu executed an action after its batch was revoked"); + } finally { + service.showContextMenu = originalMenu; + active = false; binding.dispose(); initialEditor.setModel(previous); model.dispose(); + } + }); for (const test of contextMenuCases) await check(test.name, async () => test.run()); for (const test of mouseInputCases) await check(test.name, async () => test.run()); for (const test of imeInputCases) await check(test.name, async () => test.run()); diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index 2fcf4dc877..d4a91515b2 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -235,6 +235,7 @@ "Add another account" = "Add another account"; "Add at least one absolute allowed folder path after the Filesystem package argument." = "Add at least one absolute allowed folder path after the Filesystem package argument."; "Add at least one absolute folder path after the package argument, one per line. Filesystem can read and write only in the folders you allow. Requires Node.js 24 or later." = "Add at least one absolute folder path after the package argument, one per line. Filesystem can read and write only in the folders you allow. Requires Node.js 24 or later."; +"Add clickable actions beside document text" = "Add clickable actions beside document text"; "Add database connection" = "Add database connection"; "Add header" = "Add header"; "Add memory usage monitoring\n\nTrack current and average memory usage in the status bar." = "Add memory usage monitoring\n\nTrack current and average memory usage in the status bar."; @@ -4874,6 +4875,7 @@ "native.addAnAIProviderInAIProvidersFirst" = "Add an AI provider in AI Providers first."; "native.addAtLeastOneAbsoluteAllowedFolderPathAfterTheFilesystemPackageArgument" = "Add at least one absolute allowed folder path after the Filesystem package argument."; "native.addAtLeastOneAbsoluteFolderPathAfterThePackageArgumentOnePerLineFilesystemCanReadAndWriteOnlyInTheFoldersYouAllowRequiresNodeJs24OrLater" = "Add at least one absolute folder path after the package argument, one per line. Filesystem can read and write only in the folders you allow. Requires Node.js 24 or later."; +"native.addClickableActionsBesideDocumentText" = "Add clickable actions beside document text"; "native.addDatabaseConnection" = "Add database connection"; "native.addHeader" = "Add header"; "native.addHook" = "Add Hook"; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index ea4dcd246a..ca32b7f055 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -235,6 +235,7 @@ "Add another account" = "添加另一个账号"; "Add at least one absolute allowed folder path after the Filesystem package argument." = "请在 Filesystem 包参数后至少添加一个允许访问的文件夹绝对路径。"; "Add at least one absolute folder path after the package argument, one per line. Filesystem can read and write only in the folders you allow. Requires Node.js 24 or later." = "在包参数后至少添加一个文件夹的绝对路径,每行一个。Filesystem 只可读写你允许的文件夹。需要 Node.js 24 或更高版本。"; +"Add clickable actions beside document text" = "在文档文本旁添加可点击的操作"; "Add database connection" = "添加数据库连接"; "Add header" = "添加请求头"; "Add memory usage monitoring\n\nTrack current and average memory usage in the status bar." = "添加内存占用监控\n\n在状态栏显示当前及平均内存占用。"; @@ -4874,6 +4875,7 @@ "native.addAnAIProviderInAIProvidersFirst" = "请先在“AI 服务商”中添加服务商。"; "native.addAtLeastOneAbsoluteAllowedFolderPathAfterTheFilesystemPackageArgument" = "请在 Filesystem 包参数后至少添加一个允许访问的文件夹绝对路径。"; "native.addAtLeastOneAbsoluteFolderPathAfterThePackageArgumentOnePerLineFilesystemCanReadAndWriteOnlyInTheFoldersYouAllowRequiresNodeJs24OrLater" = "在包参数后至少添加一个文件夹的绝对路径,每行一个。Filesystem 只可读写你允许的文件夹。需要 Node.js 24 或更高版本。"; +"native.addClickableActionsBesideDocumentText" = "在文档文本旁添加可点击的操作"; "native.addDatabaseConnection" = "添加数据库连接"; "native.addHeader" = "添加请求头"; "native.addHook" = "添加 Hook"; diff --git a/macos/Sources/Lithe/Application/Composition/AppServices.swift b/macos/Sources/Lithe/Application/Composition/AppServices.swift index d39b69d5ae..239ced2d76 100644 --- a/macos/Sources/Lithe/Application/Composition/AppServices.swift +++ b/macos/Sources/Lithe/Application/Composition/AppServices.swift @@ -42,6 +42,10 @@ final class AppServices { /// Starts plugin backends on `onWorkspaceOpen` and `onCommand:`; /// `nil` when the platform runs no plugin backends. var pluginBackendActivation: (any PluginBackendActivating)? + var pluginEditorActions: PluginEditorActionHostService? + /// Static plugin editor resources; `nil` when + /// the platform loads no plugins. + var pluginEditorLanguages: (any PluginEditorLanguageProviding)? let moduleRuntime: ModuleRuntime let pluginManager: any PluginManaging let pluginMarketplace: any PluginMarketplaceServicing diff --git a/macos/Sources/Lithe/Application/Features/PluginEditorActionHostService.swift b/macos/Sources/Lithe/Application/Features/PluginEditorActionHostService.swift new file mode 100644 index 0000000000..6537345561 --- /dev/null +++ b/macos/Sources/Lithe/Application/Features/PluginEditorActionHostService.swift @@ -0,0 +1,146 @@ +import Combine +import Foundation +import LitheCoreContracts +import LitheModuleAPI + +// Note: 独立扩展点与领域契约的边界见 .agents/notes/implemented/architecture/2026-10-31-plugin-capability-and-domain-boundaries.md + +/// One total collection budget, below the editor bridge's ten-second deadline. +enum PluginEditorActionCollectionBudget { + static let maximum: Duration = .seconds(8) + + static func remaining(after elapsed: Duration) -> Duration? { + let remaining = maximum - elapsed + return remaining > .zero ? remaining : nil + } +} + +/// A context/session-owned binding, not a language, Build or Run provider. +struct PluginEditorActionRegistration: Equatable { + let lease: UUID + let pluginID: String + let sessionID: String + let context: PluginWorkspaceContextReference + let id: String + let functionID: String + let fileExtensions: [String] + let fileNames: [String] + + func matches(_ filename: String) -> Bool { + let filename = filename.lowercased() + return fileNames.contains(filename) || fileExtensions.contains { filename.hasSuffix("." + $0) } + } +} + +/// Registration only changes a binding. It never calls a backend while the +/// backend is awaiting this host response; collection happens in a later turn. +@MainActor +final class PluginEditorActionHostService: PluginHostServiceHandling { + let service = "editor" + let methods: Set = ["lithe/host/editor/registerActions"] + private let contexts: PluginWorkspaceContexts + private let decoder: any PluginEditorActionDecoding + private let changed = PassthroughSubject() + private var entries: [UUID: PluginEditorActionRegistration] = [:] + private var disposeRegistration: [UUID: @MainActor () -> Void] = [:] + private var functions: [String: (session: String, ids: Set)] = [:] + + var changes: AnyPublisher { changed.eraseToAnyPublisher() } + + init(contexts: PluginWorkspaceContexts, decoder: any PluginEditorActionDecoding) { + self.contexts = contexts + self.decoder = decoder + } + + func registrations(context: PluginWorkspaceContextReference, filename: String) -> [PluginEditorActionRegistration] { + entries.values.filter { $0.context == context && $0.matches(filename) } + .sorted { ($0.pluginID, $0.id) < ($1.pluginID, $1.id) } + } + + func contains(_ registration: PluginEditorActionRegistration, functionID: String? = nil) -> Bool { + entries[registration.lease] == registration + && functions[registration.pluginID]?.session == registration.sessionID + && functions[registration.pluginID]?.ids.contains(functionID ?? registration.functionID) == true + } + + /// Function withdrawal invalidates the complete binding. Register it again + /// explicitly; a reused function ID must not resurrect old document actions. + func updateFunctions(pluginID: String, sessionID: String, ids: [String]) { + let ids = Set(ids) + guard functions[pluginID]?.session != sessionID || functions[pluginID]?.ids != ids else { return } + let previous = functions[pluginID] + functions[pluginID] = (sessionID, ids) + let revoked = previous?.session != sessionID || !(previous?.ids ?? []).isSubset(of: ids) + guard revoked else { return } + for entry in Array(entries.values) where entry.pluginID == pluginID { + disposeRegistration[entry.lease]?() + } + changed.send() + } + + func answer(_ call: PluginHostCall) async -> Result { + do { + let scope = try PluginHostAPIContext(call, contexts: contexts) + let args = try PluginHostArguments(call.params, allowed: ["context", "id", "function", "selector"]) + let input: ToolingJSONValue = .object([ + "id": .string(try args.string("id")), "function": .string(try args.string("function")), + "selector": .object(try args.object("selector")), + ]) + let normalized: ToolingJSONValue + do { normalized = try decoder.decodeEditorActions(kind: "registration", value: input, text: "") } + catch { throw PluginHostArguments.invalid } + guard case .object(let result) = normalized, case .object(let value)? = result["registration"], + case .string(let id)? = value["id"], case .string(let functionID)? = value["function"], + case .object(let selector)? = value["selector"], + case .array(let extensions)? = selector["fileExtensions"], case .array(let names)? = selector["fileNames"], + functions[call.pluginID]?.session == call.sessionID, + functions[call.pluginID]?.ids.contains(functionID) == true else { throw PluginHostArguments.invalid } + guard entries.values.filter({ $0.pluginID == call.pluginID }).count < 32 else { + throw PluginHostServiceError(.limitExceeded, L10n.string("The plugin host request could not be completed.")) + } + guard !entries.values.contains(where: { $0.pluginID == call.pluginID && $0.context == scope.workspace.reference && $0.id == id }) else { + throw PluginHostServiceError(.preconditionFailed, L10n.string("The plugin host request parameters are invalid.")) + } + let lease = UUID() + let registration = PluginEditorActionRegistration( + lease: lease, pluginID: call.pluginID, sessionID: call.sessionID, context: scope.workspace.reference, + id: id, functionID: functionID, + fileExtensions: extensions.compactMap { if case .string(let text) = $0 { text } else { nil } }, + fileNames: names.compactMap { if case .string(let text) = $0 { text } else { nil } } + ) + let subscription = call.subscriptions.add(pluginID: call.pluginID, contextID: scope.workspace.reference.contextID) { [weak self] in + self?.disposeRegistration[lease] = nil + guard self?.entries.removeValue(forKey: lease) != nil else { return } + self?.changed.send() + } + entries[lease] = registration + let pluginID = call.pluginID + disposeRegistration[lease] = { [weak subscriptions = call.subscriptions] in + subscriptions?.dispose(pluginID: pluginID, subscriptionID: subscription) + } + changed.send() + return .success(.object(["subscriptionID": .string(subscription)])) + } catch { return .failure(PluginHostAPIValues.failure(error)) } + } + + func decode(_ result: ToolingJSONValue, text: String) throws -> [PluginEditorAction] { + let value = try decoder.decodeEditorActions(kind: "actions", value: result, text: text) + struct Response: Decodable { let actions: [PluginEditorAction] } + return try JSONDecoder().decode(Response.self, from: JSONEncoder().encode(value)).actions + } + + func endSession(pluginID: String, sessionID: String) { + guard functions[pluginID]?.session == sessionID else { return } + functions[pluginID] = nil + for entry in Array(entries.values) where entry.pluginID == pluginID { + disposeRegistration[entry.lease]?() + } + changed.send() + } + + func closeContext(_ contextID: String) { + for entry in Array(entries.values) where entry.context.contextID == contextID { + disposeRegistration[entry.lease]?() + } + } +} diff --git a/macos/Sources/Lithe/Application/Features/PluginEditorLanguages.swift b/macos/Sources/Lithe/Application/Features/PluginEditorLanguages.swift new file mode 100644 index 0000000000..69276a22b1 --- /dev/null +++ b/macos/Sources/Lithe/Application/Features/PluginEditorLanguages.swift @@ -0,0 +1,19 @@ +import Combine +import Foundation +import LitheCoreContracts + +/// Static editor resources are independent of language-server and Run providers. +struct PluginEditorLanguageSet: Equatable { + var languages: [ToolingJSONValue] = [] + + var editorPayload: [String: Any] { + let values = (try? JSONSerialization.jsonObject(with: JSONEncoder().encode(languages))) ?? [] + return ["languages": values] + } +} + +@MainActor +protocol PluginEditorLanguageProviding: AnyObject { + var changes: AnyPublisher { get } + func current() -> PluginEditorLanguageSet +} diff --git a/macos/Sources/Lithe/Core/Language/PluginLanguageProviderCatalogSource.swift b/macos/Sources/Lithe/Core/Language/PluginLanguageProviderCatalogSource.swift index eed1113111..4b24b8d948 100644 --- a/macos/Sources/Lithe/Core/Language/PluginLanguageProviderCatalogSource.swift +++ b/macos/Sources/Lithe/Core/Language/PluginLanguageProviderCatalogSource.swift @@ -45,7 +45,7 @@ extension LanguageProviderCatalog { // Debug-only declarations go last: they add to what the owning package // declared, and an owner applied after them would erase their debugger. let ordered = languageSupports.sorted { - ($0.isDebugOnly ? 1 : 0, $0.id) < ($1.isDebugOnly ? 1 : 0, $1.id) + ($0.isDebugOnly || $0.isEditorOnly ? 1 : 0, $0.id) < ($1.isDebugOnly || $1.isEditorOnly ? 1 : 0, $1.id) } for support in ordered { let existingIndex = indicesByID[support.id] @@ -58,7 +58,7 @@ extension LanguageProviderCatalog { // A debug-only declaration adds a debugger to a language another // package or the catalog already provides; it must not take that // language's server, run, or test features away. - let isDebugOnly = support.isDebugOnly && existing != nil + let isDebugOnly = (support.isDebugOnly || support.isEditorOnly) && existing != nil if !isDebugOnly { capabilities.subtract([.run, .languageServer, .debugAdapter, .testing]) } diff --git a/macos/Sources/Lithe/Core/Rust/RustDeclarativePluginOperations.swift b/macos/Sources/Lithe/Core/Rust/RustDeclarativePluginOperations.swift index 27448a2dd5..d1afb2094b 100644 --- a/macos/Sources/Lithe/Core/Rust/RustDeclarativePluginOperations.swift +++ b/macos/Sources/Lithe/Core/Rust/RustDeclarativePluginOperations.swift @@ -6,7 +6,8 @@ import LitheModuleAPI /// validation and template expansion for both products; this adapter only /// translates payloads and outcomes. struct RustDeclarativePluginOperations: DeclarativePluginManifestValidating, DeclarativeExecutionPlanning, - DeclarativeLanguageServerLaunchPlanning, DeclarativePluginSettingsResolving, LanguageServerRuntimeVersionChecking { + DeclarativeLanguageServerLaunchPlanning, DeclarativePluginSettingsResolving, LanguageServerRuntimeVersionChecking, + DeclarativeEditorContributionDecoding, PluginEditorActionDecoding { let core: RustCoreBridge func validateNativeNodes(ui: PluginNativeUI, data: ToolingJSONValue) throws -> [PluginNativeUI.Node] { @@ -298,6 +299,23 @@ struct RustDeclarativePluginOperations: DeclarativePluginManifestValidating, Dec return result } + func decodeGrammar(manifest: Data, languageID: String, grammar: String) throws -> ToolingJSONValue { + struct Payload: Encodable { let manifest: ToolingJSONValue; let languageID: String; let grammar: String } + struct Result: Decodable { let language: ToolingJSONValue } + let outcome: Outcome = try execute( + "plugins.declarative.decodeGrammar", + payload: Payload(manifest: try Self.json(manifest), languageID: languageID, grammar: grammar) + ) + return outcome.value.language + } + + func decodeEditorActions(kind: String, value: ToolingJSONValue, text: String = "") throws -> ToolingJSONValue { + struct Payload: Encodable { let kind: String; let value: ToolingJSONValue; let text: String } + let result: ToolingJSONValue = try core.executeResult( + command: "plugins.editorActions.decode", payload: Payload(kind: kind, value: value, text: text)).get() + return result + } + func resolveSettings( manifest: Data, settings: [String: ToolingJSONValue], diff --git a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index f08ac9d3b9..a5baa3f16c 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -1,3 +1,4 @@ +import Combine import Foundation import LitheAIAssistanceModule import LitheAgentConversationModule @@ -448,6 +449,8 @@ final class MacServiceContainer { ) hostDispatcher.register(workspaceHost) hostDispatcher.register(PluginDocumentHostService(contexts: .shared)) + let editorActions = PluginEditorActionHostService(contexts: .shared, decoder: declarativePluginOperations) + hostDispatcher.register(editorActions) hostDispatcher.register(PluginFileHostService(contexts: .shared, metadata: workspaceHost, files: MacPluginWorkspaceFileService( searchOperations: RustWorkspaceOperations(core: rustCore), @@ -470,8 +473,18 @@ final class MacServiceContainer { fields: ["profiles": String(profiles), "passwords": String(passwords)]) } }, + canStart: { id in + guard moduleLaunchMode != .safeMode, + let package = pluginStartup.registeredPlugins.first(where: { $0.descriptor.id.rawValue == id }), + let module = package.manifest.modules.first(where: { + $0.manifest.id == .backendExtension(id) || $0.manifest.id == ModuleID("\(id).ui") + })?.manifest else { return false } + return (moduleStore.enabledState(for: module.id) ?? (module.defaultState == .enabled)) + && !moduleStore.isQuarantined(module.id) + }, hostDispatcher: hostDispatcher ) + sessions.editorActions = editorActions sessions.onActivationFailure = { [applicationLogging] pluginID, error in applicationLogging?.record(level: "warn", scope: "plugins.backend", message: "backend activation failed", fields: ["plugin": pluginID, "error": error.localizedDescription]) @@ -500,7 +513,7 @@ final class MacServiceContainer { ) // A debug-only declaration adds a debugger to a language another owner // serves, so it must not switch the host's own tools for it off. - let languageOwningSupports = installedLanguageSupports.filter { !$0.isDebugOnly } + let languageOwningSupports = installedLanguageSupports.filter { !$0.isDebugOnly && !$0.isEditorOnly } let pluginLanguageIDs = Set(languageOwningSupports.map(\.id)) // A package may declare, through its host integration, that the host // keeps providing a feature of its language. Everything else of an @@ -1132,6 +1145,34 @@ final class MacServiceContainer { ) services.ideCapabilityRegistry = ideCapabilityRegistry services.pluginRegistrationChanges = livePlugins.changes + // Static editor resources follow their own enablement; ordinary tool + // actions register at runtime in the shared backend's host services. + let editorLanguageModuleChanges = PassthroughSubject() + MacPluginEditorLanguages.observeModuleChanges(on: moduleRuntime, changed: { + editorLanguageModuleChanges.send() + }) { runtime in + for package in pluginStartup.registeredPlugins where package.descriptor.declaresBackend && package.descriptor.ui == nil { + let id = package.descriptor.id.rawValue + if let state = try? runtime.snapshot(for: .backendExtension(id)), + state.state != .disabled, !state.isQuarantined, !state.isSuppressedBySafeMode { + pluginBackends.enable(id) + } else { pluginBackends.disable(id) } + } + } + services.pluginEditorActions = pluginBackends.editorActions + services.pluginEditorLanguages = MacPluginEditorLanguages( + launchRegistered: pluginStartup.registeredPlugins, + isModuleUsable: { [weak moduleRuntime] id in + guard let snapshot = try? moduleRuntime?.snapshot(for: id) else { return false } + return snapshot.state != .disabled && !snapshot.isQuarantined && !snapshot.isSuppressedBySafeMode + }, + livePackages: { liveSnapshotBox.snapshot.activePackages }, + decoder: declarativePluginOperations, + triggers: [livePlugins.changes, editorLanguageModuleChanges.eraseToAnyPublisher()], + log: { message, fields in + NSLog("Lithe %@ %@", message, fields.sorted { $0.key < $1.key }.map { "\($0.key)=\($0.value)" }.joined(separator: " ")) + } + ) services.ideHostTransport = MacIdeHostTransport(core: rustCore, storage: fileStorage) services.proxyPasswordStore = proxyPasswordStore services.proxyConnectionChecker = proxyConnectionChecker @@ -1252,7 +1293,7 @@ final class MacServiceContainer { prepareTools: @MainActor (LanguageProviderDescriptor) async -> Void ) async throws { try Task.checkCancellation() - guard let support = supports.first(where: { $0.id == languageID && !$0.isDebugOnly }), + guard let support = supports.first(where: { $0.id == languageID && !$0.isDebugOnly && !$0.isEditorOnly }), let moduleID = support.languageServerModuleID, let snapshot = try? runtime.snapshot(for: moduleID), snapshot.state != .disabled, !snapshot.isQuarantined, !snapshot.isSuppressedBySafeMode else { @@ -1351,6 +1392,8 @@ final class MacServiceContainer { || $0.manifest.id == .buildSystemsExtension(descriptor.id.rawValue) || $0.manifest.id == .vcsProvidersExtension(descriptor.id.rawValue) || $0.manifest.id == .debugAdaptersExtension(descriptor.id.rawValue) + || $0.manifest.id == .grammarsExtension(descriptor.id.rawValue) + || $0.manifest.id == .backendExtension(descriptor.id.rawValue) || $0.manifest.id == .projectGeneratorsExtension(descriptor.id.rawValue) }.map { declaration in ModuleFactory(manifest: declaration.manifest, contributions: declaration.contributions) { diff --git a/macos/Sources/Lithe/Platform/MacOS/MonacoWorkbenchEditor.swift b/macos/Sources/Lithe/Platform/MacOS/MonacoWorkbenchEditor.swift index f2313307e1..9d266ca44b 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MonacoWorkbenchEditor.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MonacoWorkbenchEditor.swift @@ -6,6 +6,7 @@ import LitheCoreContracts import LitheDebugModule import LitheExecutionModule import LitheGitModule +import LitheModuleAPI /// Local editor assets included by ordinary preview and release packaging. enum MonacoWorkbenchResources { @@ -316,6 +317,18 @@ final class MonacoWorkbenchSession: NSObject, ObservableObject, WKNavigationDele private var javaRunMarkerLists: [String: (context: MonacoDocumentContext, revision: Int, url: URL, markers: [JavaRunMarker])] = [:] private var javaRunMarkerRequests: [String: UUID] = [:] private var javaRunMarkerRefreshSubscription: AnyCancellable? + private struct EditorActionList { + let context: MonacoDocumentContext + let contentVersion: UInt64 + let batch: String + let actions: [(key: String, registration: PluginEditorActionRegistration, action: PluginEditorAction)] + } + private var pluginActionLists: [String: EditorActionList] = [:] + private var pluginActionRequests: [String: (id: UUID, task: Task, deadline: Task)] = [:] + private var pluginActionExecutions: [UUID: (documentID: String, registration: PluginEditorActionRegistration, task: Task)] = [:] + private var pluginActionsSubscription: AnyCancellable? + private var pluginLanguagesSubscription: AnyCancellable? + private var lastPluginLanguages: PluginEditorLanguageSet? private var testOutcomeSubscription: AnyCancellable? private var testServiceIdentity: ObjectIdentifier? private var codeActionCommands: [String: (context: MonacoDocumentContext, key: String, root: URL?, command: LanguageServerCommand)] = [:] @@ -607,6 +620,7 @@ final class MonacoWorkbenchSession: NSObject, ObservableObject, WKNavigationDele bindSynchronization(document) subscriptions[id] = document.textDidChange.sink { [weak self, weak document] in guard let self, let document, self.currentDocument(id) === document, !self.applyingEdit else { return } + self.cancelPluginActions(for: id) let previous = self.revisions[id] ?? 0 self.revisions[id] = previous + 1 self.call("window.lithe.replace(payload)", arguments: ["payload": ["id": id, "text": document.text, "previousRevision": previous, "revision": previous + 1]]) @@ -722,6 +736,68 @@ final class MonacoWorkbenchSession: NSObject, ObservableObject, WKNavigationDele .sink { [weak self] _ in self?.call("window.lithe.refreshJavaRunMarkers()") } } + /// Plugin grammars apply without a restart: the editor follows every + /// change of the plugins' independent static editor resources. + private func observePluginLanguages(model: AppModel) { + guard let languages = model.services.pluginEditorLanguages else { return } + if pluginLanguagesSubscription == nil { + pluginLanguagesSubscription = languages.changes + .receive(on: DispatchQueue.main) + .sink { [weak self, weak languages] in + guard let self, let languages, self.ready, !self.failed else { return } + self.sendPluginLanguages(languages.current()) + } + } + sendPluginLanguages(languages.current()) + } + + private func sendPluginLanguages(_ set: PluginEditorLanguageSet) { + guard set != lastPluginLanguages else { return } + lastPluginLanguages = set + call("window.lithe.setPluginLanguages(payload)", arguments: ["payload": set.editorPayload]) { result in + if case .success(let value) = result, let failed = (value as? [String: Any])?["failed"] as? [[String: Any]] { + for failure in failed { + NSLog("Lithe plugin grammar failed in the editor: %@ %@", + failure["id"] as? String ?? "-", failure["message"] as? String ?? "-") + } + } + } + } + + private func cancelPluginActions(for id: String, closing: Bool = false) { + pluginActionLists[id] = nil + if let request = pluginActionRequests.removeValue(forKey: id) { + request.deadline.cancel() + request.task.cancel() + } + if closing { + for (operation, execution) in pluginActionExecutions where execution.documentID == id { + execution.task.cancel() + pluginActionExecutions[operation] = nil + } + } + } + + private func observePluginActions(model: AppModel) { + guard pluginActionsSubscription == nil, let service = model.services.pluginEditorActions else { return } + pluginActionsSubscription = service.changes.receive(on: DispatchQueue.main).sink { [weak self, weak service] in + guard let self, let service else { return } + for id in Array(self.pluginActionRequests.keys) { self.cancelPluginActions(for: id) } + self.pluginActionLists.removeAll() + for (id, execution) in self.pluginActionExecutions where !service.contains(execution.registration) { + execution.task.cancel() + self.pluginActionExecutions[id] = nil + } + if self.ready, !self.failed { + self.call("window.lithe.refreshPluginEditorActions()") { result in + if case .failure(let error) = result { + NSLog("Lithe plugin editor action refresh failed: %@", error.localizedDescription) + } + } + } + } + } + private func observeCodeVision(model: AppModel) { if codeVisionSubscription == nil { codeVisionSubscription = model.javaFeature.$javaCodeVisionHints @@ -836,6 +912,9 @@ final class MonacoWorkbenchSession: NSObject, ObservableObject, WKNavigationDele private func present(document: EditorDocument, model: AppModel, fontSize: Double, fontFamily: String, theme: MonacoWorkbenchThemeConfiguration, wrap: Bool, minimap: Bool, javaKeywordNavigationFilter: Bool = true, appearance: EditorAppearanceSettings = EditorAppearanceSettings(), markers: [EditorDiagnostic], surface: String = "primary") { guard viewOwnerID != nil else { return } + // Before the document: its file may be of a plugin language. + observePluginLanguages(model: model) + observePluginActions(model: model) let id = document.id.uuidString let groupDocuments: [EditorDocument] if let scopedDocumentID { @@ -859,6 +938,7 @@ final class MonacoWorkbenchSession: NSObject, ObservableObject, WKNavigationDele javaNavigationRequests[oldID] = nil javaRunMarkerLists[oldID] = nil javaRunMarkerRequests[oldID] = nil + cancelPluginActions(for: oldID, closing: true) lastGitStates[oldID] = nil gitLoads.removeValue(forKey: oldID)?.cancel() blameLoads.removeValue(forKey: oldID)?.cancel() @@ -1066,7 +1146,8 @@ final class MonacoWorkbenchSession: NSObject, ObservableObject, WKNavigationDele func allowsMessage(_ type: String) -> Bool { !isReadOnlyPreview || !["format", "codeActions", "resolveCodeAction", "executeCodeAction", - "rename", "resolveCompletion", "javaRunMarkerAction", "setBlameVisibility"].contains(type) + "rename", "resolveCompletion", "javaRunMarkerAction", "pluginEditorAction", + "setBlameVisibility"].contains(type) } func receive(_ message: WKScriptMessage, reply: @escaping (Any?, String?) -> Void) { @@ -1218,6 +1299,102 @@ final class MonacoWorkbenchSession: NSObject, ObservableObject, WKNavigationDele model.editorDidFocus(document) model.performJavaRunMarker(marker, action: action, in: document.url) reply(["ok": true], nil) + case "pluginEditorActions": + guard let model, !failed, !isReadOnlyPreview, + let revision = body["revision"] as? Int, revision == revisions[id], + let workspace = model.pluginWebWorkspaceContext, + model.services.pluginWorkspaceContexts?.contexts.contains(where: { $0.reference == workspace && $0.authority == .local }) == true, + let service = model.services.pluginEditorActions, let backends = model.pluginWebBackends, + document.text.utf8.count <= 128 * 1024 else { reply(["actions": []], nil); return } + cancelPluginActions(for: id) + let registrations = service.registrations(context: workspace, filename: document.url.lastPathComponent) + let text = document.text, version = document.contentVersion, requestID = UUID(), batch = UUID().uuidString + let input: ToolingJSONValue = .object([ + "document": .object(["documentID": .string(id), "version": .integer(Int(clamping: version)), + "path": model.workspaceURL.flatMap { root in + let prefix = root.standardizedFileURL.path + "/", path = document.url.standardizedFileURL.path + return path.hasPrefix(prefix) ? .string(String(path.dropFirst(prefix.count))) : nil + } ?? .null]), + "text": .string(text), + ]) + let task = Task { @MainActor [weak self, weak document] in + defer { + if self?.pluginActionRequests[id]?.id == requestID { + self?.pluginActionRequests.removeValue(forKey: id)?.deadline.cancel() + } + } + var items: [(key: String, registration: PluginEditorActionRegistration, action: PluginEditorAction)] = [] + let clock = ContinuousClock(), started = clock.now + for registration in registrations { + do { + try Task.checkCancellation() + guard let remaining = PluginEditorActionCollectionBudget.remaining(after: started.duration(to: clock.now)) else { break } + guard service.contains(registration) else { continue } + let result = try await backends.invokePluginFunction(registration.functionID, params: input, + ofPlugin: registration.pluginID, context: workspace, timeout: remaining, progress: { _ in }) + try Task.checkCancellation() + let actions = try service.decode(result, text: text) + guard service.contains(registration), actions.allSatisfy({ service.contains(registration, functionID: $0.invoke.functionID) }) else { continue } + for action in actions { items.append((UUID().uuidString, registration, action)) } + guard items.count <= 1000 else { items = []; break } + } catch { + if Task.isCancelled { break } + NSLog("Lithe plugin editor actions failed [%@]: %@", registration.pluginID, error.localizedDescription) + } + } + guard let self, let document, !Task.isCancelled, self.isCurrent(context), document.contentVersion == version, + self.pluginActionRequests[id]?.id == requestID, + items.allSatisfy({ service.contains($0.registration, functionID: $0.action.invoke.functionID) }) else { + reply(["cancelled": true], nil); return + } + self.pluginActionRequests.removeValue(forKey: id)?.deadline.cancel() + self.pluginActionLists[id] = EditorActionList(context: context, contentVersion: version, batch: batch, actions: items) + reply(["batch": batch, "actions": items.map { item in + ["id": item.key, "line": item.action.range.start.line, "endLine": item.action.range.end.line, + "title": item.action.title, "icon": item.action.icon] as [String: Any] + }], nil) + } + let deadline = Task { @MainActor [weak self] in + do { try await Task.sleep(for: PluginEditorActionCollectionBudget.maximum) } catch { return } + guard self?.pluginActionRequests[id]?.id == requestID else { return } + self?.cancelPluginActions(for: id) + } + pluginActionRequests[id] = (requestID, task, deadline) + case "pluginEditorAction": + guard let model, !failed, !isReadOnlyPreview, !self.isReadOnly(document), + let revision = body["revision"] as? Int, revision == revisions[id], + let list = pluginActionLists[id], list.batch == body["batch"] as? String, + isCurrent(list.context), list.contentVersion == document.contentVersion, + let key = body["action"] as? String, let item = list.actions.first(where: { $0.key == key }), + let service = model.services.pluginEditorActions, + service.contains(item.registration, functionID: item.action.invoke.functionID), + model.pluginWebWorkspaceContext == item.registration.context, + let backends = model.pluginWebBackends, + pluginActionExecutions.count < 16 else { reply(["cancelled": true], nil); return } + model.editorDidFocus(document) + let operation = UUID() + let params: ToolingJSONValue = .object([ + "documentID": .string(id), "expectedVersion": .integer(Int(clamping: list.contentVersion)), + "actionID": .string(item.action.id), "data": item.action.invoke.params, + ]) + let task = Task { @MainActor [weak self] in + defer { self?.pluginActionExecutions[operation] = nil } + do { + _ = try await backends.invokePluginFunction(item.action.invoke.functionID, params: params, + ofPlugin: item.registration.pluginID, context: item.registration.context, + timeout: .seconds(60), progress: { _ in }) + try Task.checkCancellation() + reply(["ok": true], nil) + } catch { + if Task.isCancelled { reply(["cancelled": true], nil) } + else { + NSLog("Lithe plugin editor action failed [%@]: %@", item.registration.pluginID, error.localizedDescription) + self?.model?.showNotification("The plugin host request could not be completed.") + reply(["ok": false], nil) + } + } + } + pluginActionExecutions[operation] = (id, item.registration, task) case "javaNavigationAction": guard let model, !failed, let revision = body["revision"] as? Int, revision == revisions[id], let list = javaNavigationLists[id], isCurrent(list.context), list.revision == revision, list.url == document.url, @@ -1354,6 +1531,7 @@ final class MonacoWorkbenchSession: NSObject, ObservableObject, WKNavigationDele } } case "edit": + cancelPluginActions(for: id) guard !self.isReadOnly(document), !failed, body["baseRevision"] as? Int == revisions[id], let changes = body["changes"] as? [[String: Any]] else { reply(nil, "Stale editor edit"); return } var bound = (document.text as NSString).length @@ -1681,6 +1859,9 @@ final class MonacoWorkbenchSession: NSObject, ObservableObject, WKNavigationDele private func fail(_ message: String) { failed = true + pluginActionRequests.values.forEach { $0.deadline.cancel(); $0.task.cancel() } + pluginActionExecutions.values.forEach { $0.task.cancel() } + pluginActionRequests.removeAll(); pluginActionExecutions.removeAll(); pluginActionLists.removeAll() contextMenu.dismiss() model?.showNotification("Editor: \(message)") } @@ -1691,6 +1872,8 @@ final class MonacoWorkbenchSession: NSObject, ObservableObject, WKNavigationDele func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { fail(error.localizedDescription) } isolated deinit { + pluginActionRequests.values.forEach { $0.deadline.cancel(); $0.task.cancel() } + pluginActionExecutions.values.forEach { $0.task.cancel() } contextMenu.dismiss() gitLoads.values.forEach { $0.cancel() } blameLoads.values.forEach { $0.cancel() } diff --git a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift index 5efd2773b8..8523b178d8 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift @@ -66,11 +66,13 @@ final class MacPluginBackendSessions: PluginBackendRequesting, PluginBackendActi private let credentials: any PluginCredentialStoring private let prepareState: @Sendable (_ installation: PluginInstallationRecord, _ stateDirectory: URL) -> Void private let endBackendProcesses: @MainActor (_ token: String, _ immediately: Bool) -> Void + private let canStart: @MainActor (String) -> Bool private let stuckSendGrace: Duration /// Serves the host requests of every backend. let hostDispatcher: PluginHostServiceDispatcher /// Reports a backend that an activation event failed to start; the /// workspace or command goes on without it. + var editorActions: PluginEditorActionHostService? var onActivationFailure: (@MainActor (_ pluginID: String, _ error: Error) -> Void)? private static let maximumBackgroundStarts = 2 private static let maximumPendingNotifications = 128 @@ -114,6 +116,7 @@ final class MacPluginBackendSessions: PluginBackendRequesting, PluginBackendActi credentials: any PluginCredentialStoring, prepareState: @escaping @Sendable (_ installation: PluginInstallationRecord, _ stateDirectory: URL) -> Void = { _, _ in }, stuckSendGrace: Duration = .seconds(2), + canStart: @escaping @MainActor (String) -> Bool = { _ in true }, hostDispatcher: PluginHostServiceDispatcher? = nil, endBackendProcesses: @escaping @MainActor (_ token: String, _ immediately: Bool) -> Void = { token, immediately in MacPluginBackendProcesses.end( @@ -131,6 +134,7 @@ final class MacPluginBackendSessions: PluginBackendRequesting, PluginBackendActi self.credentials = credentials self.prepareState = prepareState self.stuckSendGrace = stuckSendGrace + self.canStart = canStart self.endBackendProcesses = endBackendProcesses if let hostDispatcher { self.hostDispatcher = hostDispatcher @@ -213,7 +217,7 @@ final class MacPluginBackendSessions: PluginBackendRequesting, PluginBackendActi packages.filter { id, package in package.backendVersion >= 2 && package.activationEvents.contains(event) - && !disabledPluginIDs.contains(id) + && !disabledPluginIDs.contains(id) && canStart(id) && (owner == nil || owner == ModuleID("\(id).ui")) }.keys.sorted() } @@ -395,7 +399,7 @@ final class MacPluginBackendSessions: PluginBackendRequesting, PluginBackendActi /// exited is replaced, and so is one started with other setting values. private func session(for pluginID: String) async throws -> LanguageServerRuntimeSession { try Task.checkCancellation() - guard let package = packages[pluginID], !disabledPluginIDs.contains(pluginID) else { + guard let package = packages[pluginID], !disabledPluginIDs.contains(pluginID), canStart(pluginID) else { throw PluginFunctionFailure( code: "plugin.function.unavailable", message: L10n.string("This plugin has no backend that can run now.") @@ -497,6 +501,9 @@ final class MacPluginBackendSessions: PluginBackendRequesting, PluginBackendActi hostDispatcher.endSession(pluginID: pluginID) let sessionToken = hostDispatcher.beginSession(pluginID: pluginID, hostServices: hostServices) entry.hostServices = Set(hostServices) + session.onFunctionsChange = { [weak self] ids in + self?.editorActions?.updateFunctions(pluginID: pluginID, sessionID: sessionToken, ids: ids) + } session.onHostRequest = { [weak session] request in hostDispatcher.handle(request, pluginID: pluginID, sessionID: sessionToken) { outcome in session?.respond(to: request.id, with: outcome) diff --git a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginEditorLanguages.swift b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginEditorLanguages.swift new file mode 100644 index 0000000000..9ef406a313 --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginEditorLanguages.swift @@ -0,0 +1,137 @@ +import Combine +import Foundation +import LitheApplicationKernel +import LitheCoreContracts +import LitheModuleAPI + +// Note: 插件语法高亮与运行标记的决策见 .agents/notes/implemented/architecture/2026-10-31-plugin-editor-extension-apis.md + +/// Collects independent static editor resources of enabled plugins. +/// +/// Grammar files are read from the installed package, read-only and only +/// inside it; Rust Core validates them. Nothing is written: decoded grammars +/// live in memory for this launch, keyed by the file's path, size and date, +/// so an updated package is read again. +@MainActor +final class MacPluginEditorLanguages: PluginEditorLanguageProviding { + /// Largest grammar file Rust Core accepts (`MAX_GRAMMAR_BYTES`). + static let maximumGrammarBytes = 256 * 1024 + + private let launchRegistered: [InstalledPluginPackage] + private let isModuleUsable: @MainActor (ModuleID) -> Bool + private let livePackages: @MainActor () -> [InstalledPluginPackage] + private let decoder: any DeclarativeEditorContributionDecoding + private let log: @MainActor (_ message: String, _ fields: [String: String]) -> Void + private let changeSubject = PassthroughSubject() + private var subscriptions: Set = [] + /// Decoded languages by file identity; nil records a refused grammar so + /// it is reported once. + private var decoded: [String: ToolingJSONValue?] = [:] + + var changes: AnyPublisher { changeSubject.eraseToAnyPublisher() } + + /// The runtime owns this observer; the callback must not own the runtime. + @discardableResult + static func observeModuleChanges( + on runtime: ModuleRuntime, + changed: @escaping @MainActor () -> Void, + refreshBackends: @escaping @MainActor (ModuleRuntime) -> Void + ) -> UUID { + runtime.observeEvents { [weak runtime] event in + guard event.name == ModuleEvent.stateChangedName, let runtime else { return } + changed() + refreshBackends(runtime) + } + } + + /// - Parameters: + /// - launchRegistered: Plugins in the module graph since launch. Their editor resources follow the grammars module. + /// - livePackages: The enabled plugins of the live registry. + /// - triggers: Live registry and module state changes. + init( + launchRegistered: [InstalledPluginPackage], + isModuleUsable: @escaping @MainActor (ModuleID) -> Bool, + livePackages: @escaping @MainActor () -> [InstalledPluginPackage], + decoder: any DeclarativeEditorContributionDecoding, + triggers: [AnyPublisher], + log: @escaping @MainActor (_ message: String, _ fields: [String: String]) -> Void + ) { + self.launchRegistered = launchRegistered.filter { + $0.descriptor.languages.contains { $0.hasEditorResources } + } + self.isModuleUsable = isModuleUsable + self.livePackages = livePackages + self.decoder = decoder + self.log = log + for trigger in triggers { + trigger.sink { [weak self] in self?.changeSubject.send() }.store(in: &subscriptions) + } + } + + func current() -> PluginEditorLanguageSet { + var set = PluginEditorLanguageSet() + var seen: Set = [] + let grammarPackages = (launchRegistered.filter { + isModuleUsable(.grammarsExtension($0.descriptor.id.rawValue)) + } + livePackages()).sorted { $0.descriptor.id.rawValue < $1.descriptor.id.rawValue } + for package in grammarPackages { + for language in package.descriptor.languages where language.hasEditorResources { + // Static editor contributions may coexist with a domain owner. + // First enabled plugin ID wins deterministically for presentation. + guard seen.insert(language.id).inserted, let value = grammar(of: language, in: package) else { continue } + set.languages.append(value) + } + } + return set + } + + private func grammar(of language: DeclarativePluginLanguage, in package: InstalledPluginPackage) -> ToolingJSONValue? { + let fields = ["plugin": package.descriptor.id.rawValue, "language": language.id] + guard let path = language.grammarPath else { + return try? decoder.decodeGrammar(manifest: package.descriptor.normalizedManifest, languageID: language.id, grammar: "") + } + guard let url = Self.packageFile(path, in: package.packageURL) else { + log("plugin grammar is outside its package", fields) + return nil + } + guard (try? url.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true else { + log("plugin grammar is not a regular file", fields) + return nil + } + let attributes = try? FileManager.default.attributesOfItem(atPath: url.path) + let size = (attributes?[.size] as? NSNumber)?.intValue ?? -1 + let date = (attributes?[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0 + let key = "\(url.path)|\(size)|\(date)|\(language.id)|\(package.descriptor.normalizedManifest.base64EncodedString())" + if let cached = decoded[key] { return cached } + let value: ToolingJSONValue? + do { + guard size >= 0, size <= Self.maximumGrammarBytes else { + throw DeclarativePluginRejection(code: "plugin.grammar.invalid", + message: "The grammar file is missing or larger than 256 KiB.", field: "grammar.path") + } + let handle = try FileHandle(forReadingFrom: url) + defer { try? handle.close() } + let bytes = try handle.read(upToCount: Self.maximumGrammarBytes + 1) ?? Data() + guard bytes.count <= Self.maximumGrammarBytes, + let text = String(data: bytes, encoding: .utf8) else { + throw DeclarativePluginRejection(code: "plugin.grammar.invalid", + message: "The grammar file is not UTF-8.", field: "grammar.path") + } + value = try decoder.decodeGrammar(manifest: package.descriptor.normalizedManifest, + languageID: language.id, grammar: text) + } catch { + log("plugin grammar refused", fields.merging(["error": error.localizedDescription]) { _, new in new }) + value = nil + } + decoded[key] = .some(value) + return value + } + + /// `path` inside the package, after resolving links; nil when it leaves it. + static func packageFile(_ path: String, in packageURL: URL) -> URL? { + let root = packageURL.standardizedFileURL.resolvingSymlinksInPath().path + let file = packageURL.appendingPathComponent(path).standardizedFileURL.resolvingSymlinksInPath() + let prefix = root.hasSuffix("/") ? root : root + "/" + return file.path.hasPrefix(prefix) ? file : nil + } +} diff --git a/macos/Sources/Lithe/Views/App/PluginMarketplaceView.swift b/macos/Sources/Lithe/Views/App/PluginMarketplaceView.swift index a724bba107..5595b57405 100644 --- a/macos/Sources/Lithe/Views/App/PluginMarketplaceView.swift +++ b/macos/Sources/Lithe/Views/App/PluginMarketplaceView.swift @@ -756,6 +756,8 @@ enum PluginMarketplacePresentation { switch permission { case "languageServer.launch": PluginMarketplacePresentation.localized("Start a language server when you open files of its language", locale: locale) + case "editor.actions": + L10n.string("Add clickable actions beside document text") case "process.run": PluginMarketplacePresentation.localized("Run your files", locale: locale) case "ui.contribute": diff --git a/macos/Sources/LitheApplicationKernel/Plugins/PluginManifestValidator.swift b/macos/Sources/LitheApplicationKernel/Plugins/PluginManifestValidator.swift index f2a84bc4bb..704f14335b 100644 --- a/macos/Sources/LitheApplicationKernel/Plugins/PluginManifestValidator.swift +++ b/macos/Sources/LitheApplicationKernel/Plugins/PluginManifestValidator.swift @@ -167,7 +167,7 @@ public struct ValidatedPluginCatalog: Sendable { // another package owns; it never takes that ownership over. // Plugins are visited in ID order, so it waits until every // owning declaration is in, whichever ID sorts first. - if support.isDebugOnly { + if support.isDebugOnly || support.isEditorOnly { debugOnlySupports.append((plugin.id, support)) continue } diff --git a/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift b/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift index 590016a5b9..e67a02ddc4 100644 --- a/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift +++ b/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift @@ -319,16 +319,20 @@ public struct DeclarativePluginSetting: Equatable, Identifiable, Sendable { public struct DeclarativePluginLanguage: Equatable, Hashable, Sendable { public let id: String public let displayName: String + /// Package-relative grammar file (`languages` 2). The host reads it from + /// the installed package and has Rust Core validate it. + public let grammarPath: String? + public let hasEditorResources: Bool - public init(id: String, displayName: String) { + public init(id: String, displayName: String, grammarPath: String? = nil, hasEditorResources: Bool = false) { self.id = id self.displayName = displayName + self.grammarPath = grammarPath + self.hasEditorResources = hasEditorResources || grammarPath != nil } } -/// A button a plugin adds to a settings page. The host draws it and invokes -/// the named plugin function of the same plugin; what it does is the -/// plugin's business. +/// A settings button binds a function of the same plugin; its business stays private. public struct DeclarativePluginSettingAction: Equatable, Identifiable, Sendable { public let id: String public let title: String @@ -714,7 +718,10 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { declaresBackend = decoded.backend != nil settings = try decoded.settings.map { try $0.declaration() } settingActions = decoded.settingActions.map { $0.declaration() } - languages = decoded.languages.map { DeclarativePluginLanguage(id: $0.id, displayName: $0.displayName) } + languages = decoded.languages.map { + DeclarativePluginLanguage(id: $0.id, displayName: $0.displayName, grammarPath: $0.grammar?.path, + hasEditorResources: $0.grammar != nil || $0.configuration != nil) + } permissions = decoded.permissions activationEvents = decoded.activationEvents self.normalizedManifest = normalizedManifest @@ -786,6 +793,16 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { ) )) } + if languages.contains(where: \.hasEditorResources) { + // Enablement only: the editor applies the grammars while it is on. + modules.append(PluginModuleDeclaration( + manifest: LanguageExtensionModuleManifests.grammars( + pluginID: decoded.id, + displayName: decoded.displayName, + enabledByDefault: enabledByDefault + ) + )) + } if !projectGenerators.isEmpty { modules.append(PluginModuleDeclaration( manifest: LanguageExtensionModuleManifests.projectGenerators( @@ -804,6 +821,13 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { contributions: ui.contributions )) } + if decoded.backend != nil && decoded.ui == nil { + modules.append(PluginModuleDeclaration(manifest: ModuleManifest( + id: .backendExtension(decoded.id), displayName: decoded.displayName, + scope: .application, defaultState: enabledByDefault ? .enabled : .disabled, + activationPolicy: .onDemand + ))) + } let debuggedLanguages = Set(debugAdapters.flatMap(\.languageIDs)) let languageSupports = decoded.languages.map { language in let execution = executionByLanguage[language.id] @@ -833,6 +857,8 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { debugModuleID: debuggedLanguages.contains(language.id) ? .debugAdaptersExtension(decoded.id) : nil, + editorModuleID: language.grammar != nil || language.configuration != nil + ? .grammarsExtension(decoded.id) : nil, hostCapabilities: hostCapabilitiesByLanguage[language.id] ?? [] ) } @@ -858,16 +884,18 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { } /// Whether the plugin can take effect in a running session. That holds when - /// debug adapters are its only contribution: the host reads them on demand - /// when a debug session starts, so the module graph, the language-server and - /// run/test modules, backend processes and UI composition built at launch do - /// not change. Anything else waits for the next launch. + /// debug adapters and language grammars are its only contributions: the + /// host reads adapters on demand when a debug session starts and hands + /// grammars to open editors when the registry changes, so the module graph, + /// the language-server and run/test modules, backend processes and UI + /// composition built at launch do not change. Anything else waits for the + /// next launch. public var canActivateWithoutRestart: Bool { let modules = pluginManifest.modules - return !debugAdapters.isEmpty + let live: Set = [.debugAdaptersExtension(id.rawValue), .grammarsExtension(id.rawValue)] + return !modules.isEmpty && !declaresBackend - && modules.map(\.manifest.id) == [.debugAdaptersExtension(id.rawValue)] - && modules.allSatisfy { $0.contributions.isEmpty } + && modules.allSatisfy { live.contains($0.manifest.id) && $0.contributions.isEmpty } } private static func launch(_ launch: NormalizedManifest.Launch) -> DeclarativeLanguageServer.Launch { @@ -918,6 +946,12 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { let fileNames: [String] let projectFileNames: [String] let dependencies: Dependencies? + let grammar: Grammar? + let configuration: ToolingJSONValue? + + struct Grammar: Decodable { + let path: String + } init(from decoder: Decoder) throws { let values = try decoder.container(keyedBy: CodingKeys.self) @@ -927,10 +961,12 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { fileNames = try values.decodeIfPresent([String].self, forKey: .fileNames) ?? [] projectFileNames = try values.decodeIfPresent([String].self, forKey: .projectFileNames) ?? [] dependencies = try values.decodeIfPresent(Dependencies.self, forKey: .dependencies) + grammar = try values.decodeIfPresent(Grammar.self, forKey: .grammar) + configuration = try values.decodeIfPresent(ToolingJSONValue.self, forKey: .configuration) } private enum CodingKeys: String, CodingKey { - case id, displayName, fileExtensions, fileNames, projectFileNames, dependencies + case id, displayName, fileExtensions, fileNames, projectFileNames, dependencies, grammar, configuration } } struct Launch: Decodable { @@ -1506,8 +1542,13 @@ public struct ResolvedPluginSetting: Equatable, Sendable { } } -/// Rust Core's view of plugin settings: the values a launch would use and the -/// workspace configuration an `immediate` change sends to a running server. +/// Core validates package-owned editor resources; the host only reads files. +public protocol DeclarativeEditorContributionDecoding: Sendable { + /// The registered file type, optional grammar, and editor configuration. + func decodeGrammar(manifest: Data, languageID: String, grammar: String) throws -> ToolingJSONValue +} + +/// Settings values for launches and immediate workspace configuration changes. public protocol DeclarativePluginSettingsResolving: Sendable { func resolveSettings( manifest: Data, diff --git a/macos/Sources/LitheCoreContracts/Plugins/PluginEditorActionContracts.swift b/macos/Sources/LitheCoreContracts/Plugins/PluginEditorActionContracts.swift new file mode 100644 index 0000000000..416abaa851 --- /dev/null +++ b/macos/Sources/LitheCoreContracts/Plugins/PluginEditorActionContracts.swift @@ -0,0 +1,27 @@ +import Foundation + +/// Core validates independent editor presentation without interpreting plugin business data. +public protocol PluginEditorActionDecoding: Sendable { + func decodeEditorActions(kind: String, value: ToolingJSONValue, text: String) throws -> ToolingJSONValue +} + +/// One plugin-private action, anchored in a one-based UTF-16 document range. +public struct PluginEditorAction: Equatable, Decodable, Sendable { + public struct Position: Equatable, Decodable, Sendable { + public let line: Int + public let column: Int + } + public struct Range: Equatable, Decodable, Sendable { + public let start: Position + public let end: Position + } + public struct Invocation: Equatable, Decodable, Sendable { + public let functionID: String + public let params: ToolingJSONValue + } + public let id: String + public let range: Range + public let title: String + public let icon: String + public let invoke: Invocation +} diff --git a/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift b/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift index 343108bac0..95eee7b464 100644 --- a/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift +++ b/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift @@ -372,6 +372,24 @@ public enum LanguageExtensionModuleManifests { ) } + /// One module for all language grammars of a plugin (`languages` 2). It + /// owns no process: while it is enabled the editor highlights the + /// plugin's languages with their grammars. + public static func grammars( + pluginID: String, + displayName: String, + enabledByDefault: Bool = false + ) -> ModuleManifest { + ModuleManifest( + id: .grammarsExtension(pluginID), + displayName: displayName, + scope: .workspace, + defaultState: enabledByDefault ? .enabled : .disabled, + activationPolicy: .onDemand, + dependencies: [.module(.workspace)] + ) + } + /// One module for all project generators of a plugin. The New Project /// window runs without a workspace, so the module is application-scoped /// and owns no process: Core plans and the host creates the project. diff --git a/macos/Sources/LitheModuleAPI/Lifecycle/ModuleTypes.swift b/macos/Sources/LitheModuleAPI/Lifecycle/ModuleTypes.swift index 21c3fbeb43..d049041dab 100644 --- a/macos/Sources/LitheModuleAPI/Lifecycle/ModuleTypes.swift +++ b/macos/Sources/LitheModuleAPI/Lifecycle/ModuleTypes.swift @@ -319,6 +319,17 @@ public extension ModuleID { ModuleID("\(pluginID).debug-adapters") } + /// Enablement for a standalone tool backend with no UI/domain module. + static func backendExtension(_ pluginID: String) -> ModuleID { + ModuleID("\(pluginID).backend") + } + + /// The language grammars one declarative plugin declares, enabled + /// together. Nothing runs while it is enabled; it only gates highlighting. + static func grammarsExtension(_ pluginID: String) -> ModuleID { + ModuleID("\(pluginID).grammars") + } + /// The New Project entries one declarative plugin declares, enabled /// together. Nothing runs while it is enabled; it only gates the entries. static func projectGeneratorsExtension(_ pluginID: String) -> ModuleID { diff --git a/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift b/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift index 3bed98a4c6..7ac2b2ed1a 100644 --- a/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift +++ b/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift @@ -353,7 +353,7 @@ public extension Sequence where Element == LanguageSupportDeclaration { /// A debug-only declaration adds a debugger to a language another owner /// serves, so it never takes the host's run or test features away. func languageIDs(requiringExtensionFor capability: LanguageHostCapability) -> Set { - Set(lazy.filter { !$0.isDebugOnly && !$0.hostCapabilities.contains(capability) }.map(\.id)) + Set(lazy.filter { !$0.isDebugOnly && !$0.isEditorOnly && !$0.hostCapabilities.contains(capability) }.map(\.id)) } } @@ -371,6 +371,8 @@ public struct LanguageSupportDeclaration: Equatable, Codable, Sendable { public let executionModuleID: ModuleID? public let testingModuleID: ModuleID? public let debugModuleID: ModuleID? + /// Static editor resources; no language server or execution ownership. + public let editorModuleID: ModuleID? /// Features the host's own implementation provides for this language, as /// declared by the plugin's host integration. Never overlaps with a /// capability one of the modules above provides. @@ -388,6 +390,7 @@ public struct LanguageSupportDeclaration: Equatable, Codable, Sendable { executionModuleID: ModuleID? = nil, testingModuleID: ModuleID? = nil, debugModuleID: ModuleID? = nil, + editorModuleID: ModuleID? = nil, hostCapabilities: Set = [] ) { self.id = id @@ -401,6 +404,7 @@ public struct LanguageSupportDeclaration: Equatable, Codable, Sendable { self.executionModuleID = executionModuleID self.testingModuleID = testingModuleID self.debugModuleID = debugModuleID + self.editorModuleID = editorModuleID self.hostCapabilities = hostCapabilities } @@ -414,15 +418,21 @@ public struct LanguageSupportDeclaration: Equatable, Codable, Sendable { && hostCapabilities.isEmpty } + /// A presentation-only contribution must not claim a language's domain ownership. + public var isEditorOnly: Bool { + editorModuleID != nil && languageServerModuleID == nil && executionModuleID == nil + && testingModuleID == nil && debugModuleID == nil && hostCapabilities.isEmpty + } + public var moduleIDs: [ModuleID] { - [languageServerModuleID, executionModuleID, testingModuleID, debugModuleID].compactMap { $0 } + [languageServerModuleID, executionModuleID, testingModuleID, debugModuleID, editorModuleID].compactMap { $0 } } private enum CodingKeys: String, CodingKey { case id, displayName, fileExtensions, fileNames, projectFileNames case dependencies, virtualDocumentSchemes case languageServerModuleID, executionModuleID, testingModuleID, debugModuleID - case hostCapabilities + case editorModuleID, hostCapabilities } public init(from decoder: Decoder) throws { @@ -439,6 +449,7 @@ public struct LanguageSupportDeclaration: Equatable, Codable, Sendable { executionModuleID: try values.decodeIfPresent(ModuleID.self, forKey: .executionModuleID), testingModuleID: try values.decodeIfPresent(ModuleID.self, forKey: .testingModuleID), debugModuleID: try values.decodeIfPresent(ModuleID.self, forKey: .debugModuleID), + editorModuleID: try values.decodeIfPresent(ModuleID.self, forKey: .editorModuleID), hostCapabilities: try values.decodeIfPresent( Set.self, forKey: .hostCapabilities @@ -514,7 +525,7 @@ public struct PluginManifest: Equatable, Codable, Sendable { "buildSystems": [1, 2], "debugAdapters": [1, 2], "executableResolution": [1], "executionProviders": [1], "languageServers": [1, 2], - "languages": [1], + "languages": [1, 2], "projectGenerators": [1], "serverProcess": [1], "settings": [1, 2], diff --git a/macos/Tests/LitheTests/MonacoWorkbenchSessionLifetimeTests.swift b/macos/Tests/LitheTests/MonacoWorkbenchSessionLifetimeTests.swift index 0f1d8d7e29..7a119413c3 100644 --- a/macos/Tests/LitheTests/MonacoWorkbenchSessionLifetimeTests.swift +++ b/macos/Tests/LitheTests/MonacoWorkbenchSessionLifetimeTests.swift @@ -69,7 +69,7 @@ struct MonacoWorkbenchSessionLifetimeTests { @Test func readOnlyPreviewRejectsRunActionsAtTheNativeBoundary() { let main = MonacoWorkbenchSession.forModel(UUID()) let preview = MonacoWorkbenchSession.forModel(UUID(), documentID: UUID(), previewID: UUID()) - for message in ["javaRunMarkerAction", "format", "rename", "executeCodeAction", "setBlameVisibility"] { + for message in ["javaRunMarkerAction", "pluginEditorAction", "format", "rename", "executeCodeAction", "setBlameVisibility"] { #expect(main.allowsMessage(message)) #expect(!preview.allowsMessage(message)) } diff --git a/macos/Tests/LitheTests/PluginEditorActionTests.swift b/macos/Tests/LitheTests/PluginEditorActionTests.swift new file mode 100644 index 0000000000..4c557f54fb --- /dev/null +++ b/macos/Tests/LitheTests/PluginEditorActionTests.swift @@ -0,0 +1,97 @@ +import Foundation +import LitheCoreContracts +import Testing +@testable import Lithe + +@MainActor +struct PluginEditorActionTests { + @Test + func collectionUsesOneTotalBudgetRatherThanRestartingItForEachRegistration() { + #expect(PluginEditorActionCollectionBudget.remaining(after: .zero) == .seconds(8)) + #expect(PluginEditorActionCollectionBudget.remaining(after: .seconds(6)) == .seconds(2)) + #expect(PluginEditorActionCollectionBudget.remaining(after: .seconds(8)) == nil) + #expect(PluginEditorActionCollectionBudget.remaining(after: .seconds(12)) == nil) + } + + @Test + func registrationsAreIndependentScopedAndDisposable() async throws { + let fixture = PluginHostFixture() + defer { fixture.tearDown() } + let contexts = PluginWorkspaceContexts() + let context = contexts.open(owner: fixture.window, rootURL: PluginHostFixture.workspaceURL, remote: nil) + let service = PluginEditorActionHostService(contexts: contexts, decoder: DeclarativePluginTestSupport.operations) + let dispatcher = PluginHostServiceDispatcher(contexts: contexts) + dispatcher.register(service) + let session = dispatcher.beginSession(pluginID: "tool", hostServices: ["lithe/host/editor/registerActions"]) + defer { dispatcher.endSession(pluginID: "tool") } + service.updateFunctions(pluginID: "tool", sessionID: session, ids: ["actions.collect", "requests.send", "queries.execute"]) + let result = try await dispatcher.dispatch(request(context: context, function: "actions.collect"), pluginID: "tool").get() + let registration = try #require(service.registrations(context: context.reference, filename: "a.HTTP").first) + #expect(service.contains(registration, functionID: "requests.send")) + #expect(service.contains(registration, functionID: "queries.execute")) + #expect(!service.contains(registration, functionID: "anotherPlugin.execute")) + #expect(service.registrations(context: context.reference, filename: "a.txt").isEmpty) + service.updateFunctions(pluginID: "tool", sessionID: session, ids: ["actions.collect", "requests.send", "queries.execute", "extra.function"]) + #expect(service.contains(registration), "adding a function must not withdraw unrelated bindings") + guard case .object(let value) = result, case .string(let subscription)? = value["subscriptionID"] else { + Issue.record("missing subscription"); return + } + dispatcher.subscriptions.dispose(pluginID: "tool", subscriptionID: subscription) + #expect(!service.contains(registration)) + } + + @Test + func withdrawalAndSessionReplacementNeverResurrectOldLeases() async throws { + let fixture = PluginHostFixture() + defer { fixture.tearDown() } + let contexts = PluginWorkspaceContexts() + let context = contexts.open(owner: fixture.window, rootURL: PluginHostFixture.workspaceURL, remote: nil) + let service = PluginEditorActionHostService(contexts: contexts, decoder: DeclarativePluginTestSupport.operations) + let dispatcher = PluginHostServiceDispatcher(contexts: contexts) + dispatcher.register(service) + let session = dispatcher.beginSession(pluginID: "tool", hostServices: ["lithe/host/editor/registerActions"]) + defer { dispatcher.endSession(pluginID: "tool") } + service.updateFunctions(pluginID: "tool", sessionID: session, ids: ["actions.collect"]) + _ = try await dispatcher.dispatch(request(context: context, function: "actions.collect"), pluginID: "tool").get() + let old = try #require(service.registrations(context: context.reference, filename: "a.http").first) + service.updateFunctions(pluginID: "tool", sessionID: session, ids: []) + service.updateFunctions(pluginID: "tool", sessionID: session, ids: ["actions.collect"]) + #expect(!service.contains(old)) + _ = try await dispatcher.dispatch(request(context: context, function: "actions.collect"), pluginID: "tool").get() + let current = try #require(service.registrations(context: context.reference, filename: "a.http").first) + #expect(current.lease != old.lease) + contexts.close(owner: fixture.window) + #expect(!service.contains(current)) + #expect(dispatcher.subscriptions.count(pluginID: "tool") == 0) + } + + @Test + func missingPermissionAndUnknownFunctionDoNotCreateBindings() async throws { + let fixture = PluginHostFixture() + defer { fixture.tearDown() } + let contexts = PluginWorkspaceContexts() + let context = contexts.open(owner: fixture.window, rootURL: PluginHostFixture.workspaceURL, remote: nil) + let service = PluginEditorActionHostService(contexts: contexts, decoder: DeclarativePluginTestSupport.operations) + let dispatcher = PluginHostServiceDispatcher(contexts: contexts) + dispatcher.register(service) + dispatcher.beginSession(pluginID: "tool", hostServices: []) + defer { dispatcher.endSession(pluginID: "tool") } + if case .failure(let error) = await dispatcher.dispatch(request(context: context, function: "actions.collect"), pluginID: "tool") { + #expect(error.code == "plugin.host.permissionDenied") + } else { Issue.record("permission must be denied") } + let session = dispatcher.beginSession(pluginID: "tool", hostServices: ["lithe/host/editor/registerActions"]) + service.updateFunctions(pluginID: "tool", sessionID: session, ids: []) + if case .failure(let error) = await dispatcher.dispatch(request(context: context, function: "actions.collect"), pluginID: "tool") { + #expect(error.code == "plugin.host.invalidParams") + } else { Issue.record("unknown function must be denied") } + #expect(service.registrations(context: context.reference, filename: "a.http").isEmpty) + } + + private func request(context: PluginWorkspaceContext, function: String) -> PluginHostRequest { + PluginHostRequest(id: "registration", method: "lithe/host/editor/registerActions", service: "editor", params: .object([ + "context": .object(["contextID": .string(context.contextID), "generation": .integer(context.generation)]), + "id": .string("actions"), "function": .string(function), + "selector": .object(["fileExtensions": .array([.string("http")])]), + ])) + } +} diff --git a/macos/Tests/LitheTests/PluginEditorLanguagesTests.swift b/macos/Tests/LitheTests/PluginEditorLanguagesTests.swift new file mode 100644 index 0000000000..c995c489bb --- /dev/null +++ b/macos/Tests/LitheTests/PluginEditorLanguagesTests.swift @@ -0,0 +1,205 @@ +import Combine +import Foundation +@testable import Lithe +import LitheCoreContracts +import LitheModuleAPI +import LitheApplicationKernel +import Testing + +/// Static editor resources are accepted without language-server or Run providers. +@Suite("Plugin editor languages") +@MainActor +struct PluginEditorLanguagesTests { + private static let fixtures = DeclarativePluginTestSupport.repositoryRoot + .appendingPathComponent("shared/contracts/plugins/v1/fixtures", isDirectory: true) + + @Test + func pluginModuleObservationDeliversChangesWithoutRetainingItsRuntime() { + weak var released: ModuleRuntime? + var changes = 0, backendRefreshes = 0 + do { + let runtime = ModuleRuntime() + released = runtime + MacPluginEditorLanguages.observeModuleChanges(on: runtime, changed: { changes += 1 }) { observed in + #expect(observed === released) + backendRefreshes += 1 + } + runtime.publish(ModuleEvent(source: .workspace, name: "unrelated")) + #expect(changes == 0 && backendRefreshes == 0) + runtime.publish(ModuleEvent(source: .workspace, name: ModuleEvent.stateChangedName)) + #expect(changes == 1 && backendRefreshes == 1) + } + #expect(released == nil, "The plugin state observer must not own its event source") + } + + @Test + func orderedGrammarSourceSurvivesCoreAndSwiftDictionaryRoundtrips() throws { + let descriptor = try Self.descriptor("language-grammar") + let source = #"{"tokenizer":{"comment":[[".*","comment"]],"root":[["[A-Z]+",{"cases":{"GET":"keyword","[A-Z]+":"string","@default":"identifier"}}]]}}"# + let decoded = try DeclarativePluginTestSupport.operations.decodeGrammar( + manifest: descriptor.normalizedManifest, languageID: "http", grammar: source) + let encoder = JSONEncoder() + encoder.outputFormatting = .sortedKeys + let payload = try JSONDecoder().decode(ToolingJSONValue.self, from: encoder.encode(decoded)) + guard case .object(let language) = payload, case .object(let grammar)? = language["grammar"] else { + Issue.record("missing grammar payload"); return + } + #expect(grammar["source"] == .string(source)) + #expect(grammar["monarch"] == nil) + } + + @Test + func aGrammarOnlyPluginActivatesWithoutARestart() throws { + let descriptor = try Self.descriptor("language-grammar") + #expect(descriptor.languages.map(\.grammarPath) == ["grammars/http.monarch.json"]) + #expect(descriptor.pluginManifest.modules.map(\.manifest.id) == [.grammarsExtension(descriptor.id.rawValue)]) + #expect(descriptor.canActivateWithoutRestart) + } + + @Test + func grammarContributionsPassTheRealCatalogValidation() throws { + let descriptor = try Self.descriptor("language-grammar") + let catalog = try ValidatedPluginCatalog(manifests: BuiltInPluginCatalog.manifests + [descriptor.pluginManifest], + hostVersion: BuiltInPluginCatalog.hostVersion) + #expect(catalog.languageSupport(for: URL(fileURLWithPath: "/workspace/test.http")) != nil) + #expect(descriptor.pluginManifest.languageSupports?.first?.isEditorOnly == true) + #expect(descriptor.pluginManifest.languageSupports?.languageIDs(requiringExtensionFor: .run).isEmpty == true) + } + + @Test + func aStandaloneToolBackendPassesTheCatalogWithoutFakeUIOrLSP() throws { + let descriptor = try Self.descriptor("editor-actions") + #expect(descriptor.pluginManifest.modules.map(\.manifest.id) == [.backendExtension(descriptor.id.rawValue)]) + #expect(descriptor.languages.isEmpty) + #expect(descriptor.languageServers.isEmpty) + _ = try ValidatedPluginCatalog(manifests: BuiltInPluginCatalog.manifests + [descriptor.pluginManifest], + hostVersion: BuiltInPluginCatalog.hostVersion) + } + + @Test + func configurationOnlyNeedsNoBackendAndDecodesWithoutAFile() throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + var object = try #require(JSONSerialization.jsonObject(with: Data(contentsOf: + Self.fixtures.appendingPathComponent("language-grammar.plugin.json"))) as? [String: Any]) + var languages = try #require(object["languages"] as? [[String: Any]]) + languages[0]["grammar"] = nil + object["languages"] = languages + let descriptor = try DeclarativePluginTestSupport.operations.validate(manifest: JSONSerialization.data(withJSONObject: object)) + #expect(!descriptor.declaresBackend) + #expect(descriptor.canActivateWithoutRestart) + let decoded = try DeclarativePluginTestSupport.operations.decodeGrammar(manifest: descriptor.normalizedManifest, + languageID: "http", grammar: "") + guard case .object(let result) = decoded, case .object(let configuration)? = result["configuration"] else { + Issue.record("missing independent editor configuration"); return + } + #expect(result["grammar"] == .null) + #expect(configuration["lineComment"] == .string("#")) + } + + @Test + func liveGrammarsReachTheEditorAndLeaveWithThePlugin() throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let package = try fixture.package("language-grammar") + var live = [package] + let languages = fixture.languages(livePackages: { live }) + let set = languages.current() + #expect(set.languages.count == 1) + guard case .object(let language) = try #require(set.languages.first), + case .object(let grammar) = try #require(language["grammar"]) else { + Issue.record("decoded language is not an object"); return + } + #expect(language["id"] == .string("http")) + #expect(grammar["kind"] == .string("monarch")) + let source = try String(contentsOf: Self.fixtures.appendingPathComponent("grammars/http.monarch.json"), encoding: .utf8) + #expect(grammar["source"] == .string(source)) + live = [] + #expect(languages.current() == PluginEditorLanguageSet()) + } + + @Test + func staticEditorResourcesFollowTheirOwnEnablement() throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let package = try fixture.package("language-grammar") + var enabled = true + let languages = fixture.languages(launchRegistered: [package], isModuleUsable: { _ in enabled }) + #expect(languages.current().languages.count == 1) + enabled = false + #expect(languages.current().languages.isEmpty) + } + + @Test + func aGrammarOutsideItsPackageOrTooLargeIsRefusedOnce() throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let package = try fixture.package("language-grammar") + let grammar = package.packageURL.appendingPathComponent("grammars/http.monarch.json") + try FileManager.default.removeItem(at: grammar) + let outside = fixture.root.appendingPathComponent("outside.json") + try Data("{}".utf8).write(to: outside) + try FileManager.default.createSymbolicLink(at: grammar, withDestinationURL: outside) + #expect(MacPluginEditorLanguages.packageFile("grammars/http.monarch.json", in: package.packageURL) == nil) + #expect(MacPluginEditorLanguages.packageFile("../outside.json", in: package.packageURL) == nil) + + var messages: [String] = [] + let languages = fixture.languages(livePackages: { [package] }, log: { message, _ in messages.append(message) }) + #expect(languages.current().languages.isEmpty) + try FileManager.default.removeItem(at: grammar) + try Data(repeating: 0x20, count: MacPluginEditorLanguages.maximumGrammarBytes + 1).write(to: grammar) + #expect(languages.current().languages.isEmpty) + #expect(languages.current().languages.isEmpty) + #expect(messages == ["plugin grammar is outside its package", "plugin grammar refused"]) + } + + private static func descriptor(_ name: String) throws -> DeclarativePluginDescriptor { + try DeclarativePluginTestSupport.operations.validate( + manifest: Data(contentsOf: fixtures.appendingPathComponent("\(name).plugin.json")) + ) + } + + @MainActor + private final class Fixture { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("PluginEditorLanguagesTests-\(UUID().uuidString)", isDirectory: true) + + init() throws { + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + } + + func package(_ name: String) throws -> InstalledPluginPackage { + let descriptor = try PluginEditorLanguagesTests.descriptor(name) + let packageURL = root.appendingPathComponent(name, isDirectory: true) + let grammars = packageURL.appendingPathComponent("grammars", isDirectory: true) + try FileManager.default.createDirectory(at: grammars, withIntermediateDirectories: true) + try FileManager.default.copyItem( + at: PluginEditorLanguagesTests.fixtures.appendingPathComponent("grammars/http.monarch.json"), + to: grammars.appendingPathComponent("http.monarch.json") + ) + return InstalledPluginPackage( + descriptor: descriptor, + installation: PluginInstallationRecord( + pluginID: descriptor.id, activeVersion: descriptor.pluginManifest.version, + origin: .marketplace, status: .installed + ), + packageURL: packageURL + ) + } + + @MainActor + func languages( + launchRegistered: [InstalledPluginPackage] = [], + isModuleUsable: @escaping @MainActor (ModuleID) -> Bool = { _ in true }, + livePackages: @escaping @MainActor () -> [InstalledPluginPackage] = { [] }, + log: @escaping @MainActor (String, [String: String]) -> Void = { _, _ in } + ) -> MacPluginEditorLanguages { + MacPluginEditorLanguages( + launchRegistered: launchRegistered, isModuleUsable: isModuleUsable, livePackages: livePackages, + decoder: DeclarativePluginTestSupport.operations, triggers: [], log: log + ) + } + + func cleanUp() { try? FileManager.default.removeItem(at: root) } + } +} diff --git a/rust/lithe-core/src/lsp/interface/plugin_function.rs b/rust/lithe-core/src/lsp/interface/plugin_function.rs index 6ca174a28a..3bdfb4caf5 100644 --- a/rust/lithe-core/src/lsp/interface/plugin_function.rs +++ b/rust/lithe-core/src/lsp/interface/plugin_function.rs @@ -100,6 +100,7 @@ pub(crate) const HOST_PERMISSIONS: &[&str] = &[ "workspace.search", "documents.read", "editor.edit", + "editor.actions", "editor.open", "preview.open", "browser.open", @@ -610,6 +611,13 @@ pub(crate) const HOST_METHODS: &[HostMethod] = &[ since: CONTEXT_VERSION, params: HostParams::Object, }, + HostMethod { + method: "lithe/host/editor/registerActions", + service: "editor", + permission: Some("editor.actions"), + since: CONTEXT_VERSION, + params: HostParams::Object, + }, HostMethod { method: "lithe/host/editor/getActive", service: "editor", diff --git a/rust/lithe-core/src/plugins/capabilities.rs b/rust/lithe-core/src/plugins/capabilities.rs index e1e56208c8..e17d2f2b1c 100644 --- a/rust/lithe-core/src/plugins/capabilities.rs +++ b/rust/lithe-core/src/plugins/capabilities.rs @@ -22,7 +22,8 @@ use std::collections::{BTreeMap, BTreeSet}; /// First plugin API level whose manifests declare capability versions. pub(crate) const CAPABILITIES_API_VERSION: u32 = 3; -/// `languages[]`: file types and project markers. +/// `languages[]`: file types and project markers. Version 2 adds a +/// declarative `grammar` and an editing `configuration`; see `super::grammar`. pub(crate) const LANGUAGES: &str = "languages"; /// `languageServers[]` apart from their launch fields. Version 2 adds /// `workspaceConfiguration`. @@ -80,7 +81,7 @@ pub const SUPPORTED_CAPABILITIES: &[(&str, &[u32])] = &[ (EXECUTION_PROVIDERS, &[1]), (EXECUTABLE_RESOLUTION, &[1]), (LANGUAGE_SERVERS, &[1, 2]), - (LANGUAGES, &[1]), + (LANGUAGES, &[1, 2]), (PACKAGE_TRUST, &[1]), (PROJECT_GENERATORS, &[1]), (SERVER_PROCESS, &[1, 2]), diff --git a/rust/lithe-core/src/plugins/declarative.rs b/rust/lithe-core/src/plugins/declarative.rs index 62770d46be..a13ab4897a 100644 --- a/rust/lithe-core/src/plugins/declarative.rs +++ b/rust/lithe-core/src/plugins/declarative.rs @@ -421,6 +421,13 @@ struct LanguageDeclaration { project_file_names: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] dependencies: Option, + /// Syntax highlighting from a package file. `languages` 2; see + /// `super::grammar`. + #[serde(default, skip_serializing_if = "Option::is_none")] + grammar: Option, + /// Comment and bracket behavior of the editor. `languages` 2. + #[serde(default, skip_serializing_if = "Option::is_none")] + configuration: Option, } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -857,9 +864,14 @@ impl DeclarativePluginManifest { return Err(invalid("languages", "The plugin declares no languages.")); } + let languages_version = self + .requires + .get(capabilities::LANGUAGES) + .copied() + .unwrap_or(1); let mut language_ids = BTreeSet::new(); for (index, language) in self.languages.iter_mut().enumerate() { - language.normalize(index)?; + language.normalize(index, languages_version)?; if !language_ids.insert(language.id.clone()) { return Err(invalid( format!("languages[{index}].id"), @@ -868,6 +880,17 @@ impl DeclarativePluginManifest { } } self.languages.sort_by(|left, right| left.id.cmp(&right.id)); + if languages_version >= super::grammar::GRAMMAR_LANGUAGES_VERSION + && !self + .languages + .iter() + .any(LanguageDeclaration::uses_version_2) + { + return Err(invalid( + "requires.languages", + "The declared languages version must be used: languages 2 adds grammar and configuration.", + )); + } let declared_settings = self.validate_settings()?; @@ -983,6 +1006,8 @@ impl DeclarativePluginManifest { && self.debug_adapters.is_empty() && self.project_generators.is_empty() && self.backend.is_none() + // A grammar alone is a complete highlighting plugin. + && !self.languages.iter().any(LanguageDeclaration::uses_version_2) { return Err(invalid( "languages", @@ -1406,6 +1431,20 @@ impl DeclarativePluginManifest { "Host API permissions require backend 2.", )); } + if self + .permissions + .iter() + .any(|permission| permission == "editor.actions") + && !self + .permissions + .iter() + .any(|permission| permission == "documents.read") + { + return Err(invalid( + "permissions", + "Editor actions require documents.read for live snapshots.", + )); + } for permission in required { if !self .permissions @@ -1750,8 +1789,29 @@ impl DeclarativePluginManifest { } impl LanguageDeclaration { - fn normalize(&mut self, index: usize) -> Validation<()> { + /// Whether the language uses a field `languages` 2 adds. + fn uses_version_2(&self) -> bool { + self.grammar.is_some() || self.configuration.is_some() + } + + fn normalize(&mut self, index: usize, capability_version: u32) -> Validation<()> { let field = format!("languages[{index}]"); + if self.uses_version_2() && capability_version < super::grammar::GRAMMAR_LANGUAGES_VERSION { + return Err(invalid( + if self.grammar.is_some() { + format!("{field}.grammar") + } else { + format!("{field}.configuration") + }, + "grammar and configuration require version 2 of the languages capability.", + )); + } + if let Some(grammar) = &self.grammar { + grammar.validate(&format!("{field}.grammar"))?; + } + if let Some(configuration) = &self.configuration { + configuration.validate(&format!("{field}.configuration"))?; + } let id = self.id.trim().to_lowercase(); if id.is_empty() || id != self.id @@ -4121,6 +4181,62 @@ pub fn workspace_configuration_command(payload: Value) -> Result Result { + let payload: GrammarPayload = decode_payload(payload, "plugins.declarative.decodeGrammar")?; + Ok(outcome(validate_manifest_value(payload.manifest).and_then( + |manifest| { + let language = manifest + .languages + .iter() + .find(|language| language.id == payload.language_id) + .filter(|language| language.grammar.is_some() || language.configuration.is_some()) + .ok_or_else(|| { + PluginRejection::new( + "plugin.grammar.unavailable", + format!( + "The plugin declares no grammar for {}.", + payload.language_id + ), + ) + })?; + let grammar = language + .grammar + .as_ref() + .map(|grammar| match grammar.kind { + super::grammar::GrammarKind::Monarch => { + // Monarch cases are ordered. Keep the validated source + // opaque across JSON maps and platform dictionaries. + super::grammar::decode_monarch(&payload.grammar) + .map(|_| json!({"kind": grammar.kind, "source": payload.grammar})) + } + }) + .transpose()?; + Ok(( + "language", + json!({ + "id": language.id, + "fileExtensions": language.file_extensions, + "fileNames": language.file_names, + "grammar": grammar, + "configuration": language.configuration, + }), + )) + }, + ))) +} + /// `plugins.declarative.checkRuntimeVersion`: decides whether a launch /// runtime's version output meets the plan's `minimumVersion`. pub fn check_runtime_version_command(payload: Value) -> Result { diff --git a/rust/lithe-core/src/plugins/editor_actions.rs b/rust/lithe-core/src/plugins/editor_actions.rs new file mode 100644 index 0000000000..5ea741b24b --- /dev/null +++ b/rust/lithe-core/src/plugins/editor_actions.rs @@ -0,0 +1,250 @@ +//! Independent editor actions for tool plugins, not language/build/run providers. +//! +//! Core validates presentation and opaque function bindings. Plugin parsers own +//! their business facts; hosts own document versions, session leases and clicks. + +use crate::lsp::interface::plugin_function::is_function_id; +use crate::protocol::{CoreError, ErrorCode}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::BTreeSet; + +const MAX_ACTIONS: usize = 1000; +const MAX_BYTES: usize = 1024 * 1024; + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Registration { + id: String, + function: String, + selector: Selector, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Selector { + #[serde(default)] + file_extensions: Vec, + #[serde(default)] + file_names: Vec, +} + +#[derive(Debug, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(deny_unknown_fields)] +struct Position { + line: usize, + column: usize, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct Range { + start: Position, + end: Position, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Invocation { + #[serde(rename = "functionID")] + function_id: String, + /// Plugin-private data: Core never interprets tool names or selectors. + #[serde(default = "empty_params")] + params: Value, +} + +fn empty_params() -> Value { + json!({}) +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct Action { + id: String, + range: Range, + title: String, + /// Presentation only; never identifies a Run/Test/Debug domain operation. + icon: String, + invoke: Invocation, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ResultList { + actions: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Payload { + kind: String, + value: Value, + #[serde(default)] + text: String, +} + +fn invalid(message: impl Into) -> CoreError { + CoreError::new(ErrorCode::InvalidRequest, message) +} + +fn identifier(value: &str) -> bool { + !value.is_empty() && value.len() <= 128 && !value.chars().any(char::is_control) +} + +fn registration(value: Value) -> Result { + let mut item: Registration = serde_json::from_value(value) + .map_err(|_| invalid("An editor action registration needs id, function and selector."))?; + if !identifier(&item.id) || !is_function_id(&item.function) { + return Err(invalid( + "Invalid editor action registration or function ID.", + )); + } + if item.selector.file_extensions.len() + item.selector.file_names.len() > 32 + || item.selector.file_extensions.is_empty() && item.selector.file_names.is_empty() + { + return Err(invalid( + "An editor selector has 1 to 32 file names/extensions.", + )); + } + for extension in &mut item.selector.file_extensions { + *extension = extension.trim_start_matches('.').to_lowercase(); + } + for name in &mut item.selector.file_names { + *name = name.to_lowercase(); + } + for name in item + .selector + .file_extensions + .iter() + .chain(&item.selector.file_names) + { + if !identifier(name) || name.contains(['/', '\\', '*', '?', ':']) { + return Err(invalid( + "An editor selector contains literal file names/extensions only.", + )); + } + } + item.selector.file_extensions.sort(); + item.selector.file_extensions.dedup(); + item.selector.file_names.sort(); + item.selector.file_names.dedup(); + serde_json::to_value(item).map_err(|_| invalid("Cannot encode editor registration.")) +} + +fn actions(value: Value, text: &str) -> Result { + let mut result: ResultList = serde_json::from_value(value) + .map_err(|_| invalid("An editor action result is { actions: [...] }."))?; + if result.actions.len() > MAX_ACTIONS || text.len() > 128 * 1024 { + return Err(invalid( + "The editor action result or document exceeds its limit.", + )); + } + let normalized = text.replace("\r\n", "\n").replace('\r', "\n"); + let lines: Vec<&str> = normalized.split('\n').collect(); + let valid_position = |position: &Position| { + lines + .get(position.line.wrapping_sub(1)) + .is_some_and(|line| { + let line = line.strip_suffix('\r').unwrap_or(line); + if position.column == 0 { + return false; + } + let target = position.column - 1; + let mut offset = 0; + for character in line.chars() { + if offset == target { + return true; + } + offset += character.len_utf16(); + } + offset == target + }) + }; + let mut ids = BTreeSet::new(); + for item in &result.actions { + if !identifier(&item.id) + || !ids.insert(&item.id) + || item.title.trim().is_empty() + || item.title.chars().count() > 200 + || item.title.chars().any(char::is_control) + || !["run", "run-all"].contains(&item.icon.as_str()) + || !is_function_id(&item.invoke.function_id) + || !item.invoke.params.is_object() + || item.range.start > item.range.end + || !valid_position(&item.range.start) + || !valid_position(&item.range.end) + { + return Err(invalid( + "Invalid editor action identity, range, presentation or invocation.", + )); + } + } + result + .actions + .sort_by(|a, b| a.range.start.cmp(&b.range.start).then(a.id.cmp(&b.id))); + serde_json::to_value(result.actions).map_err(|_| invalid("Cannot encode editor actions.")) +} + +/// Validates an independent registration (`kind: registration`) or a parser +/// result (`kind: actions`, with its exact text). No I/O or tool parsing occurs. +pub fn decode_command(payload: Value) -> Result { + let payload: Payload = serde_json::from_value(payload) + .map_err(|_| invalid("Invalid editor action validation payload."))?; + if payload.value.to_string().len() > MAX_BYTES { + return Err(invalid("Editor action data exceeds 1 MiB.")); + } + match payload.kind.as_str() { + "registration" => Ok(json!({"registration": registration(payload.value)?})), + "actions" => Ok(json!({"actions": actions(payload.value, &payload.text)?})), + _ => Err(invalid("Unknown editor action validation kind.")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn different_tools_share_registration_and_opaque_invocations() { + for (extension, function) in [("http", "requests.send"), ("sql", "queries.execute")] { + let registration = decode_command(json!({"kind":"registration", "value":{ + "id":"actions", "function":"actions.collect", "selector":{"fileExtensions":[extension]} + }})).unwrap(); + assert_eq!( + registration["registration"]["selector"]["fileExtensions"][0], + extension + ); + let result = decode_command(json!({"kind":"actions", "text":"hello\nworld", "value":{"actions":[{ + "id":"a", "range":{"start":{"line":1,"column":1},"end":{"line":2,"column":6}}, + "title":"Execute", "icon":"run", "invoke":{"functionID":function,"params":{"opaque":[1,2]}} + }]}})).unwrap(); + assert_eq!( + result["actions"][0]["invoke"]["params"]["opaque"], + json!([1, 2]) + ); + } + } + + #[test] + fn invalid_ranges_and_duplicate_ids_reject_the_whole_result() { + let item = json!({"id":"a","range":{"start":{"line":1,"column":1},"end":{"line":1,"column":3}}, + "title":"Execute","icon":"run","invoke":{"functionID":"action.execute","params":{}}}); + for (text, items) in [ + ("x", json!([item.clone()])), + ("abc", json!([item.clone(), item])), + ] { + assert!(decode_command( + json!({"kind":"actions","text":text,"value":{"actions":items}}) + ) + .is_err()); + } + // Column 2 splits an astral character's UTF-16 surrogate pair. + assert!( + decode_command(json!({"kind":"actions","text":"😀","value":{"actions":[{ + "id":"a","range":{"start":{"line":1,"column":2},"end":{"line":1,"column":3}}, + "title":"Execute","icon":"run","invoke":{"functionID":"action.execute"} + }]}})) + .is_err() + ); + } +} diff --git a/rust/lithe-core/src/plugins/grammar.rs b/rust/lithe-core/src/plugins/grammar.rs new file mode 100644 index 0000000000..0133e68c0e --- /dev/null +++ b/rust/lithe-core/src/plugins/grammar.rs @@ -0,0 +1,617 @@ +//! Declarative syntax highlighting of plugin languages, `languages` 2. +//! +//! A language may name a grammar file in its package and a small editing +//! configuration (comments and brackets). The grammar is data the editor's +//! tokenizer engine runs; `kind` names that engine, so a later version can add +//! another engine without changing what an existing grammar means. Version 2 +//! accepts only `monarch`, the tokenizer the shared Monaco editor already +//! runs for its built-in languages. Plugin definitions compile and tokenize +//! only in a separate, terminable worker; no extra regex engine is introduced. +//! +//! The host reads the file from the read-only package and passes its text to +//! `plugins.declarative.decodeGrammar`; Core never opens package files. Core +//! checks the Monarch subset below and bounds its size, so a hostile grammar +//! cannot write to the console, jump to a state that does not exist, or grow +//! without limit. Expanded include graphs have their own budget. Regex cost +//! cannot be proven by string length: the host editor terminates a stalled +//! worker and keeps text editable without executing plugin regexes on its UI thread. +//! +//! Note: the decision is recorded in .agents/notes/implemented/architecture/2026-10-31-plugin-editor-extension-apis.md + +use super::declarative::{invalid, is_safe_relative_path, PluginRejection, Validation}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use std::collections::BTreeSet; + +/// First `languages` version with `grammar` and `configuration`. +pub(crate) const GRAMMAR_LANGUAGES_VERSION: u32 = 2; + +/// Largest grammar file, in UTF-8 bytes. +pub(crate) const MAX_GRAMMAR_BYTES: usize = 256 * 1024; +/// Most tokenizer states in one grammar. +const MAX_STATES: usize = 256; +/// Most rules across every state. +const MAX_RULES: usize = 4096; +/// Longest regular expression source or attribute string. +const MAX_REGEX_BYTES: usize = 2048; +/// Most entries of one attribute list, such as `keywords`. +const MAX_ATTRIBUTE_ENTRIES: usize = 4096; +/// Longest attribute list entry. +const MAX_ATTRIBUTE_ENTRY_BYTES: usize = 256; +/// Most guards of one `cases` object. +const MAX_CASES: usize = 64; +/// Deepest nesting of actions inside groups and `cases`. +const MAX_ACTION_DEPTH: usize = 8; +/// Longest token, state, or embedded language name. +const MAX_NAME_BYTES: usize = 128; +/// Most bracket pairs in a grammar or a language configuration. +const MAX_BRACKETS: usize = 32; +/// Longest comment or bracket delimiter. +const MAX_DELIMITER_BYTES: usize = 16; + +/// Keys of an action object. `log` is refused: a grammar never writes to the +/// editor's console. +const ACTION_KEYS: &[&str] = &[ + "token", + "next", + "switchTo", + "goBack", + "bracket", + "nextEmbedded", + "cases", +]; + +/// Engine that runs a grammar. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum GrammarKind { + /// A Monaco Monarch tokenizer as JSON. + Monarch, +} + +/// `languages[].grammar`: the engine and the package file it reads. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct GrammarDeclaration { + pub(crate) kind: GrammarKind, + /// Package-relative grammar file, e.g. `syntax/http.monarch.json`. + pub(crate) path: String, +} + +/// `languages[].configuration`: comment and bracket behavior of the editor. +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct LanguageConfiguration { + #[serde(default, skip_serializing_if = "Option::is_none")] + line_comment: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + block_comment: Option<[String; 2]>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + brackets: Vec<[String; 2]>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + auto_closing_pairs: Vec<[String; 2]>, +} + +impl GrammarDeclaration { + pub(crate) fn validate(&self, field: &str) -> Validation<()> { + if !is_safe_relative_path(&self.path) || self.path.contains(':') { + return Err(invalid( + format!("{field}.path"), + "The grammar must be a file inside the plugin package.", + )); + } + Ok(()) + } +} + +impl LanguageConfiguration { + pub(crate) fn validate(&self, field: &str) -> Validation<()> { + let delimiter = |value: &str| { + !value.is_empty() + && value.len() <= MAX_DELIMITER_BYTES + && !value.chars().any(char::is_control) + }; + if self + .line_comment + .as_deref() + .is_some_and(|value| !delimiter(value)) + { + return Err(invalid( + format!("{field}.lineComment"), + "A comment delimiter is 1 to 16 bytes without control characters.", + )); + } + if self + .block_comment + .as_ref() + .is_some_and(|[open, close]| !delimiter(open) || !delimiter(close)) + { + return Err(invalid( + format!("{field}.blockComment"), + "A comment delimiter is 1 to 16 bytes without control characters.", + )); + } + for (key, pairs) in [ + ("brackets", &self.brackets), + ("autoClosingPairs", &self.auto_closing_pairs), + ] { + if pairs.len() > MAX_BRACKETS + || pairs + .iter() + .any(|[open, close]| !delimiter(open) || !delimiter(close)) + { + return Err(invalid( + format!("{field}.{key}"), + "At most 32 pairs, each delimiter 1 to 16 bytes without control characters.", + )); + } + } + Ok(()) + } +} + +fn grammar_error(field: impl Into, message: impl Into) -> PluginRejection { + PluginRejection::at("plugin.grammar.invalid", field, message) +} + +/// Validates the text of a Monarch grammar file and returns it as a JSON +/// object for validation. Hosts transport the original text, not this map, +/// because Monarch case precedence depends on source key order. +pub(crate) fn decode_monarch(text: &str) -> Validation { + if text.len() > MAX_GRAMMAR_BYTES { + return Err(grammar_error( + "grammar", + "The grammar file is larger than 256 KiB.", + )); + } + let value: Value = serde_json::from_str(text) + .map_err(|error| grammar_error("grammar", format!("The grammar is not JSON: {error}.")))?; + let Value::Object(grammar) = &value else { + return Err(grammar_error( + "grammar", + "A Monarch grammar is a JSON object.", + )); + }; + let Some(Value::Object(tokenizer)) = grammar.get("tokenizer") else { + return Err(grammar_error( + "grammar.tokenizer", + "A Monarch grammar has a tokenizer object.", + )); + }; + if tokenizer.is_empty() || tokenizer.len() > MAX_STATES { + return Err(grammar_error( + "grammar.tokenizer", + "A tokenizer has 1 to 256 states.", + )); + } + let states: BTreeSet<&str> = tokenizer.keys().map(String::as_str).collect(); + if let Some(state) = states.iter().find(|state| !is_state_name(state)) { + return Err(grammar_error( + format!("grammar.tokenizer.{state}"), + "A state name is an identifier, optionally with dot-separated parts.", + )); + } + let start = match grammar.get("start") { + None => "root", + Some(Value::String(start)) => start.as_str(), + Some(_) => return Err(grammar_error("grammar.start", "start names a state.")), + }; + if !states.contains(start) { + return Err(grammar_error( + "grammar.start", + format!("The tokenizer has no {start} state to start in."), + )); + } + let mut attributes = BTreeSet::new(); + for (key, entry) in grammar { + let field = format!("grammar.{key}"); + match key.as_str() { + "tokenizer" | "start" => {} + "ignoreCase" | "unicode" | "includeLF" => { + if !entry.is_boolean() { + return Err(grammar_error(field, format!("{key} is a boolean."))); + } + } + "defaultToken" | "tokenPostfix" => { + if !entry.as_str().is_some_and(is_token) { + return Err(grammar_error(field, format!("{key} is a token name."))); + } + } + "brackets" => check_grammar_brackets(entry, &field)?, + _ => { + if !is_identifier(key) { + return Err(grammar_error(field, "An attribute name is an identifier.")); + } + check_attribute(entry, &field)?; + attributes.insert(key.as_str()); + } + } + } + let context = RuleContext { + states: &states, + attributes: &attributes, + }; + let mut rules = 0; + for (state, entry) in tokenizer { + let field = format!("grammar.tokenizer.{state}"); + let Value::Array(entries) = entry else { + return Err(grammar_error(field, "A state is an array of rules.")); + }; + rules += entries.len(); + if rules > MAX_RULES { + return Err(grammar_error( + "grammar.tokenizer", + "A tokenizer has at most 4096 rules.", + )); + } + for (index, rule) in entries.iter().enumerate() { + context.check_rule(rule, &format!("{field}[{index}]"))?; + } + } + // Monarch expands includes synchronously. Bound the expanded graph, not + // merely the input JSON; cycles and repeated fan-out must fail in Core. + fn expanded( + state: &str, + tokenizer: &Map, + stack: &mut BTreeSet, + memo: &mut std::collections::BTreeMap, + ) -> Validation { + if let Some(count) = memo.get(state) { + return Ok(*count); + } + if !stack.insert(state.to_string()) { + return Err(grammar_error( + "grammar.tokenizer", + "Tokenizer includes must not form a cycle.", + )); + } + let rules = tokenizer + .get(state) + .and_then(Value::as_array) + .ok_or_else(|| { + grammar_error("grammar.tokenizer", "An include names an exact state.") + })?; + let mut count = 0usize; + for rule in rules { + count = count.saturating_add( + if let Some(target) = rule.get("include").and_then(Value::as_str) { + expanded( + target.strip_prefix('@').unwrap_or(target), + tokenizer, + stack, + memo, + )? + } else { + 1 + }, + ); + if count > MAX_RULES { + return Err(grammar_error( + "grammar.tokenizer", + "Expanded tokenizer exceeds 4096 rules.", + )); + } + } + stack.remove(state); + memo.insert(state.to_string(), count); + Ok(count) + } + let mut memo = std::collections::BTreeMap::new(); + for state in &states { + expanded(state, tokenizer, &mut BTreeSet::new(), &mut memo)?; + } + Ok(value) +} + +struct RuleContext<'a> { + states: &'a BTreeSet<&'a str>, + attributes: &'a BTreeSet<&'a str>, +} + +impl RuleContext<'_> { + fn check_rule(&self, rule: &Value, field: &str) -> Validation<()> { + match rule { + Value::Array(parts) => { + let (regex, action, next) = match parts.as_slice() { + [regex, action] => (regex, action, None), + [regex, action, next] => (regex, action, Some(next)), + _ => { + return Err(grammar_error( + field, + "A rule array is [regex, action] or [regex, action, next].", + )) + } + }; + self.check_regex(regex, field)?; + self.check_action(action, &format!("{field}[1]"), 0)?; + if let Some(next) = next { + self.check_next(next, &format!("{field}[2]"))?; + } + Ok(()) + } + Value::Object(object) if object.contains_key("include") => { + if object.len() != 1 { + return Err(grammar_error(field, "An include rule has only include.")); + } + let target = object.get("include").and_then(Value::as_str).unwrap_or(""); + let state = target.strip_prefix('@').unwrap_or(target); + if !self.has_state(state) { + return Err(grammar_error( + format!("{field}.include"), + "An include names a state of the tokenizer.", + )); + } + Ok(()) + } + Value::Object(object) => { + if object.keys().any(|key| key != "regex" && key != "action") { + return Err(grammar_error( + field, + "A rule object has regex and action, or include.", + )); + } + let regex = object + .get("regex") + .ok_or_else(|| grammar_error(field, "A rule object has a regex."))?; + self.check_regex(regex, field)?; + let action = object + .get("action") + .ok_or_else(|| grammar_error(field, "A rule object has an action."))?; + self.check_action(action, &format!("{field}.action"), 0) + } + _ => Err(grammar_error(field, "A rule is an array or an object.")), + } + } + + fn check_regex(&self, regex: &Value, field: &str) -> Validation<()> { + match regex.as_str() { + Some(source) + if !source.is_empty() + && source.len() <= MAX_REGEX_BYTES + && !source.contains('\0') => + { + Ok(()) + } + _ => Err(grammar_error( + field, + "A rule regex is a string of 1 to 2048 bytes without NUL.", + )), + } + } + + fn check_action(&self, action: &Value, field: &str, depth: usize) -> Validation<()> { + if depth > MAX_ACTION_DEPTH { + return Err(grammar_error(field, "Actions nest at most 8 levels deep.")); + } + match action { + Value::String(token) if is_token(token) => Ok(()), + Value::Array(group) if !group.is_empty() && group.len() <= MAX_CASES => { + for (index, part) in group.iter().enumerate() { + self.check_action(part, &format!("{field}[{index}]"), depth + 1)?; + } + Ok(()) + } + Value::Object(object) => self.check_action_object(object, field, depth), + _ => Err(grammar_error( + field, + "An action is a token name, a group of actions, or an action object.", + )), + } + } + + fn check_action_object( + &self, + object: &Map, + field: &str, + depth: usize, + ) -> Validation<()> { + if let Some(key) = object + .keys() + .find(|key| !ACTION_KEYS.contains(&key.as_str())) + { + return Err(grammar_error( + format!("{field}.{key}"), + "Unknown action field; log is not allowed.", + )); + } + if let Some(cases) = object.get("cases") { + let Value::Object(cases) = cases else { + return Err(grammar_error( + format!("{field}.cases"), + "cases is an object.", + )); + }; + if cases.is_empty() || cases.len() > MAX_CASES { + return Err(grammar_error( + format!("{field}.cases"), + "cases has 1 to 64 guards.", + )); + } + for (guard, case) in cases { + let case_field = format!("{field}.cases.{guard}"); + if guard.is_empty() || guard.len() > MAX_REGEX_BYTES { + return Err(grammar_error(case_field, "A guard is 1 to 2048 bytes.")); + } + if let Some(attribute) = guard.strip_prefix('@') { + if !matches!(attribute, "default" | "eos") + && is_identifier(attribute) + && !self.attributes.contains(attribute) + { + return Err(grammar_error( + case_field, + format!("The guard names an undeclared attribute {attribute}."), + )); + } + } + self.check_action(case, &case_field, depth + 1)?; + } + } + if let Some(token) = object.get("token") { + if !token.as_str().is_some_and(is_token) { + return Err(grammar_error( + format!("{field}.token"), + "token is a token name.", + )); + } + } + for key in ["next", "switchTo"] { + if let Some(next) = object.get(key) { + self.check_next(next, &format!("{field}.{key}"))?; + } + } + if let Some(back) = object.get("goBack") { + if !back + .as_u64() + .is_some_and(|count| count <= MAX_REGEX_BYTES as u64) + { + return Err(grammar_error( + format!("{field}.goBack"), + "goBack is a small non-negative integer.", + )); + } + } + if let Some(bracket) = object.get("bracket") { + if !matches!(bracket.as_str(), Some("@open" | "@close")) { + return Err(grammar_error( + format!("{field}.bracket"), + "bracket is @open or @close.", + )); + } + } + if let Some(embedded) = object.get("nextEmbedded") { + if !embedded + .as_str() + .is_some_and(|name| name == "@pop" || is_embedded_language(name)) + { + return Err(grammar_error( + format!("{field}.nextEmbedded"), + "nextEmbedded is @pop or a language ID.", + )); + } + } + if !object.contains_key("token") && !object.contains_key("cases") { + return Err(grammar_error(field, "An action object has token or cases.")); + } + Ok(()) + } + + fn check_next(&self, next: &Value, field: &str) -> Validation<()> { + let Some(next) = next.as_str() else { + return Err(grammar_error(field, "next names a state.")); + }; + if matches!(next, "@pop" | "@push" | "@popall") { + return Ok(()); + } + // `$S2`-style substitutions are resolved by the editor at run time. + if next.contains('$') && next.starts_with('@') && next.len() <= MAX_NAME_BYTES { + return Ok(()); + } + let state = next.strip_prefix('@').unwrap_or(next); + if self.has_state(state) { + Ok(()) + } else { + Err(grammar_error( + field, + format!("The tokenizer has no {state} state."), + )) + } + } + + /// Monarch resolves `a.b.c` to the longest declared prefix, so a dotted + /// state exists when one of its prefixes does. + fn has_state(&self, state: &str) -> bool { + let mut candidate = state; + loop { + if self.states.contains(candidate) { + return true; + } + match candidate.rfind('.') { + Some(index) => candidate = &candidate[..index], + None => return false, + } + } + } +} + +fn check_attribute(value: &Value, field: &str) -> Validation<()> { + match value { + Value::String(text) if text.len() <= MAX_REGEX_BYTES && !text.contains('\0') => Ok(()), + Value::Array(entries) + if entries.len() <= MAX_ATTRIBUTE_ENTRIES + && entries.iter().all(|entry| { + entry.as_str().is_some_and(|text| { + text.len() <= MAX_ATTRIBUTE_ENTRY_BYTES && !text.contains('\0') + }) + }) => + { + Ok(()) + } + _ => Err(grammar_error( + field, + "An attribute is a regex string or a list of at most 4096 strings.", + )), + } +} + +fn check_grammar_brackets(value: &Value, field: &str) -> Validation<()> { + let Value::Array(brackets) = value else { + return Err(grammar_error(field, "brackets is an array.")); + }; + if brackets.len() > MAX_BRACKETS { + return Err(grammar_error(field, "A grammar has at most 32 brackets.")); + } + for (index, bracket) in brackets.iter().enumerate() { + let well_formed = bracket.as_object().is_some_and(|bracket| { + bracket.len() == 3 + && ["open", "close"].iter().all(|key| { + bracket + .get(*key) + .and_then(Value::as_str) + .is_some_and(|text| !text.is_empty() && text.len() <= MAX_DELIMITER_BYTES) + }) + && bracket + .get("token") + .and_then(Value::as_str) + .is_some_and(is_token) + }); + if !well_formed { + return Err(grammar_error( + format!("{field}[{index}]"), + "A bracket is { open, close, token }.", + )); + } + } + Ok(()) +} + +fn is_identifier(value: &str) -> bool { + let mut characters = value.chars(); + value.len() <= MAX_NAME_BYTES + && characters + .next() + .is_some_and(|first| first.is_ascii_alphabetic() || first == '_') + && characters.all(|rest| rest.is_ascii_alphanumeric() || rest == '_') +} + +fn is_state_name(value: &str) -> bool { + value.len() <= MAX_NAME_BYTES && value.split('.').all(is_identifier) +} + +/// Token names map to theme roles; `$n` and `@` forms are Monarch +/// substitutions (`$S0`, `@rematch`, `@brackets`). The empty token is plain text. +fn is_token(value: &str) -> bool { + value.len() <= MAX_NAME_BYTES + && value + .chars() + .all(|character| character.is_ascii_alphanumeric() || "._-$@#".contains(character)) +} + +fn is_embedded_language(value: &str) -> bool { + !value.is_empty() + && value.len() <= MAX_NAME_BYTES + && value.chars().all(|character| { + character.is_ascii_lowercase() + || character.is_ascii_digit() + || "+-_.$".contains(character) + }) +} diff --git a/rust/lithe-core/src/plugins/mod.rs b/rust/lithe-core/src/plugins/mod.rs index a7ea21d897..da3fd6006b 100644 --- a/rust/lithe-core/src/plugins/mod.rs +++ b/rust/lithe-core/src/plugins/mod.rs @@ -3,7 +3,10 @@ pub(crate) mod backend; pub mod capabilities; pub mod declarative; +/// Independent, host-rendered editor actions of ordinary tool plugins. +pub mod editor_actions; pub(crate) mod executables; +pub(crate) mod grammar; pub mod marketplace; pub(crate) mod native_ui; pub(crate) mod settings; diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index 2f613c9945..be4d3964e8 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -104,6 +104,10 @@ pub enum CoreCommand { PluginsDeclarativeResolveSettings, /// Expands a language server's `workspaceConfiguration` (`plugins.declarative.workspaceConfiguration`). PluginsDeclarativeWorkspaceConfiguration, + /// Validates a language's grammar file (`plugins.declarative.decodeGrammar`). + PluginsDeclarativeDecodeGrammar, + /// Validates independent editor action registration or results. + PluginsEditorActionsDecode, /// Finds the build-system instances of a workspace (`build.detect`). BuildDetect, /// Lists a build system's executable candidates and version probes (`build.invocationPlan`). @@ -582,6 +586,8 @@ impl CoreCommand { "plugins.declarative.workspaceConfiguration" => { Some(Self::PluginsDeclarativeWorkspaceConfiguration) } + "plugins.declarative.decodeGrammar" => Some(Self::PluginsDeclarativeDecodeGrammar), + "plugins.editorActions.decode" => Some(Self::PluginsEditorActionsDecode), "build.detect" => Some(Self::BuildDetect), "build.invocationPlan" => Some(Self::BuildInvocationPlan), "build.checkToolVersion" => Some(Self::BuildCheckToolVersion), diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index 2c1cf77947..026c2bf001 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -266,6 +266,16 @@ fn execute(request: &str) -> CoreResponse { parsed.payload, crate::plugins::declarative::workspace_configuration_command, ), + CoreCommand::PluginsDeclarativeDecodeGrammar => plugin_response( + id, + parsed.payload, + crate::plugins::declarative::decode_grammar_command, + ), + CoreCommand::PluginsEditorActionsDecode => plugin_response( + id, + parsed.payload, + crate::plugins::editor_actions::decode_command, + ), CoreCommand::BuildDetect => { plugin_response(id, parsed.payload, crate::build::build_detect_command) } diff --git a/rust/lithe-core/src/tests/declarative_plugins.rs b/rust/lithe-core/src/tests/declarative_plugins.rs index 4908e58b5f..7706a7d625 100644 --- a/rust/lithe-core/src/tests/declarative_plugins.rs +++ b/rust/lithe-core/src/tests/declarative_plugins.rs @@ -38,6 +38,8 @@ const DEBUG_ADAPTER_LANGUAGE_SERVER_FIXTURE: &str = const DEBUG_ADAPTER_REMOTE_ATTACH_FIXTURE: &str = contract_file!("fixtures/debug-adapter-remote-attach.plugin.json"); const DEBUG_ADAPTER_REJECTIONS: &str = contract_file!("fixtures/debug-adapter-rejections.json"); +const EDITOR_ACTIONS_FIXTURE: &str = contract_file!("fixtures/editor-actions.plugin.json"); +const GRAMMAR_FIXTURE: &str = contract_file!("fixtures/language-grammar.plugin.json"); const INVALID_MANIFESTS: &str = contract_file!("fixtures/invalid-manifests.json"); const COMMAND_PLANS: &str = contract_file!("fixtures/command-plans.json"); const RUNTIME_VERSIONS: &str = contract_file!("fixtures/runtime-versions.json"); @@ -66,6 +68,7 @@ fn fixture(name: &str) -> Value { "debug-adapter.plugin.json" => DEBUG_ADAPTER_FIXTURE, "debug-adapter-language-server.plugin.json" => DEBUG_ADAPTER_LANGUAGE_SERVER_FIXTURE, "debug-adapter-remote-attach.plugin.json" => DEBUG_ADAPTER_REMOTE_ATTACH_FIXTURE, + "language-grammar.plugin.json" => GRAMMAR_FIXTURE, other => panic!("unknown fixture {other}"), }; serde_json::from_str(source).expect("fixture manifest is JSON") @@ -89,6 +92,8 @@ fn contract_fixtures_and_official_plugins_validate() { WORKSPACE_BACKEND_FIXTURE, DEBUG_ADAPTER_FIXTURE, DEBUG_ADAPTER_REMOTE_ATTACH_FIXTURE, + GRAMMAR_FIXTURE, + EDITOR_ACTIONS_FIXTURE, OFFICIAL_JAVA, ] { let outcome = validate(serde_json::from_str(source).unwrap()); diff --git a/rust/lithe-core/src/tests/mod.rs b/rust/lithe-core/src/tests/mod.rs index 27f124b6da..673fe8e091 100644 --- a/rust/lithe-core/src/tests/mod.rs +++ b/rust/lithe-core/src/tests/mod.rs @@ -26,3 +26,5 @@ mod run_configuration; mod spring; pub(crate) mod support; mod vcs; + +mod plugin_editor_resources; diff --git a/rust/lithe-core/src/tests/plugin_editor_resources.rs b/rust/lithe-core/src/tests/plugin_editor_resources.rs new file mode 100644 index 0000000000..e5473cc563 --- /dev/null +++ b/rust/lithe-core/src/tests/plugin_editor_resources.rs @@ -0,0 +1,210 @@ +//! Independent static editor resources, without language-server or Run providers. + +use crate::plugins::declarative::{decode_grammar_command, validate_command}; +use serde_json::{json, Value}; + +macro_rules! contract_file { + ($path:literal) => { + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/contracts/plugins/v1/", + $path + )) + }; +} + +const GRAMMAR_PLUGIN: &str = contract_file!("fixtures/language-grammar.plugin.json"); +const HTTP_GRAMMAR: &str = contract_file!("fixtures/grammars/http.monarch.json"); + +fn manifest(source: &str) -> Value { + serde_json::from_str(source).expect("fixture manifest is JSON") +} + +fn decode_grammar(grammar: &str) -> Value { + decode_grammar_command(json!({ + "manifest": manifest(GRAMMAR_PLUGIN), + "languageID": "http", + "grammar": grammar, + })) + .expect("payload is well formed") +} + +fn grammar_rejection(grammar: Value) -> (String, String) { + let outcome = decode_grammar(&grammar.to_string()); + assert_eq!(outcome["status"], "rejected", "{grammar}: {outcome}"); + ( + outcome["error"]["code"].as_str().unwrap().to_string(), + outcome["error"]["field"] + .as_str() + .unwrap_or_default() + .to_string(), + ) +} + +/// The editor gets the grammar file unchanged with what it needs to +/// register the language: file matching, engine kind and configuration. +#[test] +fn a_grammar_file_decodes_to_the_language_the_editor_registers() { + let outcome = decode_grammar(HTTP_GRAMMAR); + assert_eq!(outcome["status"], "ok", "{outcome}"); + let language = &outcome["language"]; + assert_eq!(language["id"], "http"); + assert_eq!(language["fileExtensions"], json!(["http", "rest"])); + assert_eq!(language["grammar"]["kind"], "monarch"); + assert_eq!(language["grammar"]["source"], HTTP_GRAMMAR); + assert_eq!(language["configuration"]["lineComment"], "#"); + assert_eq!( + language["configuration"]["brackets"], + json!([["{", "}"], ["[", "]"]]) + ); +} + +#[test] +fn grammar_source_preserves_order_through_sorted_json_roundtrips() { + let source = r#"{"tokenizer":{"comment":[[".*","comment"]],"root":[["[A-Z]+",{"cases":{"GET":"keyword","[A-Z]+":"string","@default":"identifier"}}]]}}"#; + let outcome = decode_grammar(source); + assert_eq!(outcome["status"], "ok", "{outcome}"); + let roundtrip: Value = serde_json::from_str(&outcome.to_string()).unwrap(); + assert_eq!(roundtrip["language"]["grammar"]["source"], source); + assert!(roundtrip["language"]["grammar"].get("monarch").is_none()); +} + +#[test] +fn a_language_without_a_grammar_has_nothing_to_decode() { + let outcome = decode_grammar_command(json!({ + "manifest": manifest(GRAMMAR_PLUGIN), + "languageID": "rest", + "grammar": HTTP_GRAMMAR, + })) + .unwrap(); + assert_eq!( + outcome["error"]["code"], "plugin.grammar.unavailable", + "{outcome}" + ); +} + +/// A grammar is data the editor runs on every keystroke, so anything that +/// would make it fail at run time, print, or grow without bound is refused +/// before it reaches the editor. +#[test] +fn grammar_files_are_refused_when_the_editor_could_not_run_them() { + let rejected = |grammar: Value, field: &str| { + let (code, actual) = grammar_rejection(grammar.clone()); + assert_eq!(code, "plugin.grammar.invalid", "{grammar}"); + assert_eq!(actual, field, "{grammar}"); + }; + rejected(json!([]), "grammar"); + rejected(json!({ "tokenizer": {} }), "grammar.tokenizer"); + rejected(json!({ "tokenizer": { "main": [] } }), "grammar.start"); + rejected( + json!({ "tokenizer": { "root": [["a", "keyword", "@missing"]] } }), + "grammar.tokenizer.root[0][2]", + ); + rejected( + json!({ "tokenizer": { "root": [["a", { "token": "keyword", "log": "hit" }]] } }), + "grammar.tokenizer.root[0][1].log", + ); + rejected( + json!({ "tokenizer": { "root": [["\\w+", { "cases": { "@keywords": "keyword" } }]] } }), + "grammar.tokenizer.root[0][1].cases.@keywords", + ); + rejected( + json!({ "tokenizer": { "root": [["", "keyword"]] } }), + "grammar.tokenizer.root[0]", + ); + rejected( + json!({ "tokenizer": { "root": [{ "include": "@other" }] } }), + "grammar.tokenizer.root[0].include", + ); + rejected( + json!({ "ignoreCase": "yes", "tokenizer": { "root": [] } }), + "grammar.ignoreCase", + ); + rejected( + json!({ "tokenizer": { "root": [["a", { "token": "x", "nextEmbedded": "Bad Lang" }]] } }), + "grammar.tokenizer.root[0][1].nextEmbedded", + ); + let (code, _) = grammar_rejection(json!({ + "tokenizer": { "root": [["a".repeat(2049), "keyword"]] } + })); + assert_eq!(code, "plugin.grammar.invalid"); + let oversized = format!( + "{{\"padding\":\"{}\",\"tokenizer\":{{\"root\":[]}}}}", + "x".repeat(256 * 1024) + ); + assert_eq!( + decode_grammar(&oversized)["error"]["code"], + "plugin.grammar.invalid" + ); +} + +/// Monarch resolves `@comment.block` to the longest declared prefix, so a +/// dotted next state is accepted when its prefix exists. +#[test] +fn dotted_states_resolve_to_their_declared_prefix() { + let outcome = decode_grammar( + &json!({ + "tokenizer": { + "root": [["/\\*", "comment", "@comment.block"]], + "comment": [["\\*/", "comment", "@pop"], [".", "comment"]] + } + }) + .to_string(), + ); + assert_eq!(outcome["status"], "ok", "{outcome}"); +} + +/// A grammar on its own is a complete plugin: it needs no language server, +/// no backend, and no permission. +#[test] +fn a_grammar_only_plugin_validates_without_a_language_server() { + let outcome = validate_command(json!({ "manifest": manifest(GRAMMAR_PLUGIN) })).unwrap(); + assert_eq!(outcome["status"], "ok", "{outcome}"); + let language = &outcome["plugin"]["languages"][0]; + assert_eq!(language["grammar"]["path"], "grammars/http.monarch.json"); +} + +#[test] +fn includes_reject_cycles_and_expanded_fan_out() { + assert_eq!( + decode_grammar(&json!({"tokenizer":{"root":[{"include":"@root"}]}}).to_string())["error"] + ["code"], + "plugin.grammar.invalid" + ); + let mut states = serde_json::Map::new(); + states.insert("root".into(), json!([{"include":"@s0"}])); + for index in 0..16 { + states.insert( + format!("s{index}"), + json!([ + {"include":format!("@s{}",index+1)}, {"include":format!("@s{}",index+1)}]), + ); + } + states.insert("s16".into(), json!([["a", "keyword"]])); + assert_eq!( + decode_grammar(&json!({"tokenizer":states}).to_string())["error"]["code"], + "plugin.grammar.invalid" + ); +} + +#[test] +fn configuration_only_contribution_decodes_without_grammar() { + let mut plugin = manifest(GRAMMAR_PLUGIN); + plugin["languages"][0] + .as_object_mut() + .unwrap() + .remove("grammar"); + // A backend is a complete tool plugin, with no language-server ownership. + let tool = manifest(contract_file!("fixtures/backend-workspace.plugin.json")); + plugin["backend"] = tool["backend"].clone(); + plugin["requires"]["backend"] = json!(2); + plugin["requires"]["serverProcess"] = json!(1); + plugin["permissions"] = json!(["backend.launch"]); + plugin["activationEvents"] = json!(["onWorkspaceOpen"]); + let result = + decode_grammar_command(json!({"manifest":plugin,"languageID":"http","grammar":""})) + .unwrap(); + assert_eq!(result["status"], "ok", "{result}"); + assert!(result["language"]["grammar"].is_null()); + assert_eq!(result["language"]["configuration"]["lineComment"], "#"); +} diff --git a/scripts/resources/reuse-worktree-resources.mjs b/scripts/resources/reuse-worktree-resources.mjs index 19f3e51934..90356f2500 100644 --- a/scripts/resources/reuse-worktree-resources.mjs +++ b/scripts/resources/reuse-worktree-resources.mjs @@ -81,6 +81,8 @@ async function readRegistry() { // are mutable user state, never download artifacts. Runtime profile import is // owned by the platform startup adapters; this build reuse route always rejects // both original state and migration snapshots and must not gain a validator. + // Plugin grammar workers use checkout-built editor assets; decoded grammars, + // document tokens and editor action leases are memory-only, never reusable. // Shared i18n source and generated tables take this same refusal route: // rebuild from the target checkout, never copy another worktree's translations. // AutoBuild publication checkpoints and baseline downloads are invocation-owned, diff --git a/scripts/resources/test-reuse-worktree-resources.mjs b/scripts/resources/test-reuse-worktree-resources.mjs index f2ddc9b21b..2ecde67ad3 100644 --- a/scripts/resources/test-reuse-worktree-resources.mjs +++ b/scripts/resources/test-reuse-worktree-resources.mjs @@ -117,6 +117,16 @@ try { assert.notEqual(refused.status, 0); assert.match(diagnostics(refused), /editor-theme-assets.*cannot be reused/); }); + await test("plugin grammar tokens and action leases cannot cross worktrees", { timeout: 15000 }, async () => { + const registry = JSON.parse(await fs.readFile(path.join(scriptDirectory, "worktree-resources.json"), "utf8")); + const resource = registry.excludedResources.find(item => item.id === "editor-theme-assets"); + assert.equal(resource.reusable, false); + assert.ok(resource.locations.includes("/plugin-grammar-workers-and-tokens")); + assert.ok(resource.locations.includes("/plugin-editor-action-leases")); + const refused = reuse(["--resource", resource.id]); + assert.notEqual(refused.status, 0); + assert.match(diagnostics(refused), /editor-theme-assets.*cannot be reused/); + }); await test("shared translations are regenerated locally rather than reused", { timeout: 15000 }, async () => { const registry = JSON.parse(await fs.readFile(path.join(scriptDirectory, "worktree-resources.json"), "utf8")); const resource = registry.excludedResources.find(item => item.id === "shared-i18n"); diff --git a/scripts/resources/worktree-resources.json b/scripts/resources/worktree-resources.json index f148aef983..c89deb5c18 100644 --- a/scripts/resources/worktree-resources.json +++ b/scripts/resources/worktree-resources.json @@ -436,10 +436,12 @@ "locations": [ ".artifacts/editor/macos/", ".artifacts/monaco-workbench/test-assets/", - ".artifacts/monaco-probe/assets/" + ".artifacts/monaco-probe/assets/", + "/plugin-grammar-workers-and-tokens", + "/plugin-editor-action-leases" ], "identity": "Current source palettes, upstream pinned theme revisions, license/NOTICE files, Monaco and Bun versions; no validated build identity stamp", - "reason": "Rebuild JavaScript and bundled theme licenses together from this checkout. No copy stage or runtime writes; packaged assets remain read-only and preserve code signatures and Sparkle delta baselines." + "reason": "Rebuild JavaScript and bundled theme licenses together from this checkout. No copy stage or runtime writes; packaged assets remain read-only and preserve code signatures and Sparkle delta baselines. Plugin grammars compile only in terminable workers loaded from the same checkout-built textmate-worker.js. Decoded package resources, tokens and action leases stay in process memory; no reusable identity stamp or runtime bundle writes." }, { "id": "plugin-host-runtime-state", diff --git a/shared/contracts/plugins/v1/README.md b/shared/contracts/plugins/v1/README.md index bd94d74663..0a99c04483 100644 --- a/shared/contracts/plugins/v1/README.md +++ b/shared/contracts/plugins/v1/README.md @@ -85,6 +85,77 @@ present. A leading dot is optional only in `fileExtensions` (`.php` equals is part of the name, so `.toolrc` and `.env.example` stay hidden-file names. Optional `dependencies` lists `managementFileNames`, `projectDependencyPaths`, and `projectBinaryPaths` relative to the project. +#### Grammars and editor configuration (`languages` 2) + +`languages` 2 adds two optional fields to a language. Both are data: the host +reads them from the package and the editor applies them without starting any +process, so a language with a grammar needs no language server, backend, or +activation event, and a plugin may consist of grammars alone. + +```json +{ + "id": "http", + "displayName": "HTTP Request", + "fileExtensions": ["http", "rest"], + "grammar": { "kind": "monarch", "path": "grammars/http.monarch.json" }, + "configuration": { + "lineComment": "#", + "blockComment": ["/*", "*/"], + "brackets": [["{", "}"], ["[", "]"]], + "autoClosingPairs": [["{", "}"], ["\"", "\""]] + } +} +``` + +- `grammar.kind` names the highlighting engine. Version 2 defines only + `monarch`; another engine is added as a new kind in a later version, so a + plugin can move between engines without changing anything else. +- This is a static package contribution: `languages[]` belongs to the manifest + and `grammar.path` references a package JSON file. No public host method accepts + inline grammar JSON to dynamically register, replace, or dispose a highlighting + provider. Live installation/update/withdrawal of eligible static packages does + not imply such an API or automatic reload after arbitrary file changes; + installed packages remain read-only. +- Adding another language or grammar using the supported structure needs only + plugin data; changing its rules does not itself raise the API version. A new + engine or capability kind still needs an implemented host adapter and an + explicit contract; an arbitrary new `kind` string cannot supply one. +- This contribution does not register completion, hover, definition, symbol, + formatting, rename, or Code Action providers backed by ordinary plugin + functions. Those independent provider bindings are not implemented; existing + LSP support and `editor/registerActions` are separate capabilities, not a + substitute for them. +- `grammar.path` is a package-relative file that stays inside the package. + Hosts read it read-only from the installed package, pass its text to + `plugins.declarative.decodeGrammar`, and hand the returned object to the + editor as validated source text, never through a reconstructed JSON object: + Monarch `cases` are evaluated in source order. The worker parses that text and + defaults `start` to `root` when absent. A grammar Core refuses is `plugin.grammar.invalid` with the + failing field (such as `grammar.tokenizer.root[3][1].log`); the language then + opens as plain text and the rest of the plugin keeps working. +- A Monarch grammar is the JSON form of a + [Monaco Monarch](https://microsoft.github.io/monaco-editor/monarch.html) + definition: rule regexes are strings, and attribute values are strings or + string lists. Core accepts at most 256 KiB, 256 states, 4096 rules, regexes + of 2048 bytes, attribute lists of 4096 entries, 64 `cases` guards, 32 + brackets, and actions nested 8 deep. It refuses `log` actions, `next`, + `switchTo`, and `include` targets that are not states of the grammar + (`@a.b` resolves to the longest declared prefix, as in Monarch), `cases` + guards naming an undeclared attribute, and `nextEmbedded` values other + than `@pop` or a language ID. The fixture + [`fixtures/grammars/http.monarch.json`](fixtures/grammars/http.monarch.json) + is a complete example. +- `configuration` sets `lineComment`, `blockComment` (open and close), + `brackets`, and `autoClosingPairs`. Each delimiter is 1 to 16 bytes without + control characters; each list has at most 32 pairs. +- Grammar compilation and matching run only in a terminable worker. No plugin + regex runs on the editor UI thread. Include cycles and expanded rule counts + over 4096 are refused; a stalled worker is terminated after its local deadline. + Buffers over 128 Ki UTF-16 units or 10,000 lines remain plain/editable. Embedded + languages are plain text in this first worker adapter. +- Declaring `languages: 2` without using either field is + `plugin.manifest.invalid`; using them under `languages: 1` is too. + ### Language servers | Field | Meaning | @@ -399,7 +470,7 @@ hosts. Level 3 versions each section on its own. A level 3 manifest lists in | Capability | Covers | Versions | | --- | --- | --- | -| `languages` | `languages[]` | `1` | +| `languages` | `languages[]` | `1`, `2` (adds [`grammar` and `configuration`](#grammars-and-editor-configuration-languages-2)) | | `packageTrust` | `vendor.bundledSignatureRequirement`: origin-scoped trust for a host-sealed bundled copy of a publisher-signed plugin | `1` | | `languageServers` | `languageServers[]` apart from their launch fields | `1`, `2` (adds [`workspaceConfiguration`](#workspace-configuration-languageservers-2)) | | `serverProcess` | The launch fields of language servers, of build providers with a process of their own, of VCS providers, and of the plugin backend: `launch`, `arguments`, `validationArguments`, `runtimeDependencies`, `initializationOptions` | `1` | @@ -502,6 +573,7 @@ Rules for publishing across levels and capability versions: | `backend.launch` | `backend` is declared | | `credentials.store` | the backend uses the [credentials host service](#host-services-protocol-version-2); refused without a `backend` | | `workspace.metadata` | the backend uses the [workspace host service](#workspace-contexts-and-capability-discovery-protocol-version-3); refused without `backend` 2 | +| `editor.actions` | the backend registers independent editor actions; requires `backend` 2 and `documents.read`, grants only `editor/registerActions`, not document edits or domain execution | | Activation event | Meaning | | --- | --- | @@ -2094,6 +2166,7 @@ methods. Unknown options fail before side effects with `unsupportedOption`. | --- | --- | --- | --- | | `documents/list` | `documents.read` | none | `{documents:[]}` | | `documents/getSnapshot` | `documents.read` | `documentID` | `{document:, text}` from the dirty live buffer | +| `editor/registerActions` | `editor.actions` (+ `documents.read`) | `id,function,selector` | `{subscriptionID}`; independent editor actions, see below | | `editor/getActive` | `documents.read` | none | summary of the foreground text tab, or `null` | | `editor/getSelections` | `documents.read` | `documentID` | `{documentID,version,selections:[{anchor,active}],primaryIndex}` | | `editor/applyEdits` | `editor.edit` | `documentID,expectedVersion,edits:[{range,text}]` | `{documentID,version,applied}` | @@ -2168,6 +2241,79 @@ plugins. `didRun` remains an event, not an acknowledgement that the command's business action succeeded; a future result-bearing execute API must use the existing request lifecycle rather than infer success from notification delivery. +### Independent editor actions (protocol version 3) + +Editor actions are a host presentation capability of an ordinary tool backend, +not an LSP, Build, Run, Test or Debug provider. A play icon is presentation, not +an execution kind. The host understands the bounded range/title/icon/invocation +shape; HTTP/SQL parsing and execution remain private to the plugin. Native domain +workflows still use their own typed contracts, never a private-function substitute. + +A backend declares `backend: 2`, `documents.read` and `editor.actions`, discovers +`lithe/host/editor/registerActions` with `getCapabilities`, then registers per +workspace context: + +```json +{ + "context": {"contextID": "w1", "generation": 1}, + "id": "requestActions", + "function": "requests.collectActions", + "selector": {"fileExtensions": ["http", "rest"], "fileNames": []} +} +``` + +- Registration returns `{subscriptionID}`. Dispose with the existing subscription + API. It only records the binding and never calls the backend while answering; + collection runs later. The function must already be registered on that session. +- No manifest `runMarkers` section or capability, no special command notification, + and no language-server/Run declaration is added. The first implementation starts + through existing `onWorkspaceOpen` (or an existing explicit entry); registration + cannot discover a backend that has not started. Backend installation still needs + restart. Standalone backends have an application-scoped enablement module, + without a fake UI or language provider; safe mode/quarantine/disable still veto + their startup. Example package: `fixtures/editor-actions.plugin.json`. +- A selector has 1 to 32 literal names/extensions; extensions accept an optional + leading dot. Matching is case-insensitive. At most 32 bindings per plugin across + contexts; duplicate registration IDs in a context are refused. +- The host invokes the collection function via existing `lithe/function/invoke` + with workspace context and `{document:{documentID,version,path},text}`. Version + is the same public buffer version as `documents/getSnapshot`, not a WebView + revision. Source is the dirty live buffer, capped at 128 KiB. Remote workspaces + and read-only preview surfaces are not served by this first implementation. +- Collection is read-only by contract: no network request or business execution. + Edits invalidate the old actions immediately and cancel older collection work; + the host debounces fresh collection and rejects stale or withdrawn results. + macOS bounds the whole collection batch to eight seconds, not eight seconds per + binding. Refresh acknowledges scheduling without waiting for collectors; a slow + or failed collector must not fail editing or the save synchronization barrier. +- Return `{actions:[{id,range:{start:{line,column},end:{line,column}},title,icon, + invoke:{functionID,params?}}]}`. Positions are one-based UTF-16, half-open and + cannot split surrogate pairs. Each range stays in the exact input document. + `id` is 1 to 128 bytes without controls, unique per answer; `title` is 1 to 200 + characters on one line. `icon` is `run` or `run-all`, a host-owned presentation + token. At most 1000 actions and 1 MiB serialized; malformed answers are refused + whole. Same-line actions share a menu rather than silently replacing each other. +- Each invocation target is a currently registered function of the same plugin; + `params` is a plugin-private JSON object. The host stores it behind an opaque + per-batch action key, never accepts invocation payloads from the web editor, and + validates document/context/session/registration again on click. It calls the + function with `{documentID,expectedVersion,actionID,data:}` through the + ordinary invocation lifecycle. The plugin validates/re-reads the live document + before side effects. Click executions are not cancelled by unrelated re-parsing; + withdrawing the binding, ending its session/context or tearing down the owning + editor cancels them. The host never retries side effects automatically. +- Registration/collection validation is shared Core command + `plugins.editorActions.decode`; public host failures retain protocol 3's existing + distinct codes (`invalidParams`, `permissionDenied`, `versionConflict`, etc.). + No tool-private error or result is interpreted as a build/test result. +- Existing `commands/didRun {commandID,context?}` is unchanged. A command-palette + entry can read the document/selection and call the same plugin implementation. +- This is an additive host method within protocol 3, not a protocol-version bump. + Old hosts do not advertise it. A plugin can retain its existing command/window + entry point; a package requiring `editor.actions` is deliberately refused by an + older host that does not recognize that permission, rather than silently granted. + Grammar capability admission is separate. SDK/contract mirrors still need sync. + ### Compatibility - Packages of every API level are unchanged: functions add no manifest field. @@ -2260,6 +2406,8 @@ command payload is a Core `invalid_request` failure instead. | `plugin.build.invalidSelection` | Core | A build request selects a scope, target, filter, or argument the declaration does not allow, or misses a required path | | `plugin.build.invalidOutput` | Core | Build-tool output or a report does not fit its decoder | | `plugin.launch.invalidRequest` | Core | A launch plan request has an unusable directory or an unknown architecture | +| `plugin.grammar.invalid` | Core | A language's grammar file is not a Monarch grammar Core accepts; `field` names the failing part | +| `plugin.grammar.unavailable` | Core | The plugin declares no grammar for the language | | `plugin.vcs.reserved` | Core | A VCS provider claims the ID of the built-in Git support | | `plugin.vcs.invalidOutput` | Core | A VCS provider's reply does not fit the [VCS provider contract](https://github.com/1lck/Lithe-IDEA/blob/preview/shared/contracts/vcs-provider-v1.md) | | `plugin.index.invalid` | Core | Malformed index root or entry, or an invalid URL, checksum, platform, version, or date | diff --git a/shared/contracts/plugins/v1/fixtures/editor-actions.plugin.json b/shared/contracts/plugins/v1/fixtures/editor-actions.plugin.json new file mode 100644 index 0000000000..2c499d2f7c --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/editor-actions.plugin.json @@ -0,0 +1,36 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.fixture.editoractions", + "displayName": "Editor Actions", + "version": "1.0.0", + "pluginAPIVersion": 3, + "requires": { + "backend": 2, + "serverProcess": 1 + }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "backend.launch", + "documents.read", + "editor.actions", + "workspace.metadata" + ], + "activationEvents": [ + "onWorkspaceOpen" + ], + "languages": [], + "backend": { + "launch": { + "kind": "packaged", + "path": "bin/backend" + }, + "arguments": [ + "--state", + "${stateDir}" + ] + } +} diff --git a/shared/contracts/plugins/v1/fixtures/function-protocol.json b/shared/contracts/plugins/v1/fixtures/function-protocol.json index b79fdcb094..6bd6b38809 100644 --- a/shared/contracts/plugins/v1/fixtures/function-protocol.json +++ b/shared/contracts/plugins/v1/fixtures/function-protocol.json @@ -532,7 +532,10 @@ "method": "lithe/host/process/run", "params": {}, "expected": { "rpcCode": -32601, "code": "plugin.host.unavailable" } - } + }, + {"name": "editor actions are a discoverable independent host service", "method": "lithe/host/editor/registerActions", "version": 3, "hostServices": ["lithe/host/editor/registerActions"], "params": {"context": {"contextID": "w1", "generation": 1}, "id": "actions", "function": "actions.collect", "selector": {"fileExtensions": ["http"]}}, "expected": {"forward": "editor"}}, + {"name": "document reads do not grant editor action contribution", "method": "lithe/host/editor/registerActions", "version": 3, "hostServices": ["lithe/host/documents/getSnapshot"], "params": {}, "expected": {"code": "plugin.host.permissionDenied", "rpcCode": -32010}}, + {"name": "protocol 2 does not expose independent editor actions", "method": "lithe/host/editor/registerActions", "version": 2, "hostServices": ["lithe/host/editor/registerActions"], "params": {}, "expected": {"code": "plugin.host.unavailable", "rpcCode": -32601}} ], "invocationContexts": [ { diff --git a/shared/contracts/plugins/v1/fixtures/grammars/http.monarch.json b/shared/contracts/plugins/v1/fixtures/grammars/http.monarch.json new file mode 100644 index 0000000000..ca824f09d7 --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/grammars/http.monarch.json @@ -0,0 +1,44 @@ +{ + "defaultToken": "", + "tokenPostfix": ".http", + "methods": ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "TRACE", "CONNECT"], + "brackets": [ + { "open": "{", "close": "}", "token": "delimiter.curly" }, + { "open": "[", "close": "]", "token": "delimiter.square" } + ], + "tokenizer": { + "root": [ + ["###.*$", "comment.separator"], + ["\\s*(#|//).*$", "comment"], + ["@[A-Za-z_][\\w.-]*", "variable", "@variableValue"], + ["([A-Z]+)(\\s+)", [{ "cases": { "@methods": "keyword", "@default": "identifier" } }, ""], "@requestTarget"], + { "include": "@variables" }, + ["[A-Za-z][\\w-]*(?=\\s*:)", "attribute.name", "@headerValue"], + ["[{}\\[\\]]", "@brackets"], + ["\"([^\"\\\\]|\\\\.)*\"", "string"], + ["-?\\d+(\\.\\d+)?([eE][-+]?\\d+)?", "number"], + ["\\b(true|false|null)\\b", "keyword"] + ], + "requestTarget": [ + { "include": "@variables" }, + ["\\s+HTTP/[\\d.]+\\s*$", "keyword", "@pop"], + ["[^\\s{]+", "string.link"], + ["$", "", "@pop"] + ], + "headerValue": [ + [":\\s*", "delimiter"], + { "include": "@variables" }, + ["[^{]+", "attribute.value"], + ["$", "", "@pop"] + ], + "variableValue": [ + ["\\s*=\\s*", "delimiter"], + { "include": "@variables" }, + ["[^{]+", "string"], + ["$", "", "@pop"] + ], + "variables": [ + ["\\{\\{[^}]*\\}\\}", "variable"] + ] + } +} diff --git a/shared/contracts/plugins/v1/fixtures/invalid-manifests.json b/shared/contracts/plugins/v1/fixtures/invalid-manifests.json index dd82092e47..2a20bb42db 100644 --- a/shared/contracts/plugins/v1/fixtures/invalid-manifests.json +++ b/shared/contracts/plugins/v1/fixtures/invalid-manifests.json @@ -1049,6 +1049,11 @@ "base": "backend-workspace.plugin.json", "edits": [{ "op": "set", "path": "/backend/arguments", "value": ["--cache", "${workspaceStateDir}"] }], "expectedCode": "plugin.manifest.invalid" - } + }, + {"name": "a grammar needs languages 2", "base": "language-grammar.plugin.json", "edits": [{"op": "set", "path": "/requires/languages", "value": 1}], "expectedCode": "plugin.manifest.invalid"}, + {"name": "languages 2 is used by a grammar or configuration", "base": "language-grammar.plugin.json", "edits": [{"op": "remove", "path": "/languages/0/grammar"}, {"op": "remove", "path": "/languages/0/configuration"}], "expectedCode": "plugin.manifest.invalid"}, + {"name": "a grammar names a known engine", "base": "language-grammar.plugin.json", "edits": [{"op": "set", "path": "/languages/0/grammar/kind", "value": "textmate"}], "expectedCode": "plugin.manifest.invalid"}, + {"name": "a grammar file stays in the package", "base": "language-grammar.plugin.json", "edits": [{"op": "set", "path": "/languages/0/grammar/path", "value": "../http.monarch.json"}], "expectedCode": "plugin.manifest.invalid"}, + {"name": "a comment delimiter is one short line", "base": "language-grammar.plugin.json", "edits": [{"op": "set", "path": "/languages/0/configuration/lineComment", "value": "#\n"}], "expectedCode": "plugin.manifest.invalid"} ] } diff --git a/shared/contracts/plugins/v1/fixtures/language-grammar.plugin.json b/shared/contracts/plugins/v1/fixtures/language-grammar.plugin.json new file mode 100644 index 0000000000..09be19bab4 --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/language-grammar.plugin.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.fixture.httpgrammar", + "displayName": "HTTP Request Syntax", + "version": "1.0.0", + "pluginAPIVersion": 3, + "requires": { + "languages": 2 + }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [], + "activationEvents": [], + "languages": [ + { + "id": "http", + "displayName": "HTTP Request", + "fileExtensions": [ + "http", + "rest" + ], + "grammar": { + "kind": "monarch", + "path": "grammars/http.monarch.json" + }, + "configuration": { + "lineComment": "#", + "brackets": [ + ["{", "}"], + ["[", "]"] + ], + "autoClosingPairs": [ + ["{", "}"], + ["[", "]"], + ["\"", "\""] + ] + } + } + ] +} diff --git a/shared/contracts/plugins/v1/plugin-manifest.schema.json b/shared/contracts/plugins/v1/plugin-manifest.schema.json index 1d2b09a9ee..e108cc36ab 100644 --- a/shared/contracts/plugins/v1/plugin-manifest.schema.json +++ b/shared/contracts/plugins/v1/plugin-manifest.schema.json @@ -35,7 +35,7 @@ "executableResolution": { "enum": [1], "description": "Shared executable candidate ordering and strict selection." }, "executionProviders": { "enum": [1] }, "languageServers": { "enum": [1] }, - "languages": { "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] }, "packageTrust": { "description": "Required exactly when vendor.bundledSignatureRequirement is present.", "enum": [1] }, "projectGenerators": { "description": "Entries of the New Project window: fields, requirements, and steps.", "enum": [1] }, "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] }, @@ -66,6 +66,7 @@ "workspace.search", "documents.read", "editor.edit", + "editor.actions", "editor.open", "preview.open", "browser.open", @@ -1056,9 +1057,37 @@ "projectDependencyPaths": { "$ref": "#/$defs/fileNameList" }, "projectBinaryPaths": { "$ref": "#/$defs/fileNameList" } } + }, + "grammar": { + "description": "languages 2: syntax highlighting from a package file, applied by the editor without any process.", + "type": "object", + "additionalProperties": false, + "required": ["kind", "path"], + "properties": { + "kind": { "description": "Highlighting engine of the file. Version 2 defines monarch: the JSON form of a Monaco Monarch definition.", "enum": ["monarch"] }, + "path": { "description": "Package-relative grammar file inside the package.", "type": "string", "minLength": 1 } + } + }, + "configuration": { + "description": "languages 2: comment and bracket behavior of the editor.", + "type": "object", + "additionalProperties": false, + "properties": { + "lineComment": { "$ref": "#/$defs/editorDelimiter" }, + "blockComment": { "$ref": "#/$defs/editorDelimiterPair" }, + "brackets": { "type": "array", "maxItems": 32, "items": { "$ref": "#/$defs/editorDelimiterPair" } }, + "autoClosingPairs": { "type": "array", "maxItems": 32, "items": { "$ref": "#/$defs/editorDelimiterPair" } } + } } } }, + "editorDelimiter": { "type": "string", "minLength": 1, "maxLength": 16 }, + "editorDelimiterPair": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": { "$ref": "#/$defs/editorDelimiter" } + }, "languageServer": { "type": "object", "$ref": "#/$defs/serverProcess", diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 5716fa0dee..bf8ede9a3b 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -1141,6 +1141,8 @@ 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 | +| `plugins.declarative.decodeGrammar` | Validate independent static editor resources (`languages` 2) | +| `plugins.editorActions.decode` | Validate independent action registration or collection results | | `build.detect` | Find every build-system instance the enabled plugins declare in a workspace, with models that need no process | | `build.invocationPlan` | List a build system's executable candidates, runtime dependencies, and version probe for one platform | | `build.checkToolVersion` | Decide whether a build tool's or runtime's version output meets the declared minimum | @@ -3830,6 +3832,25 @@ for the plan's `launch.versionArguments`, and returns `{ "status": "ok", "check": { "satisfied": bool, "version"?: string } }`. `version` is absent when the output contains none. It takes no manifest. +`plugins.declarative.decodeGrammar` accepts `{ manifest, languageID, grammar }`, +where `grammar` is the text the host read from the language's `grammar.path` +inside the installed package, and returns `language: { id, fileExtensions, +fileNames, grammar: { kind, source }, configuration }`. `source` is the +validated original JSON text, kept opaque through host dictionaries so Monarch's +ordered `cases` retain their precedence. Only the terminable worker parses and +compiles it, explicitly using `root` when `start` is absent. A grammar Core +refuses is `plugin.grammar.invalid` with the failing `field`; a language +without static editor resources is `plugin.grammar.unavailable`. Configuration-only +languages return `grammar:null` and require no file read. Core reads no files. + +`plugins.editorActions.decode` accepts `{kind:"registration",value}` or +`{kind:"actions",value,text}` and returns `{registration}` or `{actions}`. +Core validates literal selectors, one-based UTF-16 ranges and bounded opaque +function bindings. Hosts enforce permission, live document versions and session +ownership; HTTP/SQL business semantics stay with plugins. See the plugin +contract's independent editor actions section. + + The `plugins.marketplace.*` commands are equally pure: hosts fetch indexes and archives and pass the parsed JSON. `validateIndex` accepts `{ index, sourceKind: "official" | "thirdParty", platform, hostCapabilities? }` diff --git a/shared/i18n/locales/en-US.json b/shared/i18n/locales/en-US.json index ff68fb19ea..b26ba6b8af 100644 --- a/shared/i18n/locales/en-US.json +++ b/shared/i18n/locales/en-US.json @@ -9729,7 +9729,8 @@ "native.gitIgnored": "Git: ignored", "native.gitNotIgnored": "Git: not ignored", "native.onePatternPerLineCaseInsensitivePatternsUseWildcardsASimplifiedFormOfRegularEx35941b6a99": "One pattern per line, case-insensitive. Patterns use wildcards, a simplified form of regular expressions: `*` matches any characters and `?` matches one.\nEnd a pattern with `/`, `/*` or `/**` to hide a folder and everything in it; start with `**/` to match at any depth. A pattern without a trailing slash only hides files: `.lithe` hides a file named .lithe, not the folder.\nExamples:\n`**/dist/` — every dist folder\n`.lithe/**` — the .lithe folder in the project root\n`*.log` — all log files\n`docs/*.md` — Markdown files in docs\nThe list starts with the built-in patterns. Delete one to show those files again; type it back to hide them.", - "history.clearFailed": "Could not clear this project's local history. Check storage permissions and try again." + "history.clearFailed": "Could not clear this project's local history. Check storage permissions and try again.", + "native.addClickableActionsBesideDocumentText": "Add clickable actions beside document text" }, "overrides": { "windows": { diff --git a/shared/i18n/locales/zh-CN.json b/shared/i18n/locales/zh-CN.json index 9542eea2a9..8ea52e14b0 100644 --- a/shared/i18n/locales/zh-CN.json +++ b/shared/i18n/locales/zh-CN.json @@ -9729,7 +9729,8 @@ "native.gitIgnored": "Git:已忽略", "native.gitNotIgnored": "Git:未忽略", "native.onePatternPerLineCaseInsensitivePatternsUseWildcardsASimplifiedFormOfRegularEx35941b6a99": "每行一条规则,不区分大小写。规则使用通配符,是正则表达式的简化写法:`*` 匹配任意字符,`?` 匹配单个字符。\n以 `/`、`/*` 或 `/**` 结尾表示隐藏整个文件夹及其内容,以 `**/` 开头表示匹配任意层级。不带结尾斜杠的规则只隐藏文件:`.lithe` 隐藏名为 .lithe 的文件,而不是文件夹。\n示例:\n`**/dist/` — 任意层级的 dist 文件夹\n`.lithe/**` — 项目根目录下的 .lithe 文件夹\n`*.log` — 所有 .log 文件\n`docs/*.md` — docs 下的 Markdown 文件\n列表开头是内置规则。删掉一行即可重新显示这些文件,再输入回来就会重新隐藏。", - "history.clearFailed": "无法清空此项目的本地历史。请检查存储目录权限后重试。" + "history.clearFailed": "无法清空此项目的本地历史。请检查存储目录权限后重试。", + "native.addClickableActionsBesideDocumentText": "在文档文本旁添加可点击的操作" }, "overrides": { "macos": { diff --git a/shared/i18n/messages.json b/shared/i18n/messages.json index 764579b111..3068c8aa8a 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -93151,6 +93151,17 @@ "key": "Could not clear this project's local history. Check storage permissions and try again.", "arguments": [] } + }, + { + "id": "native.addClickableActionsBesideDocumentText", + "description": "Native UI: editor action contribution permission", + "platforms": [ + "macos" + ], + "macos": { + "key": "Add clickable actions beside document text", + "arguments": [] + } } ] } diff --git a/shared/platform-feature-matrix/features/plugin-editor-actions.json b/shared/platform-feature-matrix/features/plugin-editor-actions.json new file mode 100644 index 0000000000..20f7a57192 --- /dev/null +++ b/shared/platform-feature-matrix/features/plugin-editor-actions.json @@ -0,0 +1,26 @@ +{ + "id": "plugin-editor-actions", + "area": "扩展", + "group": "插件与主题", + "capability": "普通工具插件的独立编辑器动作:运行期注册文件选择器,收集文档范围与同插件函数绑定,点击复用普通 invocation;不接 LSP/Build/Run/Debug", + "macos": { + "implementationStatus": "partial", + "verificationStatus": "pending", + "evidence": [ + "rust/lithe-core/src/plugins/editor_actions.rs", + "macos/Sources/Lithe/Application/Features/PluginEditorActionHostService.swift", + "frontend/editor/src/plugin-editor-actions.ts", + "shared/contracts/plugins/v1/fixtures/editor-actions.plugin.json" + ] + }, + "windows": { + "implementationStatus": "missing", + "verificationStatus": "not-applicable", + "evidence": [ + "shared/contracts/plugins/v1/README.md" + ] + }, + "owner": "Extensions", + "verification": "用 HTTP 与 SQL 两种解析函数注册动作;验证当前脏文档版本、同一行多动作、明确点击执行、编辑取消旧解析、撤回/关闭/会话替换清理,不自动重试副作用。慢收集保持未完成时刷新立即确认,仍能输入并完成保存屏障;整批只使用八秒预算。", + "notes": "当前仅 macOS 接线;真机和完整用户流程仍待验证。共享校验通过不代表 Windows 已实现。" +} diff --git a/shared/platform-feature-matrix/features/plugin-editor-grammars.json b/shared/platform-feature-matrix/features/plugin-editor-grammars.json new file mode 100644 index 0000000000..389940b2c3 --- /dev/null +++ b/shared/platform-feature-matrix/features/plugin-editor-grammars.json @@ -0,0 +1,27 @@ +{ + "id": "plugin-editor-grammars", + "area": "扩展", + "group": "插件与主题", + "capability": "独立插件语法高亮与编辑配置:不依赖 LSP/Run,配置可独立于 grammar,语法仅在可终止 worker 执行", + "macos": { + "implementationStatus": "partial", + "verificationStatus": "pending", + "evidence": [ + "rust/lithe-core/src/plugins/grammar.rs", + "frontend/editor/src/plugin-languages.ts", + "frontend/editor/src/plugin-grammar-tokenizer.ts", + "macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginEditorLanguages.swift", + "macos/Tests/LitheTests/PluginEditorLanguagesTests.swift" + ] + }, + "windows": { + "implementationStatus": "missing", + "verificationStatus": "not-applicable", + "evidence": [ + "shared/contracts/plugins/v1/README.md" + ] + }, + "owner": "Extensions", + "verification": "安装纯语法插件后打开 .http;验证即时出现、停用撤回、配置独立应用、病态语法不阻塞输入和保存;安装目录哈希不变。语法源文本经 Core/Swift/worker 往返保持 cases 顺序并显式默认 root;properties 等仅配置别名保留高亮、撤回不销毁基础 tokenizer;模块状态观察回调不持有运行时。", + "notes": "当前仅 macOS 接线,只接受清单与包内 JSON 静态资源,grammar.kind 仅支持 monarch;没有运行期传 JSON 注册/替换/释放高亮 Provider 的公共方法,也未开放独立语言语义 Provider 的普通函数绑定。静态插件生命周期即时更新不等于动态注册 API。高亮超过 128 Ki UTF-16 单位或 10,000 行时保持普通文本,嵌入语言按普通文本处理;SDK 新能力封装/发布和真实插件完整安装验收仍待完成。共享校验通过不代表 Windows 已实现。" +}