From 4a38fcddfc313da33b940f05ae754c94c4a9dd7f Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Sat, 31 Oct 2026 12:33:15 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(plugins):=20=E5=A3=B0=E6=98=8E?= =?UTF-8?q?=E5=BC=8F=E8=AF=AD=E6=B3=95=E9=AB=98=E4=BA=AE=E4=B8=8E=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E6=A0=87=E8=AE=B0=EF=BC=88API-25/27/29=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - languages 2:插件语言可带 Monarch 语法和编辑器配置,Core 校验,纯语法插件免重启 - runMarkers 1:backend 2 插件函数回答可运行行,编辑器画运行按钮,点击经 commands/didRun 带 runMarker - 契约、fixture、Agent Note、计划文档与功能矩阵同步更新;Windows 暂不支持 Refs #570 --- .../2026-10-26-plugin-live-activation.md | 7 +- ...6-10-31-plugin-grammars-and-run-markers.md | 74 +++ docs/development/macos-plugin-api-plan.md | 12 +- frontend/editor/src/language-contributions.ts | 3 + frontend/editor/src/plugin-languages.test.ts | 78 +++ frontend/editor/src/plugin-languages.ts | 81 +++ frontend/editor/src/run-markers.test.ts | 29 +- frontend/editor/src/run-markers.ts | 24 +- frontend/editor/src/workbench.ts | 66 +- .../Application/Composition/AppServices.swift | 3 + .../Features/PluginEditorLanguages.swift | 65 ++ .../Features/PluginHostServiceDispatch.swift | 7 +- .../RustDeclarativePluginOperations.swift | 27 +- .../AppModel/AppModel+UIContributions.swift | 2 +- .../Platform/MacOS/MacServiceContainer.swift | 22 + .../MacOS/MonacoWorkbenchEditor.swift | 116 +++- .../Plugins/MacPluginBackendSessions.swift | 10 +- .../Plugins/MacPluginEditorLanguages.swift | 130 ++++ .../Plugins/DeclarativePluginContracts.swift | 106 +++- .../Catalog/BuiltInModuleCatalog.swift | 18 + .../Lifecycle/ModuleTypes.swift | 6 + .../LitheModuleAPI/Plugins/PluginTypes.swift | 3 +- .../PluginEditorLanguagesTests.swift | 162 +++++ .../src/lsp/interface/plugin_function.rs | 10 +- rust/lithe-core/src/plugins/capabilities.rs | 10 +- rust/lithe-core/src/plugins/declarative.rs | 195 +++++- rust/lithe-core/src/plugins/grammar.rs | 563 ++++++++++++++++++ rust/lithe-core/src/plugins/mod.rs | 2 + rust/lithe-core/src/plugins/run_markers.rs | 223 +++++++ rust/lithe-core/src/protocol/command.rs | 8 + rust/lithe-core/src/runtime/dispatcher.rs | 10 + .../src/tests/declarative_plugins.rs | 6 + rust/lithe-core/src/tests/mod.rs | 1 + .../src/tests/plugin_grammars_run_markers.rs | 305 ++++++++++ shared/contracts/plugins/v1/README.md | 117 +++- .../v1/fixtures/function-protocol.json | 3 + .../v1/fixtures/grammars/http.monarch.json | 44 ++ .../v1/fixtures/invalid-manifests.json | 75 +++ .../v1/fixtures/language-grammar.plugin.json | 43 ++ .../v1/fixtures/run-markers.plugin.json | 70 +++ .../plugins/v1/plugin-manifest.schema.json | 46 +- shared/contracts/rust-core-api.md | 19 + .../features/plugin-grammars-run-markers.json | 34 ++ 43 files changed, 2780 insertions(+), 55 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-10-31-plugin-grammars-and-run-markers.md create mode 100644 frontend/editor/src/plugin-languages.test.ts create mode 100644 frontend/editor/src/plugin-languages.ts create mode 100644 macos/Sources/Lithe/Application/Features/PluginEditorLanguages.swift create mode 100644 macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginEditorLanguages.swift create mode 100644 macos/Tests/LitheTests/PluginEditorLanguagesTests.swift create mode 100644 rust/lithe-core/src/plugins/grammar.rs create mode 100644 rust/lithe-core/src/plugins/run_markers.rs create mode 100644 rust/lithe-core/src/tests/plugin_grammars_run_markers.rs create mode 100644 shared/contracts/plugins/v1/fixtures/grammars/http.monarch.json create mode 100644 shared/contracts/plugins/v1/fixtures/language-grammar.plugin.json create mode 100644 shared/contracts/plugins/v1/fixtures/run-markers.plugin.json create mode 100644 shared/platform-feature-matrix/features/plugin-grammars-run-markers.json 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..0847b70ec8 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` 的语言,见 [插件语法高亮与运行标记](2026-10-31-plugin-grammars-and-run-markers.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. 目前仍然需要重启的贡献:语言服务器、运行/测试、运行标记(`runMarkers`,因为它依赖 `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-grammars-and-run-markers.md b/.agents/notes/implemented/architecture/2026-10-31-plugin-grammars-and-run-markers.md new file mode 100644 index 0000000000..df24a8e5e8 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-31-plugin-grammars-and-run-markers.md @@ -0,0 +1,74 @@ +# Agent 笔记:插件语法高亮与运行标记 + +状态:已实现 + +## 先说结论 + +这次给插件新增两个能力,对应 #1548 计划里的 API-25、API-27 和 API-29,起因是 #570 的 `.http` HTTP 客户端插件。 + +- `languages: 2`(API-25):插件给自己声明的语言附上一份语法文件和一份编辑器配置(括号、注释、自动配对),只靠配置文件高亮,不需要语言服务器、后端或激活事件。`grammar.kind` 用来指定渲染引擎,目前只定义 `monarch`;以后要换 TextMate 或 tree-sitter 时加新的 kind,不改已有插件。 +- `runMarkers: 1`(API-27 运行标记装饰,加上 API-29 从标记运行):一个通用的 Provider 回答“这份文档哪些行能运行”。插件声明 `{languageID, function, command}`,编辑器在文档变化后调用 `backend: 2` 的函数,把它返回的行画成行号旁的运行按钮;用户点击后宿主执行插件命令,通过 `lithe/host/commands/didRun` 把 `runMarker{id, languageID, path, line, version}` 交给后端。 + +开发时记住三点。第一,语法和运行标记的输出都由 Rust Core 校验(`plugins.declarative.decodeGrammar`、`plugins.declarative.decodeRunMarkers`),宿主和编辑器只消费校验后的结果,不自己解析插件的 JSON。第二,纯语法插件免重启,带运行标记的插件需要重启,因为运行标记依赖后端,后端目前只在启动时登记。第三,宿主不按插件名或语言名加分支,所有插件都走同一条路径。 + +## 问题 + +#570 要做一个类似 IDEA HTTP Client 的插件:`.http` 文件要有高亮,每个请求旁边要有运行按钮。现有插件能力做不到: + +- 语言贡献(`languages: 1`)只能声明 ID、扩展名和 LSP。没有语言服务器的格式只能显示纯文本,为了高亮去写一个 LSP 成本太高。 +- 运行按钮只有内置的 Java 运行标记,位置由宿主自己的识别逻辑决定,插件无法提供。 +- 用户希望 API 尽量少:不能为“测试函数”“main 函数”“HTTP 请求”各开一个识别 API,要一个通用接口覆盖任意语言、类、函数,以及自定义格式里的可执行块。 +- 插件可以写任意正则,必须防止病态语法拖垮编辑器,也不能让语法文件从包外读内容。 + +## 决策 + +### 语法是声明式配置,引擎由 `kind` 决定 + +- 清单写法是 `languages[].grammar: {kind: "monarch", path}` 和可选的 `languages[].configuration`。`path` 相对插件包,不能离开包。 +- 选 Monarch 先做,是因为编辑器本来就是 Monaco:不需要额外的 WASM 引擎,正则用浏览器自带的引擎,内存只多出每个语言一份状态机定义。TextMate 语法表达力更强,但要加载 oniguruma WASM,每个打开的文档都要保留行状态,内存明显更多,留到以后加新的 `kind`。 +- Core 的 `grammar.rs` 负责校验:文件最大 256 KiB,最多 256 个状态、4096 条规则,每个正则最长 2048 字节,动作嵌套最多 8 层;`next`、`switchTo`、`include` 必须指向语法里存在的状态,日志字段之类的调试动作一律拒绝。校验失败返回 `plugin.grammar.invalid` 并带上失败字段,该语言照常注册,只是没有高亮;插件没有为该语言声明语法时返回 `plugin.grammar.unavailable`。 +- 宿主(`MacPluginEditorLanguages`)只读插件包:解析符号链接后确认文件仍在包内,限制 256 KiB 并要求 UTF-8,按路径、大小、修改时间和语言缓存解码结果,被拒的语法只记一次日志。 +- 编辑器(`frontend/editor/src/plugin-languages.ts`)收到的是完整语言列表,用 `setMonarchTokensProvider` 和 `setLanguageConfiguration` 注册;某个语言注册失败时回报给宿主记日志,不影响其他语言。已打开的文件在语言列表变化后重新判定语言。 + +### 语法高亮免重启 + +- 语法通过一个只承载启用状态的模块 `<插件 id>.grammars`(`grammarsExtension`)参与插件生命周期。`canActivateWithoutRestart` 扩展为:没有 `backend`,模块列表非空,且每个模块都是 `debugAdaptersExtension` 或 `grammarsExtension`,没有其他贡献。规则见 [插件免重启激活](2026-10-26-plugin-live-activation.md)。 +- 实时安装的插件来自 `MacLivePluginRegistry`;启动时登记的插件跟随模块状态(停用、隔离、安全模式)变化。两者任何变化都会触发编辑器重新收到完整列表,撤回某个插件就是从列表里拿掉它。 + +### 运行标记是一个通用 Provider + +- 清单写法是 `runMarkers[]: {languageID, function, command}`,每种语言最多一个 Provider。需要 `backend: 2`、`documents.read` 权限(函数会收到文档全文)和 `onLanguage:` 激活事件;`command` 必须是插件自己的 `ui.commands[]`。 +- 编辑器在文档变化后发 `pluginRunMarkers`,宿主把 `{languageID, path(工作区相对路径), version, text}` 交给插件函数,超时 10 秒。超过 4 MiB 的文档和不在工作区内的文件不请求。 +- 函数返回 `{markers: [{id, line, endLine?, kind, title}]}`,行号从 1 开始;`kind` 是 `run`、`test` 或 `testSuite`。Core 的 `run_markers.rs` 校验:结果最大 1 MiB,最多 1000 个,ID 最长 256 字节且不重复,标题最长 200 字符,行号不能超出文档,同一行不能有两个。任一项不合格就拒绝整个结果(`plugin.runMarkers.invalidOutput`),不画一部分;插件没有为该语言声明 Provider 时返回 `plugin.runMarkers.unavailable`。 +- 返回时如果文档已经变了、Provider 已经撤回,宿主回 `cancelled`,编辑器丢弃结果。 +- 点击按钮时宿主重新核对版本和 Provider,再用通用的 `runCommand` 执行插件命令,参数里带 `runMarker`。后端用 `version` 核对文档是否已被修改。只读的编辑器视图不允许点击运行。 +- “智能识别”放在插件里:插件用自己熟悉的方式(正则、解析器、调用 LSP)找出可运行的位置,宿主只负责画按钮和转发点击。这样一个 API 就能覆盖任意语言的函数、类、测试,以及 `.http` 这类自定义格式。 + +## 考虑过的备选方案 + +- 让插件写 LSP 提供语义高亮:能用,但一个简单格式也要写语言服务器并随包分发,成本太高,用户明确不要依赖 LSP。 +- 直接上 TextMate:表达力强、生态大,但需要 WASM 引擎和每文档行状态,内存更多;先做 Monarch,引擎靠 `kind` 字段保留切换余地。 +- 宿主内置 HTTP 请求识别,或为测试、main 函数分别开识别 API:会在宿主里写插件专属逻辑,API 数量也会膨胀,因此改成一个通用 Provider。 +- 用 CodeLens 表示运行入口:CodeLens 会占用行高,也和 Java 已有的行号旁运行按钮不一致,因此沿用运行标记的样式。 +- 运行标记也做免重启:需要后端支持实时登记,属于后续的“后端热激活”,本次不做。 + +## 后果 + +- 插件可以只靠一份 JSON 配置给新格式加上高亮,安装、更新、停用、卸载都不需要重启。 +- Monaco 不能注销已注册的语言。插件卸载后,它的语法不再更新,相关文件按纯文本显示;重启后语言消失。 +- 插件声明内置语言 ID 时沿用已有的语言目录规则,同一语言有多个插件语法时按插件 ID 排序取第一个。 +- 带 `runMarkers` 的插件安装后需要重启。 +- Windows/Tauri 还没有插件后端和插件语法的宿主,这两项能力在 Windows 上标为缺失。 +- `commands/didRun` 多了可选的 `runMarker` 参数。合并后要在 lithe-plugin-docs 同步契约,并跑一次两个 SDK 的端到端测试。 + +## 验证 + +- `cargo test --manifest-path rust/Cargo.toml -p lithe-core plugin_grammars_run_markers`:语法限额、未知 kind、坏正则、越界路径,运行标记的重复行、越界行和数量上限。 +- `scripts/architecture/verify-shared-contracts.sh`:契约 fixture,包括 `language-grammar.plugin.json`、`run-markers.plugin.json` 和无效清单。 +- 在 `frontend/editor` 运行 `bun test`:`plugin-languages.test.ts` 和 `run-markers.test.ts`。 +- `scripts/macos/test/test-macos.sh --filter "PluginEditorLanguagesTests|PluginLiveActivationTests|MacPluginBackendSessionsTests"`:免重启判定、实时语法的出现和消失、模块状态跟随、包外和超限语法、`runMarker` 参数。 +- `scripts/macos/verify/verify-runtime-bundle-immutability.sh`:只从插件包读取语法,不向发行目录写入。 + +## 适用范围 + +适用于插件提供的语言语法、编辑器语言配置和编辑器运行标记。内置语言的高亮和 Java 的运行标记不受影响。以后加新的语法引擎、做后端热激活或在 Windows 上实现这两项能力时,先更新这份笔记。 diff --git a/docs/development/macos-plugin-api-plan.md b/docs/development/macos-plugin-api-plan.md index 0245ef5aa0..bc2cc3c4d4 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` 声明式语法(`grammar.kind` 先支持 monarch,不依赖 LSP,Mac 免重启);语言语义 Provider 未做 | | API-26 | 未做 | 公共诊断 collection/events API 未开放;既有 LSP 诊断不等于该 API | -| API-27 | 未做 | 通用主题、片段、装饰贡献未实现;既有 UI 图标不等价于完整主题 API | +| API-27 | 部分完成 | 新增 `runMarkers:1` 运行标记装饰(插件函数回答可运行行,编辑器画行号旁按钮);通用主题、片段、其他装饰未做 | | API-28 | 未做 | 公共 custom editor 文档 owner/保存/撤销/恢复未开放 | -| API-29 | 既有能力 | #1517 BuildProvider 1–6/model inputs;本轮旧 SDK E2E 兼容,不代表新指南齐全 | +| API-29 | 部分完成 | #1517 BuildProvider 1–6/model inputs;新增从运行标记运行(`commands/didRun` 带 `runMarker`,后端可按 `onLanguage:` 激活);新指南未齐全 | | API-30 | 既有能力 | 已有 debugAdapters 1/2 与 DAP 宿主;本 PR 不重建调试协议 | | API-31 | 既有能力 | 已有 vcsProviders:1;不包含内置 Git 公共读取面 | | API-32 | 未做 | 通用 ServiceProvider 仍为提案 | @@ -206,9 +206,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-grammars-and-run-markers);后续:新 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 资源不得执行脚本 | `runMarkers:1` 运行标记已实现(Mac;运行标记依赖后端,需要重启);主题/片段/其他装饰后续 | | 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 +217,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。从运行标记运行:点击按钮经 `lithe/host/commands/didRun` 把 `runMarker{id,languageID,path,line,version}` 交给插件命令 | M4;运行标记入口已实现(Mac) | | 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/plugin-languages.test.ts b/frontend/editor/src/plugin-languages.test.ts new file mode 100644 index 0000000000..322bf2cc48 --- /dev/null +++ b/frontend/editor/src/plugin-languages.test.ts @@ -0,0 +1,78 @@ +import { beforeEach, expect, test } from "bun:test"; +import { hasPluginGrammar, setPluginLanguages as replace, toLanguageConfiguration, + type MonacoLanguages, type PluginLanguage } from "./plugin-languages"; + +// A Monaco languages double that records registrations and provider lifetimes. +const registered: { id: string; extensions: string[]; filenames: string[] }[] = []; +const live = new Map(); +let failCompile = new Set(); +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; extensions: string[]; filenames: string[] }) => registered.push(language), + setMonarchTokensProvider: (id: string) => { + if (failCompile.has(id)) throw new Error(`cannot compile ${id}`); + return provider(id); + }, + setLanguageConfiguration: (id: string) => provider(id), +} as unknown as MonacoLanguages; + +const setPluginLanguages = (list: PluginLanguage[]) => replace(monaco, list); + +const http = { + id: "http", fileExtensions: ["http", ".rest"], + grammar: { kind: "monarch" as const, monarch: { tokenizer: { root: [] } } }, + configuration: { lineComment: "#", brackets: [["{", "}"]] as [string, string][], autoClosingPairs: [["\"", "\""]] as [string, string][] }, +}; + +beforeEach(() => { + setPluginLanguages([]); + failCompile = new Set(); +}); + +test("registers a plugin language once and owns its tokenizer", () => { + expect(setPluginLanguages([http])).toEqual({ failed: [] }); + expect(hasPluginGrammar("http")).toBe(true); + expect(registered.filter(language => language.id === "http")).toEqual([ + { id: "http", extensions: [".http", ".rest"], filenames: [] }, + ]); + expect(live.get("http")).toBe(2); + + // The same list keeps the providers; an empty list disposes them. + setPluginLanguages([http]); + expect(live.get("http")).toBe(2); + setPluginLanguages([]); + expect(hasPluginGrammar("http")).toBe(false); + expect(live.get("http")).toBe(0); + expect(registered.filter(language => language.id === "http")).toHaveLength(1); +}); + +test("a changed grammar replaces its providers", () => { + setPluginLanguages([http]); + setPluginLanguages([{ ...http, configuration: null }]); + expect(live.get("http")).toBe(2); + expect(hasPluginGrammar("http")).toBe(true); +}); + +test("a grammar Monaco rejects is reported and the others still apply", () => { + failCompile = new Set(["broken"]); + const broken = { ...http, id: "broken", fileExtensions: ["broken"] }; + expect(setPluginLanguages([broken, http])).toEqual({ failed: [{ id: "broken", message: "cannot compile broken" }] }); + expect(hasPluginGrammar("broken")).toBe(false); + expect(hasPluginGrammar("http")).toBe(true); +}); + +test("maps contract configuration to Monaco configuration", () => { + expect(toLanguageConfiguration(http.configuration)).toEqual({ + comments: { lineComment: "#", blockComment: undefined }, + brackets: [["{", "}"]], + autoClosingPairs: [{ open: "\"", close: "\"" }], + surroundingPairs: [{ open: "\"", close: "\"" }], + }); + expect(toLanguageConfiguration(null)).toEqual({ + comments: undefined, brackets: undefined, autoClosingPairs: [], surroundingPairs: [], + }); +}); diff --git a/frontend/editor/src/plugin-languages.ts b/frontend/editor/src/plugin-languages.ts new file mode 100644 index 0000000000..bc4af45d03 --- /dev/null +++ b/frontend/editor/src/plugin-languages.ts @@ -0,0 +1,81 @@ +// Plugin languages with a grammar (`languages` 2). Rust Core has validated each +// grammar; the editor registers the language, applies the Monarch tokenizer and +// editor configuration, and replaces them all when the host sends a new list. +import type { languages } from "monaco-editor/esm/vs/editor/editor.api.js"; +import { toMonacoLanguageId } from "./language"; + +/** One `plugins.declarative.decodeGrammar` language as the host forwards it. */ +export interface PluginLanguage { + id: string; + fileExtensions?: string[]; + fileNames?: string[]; + grammar: { kind: "monarch"; monarch: languages.IMonarchLanguage }; + configuration?: { + lineComment?: string; + blockComment?: [string, string]; + brackets?: [string, string][]; + autoClosingPairs?: [string, string][]; + } | null; +} + +const owned = new Map(); + +/** Whether a plugin grammar, not a built-in tokenizer, highlights the language. */ +export function hasPluginGrammar(monacoLanguageId: string): boolean { + return owned.has(monacoLanguageId); +} + +/** Monaco's configuration for the contract's comment and bracket fields. */ +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}`; + +/** The part of Monaco's `languages` namespace plugin grammars use. */ +export type MonacoLanguages = Pick; + +/** + * Makes `list` the complete set of plugin grammars. A grammar Monaco cannot + * compile leaves its language as plain text and is reported back; the other + * languages still apply. + */ +export function setPluginLanguages(monaco: MonacoLanguages, list: readonly PluginLanguage[]): { failed: { id: string; message: string }[] } { + const failed: { id: string; message: string }[] = []; + const next = new Map(list.filter(language => language?.grammar?.kind === "monarch") + .map(language => [toMonacoLanguageId(language.id), language] as const)); + for (const [id, current] of owned) { + if (next.has(id)) continue; + current.providers.forEach(provider => provider.dispose()); + owned.delete(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 a language, so file matching only grows; a + // removed plugin's files then open without its tokenizer. + if (!monaco.getLanguages().some(registered => registered.id === id)) { + monaco.register({ id, extensions: (language.fileExtensions ?? []).map(withDot), filenames: language.fileNames ?? [] }); + } + const providers: { dispose(): void }[] = []; + try { + providers.push(monaco.setMonarchTokensProvider(id, language.grammar.monarch)); + providers.push(monaco.setLanguageConfiguration(id, toLanguageConfiguration(language.configuration))); + owned.set(id, { key, providers }); + } catch (error) { + providers.forEach(provider => provider.dispose()); + failed.push({ id: language.id, message: error instanceof Error ? error.message : String(error) }); + } + } + return { failed }; +} diff --git a/frontend/editor/src/run-markers.test.ts b/frontend/editor/src/run-markers.test.ts index a71582da34..12f2dbe96c 100644 --- a/frontend/editor/src/run-markers.test.ts +++ b/frontend/editor/src/run-markers.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { normalizeRunMarkers, runMarkerForLine, runMarkerIcon, type RunMarker } from "./run-markers"; +import { normalizeRunMarkers, pluginRunMarkers, runMarkerForLine, runMarkerIcon, type RunMarker } from "./run-markers"; function marker(overrides: Partial): RunMarker { return { id: "m", line: 1, endLine: 1, kind: "main", label: "App.main()", status: "none", ...overrides }; @@ -41,4 +41,31 @@ describe("editor Run markers", () => { expect(runMarkerForLine(markers, 1)?.id).toBe("main"); expect(runMarkerForLine([], 1)).toBeUndefined(); }); + + test("map plugin markers into the editor's kinds with their title as the target", () => { + const markers = pluginRunMarkers([ + { id: "list", line: 1, endLine: 3, kind: "run", title: "GET /orders", command: "http.send" }, + { id: "suite", line: 5, endLine: 9, kind: "testSuite", title: "Orders", command: "http.send" }, + { id: "case", line: 6, endLine: 6, kind: "test", title: "creates", command: "http.send" }, + { id: "debug", line: 8, kind: "debug", title: "dropped" }, + ], 10); + expect(markers).toEqual([ + { id: "list", line: 1, endLine: 3, kind: "run", label: "GET /orders", status: "none" }, + { id: "suite", line: 5, endLine: 9, kind: "testClass", label: "Orders", status: "none" }, + { id: "case", line: 6, endLine: 6, kind: "testMethod", label: "creates", status: "none" }, + ]); + expect(runMarkerIcon(markers[0])).toBe("run"); + expect(runMarkerIcon(markers[1])).toBe("run-all"); + expect(pluginRunMarkers(null, 10)).toEqual([]); + }); + + test("a caret inside a plugin request runs that request", () => { + const markers = pluginRunMarkers([ + { id: "first", line: 1, endLine: 4, kind: "run", title: "GET /a" }, + { id: "second", line: 6, endLine: 9, kind: "run", title: "POST /b" }, + ], 10); + expect(runMarkerForLine(markers, 3)?.id).toBe("first"); + expect(runMarkerForLine(markers, 9)?.id).toBe("second"); + expect(runMarkerForLine(markers, 5)).toBeUndefined(); + }); }); diff --git a/frontend/editor/src/run-markers.ts b/frontend/editor/src/run-markers.ts index 5697bdc666..bcee458bfc 100644 --- a/frontend/editor/src/run-markers.ts +++ b/frontend/editor/src/run-markers.ts @@ -1,8 +1,9 @@ // Pure rules for editor Run markers shared by editor hosts. Marker facts come -// from the host (JDT through Rust Core); this module only picks icons and the -// marker a caret line refers to. +// from the host (JDT through Rust Core, or a plugin's provider validated by +// Rust Core); this module only picks icons and the marker a caret line refers to. -export type RunMarkerKind = "main" | "testClass" | "testMethod"; +/** `run` is a plugin's runnable unit, such as one request of an `.http` file. */ +export type RunMarkerKind = "main" | "run" | "testClass" | "testMethod"; export type RunMarkerStatus = "none" | "passed" | "failed" | "skipped"; /** One gutter marker as the host sends it, with one-based lines. */ @@ -34,7 +35,7 @@ export function normalizeRunMarkers(value: unknown, lineCount: number): RunMarke for (const candidate of value as Partial[]) { if (!candidate || typeof candidate.id !== "string" || typeof candidate.label !== "string" || !Number.isInteger(candidate.line) || candidate.line! < 1 || candidate.line! > lineCount || - !["main", "testClass", "testMethod"].includes(candidate.kind as string)) continue; + !["main", "run", "testClass", "testMethod"].includes(candidate.kind as string)) continue; const endLine = Number.isInteger(candidate.endLine) && candidate.endLine! >= candidate.line! ? Math.min(candidate.endLine!, lineCount) : candidate.line!; const status = ["passed", "failed", "skipped"].includes(candidate.status as string) @@ -46,6 +47,21 @@ export function normalizeRunMarkers(value: unknown, lineCount: number): RunMarke return markers; } +/** Plugin marker kinds (`runMarkers` 1) in the editor's marker vocabulary. */ +const PLUGIN_MARKER_KINDS: Record = { run: "run", test: "testMethod", testSuite: "testClass" }; + +/** + * Markers of a `plugins.declarative.decodeRunMarkers` answer. Plugin markers + * carry no test outcome, and their `title` is the menu target. + */ +export function pluginRunMarkers(value: unknown, lineCount: number): RunMarker[] { + if (!Array.isArray(value)) return []; + return normalizeRunMarkers(value.map(marker => marker && typeof marker === "object" ? { + id: marker.id, line: marker.line, endLine: marker.endLine, + kind: PLUGIN_MARKER_KINDS[marker.kind], label: marker.title, status: "none", + } : undefined), lineCount); +} + /** * The marker a caret line refers to, following IDEA: the innermost test * method or class whose declaration contains the line, otherwise the `main` diff --git a/frontend/editor/src/workbench.ts b/frontend/editor/src/workbench.ts index a52b5fc82e..25d751598a 100644 --- a/frontend/editor/src/workbench.ts +++ b/frontend/editor/src/workbench.ts @@ -26,7 +26,8 @@ import { CancellationError, isCancellationError } from "monaco-editor/esm/vs/bas 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 { normalizeRunMarkers, pluginRunMarkers, runMarkerForLine, runMarkerIcon, type RunMarker, type RunMarkerIcon } from "./run-markers"; +import { setPluginLanguages, type PluginLanguage } from "./plugin-languages"; const escapeHTML = (value: string) => value.replace(/[&<>"']/g, character => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[character]!); @@ -213,10 +214,16 @@ export function mountWorkbench(host: WorkbenchHost) { Object.assign(state, { markers, version, revision }); for (const view of allEditors()) navigationContexts.get(view)?.(); } - // 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. + // Run markers: for Java the host answers which `main` methods and tests exist + // and their last outcomes; for a plugin language with `runMarkers` 1 the + // plugin's provider answers through the host. 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 pluginRunMarkerLanguages = new Set(); + let previousPluginLanguageIDs = new Set(); + const runMarkerSource = (entry: Entry): "java" | "plugin" | undefined => + entry.model.getLanguageId() === "java" ? "java" : pluginRunMarkerLanguages.has(entry.languageID) ? "plugin" : undefined; const runMarkerStates = new WeakMap(); const runMarkerContexts = new WeakMap void>(); async function refreshRunMarkers(id: string, entry: Entry) { @@ -233,11 +240,14 @@ export function mountWorkbench(host: WorkbenchHost) { const valid = () => current() && entries.get(id) === entry && !entry.model.isDisposed() && entry.model.getVersionId() === version && state!.generation === generation; if (!valid()) return; - const revision = entry.revision, language = entry.model.getLanguageId(); - const reply = language === "java" && !entry.readonly - ? await languageRequest({ type: "javaRunMarkers", id, revision }) : { markers: [] }; - if (!valid() || entry.revision !== revision || entry.model.getLanguageId() !== language || reply.cancelled) return; - const markers = normalizeRunMarkers(reply.markers, entry.model.getLineCount()); + const revision = entry.revision, language = entry.model.getLanguageId(), source = runMarkerSource(entry); + const reply = !source || entry.readonly ? { markers: [] } + : await languageRequest(source === "java" ? { type: "javaRunMarkers", id, revision } + : { type: "pluginRunMarkers", id, revision, languageID: entry.languageID }); + if (!valid() || entry.revision !== revision || entry.model.getLanguageId() !== language || + runMarkerSource(entry) !== source || reply.cancelled) return; + const markers = source === "plugin" ? pluginRunMarkers(reply.markers, entry.model.getLineCount()) + : normalizeRunMarkers(reply.markers, entry.model.getLineCount()); state.decorations = entry.model.deltaDecorations(state.decorations, markers.map(marker => ({ range: new monaco.Range(marker.line, 1, marker.line, 1), options: { glyphMarginClassName: `lithe-java-run lithe-java-run-${runMarkerIcon(marker)}`, @@ -248,11 +258,11 @@ export function mountWorkbench(host: WorkbenchHost) { glyphMargin: { position: monaco.editor.GlyphMarginLane.Right }, glyphMarginHoverMessage: { value: `Run '${marker.label}'`, isTrusted: false }, } }))); - Object.assign(state, { markers, version, revision, canDebug: reply.canDebug === true }); + Object.assign(state, { markers, version, revision, canDebug: source === "java" && reply.canDebug === true }); for (const view of allEditors()) runMarkerContexts.get(view)?.(); } function scheduleRunMarkers(id: string, entry: Entry) { - if (entry.model.getLanguageId() !== "java" && !runMarkerStates.has(entry)) return; + if (!runMarkerSource(entry) && !runMarkerStates.has(entry)) return; const state = runMarkerStates.get(entry); if (!state) { void refreshRunMarkers(id, entry).catch(console.error); return; } clearTimeout(state.timer); @@ -803,7 +813,7 @@ export function mountWorkbench(host: WorkbenchHost) { const pair = [...entries].find(([, entry]) => entry.model === view.getModel()); if (!pair || line === undefined) return; const [id, entry] = pair, state = runMarkerStates.get(entry); - if (!state || entry.model.isDisposed() || entry.model.getLanguageId() !== "java" || + if (!state || entry.model.isDisposed() || !runMarkerSource(entry) || state.version !== entry.model.getVersionId() || state.revision !== entry.revision || entry.readonly || entry.frozen || entry.closingHolds || failed) return; return { id, state }; @@ -812,7 +822,9 @@ export function mountWorkbench(host: WorkbenchHost) { const entry = entries.get(id); if (!entry || entry.readonly || runMarkerStates.get(entry) !== state || entry.revision !== state.revision) return Promise.resolve({ cancelled: true }); - return languageRequest({ type: "javaRunMarkerAction", id, revision: state.revision, marker: marker.id, action }); + return runMarkerSource(entry) === "plugin" + ? languageRequest({ type: "pluginRunMarkerAction", id, revision: state.revision, marker: marker.id, line: marker.line }) + : languageRequest({ type: "javaRunMarkerAction", id, revision: state.revision, marker: marker.id, action }); }; // Monaco action labels are fixed, so the caret's target is re-registered // whenever it changes, giving IDEA's "Run 'OrderTest.creates'" wording. @@ -1032,6 +1044,36 @@ export function mountWorkbench(host: WorkbenchHost) { lastMarkdownRatio = Math.min(1, Math.max(0, payload.ratio!)); } finally { applyingMarkdownScroll = false; } }, + /** + * Replaces the plugin languages with a grammar and the plugin languages + * whose Run markers the host provides (`pluginRunMarkers`). + */ + async setPluginLanguages(payload: { languages?: PluginLanguage[]; runMarkerLanguages?: string[] }) { + await activation; + const languages = Array.isArray(payload?.languages) ? payload.languages : []; + const result = setPluginLanguages(monaco.languages, languages); + pluginRunMarkerLanguages = new Set(Array.isArray(payload?.runMarkerLanguages) ? payload.runMarkerLanguages : []); + // 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 => toMonacoLanguageId(language?.id))]); + previousPluginLanguageIDs = new Set(languages.map(language => toMonacoLanguageId(language?.id))); + for (const [id, entry] of entries) { + if (entry.filename === undefined || entry.model.isDisposed()) continue; + const languageID = languageForFilename(entry.filename), language = toMonacoLanguageId(languageID); + if (language === entry.model.getLanguageId() || !(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); + } + } + await Promise.all([...entries].filter(([, entry]) => allEditors().some(view => view.getModel() === entry.model)) + .map(([id, entry]) => refreshRunMarkers(id, entry))); + return result; + }, async refreshJavaRunMarkers() { await activation; await Promise.all([...entries].filter(([, entry]) => allEditors().some(view => view.getModel() === entry.model)) diff --git a/macos/Sources/Lithe/Application/Composition/AppServices.swift b/macos/Sources/Lithe/Application/Composition/AppServices.swift index d39b69d5ae..5d4e05e6a3 100644 --- a/macos/Sources/Lithe/Application/Composition/AppServices.swift +++ b/macos/Sources/Lithe/Application/Composition/AppServices.swift @@ -42,6 +42,9 @@ final class AppServices { /// Starts plugin backends on `onWorkspaceOpen` and `onCommand:`; /// `nil` when the platform runs no plugin backends. var pluginBackendActivation: (any PluginBackendActivating)? + /// Plugin grammars and Run marker providers for the editors; `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/PluginEditorLanguages.swift b/macos/Sources/Lithe/Application/Features/PluginEditorLanguages.swift new file mode 100644 index 0000000000..d874f98178 --- /dev/null +++ b/macos/Sources/Lithe/Application/Features/PluginEditorLanguages.swift @@ -0,0 +1,65 @@ +import Combine +import Foundation +import LitheCoreContracts + +// Note: 插件语法高亮与运行标记的决策见 .agents/notes/implemented/architecture/2026-10-31-plugin-grammars-and-run-markers.md + +/// A plugin backend's Run marker provider for one language (`runMarkers` 1). +struct PluginRunMarkerProvider: Equatable { + let pluginID: String + /// The normalized manifest Rust Core checks the provider's answers against. + let manifest: Data + let languageID: String + let functionID: String + let commandID: String +} + +/// The Run marker a command was run from, sent to the backend as +/// `lithe/host/commands/didRun` `runMarker`. +struct PluginRunMarkerInvocation: Equatable { + let id: String + let languageID: String + /// Workspace-relative, `/`-separated. + let path: String + /// 0-based, as the provider reported it. + let line: Int + /// The document version the marker was computed for. + let version: Int + + var json: ToolingJSONValue { + .object([ + "id": .string(id), "languageID": .string(languageID), "path": .string(path), + "line": .integer(line), "version": .integer(version), + ]) + } +} + +/// What the editor gets from plugins right now: the languages with a +/// grammar Rust Core accepted, and the languages whose Run markers a plugin +/// backend provides. +struct PluginEditorLanguageSet: Equatable { + /// `plugins.declarative.decodeGrammar` languages, for the editor's + /// `setPluginLanguages`. + var languages: [ToolingJSONValue] = [] + var runMarkerProviders: [String: PluginRunMarkerProvider] = [:] + + /// The editor's `setPluginLanguages` payload. + var editorPayload: [String: Any] { + let languages = (try? JSONSerialization.jsonObject(with: JSONEncoder().encode(languages))) ?? [] + return ["languages": languages, "runMarkerLanguages": runMarkerProviders.keys.sorted()] + } +} + +/// The plugin grammars and Run marker providers of the active plugins. +/// A grammar-only plugin applies without a restart, so the set follows the +/// live plugin registry and module enablement. +@MainActor +protocol PluginEditorLanguageProviding: AnyObject { + /// Fires after `current()` may have changed. + var changes: AnyPublisher { get } + func current() -> PluginEditorLanguageSet + /// Rust Core's check of a provider's answer for a document of `lineCount` lines. + func decodeRunMarkers( + _ provider: PluginRunMarkerProvider, lineCount: Int, result: ToolingJSONValue + ) throws -> [PluginRunMarker] +} diff --git a/macos/Sources/Lithe/Application/Features/PluginHostServiceDispatch.swift b/macos/Sources/Lithe/Application/Features/PluginHostServiceDispatch.swift index f52816e7dd..aa3d464716 100644 --- a/macos/Sources/Lithe/Application/Features/PluginHostServiceDispatch.swift +++ b/macos/Sources/Lithe/Application/Features/PluginHostServiceDispatch.swift @@ -13,7 +13,12 @@ protocol PluginBackendActivating: AnyObject { /// The user ran one of the owner plugin's commands: its `backend` 2 /// starts when it declares `onCommand:`, and the running backend /// receives `lithe/host/commands/didRun` with the window's context. - func runCommand(_ commandID: String, owner: ModuleID, context: PluginWorkspaceContextReference?) + /// A command run from a Run marker also carries `runMarker` and starts + /// the backend on `onLanguage:`. + func runCommand( + _ commandID: String, owner: ModuleID, context: PluginWorkspaceContextReference?, + runMarker: PluginRunMarkerInvocation? + ) } /// How a host service failed, as a function protocol 3 backend sees it. diff --git a/macos/Sources/Lithe/Core/Rust/RustDeclarativePluginOperations.swift b/macos/Sources/Lithe/Core/Rust/RustDeclarativePluginOperations.swift index 27448a2dd5..43fbacc9fa 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 { let core: RustCoreBridge func validateNativeNodes(ui: PluginNativeUI, data: ToolingJSONValue) throws -> [PluginNativeUI.Node] { @@ -298,6 +299,30 @@ 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 decodeRunMarkers( + manifest: Data, languageID: String, lineCount: Int, result: ToolingJSONValue + ) throws -> [PluginRunMarker] { + struct Payload: Encodable { + let manifest: ToolingJSONValue; let languageID: String; let lineCount: Int; let result: ToolingJSONValue + } + struct Result: Decodable { let markers: [PluginRunMarker] } + let outcome: Outcome = try execute( + "plugins.declarative.decodeRunMarkers", + payload: Payload(manifest: try Self.json(manifest), languageID: languageID, lineCount: lineCount, result: result) + ) + return outcome.value.markers + } + func resolveSettings( manifest: Data, settings: [String: ToolingJSONValue], diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+UIContributions.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+UIContributions.swift index 99024c47f9..1f60d7f7b2 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+UIContributions.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+UIContributions.swift @@ -23,7 +23,7 @@ extension AppModel { // the plugin declares `onCommand:`; the window opens without // waiting. if contribution.kind == .command, let commandID = contribution.actionID { - services.pluginBackendActivation?.runCommand(commandID, owner: owner, context: pluginWebWorkspaceContext) + services.pluginBackendActivation?.runCommand(commandID, owner: owner, context: pluginWebWorkspaceContext, runMarker: nil) } guard let target else { return } let window = WorkbenchFeatureModel.ToolWindow(rawValue: target) diff --git a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index f08ac9d3b9..4502155641 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 @@ -1132,6 +1133,26 @@ final class MacServiceContainer { ) services.ideCapabilityRegistry = ideCapabilityRegistry services.pluginRegistrationChanges = livePlugins.changes + // Grammars apply without a restart; Run marker providers follow the + // launch-registered plugin's UI module. + let editorLanguageModuleChanges = PassthroughSubject() + moduleRuntime.observeEvents { event in + guard event.name == ModuleEvent.stateChangedName else { return } + editorLanguageModuleChanges.send() + } + services.pluginEditorLanguages = MacPluginEditorLanguages( + launchRegistered: pluginStartup.registeredPlugins, + isModuleUsable: { 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 @@ -1351,6 +1372,7 @@ 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 == .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..503e04e8ca 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,10 @@ 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 var pluginRunMarkerLists: [String: (context: MonacoDocumentContext, revision: Int, url: URL, provider: PluginRunMarkerProvider, path: String, markers: [PluginRunMarker])] = [:] + private var pluginRunMarkerRequests: [String: UUID] = [:] + 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)] = [:] @@ -722,6 +727,36 @@ 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' languages and Run marker providers. + 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 + // Markers of a provider that went away must not run. + pluginRunMarkerLists = pluginRunMarkerLists.filter { set.runMarkerProviders[$0.value.provider.languageID] == $0.value.provider } + 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 observeCodeVision(model: AppModel) { if codeVisionSubscription == nil { codeVisionSubscription = model.javaFeature.$javaCodeVisionHints @@ -836,6 +871,8 @@ 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) let id = document.id.uuidString let groupDocuments: [EditorDocument] if let scopedDocumentID { @@ -859,6 +896,8 @@ final class MonacoWorkbenchSession: NSObject, ObservableObject, WKNavigationDele javaNavigationRequests[oldID] = nil javaRunMarkerLists[oldID] = nil javaRunMarkerRequests[oldID] = nil + pluginRunMarkerLists[oldID] = nil + pluginRunMarkerRequests[oldID] = nil lastGitStates[oldID] = nil gitLoads.removeValue(forKey: oldID)?.cancel() blameLoads.removeValue(forKey: oldID)?.cancel() @@ -1064,9 +1103,23 @@ final class MonacoWorkbenchSession: NSObject, ObservableObject, WKNavigationDele documents: documents, workspaceURL: model.workspaceURL) } + /// Largest document a plugin Run marker provider receives. + static let maximumRunMarkerDocumentBytes = 4 * 1024 * 1024 + + /// `url` relative to the workspace, `/`-separated; nil outside it. + static func workspaceRelativePath(of url: URL, in workspaceURL: URL?) -> String? { + guard let workspaceURL else { return nil } + let root = workspaceURL.standardizedFileURL.path + let prefix = root.hasSuffix("/") ? root : root + "/" + let path = url.standardizedFileURL.path + guard path.hasPrefix(prefix), path.count > prefix.count else { return nil } + return String(path.dropFirst(prefix.count)) + } + func allowsMessage(_ type: String) -> Bool { !isReadOnlyPreview || !["format", "codeActions", "resolveCodeAction", "executeCodeAction", - "rename", "resolveCompletion", "javaRunMarkerAction", "setBlameVisibility"].contains(type) + "rename", "resolveCompletion", "javaRunMarkerAction", "pluginRunMarkerAction", + "setBlameVisibility"].contains(type) } func receive(_ message: WKScriptMessage, reply: @escaping (Any?, String?) -> Void) { @@ -1218,6 +1271,67 @@ final class MonacoWorkbenchSession: NSObject, ObservableObject, WKNavigationDele model.editorDidFocus(document) model.performJavaRunMarker(marker, action: action, in: document.url) reply(["ok": true], nil) + case "pluginRunMarkers": + // A plugin provider answers for documents in the workspace up to 4 MiB. + guard let model, !failed, let revision = body["revision"] as? Int, revision == revisions[id], + let languageID = body["languageID"] as? String, + let provider = lastPluginLanguages?.runMarkerProviders[languageID], + let backends = model.pluginWebBackends, + let path = Self.workspaceRelativePath(of: document.url, in: model.workspaceURL) else { + reply(["markers": []], nil); return + } + let text = document.text + guard text.utf8.count <= Self.maximumRunMarkerDocumentBytes else { reply(["markers": []], nil); return } + let url = document.url, version = revision + let lineCount = text.utf8.reduce(1) { $1 == 0x0A ? $0 + 1 : $0 } + let requestID = UUID() + pluginRunMarkerRequests[id] = requestID + let params: ToolingJSONValue = .object([ + "languageID": .string(languageID), "path": .string(path), + "version": .integer(version), "text": .string(text), + ]) + let workspaceContext = model.pluginWebWorkspaceContext + let languages = model.services.pluginEditorLanguages + Task { @MainActor [weak self, weak document] in + guard let self, let document, self.isCurrent(context) else { reply(["cancelled": true], nil); return } + var markers: [PluginRunMarker] = [] + do { + let result = try await backends.invokePluginFunction( + provider.functionID, params: params, ofPlugin: provider.pluginID, + context: workspaceContext, timeout: .seconds(10), progress: { _ in } + ) + markers = try languages?.decodeRunMarkers(provider, lineCount: lineCount, result: result) ?? [] + } catch { + NSLog("Lithe plugin run markers failed [%@]: %@", provider.pluginID, error.localizedDescription) + } + guard self.isCurrent(context), self.revisions[id] == revision, document.url == url, + self.pluginRunMarkerRequests[id] == requestID, + self.lastPluginLanguages?.runMarkerProviders[languageID] == provider else { + reply(["cancelled": true], nil); return + } + self.pluginRunMarkerLists[id] = (context, revision, url, provider, path, markers) + reply(["markers": markers.map { marker in + ["id": marker.id, "line": marker.line, "endLine": marker.endLine, + "kind": marker.kind, "title": marker.title] as [String: Any] + }], nil) + } + case "pluginRunMarkerAction": + guard let model, !failed, let revision = body["revision"] as? Int, revision == revisions[id], + let list = pluginRunMarkerLists[id], isCurrent(list.context), list.revision == revision, list.url == document.url, + lastPluginLanguages?.runMarkerProviders[list.provider.languageID] == list.provider, + let markerID = body["marker"] as? String, + let marker = list.markers.first(where: { $0.id == markerID }) else { + reply(["cancelled": true], nil); return + } + model.editorDidFocus(document) + model.services.pluginBackendActivation?.runCommand( + marker.command, owner: ModuleID("\(list.provider.pluginID).ui"), context: model.pluginWebWorkspaceContext, + runMarker: PluginRunMarkerInvocation( + id: marker.id, languageID: list.provider.languageID, path: list.path, + line: marker.line, version: list.revision + ) + ) + reply(["ok": true], nil) 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, diff --git a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift index 5efd2773b8..987682f139 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginBackendSessions.swift @@ -260,7 +260,10 @@ final class MacPluginBackendSessions: PluginBackendRequesting, PluginBackendActi }) } - func runCommand(_ commandID: String, owner: ModuleID, context: PluginWorkspaceContextReference?) { + func runCommand( + _ commandID: String, owner: ModuleID, context: PluginWorkspaceContextReference?, + runMarker: PluginRunMarkerInvocation? = nil + ) { let pluginID = String(owner.rawValue.dropLast(".ui".count)) guard owner.rawValue.hasSuffix(".ui"), let package = packages[pluginID], package.backendVersion >= 2, !disabledPluginIDs.contains(pluginID) else { return } @@ -270,11 +273,14 @@ final class MacPluginBackendSessions: PluginBackendRequesting, PluginBackendActi "contextID": .string(context.contextID), "generation": .integer(context.generation), ]) } + if let runMarker { params["runMarker"] = runMarker.json } if commandStarts[pluginID] == nil, running[pluginID]?.ready == true, running[pluginID]?.session?.isRunning == true { notify(pluginID: pluginID, method: "lithe/host/commands/didRun", params: .object(params)) return } - guard running[pluginID] != nil || package.activationEvents.contains("onCommand:\(commandID)") else { return } + // A Run marker's provider is activated by its language. + guard running[pluginID] != nil || package.activationEvents.contains("onCommand:\(commandID)") + || runMarker.map({ package.activationEvents.contains("onLanguage:\($0.languageID)") }) == true else { return } guard pendingCommands[pluginID, default: []].count < Self.maximumPendingNotifications else { reportNotificationLimit(pluginID) return 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..fe824f5989 --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginEditorLanguages.swift @@ -0,0 +1,130 @@ +import Combine +import Foundation +import LitheCoreContracts +import LitheModuleAPI + +// Note: 插件语法高亮与运行标记的决策见 .agents/notes/implemented/architecture/2026-10-31-plugin-grammars-and-run-markers.md + +/// Collects the grammars (`languages` 2) and Run marker providers +/// (`runMarkers` 1) of the active plugins for the editors of every window. +/// +/// 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() } + + /// - Parameters: + /// - launchRegistered: Plugins in the module graph since launch. Their + /// grammars follow the grammars module, their providers the UI module + /// that owns the command. + /// - 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.grammarPath != nil } || !$0.descriptor.runMarkerProviders.isEmpty + } + 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.grammarPath != nil { + // Rust Core's catalog refuses two plugins declaring one + // language; the first plugin by ID keeps it regardless. + guard seen.insert(language.id).inserted, let value = grammar(of: language, in: package) else { continue } + set.languages.append(value) + } + } + for package in launchRegistered where isModuleUsable(ModuleID("\(package.descriptor.id.rawValue).ui")) { + for provider in package.descriptor.runMarkerProviders where set.runMarkerProviders[provider.languageID] == nil { + set.runMarkerProviders[provider.languageID] = PluginRunMarkerProvider( + pluginID: package.descriptor.id.rawValue, manifest: package.descriptor.normalizedManifest, + languageID: provider.languageID, functionID: provider.functionID, commandID: provider.commandID + ) + } + } + return set + } + + func decodeRunMarkers( + _ provider: PluginRunMarkerProvider, lineCount: Int, result: ToolingJSONValue + ) throws -> [PluginRunMarker] { + try decoder.decodeRunMarkers( + manifest: provider.manifest, languageID: provider.languageID, lineCount: lineCount, result: result + ) + } + + 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, let url = Self.packageFile(path, in: package.packageURL) else { + log("plugin grammar is outside its package", 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)" + 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") + } + guard let text = String(data: try Data(contentsOf: url), 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] = 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/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift b/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift index 590016a5b9..b515babe7a 100644 --- a/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift +++ b/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift @@ -319,10 +319,29 @@ 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 init(id: String, displayName: String) { + public init(id: String, displayName: String, grammarPath: String? = nil) { self.id = id self.displayName = displayName + self.grammarPath = grammarPath + } +} + +/// A plugin's Run marker provider for one language (`runMarkers` 1). The +/// backend function answers where a document is runnable; running a marker +/// runs the command with `runMarker` in `lithe/host/commands/didRun`. +public struct DeclarativeRunMarkerProvider: Equatable, Hashable, Sendable { + public let languageID: String + public let functionID: String + public let commandID: String + + public init(languageID: String, functionID: String, commandID: String) { + self.languageID = languageID + self.functionID = functionID + self.commandID = commandID } } @@ -648,6 +667,8 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { public let settingActions: [DeclarativePluginSettingAction] /// Languages the plugin declares, with their display names. public let languages: [DeclarativePluginLanguage] + /// Run marker providers of the plugin's backend (`runMarkers` 1). + public let runMarkerProviders: [DeclarativeRunMarkerProvider] public let permissions: [String] public let activationEvents: [String] public let ui: DeclarativeUIContributions? @@ -714,7 +735,12 @@ 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) + } + runMarkerProviders = decoded.runMarkers.map { + DeclarativeRunMarkerProvider(languageID: $0.languageID, functionID: $0.function, commandID: $0.command) + } permissions = decoded.permissions activationEvents = decoded.activationEvents self.normalizedManifest = normalizedManifest @@ -786,6 +812,16 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { ) )) } + if languages.contains(where: { $0.grammarPath != nil }) { + // 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( @@ -858,16 +894,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 +956,11 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { let fileNames: [String] let projectFileNames: [String] let dependencies: Dependencies? + let grammar: Grammar? + + struct Grammar: Decodable { + let path: String + } init(from decoder: Decoder) throws { let values = try decoder.container(keyedBy: CodingKeys.self) @@ -927,10 +970,11 @@ 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) } private enum CodingKeys: String, CodingKey { - case id, displayName, fileExtensions, fileNames, projectFileNames, dependencies + case id, displayName, fileExtensions, fileNames, projectFileNames, dependencies, grammar } } struct Launch: Decodable { @@ -1346,6 +1390,7 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { let settings: [Setting] let settingActions: [SettingAction] let projectGenerators: [ProjectGenerator] + let runMarkers: [RunMarkerProvider] /// Launch fields stay with Rust Core; the host only needs to know /// a backend exists. let backend: ToolingJSONValue? @@ -1358,6 +1403,12 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { let icon: PluginUIIcon? } + struct RunMarkerProvider: Decodable { + let languageID: String + let function: String + let command: String + } + init(from decoder: Decoder) throws { let values = try decoder.container(keyedBy: CodingKeys.self) id = try values.decode(String.self, forKey: .id) @@ -1377,13 +1428,14 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { settings = try values.decodeIfPresent([Setting].self, forKey: .settings) ?? [] settingActions = try values.decodeIfPresent([SettingAction].self, forKey: .settingActions) ?? [] projectGenerators = try values.decodeIfPresent([ProjectGenerator].self, forKey: .projectGenerators) ?? [] + runMarkers = try values.decodeIfPresent([RunMarkerProvider].self, forKey: .runMarkers) ?? [] backend = try values.decodeIfPresent(ToolingJSONValue.self, forKey: .backend) } private enum CodingKeys: String, CodingKey { case id, displayName, version, pluginAPIVersion, vendor, permissions, activationEvents case languages, languageServers, executionProviders, buildSystems, vcsProviders, ui, settings, settingActions - case projectGenerators, backend, debugAdapters + case projectGenerators, backend, debugAdapters, runMarkers } } } @@ -1508,6 +1560,38 @@ 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. +/// One Run marker of a plugin provider's answer, as Rust Core accepted it. +/// Lines are one-based; `command` is the provider's command. +public struct PluginRunMarker: Equatable, Sendable, Decodable { + public let id: String + public let line: Int + public let endLine: Int + public let kind: String + public let title: String + public let command: String + + public init(id: String, line: Int, endLine: Int, kind: String, title: String, command: String) { + self.id = id + self.line = line + self.endLine = endLine + self.kind = kind + self.title = title + self.command = command + } +} + +/// Rust Core's checks of what a plugin adds to the editor: grammars +/// (`languages` 2) and Run marker answers (`runMarkers` 1). Core reads no +/// files; the host passes the grammar text it read from the package. +public protocol DeclarativeEditorContributionDecoding: Sendable { + /// The language as the editor registers it: `{ id, fileExtensions, + /// fileNames, grammar: { kind, monarch }, configuration }`. + func decodeGrammar(manifest: Data, languageID: String, grammar: String) throws -> ToolingJSONValue + func decodeRunMarkers( + manifest: Data, languageID: String, lineCount: Int, result: ToolingJSONValue + ) throws -> [PluginRunMarker] +} + public protocol DeclarativePluginSettingsResolving: Sendable { func resolveSettings( manifest: Data, diff --git a/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift b/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift index 343108bac0..28b14884fc 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) Syntax Highlighting", + 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..bc5f98944f 100644 --- a/macos/Sources/LitheModuleAPI/Lifecycle/ModuleTypes.swift +++ b/macos/Sources/LitheModuleAPI/Lifecycle/ModuleTypes.swift @@ -319,6 +319,12 @@ public extension ModuleID { ModuleID("\(pluginID).debug-adapters") } + /// 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..1ae41a6269 100644 --- a/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift +++ b/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift @@ -514,8 +514,9 @@ 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], + "runMarkers": [1], "serverProcess": [1], "settings": [1, 2], "ui": [1, 2, 3, 4], diff --git a/macos/Tests/LitheTests/PluginEditorLanguagesTests.swift b/macos/Tests/LitheTests/PluginEditorLanguagesTests.swift new file mode 100644 index 0000000000..f42220f3fd --- /dev/null +++ b/macos/Tests/LitheTests/PluginEditorLanguagesTests.swift @@ -0,0 +1,162 @@ +import Combine +import Foundation +@testable import Lithe +import LitheCoreContracts +import LitheModuleAPI +import Testing + +/// Plugin grammars (`languages` 2) apply without a restart; Run marker +/// providers (`runMarkers` 1) come from launch-registered backends. These +/// tests cover which plugins qualify and what the editors receive. +@Suite("Plugin editor languages") +@MainActor +struct PluginEditorLanguagesTests { + private static let fixtures = DeclarativePluginTestSupport.repositoryRoot + .appendingPathComponent("shared/contracts/plugins/v1/fixtures", isDirectory: true) + + @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 aRunMarkerPluginNeedsARestartForItsBackend() throws { + let descriptor = try Self.descriptor("run-markers") + #expect(descriptor.runMarkerProviders == [DeclarativeRunMarkerProvider( + languageID: "http", functionID: "requests.markers", commandID: "dev.lithe.fixture.httpclient.send" + )]) + #expect(!descriptor.canActivateWithoutRestart) + } + + @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")) + #expect(set.runMarkerProviders.isEmpty) + live = [] + #expect(languages.current() == PluginEditorLanguageSet()) + } + + @Test + func launchRegisteredGrammarsAndProvidersFollowTheirModules() throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let package = try fixture.package("run-markers") + let pluginID = package.descriptor.id.rawValue + var disabled: Set = [] + let languages = fixture.languages(launchRegistered: [package], isModuleUsable: { !disabled.contains($0) }) + let set = languages.current() + #expect(set.languages.count == 1) + #expect(set.runMarkerProviders["http"] == PluginRunMarkerProvider( + pluginID: pluginID, manifest: package.descriptor.normalizedManifest, languageID: "http", + functionID: "requests.markers", commandID: "dev.lithe.fixture.httpclient.send" + )) + #expect(set.editorPayload["runMarkerLanguages"] as? [String] == ["http"]) + disabled = [ModuleID("\(pluginID).ui")] + #expect(languages.current().runMarkerProviders.isEmpty) + #expect(languages.current().languages.count == 1) + disabled = [.grammarsExtension(pluginID)] + #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"]) + } + + @Test + func runMarkerInvocationsCarryTheDocumentOfTheMarker() { + let invocation = PluginRunMarkerInvocation(id: "request-1", languageID: "http", path: "api/users.http", line: 3, version: 7) + #expect(invocation.json == .object([ + "id": .string("request-1"), "languageID": .string("http"), "path": .string("api/users.http"), + "line": .integer(3), "version": .integer(7), + ])) + let root = URL(fileURLWithPath: "/workspace") + #expect(MonacoWorkbenchSession.workspaceRelativePath(of: root.appendingPathComponent("api/users.http"), in: root) + == "api/users.http") + #expect(MonacoWorkbenchSession.workspaceRelativePath(of: URL(fileURLWithPath: "/elsewhere/a.http"), in: root) == nil) + #expect(MonacoWorkbenchSession.workspaceRelativePath(of: root, in: root) == nil) + } + + 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..ca52330c64 100644 --- a/rust/lithe-core/src/lsp/interface/plugin_function.rs +++ b/rust/lithe-core/src/lsp/interface/plugin_function.rs @@ -826,8 +826,9 @@ pub(crate) enum NotificationParams { /// backend holds, a positive sequence that grows per subscription, and /// the service's event object. SubscriptionEvent, - /// `{ commandID, context? }`: one of the plugin's own commands and the - /// workspace context of the window it ran in. + /// `{ commandID, context?, runMarker? }`: one of the plugin's own + /// commands, the workspace context of the window it ran in, and the + /// editor Run marker it ran for (`runMarkers` 1). Command, } @@ -933,12 +934,15 @@ pub(crate) fn host_notification( && object.get("event").is_some_and(Value::is_object) } NotificationParams::Command => { - only(&["commandID", "context"]) + only(&["commandID", "context", "runMarker"]) && object .get("commandID") .and_then(Value::as_str) .is_some_and(is_command_id) && object.get("context").map_or(true, is_context_reference) + && object + .get("runMarker") + .map_or(true, crate::plugins::run_markers::is_did_run_marker) } }; if !well_formed { diff --git a/rust/lithe-core/src/plugins/capabilities.rs b/rust/lithe-core/src/plugins/capabilities.rs index e1e56208c8..b62b7d6eb0 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`. @@ -66,6 +67,10 @@ pub(crate) const DEBUG_ADAPTERS: &str = "debugAdapters"; /// First plugin API level that may require `debugAdapters` or `serverProcess` 2. pub(crate) const DEBUG_ADAPTERS_API_VERSION: u32 = 4; +/// `runMarkers[]`: editor Run markers a plugin backend lists and runs; see +/// `super::run_markers`. +pub(crate) const RUN_MARKERS: &str = "runMarkers"; + /// Shared executable candidate order and strict explicit selection. pub(crate) const EXECUTABLE_RESOLUTION: &str = "executableResolution"; @@ -80,9 +85,10 @@ 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]), + (RUN_MARKERS, &[1]), (SERVER_PROCESS, &[1, 2]), (SETTINGS, &[1, 2]), (UI, &[1, 2, 3, 4]), diff --git a/rust/lithe-core/src/plugins/declarative.rs b/rust/lithe-core/src/plugins/declarative.rs index 62770d46be..315cf0c408 100644 --- a/rust/lithe-core/src/plugins/declarative.rs +++ b/rust/lithe-core/src/plugins/declarative.rs @@ -62,6 +62,9 @@ const PERMISSION_WORKSPACE_METADATA: &str = "workspace.metadata"; const PERMISSION_DEBUG_LAUNCH: &str = "debug.launch"; /// Attaching the plugin's adapter to a running process. `debugAdapters` 1. const PERMISSION_DEBUG_ATTACH: &str = "debug.attach"; +/// Reading open documents, here the text a Run marker provider receives. +/// A protocol 3 host permission that `runMarkers` 1 also requires. +const PERMISSION_DOCUMENTS_READ: &str = "documents.read"; const KNOWN_PERMISSIONS: &[&str] = &[ PERMISSION_BACKEND, PERMISSION_BUILD, @@ -207,6 +210,10 @@ pub struct DeclarativePluginManifest { /// `super::backend`. #[serde(default, skip_serializing_if = "Option::is_none")] backend: Option, + /// Editor Run markers the plugin's backend lists. `runMarkers` 1; see + /// `super::run_markers`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + run_markers: Vec, } /// UI supplied by plugins. The host owns every window shell: docking, title, @@ -421,6 +428,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 +871,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 +887,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 +1013,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(|language| language.grammar.is_some()) { return Err(invalid( "languages", @@ -1035,6 +1067,7 @@ impl DeclarativePluginManifest { .any(|event| event == super::backend::ON_WORKSPACE_OPEN), })?; } + self.validate_run_markers()?; self.validate_permissions()?; self.validate_activation_events()?; Ok(self) @@ -1113,6 +1146,7 @@ impl DeclarativePluginManifest { !self.debug_adapters.is_empty(), ), (capabilities::BACKEND, self.backend.is_some()), + (capabilities::RUN_MARKERS, !self.run_markers.is_empty()), ]; sections .into_iter() @@ -1371,6 +1405,10 @@ impl DeclarativePluginManifest { if !self.project_generators.is_empty() { required.insert(PERMISSION_PROJECT_GENERATE); } + // The provider receives the text of the documents it marks. + if !self.run_markers.is_empty() { + required.insert(PERMISSION_DOCUMENTS_READ); + } if self.backend.is_some() { required.insert(PERMISSION_BACKEND); } else if self.permissions.iter().any(|p| p == PERMISSION_CREDENTIALS) { @@ -1422,6 +1460,41 @@ impl DeclarativePluginManifest { Ok(()) } + /// Checks the Run marker providers: one per declared language, served by + /// a backend 2 that receives the plugin's own command. + fn validate_run_markers(&mut self) -> Validation<()> { + if self.run_markers.is_empty() { + return Ok(()); + } + if self.backend.is_none() || self.backend_version() < 2 { + return Err(invalid( + "runMarkers", + "Run marker providers are functions of a plugin backend 2.", + )); + } + let languages: BTreeSet<&str> = self.language_ids().collect(); + let commands: BTreeSet<&str> = self + .ui + .iter() + .flat_map(|ui| &ui.commands) + .map(|command| command.id.as_str()) + .collect(); + let mut provided = BTreeSet::new(); + for (index, provider) in self.run_markers.iter().enumerate() { + let field = format!("runMarkers[{index}]"); + provider.validate(&field, &languages, &commands)?; + if !provided.insert(provider.language_id.as_str()) { + return Err(invalid( + format!("{field}.languageID"), + "A language declares more than one Run marker provider.", + )); + } + } + self.run_markers + .sort_by(|left, right| left.language_id.cmp(&right.language_id)); + Ok(()) + } + fn validate_activation_events(&mut self) -> Validation<()> { let mut expected = BTreeSet::new(); for server in &self.language_servers { @@ -1444,6 +1517,11 @@ impl DeclarativePluginManifest { for adapter in &self.debug_adapters { expected.insert(crate::debug::adapters::activation_event(adapter)); } + // The backend starts when a document of the language first asks for + // its markers. + for provider in &self.run_markers { + expected.insert(format!("onLanguage:{}", provider.language_id)); + } if let Some(ui) = &self.ui { for window in &ui.tool_windows { expected.insert(format!("onView:{}", window.id)); @@ -1750,8 +1828,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 +4220,98 @@ 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()) + .ok_or_else(|| { + PluginRejection::new( + "plugin.grammar.unavailable", + format!( + "The plugin declares no grammar for {}.", + payload.language_id + ), + ) + })?; + let grammar = language.grammar.as_ref().expect("filtered above"); + let tokenizer = match grammar.kind { + super::grammar::GrammarKind::Monarch => { + super::grammar::decode_monarch(&payload.grammar)? + } + }; + Ok(( + "language", + json!({ + "id": language.id, + "fileExtensions": language.file_extensions, + "fileNames": language.file_names, + "grammar": { "kind": grammar.kind, "monarch": tokenizer }, + "configuration": language.configuration, + }), + )) + }, + ))) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct RunMarkersPayload { + manifest: Value, + #[serde(rename = "languageID")] + language_id: String, + /// Lines of the document version the provider answered for. + line_count: u64, + /// The provider function's raw result. + result: Value, +} + +/// `plugins.declarative.decodeRunMarkers`: validates a provider's markers +/// for one document version and attaches the command that runs them. +pub fn decode_run_markers_command(payload: Value) -> Result { + let payload: RunMarkersPayload = + decode_payload(payload, "plugins.declarative.decodeRunMarkers")?; + Ok(outcome(validate_manifest_value(payload.manifest).and_then( + |manifest| { + let provider = manifest + .run_markers + .iter() + .find(|provider| provider.language_id == payload.language_id) + .ok_or_else(|| { + PluginRejection::new( + "plugin.runMarkers.unavailable", + format!( + "The plugin provides no Run markers for {}.", + payload.language_id + ), + ) + })?; + super::run_markers::decode_markers( + &payload.result, + payload.line_count, + &provider.command, + ) + .map(|markers| ("markers", markers)) + }, + ))) +} + /// `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/grammar.rs b/rust/lithe-core/src/plugins/grammar.rs new file mode 100644 index 0000000000..e21dcd5ba8 --- /dev/null +++ b/rust/lithe-core/src/plugins/grammar.rs @@ -0,0 +1,563 @@ +//! 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, so a grammar costs no extra engine, +//! WebAssembly module, or worker. +//! +//! 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. Regular expressions are compiled by the editor, which keeps +//! the language plain text when one does not compile. +//! +//! Note: the decision is recorded in .agents/notes/implemented/architecture/2026-10-31-plugin-grammars-and-run-markers.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 the editor passes to `setMonarchTokensProvider` unchanged. +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}]"))?; + } + } + 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..66dc10193f 100644 --- a/rust/lithe-core/src/plugins/mod.rs +++ b/rust/lithe-core/src/plugins/mod.rs @@ -4,8 +4,10 @@ pub(crate) mod backend; pub mod capabilities; pub mod declarative; pub(crate) mod executables; +pub(crate) mod grammar; pub mod marketplace; pub(crate) mod native_ui; +pub(crate) mod run_markers; pub(crate) mod settings; use serde::Deserialize; diff --git a/rust/lithe-core/src/plugins/run_markers.rs b/rust/lithe-core/src/plugins/run_markers.rs new file mode 100644 index 0000000000..14100d7193 --- /dev/null +++ b/rust/lithe-core/src/plugins/run_markers.rs @@ -0,0 +1,223 @@ +//! Editor Run markers answered by a plugin, `runMarkers` 1. +//! +//! A plugin names, per language it declares, the backend function that lists +//! the runnable places of one document version and the UI command that runs +//! one of them. The host asks the function with the document text, passes the +//! raw answer to `plugins.declarative.decodeRunMarkers`, and draws what Core +//! returns in the gutter and editor context menu next to the built-in Java +//! markers. Running a marker sends the plugin its `commands/didRun` with a +//! `runMarker` describing the place, so the plugin executes it with its own +//! backend (an HTTP request, a script, a test) through APIs it already has. +//! +//! Neither the host nor Core parses source: the provider decides what is +//! runnable, in any language or custom format, which is the rule the Java +//! markers already follow with JDT. +//! +//! Note: the decision is recorded in .agents/notes/implemented/architecture/2026-10-31-plugin-grammars-and-run-markers.md + +use super::declarative::{PluginRejection, Validation}; +use crate::lsp::interface::plugin_function::is_function_id; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::BTreeSet; + +/// Most markers in one document. +pub(crate) const MAX_MARKERS: usize = 1000; +/// Longest marker ID, in UTF-8 bytes. +pub(crate) const MAX_MARKER_ID_BYTES: usize = 256; +/// Longest marker title, in characters. +const MAX_TITLE_CHARS: usize = 200; +/// Largest provider answer, in UTF-8 JSON bytes. +const MAX_RESULT_BYTES: usize = 1024 * 1024; +/// `runMarkers[]`: one language's marker provider. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct RunMarkerProviderDeclaration { + #[serde(rename = "languageID")] + pub(crate) language_id: String, + /// Backend function that lists the markers of a document version. + pub(crate) function: String, + /// `ui.commands[]` ID that runs one marker. + pub(crate) command: String, +} + +impl RunMarkerProviderDeclaration { + pub(crate) fn validate( + &self, + field: &str, + languages: &BTreeSet<&str>, + commands: &BTreeSet<&str>, + ) -> Validation<()> { + let invalid = super::declarative::invalid; + if !languages.contains(self.language_id.as_str()) { + return Err(invalid( + format!("{field}.languageID"), + "Run markers are provided only for a language the plugin declares.", + )); + } + if !is_function_id(&self.function) { + return Err(invalid( + format!("{field}.function"), + "The provider is a backend function ID such as markers.list.", + )); + } + if !commands.contains(self.command.as_str()) { + return Err(invalid( + format!("{field}.command"), + "The command that runs a marker must be one of the plugin's ui.commands.", + )); + } + Ok(()) + } +} + +/// What a marker runs, for the gutter icon and the menu wording. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +enum MarkerKind { + /// One runnable unit, such as a request or a script entry. + Run, + /// One test. + Test, + /// A group of tests, drawn with the run-all icon. + TestSuite, +} + +#[derive(Debug, Deserialize)] +struct RawMarker { + id: Value, + line: Value, + #[serde(default, rename = "endLine")] + end_line: Option, + kind: Value, + title: Value, +} + +fn output_error(message: impl Into) -> PluginRejection { + PluginRejection::new("plugin.runMarkers.invalidOutput", message) +} + +/// Validates a provider's `{ markers: [...] }` answer for a document of +/// `line_count` lines. Lines are one-based; `endLine` defaults to `line` and +/// is clamped to the document. Unknown fields are ignored; any malformed +/// marker, a duplicate ID or line, or an oversized list rejects the answer. +pub(crate) fn decode_markers(result: &Value, line_count: u64, command: &str) -> Validation { + if serde_json::to_vec(result).map_or(true, |bytes| bytes.len() > MAX_RESULT_BYTES) { + return Err(output_error("The run marker answer is larger than 1 MiB.")); + } + let Some(markers) = result.get("markers").and_then(Value::as_array) else { + return Err(output_error("The run marker answer is { markers: [...] }.")); + }; + if markers.len() > MAX_MARKERS { + return Err(output_error("A document has at most 1000 run markers.")); + } + let mut ids = BTreeSet::new(); + let mut lines = BTreeSet::new(); + let mut decoded = Vec::with_capacity(markers.len()); + for (index, marker) in markers.iter().enumerate() { + let raw: RawMarker = serde_json::from_value(marker.clone()).map_err(|_| { + output_error(format!( + "Run marker {index} needs id, line, kind and title." + )) + })?; + let id = raw + .id + .as_str() + .filter(|id| { + !id.is_empty() + && id.len() <= MAX_MARKER_ID_BYTES + && !id.chars().any(char::is_control) + }) + .ok_or_else(|| { + output_error(format!( + "Run marker {index} has an id of 1 to 256 bytes without control characters." + )) + })?; + let line = raw + .line + .as_u64() + .filter(|line| (1..=line_count).contains(line)) + .ok_or_else(|| output_error(format!("Run marker {index} is outside the document.")))?; + let end_line = match raw.end_line { + None | Some(Value::Null) => line, + Some(value) => value + .as_u64() + .filter(|end| *end >= line) + .ok_or_else(|| output_error(format!("Run marker {index} ends before it starts.")))? + .min(line_count), + }; + let kind: MarkerKind = serde_json::from_value(raw.kind).map_err(|_| { + output_error(format!( + "Run marker {index} has kind run, test, or testSuite." + )) + })?; + let title = raw + .title + .as_str() + .map(str::trim) + .filter(|title| { + !title.is_empty() + && title.chars().count() <= MAX_TITLE_CHARS + && !title.chars().any(char::is_control) + }) + .ok_or_else(|| { + output_error(format!( + "Run marker {index} has a title of 1 to 200 characters on one line." + )) + })?; + if !ids.insert(id.to_string()) { + return Err(output_error(format!("Run marker ID {id} appears twice."))); + } + if !lines.insert(line) { + return Err(output_error(format!( + "Line {line} has more than one run marker." + ))); + } + decoded.push((line, id.to_string(), end_line, kind, title.to_string())); + } + decoded.sort(); + Ok(Value::Array( + decoded + .into_iter() + .map(|(line, id, end_line, kind, title)| { + json!({ + "id": id, + "line": line, + "endLine": end_line, + "kind": kind, + "title": title, + "command": command, + }) + }) + .collect(), + )) +} + +/// Whether `value` is the `runMarker` of a `commands/didRun`: +/// `{ id, languageID, path, line, version }` with a workspace-relative path. +pub(crate) fn is_did_run_marker(value: &Value) -> bool { + let Some(object) = value.as_object() else { + return false; + }; + object + .keys() + .all(|key| ["id", "languageID", "path", "line", "version"].contains(&key.as_str())) + && object.get("id").and_then(Value::as_str).is_some_and(|id| { + !id.is_empty() && id.len() <= MAX_MARKER_ID_BYTES && !id.chars().any(char::is_control) + }) + && object + .get("languageID") + .and_then(Value::as_str) + .is_some_and(|id| !id.is_empty() && id.len() <= 128) + && object + .get("path") + .and_then(Value::as_str) + .is_some_and(|path| { + path.len() <= 4096 && super::declarative::is_safe_relative_path(path) + }) + && object + .get("line") + .and_then(Value::as_u64) + .is_some_and(|line| line >= 1) + && object.get("version").is_some_and(Value::is_u64) +} diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index f27e694ed3..e3cd071dc0 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 the Run markers a plugin listed (`plugins.declarative.decodeRunMarkers`). + PluginsDeclarativeDecodeRunMarkers, /// Finds the build-system instances of a workspace (`build.detect`). BuildDetect, /// Lists a build system's executable candidates and version probes (`build.invocationPlan`). @@ -576,6 +580,10 @@ impl CoreCommand { "plugins.declarative.workspaceConfiguration" => { Some(Self::PluginsDeclarativeWorkspaceConfiguration) } + "plugins.declarative.decodeGrammar" => Some(Self::PluginsDeclarativeDecodeGrammar), + "plugins.declarative.decodeRunMarkers" => { + Some(Self::PluginsDeclarativeDecodeRunMarkers) + } "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 6621e4974d..2f1964582d 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -265,6 +265,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::PluginsDeclarativeDecodeRunMarkers => plugin_response( + id, + parsed.payload, + crate::plugins::declarative::decode_run_markers_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..670aa289bd 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 GRAMMAR_FIXTURE: &str = contract_file!("fixtures/language-grammar.plugin.json"); +const RUN_MARKERS_FIXTURE: &str = contract_file!("fixtures/run-markers.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,8 @@ 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, + "run-markers.plugin.json" => RUN_MARKERS_FIXTURE, other => panic!("unknown fixture {other}"), }; serde_json::from_str(source).expect("fixture manifest is JSON") @@ -89,6 +93,8 @@ fn contract_fixtures_and_official_plugins_validate() { WORKSPACE_BACKEND_FIXTURE, DEBUG_ADAPTER_FIXTURE, DEBUG_ADAPTER_REMOTE_ATTACH_FIXTURE, + GRAMMAR_FIXTURE, + RUN_MARKERS_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..2c1adccc5f 100644 --- a/rust/lithe-core/src/tests/mod.rs +++ b/rust/lithe-core/src/tests/mod.rs @@ -18,6 +18,7 @@ mod github; mod languages; mod marketplace_plugins; mod mybatis; +mod plugin_grammars_run_markers; mod plugins; mod project; mod project_generators; diff --git a/rust/lithe-core/src/tests/plugin_grammars_run_markers.rs b/rust/lithe-core/src/tests/plugin_grammars_run_markers.rs new file mode 100644 index 0000000000..d16e40e33e --- /dev/null +++ b/rust/lithe-core/src/tests/plugin_grammars_run_markers.rs @@ -0,0 +1,305 @@ +//! Plugin grammars (`languages` 2) and plugin Run markers (`runMarkers` 1). + +use crate::plugins::declarative::{ + decode_grammar_command, decode_run_markers_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 RUN_MARKERS_PLUGIN: &str = contract_file!("fixtures/run-markers.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(), + ) +} + +fn decode_markers(result: Value, line_count: u64) -> Value { + decode_run_markers_command(json!({ + "manifest": manifest(RUN_MARKERS_PLUGIN), + "languageID": "http", + "lineCount": line_count, + "result": result, + })) + .expect("payload is well formed") +} + +fn marker_rejection(result: Value, line_count: u64) -> String { + let outcome = decode_markers(result.clone(), line_count); + assert_eq!(outcome["status"], "rejected", "{result}: {outcome}"); + outcome["error"]["code"].as_str().unwrap().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"]["monarch"], + serde_json::from_str::(HTTP_GRAMMAR).unwrap() + ); + assert_eq!(language["configuration"]["lineComment"], "#"); + assert_eq!( + language["configuration"]["brackets"], + json!([["{", "}"], ["[", "]"]]) + ); +} + +#[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"); +} + +/// Markers come back sorted by line with the command that runs them, and +/// `endLine` defaults to the marker line and stops at the document end. +#[test] +fn provider_markers_are_sorted_and_carry_their_command() { + let outcome = decode_markers( + json!({ + "markers": [ + { "id": "create", "line": 9, "endLine": 40, "kind": "run", "title": " POST /orders ", "extra": 1 }, + { "id": "list", "line": 1, "kind": "test", "title": "GET /orders" }, + { "id": "suite", "line": 4, "endLine": null, "kind": "testSuite", "title": "Orders" } + ] + }), + 20, + ); + assert_eq!(outcome["status"], "ok", "{outcome}"); + let command = "dev.lithe.fixture.httpclient.send"; + assert_eq!( + outcome["markers"], + json!([ + { "id": "list", "line": 1, "endLine": 1, "kind": "test", "title": "GET /orders", "command": command }, + { "id": "suite", "line": 4, "endLine": 4, "kind": "testSuite", "title": "Orders", "command": command }, + { "id": "create", "line": 9, "endLine": 20, "kind": "run", "title": "POST /orders", "command": command } + ]) + ); +} + +/// One bad marker rejects the answer, so the gutter never shows part of a +/// list the provider got wrong. +#[test] +fn malformed_provider_answers_are_rejected_whole() { + let marker = |patch: Value| { + let mut marker = json!({ "id": "a", "line": 2, "kind": "run", "title": "Send" }); + for (key, value) in patch.as_object().unwrap() { + marker[key] = value.clone(); + } + json!({ "markers": [marker] }) + }; + let invalid = "plugin.runMarkers.invalidOutput"; + assert_eq!(marker_rejection(json!([]), 5), invalid); + assert_eq!(marker_rejection(marker(json!({ "line": 0 })), 5), invalid); + assert_eq!(marker_rejection(marker(json!({ "line": 6 })), 5), invalid); + assert_eq!( + marker_rejection(marker(json!({ "endLine": 1 })), 5), + invalid + ); + assert_eq!( + marker_rejection(marker(json!({ "kind": "debug" })), 5), + invalid + ); + assert_eq!( + marker_rejection(marker(json!({ "title": " " })), 5), + invalid + ); + assert_eq!( + marker_rejection(marker(json!({ "title": "a\nb" })), 5), + invalid + ); + assert_eq!( + marker_rejection(marker(json!({ "title": "t".repeat(201) })), 5), + invalid + ); + assert_eq!(marker_rejection(marker(json!({ "id": "" })), 5), invalid); + assert_eq!( + marker_rejection(marker(json!({ "id": "i".repeat(257) })), 5), + invalid + ); + assert_eq!( + marker_rejection( + json!({ "markers": [ + { "id": "a", "line": 1, "kind": "run", "title": "A" }, + { "id": "a", "line": 2, "kind": "run", "title": "B" } + ] }), + 5 + ), + invalid + ); + assert_eq!( + marker_rejection( + json!({ "markers": [ + { "id": "a", "line": 1, "kind": "run", "title": "A" }, + { "id": "b", "line": 1, "kind": "run", "title": "B" } + ] }), + 5 + ), + invalid + ); + let many: Vec = (1..=1001) + .map(|line| json!({ "id": format!("m{line}"), "line": line, "kind": "run", "title": "Send" })) + .collect(); + assert_eq!(marker_rejection(json!({ "markers": many }), 2000), invalid); +} + +#[test] +fn a_language_without_a_provider_has_no_markers() { + let outcome = decode_run_markers_command(json!({ + "manifest": manifest(RUN_MARKERS_PLUGIN), + "languageID": "rest", + "lineCount": 3, + "result": { "markers": [] }, + })) + .unwrap(); + assert_eq!( + outcome["error"]["code"], "plugin.runMarkers.unavailable", + "{outcome}" + ); +} + +/// The provider declaration keeps its order-independent normal form. +#[test] +fn run_marker_providers_are_normalized_by_language() { + let outcome = validate_command(json!({ "manifest": manifest(RUN_MARKERS_PLUGIN) })).unwrap(); + assert_eq!(outcome["status"], "ok", "{outcome}"); + assert_eq!( + outcome["plugin"]["runMarkers"], + json!([{ + "languageID": "http", + "function": "requests.markers", + "command": "dev.lithe.fixture.httpclient.send" + }]) + ); +} + +#[test] +fn malformed_decode_payloads_are_core_failures() { + assert!(decode_grammar_command(json!({ "manifest": {}, "languageID": "http" })).is_err()); + assert!(decode_run_markers_command(json!({ "manifest": {}, "languageID": "http" })).is_err()); +} diff --git a/shared/contracts/plugins/v1/README.md b/shared/contracts/plugins/v1/README.md index bd94d74663..cd5c336838 100644 --- a/shared/contracts/plugins/v1/README.md +++ b/shared/contracts/plugins/v1/README.md @@ -69,6 +69,7 @@ symbolic links. A host treats an installed package as read-only. | `settings` | User-adjustable plugin options (level 2 and later). | | `ui` | Host-rendered tool windows and commands (level 2 and later). | | `buildSystems` | Project-level build tools (level 3). | +| `runMarkers` | Editor Run markers listed by the plugin backend (level 3, [`runMarkers` 1](#run-markers-runmarkers-1)). | Unknown fields are refused (`plugin.manifest.invalid`) so an older host rejects a package instead of ignoring part of it. At levels 1 and 2 a new @@ -85,6 +86,55 @@ 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. +- `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 unchanged. 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. +- Declaring `languages: 2` without using either field is + `plugin.manifest.invalid`; using them under `languages: 1` is too. + ### Language servers | Field | Meaning | @@ -399,7 +449,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` | @@ -413,6 +463,7 @@ hosts. Level 3 versions each section on its own. A level 3 manifest lists in | `debugAdapters` | [`debugAdapters[]`](#api-level-4-debug-adapters); v2 adds target arguments/environment | `1`, `2` | | `projectGenerators` | [`projectGenerators[]`](#project-generators): entries of the New Project window | `1` | | `backend` | [`backend`](#plugin-backend): the plugin's own long-lived process | `1`, `2` (adds [start without a page and workspace contexts](#backend-2-workspace-contexts)) | +| `runMarkers` | [`runMarkers[]`](#run-markers-runmarkers-1): editor Run markers listed by a backend function | `1` | Version 1 of every capability means what its section means at level 2 (runtime launches, launch templates, `initializationOptions`, and so on), so @@ -2011,7 +2062,7 @@ then never sees them. | --- | --- | --- | | `lithe/host/workspace/didChangeContexts` | `{ revision }` | `workspace.metadata` | | `lithe/host/subscriptions/notify` | `{ subscriptionID, sequence, event }` | every plugin-owned session | -| `lithe/host/commands/didRun` | `{ commandID, context? }` | every plugin-owned session | +| `lithe/host/commands/didRun` | `{ commandID, context?, runMarker? }` | every plugin-owned session | - `didChangeContexts` follows every change `revision` of `listContexts` counts; the backend calls `listContexts` for the contexts. A backend that @@ -2024,7 +2075,9 @@ then never sees them. (`{ contextID, generation }`) is the window's context when it has one. A running backend receives it in order with the other notifications; a stopped one is started for it only when it declares - `onCommand:`, and receives it once ready. + `onCommand:`, and receives it once ready. `runMarker` is present + when the user ran the command from an editor Run marker; see + [Run markers](#run-markers-runmarkers-1). - `revision` and `sequence` are positive integers, `event` is an object, and serialized params are at most `limits.notificationBytes`. Core refuses any other method, a service the session is not granted, malformed params, @@ -2168,6 +2221,60 @@ 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. +### Run markers (`runMarkers` 1) + +A plugin backend can put Run buttons in the editor gutter of its own +languages, next to the built-in Java ones, without a language server and +without new backend methods. The provider is an ordinary +[plugin function](#plugin-functions) and the click is an ordinary command, so +the plugin decides what is runnable in any language or custom format: the +requests of an `.http` file, the tests of a script, the entry points of a +program. + +```json +"requires": { "backend": 2, "languages": 2, "runMarkers": 1, "serverProcess": 1, "ui": 4 }, +"permissions": ["backend.launch", "documents.read", "ui.contribute"], +"activationEvents": ["onCommand:dev.example.http.send", "onLanguage:http"], +"runMarkers": [ + { "languageID": "http", "function": "requests.markers", "command": "dev.example.http.send" } +] +``` + +- One provider per declared language. `function` is a function ID the + backend registers; `command` is one of the plugin's `ui.commands`. Run + markers need `backend: 2`, the `documents.read` permission (the provider + receives document text), and `onLanguage:` so the backend + starts when such a document opens. See `fixtures/run-markers.plugin.json`. +- When a document of the language opens or changes, the host calls the + function with `params: { languageID, path, version, text }`: `path` is + workspace-relative with `/`, `version` is the editor buffer version, and + `text` the full buffer. Documents larger than 4 MiB of UTF-8, and documents + outside the workspace, get no markers. The host waits for edits to settle + before asking, cancels a request for an older version, and draws only the + answer for the current version. +- The function returns `{ markers: [{ id, line, endLine?, kind, title }] }`. + Lines are one-based; `endLine` (default `line`) is the last line the marker + covers, and the editor context menu runs the innermost marker around the + caret. `kind` is `run`, `test`, or `testSuite` (drawn with the run-all + icon). `title` (1 to 200 characters, one line) names the target in the + menu, such as `POST /orders`. `id` (1 to 256 bytes) is the plugin's own + name for the marker and comes back when it runs. Unknown marker fields are + ignored. +- The host passes the answer to `plugins.declarative.decodeRunMarkers`. + Core refuses the whole answer with `plugin.runMarkers.invalidOutput` when + it is larger than 1 MiB, lists more than 1000 markers, or has a malformed + marker, a duplicate `id`, or two markers on one line, so the gutter never + shows a partial list. The host then shows no plugin markers for that + version; a failing or missing function is treated the same way. +- Running a marker sends `lithe/host/commands/didRun` with `commandID` set to + the provider's command and `runMarker: { id, languageID, path, line, + version }`. The backend checks `version` against the document (for + example with `documents/getSnapshot`) before acting, because the user may + have edited it since. What the command does, and how it reports, is the + plugin's own: an output window, a web panel, a message. +- Run markers have no Debug action; a plugin that debugs uses + [debug adapters](#api-level-4-debug-adapters). + ### Compatibility - Packages of every API level are unchanged: functions add no manifest field. @@ -2260,6 +2367,10 @@ 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.runMarkers.invalidOutput` | Core | A Run marker provider's answer does not fit [Run markers](#run-markers-runmarkers-1) | +| `plugin.runMarkers.unavailable` | Core | The plugin declares no Run marker provider 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/function-protocol.json b/shared/contracts/plugins/v1/fixtures/function-protocol.json index b79fdcb094..6ebee0f097 100644 --- a/shared/contracts/plugins/v1/fixtures/function-protocol.json +++ b/shared/contracts/plugins/v1/fixtures/function-protocol.json @@ -594,6 +594,9 @@ { "name": "a subscription event is an object", "version": 3, "hostServices": [], "method": "lithe/host/subscriptions/notify", "params": { "subscriptionID": "s1", "sequence": 1, "event": "changed" }, "expected": { "code": "plugin.host.invalidParams" } }, { "name": "a command run in a window", "version": 3, "hostServices": [], "method": "lithe/host/commands/didRun", "params": { "commandID": "db.refresh", "context": { "contextID": "w1", "generation": 2 } }, "expected": { "delivered": true } }, { "name": "a command without a window", "version": 3, "hostServices": [], "method": "lithe/host/commands/didRun", "params": { "commandID": "db.refresh" }, "expected": { "delivered": true } }, + { "name": "a command run for an editor Run marker", "version": 3, "hostServices": [], "method": "lithe/host/commands/didRun", "params": { "commandID": "http.send", "runMarker": { "id": "request-2", "languageID": "http", "path": "api/orders.http", "line": 12, "version": 7 } }, "expected": { "delivered": true } }, + { "name": "a Run marker path stays in the workspace", "version": 3, "hostServices": [], "method": "lithe/host/commands/didRun", "params": { "commandID": "http.send", "runMarker": { "id": "request-2", "languageID": "http", "path": "../secrets.http", "line": 12, "version": 7 } }, "expected": { "code": "plugin.host.invalidParams" } }, + { "name": "a Run marker line is one-based", "version": 3, "hostServices": [], "method": "lithe/host/commands/didRun", "params": { "commandID": "http.send", "runMarker": { "id": "request-2", "languageID": "http", "path": "api/orders.http", "line": 0, "version": 7 } }, "expected": { "code": "plugin.host.invalidParams" } }, { "name": "extra command params are refused", "version": 3, "hostServices": [], "method": "lithe/host/commands/didRun", "params": { "commandID": "db.refresh", "path": "/etc" }, "expected": { "code": "plugin.host.invalidParams" } }, { "name": "the host cannot call the backend's own protocol", "version": 3, "hostServices": [], "method": "lithe/functions/invoke", "params": {}, "expected": { "code": "plugin.host.unavailable" } } ] 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..10816d5c14 100644 --- a/shared/contracts/plugins/v1/fixtures/invalid-manifests.json +++ b/shared/contracts/plugins/v1/fixtures/invalid-manifests.json @@ -1049,6 +1049,81 @@ "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" + }, + { + "name": "Run markers need a backend 2", + "base": "run-markers.plugin.json", + "edits": [{ "op": "set", "path": "/requires/backend", "value": 1 }], + "expectedCode": "plugin.manifest.invalid" + }, + { + "name": "Run markers are declared with runMarkers 1", + "base": "run-markers.plugin.json", + "edits": [{ "op": "remove", "path": "/requires/runMarkers" }], + "expectedCode": "plugin.manifest.invalid" + }, + { + "name": "Run markers are for a declared language", + "base": "run-markers.plugin.json", + "edits": [{ "op": "set", "path": "/runMarkers/0/languageID", "value": "rest" }], + "expectedCode": "plugin.manifest.invalid" + }, + { + "name": "Run markers run one of the plugin's commands", + "base": "run-markers.plugin.json", + "edits": [{ "op": "set", "path": "/runMarkers/0/command", "value": "dev.lithe.fixture.httpclient.other" }], + "expectedCode": "plugin.manifest.invalid" + }, + { + "name": "a Run marker provider is a function ID", + "base": "run-markers.plugin.json", + "edits": [{ "op": "set", "path": "/runMarkers/0/function", "value": "lithe/markers" }], + "expectedCode": "plugin.manifest.invalid" + }, + { + "name": "Run markers read documents", + "base": "run-markers.plugin.json", + "edits": [{ "op": "set", "path": "/permissions", "value": ["backend.launch", "ui.contribute"] }], + "expectedCode": "plugin.permission.undeclared" + }, + { + "name": "Run markers activate with their language", + "base": "run-markers.plugin.json", + "edits": [{ "op": "set", "path": "/activationEvents", "value": ["onCommand:dev.lithe.fixture.httpclient.send"] }], + "expectedCode": "plugin.activation.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/fixtures/run-markers.plugin.json b/shared/contracts/plugins/v1/fixtures/run-markers.plugin.json new file mode 100644 index 0000000000..7a516b5437 --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/run-markers.plugin.json @@ -0,0 +1,70 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.fixture.httpclient", + "displayName": "HTTP Client", + "version": "1.0.0", + "pluginAPIVersion": 3, + "requires": { + "backend": 2, + "languages": 2, + "runMarkers": 1, + "serverProcess": 1, + "ui": 4 + }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "backend.launch", + "documents.read", + "ui.contribute" + ], + "activationEvents": [ + "onCommand:dev.lithe.fixture.httpclient.send", + "onLanguage:http" + ], + "languages": [ + { + "id": "http", + "displayName": "HTTP Request", + "fileExtensions": [ + "http" + ], + "grammar": { + "kind": "monarch", + "path": "grammars/http.monarch.json" + } + } + ], + "backend": { + "launch": { + "kind": "packaged", + "path": "bin/backend" + }, + "arguments": [ + "--state", + "${stateDir}" + ] + }, + "runMarkers": [ + { + "languageID": "http", + "function": "requests.markers", + "command": "dev.lithe.fixture.httpclient.send" + } + ], + "ui": { + "toolWindows": [], + "commands": [ + { + "id": "dev.lithe.fixture.httpclient.send", + "title": "Send Request", + "placements": [ + "commandPalette" + ] + } + ] + } +} diff --git a/shared/contracts/plugins/v1/plugin-manifest.schema.json b/shared/contracts/plugins/v1/plugin-manifest.schema.json index 1d2b09a9ee..9619698b02 100644 --- a/shared/contracts/plugins/v1/plugin-manifest.schema.json +++ b/shared/contracts/plugins/v1/plugin-manifest.schema.json @@ -35,9 +35,10 @@ "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] }, + "runMarkers": { "description": "Required exactly when runMarkers is non-empty: editor Run markers listed by a backend function and run through a plugin command.", "enum": [1] }, "serverProcess": { "description": "The launch fields of language servers, of build providers that start their own process, of VCS providers, of debug adapters, and of the plugin backend. Version 2 adds runSelectedRuntime and is required exactly when a debug adapter uses it (pluginAPIVersion 4).", "enum": [1, 2] }, "settings": { "enum": [1] }, "ui": { "description": "Version 2 adds web content; version 3 adds native controls; version 4 adds backend-only commands.", "enum": [1, 2, 3, 4] }, @@ -102,6 +103,11 @@ "description": "pluginAPIVersion 3, requires.backend 1 or 2: the plugin's own LSP base-protocol process. The host keeps one process per plugin on the machine that runs Lithe, independent of any workspace, and ends it when the plugin is disabled, updated, or uninstalled. Version 1 starts on the first plugin-function call from one of the plugin's web pages and requires a web tool window. Version 2 also starts on onWorkspaceOpen or one of the plugin's commands and requires one of the three. Requires backend.launch; credentials.store lets it use the host's private credential store; workspace.metadata (version 2) lets it list workspace contexts.", "$ref": "#/$defs/backend" }, + "runMarkers": { + "description": "pluginAPIVersion 3, requires.runMarkers 1: one editor Run marker provider per declared language. Requires backend 2, documents.read, and one onLanguage: activation event per provider.", + "type": "array", + "items": { "$ref": "#/$defs/runMarkerProvider" } + }, "vcsProviders": { "description": "pluginAPIVersion 3: version control systems the plugin provides, each through its own LSP base-protocol process. Requires languageServer.launch, process.vcs, and one onVcsRoot: activation event per provider.", "type": "array", @@ -1056,9 +1062,47 @@ "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" } + }, + "runMarkerProvider": { + "type": "object", + "additionalProperties": false, + "required": ["languageID", "function", "command"], + "properties": { + "languageID": { "description": "A language the plugin declares.", "type": "string" }, + "function": { "description": "Backend function that lists the markers of one document version.", "type": "string", "minLength": 1 }, + "command": { "description": "One of the plugin's ui.commands; running a marker sends its commands/didRun with runMarker.", "type": "string", "minLength": 1 } + } + }, "languageServer": { "type": "object", "$ref": "#/$defs/serverProcess", diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 6479b3f9cd..1c0f8c63cc 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -1138,6 +1138,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 the grammar file of one plugin language (`languages` 2) and return what the editor registers | +| `plugins.declarative.decodeRunMarkers` | Validate a plugin's Run marker answer for one document version (`runMarkers` 1) | | `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 | @@ -3799,6 +3801,23 @@ 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, monarch }, configuration }`. `monarch` is the +grammar object unchanged, for Monaco `setMonarchTokensProvider`. A grammar Core +refuses is `plugin.grammar.invalid` with the failing `field`; a language +without a grammar is `plugin.grammar.unavailable`. Core reads no files. + +`plugins.declarative.decodeRunMarkers` accepts `{ manifest, languageID, +lineCount, result }`, where `result` is the raw value the language's Run +marker function returned for a document of `lineCount` lines, and returns +`markers`: `[{ id, line, endLine, kind, title, command }]` sorted by line, +with `endLine` clamped to the document and `command` the provider's command. +A malformed answer is `plugin.runMarkers.invalidOutput`; a language without a +provider is `plugin.runMarkers.unavailable`. The rules are in the Run markers +section of [plugin contract v1](plugins/v1/README.md#run-markers-runmarkers-1). + 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/platform-feature-matrix/features/plugin-grammars-run-markers.json b/shared/platform-feature-matrix/features/plugin-grammars-run-markers.json new file mode 100644 index 0000000000..e9a1ed2eeb --- /dev/null +++ b/shared/platform-feature-matrix/features/plugin-grammars-run-markers.json @@ -0,0 +1,34 @@ +{ + "id": "plugin-grammars-run-markers", + "area": "扩展", + "group": "插件与主题", + "capability": "插件语法高亮与运行标记:languages 2 让插件只用配置文件(grammar.kind monarch)给自己的语言加高亮、括号和注释规则,不依赖 LSP,安装、更新、停用、卸载都免重启;runMarkers 1 让 backend 2 插件的函数回答文档里哪些行可运行,编辑器在行号旁画运行按钮,点击后插件命令经 commands/didRun 收到 runMarker(id、languageID、工作区相对 path、line、version)", + "macos": { + "implementationStatus": "partial", + "verificationStatus": "pending", + "evidence": [ + "rust/lithe-core/src/plugins/grammar.rs", + "rust/lithe-core/src/plugins/run_markers.rs", + "rust/lithe-core/src/tests/plugin_grammars_run_markers.rs", + "shared/contracts/plugins/v1/README.md", + "shared/contracts/plugins/v1/fixtures/language-grammar.plugin.json", + "shared/contracts/plugins/v1/fixtures/run-markers.plugin.json", + "frontend/editor/src/plugin-languages.ts", + "frontend/editor/src/plugin-languages.test.ts", + "frontend/editor/src/run-markers.ts", + "macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginEditorLanguages.swift", + "macos/Sources/Lithe/Platform/MacOS/MonacoWorkbenchEditor.swift", + "macos/Tests/LitheTests/PluginEditorLanguagesTests.swift" + ] + }, + "windows": { + "implementationStatus": "missing", + "verificationStatus": "not-applicable", + "evidence": [ + "shared/contracts/plugins/v1/README.md" + ] + }, + "owner": "Extensions", + "verification": "共享 Core:cargo test --manifest-path rust/Cargo.toml -p lithe-core plugin_grammars_run_markers,核对语法超过 256 KiB、未知 kind、坏正则和越界路径被拒,运行标记重复行、越界行和超过 1000 项被拒。编辑器:在 frontend/editor 运行 bun test。macOS 宿主:scripts/macos/test/test-macos.sh --filter \"PluginEditorLanguagesTests|PluginLiveActivationTests|MacPluginBackendSessionsTests\"。macOS 原生验收:debug 应用打开含 .http 文件的工作区,安装 language-grammar 示例插件后不重启即看到高亮,停用后高亮消失;安装 run-markers 示例插件并重启后,.http 文件每个请求行出现运行按钮,点击后后端收到带 runMarker 的 commands/didRun。", + "notes": "本条覆盖 #1548 计划的 API-25(声明式语法)、API-27(运行标记装饰)和 API-29(从标记运行),起因是 #570 HTTP 客户端插件。语法高亮免重启;运行标记依赖后端,后端只在启动时登记,所以带运行标记的插件仍需重启。Monaco 无法撤销已注册的语言,卸载后对应文件以纯文本显示。尚未做原生验收,验证状态为待验证。Windows/Tauri 尚无插件后端与插件语法宿主。" +} From a75cb52a46c73063bf3bff41ab494c73c5306bca Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Sat, 31 Oct 2026 16:18:40 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat(plugins):=20=E7=8B=AC=E7=AB=8B?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E5=99=A8=E9=AB=98=E4=BA=AE=E4=B8=8E=E5=8A=A8?= =?UTF-8?q?=E4=BD=9C=E6=89=A9=E5=B1=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - languages 2 提供静态语法和独立编辑配置,插件正则在可终止 worker 中运行\n- editor/registerActions 运行期注册文本动作,点击复用普通函数调用,不扩张 Run/LSP/Build/Debug 领域契约\n- 补齐文档版本、动作租约、取消与清理、独立工具后端启用及安全模式边界\n- 更新 Skill、审查规则与决策笔记,总结 #1581/#1558 的架构边界\n- 同步契约、fixtures、中英词条、功能矩阵与资源复用说明\n\nRefs #570\nReworks #1617 --- .../2026-10-20-plugin-function-contract.md | 2 +- .../2026-10-26-plugin-live-activation.md | 7 +- ...plugin-capability-and-domain-boundaries.md | 75 +++ ...2026-10-31-plugin-editor-extension-apis.md | 71 ++ .agents/skills/develop-lithe/SKILL.md | 22 + .../references/review-policy.md | 5 + docs/ci-builds.md | 1 + docs/development/macos-plugin-api-plan.md | 12 +- frontend/editor/src/language-contributions.ts | 3 + .../src/monaco-tokenization-internals.d.ts | 9 + frontend/editor/src/plugin-editor-actions.ts | 107 +++ .../editor/src/plugin-grammar-tokenizer.ts | 123 ++++ frontend/editor/src/plugin-grammar-worker.ts | 50 ++ frontend/editor/src/plugin-languages.test.ts | 80 +++ frontend/editor/src/plugin-languages.ts | 64 ++ frontend/editor/src/textmate-worker.ts | 6 + frontend/editor/src/workbench.ts | 62 +- .../Monaco/workbench.integration.ts | 70 ++ macos/Resources/en.lproj/Localizable.strings | 2 + .../zh-Hans.lproj/Localizable.strings | 2 + .../Application/Composition/AppServices.swift | 4 + .../PluginEditorActionHostService.swift | 136 ++++ .../Features/PluginEditorLanguages.swift | 19 + .../PluginLanguageProviderCatalogSource.swift | 4 +- .../RustDeclarativePluginOperations.swift | 20 +- .../Platform/MacOS/MacServiceContainer.swift | 47 +- .../MacOS/MonacoWorkbenchEditor.swift | 164 ++++- .../Plugins/MacPluginBackendSessions.swift | 11 +- .../Plugins/MacPluginEditorLanguages.swift | 122 ++++ .../Views/App/PluginMarketplaceView.swift | 2 + .../Plugins/PluginManifestValidator.swift | 2 +- .../Plugins/DeclarativePluginContracts.swift | 71 +- .../Plugins/PluginEditorActionContracts.swift | 27 + .../Catalog/BuiltInModuleCatalog.swift | 18 + .../Lifecycle/ModuleTypes.swift | 11 + .../LitheModuleAPI/Plugins/PluginTypes.swift | 19 +- .../MonacoWorkbenchSessionLifetimeTests.swift | 2 +- .../LitheTests/PluginEditorActionTests.swift | 89 +++ .../PluginEditorLanguagesTests.swift | 168 +++++ .../src/lsp/interface/plugin_function.rs | 8 + rust/lithe-core/src/plugins/capabilities.rs | 5 +- rust/lithe-core/src/plugins/declarative.rs | 118 +++- rust/lithe-core/src/plugins/editor_actions.rs | 250 +++++++ rust/lithe-core/src/plugins/grammar.rs | 616 ++++++++++++++++++ rust/lithe-core/src/plugins/mod.rs | 3 + rust/lithe-core/src/protocol/command.rs | 6 + rust/lithe-core/src/runtime/dispatcher.rs | 10 + .../src/tests/declarative_plugins.rs | 5 + rust/lithe-core/src/tests/mod.rs | 2 + .../src/tests/plugin_editor_resources.rs | 203 ++++++ .../resources/reuse-worktree-resources.mjs | 2 + .../test-reuse-worktree-resources.mjs | 10 + scripts/resources/worktree-resources.json | 6 +- shared/contracts/plugins/v1/README.md | 130 +++- .../v1/fixtures/editor-actions.plugin.json | 36 + .../v1/fixtures/function-protocol.json | 5 +- .../v1/fixtures/grammars/http.monarch.json | 44 ++ .../v1/fixtures/invalid-manifests.json | 7 +- .../v1/fixtures/language-grammar.plugin.json | 43 ++ .../plugins/v1/plugin-manifest.schema.json | 31 +- shared/contracts/rust-core-api.md | 19 + shared/i18n/locales/en-US.json | 3 +- shared/i18n/locales/zh-CN.json | 3 +- shared/i18n/messages.json | 11 + .../features/plugin-editor-actions.json | 26 + .../features/plugin-editor-grammars.json | 27 + 66 files changed, 3283 insertions(+), 55 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-10-31-plugin-capability-and-domain-boundaries.md create mode 100644 .agents/notes/implemented/architecture/2026-10-31-plugin-editor-extension-apis.md create mode 100644 frontend/editor/src/plugin-editor-actions.ts create mode 100644 frontend/editor/src/plugin-grammar-tokenizer.ts create mode 100644 frontend/editor/src/plugin-grammar-worker.ts create mode 100644 frontend/editor/src/plugin-languages.test.ts create mode 100644 frontend/editor/src/plugin-languages.ts create mode 100644 macos/Sources/Lithe/Application/Features/PluginEditorActionHostService.swift create mode 100644 macos/Sources/Lithe/Application/Features/PluginEditorLanguages.swift create mode 100644 macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginEditorLanguages.swift create mode 100644 macos/Sources/LitheCoreContracts/Plugins/PluginEditorActionContracts.swift create mode 100644 macos/Tests/LitheTests/PluginEditorActionTests.swift create mode 100644 macos/Tests/LitheTests/PluginEditorLanguagesTests.swift create mode 100644 rust/lithe-core/src/plugins/editor_actions.rs create mode 100644 rust/lithe-core/src/plugins/grammar.rs create mode 100644 rust/lithe-core/src/tests/plugin_editor_resources.rs create mode 100644 shared/contracts/plugins/v1/fixtures/editor-actions.plugin.json create mode 100644 shared/contracts/plugins/v1/fixtures/grammars/http.monarch.json create mode 100644 shared/contracts/plugins/v1/fixtures/language-grammar.plugin.json create mode 100644 shared/platform-feature-matrix/features/plugin-editor-actions.json create mode 100644 shared/platform-feature-matrix/features/plugin-editor-grammars.json 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..36e9b576b5 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-31-plugin-editor-extension-apis.md @@ -0,0 +1,71 @@ +# 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 工作流。 + +### 编辑器动作是小型独立契约 + +后端先注册普通函数,再通过 `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 产品接入和完整界面验收仍需后续完成。 + +## 验证 + +- `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..6205342e42 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` 声明式语法(`grammar.kind` 先支持 monarch,不依赖 LSP,Mac 免重启);语言语义 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 仍为提案 | @@ -206,9 +206,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 +217,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..29ce91d711 --- /dev/null +++ b/frontend/editor/src/plugin-grammar-tokenizer.ts @@ -0,0 +1,123 @@ +/// +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; + +/** Regex execution is isolated behind a terminable worker with a local deadline. */ +export function installPluginGrammar(languageID: string, grammar: languages.IMonarchLanguage) { + 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(source => { + if (stopped) return; + url = URL.createObjectURL(new Blob([source], { 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, grammar }); + }).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..9403819baf --- /dev/null +++ b/frontend/editor/src/plugin-grammar-worker.ts @@ -0,0 +1,50 @@ +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") { + // 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, data.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..9dfd078957 --- /dev/null +++ b/frontend/editor/src/plugin-languages.test.ts @@ -0,0 +1,80 @@ +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 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); +const http: PluginLanguage = { + id: "http", fileExtensions: ["http", ".rest"], + grammar: { kind: "monarch", monarch: { tokenizer: { root: [] } } }, + configuration: { lineComment: "#", brackets: [["{", "}"]], autoClosingPairs: [["\"", "\""]] }, +}; +beforeEach(() => { setPluginLanguages([]); failCompile = new Set(); }); + +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("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..c40cad38a2 --- /dev/null +++ b/frontend/editor/src/plugin-languages.ts @@ -0,0 +1,64 @@ +// Static file types and presentation are independent of language-server ownership. +import type { languages } from "monaco-editor/esm/vs/editor/editor.api.js"; + +export interface PluginLanguage { + id: string; + fileExtensions?: string[]; + fileNames?: string[]; + grammar?: { kind: "monarch"; monarch: languages.IMonarchLanguage } | 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, grammar: languages.IMonarchLanguage) => { 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.monarch)); + 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..496caf918a 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, grammar: data.grammar } } 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..f57610b595 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 } 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,44 @@ 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); + // 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); + } + } + await pluginActions.refresh(); + return result; + }); + return pluginLanguageUpdates; + }, + async refreshPluginEditorActions() { + await activation; + await pluginActions.refresh(); + }, async refreshJavaRunMarkers() { await activation; await Promise.all([...entries].filter(([, entry]) => allEditors().some(view => view.getModel() === entry.model)) @@ -1526,14 +1580,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 +1881,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/workbench.integration.ts b/macos/Experiments/Monaco/workbench.integration.ts index d2ba137b40..102b58a6e7 100644 --- a/macos/Experiments/Monaco/workbench.integration.ts +++ b/macos/Experiments/Monaco/workbench.integration.ts @@ -23,6 +23,8 @@ 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 { imeInputCases } from "./ime-input.integration"; // Real WebKit integration, using the exact workbench bundle and the existing @@ -54,6 +56,74 @@ 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", { 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("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..9eea5fc5f0 --- /dev/null +++ b/macos/Sources/Lithe/Application/Features/PluginEditorActionHostService.swift @@ -0,0 +1,136 @@ +import Combine +import Foundation +import LitheCoreContracts +import LitheModuleAPI + +// Note: 独立扩展点与领域契约的边界见 .agents/notes/implemented/architecture/2026-10-31-plugin-capability-and-domain-boundaries.md + +/// 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..0f8fb622c7 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() + moduleRuntime.observeEvents { event in + guard event.name == ModuleEvent.stateChangedName else { return } + editorLanguageModuleChanges.send() + for package in pluginStartup.registeredPlugins where package.descriptor.declaresBackend && package.descriptor.ui == nil { + let id = package.descriptor.id.rawValue + if let state = try? moduleRuntime.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: { 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..86f10e5b55 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)] = [:] + 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,59 @@ 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 + pluginActionRequests.removeValue(forKey: id)?.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()") } + } + } + private func observeCodeVision(model: AppModel) { if codeVisionSubscription == nil { codeVisionSubscription = model.javaFeature.$javaCodeVisionHints @@ -836,6 +903,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 +929,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 +1137,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 +1290,90 @@ 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 + var items: [(key: String, registration: PluginEditorActionRegistration, action: PluginEditorAction)] = [] + for registration in registrations { + do { + try Task.checkCancellation() + guard service.contains(registration) else { continue } + let result = try await backends.invokePluginFunction(registration.functionID, params: input, + ofPlugin: registration.pluginID, context: workspace, timeout: .seconds(10), 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[id] = nil + 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) + } + pluginActionRequests[id] = (requestID, task) + 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 +1510,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 +1838,9 @@ final class MonacoWorkbenchSession: NSObject, ObservableObject, WKNavigationDele private func fail(_ message: String) { failed = true + pluginActionRequests.values.forEach { $0.task.cancel() } + pluginActionExecutions.values.forEach { $0.task.cancel() } + pluginActionRequests.removeAll(); pluginActionExecutions.removeAll(); pluginActionLists.removeAll() contextMenu.dismiss() model?.showNotification("Editor: \(message)") } @@ -1691,6 +1851,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.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..1f2bdca131 --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginEditorLanguages.swift @@ -0,0 +1,122 @@ +import Combine +import Foundation +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() } + + /// - 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..68be4c41d9 --- /dev/null +++ b/macos/Tests/LitheTests/PluginEditorActionTests.swift @@ -0,0 +1,89 @@ +import Foundation +import LitheCoreContracts +import Testing +@testable import Lithe + +@MainActor +struct PluginEditorActionTests { + @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..0fce4459db --- /dev/null +++ b/macos/Tests/LitheTests/PluginEditorLanguagesTests.swift @@ -0,0 +1,168 @@ +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 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")) + 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..e134a2fdfa 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,60 @@ 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 => { + super::grammar::decode_monarch(&payload.grammar) + .map(|tokenizer| json!({"kind": grammar.kind, "monarch": tokenizer})) + } + }) + .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..7d0e8b84ca --- /dev/null +++ b/rust/lithe-core/src/plugins/grammar.rs @@ -0,0 +1,616 @@ +//! 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 the editor passes to worker-isolated Monaco compilation unchanged. +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..eb392226bf --- /dev/null +++ b/rust/lithe-core/src/tests/plugin_editor_resources.rs @@ -0,0 +1,203 @@ +//! 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"]["monarch"], + serde_json::from_str::(HTTP_GRAMMAR).unwrap() + ); + assert_eq!(language["configuration"]["lineComment"], "#"); + assert_eq!( + language["configuration"]["brackets"], + json!([["{", "}"], ["[", "]"]]) + ); +} + +#[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..4dd65139d5 100644 --- a/shared/contracts/plugins/v1/README.md +++ b/shared/contracts/plugins/v1/README.md @@ -85,6 +85,60 @@ 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. +- `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 unchanged. 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 +453,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 +556,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 +2149,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 +2224,76 @@ 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. +- 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 +2386,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..db31a3ba88 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,23 @@ 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, monarch }, configuration }`. `monarch` is the +grammar object unchanged, for Monaco compilation only in a terminable worker. 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..d4cd088846 --- /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..cbe08ff3a5 --- /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;验证即时出现、停用撤回、配置独立应用、病态语法不阻塞输入和保存;安装目录哈希不变。", + "notes": "当前仅 macOS 接线;真机和完整用户流程仍待验证。共享校验通过不代表 Windows 已实现。" +} From ecedf1b167d136b87e4d4d17cd9eed8f31f30efd Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Sat, 31 Oct 2026 16:34:11 +0800 Subject: [PATCH 3/4] =?UTF-8?q?docs(plugins):=20=E6=98=8E=E7=A1=AE=20API-2?= =?UTF-8?q?5=20JSON=20=E8=B4=A1=E7=8C=AE=E7=9A=84=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E8=8C=83=E5=9B=B4=E4=B8=8E=E5=B1=80=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 区分包内静态 JSON 贡献与尚未开放的运行期 Provider 注册、替换和释放;说明独立语义 Provider、新引擎适配及平台、SDK 和验收限制。同步计划、架构笔记、正式契约与功能矩阵。 --- ...2026-10-31-plugin-editor-extension-apis.md | 8 ++++++ docs/development/macos-plugin-api-plan.md | 25 ++++++++++++++++++- shared/contracts/plugins/v1/README.md | 15 +++++++++++ .../features/plugin-editor-grammars.json | 2 +- 4 files changed, 48 insertions(+), 2 deletions(-) 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 index 36e9b576b5..310544a83a 100644 --- 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 @@ -52,6 +52,14 @@ grammar 从只读插件包有界读取,校验结果仅在本次进程内缓存 收益是工具插件不污染领域模型,执行复用现成函数基础设施。代价是增加一个明确授权的独立编辑器贡献契约,需要跨语言校验与生命周期接线。首版不支持远程、只读预览动作,也不承诺任意大型文档的插件高亮;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 预算。 diff --git a/docs/development/macos-plugin-api-plan.md b/docs/development/macos-plugin-api-plan.md index 6205342e42..e2cc45715c 100644 --- a/docs/development/macos-plugin-api-plan.md +++ b/docs/development/macos-plugin-api-plan.md @@ -47,7 +47,7 @@ Windows 不在本轮,平台矩阵的 partial/pending 保持真实。 | API-22 | 部分完成 | 既有函数进度与取消;独立 output channel、通用 log/progress API 未做 | | API-23 | 部分完成 | 已有进程声明与运行时解析;通用 toolchains/process 服务未开放 | | API-24 | 未做 | 插件专属终端 create/sendText/onExit 未开放 | -| API-25 | 部分完成 | 既有语言/LSP 贡献;新增 `languages:2` 声明式语法(`grammar.kind` 先支持 monarch,不依赖 LSP,Mac 免重启);语言语义 Provider 未做 | +| API-25 | 部分完成 | 既有语言/LSP 贡献;新增 `languages:2` 的清单与包内 JSON 语法/编辑配置,仅支持 Monarch;运行期传入 JSON 动态注册/替换高亮 Provider、独立语言语义 Provider 未做,详见下方范围说明 | | API-26 | 未做 | 公共诊断 collection/events API 未开放;既有 LSP 诊断不等于该 API | | API-27 | 部分完成 | 独立 `editor/registerActions` 绑定普通函数与文档范围,不依赖 LSP/Build/Run;通用主题、片段、其他装饰未做 | | API-28 | 未做 | 公共 custom editor 文档 owner/保存/撤销/恢复未开放 | @@ -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/` 为前缀;这里的点号是阅读简写,正式方法使用 `/`。 diff --git a/shared/contracts/plugins/v1/README.md b/shared/contracts/plugins/v1/README.md index 4dd65139d5..83a3a185c8 100644 --- a/shared/contracts/plugins/v1/README.md +++ b/shared/contracts/plugins/v1/README.md @@ -110,6 +110,21 @@ activation event, and a plugin may consist of grammars alone. - `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 diff --git a/shared/platform-feature-matrix/features/plugin-editor-grammars.json b/shared/platform-feature-matrix/features/plugin-editor-grammars.json index cbe08ff3a5..6e27e515fa 100644 --- a/shared/platform-feature-matrix/features/plugin-editor-grammars.json +++ b/shared/platform-feature-matrix/features/plugin-editor-grammars.json @@ -23,5 +23,5 @@ }, "owner": "Extensions", "verification": "安装纯语法插件后打开 .http;验证即时出现、停用撤回、配置独立应用、病态语法不阻塞输入和保存;安装目录哈希不变。", - "notes": "当前仅 macOS 接线;真机和完整用户流程仍待验证。共享校验通过不代表 Windows 已实现。" + "notes": "当前仅 macOS 接线,只接受清单与包内 JSON 静态资源,grammar.kind 仅支持 monarch;没有运行期传 JSON 注册/替换/释放高亮 Provider 的公共方法,也未开放独立语言语义 Provider 的普通函数绑定。静态插件生命周期即时更新不等于动态注册 API。高亮超过 128 Ki UTF-16 单位或 10,000 行时保持普通文本,嵌入语言按普通文本处理;SDK 新能力封装/发布和真实插件完整安装验收仍待完成。共享校验通过不代表 Windows 已实现。" } From 874a779af9bb8c3a55b3edd842bc44c010ac835e Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Sat, 31 Oct 2026 17:31:26 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(plugins):=20=E4=BF=AE=E5=A4=8D=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=E6=89=A9=E5=B1=95=E5=AE=A1=E6=9F=A5=E4=B8=AD?= =?UTF-8?q?=E7=9A=84=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F=E4=B8=8E=E9=AB=98?= =?UTF-8?q?=E4=BA=AE=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 动作刷新立即确认调度并隔离非致命失败,整批收集共享八秒期限并在撤销时清理计时任务。 Core 保留校验后的 grammar 原始文本,worker 按源顺序解析 cases 并显式默认 root;配置-only 别名借用既有 tokenizer,重映射 token 语言身份以保留独立注释配置。 模块状态观察弱引用 runtime,补齐有界、确定性的 Swift/Rust/WebKit 回归与测试文档模型清理,同步契约、Agent Note 和功能矩阵。 验证:57 项 Swift、1459 项 Rust、39 项编辑器和 119 项真实 WebKit 测试通过;静态边界、i18n、只读 bundle、资源复用与矩阵门禁通过。未执行 Windows 原生构建或打包安装验收。 Refs #1617, #570 --- ...2026-10-31-plugin-editor-extension-apis.md | 4 ++ .../editor/src/plugin-grammar-tokenizer.ts | 35 +++++++-- frontend/editor/src/plugin-grammar-worker.ts | 6 +- frontend/editor/src/plugin-languages.test.ts | 26 ++++++- frontend/editor/src/plugin-languages.ts | 12 +++- frontend/editor/src/textmate-worker.ts | 2 +- frontend/editor/src/workbench.ts | 10 +-- macos/Experiments/Monaco/main.swift | 20 ++++++ .../Monaco/workbench.integration.ts | 71 ++++++++++++++++++- .../PluginEditorActionHostService.swift | 10 +++ .../Platform/MacOS/MacServiceContainer.swift | 10 +-- .../MacOS/MonacoWorkbenchEditor.swift | 37 +++++++--- .../Plugins/MacPluginEditorLanguages.swift | 15 ++++ .../LitheTests/PluginEditorActionTests.swift | 8 +++ .../PluginEditorLanguagesTests.swift | 37 ++++++++++ rust/lithe-core/src/plugins/declarative.rs | 4 +- rust/lithe-core/src/plugins/grammar.rs | 3 +- .../src/tests/plugin_editor_resources.rs | 15 ++-- shared/contracts/plugins/v1/README.md | 7 +- shared/contracts/rust-core-api.md | 6 +- .../features/plugin-editor-actions.json | 2 +- .../features/plugin-editor-grammars.json | 2 +- 22 files changed, 301 insertions(+), 41 deletions(-) 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 index 310544a83a..8f3a455a16 100644 --- 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 @@ -18,6 +18,8 @@ HTTP 工具插件需要高亮和请求旁的按钮。原方案把按钮建模成 复用已固定版本的 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,由会话、上下文或显式释放共同清理。注册时只记录绑定,下一事件轮才请求解析,避免后端等待宿主注册答复、宿主又等待同一后端的循环。 @@ -30,6 +32,8 @@ HTTP 工具插件需要高亮和请求旁的按钮。原方案把按钮建模成 ### 生命周期与兼容 +动作刷新只确认已调度,不能等待插件返回后才结束编辑器桥接调用。整批收集共享八秒期限,各注册只拿剩余预算;失败不进入编辑、保存同步的致命错误路径。模块状态观察者弱引用运行时,避免事件源持有回调、回调又持有事件源,导致关闭项目后模块图不能释放。 + 编辑立即撤销旧动作,取消旧收集任务,防抖后再请求;关闭、替换和失效结果均不得恢复旧按钮。解析和用户执行分开管理,后者不因重新解析被误取消;撤销注册、关闭上下文或会话结束时取消所属执行,不自动重试 HTTP 等副作用。 新宿主方法在已支持增量能力发现的函数协议 3 内新增,不提高整套协议或新增领域版本。新增 `editor.actions` 权限由安装审查明确确认,旧宿主不认识时拒绝安装;不能一边要求新权限、一边宣称同一包能在旧宿主无条件降级。静态清单新增资源仍要求 `languages:2`;契约、fixture 与两个 SDK 的验证和合并后镜像同步不能省略。 diff --git a/frontend/editor/src/plugin-grammar-tokenizer.ts b/frontend/editor/src/plugin-grammar-tokenizer.ts index 29ce91d711..6443906934 100644 --- a/frontend/editor/src/plugin-grammar-tokenizer.ts +++ b/frontend/editor/src/plugin-grammar-tokenizer.ts @@ -13,8 +13,35 @@ class PlainState implements languages.IState { 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, grammar: languages.IMonarchLanguage) { +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); @@ -81,9 +108,9 @@ export function installPluginGrammar(languageID: string, grammar: languages.IMon return response.text(); }); watch(); - void workerSource.then(source => { + void workerSource.then(code => { if (stopped) return; - url = URL.createObjectURL(new Blob([source], { type: "text/javascript" })); + url = URL.createObjectURL(new Blob([code], { type: "text/javascript" })); worker = new Worker(url); worker.onerror = event => stop(event.message); worker.onmessage = ({ data }) => { @@ -111,7 +138,7 @@ export function installPluginGrammar(languageID: string, grammar: languages.IMon } if ([...bindings.values()].some(binding => binding.pending) && !deadline) watch(); }; - worker.postMessage({ type: "pluginGrammar", languageID, grammar }); + worker.postMessage({ type: "pluginGrammar", languageID, source }); }).catch(stop); const themeChanges = theme.onDidColorThemeChange(() => { for (const [id, binding] of bindings) open(id, binding); diff --git a/frontend/editor/src/plugin-grammar-worker.ts b/frontend/editor/src/plugin-grammar-worker.ts index 9403819baf..6f690cc4f7 100644 --- a/frontend/editor/src/plugin-grammar-worker.ts +++ b/frontend/editor/src/plugin-grammar-worker.ts @@ -26,13 +26,17 @@ export function installPluginGrammarWorker() { 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, data.grammar), { + }, {}, data.languageID, compile(data.languageID, grammar), { getValue: () => 20_000, onDidChangeConfiguration: () => ({ dispose() {} }), }); diff --git a/frontend/editor/src/plugin-languages.test.ts b/frontend/editor/src/plugin-languages.test.ts index 9dfd078957..ceec5a3edf 100644 --- a/frontend/editor/src/plugin-languages.test.ts +++ b/frontend/editor/src/plugin-languages.test.ts @@ -5,6 +5,7 @@ import { hasPluginGrammar, pluginLanguageForFilename, setPluginLanguages as repl 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) }; @@ -18,13 +19,16 @@ 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); +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", monarch: { tokenizer: { root: [] } } }, + grammar: { kind: "monarch", source: '{"tokenizer":{"root":[]}}' }, configuration: { lineComment: "#", brackets: [["{", "}"]], autoClosingPairs: [["\"", "\""]] }, }; -beforeEach(() => { setPluginLanguages([]); failCompile = new Set(); }); +beforeEach(() => { setPluginLanguages([]); failCompile = new Set(); inherited.length = 0; }); test("owns providers, matches only active file types and disposes on withdrawal", () => { try { @@ -72,6 +76,22 @@ test("a refused grammar does not prevent another file type from applying", () => } 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: [["{", "}"]], diff --git a/frontend/editor/src/plugin-languages.ts b/frontend/editor/src/plugin-languages.ts index c40cad38a2..d04690cd8d 100644 --- a/frontend/editor/src/plugin-languages.ts +++ b/frontend/editor/src/plugin-languages.ts @@ -1,11 +1,12 @@ // 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"; monarch: languages.IMonarchLanguage } | null; + grammar?: { kind: "monarch"; source: string } | null; configuration?: { lineComment?: string; blockComment?: [string, string]; @@ -36,7 +37,8 @@ export type MonacoLanguages = Pick { dispose(): void } = () => ({ dispose() {} }), + 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])); @@ -52,7 +54,11 @@ export function setPluginLanguages(monaco: MonacoLanguages, list: readonly Plugi 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.monarch)); + 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) { diff --git a/frontend/editor/src/textmate-worker.ts b/frontend/editor/src/textmate-worker.ts index 496caf918a..e691ea170b 100644 --- a/frontend/editor/src/textmate-worker.ts +++ b/frontend/editor/src/textmate-worker.ts @@ -42,7 +42,7 @@ self.onmessage = async ({ data }) => { try { if (data.type === "pluginGrammar") { installPluginGrammarWorker(); - self.onmessage!({ data: { type: "init", languageID: data.languageID, grammar: data.grammar } } as MessageEvent); + self.onmessage!({ data: { type: "init", languageID: data.languageID, source: data.source } } as MessageEvent); return; } if (data.type === "init") { diff --git a/frontend/editor/src/workbench.ts b/frontend/editor/src/workbench.ts index f57610b595..0d02ad3835 100644 --- a/frontend/editor/src/workbench.ts +++ b/frontend/editor/src/workbench.ts @@ -28,7 +28,7 @@ import { IContextMenuService } from "monaco-editor/esm/vs/platform/contextview/b 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 } from "./plugin-grammar-tokenizer"; +import { installPluginGrammar, inheritPluginTokenizer } from "./plugin-grammar-tokenizer"; import { setPluginLanguages, hasPluginLanguage, pluginLanguageForFilename, type PluginLanguage } from "./plugin-languages"; const escapeHTML = (value: string) => value.replace(/[&<>"']/g, character => @@ -1059,7 +1059,7 @@ export function mountWorkbench(host: WorkbenchHost) { // 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); + 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))]); @@ -1077,14 +1077,16 @@ export function mountWorkbench(host: WorkbenchHost) { if (view.getModel() === entry.model) syncDefinitionNavigation(view); } } - await pluginActions.refresh(); + void pluginActions.refresh().catch(console.error); return result; }); return pluginLanguageUpdates; }, async refreshPluginEditorActions() { await activation; - await pluginActions.refresh(); + // 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; 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 102b58a6e7..e219a63a86 100644 --- a/macos/Experiments/Monaco/workbench.integration.ts +++ b/macos/Experiments/Monaco/workbench.integration.ts @@ -25,6 +25,7 @@ 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 @@ -58,7 +59,7 @@ async function verify() { } 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", { tokenizer: { root: [["#.*$", "comment"]] } }); + 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; @@ -75,6 +76,74 @@ async function verify() { 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"); diff --git a/macos/Sources/Lithe/Application/Features/PluginEditorActionHostService.swift b/macos/Sources/Lithe/Application/Features/PluginEditorActionHostService.swift index 9eea5fc5f0..6537345561 100644 --- a/macos/Sources/Lithe/Application/Features/PluginEditorActionHostService.swift +++ b/macos/Sources/Lithe/Application/Features/PluginEditorActionHostService.swift @@ -5,6 +5,16 @@ 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 diff --git a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index 0f8fb622c7..a5baa3f16c 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -1148,12 +1148,12 @@ final class MacServiceContainer { // Static editor resources follow their own enablement; ordinary tool // actions register at runtime in the shared backend's host services. let editorLanguageModuleChanges = PassthroughSubject() - moduleRuntime.observeEvents { event in - guard event.name == ModuleEvent.stateChangedName else { return } + 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? moduleRuntime.snapshot(for: .backendExtension(id)), + if let state = try? runtime.snapshot(for: .backendExtension(id)), state.state != .disabled, !state.isQuarantined, !state.isSuppressedBySafeMode { pluginBackends.enable(id) } else { pluginBackends.disable(id) } @@ -1162,8 +1162,8 @@ final class MacServiceContainer { services.pluginEditorActions = pluginBackends.editorActions services.pluginEditorLanguages = MacPluginEditorLanguages( launchRegistered: pluginStartup.registeredPlugins, - isModuleUsable: { id in - guard let snapshot = try? moduleRuntime.snapshot(for: id) else { return false } + 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 }, diff --git a/macos/Sources/Lithe/Platform/MacOS/MonacoWorkbenchEditor.swift b/macos/Sources/Lithe/Platform/MacOS/MonacoWorkbenchEditor.swift index 86f10e5b55..9d266ca44b 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MonacoWorkbenchEditor.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MonacoWorkbenchEditor.swift @@ -324,7 +324,7 @@ final class MonacoWorkbenchSession: NSObject, ObservableObject, WKNavigationDele let actions: [(key: String, registration: PluginEditorActionRegistration, action: PluginEditorAction)] } private var pluginActionLists: [String: EditorActionList] = [:] - private var pluginActionRequests: [String: (id: UUID, task: Task)] = [:] + 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? @@ -766,7 +766,10 @@ final class MonacoWorkbenchSession: NSObject, ObservableObject, WKNavigationDele private func cancelPluginActions(for id: String, closing: Bool = false) { pluginActionLists[id] = nil - pluginActionRequests.removeValue(forKey: id)?.task.cancel() + 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() @@ -785,7 +788,13 @@ final class MonacoWorkbenchSession: NSObject, ObservableObject, WKNavigationDele execution.task.cancel() self.pluginActionExecutions[id] = nil } - if self.ready, !self.failed { self.call("window.lithe.refreshPluginEditorActions()") } + 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) + } + } + } } } @@ -1309,13 +1318,20 @@ final class MonacoWorkbenchSession: NSObject, ObservableObject, WKNavigationDele "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: .seconds(10), progress: { _ in }) + 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 } @@ -1331,14 +1347,19 @@ final class MonacoWorkbenchSession: NSObject, ObservableObject, WKNavigationDele items.allSatisfy({ service.contains($0.registration, functionID: $0.action.invoke.functionID) }) else { reply(["cancelled": true], nil); return } - self.pluginActionRequests[id] = nil + 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) } - pluginActionRequests[id] = (requestID, task) + 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], @@ -1838,7 +1859,7 @@ final class MonacoWorkbenchSession: NSObject, ObservableObject, WKNavigationDele private func fail(_ message: String) { failed = true - pluginActionRequests.values.forEach { $0.task.cancel() } + pluginActionRequests.values.forEach { $0.deadline.cancel(); $0.task.cancel() } pluginActionExecutions.values.forEach { $0.task.cancel() } pluginActionRequests.removeAll(); pluginActionExecutions.removeAll(); pluginActionLists.removeAll() contextMenu.dismiss() @@ -1851,7 +1872,7 @@ 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.task.cancel() } + pluginActionRequests.values.forEach { $0.deadline.cancel(); $0.task.cancel() } pluginActionExecutions.values.forEach { $0.task.cancel() } contextMenu.dismiss() gitLoads.values.forEach { $0.cancel() } diff --git a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginEditorLanguages.swift b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginEditorLanguages.swift index 1f2bdca131..9ef406a313 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginEditorLanguages.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginEditorLanguages.swift @@ -1,5 +1,6 @@ import Combine import Foundation +import LitheApplicationKernel import LitheCoreContracts import LitheModuleAPI @@ -29,6 +30,20 @@ final class MacPluginEditorLanguages: PluginEditorLanguageProviding { 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. diff --git a/macos/Tests/LitheTests/PluginEditorActionTests.swift b/macos/Tests/LitheTests/PluginEditorActionTests.swift index 68be4c41d9..4c557f54fb 100644 --- a/macos/Tests/LitheTests/PluginEditorActionTests.swift +++ b/macos/Tests/LitheTests/PluginEditorActionTests.swift @@ -5,6 +5,14 @@ import Testing @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() diff --git a/macos/Tests/LitheTests/PluginEditorLanguagesTests.swift b/macos/Tests/LitheTests/PluginEditorLanguagesTests.swift index 0fce4459db..c995c489bb 100644 --- a/macos/Tests/LitheTests/PluginEditorLanguagesTests.swift +++ b/macos/Tests/LitheTests/PluginEditorLanguagesTests.swift @@ -13,6 +13,41 @@ 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") @@ -77,6 +112,8 @@ struct PluginEditorLanguagesTests { } #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()) } diff --git a/rust/lithe-core/src/plugins/declarative.rs b/rust/lithe-core/src/plugins/declarative.rs index e134a2fdfa..a13ab4897a 100644 --- a/rust/lithe-core/src/plugins/declarative.rs +++ b/rust/lithe-core/src/plugins/declarative.rs @@ -4216,8 +4216,10 @@ pub fn decode_grammar_command(payload: Value) -> Result { .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(|tokenizer| json!({"kind": grammar.kind, "monarch": tokenizer})) + .map(|_| json!({"kind": grammar.kind, "source": payload.grammar})) } }) .transpose()?; diff --git a/rust/lithe-core/src/plugins/grammar.rs b/rust/lithe-core/src/plugins/grammar.rs index 7d0e8b84ca..0133e68c0e 100644 --- a/rust/lithe-core/src/plugins/grammar.rs +++ b/rust/lithe-core/src/plugins/grammar.rs @@ -155,7 +155,8 @@ fn grammar_error(field: impl Into, message: impl Into) -> Plugin } /// Validates the text of a Monarch grammar file and returns it as a JSON -/// object the editor passes to worker-isolated Monaco compilation unchanged. +/// 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( diff --git a/rust/lithe-core/src/tests/plugin_editor_resources.rs b/rust/lithe-core/src/tests/plugin_editor_resources.rs index eb392226bf..e5473cc563 100644 --- a/rust/lithe-core/src/tests/plugin_editor_resources.rs +++ b/rust/lithe-core/src/tests/plugin_editor_resources.rs @@ -51,10 +51,7 @@ fn a_grammar_file_decodes_to_the_language_the_editor_registers() { assert_eq!(language["id"], "http"); assert_eq!(language["fileExtensions"], json!(["http", "rest"])); assert_eq!(language["grammar"]["kind"], "monarch"); - assert_eq!( - language["grammar"]["monarch"], - serde_json::from_str::(HTTP_GRAMMAR).unwrap() - ); + assert_eq!(language["grammar"]["source"], HTTP_GRAMMAR); assert_eq!(language["configuration"]["lineComment"], "#"); assert_eq!( language["configuration"]["brackets"], @@ -62,6 +59,16 @@ fn a_grammar_file_decodes_to_the_language_the_editor_registers() { ); } +#[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!({ diff --git a/shared/contracts/plugins/v1/README.md b/shared/contracts/plugins/v1/README.md index 83a3a185c8..0a99c04483 100644 --- a/shared/contracts/plugins/v1/README.md +++ b/shared/contracts/plugins/v1/README.md @@ -128,7 +128,9 @@ activation event, and a plugin may consist of grammars alone. - `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 unchanged. A grammar Core refuses is `plugin.grammar.invalid` with 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 @@ -2281,6 +2283,9 @@ workspace context: - 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. diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index db31a3ba88..bf8ede9a3b 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -3835,8 +3835,10 @@ for the plan's `launch.versionArguments`, and returns `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, monarch }, configuration }`. `monarch` is the -grammar object unchanged, for Monaco compilation only in a terminable worker. A grammar Core +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. diff --git a/shared/platform-feature-matrix/features/plugin-editor-actions.json b/shared/platform-feature-matrix/features/plugin-editor-actions.json index d4cd088846..20f7a57192 100644 --- a/shared/platform-feature-matrix/features/plugin-editor-actions.json +++ b/shared/platform-feature-matrix/features/plugin-editor-actions.json @@ -21,6 +21,6 @@ ] }, "owner": "Extensions", - "verification": "用 HTTP 与 SQL 两种解析函数注册动作;验证当前脏文档版本、同一行多动作、明确点击执行、编辑取消旧解析、撤回/关闭/会话替换清理,不自动重试副作用。", + "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 index 6e27e515fa..389940b2c3 100644 --- a/shared/platform-feature-matrix/features/plugin-editor-grammars.json +++ b/shared/platform-feature-matrix/features/plugin-editor-grammars.json @@ -22,6 +22,6 @@ ] }, "owner": "Extensions", - "verification": "安装纯语法插件后打开 .http;验证即时出现、停用撤回、配置独立应用、病态语法不阻塞输入和保存;安装目录哈希不变。", + "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 已实现。" }