diff --git a/README.md b/README.md index 457d6c0..13253c0 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ Schema 只检查 JSON 结构。ID 引用、权限、激活事件、模板变量 | [Java](sdk/java/README.md) | Java 8+ | `sdk-java-v<版本>` | `lithe-plugin-sdk-java-<版本>.jar` 和 `.sha256` | shade 进后端 JAR | | [Rust](sdk/rust/README.md) | Rust 1.87+ | `sdk-rust-v<版本>` | `lithe-plugin-sdk-rust-<版本>.crate` 和 `.sha256` | 用 Git 依赖锁定标签(`Cargo.lock` 记录 commit),或解压后作为 `path` 依赖;编译成每个平台各一份的插件后端可执行文件 | -SDK 的源码都在本仓库的 `sdk/` 下,发布在[本仓库的 Releases](https://github.com/Lithe-IDEA/lithe-plugin-docs/releases) 里,不在 npm、Maven 或 crates.io 上。Rust SDK 目前只实现插件后端(`backend`):插件函数和宿主服务(插件函数协议 1–3);构建后端请用 Node.js 或 Java SDK,VCS 后端请用 Node.js SDK。各 SDK 实现的协议版本见[宿主与官方 SDK 对照](reference/capabilities-and-versions.md#宿主与官方-sdk-对照)。Java SDK 0.1.0 是搬到这里之前从 Lithe 主仓库发布的,[那个 Release](https://github.com/1lck/Lithe-IDEA/releases/tag/sdk-java-v0.1.0) 继续可用,已经锁定它的插件不用改。 +SDK 的源码都在本仓库的 `sdk/` 下,发布在[本仓库的 Releases](https://github.com/Lithe-IDEA/lithe-plugin-docs/releases) 里,不在 npm、Maven 或 crates.io 上。Rust SDK 目前只实现插件后端(`backend`):插件函数和宿主服务(插件函数协议 1–4);构建后端请用 Node.js 或 Java SDK,VCS 后端请用 Node.js SDK。各 SDK 实现的协议版本见[宿主与官方 SDK 对照](reference/capabilities-and-versions.md#宿主与官方-sdk-对照)。Java SDK 0.1.0 是搬到这里之前从 Lithe 主仓库发布的,[那个 Release](https://github.com/1lck/Lithe-IDEA/releases/tag/sdk-java-v0.1.0) 继续可用,已经锁定它的插件不用改。 规则只有一条:**在构建时下载,并按你锁定的 SHA-256(或 Git commit)校验;运行时不下载任何东西。** 插件包安装后没有 `npm install` 步骤,也必须能离线运行。 @@ -313,7 +313,7 @@ A plugin that uses only language servers, run, and test needs no backend. When y | [Java](sdk/java/README.md) | Java 8+ | `sdk-java-v` | `lithe-plugin-sdk-java-.jar` and `.sha256` | Shade it into the backend JAR | | [Rust](sdk/rust/README.md) | Rust 1.87+ | `sdk-rust-v` | `lithe-plugin-sdk-rust-.crate` and `.sha256` | Pin the tag as a Git dependency (`Cargo.lock` records the commit), or unpack it as a `path` dependency; compile one backend executable per platform | -All SDK sources live under `sdk/` in this repository and are published in [this repository's Releases](https://github.com/Lithe-IDEA/lithe-plugin-docs/releases), not on npm, Maven, or crates.io. The Rust SDK implements only the plugin backend (`backend`): plugin functions and host services (plugin function protocol 1–3); write build backends with the Node.js or Java SDK and VCS backends with the Node.js SDK. For the protocol versions each SDK implements, see [Host and official SDKs](reference/capabilities-and-versions.md#host-and-official-sdks). Java SDK 0.1.0 was released from the Lithe repository before the move; [that Release](https://github.com/1lck/Lithe-IDEA/releases/tag/sdk-java-v0.1.0) stays available, and plugins pinned to it need no change. +All SDK sources live under `sdk/` in this repository and are published in [this repository's Releases](https://github.com/Lithe-IDEA/lithe-plugin-docs/releases), not on npm, Maven, or crates.io. The Rust SDK implements only the plugin backend (`backend`): plugin functions and host services (plugin function protocol 1–4); write build backends with the Node.js or Java SDK and VCS backends with the Node.js SDK. For the protocol versions each SDK implements, see [Host and official SDKs](reference/capabilities-and-versions.md#host-and-official-sdks). Java SDK 0.1.0 was released from the Lithe repository before the move; [that Release](https://github.com/1lck/Lithe-IDEA/releases/tag/sdk-java-v0.1.0) stays available, and plugins pinned to it need no change. There is one rule: **download at build time and verify against the SHA-256 (or Git commit) you pinned; download nothing at run time.** An installed package has no `npm install` step and must run offline. diff --git a/guides/plugin-backend-workspace.md b/guides/plugin-backend-workspace.md index 272213e..8c2557a 100644 --- a/guides/plugin-backend-workspace.md +++ b/guides/plugin-backend-workspace.md @@ -13,7 +13,7 @@ > 只提供 `backend: [1]` 的旧宿主会以 `plugin.api.unsupported` 拒绝 `backend` 2 的插件,所以老版本 Lithe > 不会启动一个期待工作区上下文的进程。三个官方 SDK 都实现了协议 3:Node.js SDK 0.2.0、Java SDK 0.2.0 和 Rust SDK 0.1.0 起 > (见各自 README 的 Host services:[Node.js](../sdk/node/README.md#host-services)、[Java](../sdk/java/README.md#host-services)、 -> [Rust](../sdk/rust/README.md#host-services))。宿主还支持函数协议 4(跨插件调用,见第 10 节),三个官方 SDK 目前只实现到协议 3。 +> [Rust](../sdk/rust/README.md#host-services))。函数协议 4(跨插件调用,见第 10 节)由 Node.js SDK 0.4.0、Java SDK 0.4.0 和 Rust SDK 0.3.0 起实现。 > 用其他语言或更早的 SDK 需要自己处理下文的请求和通知。 > 各宿主支持到哪一版,见按最新 preview 核对的[版本对照](../reference/capabilities-and-versions.md)。 @@ -269,7 +269,37 @@ - 只协商到 3 的后端照常工作。它不能调用 `functions/*`;它导出的函数仍可被中转调用,但收不到 `caller`,要按调用方判断就必须协商 4。 - 导出或调用跨插件函数,清单要声明 `pluginFunctions`:`pluginAPIVersion` 不低于 3,`requires` 里写 `pluginFunctions: 1`、`backend: 2`、`serverProcess: 1`。 它不授予任何清单权限;清单声明了导出,后端仍要用 `lithe/function/register` 注册该函数。 -- 目前只有 macOS 宿主实现;三个官方 SDK 还只协商到协议 3,没有 `functions/*` 的封装,也读不到 `caller`。 +- 目前只有 macOS 宿主实现。 + +三个官方 SDK 都协商到协议 4,封装了 `functions/providers`、`functions/invoke`,被调函数能读到 `caller`; +协商到 3 及以下时,这两个方法在 SDK 本地以 `plugin.host.unavailable`(-32601)失败,不会发给宿主。 +`parentOperationID` 不传时,SDK 取正在处理的 `lithe/function/invoke` 的 `operationID`。 +下面以 Node.js 为例,[Java](../sdk/java/README.md) 和 [Rust](../sdk/rust/README.md) 的对应方法见各自 README: + +```js +// Caller. The manifest declares pluginFunctions.calls for document.format. +backend.function('format.current', async (params, context) => { + const workspace = context.workspaceContext; + if (!workspace || !await context.host.supports('lithe/host/functions/invoke')) { + throw new PluginError('format.unavailable', 'Cross-plugin calls are unavailable.'); + } + const { providers } = await context.host.functions.providers('document.format'); + if (providers.length === 0) return null; + const { value } = await context.host.functions.invoke(workspace, { + pluginID: providers[0].pluginID, + functionID: 'document.format', + params: { path: params.path }, + timeoutMilliseconds: 30000, + }); + return value; +}); + +// Provider. `caller` is { pluginID } for a relayed call and null when the host started the call. +backend.function('document.format', async (params, context) => { + const callerID = context.caller?.pluginID ?? null; + return formatDocument(params, callerID); +}); +``` ### 11. 验证 @@ -293,7 +323,7 @@ The [contract](../contract/v1/README.md#backend-2-workspace-contexts) is normati > An older host that offers only `backend: [1]` refuses a `backend` 2 plugin with `plugin.api.unsupported`, so an older Lithe > never starts a process that expects workspace contexts. All three official SDKs implement protocol 3 from Node.js SDK 0.2.0, Java SDK 0.2.0, and Rust SDK 0.1.0 > (see Host services in each README: [Node.js](../sdk/node/README.md#host-services), [Java](../sdk/java/README.md#host-services), -> [Rust](../sdk/rust/README.md#host-services)). The host also supports function protocol 4 (cross-plugin calls, see section 10); the three official SDKs implement only up to protocol 3 so far. +> [Rust](../sdk/rust/README.md#host-services)). Function protocol 4 (cross-plugin calls, see section 10) is implemented from Node.js SDK 0.4.0, Java SDK 0.4.0, and Rust SDK 0.3.0. > With another language or an earlier SDK, handle the requests and notifications below yourself. > For how far each host supports each version, see the [version tables](../reference/capabilities-and-versions.md), which track the latest preview. @@ -549,7 +579,37 @@ What to remember: - A backend that negotiated only 3 keeps working as before. It cannot call `functions/*`; the functions it exports can still be called through the relay, but it never receives `caller`, so a provider that decides by who is asking must negotiate 4. - To export or call cross-plugin functions, the manifest declares `pluginFunctions`: `pluginAPIVersion` 3 or later, with `pluginFunctions: 1`, `backend: 2`, and `serverProcess: 1` in `requires`. It grants no manifest permission, and declaring an export does not register it: the backend still registers the function with `lithe/function/register`. -- Only the macOS host implements it so far; the three official SDKs still negotiate only up to protocol 3, have no wrapper for `functions/*`, and do not expose `caller`. +- Only the macOS host implements it so far. + +All three official SDKs negotiate protocol 4, wrap `functions/providers` and `functions/invoke`, and expose `caller` to the called function; +when version 3 or lower is negotiated, both methods fail locally in the SDK with `plugin.host.unavailable` (-32601) and are never sent to the host. +When `parentOperationID` is omitted, the SDK uses the `operationID` of the `lithe/function/invoke` being handled. +The example below uses Node.js; for the matching methods in [Java](../sdk/java/README.md) and [Rust](../sdk/rust/README.md), see their READMEs: + +```js +// Caller. The manifest declares pluginFunctions.calls for document.format. +backend.function('format.current', async (params, context) => { + const workspace = context.workspaceContext; + if (!workspace || !await context.host.supports('lithe/host/functions/invoke')) { + throw new PluginError('format.unavailable', 'Cross-plugin calls are unavailable.'); + } + const { providers } = await context.host.functions.providers('document.format'); + if (providers.length === 0) return null; + const { value } = await context.host.functions.invoke(workspace, { + pluginID: providers[0].pluginID, + functionID: 'document.format', + params: { path: params.path }, + timeoutMilliseconds: 30000, + }); + return value; +}); + +// Provider. `caller` is { pluginID } for a relayed call and null when the host started the call. +backend.function('document.format', async (params, context) => { + const callerID = context.caller?.pluginID ?? null; + return formatDocument(params, callerID); +}); +``` ### 11. Verification diff --git a/reference/api-catalog.md b/reference/api-catalog.md index fcfc14e..2febcff 100644 --- a/reference/api-catalog.md +++ b/reference/api-catalog.md @@ -54,7 +54,7 @@ Lithe 的插件公共 API 按 [API 实施计划](https://github.com/1lck/Lithe-I | API-30 | 调试适配器 | 部分完成 | [`debugAdapters` 1、2](../contract/v1/README.md#api-level-4-debug-adapters) 与宿主的 DAP 会话;[`debugAdapters` 3](../contract/v1/README.md#adapter-features-and-defaults-debugadapters-3) 的 `features`、`defaults`(热代码替换、步进过滤器);后端发起的 [`debug/start`](../contract/v1/README.md#run-and-debug-sessions-protocol-version-3)(权限 `debug.start`,macOS) | 后端只能在本插件自己声明的适配器上启动会话,不能使用其他插件的适配器 | [调试适配器](domains/debug-adapters.md) | | API-31 | VCS 提供者 | 既有能力 | [`vcsProviders` 1](../contract/v1/README.md#api-level-3-vcs-providers) | 不含内置 Git 的公共读取(见 API-42) | [VCS](domains/vcs.md) | | API-33 | 项目生成器 | 既有能力 | [`projectGenerators` 1、2](../contract/v1/README.md#project-generators) | — | [项目生成器](domains/project-generators.md) | -| API-35 | 插件间调用与依赖 | 部分完成 | 第一阶段:[`pluginFunctions` 1](../contract/v1/README.md#cross-plugin-calls-protocol-version-4)(清单 `exports`、`calls` 声明,不授予清单权限)与函数协议 4 的 [`functions/providers`、`functions/invoke`](../contract/v1/README.md#discovery-and-invocation):经 Core 中转,调用另一个已启用插件导出的函数;信任不传递,不是安装依赖(macOS,本机工作区) | 依赖与导出的安装闭包、版本求解、远程 Provider;macOS 上新安装或更新的后端插件要重启 Lithe 后才会出现在 `providers` 里;官方 SDK 尚无 `functions/*` 封装 | [插件后端](domains/backend.md) | +| API-35 | 插件间调用与依赖 | 部分完成 | 第一阶段:[`pluginFunctions` 1](../contract/v1/README.md#cross-plugin-calls-protocol-version-4)(清单 `exports`、`calls` 声明,不授予清单权限)与函数协议 4 的 [`functions/providers`、`functions/invoke`](../contract/v1/README.md#discovery-and-invocation):经 Core 中转,调用另一个已启用插件导出的函数;信任不传递,不是安装依赖(macOS,本机工作区) | 依赖与导出的安装闭包、版本求解、远程 Provider;macOS 上新安装或更新的后端插件要重启 Lithe 后才会出现在 `providers` 里 | [插件后端](domains/backend.md) | | API-37 | 环境信息与语言包 | 部分完成 | `getCapabilities` 返回协议版本、可用方法、限制和后端运行位置(`backendAuthority`) | 语言包、环境和主题变化事件 | [插件后端](domains/backend.md) | | API-40 | 工作区 authority | 部分完成 | 工作区上下文带 `local` 或 `remote`;文件和编辑器方法对远程上下文明确返回 `plugin.host.authorityUnsupported` | 远程执行 | [插件后端](domains/backend.md) | | API-41 | 激活事件与启动预算 | 部分完成 | [`onWorkspaceOpen`](../contract/v1/README.md#permissions-and-activation)、`onCommand`、后台两路并发启动、`initialize` 超时、有界通知队列 | 按工作区内容匹配激活、全局冷启动预算 | [插件后端](domains/backend.md) | @@ -83,7 +83,7 @@ Lithe 的插件公共 API 按 [API 实施计划](https://github.com/1lck/Lithe-I 后端向宿主发送的请求,前缀都是 `lithe/host/`,下表省略前缀。一个 API 编号下可以有多个方法,每个方法单独一行,按 API 编号排列。 - **“API”列是规划编号**,来自 [API 实施计划](https://github.com/1lck/Lithe-IDEA/blob/preview/docs/development/macos-plugin-api-plan.md),和“协议”列的 `litheFunctions` 版本、清单里的能力版本分开记录。同一个 API 的方法可以分几个 PR 陆续加入;协议 3 里向后兼容地新增方法,也不升协议版本。每个方法哪天、由哪个 PR 首次加入,见[插件协议更新记录](protocol-changelog.md#宿主方法按-api-编号)。 -- **“协议”列**是最低的 `litheFunctions` 版本。除 `getCapabilities`、`subscriptions/dispose`、`workspace/listContexts`、`run/stop`、`functions/providers` 外,协议 3 及以上的方法都要带 `context: {contextID, generation}`。协议 4 的方法(`functions/*`)只对协商到协议 4 的后端开放;官方 SDK 目前只协商 1–3。 +- **“协议”列**是最低的 `litheFunctions` 版本。除 `getCapabilities`、`subscriptions/dispose`、`workspace/listContexts`、`run/stop`、`functions/providers` 外,协议 3 及以上的方法都要带 `context: {contextID, generation}`。协议 4 的方法(`functions/*`)只对协商到协议 4 的后端开放;三个官方 SDK 都协商 1–4,已封装 `functions/providers` 和 `functions/invoke`。 - **“权限”列**:除 `credentials.store` 外,都要求清单声明 `backend` 2,否则安装时拒绝。`functions/*` 没有清单权限,由清单 `pluginFunctions` 段的 `calls` 是否声明了该函数决定。另有两条前置条件:`debug.start` 还要求已声明的 `debugAdapters` 条目;`languageServer.request` 只在 `languageServers` 3 的某个服务器列出 `requestMethods` 或 `forwardNotifications` 时才能声明。 | API | 方法 | 用途 | 权限 | 协议 | @@ -226,7 +226,7 @@ Status meanings: | API-30 | Debug adapters | Partial | [`debugAdapters` 1 and 2](../contract/v1/README.md#api-level-4-debug-adapters) and the host's DAP sessions; `features` and `defaults` of [`debugAdapters` 3](../contract/v1/README.md#adapter-features-and-defaults-debugadapters-3) (hot code replace, step filters); backend-started [`debug/start`](../contract/v1/README.md#run-and-debug-sessions-protocol-version-3) (permission `debug.start`, macOS) | A backend can start sessions only on adapters its own plugin declares, not on another plugin's adapters | [Debug adapters](domains/debug-adapters.md) | | API-31 | VCS providers | Existing | [`vcsProviders` 1](../contract/v1/README.md#api-level-3-vcs-providers) | Public reads of built-in Git are not included (see API-42) | [VCS](domains/vcs.md) | | API-33 | Project generators | Existing | [`projectGenerators` 1, 2](../contract/v1/README.md#project-generators) | — | [Project generators](domains/project-generators.md) | -| API-35 | Cross-plugin calls and dependencies | Partial | Phase one: [`pluginFunctions` 1](../contract/v1/README.md#cross-plugin-calls-protocol-version-4) (`exports` and `calls` in the manifest, granting no manifest permission) and, in function protocol 4, [`functions/providers` and `functions/invoke`](../contract/v1/README.md#discovery-and-invocation): Core relays a call to a function another enabled plugin exports; trust is not transitive and this is not an install dependency (macOS, local workspaces) | The install closure of dependencies and exports, version solving, remote providers; on macOS a newly installed or updated backend plugin appears in `providers` only after Lithe restarts; the official SDKs have no `functions/*` wrapper yet | [Plugin backend](domains/backend.md) | +| API-35 | Cross-plugin calls and dependencies | Partial | Phase one: [`pluginFunctions` 1](../contract/v1/README.md#cross-plugin-calls-protocol-version-4) (`exports` and `calls` in the manifest, granting no manifest permission) and, in function protocol 4, [`functions/providers` and `functions/invoke`](../contract/v1/README.md#discovery-and-invocation): Core relays a call to a function another enabled plugin exports; trust is not transitive and this is not an install dependency (macOS, local workspaces) | The install closure of dependencies and exports, version solving, remote providers; on macOS a newly installed or updated backend plugin appears in `providers` only after Lithe restarts | [Plugin backend](domains/backend.md) | | API-37 | Environment info and l10n | Partial | `getCapabilities` returns the protocol version, usable methods, limits, and where the backend runs (`backendAuthority`) | Language packs, environment and theme change events | [Plugin backend](domains/backend.md) | | API-40 | Workspace authority | Partial | Workspace contexts carry `local` or `remote`; file and editor methods refuse a remote context with `plugin.host.authorityUnsupported` | Remote execution | [Plugin backend](domains/backend.md) | | API-41 | Activation events and startup budget | Partial | [`onWorkspaceOpen`](../contract/v1/README.md#permissions-and-activation), `onCommand`, two concurrent background starts, `initialize` timeout, bounded notification queues | Activation by workspace content, a global cold-start budget | [Plugin backend](domains/backend.md) | @@ -255,7 +255,7 @@ The manifest, marketplace, package trust, and shortcut import are installation a Requests a backend sends to the host, all prefixed `lithe/host/`; the tables omit the prefix. One API ID can cover several methods; each method has its own row, ordered by API ID. - **The “API” column holds planning IDs** from the [API implementation plan](https://github.com/1lck/Lithe-IDEA/blob/preview/docs/development/macos-plugin-api-plan.md). They are recorded separately from the `litheFunctions` version in the “Protocol” column and from manifest capability versions. The methods of one API can arrive over several PRs, and a backward-compatible method added within protocol 3 does not bump the protocol version. The date and PR that first added each method are in the [protocol changelog](protocol-changelog.md#宿主方法按-api-编号) (Chinese only). -- **The “Protocol” column** is the lowest `litheFunctions` version. Apart from `getCapabilities`, `subscriptions/dispose`, `workspace/listContexts`, `run/stop`, and `functions/providers`, every method of protocol 3 and later takes `context: {contextID, generation}`. Protocol 4 methods (`functions/*`) are open only to a backend that negotiated protocol 4; the official SDKs currently negotiate only 1–3. +- **The “Protocol” column** is the lowest `litheFunctions` version. Apart from `getCapabilities`, `subscriptions/dispose`, `workspace/listContexts`, `run/stop`, and `functions/providers`, every method of protocol 3 and later takes `context: {contextID, generation}`. Protocol 4 methods (`functions/*`) are open only to a backend that negotiated protocol 4; the three official SDKs all negotiate 1–4 and wrap `functions/providers` and `functions/invoke`. - **The “Permission” column**: apart from `credentials.store`, every permission requires the manifest to declare `backend` 2, or installation refuses it. `functions/*` has no manifest permission; whether the manifest's `pluginFunctions` section lists the function under `calls` decides. Two more preconditions: `debug.start` also requires a declared `debugAdapters` entry, and `languageServer.request` can be declared only when a `languageServers` 3 server lists `requestMethods` or `forwardNotifications`. | API | Method | Purpose | Permission | Protocol | diff --git a/reference/capabilities-and-versions.md b/reference/capabilities-and-versions.md index 373295d..fcf105e 100644 --- a/reference/capabilities-and-versions.md +++ b/reference/capabilities-and-versions.md @@ -131,9 +131,9 @@ macOS 目前只声明 `serverProcess` 1 和 3:版本 4 已由 Core 校验, “Core 提供”是宿主在 `initialize` 里给出的版本列表;SDK 列是该 SDK 能回答的版本。后端会选两边都支持的最高版本。 -| 协议 | Core 提供 | Node SDK 0.3.0 | Java SDK 0.3.0 | Rust SDK 0.2.0 | +| 协议 | Core 提供 | Node SDK 0.4.0 | Java SDK 0.4.0 | Rust SDK 0.3.0 | | --- | --- | --- | --- | --- | -| `litheFunctions` | 1、2、3、4 | 3 | 3 | 3、2、1 | +| `litheFunctions` | 1、2、3、4 | 1–4 | 1–4 | 1–4 | | `litheBuild` | 1–11 | 1–6 | 1–6 | — | | `litheVcs` | 1 | 1 | — | — | | `litheKeymapImport` | 1 | 1 | 1 | 1 | @@ -141,7 +141,7 @@ macOS 目前只声明 `serverProcess` 1 和 3:版本 4 已由 Core 校验, | `litheLogging` | 1 | 1(必需) | 1(必需) | 1(必需) | - Node 和 Java SDK 的构建方法按协商结果启用:`model`、`dependencies` 需要 1,`execute` 需要 2,`test`、`discoverTests` 需要 3,`prepareRun` 需要 4,`prepareDebug` 需要 5。版本 7–11 的内容目前没有 SDK 封装。 -- 宿主支持函数协议 1–4,三个官方 SDK(Node、Java、Rust)目前只协商 1–3,所以用它们构建的后端不会协商到协议 4。协议 4 的 `functions/*` 和 `caller`,以及协议 3 宿主方法中的 `run/*`、`debug/start`、`languageServer/*`、`documents/registerContentProvider`,目前都没有 SDK 封装;插件可以先用 SDK 的通用宿主请求调用这些协议 3 的方法。 +- 宿主支持函数协议 1–4,三个官方 SDK(Node、Java、Rust)都协商 1–4,已封装协议 4 的 `functions/providers`、`functions/invoke` 和被调函数的 `caller`;协商到 3 及以下时,这两个方法在 SDK 本地以 `plugin.host.unavailable`(-32601)失败,不会发给宿主。协议 3 宿主方法中的 `run/*`、`debug/start`、`languageServer/*`、`documents/registerContentProvider` 仍没有 SDK 封装;插件可以先用 SDK 的通用宿主请求调用这些协议 3 的方法。 - 当前 SDK 要求宿主提供 `litheLogging` 1,否则拒绝初始化;用这些 SDK 构建的后端只能在提供该协议的 Lithe 版本中启动。 ### SDK 发版历史 @@ -156,6 +156,9 @@ macOS 目前只声明 `serverProcess` 1 和 3:版本 4 已由 Core 校验, | 2026-11-05 | [Node 0.3.0](https://github.com/Lithe-IDEA/lithe-plugin-docs/releases/tag/sdk-node-v0.3.0) | 3 | 1–6 | 1 | 1 | 1(必需) | 快捷键导入提供者、原生终端、类型化诊断;移除自由文本 `log` | | 2026-11-05 | [Java 0.3.0](https://github.com/Lithe-IDEA/lithe-plugin-docs/releases/tag/sdk-java-v0.3.0) | 3 | 1–6 | — | 1 | 1(必需) | 同 Node 0.3.0 | | 2026-11-05 | [Rust 0.2.0](https://github.com/Lithe-IDEA/lithe-plugin-docs/releases/tag/sdk-rust-v0.2.0) | 3、2、1 | — | — | 1 | 1(必需) | 同 Node 0.3.0 | +| 2026-11-05 | [Node 0.4.0](https://github.com/Lithe-IDEA/lithe-plugin-docs/releases/tag/sdk-node-v0.4.0) | 1–4 | 1–6 | 1 | 1 | 1(必需) | 函数协议 4:跨插件调用(`functions/providers`、`functions/invoke`、`caller`) | +| 2026-11-05 | [Java 0.4.0](https://github.com/Lithe-IDEA/lithe-plugin-docs/releases/tag/sdk-java-v0.4.0) | 1–4 | 1–6 | — | 1 | 1(必需) | 函数协议 4:跨插件调用(`functions/providers`、`functions/invoke`、`caller`) | +| 2026-11-05 | [Rust 0.3.0](https://github.com/Lithe-IDEA/lithe-plugin-docs/releases/tag/sdk-rust-v0.3.0) | 1–4 | — | — | 1 | 1(必需) | 函数协议 4:跨插件调用(`functions/providers`、`functions/invoke`、`caller`) | ### 文档格式版本 @@ -302,9 +305,9 @@ These protocols are negotiated when a process starts and never appear in the man “Core offers” is the list the host sends in `initialize`; the SDK columns are the versions each SDK can answer. A backend picks the highest version both sides support. -| Protocol | Core offers | Node SDK 0.3.0 | Java SDK 0.3.0 | Rust SDK 0.2.0 | +| Protocol | Core offers | Node SDK 0.4.0 | Java SDK 0.4.0 | Rust SDK 0.3.0 | | --- | --- | --- | --- | --- | -| `litheFunctions` | 1, 2, 3, 4 | 3 | 3 | 3, 2, 1 | +| `litheFunctions` | 1, 2, 3, 4 | 1–4 | 1–4 | 1–4 | | `litheBuild` | 1–11 | 1–6 | 1–6 | — | | `litheVcs` | 1 | 1 | — | — | | `litheKeymapImport` | 1 | 1 | 1 | 1 | @@ -312,7 +315,7 @@ These protocols are negotiated when a process starts and never appear in the man | `litheLogging` | 1 | 1 (required) | 1 (required) | 1 (required) | - The Node and Java SDKs enable each build method from the negotiated version: `model` and `dependencies` need 1, `execute` 2, `test` and `discoverTests` 3, `prepareRun` 4, `prepareDebug` 5. Versions 7–11 have no SDK wrappers yet. -- The host supports function protocols 1–4, while the three official SDKs (Node, Java, Rust) negotiate only 1–3 today, so a backend built on them never negotiates protocol 4. The protocol 4 `functions/*` methods and `caller` field, and the protocol 3 host methods `run/*`, `debug/start`, `languageServer/*`, and `documents/registerContentProvider`, have no SDK wrappers yet; a plugin can call those protocol 3 methods through the SDK's generic host request meanwhile. +- The host supports function protocols 1–4, and the three official SDKs (Node, Java, Rust) all negotiate 1–4. They wrap the protocol 4 methods `functions/providers` and `functions/invoke` and expose `caller` to the called function; when version 3 or lower is negotiated, both methods fail locally in the SDK with `plugin.host.unavailable` (-32601) and are never sent to the host. The protocol 3 host methods `run/*`, `debug/start`, `languageServer/*`, and `documents/registerContentProvider` still have no SDK wrappers; a plugin can call those protocol 3 methods through the SDK's generic host request meanwhile. - The current SDKs require the host to offer `litheLogging` 1 and refuse to initialize otherwise, so a backend built on them starts only in a Lithe release that offers it. ### SDK release history @@ -327,6 +330,9 @@ These protocols are negotiated when a process starts and never appear in the man | 2026-11-05 | [Node 0.3.0](https://github.com/Lithe-IDEA/lithe-plugin-docs/releases/tag/sdk-node-v0.3.0) | 3 | 1–6 | 1 | 1 | 1 (required) | Shortcut import providers, native terminals, typed diagnostics; free-text `log` removed | | 2026-11-05 | [Java 0.3.0](https://github.com/Lithe-IDEA/lithe-plugin-docs/releases/tag/sdk-java-v0.3.0) | 3 | 1–6 | — | 1 | 1 (required) | Same as Node 0.3.0 | | 2026-11-05 | [Rust 0.2.0](https://github.com/Lithe-IDEA/lithe-plugin-docs/releases/tag/sdk-rust-v0.2.0) | 3, 2, 1 | — | — | 1 | 1 (required) | Same as Node 0.3.0 | +| 2026-11-05 | [Node 0.4.0](https://github.com/Lithe-IDEA/lithe-plugin-docs/releases/tag/sdk-node-v0.4.0) | 1–4 | 1–6 | 1 | 1 | 1 (required) | Function protocol 4: cross-plugin calls (`functions/providers`, `functions/invoke`, `caller`) | +| 2026-11-05 | [Java 0.4.0](https://github.com/Lithe-IDEA/lithe-plugin-docs/releases/tag/sdk-java-v0.4.0) | 1–4 | 1–6 | — | 1 | 1 (required) | Function protocol 4: cross-plugin calls (`functions/providers`, `functions/invoke`, `caller`) | +| 2026-11-05 | [Rust 0.3.0](https://github.com/Lithe-IDEA/lithe-plugin-docs/releases/tag/sdk-rust-v0.3.0) | 1–4 | — | — | 1 | 1 (required) | Function protocol 4: cross-plugin calls (`functions/providers`, `functions/invoke`, `caller`) | ### Document format versions diff --git a/reference/domains/backend.md b/reference/domains/backend.md index 97c47f9..fc7ef5e 100644 --- a/reference/domains/backend.md +++ b/reference/domains/backend.md @@ -15,7 +15,7 @@ | 权限 | `backend.launch`;宿主服务按方法另加权限(`credentials.store`、`workspace.metadata`、`workspace.files.read`、`workspace.files.watch`、`workspace.search`、`terminal.create`、`run.start` 等),见[已公开的宿主方法](../api-catalog.md#已公开的宿主方法)。跨插件调用没有清单权限:调用方的 `calls` 和被调方的 `exports` 就是双方的同意 | | 激活事件 | `backend` 1:插件网页第一次调用 `functions.*` 时启动。`backend` 2 另可用 `onCommand:` 或可选的 `onWorkspaceOpen`。只导出函数的插件不需要网页、命令或 `onWorkspaceOpen`,被调用时启动后端;只调用的插件仍要有其中一种 | | Windows | 没有插件后端宿主,不声明 `backend` | -| 官方 SDK | [Node](../../sdk/node/README.md)、[Java](../../sdk/java/README.md)、[Rust](../../sdk/rust/README.md);各 SDK 支持的协议版本见[宿主与官方 SDK 对照](../capabilities-and-versions.md#宿主与官方-sdk-对照)。三个 SDK 目前只协商函数协议 1–3,没有 `functions/*`、`run/*` 的封装,也收不到 `caller` 字段;协议 3 的方法可以先用 SDK 的通用宿主请求调用,协议 4 要等 SDK 支持后才能协商 | +| 官方 SDK | [Node](../../sdk/node/README.md)、[Java](../../sdk/java/README.md)、[Rust](../../sdk/rust/README.md);各 SDK 支持的协议版本见[宿主与官方 SDK 对照](../capabilities-and-versions.md#宿主与官方-sdk-对照)。三个 SDK 都协商函数协议 1–4,已封装 `functions/providers`、`functions/invoke`,被调函数能读到 `caller`,用法见各 SDK 的 README;`parentOperationID` 不传时,SDK 取正在处理的 `lithe/function/invoke` 的 `operationID`,协商到 3 及以下时这两个方法在 SDK 本地以 `plugin.host.unavailable` 失败。协议 3 的 `run/*`、`debug/start`、`languageServer/*`、`documents/registerContentProvider` 仍没有封装,可以先用 SDK 的通用宿主请求调用 | ### 版本对照 @@ -186,7 +186,7 @@ A plugin backend is the plugin's own long-lived process. It runs what a web page | Permissions | `backend.launch`; host services need their own permission per method (`credentials.store`, `workspace.metadata`, `workspace.files.read`, `workspace.files.watch`, `workspace.search`, `terminal.create`, `run.start`, …); see [public host methods](../api-catalog.md#public-host-methods). Cross-plugin calls have no manifest permission: the caller's `calls` and the provider's `exports` are the consent of the two sides | | Activation | `backend` 1 starts on the first `functions.*` call from the plugin's page. `backend` 2 may also start on `onCommand:` or the optional `onWorkspaceOpen`. A plugin that only exports functions needs no page, command, or `onWorkspaceOpen`: a call starts its backend; a plugin that only calls still needs one of them | | Windows | No plugin backend host; `backend` is not advertised | -| Official SDKs | [Node](../../sdk/node/README.md), [Java](../../sdk/java/README.md), [Rust](../../sdk/rust/README.md); protocol versions per SDK are in [Host and official SDKs](../capabilities-and-versions.md#host-and-official-sdks). The three SDKs currently negotiate function protocol 1–3 only: they have no wrappers for `functions/*` or `run/*` and never receive the `caller` field. Protocol 3 methods can already be called through the SDK's generic host request; protocol 4 can be negotiated once an SDK supports it | +| Official SDKs | [Node](../../sdk/node/README.md), [Java](../../sdk/java/README.md), [Rust](../../sdk/rust/README.md); protocol versions per SDK are in [Host and official SDKs](../capabilities-and-versions.md#host-and-official-sdks). The three SDKs all negotiate function protocol 1–4: they wrap `functions/providers` and `functions/invoke`, and a called function can read `caller`; see each SDK's README for usage. When `parentOperationID` is omitted, the SDK uses the `operationID` of the `lithe/function/invoke` being handled, and when version 3 or lower is negotiated both methods fail locally in the SDK with `plugin.host.unavailable`. The protocol 3 methods `run/*`, `debug/start`, `languageServer/*`, and `documents/registerContentProvider` still have no wrappers and can be called through the SDK's generic host request | ### Versions compared diff --git a/reference/protocol-changelog.md b/reference/protocol-changelog.md index 8445e28..268a920 100644 --- a/reference/protocol-changelog.md +++ b/reference/protocol-changelog.md @@ -163,7 +163,7 @@ | [#1763](https://github.com/1lck/Lithe-IDEA/pull/1763) | [@xiaoyumuxi](https://github.com/xiaoyumuxi) | 清单与工具定义:`hostIntegration`、短工具 ID(API-23、API-25) | 语言服务器的 `hostIntegration`(插件 API 级别 2)对所有插件开放,不再限 `dev.lithe` 命名空间:集成只读取声明插件自己包里的资源,交给同一插件启动的服务器或辅助进程,不额外授予权限;宿主未实现的 ID 仍返回 `plugin.integration.unsupported`。构建系统的 `hostIntegration` 仍只限官方插件。工具定义:任何插件都可以定义短 ID(如 `compiler`)或限定 ID `:`,宿主不再提供来源证明;同一个短 ID 被多个已启用插件定义时不归任何一方,注册表不登记这些定义,并报告为冲突;限定 ID 不会冲突;所有权不按到达顺序或插件 ID 顺序决定。`toolchains.registry` 的输入从 `{plugins:[{manifest,verifiedOfficial}]}` 改为 `{plugins:[{manifest}]}`,输出新增 `conflicts:[{tool,pluginIDs}]`(按 `tool` 排序,`pluginIDs` 也排序)。macOS 设置页对每个冲突显示一条诊断,建议只保留其中一个插件。`plugin.integration.unsupported` 的含义改为:宿主未实现的集成,或官方命名空间以外的插件请求构建系统的宿主集成。`fixtures/manifest/invalid-manifests.json` 删除 1 条清单拒绝用例(“host integration requested outside the official namespace”)。不新增版本号,不新增宿主方法。 | | [#1746](https://github.com/1lck/Lithe-IDEA/pull/1746) | [@xiaoyumuxi](https://github.com/xiaoyumuxi) | **`debugAdapters` 3**、**`serverProcess` 4**、**`outputLinkMatchers` 1**、函数协议 3:后端发起的运行与调试(API-22、API-23、API-30) | `debugAdapters` 3:适配器可声明 `features` 和 `defaults`(热代码替换、步进过滤器),新增 fixture `debug-adapter-features.plugin.json`。`serverProcess` 4:运行时按工具定义 ID 引用(清单 `requiresTools`),不再使用宿主的封闭运行时列表。`outputLinkMatchers` 1(API-22,新能力):插件声明匹配器,让 Run 和测试控制台的输出出现链接;Core 负责匹配,宿主只上样式并转发点击,`resolve` 函数在点击时才调用。新增权限 `run.start`(需要 `backend` 2),授予 `lithe/host/run/start`、`lithe/host/run/stop` 和通知 `lithe/host/run/didExit`;新增权限 `debug.start`(需要 `backend` 2 和已声明的 `debugAdapters` 条目),只授予 `lithe/host/debug/start`。`run/start`(API-23):参数 `context, label, process, temporaryFiles?, debug?, rerun?`,结果 `{runID}`,启动宿主拥有的 Run 会话(Run 窗口条目、控制台、停止按钮、退出状态);`process.executable` 只能是 `{kind:"tool", tool}`(清单 `requiresTools` 列出的工具定义 ID)或 `{kind:"packaged", path}`(插件包内文件);`temporaryFiles` 最多 4 个,每个不超过 4 MiB;`debug` 让它成为调试会话,`rerun` 让用户可以再次运行。`run/stop`(API-23):参数 `{runID}` 或 `{sessionID}`,结果 `null`,停止本插件启动的运行或调试会话,不带 `context`。`debug/start`(API-30):参数 `context, label, adapter, request, configuration`,结果 `{sessionID}`,在本插件自己的适配器上启动调试会话;`configuration` 不超过 256 KiB,`noDebug` 和 `__` 开头的键属于宿主,会被拒绝。通知 `lithe/host/run/didExit`:`{runID, exitCode?, signal?}`,用 `run/start` 启动的运行结束时发一次,宿主丢失进程时两个字段都没有。新增错误码 `plugin.host.toolUnresolved`(-32025):工具解析不到可执行文件。新增 fixtures `fixtures/run/`(含 `run-start.json`)和 `fixtures/debug-adapters/debug-start.json`。方法和通知是函数协议 3 内的增量,不升协议版本。`outputLinkMatchers` 和后端发起的运行与调试目前只在 macOS 实现。 | | [#1745](https://github.com/1lck/Lithe-IDEA/pull/1745) | [@xiaoyumuxi](https://github.com/xiaoyumuxi) | **`languageServers` 3**、**`serverProcess` 3**、函数协议 3:后端决定启动方式、语言服务器会话、内容 Provider(API-12、API-20、API-22、API-25) | `serverProcess` 3 新增后端解析的启动方式 `launch.kind: "backend"`:语言服务器的命令行由插件后端函数每次给出,只有语言服务器能用,需要 `backend` 2 和 `pluginAPIVersion` 不低于 4;新增 fixtures `fixtures/languages/language-server-backend-launch.plugin.json`、`fixtures/languages/language-server-launch-resolution.json`。`languageServers` 3 新增插件后端与自己的服务器交谈所用的会话字段:`requestMethods`、`commands`、`serializedCommands`、`forwardNotifications`、`state`、`remote`。新增权限 `languageServer.request`(需要 `backend` 2;只在 `languageServers` 3 的服务器列出 `requestMethods` 或 `forwardNotifications` 时才可声明),只授予本插件自己服务器的 `lithe/host/languageServer/*` 方法和两个通知;新增权限 `documents.contentProvider`(需要 `backend` 2),只授予 `lithe/host/documents/registerContentProvider`,不授予读写文档。新宿主方法均带 `context` 和 `serverID`,`serverID` 是本插件某个服务器的 `languageID`,别的插件的服务器返回 `plugin.host.foreignContribution`:`lithe/host/languageServer/request`(API-25)参数加 `method`、`params?`、`timeoutMilliseconds?`(1–300000,默认 60000),结果 `{value}`,向本插件自己的服务器发请求,受会话策略检查,第 129 个等待中的请求返回 `plugin.host.busy`;`lithe/host/languageServer/notify`(API-25)参数加 `method`、`params?`,结果 `{delivered}`;`lithe/host/languageServer/setStatus`(API-22、API-25)参数加 `state`(`preparing`、`ready`、`degraded`、`failed`)、`message?`、`percentage?`(0–100 整数),结果 `{}`,在宿主状态界面显示插件自己的准备状态(导入工程、建索引);`lithe/host/documents/registerContentProvider`(API-12)参数 `{context, scheme, function}`,结果 `{subscriptionID}`,打开该 scheme 的文档时宿主调用函数取只读内容(不超过 1 MiB,宿主等 30 秒),每个工作区窗口每个 scheme 只能有一个 Provider,用 `subscriptions/dispose` 撤销。新宿主通知(权限 `languageServer.request`,API-25):`lithe/host/languageServer/didChangeState`(`{context, serverID, state, generation}`,`state` 为 `starting`、`running`、`stopped`、`crashed`)和 `lithe/host/languageServer/didReceiveNotification`(`{context, serverID, generation, method, params?, truncated?}`,转发服务器发出且 `forwardNotifications` 列出的通知,超过 `limits.notificationBytes` 时只送一次 `truncated: true`,不带 `params`)。新增错误码:`plugin.host.languageServerUnavailable`(-32022,服务器未运行)、`plugin.host.languageServerError`(-32023,服务器返回错误)、`plugin.host.methodNotDeclared`(-32024,方法未在会话里声明)、`plugin.host.foreignContribution`(-32026)。语言服务器按工作区的状态目录按 `state.retention` 后台回收(API-20,由 Core 决定)。方法和通知是函数协议 3 内的增量,不升协议版本。仅 macOS;远程后端启动未支持;Windows 不声明 `backend`。 | -| [#1742](https://github.com/1lck/Lithe-IDEA/pull/1742) | [@xiaoyumuxi](https://github.com/xiaoyumuxi) | **`pluginFunctions` 1**、**函数协议 4**(API-35) | 插件函数跨插件中转的第一阶段:由 Core 中转调用,不是安装依赖。清单顶层新增 `pluginFunctions` 段,含导出 `exports` 和调用声明 `calls`;需要 `pluginAPIVersion` 不低于 3,以及 `requires.pluginFunctions: 1`、`backend: 2`、`serverProcess: 1`;不授予任何清单权限。函数协议 4 保留版本 3 的全部内容,新增两个宿主方法:`lithe/host/functions/providers`,参数 `{functionID}`,结果 `{providers:[{pluginID,version}]}`,列出导出该函数、已启用且不是调用方自己的插件,按插件 ID 排序,调用方必须声明过该函数,否则 `plugin.function.notDeclared`;`lithe/host/functions/invoke`,参数 `{pluginID, functionID, context:{contextID,generation}, params?, timeoutMilliseconds?, parentOperationID?}`,结果 `{value}`,Core 以 `lithe/function/invoke` 转给被调方,带顶层 `caller` 和 `operationID`。`timeoutMilliseconds` 默认 60000、上限 600000;未定义的字段返回 `-32018 plugin.host.unsupportedOption`;`parentOperationID` 为 1–128 字节字符串,否则 `-32602 plugin.host.invalidParams`。两个方法只对协商到协议 4 且声明了至少一个 call 的后端开放:协议 3 及以下返回 `-32601 plugin.host.unavailable`,未声明 call 返回 `-32010 plugin.host.permissionDenied`;`getCapabilities` 在 `functions` 服务下列出。新增错误码 `plugin.function.notDeclared`、`notExported`、`providerDisabled`、`versionMismatch`、`callCycle`、`depthExceeded`、`tooManyCalls`、`payloadTooLarge`、`providerUnavailable`。信任不传递:被调方用自己的权限执行,调用方不获得任何权限;不参与安装闭包,不做版本求解;远程工作区拒绝。新增 fixtures `fixtures/backend/plugin-functions-caller.plugin.json`、`fixtures/backend/plugin-functions-exporter.plugin.json`,`fixtures/backend/function-protocol.json` 增补用例。macOS 中转调用,Windows 不声明 `pluginFunctions`。官方 Node、Java、Rust SDK 目前只协商函数协议 1–3,还不能调用 `functions/*`。 | +| [#1742](https://github.com/1lck/Lithe-IDEA/pull/1742) | [@xiaoyumuxi](https://github.com/xiaoyumuxi) | **`pluginFunctions` 1**、**函数协议 4**(API-35) | 插件函数跨插件中转的第一阶段:由 Core 中转调用,不是安装依赖。清单顶层新增 `pluginFunctions` 段,含导出 `exports` 和调用声明 `calls`;需要 `pluginAPIVersion` 不低于 3,以及 `requires.pluginFunctions: 1`、`backend: 2`、`serverProcess: 1`;不授予任何清单权限。函数协议 4 保留版本 3 的全部内容,新增两个宿主方法:`lithe/host/functions/providers`,参数 `{functionID}`,结果 `{providers:[{pluginID,version}]}`,列出导出该函数、已启用且不是调用方自己的插件,按插件 ID 排序,调用方必须声明过该函数,否则 `plugin.function.notDeclared`;`lithe/host/functions/invoke`,参数 `{pluginID, functionID, context:{contextID,generation}, params?, timeoutMilliseconds?, parentOperationID?}`,结果 `{value}`,Core 以 `lithe/function/invoke` 转给被调方,带顶层 `caller` 和 `operationID`。`timeoutMilliseconds` 默认 60000、上限 600000;未定义的字段返回 `-32018 plugin.host.unsupportedOption`;`parentOperationID` 为 1–128 字节字符串,否则 `-32602 plugin.host.invalidParams`。两个方法只对协商到协议 4 且声明了至少一个 call 的后端开放:协议 3 及以下返回 `-32601 plugin.host.unavailable`,未声明 call 返回 `-32010 plugin.host.permissionDenied`;`getCapabilities` 在 `functions` 服务下列出。新增错误码 `plugin.function.notDeclared`、`notExported`、`providerDisabled`、`versionMismatch`、`callCycle`、`depthExceeded`、`tooManyCalls`、`payloadTooLarge`、`providerUnavailable`。信任不传递:被调方用自己的权限执行,调用方不获得任何权限;不参与安装闭包,不做版本求解;远程工作区拒绝。新增 fixtures `fixtures/backend/plugin-functions-caller.plugin.json`、`fixtures/backend/plugin-functions-exporter.plugin.json`,`fixtures/backend/function-protocol.json` 增补用例。macOS 中转调用,Windows 不声明 `pluginFunctions`。官方 SDK 从 Node 0.4.0、Java 0.4.0、Rust 0.3.0 起支持函数协议 4,见 [SDK 发版历史](capabilities-and-versions.md#sdk-发版历史)。 | | [#1730](https://github.com/1lck/Lithe-IDEA/pull/1730) | [@xiaoyumuxi](https://github.com/xiaoyumuxi) | 函数协议 3:编辑器 Provider、编辑器动作第 2 版(API-25、API-27) | 新增权限 `editor.providers`(需要 `backend` 2 和 `documents.read`),只授予宿主方法 `lithe/host/editor/registerProviders`,不授予文档编辑。macOS 路由 7 种类型:`definition`、`hover`、`completion`、`codeLens`、`foldingRange`、`semanticTokens`、`documentLink`;语言服务器优先,插件结果只补充。宿主在 `services.editor.options.providerTypes` 里列出可用类型,类型不在其中时返回 `unsupportedOption`。编辑器动作第 2 版:`services.editor.options.actionIcons` 列出宿主绘制的图标(macOS 绘制 9 个),动作可带最多 4 个 `alternatives`,点击结果可带 `reveal`;宿主未报告 `actionIcons` 时只接受第一版形状。`limits.editorActionSourceBytes` 给出发给收集函数的文档上限(macOS 1 MiB,宿主未报告时 128 KiB,此前固定 128 KiB)。新增 3 个 fixtures(`editor-providers.plugin.json`、`editor-providers.json`、`editor-actions-v2.json`)和 1 条清单拒绝用例(声明 `editor.providers` 但缺 `documents.read`)。这是协议 3 内的增量方法,不升版本号;旧宿主不认识 `editor.providers`,会拒绝该包。 | | [#1752](https://github.com/1lck/Lithe-IDEA/pull/1752) | [@xiaoyumuxi](https://github.com/xiaoyumuxi) | `litheToolchains` 1:写入正式契约 | 删除工具链设计草案 `toolchains/proposal/` 和 `fixtures/toolchains/proposal/`。草案里已实现的部分写进正式契约 `toolchains/README.md`:新增“Provider backend negotiation”一节(`initialize` 里的 `experimental.litheToolchains` 协商、`lsp.toolchainRequest`、超时)和“Error replies”小节(错误回复格式与 11 个稳定失败码的数字码对照)。Core 测试改用内联数据 `toolchain_vectors.rs`。协议行为没有变化。 | | [#1740](https://github.com/1lck/Lithe-IDEA/pull/1740) | [@xiaoyumuxi](https://github.com/xiaoyumuxi) | **`projectGenerators` 2**(API-33) | 生成器新增 `tool` 字段:从插件声明的工具定义的已有安装里选一个(`tool`、`minimumVersion`、`pinAs`、`optional`),模板可用 `${field..executable}`;`pinAs: "project"` 时计划返回 `toolPins`,宿主把它存为新项目的工具固定。表单输入加 `tools`,结果字段带 `installations`,新增问题码 `toolTooOld`;目录条目带 `tools`(声明该工具的插件)。版本 2 不能用 `runtime` 字段,版本 1 不能用 `tool` 字段。macOS 声明 1、2,Windows 不声明。新增示例 `project-generator-tool-field.plugin.json`、12 个计划用例和 9 个清单拒绝用例。 | diff --git a/sdk/java/README.md b/sdk/java/README.md index abacbea..d85aea2 100644 --- a/sdk/java/README.md +++ b/sdk/java/README.md @@ -11,11 +11,13 @@ same rules and error mapping: - **`litheBuild` 1**: typed `lithe/build/model` and `lithe/build/dependencies` requests, for a build system whose `model` or `dependencies` source is `provider`. -- **`litheFunctions` 1–3**: plugin functions, which are plugin-private actions +- **`litheFunctions` 1–4**: plugin functions, which are plugin-private actions that Lithe invokes for the same plugin. They support progress, cancellation, and registration at runtime. From version 2 a backend may call [host services](#host-services); version 3 adds workspace, document, - editor, and window services, command events, and workspace contexts. + editor, and window services, command events, and workspace contexts; + version 4 adds [calls to other plugins' functions](#calling-other-plugins-protocol-4), + which Core relays, and tells a function which plugin called it. - **`litheKeymapImport` 1** (from 0.3.0): independently negotiated shortcut import providers reading only explicitly selected source snapshots. @@ -72,6 +74,11 @@ Releases are published on [this repository's Releases page](https://github.com/L tagged `sdk-java-v`. Version 0.2.0 adds plugin-function protocols 2 and 3. Version 0.3.0 adds shortcut import providers, native terminals, and typed diagnostics through `litheLogging` 1, and removes the free-text `log` API. +Version 0.4.0 adds plugin-function protocol 4: `host().functions()` for calls +to other plugins, `CallContext.caller()` for the plugin that called a function, +and `HostError.pluginErrorCode()`. The `HostError` fallback table, which names +a refusal by its number when the host sends no code, now also holds the host +error codes -32022 to -32026 and the `plugin.function.*` codes of protocol 4. Version 0.1.0 was released from the Lithe repository before the SDK moved here; its [release](https://github.com/1lck/Lithe-IDEA/releases/tag/sdk-java-v0.1.0) @@ -112,8 +119,8 @@ there is no stderr fallback or plugin-owned file writer. | `negotiated().buildVersion()` | Highest common build version (1–6), or zero; newer methods are refused before their handler runs. | | `function(id, handler)` | Declares a function offered in `initialize`. | | `registerFunction(id, handler)`, `unregisterFunction(id)` | Add or withdraw a function. Called before the `initialize` answer (for example from `onInitialize`), they change the initial list. Later calls send `lithe/function/register` or `lithe/function/unregister`, in order. Both return a `CompletableFuture`. Withdrawing a function revokes its running calls. | -| `negotiated().functionsVersion()` | Highest common plugin-function version (1–3), or zero. | -| `host()` | [Host services](#host-services) for work outside a call, such as a command handler. | +| `negotiated().functionsVersion()` | Highest common plugin-function version (1–4), or zero. | +| `host()` | [Host services](#host-services) for work outside a call, such as a command handler. Its calls to other plugins name no parent call. | | `onCommand(commandId, handler)` | Protocol 3: runs `handler.handle(commandId, workspaceContext)` when the user runs one of the plugin's commands. | | `onContextsChanged(handler)` | Protocol 3: `lithe/host/workspace/didChangeContexts` with its `{ revision }`. | | `onHostNotification(method, handler)` | A `lithe/host/*` notification a later host adds. The three this SDK routes itself are reserved. | @@ -135,9 +142,10 @@ Handlers run on their own threads. Every handler receives a `CallContext`: | `run(command[, options])` | `Processes.run` bound to the call: cancelling or ending the call stops the process. | | `diagnostics()` | Typed diagnostics correlated with this call. | | `functionId()` | Functions only. | -| `host()` | [Host services](#host-services) bound to the call: ending the call cancels the requests it left unanswered. | +| `host()` | [Host services](#host-services) bound to the call: ending the call cancels the requests it left unanswered. From protocol 4 its calls to other plugins name this call as their parent. | | `workspaceContext()` | Protocol 3 functions: the `{ contextID, generation }` of the window that invoked the call, or null. | -| `operationId()` | Opaque wire identity for functions and bounded build/test/run-preparation calls; null for other requests. | +| `caller()`, `callerPluginId()` | Protocol 4 functions: the plugin that called this function through the relay, as an unmodifiable `{ pluginID }` map and as its ID. Null when the host started the call or the session negotiated protocol 3 or earlier. | +| `operationId()` | Opaque wire identity for functions and bounded build/test/run-preparation calls; null for other requests. It falls back to the JSON-RPC request ID, so never use it as a `parentOperationID`. | | `progress(message, percentage[, output])` | Functions use 1024-byte messages. Build/test/run preparation use 4096-byte messages and optional output up to 64 KiB. Percentages are clamped to 0–100. Cancelled or settled calls cannot report. Model/dependency/discovery reports are ignored. | The typed handlers of build protocols 2–6 ship from SDK 0.2.0. See [native build workflows](../../guides/native-build-workflows.md) @@ -196,6 +204,7 @@ backend.onCommand("db.refresh", (commandId, workspaceContext) -> { | `workspace().readDirectory`, `.stat`, `.readFile`, `.findFiles`, `.findText`, `.watch` | 3 | Files of a local context, addressed as `{ rootID, path }` from `getFolders`. | | `documents().list`, `.getSnapshot`; `editor().getActive`, `.getSelections`, `.applyEdits`, `.replaceSelections`, `.insertText`, `.open`, `.reveal`, `.registerActions` | 3 | Live documents and editors. Edits name the `expectedVersion` they were computed against. | | `preview().openHtml`, `browser().openExternal`, `window().showInformation`, `.showWarning`, `.showError` | 3 | Preview, browser, and messages. | +| `functions().providers(functionId)`, `.invoke(context, call)` | 4 | [Calls to other plugins' functions](#calling-other-plugins-protocol-4), relayed by Core. | | `request(method, params)`, `subscribe(method, params, listener)` | 2 / 3 | Any `lithe/host/*` method, including ones a later host adds. | | `subscriptions().dispose(id)` | 3 | Ends a subscription; `Subscription.dispose()` does the same. | | `withCancellation(cancellation)` | 2 | The same services, cancelled together with `cancellation`. | @@ -232,6 +241,86 @@ What the SDK does for you, and what it leaves to you: plugin's commands; the host waits for no result. A stopped backend is started for it only when the manifest declares `onCommand:`. +## Calling other plugins (protocol 4) + +From plugin-function protocol 4 a backend may call a function that another +plugin exports. The plugin must declare the call in its manifest, and Core +relays it: the backend never talks to the other plugin. A plugin that declares +no call gets `plugin.host.permissionDenied`. The exports, the declaration and +the checks Core makes are in the +[contract](../../contract/v1/README.md#cross-plugin-calls-protocol-version-4). + +```java +backend.function("project.sync", (params, context) -> { + Host.Functions functions = context.host().functions(); + Object providers = functions.providers("languageImport.settings").get().get("providers"); + // ... choose a provider ... + Map call = new LinkedHashMap<>(); + call.put("pluginID", "dev.example.go"); + call.put("functionID", "languageImport.settings"); + call.put("params", Collections.singletonMap("project", "app")); + call.put("timeoutMilliseconds", 5000L); + // Core decides who may be called for which workspace; use the one the user's call ran in. + Object settings = functions.invoke(context.workspaceContext(), call).get().get("value"); + return Collections.singletonMap("settings", settings); +}); +``` + +| Member | Purpose | +| --- | --- | +| `functions().providers(functionId)` | Completes with `{ providers: [{ pluginID, version }] }`: the enabled plugins other than yours that export the function, sorted by plugin ID. You must have declared the function. | +| `functions().invoke(context, call)` | Completes with `{ value }`, the provider's result (`value` may be null). `context` is a workspace context, `{ contextID, generation }` or a whole one from `listContexts`; the call runs for that workspace. `call` is a map with `pluginID` and `functionID`, and optionally `params` (a map, default `{}`), `timeoutMilliseconds` (a positive integer; Core defaults it to 60 000 and caps it at 600 000) and `parentOperationID`. | +| `CallContext.caller()`, `.callerPluginId()` | On the providing side: the plugin that called this function, as `{ pluginID }` and as its ID. Core writes it from the calling session. Null when the host started the call, and on sessions that negotiated protocol 3 or earlier. | +| `HostError.pluginErrorCode()` | For `plugin.function.failed`, the provider's own error code (from `details.pluginErrorCode`); null otherwise. | + +What the SDK does for you, and what it leaves to you: + +- **The relay needs protocol 4.** On a session that negotiated protocol 3 or + earlier, `providers`, `invoke`, and a `request` for either method fail at + once with `HostError` `plugin.host.unavailable` (-32601), the answer Core + would give, and nothing is sent. Check `negotiated().functionsVersion()` or + `supports(method)` first. +- **The parent call is filled in for you.** Inside a function, `context.host()` + sets `parentOperationID` to the wire `operationID` of the function call it + belongs to, so Core knows which call the new one is a child of. The ID is + used only when it is a string of 1 to 128 bytes; otherwise the field is + omitted. The SDK never uses `operationId()` for this, because it falls back + to the JSON-RPC request ID. A `parentOperationID` you put in `call` wins. + `withCancellation` keeps the parent. `Backend.host()`, which belongs to no + call, names none. Without a parent Core treats the new call as a child of + every relayed call the backend is serving. +- **Wrong arguments fail locally.** A malformed call completes the future + with `PluginSdkError` `sdk.functions.invalidCall` (a missing or wrong + workspace context is `sdk.host.invalidContext`) and sends nothing: a + `functionID` that is not a function ID, an empty `pluginID`, `params` that + is not a map, a `timeoutMilliseconds` that is not a positive integer, or a + `parentOperationID` that is not 1 to 128 bytes of text. A null optional + field counts as absent. Only the fields above are sent; any other key, + `caller` included, is dropped, because Core refuses a caller-chosen + `caller`. The SDK does not cap `timeoutMilliseconds`; Core does. +- **Refusals are `HostError`.** Besides the host codes, protocol 4 adds + `plugin.function.notDeclared`, `.notExported`, `.providerDisabled`, + `.versionMismatch`, `.callCycle`, `.depthExceeded`, `.tooManyCalls`, + `.payloadTooLarge`, `.providerUnavailable`, `.failed` and `.invalidResult`. + `plugin.function.failed` carries the provider's own code in + `pluginErrorCode()`. A call to a context the host closed fails with + `plugin.host.contextExpired`; a remote workspace with + `plugin.host.authorityUnsupported`; a deadline with `plugin.host.timeout`. + Core never retries a call, and neither does the SDK. +- **Calls end with their caller.** Ending or cancelling the function call that + made a relayed call, or cancelling the token given to `withCancellation`, + fails the future with `CancelledException` and sends `$/cancelRequest`, so + Core cancels the provider's operation and the calls it made. +- **Limits are Core's.** A chain has at most 4 calls, and a plugin already on + it, the caller included, cannot be called (`callCycle`): a provider asked to + apply values must not call the asker back inside that call. A caller has at + most 8 relayed calls running; params and results are at most 256 KiB. Only + local workspaces are supported. A relayed call holds one of the 16 + unanswered host-request slots until it ends, which may be 600 seconds, so a + backend that starts many long calls delays its other host requests. +- **Decide by `caller()`, never by params.** A plugin ID in `params` is data + the caller wrote; `context.caller()` is the identity of the calling session. + ## Rules that keep Lithe in charge These are the [Node.js SDK's rules](../node/README.md#rules-that-keep-lithe-in-charge). diff --git a/sdk/java/examples/sample-build/pom.xml b/sdk/java/examples/sample-build/pom.xml index f9e7bb9..dde6eeb 100644 --- a/sdk/java/examples/sample-build/pom.xml +++ b/sdk/java/examples/sample-build/pom.xml @@ -7,7 +7,7 @@ io.github.lithe-idea lithe-plugin-sdk-java-parent - 0.3.0 + 0.4.0 ../../pom.xml diff --git a/sdk/java/examples/sample-native-build/pom.xml b/sdk/java/examples/sample-native-build/pom.xml index d599197..1e83830 100644 --- a/sdk/java/examples/sample-native-build/pom.xml +++ b/sdk/java/examples/sample-native-build/pom.xml @@ -7,7 +7,7 @@ io.github.lithe-idea lithe-plugin-sdk-java-parent - 0.3.0 + 0.4.0 ../../pom.xml diff --git a/sdk/java/plugin-sdk/pom.xml b/sdk/java/plugin-sdk/pom.xml index 04d604d..ed65e74 100644 --- a/sdk/java/plugin-sdk/pom.xml +++ b/sdk/java/plugin-sdk/pom.xml @@ -7,7 +7,7 @@ io.github.lithe-idea lithe-plugin-sdk-java-parent - 0.3.0 + 0.4.0 lithe-plugin-sdk-java diff --git a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Backend.java b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Backend.java index 15bee55..1c8bab8 100644 --- a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Backend.java +++ b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Backend.java @@ -35,7 +35,7 @@ * then call {@link #listen()}. Handlers run on their own threads, so several * calls can be in flight at once; shared plugin state needs its own locking. * - *

Host services ({@code lithe/host/*}) of plugin-function protocols 2 and 3 + *

Host services ({@code lithe/host/*}) of plugin-function protocols 2 to 4 * are in {@link #host()}, or a handler's {@link CallContext#host()}. * *

Contract: {@code contract/v1/README.md} ("Build providers" @@ -168,9 +168,10 @@ public boolean functions() { } /** - * Selected plugin-function version (1-3), or zero when unavailable. + * Selected plugin-function version (1-4), or zero when unavailable. * Version 2 adds host credentials; version 3 workspace contexts, the - * other host services, and host notifications. + * other host services, and host notifications; version 4 calls to + * other plugins' functions and the calling plugin of a function call. */ public int functionsVersion() { return functionsVersion; @@ -227,7 +228,7 @@ private enum Phase { IDLE, LISTENING, INITIALIZING, READY, SHUTTING_DOWN, EXITED private final AtomicInteger nextRequestId = new AtomicInteger(1); private final ExecutorService executor = Executors.newCachedThreadPool(new ThreadNamer()); private final HostChannel channel = new HostChannel(new Wire()); - private final Host host = new Host(channel, Collections.emptyList()); + private final Host host = new Host(channel, Collections.emptyList(), null); /** Serializes register/unregister so the host sees them in call order. */ private CompletableFuture registrations = CompletableFuture.completedFuture(null); private volatile Phase phase = Phase.IDLE; @@ -277,9 +278,10 @@ public Negotiated negotiated() { } /** - * Host services of plugin-function protocols 2 and 3, usable once the + * Host services of plugin-function protocols 2 to 4, usable once the * session is ready. Prefer a handler's {@link CallContext#host()}, whose - * requests end with the call. + * requests end with the call; this one belongs to no call, so its calls to + * other plugins name no parent call unless the call says one. */ public Host host() { return host; @@ -938,7 +940,7 @@ private void invokeKeymap(Object id, Object raw) { return; } run(id, null, null, null, context -> handler.handle(new KeymapSnapshot((String) provider, (String) snapshot, context.host()), context), - Backend::functionResult, null, null, "lithe/keymap/import"); + Backend::functionResult, null, null, "lithe/keymap/import", null, null); } private static boolean validKeymapProvider(String id) { @@ -959,6 +961,7 @@ private void invokeFunction(Object id, Object raw) { Object operationId = invocation.get("operationID"); Object params = invocation.get("params"); Map workspaceContext = workspaceContext(invocation.get("context")); + Map caller = callerReference(invocation.get("caller")); Handler handler; synchronized (lock) { handler = negotiated.functions() && functionId instanceof String ? functions.get(functionId) : null; @@ -991,8 +994,10 @@ private void invokeFunction(Object id, Object raw) { notification.put("params", report); write(notification); }; + // The call's wire operation ID is the parent of the calls it makes to other plugins. + String parentOperationId = Protocol.isParentOperationId(operation) ? operation : null; run(id, function, operation, workspaceContext, context -> handler.handle(arguments, context), - Backend::functionResult, () -> reporting.set(false), progress); + Backend::functionResult, () -> reporting.set(false), progress, function, caller, parentOperationId); } private void invokeBuild(Object id, String method, Map arguments, Handler handler) { @@ -1001,7 +1006,8 @@ private void invokeBuild(Object id, String method, Map arguments return; } if (!Protocol.buildProgress(method)) { - run(id, null, null, null, context -> handler.handle(arguments, context), result -> buildResult(method, result), null, null, method); + run(id, null, null, null, context -> handler.handle(arguments, context), result -> buildResult(method, result), null, null, method, + null, null); return; } Object wireId = arguments.get("operationID"); @@ -1028,7 +1034,7 @@ public void report(String text, Double percentage, String output) { } }; run(id, null, (String) wireId, null, context -> handler.handle(arguments, context), - result -> buildResult(method, result), () -> reporting.set(false), progress, method); + result -> buildResult(method, result), () -> reporting.set(false), progress, method, null, null); } private interface Invocation { @@ -1046,11 +1052,18 @@ private void run(Object id, String functionId, String operationId, Invocation in /** Runs one handler with its own cancellation and answers exactly once. */ private void run(Object id, String functionId, String operationId, Map workspaceContext, Invocation invoke, Shape shape, Runnable onSettled, CallContext.ProgressSink progress) { - run(id, functionId, operationId, workspaceContext, invoke, shape, onSettled, progress, functionId == null ? "function-call" : functionId); + run(id, functionId, operationId, workspaceContext, invoke, shape, onSettled, progress, + functionId == null ? "function-call" : functionId, null, null); } + /** + * {@code caller} is the plugin that called a function through the relay and + * {@code parentOperationId} the wire operation ID calls to other plugins + * name as their parent; both are null for every call but a function call. + */ private void run(Object id, String functionId, String operationId, Map workspaceContext, - Invocation invoke, Shape shape, Runnable onSettled, CallContext.ProgressSink progress, String diagnosticOperation) { + Invocation invoke, Shape shape, Runnable onSettled, CallContext.ProgressSink progress, String diagnosticOperation, + Map caller, String parentOperationId) { long started = System.nanoTime(); String diagnosticOperationId = Diagnostics.requestOperationId(operationId, id); Cancellation cancellation = new Cancellation(() -> diagnostics().degraded("notification", "cancellation_listener_failed")); @@ -1059,7 +1072,7 @@ private void run(Object id, String functionId, String operationId, Map { Object result = null; Map error = null; @@ -1475,6 +1488,23 @@ private static Map workspaceContext(Object value) { return Protocol.isContextReference(value) ? Host.contextReference(Json.object(value)) : null; } + /** + * The {@code { pluginID }} of the plugin that called a function through the + * relay, or null. Core adds it on protocol 4 only, and only as exactly + * {@code { pluginID }}; anything else is not a caller. + */ + private Map callerReference(Object value) { + if (negotiated.functionsVersion() < Protocol.RELAY_VERSION || !(value instanceof Map)) { + return null; + } + Map caller = (Map) value; + Object pluginId = caller.get("pluginID"); + if (caller.size() != 1 || !(pluginId instanceof String) || ((String) pluginId).isEmpty()) { + return null; + } + return Collections.singletonMap("pluginID", pluginId); + } + /** The session's wire, for host services. */ private final class Wire implements HostChannel.Transport { @Override diff --git a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/CallContext.java b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/CallContext.java index ceffda4..19e8ed7 100644 --- a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/CallContext.java +++ b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/CallContext.java @@ -24,9 +24,10 @@ interface ProgressSink { private final ProgressSink progress; private final Host host; private final Map workspaceContext; + private final Map caller; CallContext(Cancellation cancellation, Backend backend, String functionId, String operationId, ProgressSink progress, - Host host, Map workspaceContext) { + Host host, Map workspaceContext, Map caller) { this.cancellation = cancellation; this.backend = backend; this.functionId = functionId; @@ -34,6 +35,7 @@ interface ProgressSink { this.progress = progress; this.host = host; this.workspaceContext = workspaceContext; + this.caller = caller; } /** @@ -92,7 +94,12 @@ public String functionId() { return functionId; } - /** The wire operation ID of a function or bounded build call, otherwise null. */ + /** + * The wire operation ID of a function or bounded build call, otherwise null. + * It identifies the call in diagnostics and, when the request has no usable + * operation ID, falls back to the JSON-RPC request ID: do not use it as a + * {@code parentOperationID}. {@link #host()} already sends the right one. + */ public String operationId() { return operationId; } @@ -116,4 +123,21 @@ public Host host() { public Map workspaceContext() { return workspaceContext; } + + /** + * The plugin that called this function through the relay (protocol 4): an + * unmodifiable {@code { pluginID }}, written by Core from the calling + * session. Null when the host started the call, when the session negotiated + * protocol 3 or earlier, and for calls that are not function invocations. + * Decide by who asks from this, never from a plugin ID in the params, + * which any caller can write. + */ + public Map caller() { + return caller; + } + + /** The {@code pluginID} of {@link #caller()}, or null when there is no calling plugin. */ + public String callerPluginId() { + return caller == null ? null : (String) caller.get("pluginID"); + } } diff --git a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Host.java b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Host.java index 5e67d63..b22989a 100644 --- a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Host.java +++ b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Host.java @@ -10,11 +10,12 @@ import java.util.function.BooleanSupplier; /** - * Host services ({@code lithe/host/*}) of plugin-function protocols 2 and 3: + * Host services ({@code lithe/host/*}) of plugin-function protocols 2 to 4: * credentials from version 2; workspace contexts, files, documents, the * editor, previews, the browser, messages, and capability discovery from - * version 3. Every method answers with a future and never blocks; failures - * the host reports arrive as {@link HostError}. + * version 3; calls to other plugins' functions from version 4. Every method + * answers with a future and never blocks; failures the host reports arrive as + * {@link HostError}. * *

{@link Backend#host()} is usable once the session is ready. A handler's * {@link CallContext#host()} belongs to its call: requests still unanswered @@ -97,6 +98,8 @@ public CompletableFuture dispose() { private final HostChannel channel; private final List scope; + /** The wire operation ID of the function call this Host belongs to, or null; the default parent of {@link Functions#invoke}. */ + private final String parentOperationId; private final Credentials credentials = new Credentials(); private final Subscriptions subscriptions = new Subscriptions(); private final Workspace workspace = new Workspace(); @@ -105,10 +108,12 @@ public CompletableFuture dispose() { private final Preview preview = new Preview(); private final Browser browser = new Browser(); private final Window window = new Window(); + private final Functions functions = new Functions(); - Host(HostChannel channel, List scope) { + Host(HostChannel channel, List scope, String parentOperationId) { this.channel = channel; this.scope = Collections.unmodifiableList(new ArrayList<>(scope)); + this.parentOperationId = parentOperationId; } /** The negotiated plugin-function version, or 0 before or without one. */ @@ -118,7 +123,8 @@ public int version() { /** * The same services, with requests also cancelled by {@code cancellation}, - * for work a plugin may stop for its own reasons. + * for work a plugin may stop for its own reasons. Calls to other plugins + * keep the parent call of this Host. */ public Host withCancellation(Cancellation cancellation) { if (cancellation == null) { @@ -126,7 +132,7 @@ public Host withCancellation(Cancellation cancellation) { } List combined = new ArrayList<>(scope); combined.add(cancellation); - return new Host(channel, combined); + return new Host(channel, combined, parentOperationId); } /** @@ -197,6 +203,11 @@ public Window window() { return window; } + /** Calls to functions other plugins export (protocol 4). */ + public Functions functions() { + return functions; + } + /** * {@code { contextID, generation }} of a context reference or of a full * context from {@code listContexts} or {@code getContext}, so either can be @@ -471,6 +482,115 @@ public CompletableFuture> showError(Map context, } } + /** + * Calls to functions other plugins export, relayed by Core (protocol 4). + * The plugin's manifest must declare each function it calls. Both methods + * fail with a {@link HostError} {@code plugin.host.unavailable} before + * protocol 4, without sending anything, and a malformed argument fails + * with {@code sdk.functions.invalidCall} (a malformed context with + * {@code sdk.host.invalidContext}), also without sending anything. + * + *

A provider's own failure arrives as a {@link HostError} with code + * {@code plugin.function.failed}; {@link HostError#pluginErrorCode()} is + * its error code. Other relay failures have their own + * {@code plugin.function.*} codes, see {@link HostError#CODES}. + */ + public final class Functions { + private Functions() {} + + /** + * {@code { providers: [{ pluginID, version }] }}: the enabled plugins + * other than this one that export {@code functionId}, sorted by plugin + * ID. A listed provider's backend need not be running. + */ + public CompletableFuture> providers(String functionId) { + if (!Protocol.isFunctionId(functionId)) { + return failed(invalidCall("A functionID is dot-separated lowerCamelCase segments of at most 128 bytes.")); + } + return object(Protocol.HOST_FUNCTIONS_PROVIDERS, channel.request( + Protocol.HOST_FUNCTIONS_PROVIDERS, params("functionID", functionId), scope)); + } + + /** + * Calls one provider's exported function in the workspace {@code context} + * (a reference from {@link CallContext#workspaceContext()}, or a context + * from {@code workspace().listContexts()}) and answers {@code { value }}, + * the provider's result, where {@code value} may be null. Only the + * fields listed here are sent. {@code call}: + *

    + *
  • {@code pluginID}: the provider, a non-empty string; required;
  • + *
  • {@code functionID}: the exported function; required;
  • + *
  • {@code params}: an object, omitted when absent;
  • + *
  • {@code timeoutMilliseconds}: a positive integer; Core defaults to + * 60 000 and caps it at 600 000;
  • + *
  • {@code parentOperationID}: a string of 1-128 bytes. When absent + * and this Host belongs to a function call, that call's wire operation + * ID is sent, so Core knows the new call belongs to it; otherwise the + * field is omitted and Core counts the new call as a child of every + * relayed call this backend is serving.
  • + *
+ * When the call this Host belongs to is cancelled or ends, the relayed + * call is cancelled on the wire and the future fails with + * {@link CancelledException}. + */ + public CompletableFuture> invoke(Map context, Map call) { + Map message; + try { + message = relayParams(context, call); + } catch (PluginSdkError error) { + return failed(error); + } + return object(Protocol.HOST_FUNCTIONS_INVOKE, channel.request(Protocol.HOST_FUNCTIONS_INVOKE, message, scope)); + } + } + + /** The wire params of a relayed call: the listed fields of {@code call}, never another one. */ + private Map relayParams(Map context, Map call) { + if (call == null) { + throw invalidCall("A call is { pluginID, functionID, params?, timeoutMilliseconds?, parentOperationID? }."); + } + Object pluginId = call.get("pluginID"); + if (!(pluginId instanceof String) || ((String) pluginId).isEmpty()) { + throw invalidCall("A call's pluginID is a non-empty string."); + } + Object functionId = call.get("functionID"); + if (!(functionId instanceof String) || !Protocol.isFunctionId((String) functionId)) { + throw invalidCall("A functionID is dot-separated lowerCamelCase segments of at most 128 bytes."); + } + Map message = new LinkedHashMap<>(); + message.put("pluginID", pluginId); + message.put("functionID", functionId); + message.put("context", contextReference(context)); + Object params = call.get("params"); + if (params != null) { + if (!(params instanceof Map)) { + throw invalidCall("A call's params is an object."); + } + message.put("params", params); + } + Object timeout = call.get("timeoutMilliseconds"); + if (timeout != null) { + if (!Protocol.isGeneration(timeout)) { + throw invalidCall("A call's timeoutMilliseconds is a positive integer."); + } + message.put("timeoutMilliseconds", ((Number) timeout).longValue()); + } + Object parent = call.get("parentOperationID"); + if (parent == null) { + parent = parentOperationId; + } else if (!Protocol.isParentOperationId(parent)) { + throw invalidCall("A call's parentOperationID is a string of 1-128 bytes."); + } + if (parent != null) { + message.put("parentOperationID", parent); + } + return message; + } + + private static PluginSdkError invalidCall(String message) { + return new PluginSdkError("sdk.functions.invalidCall", message); + } + /** A method whose params name a workspace context. */ private CompletableFuture> scoped(String method, Map context, Map params) { Map message; diff --git a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/HostChannel.java b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/HostChannel.java index 274fbd4..a562144 100644 --- a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/HostChannel.java +++ b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/HostChannel.java @@ -13,7 +13,7 @@ import java.util.concurrent.RejectedExecutionException; /** - * The session's host-request state for plugin-function protocols 2 and 3. The + * The session's host-request state for plugin-function protocols 2 to 4. The * backend owns the wire; this class owns what Core expects of a well-behaved * client -- at most 16 unanswered host requests, cancellation through * {@code $/cancelRequest} from version 3, and subscription events routed by @@ -146,6 +146,10 @@ CompletableFuture request(String method, Object params, List CODES; private static final Map BY_RPC_CODE; @@ -40,6 +41,22 @@ public final class HostError extends PluginSdkError { codes.put("plugin.host.preconditionFailed", -32019); codes.put("plugin.host.busy", -32020); codes.put("plugin.host.rateLimited", -32021); + codes.put("plugin.host.languageServerUnavailable", -32022); + codes.put("plugin.host.languageServerError", -32023); + codes.put("plugin.host.methodNotDeclared", -32024); + codes.put("plugin.host.toolUnresolved", -32025); + codes.put("plugin.host.foreignContribution", -32026); + codes.put("plugin.function.notDeclared", -32027); + codes.put("plugin.function.notExported", -32028); + codes.put("plugin.function.providerDisabled", -32029); + codes.put("plugin.function.versionMismatch", -32030); + codes.put("plugin.function.callCycle", -32065); + codes.put("plugin.function.depthExceeded", -32066); + codes.put("plugin.function.tooManyCalls", -32067); + codes.put("plugin.function.payloadTooLarge", -32068); + codes.put("plugin.function.providerUnavailable", -32069); + codes.put("plugin.function.failed", -32070); + codes.put("plugin.function.invalidResult", -32071); CODES = Collections.unmodifiableMap(codes); Map byNumber = new HashMap<>(); for (Map.Entry entry : codes.entrySet()) { @@ -67,6 +84,19 @@ public String method() { return method; } + /** + * For {@code plugin.function.failed}, the provider's own + * {@code error.data.code} (from {@code details.pluginErrorCode}); null for + * any other failure or when the provider named none. + */ + public String pluginErrorCode() { + if (!"plugin.function.failed".equals(code()) || details == null) { + return null; + } + Object pluginErrorCode = details.get("pluginErrorCode"); + return pluginErrorCode instanceof String ? (String) pluginErrorCode : null; + } + /** * The failure for a JSON-RPC error answer. A host method without a known * code is a host failure: from version 3 a number names one code, and an diff --git a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Protocol.java b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Protocol.java index c4e2ff8..8638386 100644 --- a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Protocol.java +++ b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Protocol.java @@ -13,13 +13,15 @@ */ public final class Protocol { /** Protocol versions this SDK can answer: the highest of each. */ - public static final int FUNCTIONS_VERSION = 3; + public static final int FUNCTIONS_VERSION = 4; public static final int BUILD_VERSION = 6; /** First plugin-function version with host services (credentials). */ public static final int HOST_SERVICES_VERSION = 2; /** First plugin-function version with workspace contexts, discovery, and host notifications. */ public static final int CONTEXT_VERSION = 3; + /** First plugin-function version with cross-plugin calls through Core and the {@code caller} of a relayed call. */ + public static final int RELAY_VERSION = 4; public static final String INITIALIZE = "initialize"; public static final String INITIALIZED = "initialized"; @@ -40,7 +42,7 @@ public final class Protocol { public static final String BUILD_PROGRESS = "lithe/build/progress"; /** - * Backend-to-host requests of plugin-function protocols 2 and 3. From + * Backend-to-host requests of plugin-function protocols 2 to 4. From * version 3 the host adds methods without a new version; * {@link Host#request} sends any {@code lithe/host/*} method, and * {@code getCapabilities} says which ones the session may call. @@ -82,6 +84,10 @@ public final class Protocol { public static final String HOST_WINDOW_SHOW_WARNING = "lithe/host/window/showWarning"; public static final String HOST_WINDOW_SHOW_ERROR = "lithe/host/window/showError"; + /** Cross-plugin calls Core relays; they need plugin-function protocol 4 ({@link #RELAY_VERSION}). */ + public static final String HOST_FUNCTIONS_PROVIDERS = "lithe/host/functions/providers"; + public static final String HOST_FUNCTIONS_INVOKE = "lithe/host/functions/invoke"; + /** Host-to-backend notifications of plugin-function protocol 3. */ public static final String HOST_DID_CHANGE_CONTEXTS = "lithe/host/workspace/didChangeContexts"; public static final String HOST_SUBSCRIPTION_NOTIFY = "lithe/host/subscriptions/notify"; @@ -108,6 +114,11 @@ public final class Protocol { /** Unanswered host requests Core allows a session; the SDK queues the rest. */ public static final int MAX_PENDING_HOST_REQUESTS = 16; public static final int MAX_CREDENTIAL_SECRET_BYTES = 16 * 1024; + /** Timeout Core gives a relayed call that names none. */ + public static final int RELAY_DEFAULT_TIMEOUT_MILLISECONDS = 60000; + /** Longest timeout of a relayed call; the SDK sends a longer one unchanged and leaves the cap to Core. */ + public static final int RELAY_MAX_TIMEOUT_MILLISECONDS = 600000; + public static final int MAX_PARENT_OPERATION_ID_BYTES = 128; /** Minimum negotiated version; unknown methods can never pass admission. */ static int buildMethodVersion(String method) { @@ -170,6 +181,20 @@ public static boolean isContextReference(Object value) { return id instanceof String && isCredentialKey((String) id) && isGeneration(reference.get("generation")); } + /** A {@code parentOperationID}: a string of 1-128 UTF-8 bytes. */ + static boolean isParentOperationId(Object value) { + if (!(value instanceof String)) { + return false; + } + int bytes = utf8Length((String) value); + return bytes >= 1 && bytes <= MAX_PARENT_OPERATION_ID_BYTES; + } + + /** Whether {@code method} is one of the relay requests, which need protocol {@link #RELAY_VERSION}. */ + static boolean isRelayMethod(String method) { + return HOST_FUNCTIONS_PROVIDERS.equals(method) || HOST_FUNCTIONS_INVOKE.equals(method); + } + /** A command ID from the plugin's manifest: 1-128 of {@code [a-z0-9.-]}. */ public static boolean isCommandId(String value) { return value != null && COMMAND_ID.matcher(value).matches(); diff --git a/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/HostTest.java b/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/HostTest.java index 57b2024..ec2bc2b 100644 --- a/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/HostTest.java +++ b/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/HostTest.java @@ -31,7 +31,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Timeout; -/** Host services of plugin-function protocols 2 and 3; mirrors {@code sdk/node/test/host.test.mjs}. */ +/** Host services of plugin-function protocols 2 to 4; mirrors {@code sdk/node/test/host.test.mjs}. */ @Timeout(value = 30, unit = TimeUnit.SECONDS) class HostTest { private static final Map CONTEXT = Collections.unmodifiableMap(map("contextID", "w1", "generation", 2L)); @@ -134,7 +134,8 @@ void nativeTerminalMethodsKeepContextAndLiteralArguments() { @Test void theSdkAnswersTheHighestPluginFunctionVersionTheHostOffers() { Object offer = object(FIXTURE.get("offer")).get("versions"); - Object[][] cases = {{offer, 3}, {Arrays.asList(1L, 2L), 2}, {Arrays.asList(1L), 1}, {Arrays.asList(2L, 99L), 2}}; + Object[][] cases = {{offer, 4}, {Arrays.asList(1L, 2L, 3L), 3}, {Arrays.asList(1L, 2L), 2}, {Arrays.asList(1L), 1}, + {Arrays.asList(2L, 99L), 2}, {Arrays.asList(4L, 5L), 4}}; for (Object[] testCase : cases) { Backend backend = Backend.create(null, null).function("query.run", (params, context) -> null); TestHost host = new TestHost(backend); @@ -146,7 +147,7 @@ void theSdkAnswersTheHighestPluginFunctionVersionTheHostOffers() { assertEquals(expected, backend.host().version()); host.close(); } - // The fixture's answers of versions 2 and 3 have the shape the SDK sends. + // The fixture's answers of versions 2 to 4 have the shape the SDK sends. for (Map testCase : cases("negotiation")) { Object version = object(testCase.get("expected")).get("version"); if (version instanceof Number && ((Number) version).intValue() >= 2) { @@ -215,7 +216,12 @@ void localKeyAndContextChecksAgreeWithTheFixtureCasesCoreRefusesOrForwards() { for (Map testCase : cases("hostRequests")) { String name = (String) testCase.get("name"); String method = (String) testCase.get("method"); - Map params = object(testCase.get("params")); + Object rawParams = testCase.get("params"); + if (!(rawParams instanceof Map)) { + // Array params and absent params carry no key or context to compare. + continue; + } + Map params = object(rawParams); Map expected = object(testCase.get("expected")); if (method.startsWith("lithe/host/credentials/")) { String key = (String) params.get("key"); @@ -504,11 +510,19 @@ void commandsContextChangesAndLaterHostNotificationsReachTheirHandlers() { } } Set> expected = new HashSet<>(); + // Run exits and language server events have no dedicated API: onHostNotification routes them. + Set otherMethods = new HashSet<>(); for (Map testCase : delivered) { Map params = object(testCase.get("params")); - expected.add(Protocol.HOST_COMMAND_DID_RUN.equals(testCase.get("method")) - ? Arrays.asList("command", params.get("commandID"), params.get("context")) - : Arrays.asList("contexts", params)); + String deliveredMethod = (String) testCase.get("method"); + if (Protocol.HOST_COMMAND_DID_RUN.equals(deliveredMethod)) { + expected.add(Arrays.asList("command", params.get("commandID"), params.get("context"))); + } else if (Protocol.HOST_DID_CHANGE_CONTEXTS.equals(deliveredMethod)) { + expected.add(Arrays.asList("contexts", params)); + } else { + expected.add(Arrays.asList("host", deliveredMethod, params)); + otherMethods.add(deliveredMethod); + } } expected.add(Arrays.asList("later", map("value", 1L))); Set> seen = Collections.synchronizedSet(new HashSet<>()); @@ -526,6 +540,12 @@ void commandsContextChangesAndLaterHostNotificationsReachTheirHandlers() { seen.add(Arrays.asList("later", params)); all.countDown(); }); + for (String otherMethod : otherMethods) { + backend.onHostNotification(otherMethod, params -> { + seen.add(Arrays.asList("host", otherMethod, params)); + all.countDown(); + }); + } assertEquals("sdk.method.reserved", assertThrows(PluginSdkError.class, () -> backend.onHostNotification(Protocol.HOST_COMMAND_DID_RUN, params -> {})).code()); assertEquals("sdk.method.invalid", assertThrows(PluginSdkError.class, @@ -545,4 +565,348 @@ void commandsContextChangesAndLaterHostNotificationsReachTheirHandlers() { assertEquals(expected.size(), seen.size()); host.close(); } + + /** {@code base} as a new map, with {@code overrides} (alternating keys and values) replacing or adding entries. */ + private static Map with(Map base, Object... overrides) { + Map copy = map(); + copy.putAll(base); + copy.putAll(map(overrides)); + return copy; + } + + private static String repeat(char character, int count) { + StringBuilder text = new StringBuilder(); + for (int index = 0; index < count; index++) { + text.append(character); + } + return text.toString(); + } + + /** Makes one relayed call, lets the host answer it, and returns the params the host received. */ + private static Map relayed(Backend backend, TestHost host, Set taken, Map call) { + CompletableFuture> pending = backend.host().functions().invoke(CONTEXT, call); + Map request = nextRequest(host, Protocol.HOST_FUNCTIONS_INVOKE, taken); + host.send(map("id", request.get("id"), "result", map("value", null))); + assertEquals(map("value", null), value(pending)); + return object(request.get("params")); + } + + @Test + void aVersionFourFunctionSeesTheCallingPluginCoreWroteAndOlderSessionsSeeNone() { + for (Map testCase : cases("invocationCallers")) { + String name = (String) testCase.get("name"); + int version = ((Number) testCase.get("version")).intValue(); + Map expectedParams = object(object(testCase.get("expected")).get("params")); + // Core writes the caller into what a version 4 callee gets; an older session is sent it too and ignores it. + Map wire = with(expectedParams); + if (testCase.get("caller") != null) { + wire.put("caller", map("pluginID", testCase.get("caller"))); + } + AtomicReference seen = new AtomicReference<>("unset"); + AtomicReference seenPluginId = new AtomicReference<>("unset"); + Backend backend = Backend.create(null, null).function((String) wire.get("functionID"), (arguments, context) -> { + seen.set(context.caller()); + seenPluginId.set(context.callerPluginId()); + return null; + }); + TestHost host = session(version, backend); + assertNull(host.request(Protocol.FUNCTION_INVOKE, wire).get("error"), name); + Object expectedCaller = version >= 4 ? expectedParams.get("caller") : null; + assertEquals(expectedCaller, seen.get(), name); + assertEquals(expectedCaller == null ? null : object(expectedCaller).get("pluginID"), seenPluginId.get(), name); + host.close(); + } + + // Only exactly { pluginID: non-empty string } is a caller, and it cannot be changed. + AtomicReference seen = new AtomicReference<>("unset"); + AtomicReference seenPluginId = new AtomicReference<>("unset"); + Backend backend = Backend.create(null, null).function("project.describe", (arguments, context) -> { + seen.set(context.caller()); + seenPluginId.set(context.callerPluginId()); + return null; + }); + TestHost host = session(4, backend); + List notCallers = Arrays.asList("dev.lithe.fixture.python", Collections.emptyList(), map(), + map("pluginID", ""), map("pluginID", 7L), map("plugin", "dev.lithe.fixture.python"), + map("pluginID", "dev.lithe.fixture.python", "name", "python")); + for (Object notCaller : notCallers) { + assertNull(host.request(Protocol.FUNCTION_INVOKE, + map("functionID", "project.describe", "operationID", "7", "params", map(), "caller", notCaller)).get("error")); + assertNull(seen.get(), String.valueOf(notCaller)); + assertNull(seenPluginId.get(), String.valueOf(notCaller)); + } + assertNull(host.request(Protocol.FUNCTION_INVOKE, map("functionID", "project.describe", "operationID", "7")).get("error")); + assertNull(seen.get()); + assertNull(host.request(Protocol.FUNCTION_INVOKE, map("functionID", "project.describe", "operationID", "7", + "caller", map("pluginID", "dev.lithe.fixture.python"))).get("error")); + Map caller = object(seen.get()); + assertEquals(map("pluginID", "dev.lithe.fixture.python"), caller); + assertEquals("dev.lithe.fixture.python", seenPluginId.get()); + assertThrows(UnsupportedOperationException.class, () -> caller.put("pluginID", "dev.lithe.fixture.other")); + // A caller key inside the params is ordinary data, not the calling plugin. + assertNull(host.request(Protocol.FUNCTION_INVOKE, map("functionID", "project.describe", "operationID", "7", + "params", map("caller", map("pluginID", "dev.lithe.fixture.other")))).get("error")); + assertNull(seen.get()); + host.close(); + } + + @Test + void relayRequestsCarryTheContractParamsAndAnswerTheHostsResultUnchanged() { + Backend backend = Backend.create(null, null); + TestHost host = session(4, backend); + Set taken = new HashSet<>(); + int relayCases = 0; + for (Map testCase : cases("hostRequests")) { + String method = (String) testCase.get("method"); + if (!Boolean.TRUE.equals(object(testCase.get("expected")).get("relay"))) { + continue; + } + String name = (String) testCase.get("name"); + assertEquals(4, ((Number) testCase.get("version")).intValue(), name); + Map wire = object(testCase.get("params")); + CompletableFuture> pending; + Object answer; + if (Protocol.HOST_FUNCTIONS_PROVIDERS.equals(method)) { + pending = backend.host().functions().providers((String) wire.get("functionID")); + answer = map("providers", Arrays.asList(map("pluginID", "dev.lithe.fixture.go", "version", 1L))); + } else { + assertEquals(Protocol.HOST_FUNCTIONS_INVOKE, method, name); + Map call = with(wire); + call.remove("context"); + pending = backend.host().functions().invoke(object(wire.get("context")), call); + answer = map("value", map("settings", Arrays.asList("a"))); + } + Map request = nextRequest(host, method, taken); + assertEquals(wire, request.get("params"), name); + host.send(map("id", request.get("id"), "result", answer)); + assertEquals(answer, value(pending), name); + relayCases++; + } + assertEquals(3, relayCases); + // A provider's null result is a result. + CompletableFuture> nothing = backend.host().functions().invoke(CONTEXT, + map("pluginID", "dev.lithe.fixture.go", "functionID", "languageImport.settings")); + host.send(map("id", nextRequest(host, Protocol.HOST_FUNCTIONS_INVOKE, taken).get("id"), "result", map("value", null))); + assertEquals(map("value", null), value(nothing)); + host.close(); + } + + @Test + void theRelayNeedsProtocolFourAndOlderSessionsFailLocallyWithoutSendingAnything() { + Map wire = null; + for (Map testCase : cases("hostRequests")) { + if (Protocol.HOST_FUNCTIONS_INVOKE.equals(testCase.get("method")) + && ((Number) testCase.get("version")).intValue() == 3 + && "plugin.host.unavailable".equals(object(testCase.get("expected")).get("code"))) { + wire = object(testCase.get("params")); + } + } + assertTrue(wire != null, "The fixture has no version 3 relay case."); + for (int version : new int[] {3, 2, 1}) { + Backend backend = Backend.create(null, null); + TestHost host = session(version, backend); + int before = host.messages().size(); + Map call = with(wire); + call.remove("context"); + List> attempts = new ArrayList<>(); + attempts.add(backend.host().functions().providers("languageImport.settings")); + attempts.add(backend.host().functions().invoke(object(wire.get("context")), call)); + attempts.add(backend.host().request(Protocol.HOST_FUNCTIONS_PROVIDERS, map("functionID", "languageImport.settings"))); + attempts.add(backend.host().request(Protocol.HOST_FUNCTIONS_INVOKE, wire)); + for (CompletableFuture attempt : attempts) { + Throwable error = failure(attempt); + assertTrue(error instanceof HostError, "version " + version + ": " + error); + assertEquals("plugin.host.unavailable", ((HostError) error).code(), "version " + version); + assertEquals(Integer.valueOf(-32601), ((HostError) error).rpcCode(), "version " + version); + } + assertEquals(before, host.messages().size(), "version " + version); + host.close(); + } + } + + @Test + void relayFailuresKeepTheirCodeNumberAndTheProvidersOwnErrorCode() { + Backend backend = Backend.create(null, null); + TestHost host = session(4, backend); + Set taken = new HashSet<>(); + Map call = map("pluginID", "dev.lithe.fixture.go", "functionID", "languageImport.settings"); + java.util.function.Function, HostError> refused = wire -> { + CompletableFuture> pending = backend.host().functions().invoke(CONTEXT, call); + Map request = nextRequest(host, Protocol.HOST_FUNCTIONS_INVOKE, taken); + host.send(map("id", request.get("id"), "error", wire)); + Throwable error = failure(pending); + assertTrue(error instanceof HostError, String.valueOf(error)); + return (HostError) error; + }; + int failures = 0; + for (Map testCase : cases("relayErrors")) { + String name = (String) testCase.get("name"); + String code = (String) testCase.get("code"); + long rpcCode = ((Number) testCase.get("rpcCode")).longValue(); + assertEquals(Integer.valueOf((int) rpcCode), HostError.CODES.get(code), name); + HostError named = refused.apply(map("code", rpcCode, "message", "Refused.", "data", map("code", code))); + assertEquals(code, named.code(), name); + assertEquals((int) rpcCode, named.rpcCode().intValue(), name); + assertEquals(Protocol.HOST_FUNCTIONS_INVOKE, named.method(), name); + // The number alone names the code. + assertEquals(code, refused.apply(map("code", rpcCode, "message", "Refused.")).code(), name); + failures++; + } + assertEquals(15, failures); + + HostError providerFailed = refused.apply(map("code", -32070L, "message", "The provider failed.", + "data", map("code", "plugin.function.failed", "details", map("pluginErrorCode", "go.parse.failed")))); + assertEquals("plugin.function.failed", providerFailed.code()); + assertEquals("go.parse.failed", providerFailed.pluginErrorCode()); + assertEquals(map("pluginErrorCode", "go.parse.failed"), providerFailed.details()); + // Only a provider's failure has a provider error code, and only a text one. + assertNull(refused.apply(map("code", -32070L, "message", "The provider failed.", + "data", map("code", "plugin.function.failed"))).pluginErrorCode()); + assertNull(refused.apply(map("code", -32070L, "message", "The provider failed.", + "data", map("code", "plugin.function.failed", "details", map("pluginErrorCode", 7L)))).pluginErrorCode()); + assertNull(refused.apply(map("code", -32071L, "message", "No result.", + "data", map("code", "plugin.function.invalidResult", "details", map("pluginErrorCode", "go.parse.failed")))).pluginErrorCode()); + host.close(); + } + + @Test + void aCallToAnotherPluginNamesTheFunctionCallItIsMadeInAsItsParentUnlessTheCallerSaysOtherwise() { + Backend backend = Backend.create(null, null).function("relay.run", (arguments, context) -> { + Host used = Boolean.TRUE.equals(arguments.get("scoped")) + ? context.host().withCancellation(new Cancellation()) + : context.host(); + return used.functions().invoke(CONTEXT, object(arguments.get("call"))).join(); + }); + TestHost host = session(4, backend); + Set taken = new HashSet<>(); + Map call = map("pluginID", "dev.lithe.fixture.go", "functionID", "languageImport.settings"); + String longest = repeat('p', Protocol.MAX_PARENT_OPERATION_ID_BYTES); + String tooLong = repeat('p', Protocol.MAX_PARENT_OPERATION_ID_BYTES + 1); + // The invoke request's operationID (null: none), the call, whether the Host is + // narrowed with withCancellation, and the parentOperationID the host must receive (null: none). + Object[][] cases = { + {"7", call, false, "7"}, + {"7", with(call, "parentOperationID", "17"), false, "17"}, + {"7", with(call, "parentOperationID", null), false, "7"}, + {"7", call, true, "7"}, + {longest, call, false, longest}, + {tooLong, call, false, null}, + {"", call, false, null}, + {7L, call, false, null}, + {null, call, false, null}, + }; + long id = 60; + for (Object[] testCase : cases) { + String name = "operationID " + testCase[0] + ", call " + testCase[1] + ", narrowed " + testCase[2]; + Map invocation = map("functionID", "relay.run", + "params", map("call", testCase[1], "scoped", testCase[2])); + if (testCase[0] != null) { + invocation.put("operationID", testCase[0]); + } + id++; + CompletableFuture> serving = host.requestAsync(Protocol.FUNCTION_INVOKE, invocation, id); + Map request = nextRequest(host, Protocol.HOST_FUNCTIONS_INVOKE, taken); + Map expected = with(call, "context", CONTEXT); + if (testCase[3] != null) { + expected.put("parentOperationID", testCase[3]); + } + // The JSON-RPC request ID and the diagnostic operation ID are never the parent. + assertEquals(expected, request.get("params"), name); + host.send(map("id", request.get("id"), "result", map("value", map("ok", true)))); + Map response = Fixtures.await(serving, "the serving call"); + assertNull(response.get("error"), name); + assertEquals(map("value", map("ok", true)), response.get("result"), name); + } + // A Host that belongs to no function call has no parent to name; an explicit one is still sent. + assertFalse(relayed(backend, host, taken, call).containsKey("parentOperationID")); + assertEquals("17", relayed(backend, host, taken, with(call, "parentOperationID", "17")).get("parentOperationID")); + host.close(); + } + + @Test + void malformedRelayArgumentsFailLocallyAndNeverReachTheHost() { + Backend backend = Backend.create(null, null); + TestHost host = session(4, backend); + Host.Functions functions = backend.host().functions(); + int before = host.messages().size(); + Map call = map("pluginID", "dev.lithe.fixture.go", "functionID", "languageImport.settings"); + + List> malformed = new ArrayList<>(); + for (Object pluginId : new Object[] {null, "", 7L}) { + malformed.add(with(call, "pluginID", pluginId)); + } + for (Object functionId : new Object[] {null, "", "Not-Valid", 7L}) { + malformed.add(with(call, "functionID", functionId)); + } + for (Object params : new Object[] {Arrays.asList(1L), "text"}) { + malformed.add(with(call, "params", params)); + } + for (Object timeout : new Object[] {0L, -1L, 1.5, "5000", 9007199254740992L, Double.NaN}) { + malformed.add(with(call, "timeoutMilliseconds", timeout)); + } + // 128 bytes is the longest parent ID: 65 two-byte characters are 130. + for (Object parent : new Object[] {17L, "", repeat('p', 129), repeat('é', 65)}) { + malformed.add(with(call, "parentOperationID", parent)); + } + for (Map bad : malformed) { + assertEquals("sdk.functions.invalidCall", code(functions.invoke(CONTEXT, bad)), String.valueOf(bad)); + } + assertEquals("sdk.functions.invalidCall", code(functions.invoke(CONTEXT, null))); + for (String functionId : new String[] {null, "", "Not-Valid"}) { + assertEquals("sdk.functions.invalidCall", code(functions.providers(functionId)), String.valueOf(functionId)); + } + assertEquals("sdk.host.invalidContext", code(functions.invoke(null, call))); + assertEquals("sdk.host.invalidContext", code(functions.invoke(map("contextID", "w1"), call))); + assertEquals(before, host.messages().size()); + + // What is well formed is sent as given: no cap on the timeout, which is Core's to apply. + Set taken = new HashSet<>(); + Map bare = relayed(backend, host, taken, with(call, "params", null, "timeoutMilliseconds", null, "parentOperationID", null)); + assertEquals(map("pluginID", "dev.lithe.fixture.go", "functionID", "languageImport.settings", "context", CONTEXT), bare); + Map full = relayed(backend, host, taken, with(call, "params", map(), "timeoutMilliseconds", 700000L, + "parentOperationID", repeat('é', 64))); + assertEquals(map(), full.get("params")); + assertEquals(700000L, full.get("timeoutMilliseconds")); + assertEquals(repeat('é', 64), full.get("parentOperationID")); + assertEquals(5000L, relayed(backend, host, taken, with(call, "timeoutMilliseconds", 5000.0)).get("timeoutMilliseconds")); + assertEquals(5000L, relayed(backend, host, taken, with(call, "timeoutMilliseconds", 5000)).get("timeoutMilliseconds")); + assertEquals(repeat('p', 128), relayed(backend, host, taken, with(call, "parentOperationID", repeat('p', 128))).get("parentOperationID")); + // Only the fields of the method are sent; a caller cannot be chosen from here. + Map only = relayed(backend, host, taken, with(call, "caller", map("pluginID", "dev.lithe.fixture.other"), "extra", true)); + assertEquals(map("pluginID", "dev.lithe.fixture.go", "functionID", "languageImport.settings", "context", CONTEXT), only); + host.close(); + } + + @Test + void cancellingTheCallingFunctionCancelsItsRelayedCallOnTheWire() { + CountDownLatch started = new CountDownLatch(1); + AtomicReference outcome = new AtomicReference<>(); + Backend backend = Backend.create(null, null).function("relay.run", (arguments, context) -> { + CompletableFuture> relayed = context.host().functions().invoke(CONTEXT, object(arguments.get("call"))); + started.countDown(); + try { + return relayed.join(); + } catch (CompletionException error) { + outcome.set(error.getCause()); + throw error; + } + }); + TestHost host = session(4, backend); + CompletableFuture> call = host.requestAsync(Protocol.FUNCTION_INVOKE, + map("functionID", "relay.run", "operationID", "c", + "params", map("call", map("pluginID", "dev.lithe.fixture.go", "functionID", "languageImport.settings"))), 51L); + Fixtures.await(started, "the relayed call"); + Map request = nextRequest(host, Protocol.HOST_FUNCTIONS_INVOKE); + assertEquals("c", object(request.get("params")).get("parentOperationID")); + host.notify(Protocol.CANCEL_REQUEST, map("id", 51L)); + Map response = Fixtures.await(call, "the cancelled call"); + assertEquals((long) Protocol.REQUEST_CANCELLED, object(response.get("error")).get("code")); + assertTrue(outcome.get() instanceof CancelledException, String.valueOf(outcome.get())); + assertEquals(Arrays.asList(map("id", request.get("id"))), + sent(host, Protocol.CANCEL_REQUEST).stream().map(message -> message.get("params")).collect(Collectors.toList())); + // Core's late answer is dropped; the session continues. + host.send(map("id", request.get("id"), "error", + map("code", -32800L, "message", "Cancelled.", "data", map("code", "plugin.host.cancelled")))); + assertEquals(1, host.close()); + } } diff --git a/sdk/java/pom.xml b/sdk/java/pom.xml index 34cd08b..e6af29d 100644 --- a/sdk/java/pom.xml +++ b/sdk/java/pom.xml @@ -11,7 +11,7 @@ io.github.lithe-idea lithe-plugin-sdk-java-parent - 0.3.0 + 0.4.0 pom diff --git a/sdk/node/README.md b/sdk/node/README.md index ce93464..74f23af 100644 --- a/sdk/node/README.md +++ b/sdk/node/README.md @@ -10,12 +10,13 @@ on stdio and implements Lithe's backend protocols from whose `model` or `dependencies` source is `provider`: models and dependency trees (1), bounded task execution (2), tests (3), run (4) and debug (5) preparation, and read-only model inputs (6). -- **`litheFunctions` 1–3**: plugin functions, which are plugin-private actions +- **`litheFunctions` 1–4**: plugin functions, which are plugin-private actions that Lithe invokes for the same plugin, with progress, cancellation, and - registration at runtime (1); host services such as credentials (2); and + registration at runtime (1); host services such as credentials (2); workspace contexts, capability discovery, host notifications, and the - public document, file, editor, and window methods (3). See - [Host services](#host-services). + public document, file, editor, and window methods (3); and calls between + plugins, relayed by Core (4). See [Host services](#host-services) and + [Calling other plugins](#calling-other-plugins-protocol-4). - **`litheVcs` 1**: typed `lithe/vcs/*` requests, for a plugin's `vcsProviders` entry, from the [VCS provider contract](https://github.com/1lck/Lithe-IDEA/blob/preview/shared/contracts/vcs-provider-v1.md). @@ -117,6 +118,13 @@ real Core session. Releases are published on [this repository's Releases page](https://github.com/Lithe-IDEA/lithe-plugin-docs/releases) and tagged `sdk-node-v`. Version 0.3.0 adds shortcut import providers, native terminals, and typed diagnostics through `litheLogging` 1, and removes the free-text `log` API. +Version 0.4.0 adds calls between plugins (`litheFunctions` 4): +`host.functions.providers` and `host.functions.invoke`, `context.caller` in the +function that is called, and the `plugin.function.*` relay error codes in +`HOST_ERRORS`. The number-only fallback table of `HostError` is complete: it +now also names the five host error codes -32022 to -32026 +(`plugin.host.languageServerUnavailable`, `languageServerError`, +`methodNotDeclared`, `toolUnresolved` and `foreignContribution`). A release ships `lithe-plugin-sdk-node-.tgz` and a `.sha256` file holding the archive's SHA-256. The SDK is not published to the npm registry. Use it like @@ -156,7 +164,7 @@ there is no stderr fallback or plugin-owned file writer. | `backend.build.test(handler)`, `backend.build.discoverTests(handler)` | Protocol 3+: exact test execution and runnable selectors; discovery gets `format: "litheTestDiscovery"`, `version: 1`. | | `backend.build.prepareRun(handler)` | Protocol 4: returns a portable launch plan after upstream synchronization/build; the host owns the application process. | | `backend.buildVersion` | Highest common negotiated build version (1–6), or `null`; methods newer than that version are refused before their handler runs. | -| `backend.functionsVersion` | Highest common negotiated plugin-function version (1–3), or `null`. | +| `backend.functionsVersion` | Highest common negotiated plugin-function version (1–4), or `null`. | | `backend.host` | [Host services](#host-services) (`lithe/host/*`), usable once the session is ready. | | `backend.onCommand(commandID, handler)` | Protocol 3: one of the plugin's commands ran; the handler gets `{ commandID, workspaceContext }`. | | `backend.onContextsChanged(handler)` | Protocol 3: workspace contexts opened, closed, or changed; the handler gets `{ revision }`. | @@ -170,7 +178,7 @@ there is no stderr fallback or plugin-owned file writer. | `backend.diagnostics.degraded(operation, code, count?)`, `.recovered(...)` | Typed handled fallback/recovery; no payload text. | | `backend.listen()` | Serves stdio until `exit` or the end of input. | | `PluginError(code, message)` | A failure meant for your own UI, or with a `VCS_ERRORS` code, for the host's. | -| `HostError` | The host refused or failed a host request: `code` (`error.data.code`), `rpcCode`, and protocol 3 `details`. `HOST_ERRORS` maps each code to its number. | +| `HostError` | The host refused or failed a host request: `code` (`error.data.code`), `rpcCode`, and protocol 3 `details`. `HOST_ERRORS` maps each code, including the `plugin.function.*` codes of the relay, to its number. | | `runProcess(command, args, options)` | Runs a process without a shell, as one cancellable process tree. | | `openStateStore(directory)` | A small atomic JSON store in a host-provided directory. | @@ -185,6 +193,7 @@ Every handler receives a context: | `host` | `backend.host` bound to the call: host requests it left unanswered are cancelled when the call ends. | | `functionID` | Functions only. | | `workspaceContext` | Functions only, protocol 3: `{ contextID, generation }` of the page's window, or `null`. | +| `caller` | Functions only, protocol 4: `{ pluginID }` of the plugin whose relayed call this is, or `null` for a call Lithe started, under protocol 3 or earlier, or when the field is not exactly `{ pluginID }`. Core writes it; it is read-only. | | `operationID` | Functions and bounded build/test/run-preparation calls. Opaque wire identity chosen by Core; never derive anything from it. | | `progress({ message?, percentage?, output? })` | Functions use messages up to 1024 UTF-8 bytes. Build/test/run preparation use messages up to 4096 bytes and output up to 64 KiB per report. Percentages are clamped to 0–100; cancelled or settled build calls cannot report. Model/dependency/discovery contexts have no progress. | @@ -237,6 +246,8 @@ backend.onCommand('db.refresh', async ({ workspaceContext }) => { | `workspace.readDirectory`, `.stat`, `.readFile`, `.findFiles`, `.findText`, `.watch` | 3 | Files of a local context, addressed as `{ rootID, path }` from `getFolders`. | | `documents.list`, `.getSnapshot`; `editor.getActive`, `.getSelections`, `.applyEdits`, `.replaceSelections`, `.insertText`, `.open`, `.reveal`, `.registerActions` | 3 | Live documents and editors. Edits name the `expectedVersion` they were computed against. | | `preview.openHTML`, `browser.openExternal`, `window.showInformation`, `.showWarning`, `.showError` | 3 | Preview, browser, and messages. | +| `functions.providers(functionID)` | 4 | The enabled plugins that export a function: `{ providers: [{ pluginID, version }] }`. See [Calling other plugins](#calling-other-plugins-protocol-4). | +| `functions.invoke(context, call)` | 4 | Calls one provider's exported function in a workspace context: `{ value }`. `call` is `{ pluginID, functionID, params?, timeoutMilliseconds?, parentOperationID? }`. | | `request(method, params?)`, `subscribe(method, params, onEvent?)` | 2 / 3 | Any `lithe/host/*` method, including ones a later host adds. | | `subscriptions.dispose(id)` | 3 | Ends a subscription; `Subscription.dispose()` does the same. | @@ -271,6 +282,104 @@ What the SDK does for you, and what it leaves to you: plugin's commands; the host waits for no result. A stopped backend is started for it only when the manifest declares `onCommand:`. +### Calling other plugins (protocol 4) + +A plugin can call a function another enabled plugin exports. Core relays +every call: your backend never reaches the provider's process, and the +provider learns only the call it serves. The rules of the relay are in +[the contract](../../contract/v1/README.md#cross-plugin-calls-protocol-version-4). + +- **Declare the call in the manifest.** The caller lists + `pluginFunctions.calls` (`{ id, version }`, the function and the contract + version it was written against) and `requires.pluginFunctions: 1`, with + `backend: 2` and `serverProcess: 1`. The provider lists + `pluginFunctions.exports` and registers the function with + `backend.function`. The calls are the consent; there is no permission. +- **Check `supports()` first.** `await backend.host.supports('lithe/host/functions/invoke')` + is true only in a version 4 session of a plugin that declares a call. + Under protocol 3 or earlier, `functions.providers`, `functions.invoke`, and + the same methods through `host.request` fail at once with `HostError` + `plugin.host.unavailable` (-32601) and send nothing. + +```js +backend.function('report.build', async (params, context) => { + const workspace = context.workspaceContext; + if (!workspace || !(await context.host.supports('lithe/host/functions/invoke'))) { + throw new PluginError('report.noRelay', 'This Lithe cannot call other plugins.'); + } + const { providers } = await context.host.functions.providers('project.describe'); + if (providers.length === 0) throw new PluginError('report.noProvider', 'Install a plugin that describes projects.'); + const { value } = await context.host.functions.invoke(workspace, { + pluginID: providers[0].pluginID, + functionID: 'project.describe', + params: { project: params.project }, + timeoutMilliseconds: 10000, + }); + return summarize(value); +}); +``` + +- **Results are raw.** `providers` returns `{ providers: [{ pluginID, version }] }` + and `invoke` returns `{ value }`, where `null` is a result. A call is + `{ pluginID, functionID, params?, timeoutMilliseconds?, parentOperationID? }`; + `params` is a JSON object, not sent when omitted (Core reads `{}`). + `timeoutMilliseconds` is a positive integer. Core defaults it to 60000 + and caps it at 600000 (`LIMITS.relayDefaultTimeoutMilliseconds`, + `LIMITS.relayMaxTimeoutMilliseconds`); the SDK passes it on as given. +- **Mistakes fail locally, with no frame sent.** A `call` that is not an + object, or a bad `pluginID`, `functionID`, `params`, `timeoutMilliseconds` + or `parentOperationID`, rejects with `PluginSdkError` + `sdk.functions.invalidCall`; a bad workspace context rejects with + `sdk.host.invalidContext`, as for every other host method. The SDK sends only + the fields above and drops any other, such as `caller`, which Core writes + itself and refuses in a request. +- **`parentOperationID` is filled in inside a function.** In a function + handler, `context.host` sets it to the `operationID` of the call being + served (when that is a string of 1–128 bytes), so Core knows which call the + new one belongs to and can cancel it with its parent, detect cycles, and + bound the chain at 4 calls. An explicit `parentOperationID` wins. Everything + else has no parent: `backend.host`, host notification callbacks + (`onCommand`, `onContextsChanged`, `onHostNotification`), build, VCS, and + shortcut import handlers, plain `onRequest` handlers, and async work left + running after a call ended. Without a parent Core treats the call as a + child of every relayed call the backend is serving at that moment. Never + pass the `operationID` of a call that has finished: Core refuses it with + `plugin.host.cancelled`. +- **A relayed call fills a host request slot.** It counts among the 16 + unanswered host requests until Core answers, which can take the whole + timeout, and a caller has at most 8 relayed calls running. Start calls + together only when they are independent. +- **Read the caller from `context.caller`.** In a provider's function, + `context.caller.pluginID` is the plugin that called, written by Core and + impossible to forge; `null` means the host started the call. Never decide by + a plugin ID in `params`, which any caller can write. Do not accept an + executable path, command line, or script from a caller and run it: take a + request you interpret, and choose the executable yourself. + +A failed relayed call rejects with a `HostError` whose `code` is one of these +(the `HOST_ERRORS` keys): + +| Code | Number | Meaning | +| --- | --- | --- | +| `plugin.function.notDeclared` | -32027 | The manifest does not declare this call. | +| `plugin.function.notExported` | -32028 | The provider does not export the function, or its backend has not registered it. | +| `plugin.function.providerDisabled` | -32029 | The provider is not installed or enabled. | +| `plugin.function.versionMismatch` | -32030 | The declared and exported contract versions differ. | +| `plugin.function.callCycle` | -32065 | The provider is already on the call chain. | +| `plugin.function.depthExceeded` | -32066 | The call chain is longer than 4 calls. | +| `plugin.function.tooManyCalls` | -32067 | The caller already has 8 relayed calls running. | +| `plugin.function.payloadTooLarge` | -32068 | The params, or a result, exceeds 256 KiB. | +| `plugin.function.providerUnavailable` | -32069 | The provider's backend exited, withdrew the function, or did not start in time. | +| `plugin.function.failed` | -32070 | The provider's function answered with an error. | +| `plugin.function.invalidResult` | -32071 | The provider answered without a result, or with one over 1 MiB. | + +The host codes `plugin.host.timeout`, `plugin.host.cancelled`, +`plugin.host.contextExpired`, `plugin.host.authorityUnsupported`, +`plugin.host.permissionDenied` and `plugin.host.invalidParams` also occur. On +`plugin.function.failed`, `error.message` is the provider's message and +`error.details.pluginErrorCode` is the code the provider's function threw; +only branch on a code its provider documents. + ## Results and errors The following mapping describes private functions. Typed build operations use diff --git a/sdk/node/package-lock.json b/sdk/node/package-lock.json index 6ff00f0..757f325 100644 --- a/sdk/node/package-lock.json +++ b/sdk/node/package-lock.json @@ -1,12 +1,12 @@ { "name": "@lithe/plugin-sdk", - "version": "0.3.0", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@lithe/plugin-sdk", - "version": "0.3.0", + "version": "0.4.0", "license": "Apache-2.0", "devDependencies": { "@types/node": "20.19.9", diff --git a/sdk/node/package.json b/sdk/node/package.json index add560d..b98afcc 100644 --- a/sdk/node/package.json +++ b/sdk/node/package.json @@ -1,6 +1,6 @@ { "name": "@lithe/plugin-sdk", - "version": "0.3.0", + "version": "0.4.0", "description": "Official SDK for Lithe plugin backends: plugin functions and build providers over the LSP base protocol.", "license": "Apache-2.0", "private": true, diff --git a/sdk/node/src/backend.mjs b/sdk/node/src/backend.mjs index 950cd36..2bf9490 100644 --- a/sdk/node/src/backend.mjs +++ b/sdk/node/src/backend.mjs @@ -28,6 +28,7 @@ import { isVcsId, LIMITS, METHODS, + RELAY_VERSION, VCS_OPERATIONS, VCS_REQUIRED_OPERATIONS, VCS_VERSION, @@ -104,7 +105,7 @@ class Backend { build; /** - * Host services (`lithe/host/*`) of plugin-function protocols 2 and 3, + * Host services (`lithe/host/*`) of plugin-function protocols 2 to 4, * usable once the session is ready. A handler's `context.host` is the * same object bound to its call. */ @@ -152,7 +153,7 @@ class Backend { /** Selected build protocol version, or null when unavailable. */ get buildVersion() { return this.#buildVersion; } - /** Selected plugin-function protocol version (1-3), or null when unavailable. */ + /** Selected plugin-function protocol version (1-4), or null when unavailable. */ get functionsVersion() { return this.#functionsVersion; } /** The host's `initialize` params, or null before it arrives. */ @@ -682,7 +683,7 @@ class Backend { this.#run(id, null, (context) => handler(keymapSnapshot(params, context.host), context), functionResult, undefined, 'lithe/keymap/import'); } - #invokeFunction(id, { functionID, operationID, params, context: workspaceContext }) { + #invokeFunction(id, { functionID, operationID, params, context: workspaceContext, caller }) { const handler = this.#negotiated.functions ? this.#functions.get(functionID) : undefined; if (!handler) { this.#respond(id, undefined, { code: ERROR_CODES.invalidParams, message: `Function ${functionID} is not registered.` }); @@ -696,6 +697,10 @@ class Backend { if (Number.isFinite(percentage)) report.percentage = Math.min(100, Math.max(0, Math.round(percentage))); this.#write({ jsonrpc: '2.0', method: METHODS.functionProgress, params: report }); }; + // Calls this handler makes through the relay belong to this call. Core + // needs a string of 1-128 bytes; without one the calls carry no parent. + const parentOperationId = typeof operationID === 'string' && operationID.length > 0 + && Buffer.byteLength(operationID) <= LIMITS.maxParentOperationIdBytes ? operationID : undefined; this.#run( id, functionID, @@ -705,6 +710,9 @@ class Backend { operationID, // The page's window, from protocol 3; null for a call without one. workspaceContext: isContextReference(workspaceContext) ? workspaceContext : null, + // The calling plugin, from protocol 4 and only for a relayed call; null otherwise. + caller: (this.#functionsVersion ?? 0) >= RELAY_VERSION ? callerReference(caller) : null, + host: createHost(this.#channel, context.signal, parentOperationId), progress, }), functionResult, @@ -987,6 +995,14 @@ function isObject(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); } +/** `{ pluginID }` for the exact top-level `caller` Core writes into a relayed call, else null. */ +function callerReference(value) { + if (!isObject(value) || Object.keys(value).length !== 1) return null; + return typeof value.pluginID === 'string' && value.pluginID.length > 0 + ? Object.freeze({ pluginID: value.pluginID }) + : null; +} + function validKeymapProvider(id) { return typeof id === 'string' && /^[a-z][a-z0-9-]{0,63}$/.test(id); } diff --git a/sdk/node/src/host.mjs b/sdk/node/src/host.mjs index 87640c9..fb0fff1 100644 --- a/sdk/node/src/host.mjs +++ b/sdk/node/src/host.mjs @@ -1,12 +1,12 @@ -// Host services of plugin-function protocols 2 and 3: the `lithe/host/*` +// Host services of plugin-function protocols 2 to 4: the `lithe/host/*` // requests a backend sends to Lithe, the subscriptions some of them create, // and the `host` object handlers use. The backend owns the wire; this module // owns what Core expects of a well-behaved client -- at most 16 unanswered -// host requests, cancellation through `$/cancelRequest` from version 3, and -// subscription events routed by ID. +// host requests, cancellation through `$/cancelRequest` from version 3, +// subscription events routed by ID, and the relay methods only from version 4. // // Contract: contract/v1/README.md, "Host services (protocol version 2)" and -// the version 3 sections after it. +// the version 3 and 4 sections after it. import { HostError, PluginSdkError } from './errors.mjs'; import { @@ -15,8 +15,11 @@ import { HOST_SERVICES_VERSION, isContextReference, isCredentialKey, + isFunctionId, + isRelayMethod, LIMITS, METHODS, + RELAY_VERSION, } from './protocol.mjs'; /** Events kept for a subscription whose creating request is still unanswered. */ @@ -69,6 +72,15 @@ export class HostChannel { { rpcCode: -32601, method }, )); } + if (isRelayMethod(method) && this.version < RELAY_VERSION) { + // Unlike other methods added after version 3, these two are bound to a + // version: Core answers a version 3 backend -32601, so no frame is sent. + return Promise.reject(new HostError( + 'plugin.host.unavailable', + 'The relay needs plugin-function protocol 4.', + { rpcCode: -32601, method }, + )); + } if (signal?.aborted) return Promise.reject(signal.reason); return new Promise((resolve, reject) => { // `queued` → `sent` → `done`; a request settles for its caller once. @@ -246,9 +258,12 @@ export class HostChannel { /** * The `host` object: typed host services over `channel`. A handler's * `context.host` passes the call's signal, so its unanswered host requests - * are cancelled when the call ends. + * are cancelled when the call ends. It also passes the `operationID` of the + * `lithe/function/invoke` being served as `parentOperationId`, which + * `functions.invoke` sends as the new call's parent unless the caller names + * another. A host object without them (`backend.host`) has no default parent. */ -export function createHost(channel, callSignal) { +export function createHost(channel, callSignal, parentOperationId) { const withSignal = (options = {}) => { if (!callSignal) return options; return { ...options, signal: options.signal ? AbortSignal.any([callSignal, options.signal]) : callSignal }; @@ -306,6 +321,14 @@ export function createHost(channel, callSignal) { findFiles: scoped(HOST_METHODS.workspaceFindFiles), findText: scoped(HOST_METHODS.workspaceFindText), }), + functions: Object.freeze({ + providers: async (functionID, options) => { + checkRelayFunctionId(functionID); + return request(HOST_METHODS.functionsProviders, { functionID }, options); + }, + invoke: async (context, call, options) => + request(HOST_METHODS.functionsInvoke, relayInvokeParams(context, call, parentOperationId), options), + }), documents: Object.freeze({ list: scoped(HOST_METHODS.documentsList), getSnapshot: scoped(HOST_METHODS.documentsGetSnapshot), @@ -367,3 +390,58 @@ function checkCredentialKey(key) { ); } } + +function invalidRelayCall(message) { + return new PluginSdkError('sdk.functions.invalidCall', message); +} + +function checkRelayFunctionId(functionID) { + if (!isFunctionId(functionID)) { + throw invalidRelayCall('A function ID is dot-separated lowerCamelCase segments of at most 128 bytes, such as "project.describe".'); + } +} + +function isParentOperationId(value) { + return typeof value === 'string' && value.length > 0 && Buffer.byteLength(value) <= LIMITS.maxParentOperationIdBytes; +} + +/** + * The params of one `lithe/host/functions/invoke`, built from the whitelisted + * fields of `call` only, so a caller cannot add a field of its own (Core + * refuses `caller` and writes it itself). Checks what Core checks first; a + * mistake throws before any frame is written. `params` stays absent unless + * given. The parent is the call's own `parentOperationID`, else the operation + * being served (`defaultParent`), else absent. + */ +function relayInvokeParams(context, call, defaultParent) { + if (call === null || typeof call !== 'object' || Array.isArray(call)) { + throw invalidRelayCall('A relayed call is { pluginID, functionID, params?, timeoutMilliseconds?, parentOperationID? }.'); + } + const { pluginID, functionID, params, timeoutMilliseconds, parentOperationID } = call; + if (typeof pluginID !== 'string' || pluginID.length === 0) { + throw invalidRelayCall('pluginID is the ID of the plugin that provides the function, a non-empty string.'); + } + checkRelayFunctionId(functionID); + const wire = { pluginID, functionID, context: contextReference(context) }; + if (params !== undefined) { + if (params === null || typeof params !== 'object' || Array.isArray(params)) { + throw invalidRelayCall('params are a JSON object.'); + } + wire.params = params; + } + if (timeoutMilliseconds !== undefined) { + if (!Number.isSafeInteger(timeoutMilliseconds) || timeoutMilliseconds < 1) { + throw invalidRelayCall('timeoutMilliseconds is a positive integer.'); + } + wire.timeoutMilliseconds = timeoutMilliseconds; + } + if (parentOperationID !== undefined) { + if (!isParentOperationId(parentOperationID)) { + throw invalidRelayCall('parentOperationID is a string of 1-128 bytes.'); + } + wire.parentOperationID = parentOperationID; + } else if (defaultParent !== undefined) { + wire.parentOperationID = defaultParent; + } + return wire; +} diff --git a/sdk/node/src/index.d.ts b/sdk/node/src/index.d.ts index fe7585b..3217197 100644 --- a/sdk/node/src/index.d.ts +++ b/sdk/node/src/index.d.ts @@ -172,8 +172,42 @@ export interface FindPage { type Scoped = (context: ContextReference, params: P, options?: HostRequestOptions) => Promise; type ScopedOptional = (context: ContextReference, params?: P, options?: HostRequestOptions) => Promise; +/** One enabled plugin that exports a function, from `host.functions.providers`. */ +export interface FunctionProvider { + pluginID: string; + /** The contract version of the exported function. */ + version: number; +} + +export interface FunctionProvidersResult { + /** Enabled plugins other than the caller, sorted by plugin ID. */ + providers: FunctionProvider[]; +} + +/** One cross-plugin call, the argument of `host.functions.invoke`. */ +export interface FunctionCall { + /** The providing plugin, one of `providers(functionID)`. */ + pluginID: string; + functionID: string; + /** A JSON object; not sent when omitted, which Core reads as `{}`. */ + params?: JsonObject; + /** A positive integer. Core defaults it to 60000 and caps it at 600000. */ + timeoutMilliseconds?: number; + /** + * The `operationID` of the call this one belongs to, a string of 1-128 bytes. + * Inside a function handler `context.host` fills it with the call being + * served; `backend.host` has no default. + */ + parentOperationID?: string; +} + +export interface FunctionInvokeResult { + /** The provider's result; `null` is a result. */ + value: JsonValue; +} + /** - * Host services of plugin-function protocols 2 and 3. Every method rejects + * Host services of plugin-function protocols 2 to 4. Every method rejects * with `HostError` when the host refuses; from protocol 3 check * `supports(method)` instead of assuming a method from the version. * A full `WorkspaceContext` may be passed wherever a reference is expected. @@ -273,6 +307,19 @@ export interface Host { { opened: true; source: 'web' | 'disk'; hasUnsavedChanges?: boolean } >; }; + /** + * Protocol 4; the manifest's `pluginFunctions.calls`. Calls between plugins, + * relayed through Core. Under protocol 3 or earlier both reject at once with + * `HostError` `plugin.host.unavailable`, and nothing is sent. Argument + * mistakes reject with `PluginSdkError` `sdk.functions.invalidCall`, or + * `sdk.host.invalidContext` for the context, before anything is sent. + */ + readonly functions: { + /** The enabled plugins other than this one that export `functionID`. */ + providers(functionID: string, options?: HostRequestOptions): Promise; + /** Calls one provider's function in a workspace context. A provider's own failure is `plugin.function.failed`. */ + invoke(context: ContextReference, call: FunctionCall, options?: HostRequestOptions): Promise; + }; /** Permission `terminal.create`; only terminals owned by this backend session and context. */ readonly terminal: { create: Scoped<{ executable: string; arguments?: string[]; title?: string; cwd?: WorkspaceResource; @@ -312,6 +359,12 @@ export interface FunctionContext extends RequestContext { operationID: string; /** The context of the page's window (protocol 3), or null. */ workspaceContext: ContextReference | null; + /** + * The plugin that called this function through the relay (protocol 4), or + * null: the host started the call, or the session negotiated less than 4. + * Decide by this field, never by a plugin ID inside `params`. + */ + readonly caller: { readonly pluginID: string } | null; /** Reports progress; ignored once the call has ended. */ progress(report: ProgressReport): void; } @@ -505,7 +558,7 @@ export interface Backend { readonly initializeParams: JsonObject | null; /** Highest common version (1-6), or null before/without negotiation. */ readonly buildVersion: number | null; - /** Highest common plugin-function version (1-3), or null before/without negotiation. */ + /** Highest common plugin-function version (1-4), or null before/without negotiation. */ readonly functionsVersion: number | null; readonly functionIDs: string[]; /** Host services; usable once the session is ready. */ @@ -577,7 +630,10 @@ export function openStateStore(directory: string, options?: { packageDirectory?: export function isFunctionId(value: unknown): boolean; -/** Host failure codes and the JSON-RPC number each has from protocol 3. */ +/** + * Host failure codes and the JSON-RPC number each has from protocol 3. The + * `plugin.function.*` codes belong to the relay (protocol 4). + */ export const HOST_ERRORS: Readonly<{ 'plugin.host.unavailable': -32601; 'plugin.host.invalidParams': -32602; @@ -595,9 +651,25 @@ export const HOST_ERRORS: Readonly<{ 'plugin.host.preconditionFailed': -32019; 'plugin.host.busy': -32020; 'plugin.host.rateLimited': -32021; + 'plugin.host.languageServerUnavailable': -32022; + 'plugin.host.languageServerError': -32023; + 'plugin.host.methodNotDeclared': -32024; + 'plugin.host.toolUnresolved': -32025; + 'plugin.host.foreignContribution': -32026; + 'plugin.function.notDeclared': -32027; + 'plugin.function.notExported': -32028; + 'plugin.function.providerDisabled': -32029; + 'plugin.function.versionMismatch': -32030; + 'plugin.function.callCycle': -32065; + 'plugin.function.depthExceeded': -32066; + 'plugin.function.tooManyCalls': -32067; + 'plugin.function.payloadTooLarge': -32068; + 'plugin.function.providerUnavailable': -32069; + 'plugin.function.failed': -32070; + 'plugin.function.invalidResult': -32071; }>; -/** Host request method names of protocols 2 and 3. */ +/** Host request method names of protocols 2 to 4. */ export const HOST_METHODS: Readonly>; @@ -635,4 +708,7 @@ export const LIMITS: Readonly<{ maxCredentialKeyBytes: number; maxCredentialSecretBytes: number; maxPendingHostRequests: number; + relayDefaultTimeoutMilliseconds: number; + relayMaxTimeoutMilliseconds: number; + maxParentOperationIdBytes: number; }>; diff --git a/sdk/node/src/protocol.mjs b/sdk/node/src/protocol.mjs index 4cf8e2b..f96cad3 100644 --- a/sdk/node/src/protocol.mjs +++ b/sdk/node/src/protocol.mjs @@ -5,7 +5,7 @@ // handler instead of as a refused session at runtime. /** Protocol versions this SDK can answer: the highest of each. */ -export const FUNCTIONS_VERSION = 3; +export const FUNCTIONS_VERSION = 4; export const BUILD_VERSION = 6; export const VCS_VERSION = 1; @@ -13,6 +13,8 @@ export const VCS_VERSION = 1; export const HOST_SERVICES_VERSION = 2; /** First plugin-function version with workspace contexts, discovery, and host notifications. */ export const CONTEXT_VERSION = 3; +/** First plugin-function version with cross-plugin calls relayed through Core and the callee's `caller`. */ +export const RELAY_VERSION = 4; export const METHODS = Object.freeze({ initialize: 'initialize', @@ -36,10 +38,11 @@ export const METHODS = Object.freeze({ }); /** - * Backend-to-host requests of plugin-function protocols 2 and 3. From + * Backend-to-host requests of plugin-function protocols 2 to 4. From * version 3 the host adds methods without a new version; `host.request` * sends any `lithe/host/*` method, and `getCapabilities` says which ones - * this session may call. + * this session may call. The two `functions` methods are the exception: + * they need version 4. */ export const HOST_METHODS = Object.freeze({ credentialsGet: 'lithe/host/credentials/get', @@ -77,6 +80,8 @@ export const HOST_METHODS = Object.freeze({ terminalReopen: 'lithe/host/terminal/reopen', terminalDispose: 'lithe/host/terminal/dispose', terminalOnExit: 'lithe/host/terminal/onExit', + functionsProviders: 'lithe/host/functions/providers', + functionsInvoke: 'lithe/host/functions/invoke', }); /** Host-to-backend notifications of protocol 3. */ @@ -88,8 +93,9 @@ export const HOST_NOTIFICATIONS = Object.freeze({ /** * Host failure codes (`error.data.code`) and the JSON-RPC number Core sends - * with each to a version 3 backend. A number never changes meaning; a later - * host adds codes after `-32021`. + * with each to a version 3 or 4 backend. A number never changes meaning; a + * later host adds codes after the last one listed here. The + * `plugin.function.*` codes are the relay's own (protocol 4). */ export const HOST_ERRORS = Object.freeze({ 'plugin.host.unavailable': -32601, @@ -108,6 +114,22 @@ export const HOST_ERRORS = Object.freeze({ 'plugin.host.preconditionFailed': -32019, 'plugin.host.busy': -32020, 'plugin.host.rateLimited': -32021, + 'plugin.host.languageServerUnavailable': -32022, + 'plugin.host.languageServerError': -32023, + 'plugin.host.methodNotDeclared': -32024, + 'plugin.host.toolUnresolved': -32025, + 'plugin.host.foreignContribution': -32026, + 'plugin.function.notDeclared': -32027, + 'plugin.function.notExported': -32028, + 'plugin.function.providerDisabled': -32029, + 'plugin.function.versionMismatch': -32030, + 'plugin.function.callCycle': -32065, + 'plugin.function.depthExceeded': -32066, + 'plugin.function.tooManyCalls': -32067, + 'plugin.function.payloadTooLarge': -32068, + 'plugin.function.providerUnavailable': -32069, + 'plugin.function.failed': -32070, + 'plugin.function.invalidResult': -32071, }); /** Minimum negotiated version of each typed build operation. */ @@ -183,8 +205,17 @@ export const LIMITS = Object.freeze({ maxCredentialKeyBytes: 128, maxCredentialSecretBytes: 16 * 1024, maxPendingHostRequests: 16, + // The relay's bounds are Core's to enforce; the SDK only names them. + relayDefaultTimeoutMilliseconds: 60000, + relayMaxTimeoutMilliseconds: 600000, + maxParentOperationIdBytes: 128, }); +/** True for the two host methods that need plugin-function protocol 4. */ +export function isRelayMethod(method) { + return method === HOST_METHODS.functionsProviders || method === HOST_METHODS.functionsInvoke; +} + /** Dot-separated lowerCamelCase segments, for example `snapshot.refresh`. */ export function isFunctionId(value) { return ( @@ -201,7 +232,7 @@ export function isVcsId(value) { } /** - * The key alphabet of protocols 2 and 3: 1-128 ASCII letters, digits, `.`, + * The key alphabet of protocols 2 to 4: 1-128 ASCII letters, digits, `.`, * `_`, `:`, or `-`. Credential keys, context IDs, and subscription IDs use it. */ export function isCredentialKey(value) { diff --git a/sdk/node/test/host.test.mjs b/sdk/node/test/host.test.mjs index e8e047d..032bb9e 100644 --- a/sdk/node/test/host.test.mjs +++ b/sdk/node/test/host.test.mjs @@ -29,7 +29,7 @@ function nextRequest(host, method, taken = new Set()) { } test('the SDK answers the highest plugin-function version the host offers', async () => { - for (const [offered, expected] of [[fixture.offer.versions, 3], [[1, 2], 2], [[1], 1], [[2, 99], 2]]) { + for (const [offered, expected] of [[fixture.offer.versions, 4], [[1, 2, 3], 3], [[4], 4], [[1, 2], 2], [[1], 1], [[2, 99], 2]]) { const backend = createBackend().function('query.run', () => null); const host = new TestHost(backend); const { result } = await host.initialize({ litheFunctions: { versions: offered } }); @@ -38,7 +38,7 @@ test('the SDK answers the highest plugin-function version the host offers', asyn assert.equal(backend.host.version, expected); await host.close(); } - // The fixture's answers of versions 2 and 3 have the shape the SDK sends. + // The fixture's answers of versions 2 to 4 have the shape the SDK sends. for (const testCase of fixture.negotiation.filter(({ expected }) => expected.version >= 2)) { assert.deepEqual(Object.keys(testCase.capabilities.experimental.litheFunctions).sort(), ['functions', 'version'], testCase.name); } @@ -290,11 +290,18 @@ test('subscription events reach their handler in order, also when sent before th }); test('commands, context changes, and later host notifications reach their handlers', async () => { + const COMMAND_DID_RUN = 'lithe/host/commands/didRun'; + const CONTEXTS_DID_CHANGE = 'lithe/host/workspace/didChangeContexts'; const delivered = fixture.hostNotifications.filter(({ expected, method }) => expected.delivered && method !== 'lithe/host/subscriptions/notify'); + // Commands and context changes have their own API. The other protocol 3 + // notifications (a run's exit, a language server's state and notifications) + // have no wrapper; the generic handler delivers each with its params as sent. const expected = [ - ...delivered.map(({ method, params }) => (method === 'lithe/host/commands/didRun' - ? ['command', { commandID: params.commandID, workspaceContext: params.context ?? null }] - : ['contexts', params])), + ...delivered.map(({ method, params }) => { + if (method === COMMAND_DID_RUN) return ['command', { commandID: params.commandID, workspaceContext: params.context ?? null }]; + if (method === CONTEXTS_DID_CHANGE) return ['contexts', params]; + return [method, params]; + }), ['later', { value: 1 }], ]; const backend = createBackend(); @@ -308,6 +315,11 @@ test('commands, context changes, and later host notifications reach their handle .onCommand('db.refresh', (event) => record(['command', event])) .onContextsChanged((event) => record(['contexts', event])) .onHostNotification('lithe/host/later/didSomething', (params) => record(['later', params])); + // A method gets one generic handler, however many fixture cases use it. + for (const method of new Set(delivered.map((entry) => entry.method))) { + if (method === COMMAND_DID_RUN || method === CONTEXTS_DID_CHANGE) continue; + backend.onHostNotification(method, (params) => record([method, params])); + } assert.throws(() => backend.onHostNotification('lithe/host/commands/didRun', () => {}), (error) => error.code === 'sdk.method.reserved'); assert.throws(() => backend.onHostNotification('workspace/didChange', () => {}), (error) => error.code === 'sdk.method.invalid'); assert.throws(() => backend.onCommand('DB_Refresh', () => {}), (error) => error.code === 'sdk.command.invalidID'); @@ -354,3 +366,383 @@ test('native terminal wrappers keep argv and context, and subscribe to one owned await disposing; } finally { await host.close(); } }); + +const RELAY_PROVIDERS = 'lithe/host/functions/providers'; +const RELAY_INVOKE = 'lithe/host/functions/invoke'; +/** The provider and function the fixture's relay cases name. */ +const PROVIDER = Object.freeze({ pluginID: 'dev.lithe.fixture.go', functionID: 'languageImport.settings' }); + +/** The fixture's host requests for one method. */ +const relayCases = (method) => fixture.hostRequests.filter((testCase) => testCase.method === method); + +/** A fixture invoke case as the SDK takes it: the context, and the call without it. */ +function splitInvoke({ params }) { + const { context, ...call } = params; + return { context, call }; +} + +test('context.caller is the relayed caller from protocol 4, read only from the top-level field', async () => { + // The fixture's cases: the wire message Core sends, and whether it holds a caller. + for (const testCase of fixture.invocationCallers) { + const seen = []; + const backend = createBackend().function(testCase.params.functionID, (_params, context) => { + seen.push(context.caller); + }); + const { host } = await session(testCase.version, backend); + const response = await host.request('lithe/function/invoke', testCase.expected.params); + assert.equal(response.error, undefined, testCase.name); + assert.deepEqual(seen, [testCase.expected.params.caller ?? null], testCase.name); + if (seen[0] !== null) assert.ok(Object.isFrozen(seen[0]), testCase.name); + await host.close(); + } + + // Anything but exactly { pluginID: non-empty string } at the top level is no caller. + const wire = { functionID: 'languageImport.settings', operationID: '7', params: {} }; + const seen = []; + const backend = createBackend().function('languageImport.settings', (params, context) => { + seen.push({ caller: context.caller, params }); + }); + const { host } = await session(4, backend); + const notCallers = [ + { caller: { pluginID: '' } }, + { caller: { pluginID: 7 } }, + { caller: {} }, + { caller: { pluginID: 'dev.lithe.fixture.python', extra: true } }, + { caller: 'dev.lithe.fixture.python' }, + { caller: null }, + { caller: ['dev.lithe.fixture.python'] }, + // A caller inside params is the calling plugin's own data. + { params: { caller: { pluginID: 'dev.lithe.fixture.other' } } }, + ]; + for (const extra of notCallers) { + const response = await host.request('lithe/function/invoke', { ...wire, ...extra }); + assert.equal(response.error, undefined, JSON.stringify(extra)); + } + assert.deepEqual(seen.map((entry) => entry.caller), notCallers.map(() => null)); + assert.deepEqual(seen.at(-1).params, { caller: { pluginID: 'dev.lithe.fixture.other' } }); + await host.close(); + + // A version 3 session never reads a caller, even when one is sent. + const older = []; + const { host: oldHost } = await session(3, createBackend().function('languageImport.settings', (_params, context) => { + older.push(context.caller); + })); + await oldHost.request('lithe/function/invoke', { ...wire, caller: { pluginID: 'dev.lithe.fixture.python' } }); + assert.deepEqual(older, [null]); + await oldHost.close(); +}); + +test('functions.providers sends the function ID and returns the host answer unwrapped', async () => { + const { backend, host } = await session(4); + const taken = new Set(); + const testCase = relayCases(RELAY_PROVIDERS).find(({ expected }) => expected.relay); + const listing = backend.host.functions.providers(testCase.params.functionID); + const request = await nextRequest(host, RELAY_PROVIDERS, taken); + assert.deepEqual(request.params, testCase.params); + const answer = { providers: [{ pluginID: 'dev.lithe.fixture.go', version: 1 }, { pluginID: 'dev.lithe.fixture.rust', version: 1 }] }; + host.send({ id: request.id, result: answer }); + assert.deepEqual(await listing, answer); + + const none = backend.host.functions.providers(testCase.params.functionID); + host.send({ id: (await nextRequest(host, RELAY_PROVIDERS, taken)).id, result: { providers: [] } }); + assert.deepEqual(await none, { providers: [] }); + await host.close(); +}); + +test('functions.invoke sends the fixture params and returns the host answer unwrapped', async () => { + const { backend, host } = await session(4); + const taken = new Set(); + const cases = relayCases(RELAY_INVOKE).filter(({ expected }) => expected.relay); + assert.ok(cases.some(({ params }) => params.parentOperationID !== undefined)); + assert.ok(cases.some(({ params }) => params.parentOperationID === undefined)); + for (const testCase of cases) { + const { context, call } = splitInvoke(testCase); + for (const value of [{ answer: 42 }, null]) { + const calling = backend.host.functions.invoke(context, call); + const request = await nextRequest(host, RELAY_INVOKE, taken); + // backend.host serves no call, so only an explicit parent is sent. + assert.deepEqual(request.params, testCase.params, testCase.name); + host.send({ id: request.id, result: { value } }); + assert.deepEqual(await calling, { value }, testCase.name); + } + } + // A full context from listContexts is passed on as its reference. + const calling = backend.host.functions.invoke({ ...CONTEXT, name: 'orders', folders: [] }, PROVIDER); + const request = await nextRequest(host, RELAY_INVOKE, taken); + assert.deepEqual(request.params, { ...PROVIDER, context: CONTEXT }); + host.send({ id: request.id, result: { value: 'ok' } }); + assert.deepEqual(await calling, { value: 'ok' }); + await host.close(); +}); + +test('inside a function, relayed calls default to the served call as their parent', async () => { + const backend = createBackend().function('project.describe', async ({ via, call }, context) => { + const caller = via === 'backend' ? backend.host : context.host; + await caller.functions.invoke(CONTEXT, call); + return null; + }); + const { host } = await session(4, backend); + const taken = new Set(); + const cases = [ + ['the served call', { operationID: 'op-1' }, 'context', PROVIDER, 'op-1'], + ['an explicit parent wins', { operationID: 'op-1' }, 'context', { ...PROVIDER, parentOperationID: 'explicit' }, 'explicit'], + ['backend.host has no served call', { operationID: 'op-1' }, 'backend', PROVIDER, undefined], + ['no wire operationID', {}, 'context', PROVIDER, undefined], + ['an operationID that is not a string', { operationID: 7 }, 'context', PROVIDER, undefined], + ['an empty operationID', { operationID: '' }, 'context', PROVIDER, undefined], + ['an operationID of 129 bytes', { operationID: 'o'.repeat(129) }, 'context', PROVIDER, undefined], + ['an operationID of 128 bytes', { operationID: 'o'.repeat(128) }, 'context', PROVIDER, 'o'.repeat(128)], + ['an operationID of 128 bytes in 64 characters', { operationID: 'é'.repeat(64) }, 'context', PROVIDER, 'é'.repeat(64)], + ['an operationID of 130 bytes in 65 characters', { operationID: 'é'.repeat(65) }, 'context', PROVIDER, undefined], + ]; + for (const [name, wire, via, call, parent] of cases) { + const done = host.request('lithe/function/invoke', { functionID: 'project.describe', params: { via, call }, ...wire }); + const request = await nextRequest(host, RELAY_INVOKE, taken); + if (parent === undefined) { + assert.ok(!('parentOperationID' in request.params), name); + } else { + assert.equal(request.params.parentOperationID, parent, name); + } + host.send({ id: request.id, result: { value: null } }); + assert.equal((await done).error, undefined, name); + } + await host.close(); +}); + +test('relayed calls from build and request handlers carry no default parent', async () => { + const backend = createBackend(); + backend.build.execute(async (_params, context) => { + await context.host.functions.invoke(CONTEXT, PROVIDER); + return { exitCode: 0, output: 'built\n' }; + }); + backend.onRequest('example/relay', async (_params, context) => { + await context.host.functions.invoke(CONTEXT, PROVIDER); + return null; + }); + const host = new TestHost(backend); + try { + await host.initialize({ litheFunctions: { versions: [1, 2, 3, 4] }, litheBuild: { versions: [4] } }); + const taken = new Set(); + for (const [method, params] of [['lithe/build/execute', { operationID: 'wire-execute' }], ['example/relay', { operationID: 'wire-relay' }]]) { + const done = host.request(method, params); + const request = await nextRequest(host, RELAY_INVOKE, taken); + assert.ok(!('parentOperationID' in request.params), method); + host.send({ id: request.id, result: { value: null } }); + assert.equal((await done).error, undefined, method); + } + } finally { await host.close(); } +}); + +test('under protocol 3 or earlier the relay methods fail locally and nothing is sent', async () => { + const refused = relayCases(RELAY_INVOKE).find(({ version, expected }) => version === 3 && expected.code === 'plugin.host.unavailable'); + const { context, call } = splitInvoke(refused); + const unavailable = (method) => (error) => + error instanceof HostError && error.code === 'plugin.host.unavailable' && error.rpcCode === -32601 && error.method === method; + for (const version of [3, 2]) { + const { backend, host } = await session(version); + const before = host.messages.length; + const label = `version ${version}`; + await assert.rejects(backend.host.functions.invoke(context, call), unavailable(RELAY_INVOKE), label); + await assert.rejects(backend.host.functions.providers(PROVIDER.functionID), unavailable(RELAY_PROVIDERS), label); + // The generic request is gated the same way. + await assert.rejects(backend.host.request(RELAY_INVOKE, refused.params), unavailable(RELAY_INVOKE), label); + await assert.rejects(backend.host.request(RELAY_PROVIDERS, { functionID: PROVIDER.functionID }), unavailable(RELAY_PROVIDERS), label); + if (version === 2) assert.equal(await backend.host.supports(RELAY_INVOKE), false); + assert.equal(host.messages.length, before, label); + await host.close(); + } + + // supports() tells a plugin whether to call: Core lists the methods only to a version 4 session. + const modern = await session(4); + const asking = modern.backend.host.supports(RELAY_INVOKE); + const request = await nextRequest(modern.host, 'lithe/host/getCapabilities'); + modern.host.send({ id: request.id, result: { services: { functions: { methods: [RELAY_PROVIDERS, RELAY_INVOKE] } } } }); + assert.equal(await asking, true); + assert.equal(await modern.backend.host.supports(RELAY_PROVIDERS), true); + await modern.host.close(); + + const older = await session(3); + const olderAsking = older.backend.host.supports(RELAY_INVOKE); + const olderRequest = await nextRequest(older.host, 'lithe/host/getCapabilities'); + older.host.send({ id: olderRequest.id, result: { services: { workspace: { methods: ['lithe/host/workspace/listContexts'] } } } }); + assert.equal(await olderAsking, false); + await older.host.close(); +}); + +test('relayed calls are checked locally, and a mistake sends no frame', async () => { + const { backend, host } = await session(4); + const before = host.messages.length; + const refusedWith = (code) => (error) => error instanceof PluginSdkError && error.code === code; + + // The fixture's calls that Core would answer -32602. + const refusals = relayCases(RELAY_INVOKE).filter(({ version, expected }) => version === 4 && expected.code === 'plugin.host.invalidParams'); + assert.ok(refusals.length >= 5); + for (const testCase of refusals) { + const { context, call } = splitInvoke(testCase); + const code = context === undefined ? 'sdk.host.invalidContext' : 'sdk.functions.invalidCall'; + await assert.rejects(backend.host.functions.invoke(context, call), refusedWith(code), testCase.name); + } + + const good = { ...PROVIDER }; + const badCalls = [ + ['no call', undefined], + ['a null call', null], + ['an array for a call', []], + ['an empty plugin ID', { ...good, pluginID: '' }], + ['a plugin ID that is not a string', { ...good, pluginID: 7 }], + ['no plugin ID', { functionID: good.functionID }], + ['an empty function ID', { ...good, functionID: '' }], + ['a function ID over 128 bytes', { ...good, functionID: 'a'.repeat(129) }], + ['null params', { ...good, params: null }], + ['string params', { ...good, params: 'x' }], + ['array params', { ...good, params: [] }], + ['a fractional timeout', { ...good, timeoutMilliseconds: 1.5 }], + ['a negative timeout', { ...good, timeoutMilliseconds: -1 }], + ['a timeout of NaN', { ...good, timeoutMilliseconds: Number.NaN }], + ['a timeout of Infinity', { ...good, timeoutMilliseconds: Number.POSITIVE_INFINITY }], + ['a timeout string', { ...good, timeoutMilliseconds: '5000' }], + ['a timeout beyond a safe integer', { ...good, timeoutMilliseconds: Number.MAX_SAFE_INTEGER + 1 }], + ['a null parent', { ...good, parentOperationID: null }], + ['an empty parent', { ...good, parentOperationID: '' }], + ['a parent of 129 bytes', { ...good, parentOperationID: 'p'.repeat(129) }], + ['a parent of 130 bytes in 65 characters', { ...good, parentOperationID: 'é'.repeat(65) }], + ]; + for (const [name, call] of badCalls) { + await assert.rejects(backend.host.functions.invoke(CONTEXT, call), refusedWith('sdk.functions.invalidCall'), name); + } + for (const context of [undefined, null, {}, { contextID: 'w1' }, { contextID: 'w1', generation: 0 }, { contextID: '../w', generation: 1 }]) { + await assert.rejects(backend.host.functions.invoke(context, good), refusedWith('sdk.host.invalidContext'), `context ${JSON.stringify(context)}`); + } + for (const functionID of [undefined, 7, '', 'Not-Valid', 'a..b', 'a'.repeat(129)]) { + await assert.rejects(backend.host.functions.providers(functionID), refusedWith('sdk.functions.invalidCall'), `function ID ${String(functionID)}`); + } + assert.equal(host.messages.length, before); + + // Limits Core enforces are not enforced twice: a long timeout is passed on as given. + const taken = new Set(); + const plain = backend.host.functions.invoke(CONTEXT, good); + const plainRequest = await nextRequest(host, RELAY_INVOKE, taken); + assert.deepEqual(Object.keys(plainRequest.params).sort(), ['context', 'functionID', 'pluginID']); + host.send({ id: plainRequest.id, result: { value: null } }); + await plain; + const long = backend.host.functions.invoke(CONTEXT, { ...good, timeoutMilliseconds: LIMITS.relayMaxTimeoutMilliseconds + 1 }); + const longRequest = await nextRequest(host, RELAY_INVOKE, taken); + assert.equal(longRequest.params.timeoutMilliseconds, LIMITS.relayMaxTimeoutMilliseconds + 1); + host.send({ id: longRequest.id, result: { value: null } }); + await long; + assert.deepEqual( + [LIMITS.relayDefaultTimeoutMilliseconds, LIMITS.relayMaxTimeoutMilliseconds, LIMITS.maxParentOperationIdBytes], + [60000, 600000, 128], + ); + await host.close(); +}); + +test('the wire params of a relayed call hold only the fields the contract defines', async () => { + const { backend, host } = await session(4); + // The fixture case where a caller tries to choose who the callee thinks called. + const injection = relayCases(RELAY_INVOKE).find(({ params }) => 'caller' in params); + const { context, call } = splitInvoke(injection); + assert.deepEqual(call.caller, { pluginID: 'dev.lithe.fixture.other' }); + const calling = backend.host.functions.invoke(context, { ...call, unknown: true }); + const request = await nextRequest(host, RELAY_INVOKE); + assert.deepEqual(request.params, { pluginID: call.pluginID, functionID: call.functionID, context }); + host.send({ id: request.id, result: { value: null } }); + await calling; + await host.close(); +}); + +test('relay failures become HostError by code, by number alone, and with the provider details', async () => { + const { backend, host } = await session(4); + const taken = new Set(); + const failWith = async (wire) => { + const calling = backend.host.functions.invoke(CONTEXT, PROVIDER); + const request = await nextRequest(host, RELAY_INVOKE, taken); + host.send({ id: request.id, error: wire }); + return calling.then(() => assert.fail('resolved'), (error) => error); + }; + assert.ok(fixture.relayErrors.length > 0); + for (const { name, code, rpcCode } of fixture.relayErrors) { + assert.equal(HOST_ERRORS[code], rpcCode, name); + const byCode = await failWith({ code: rpcCode, message: 'Refused.', data: { code } }); + const byNumber = await failWith({ code: rpcCode, message: 'Refused.' }); + for (const error of [byCode, byNumber]) { + assert.ok(error instanceof HostError && error instanceof PluginSdkError, name); + assert.deepEqual({ code: error.code, rpcCode: error.rpcCode, method: error.method }, { code, rpcCode, method: RELAY_INVOKE }, name); + } + } + + // The provider's own failure keeps its message and its code in the details. + const failed = await failWith({ + code: -32070, + message: 'The snapshot is locked.', + data: { code: 'plugin.function.failed', details: { pluginErrorCode: 'maven.snapshotLocked' } }, + }); + assert.equal(failed.code, 'plugin.function.failed'); + assert.equal(failed.message, 'The snapshot is locked.'); + assert.deepEqual(failed.details, { pluginErrorCode: 'maven.snapshotLocked' }); + await host.close(); +}); + +test('an aborted signal cancels a relayed call at Core, and an aborted one is never sent', async () => { + const { backend, host } = await session(4); + const controller = new AbortController(); + const pending = backend.host.functions.invoke(CONTEXT, PROVIDER, { signal: controller.signal }); + const request = await nextRequest(host, RELAY_INVOKE); + controller.abort(new Error('user stopped')); + await assert.rejects(pending, /user stopped/); + const cancel = await host.waitFor( + (message) => message.method === '$/cancelRequest' && message.params?.id === request.id, + '$/cancelRequest', + ); + assert.deepEqual(cancel.params, { id: request.id }); + // Core's late answer is dropped. + host.send({ id: request.id, error: { code: -32800, message: 'Cancelled.', data: { code: 'plugin.host.cancelled' } } }); + const before = host.messages.length; + await assert.rejects(backend.host.functions.invoke(CONTEXT, PROVIDER, { signal: controller.signal }), /user stopped/); + assert.equal(host.messages.length, before); + await host.close(); +}); + +test('cancelling the served call cancels its relayed call', async () => { + const started = gate(); + let outcome; + const backend = createBackend().function('project.describe', async (_params, context) => { + const calling = context.host.functions.invoke(CONTEXT, PROVIDER); + started.open(); + outcome = await calling.then(() => 'resolved', (error) => error); + throw outcome; + }); + const { host } = await session(4, backend); + const call = host.request('lithe/function/invoke', { functionID: 'project.describe', operationID: 'c' }, 41); + await withTimeout(started.opened, 'the relayed call'); + const request = await nextRequest(host, RELAY_INVOKE); + assert.equal(request.params.parentOperationID, 'c'); + host.notify('$/cancelRequest', { id: 41 }); + assert.equal((await call).error.code, -32800); + assert.equal(outcome?.name, 'AbortError'); + const cancels = host.messages.filter((message) => message.method === '$/cancelRequest'); + assert.deepEqual(cancels.map((message) => message.params), [{ id: request.id }]); + // Core's late answer is dropped; the session continues. + host.send({ id: request.id, error: { code: -32800, message: 'Cancelled.', data: { code: 'plugin.host.cancelled' } } }); + await host.close(); +}); + +test('a relayed call holds one of the 16 host request slots until Core answers', async () => { + const { backend, host } = await session(4); + const max = LIMITS.maxPendingHostRequests; + const calls = Array.from({ length: max + 2 }, () => backend.host.functions.invoke(CONTEXT, PROVIDER)); + // Settled before the session ends, so no rejection goes unobserved. + const settled = Promise.allSettled(calls); + const sent = () => host.messages.filter((message) => message.method === RELAY_INVOKE); + await host.waitFor(() => sent().length === max, 'the first 16 relayed calls'); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(sent().length, max); + host.send({ id: sent()[0].id, result: { value: null } }); + await host.waitFor(() => sent().length === max + 1, 'the 17th relayed call'); + assert.equal(sent().length, max + 1); + // Ending the session fails the queued call too. + await host.close(); + const outcomes = await settled; + assert.equal(outcomes[0].status, 'fulfilled'); + assert.ok(outcomes.slice(1).every((outcome) => outcome.status === 'rejected' && outcome.reason.code === 'sdk.session.closed')); +}); diff --git a/sdk/node/test/types/host.mts b/sdk/node/test/types/host.mts index 474c61b..c24abcd 100644 --- a/sdk/node/test/types/host.mts +++ b/sdk/node/test/types/host.mts @@ -1,5 +1,5 @@ // Check the host-service declarations the way a plugin uses them. -import { createBackend, HOST_ERRORS, HostError, type ContextReference, type Subscription } from '@lithe/plugin-sdk'; +import { createBackend, HOST_ERRORS, HostError, type ContextReference, type JsonValue, type Subscription } from '@lithe/plugin-sdk'; const backend = createBackend(); const version: number | null = backend.functionsVersion; @@ -20,6 +20,43 @@ backend.function('query.run', async (_params, context) => { return { truncated, choice }; }); +backend.function('project.describe', async (_params, context) => { + // The calling plugin of a relayed call, or null (protocol 4). + const caller: { readonly pluginID: string } | null = context.caller; + const callerID: string | undefined = caller?.pluginID; + void callerID; + if (caller) { + // @ts-expect-error The caller is read-only. + caller.pluginID = 'dev.example.other'; + } + const workspace = context.workspaceContext; + if (!workspace) return null; + const { providers } = await context.host.functions.providers('languageImport.settings'); + const provider: { pluginID: string; version: number } | undefined = providers[0]; + if (!provider) return null; + const { value } = await context.host.functions.invoke(workspace, { + pluginID: provider.pluginID, + functionID: 'languageImport.settings', + params: { project: 'app' }, + timeoutMilliseconds: 5000, + parentOperationID: 'op-1', + }, { signal: new AbortController().signal }); + const result: JsonValue = value; + // The minimum a call needs is the provider and the function. + await backend.host.functions.invoke(workspace, { pluginID: provider.pluginID, functionID: 'languageImport.settings' }); + // @ts-expect-error A relayed call names the plugin that provides the function. + await context.host.functions.invoke(workspace, { functionID: 'languageImport.settings' }); + // @ts-expect-error A relayed call's params are an object. + await context.host.functions.invoke(workspace, { pluginID: provider.pluginID, functionID: 'languageImport.settings', params: 'x' }); + // @ts-expect-error A parent operation ID is a string. + await context.host.functions.invoke(workspace, { pluginID: provider.pluginID, functionID: 'languageImport.settings', parentOperationID: 17 }); + // @ts-expect-error A relayed call takes the workspace context first. + await context.host.functions.invoke({ pluginID: provider.pluginID, functionID: 'languageImport.settings' }); + // @ts-expect-error The providers of a function are asked by its function ID. + await context.host.functions.providers({ functionID: 'languageImport.settings' }); + return result; +}); + backend.onCommand('db.refresh', async ({ commandID, workspaceContext }) => { const id: string = commandID; void id; @@ -66,6 +103,24 @@ async function edit(context: ContextReference) { } void edit; +async function relayFailure(context: ContextReference) { + const failedNumber: -32070 = HOST_ERRORS['plugin.function.failed']; + const cycleNumber: -32065 = HOST_ERRORS['plugin.function.callCycle']; + void failedNumber; void cycleNumber; + try { + await backend.host.functions.invoke(context, { pluginID: 'dev.example.go', functionID: 'languageImport.settings' }); + } catch (error) { + if (error instanceof HostError && error.code === 'plugin.function.failed') { + // The provider's own error code, set by the provider's function. + const pluginErrorCode: JsonValue | undefined = error.details?.pluginErrorCode; + void pluginErrorCode; + } + } + // @ts-expect-error Only the codes in the contract are keys of HOST_ERRORS. + void HOST_ERRORS['plugin.function.unknown']; +} +void relayFailure; + async function nativeTerminal(context: ContextReference) { const created = await backend.host.terminal.create(context, { executable: 'example-client', arguments: ['--interactive'], environment: { MODE: 'dev', OLD_MODE: null }, diff --git a/sdk/rust/Cargo.lock b/sdk/rust/Cargo.lock index 6b10f06..473bdd0 100644 --- a/sdk/rust/Cargo.lock +++ b/sdk/rust/Cargo.lock @@ -16,7 +16,7 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "lithe-plugin-sdk" -version = "0.2.0" +version = "0.3.0" dependencies = [ "base64", "serde_json", @@ -48,7 +48,7 @@ dependencies = [ [[package]] name = "sample-backend" -version = "0.2.0" +version = "0.3.0" dependencies = [ "lithe-plugin-sdk", "serde_json", diff --git a/sdk/rust/Cargo.toml b/sdk/rust/Cargo.toml index f737b1e..4e4f712 100644 --- a/sdk/rust/Cargo.toml +++ b/sdk/rust/Cargo.toml @@ -3,7 +3,7 @@ members = ["plugin-sdk", "examples/sample-backend"] resolver = "2" [workspace.package] -version = "0.2.0" +version = "0.3.0" edition = "2021" license = "Apache-2.0" # std::io::pipe, used by the tests. diff --git a/sdk/rust/README.md b/sdk/rust/README.md index f1bb266..0d2cf95 100644 --- a/sdk/rust/README.md +++ b/sdk/rust/README.md @@ -8,13 +8,15 @@ The backend is a packaged executable, so a plugin ships one archive per platform. It speaks the LSP base protocol on stdio and implements, from [plugin contract v1](../../contract/v1/README.md): -- **`litheFunctions` 1 to 3**: plugin functions with progress, +- **`litheFunctions` 1 to 4**: plugin functions with progress, cancellation, and registration at runtime. The SDK answers the newest version the host offers. -- **Host services** of versions 2 and 3: the plugin's own credentials, kept - by the host in the platform's secret store (the Keychain on macOS), and - from version 3 workspace contexts, files, documents, editors, preview, - the browser, window messages, commands, and subscriptions. +- **Host services** of versions 2 to 4: the plugin's own credentials, kept + by the host in the platform's secret store (the Keychain on macOS), from + version 3 workspace contexts, files, documents, editors, preview, + the browser, window messages, commands, and subscriptions, and from + version 4 [calls to the functions other plugins export](#calling-other-plugins), + relayed by Core, with the `Caller` a function called that way sees. - **`litheKeymapImport` 1** (from 0.2.0): independently negotiated shortcut import providers reading only explicitly selected source snapshots. @@ -90,6 +92,14 @@ Releases are published on [this repository's Releases page](https://github.com/Lithe-IDEA/lithe-plugin-docs/releases) and tagged `sdk-rust-v`. Version 0.2.0 adds shortcut import providers, native terminals, and typed diagnostics through `litheLogging` 1, and removes the free-text `log` API. +Version 0.3.0 adds plugin-function protocol 4: `host().functions()` to list +and call the functions other plugins export, `Context::caller()` with the +`Caller` type, and the `Functions` and `RELAY_ERROR_CODES` exports. Under a +host that offers protocol 3 or older the two relay methods are refused +locally and nothing is sent. The table that gives a refusal carrying only a +JSON-RPC number its code now covers the 5 host error codes -32022 to -32026 +(`HOST_ERROR_CODES` has 21 entries) and the 11 `plugin.function.*` codes of +`RELAY_ERROR_CODES`. The SDK is not published to crates.io. Either way below fetches it at build time, never at runtime, and pins its exact contents: @@ -99,7 +109,7 @@ exact contents: ```toml [dependencies] - lithe-plugin-sdk = { git = "https://github.com/Lithe-IDEA/lithe-plugin-docs", tag = "sdk-rust-v0.2.0" } + lithe-plugin-sdk = { git = "https://github.com/Lithe-IDEA/lithe-plugin-docs", tag = "sdk-rust-v0.3.0" } ``` - **Vendored archive.** A release ships `lithe-plugin-sdk-rust-.crate` @@ -150,6 +160,7 @@ context offers: | `progress(message, percentage)` | The message is cut to 1024 bytes and the percentage to 100; reports after the call ended are dropped. | | `host()` | Host services bound to the call: a request still waiting for the host when the call is cancelled ends at once. `credentials()` is `host().credentials()`. | | `workspace_context()` | From protocol 3, the `WorkspaceContext` of the window whose page made the call; `None` under older protocols or when no window made it. | +| `caller()` | From protocol 4, the `Caller` (`plugin_id()`) of the plugin whose backend called this function through Core's relay; `None` when the host started the call, under older protocols, and when the host's `caller` is not exactly `{ pluginID }` with a non-empty string. | | `backend()`, `function_id()`, `diagnostics()` | The handle, the function's ID, and typed diagnostics correlated with this call. | ## Host services @@ -201,7 +212,8 @@ backend | `workspace().read_directory`, `.stat`, `.read_file`, `.find_files`, `.find_text`, `.watch` | 3 | Files of a local context, addressed as `{ rootID, path }` from `get_folders`. | | `documents().list`, `.get_snapshot`; `editor().get_active`, `.get_selections`, `.apply_edits`, `.replace_selections`, `.insert_text`, `.open`, `.reveal`, `.register_actions` | 3 | Live documents and editors. Edits name the `expectedVersion` they were computed against. | | `preview().open_html`, `browser().open_external`, `window().show_information`, `.show_warning`, `.show_error` | 3 | Preview, browser, and messages. | -| `request(method, params)`, `subscribe(method, params)` | 2 / 3 | Any `lithe/host/*` method, including ones a later host adds. | +| `functions().providers(function_id)`, `.invoke(&context, call)` | 4 | The plugins that export a function, and a call to one of them; see [Calling other plugins](#calling-other-plugins). | +| `request(method, params)`, `subscribe(method, params)` | 2 / 3 | Any `lithe/host/*` method, including ones a later host adds. Under protocol 3 or older the two relay methods are refused locally. | | `subscriptions().dispose(id)` | 3 | Ends a subscription; `Subscription::dispose()` does the same. | | `with_cancellation(&cancellation)` | 2 | The same services, cancelled together with `cancellation`. | | `version()` | | The negotiated plugin-function version; `None` before `initialize`. | @@ -263,6 +275,112 @@ nothing is stored. | `sdk.call.cancelled` | The call was cancelled while waiting; `?` answers as cancelled. | | `sdk.session.notReady`, `sdk.session.closed` | The session has not finished `initialize`, or it ended while waiting. | +### Calling other plugins + +From plugin-function protocol 4 a backend may call a function another plugin +exports; Core relays the call and the provider's backend never learns more +than the call it serves. The manifest declares both sides: the caller lists +`{ id, version }` under `pluginFunctions.calls`, and the provider lists +`{ id, version, context: "workspace" }` under `pluginFunctions.exports` and +registers the function with `backend.function(id, handler)`. See +[cross-plugin calls](../../contract/v1/README.md#cross-plugin-calls-protocol-version-4) +for the manifest, the order of Core's checks, and the call chain. + +```rust +backend + .function("project.options", |_params, context| { + let workspace = context + .workspace_context() + .ok_or_else(|| PluginError::new("example.noWorkspace", "Open a folder first."))?; + let host = context.host(); + if !host.supports("lithe/host/functions/invoke")? { + return Ok(json!({ "options": [] })); + } + let providers = host.functions().providers("project.describe")?; + let Some(provider) = providers["providers"][0]["pluginID"].as_str() else { + return Ok(json!({ "options": [] })); + }; + let answer = host.functions().invoke( + workspace, + json!({ + "pluginID": provider, + "functionID": "project.describe", + "params": { "project": "app" }, + "timeoutMilliseconds": 5000, + }), + )?; + Ok(json!({ "options": answer["value"] })) + }) + .expect("a valid function ID"); + +backend + .function("project.describe", |params, context| { + // Core wrote the caller from the calling session; it cannot be forged. + let caller = context.caller().map(|caller| caller.plugin_id()); + Ok(json!({ "project": params["project"], "askedBy": caller })) + }) + .expect("a valid function ID"); +``` + +- **Check before calling.** `supports("lithe/host/functions/invoke")` and + `supports("lithe/host/functions/providers")` tell whether this session may + call. Protocol 4 alone is not enough: a plugin that declares no `calls` gets + `plugin.host.permissionDenied`. Under protocol 3 or older, `providers`, + `invoke`, and `host().request` with either method fail locally with + `plugin.host.unavailable` and send nothing. +- **`providers(function_id)`** answers `{ "providers": [{ "pluginID", + "version" }] }`: the enabled plugins other than this one that export the + function, sorted by plugin ID. This plugin must have declared the function, + or the host answers `plugin.function.notDeclared`. A provider's backend need + not be running. +- **`invoke(&context, call)`** answers `{ "value": ... }`, where `value` is the + provider's result and may be `null`. `context` is a `WorkspaceContext` this + backend learned from the workspace service or `context.workspace_context()`; + the relay accepts local workspaces only. `call` is an object with + `pluginID` and `functionID`, and optionally `params` (an object, `{}` by + default), `timeoutMilliseconds` (a positive integer; the host defaults it to + 60000 and caps it at 600000, and the SDK does not clamp it), and + `parentOperationID`. Any other key, such as `context` or `caller`, is not + sent; the host would refuse it as `plugin.host.unsupportedOption`. A call + that is not an object, lacks `pluginID` or `functionID`, or has a field of + the wrong type fails locally with `sdk.functions.invalidCall`, and a + `function_id` that is not a function ID does the same for `providers`. +- **Limits are the host's.** A chain has at most 4 calls and does not return + to a plugin already on it (`plugin.function.callCycle`); a caller has at + most 8 relayed calls running; params and a result are at most 256 KiB. A + provider that is asked to apply values must not call the plugin that asked + inside that call. +- **`parentOperationID` is filled in for you inside a handler.** A call made + with `context.host().functions().invoke(...)` while a function runs names + that call's `operationID`, so Core makes it a child: cancelling or ending + the handler's call cancels the relayed call. An explicit + `parentOperationID` in `call` overrides it (1 to 128 bytes). A + `handle.host()` serves no call and a generic `request` is sent as written, + so neither fills it in. An ID that names a call this backend is no longer + serving fails with `plugin.host.cancelled`. +- **A relayed call occupies a request slot.** It counts among the 16 + unanswered host requests until the provider answers, which can take up to + 600 seconds; further requests wait in the SDK. Keep relayed calls bounded + with `timeoutMilliseconds`. +- **`context.caller()` names the calling plugin.** It is `Some(Caller)` only + for a call Core relayed under protocol 4, and `None` for a call the host + started, which includes every call before protocol 4. The SDK accepts only + a `caller` that is exactly `{ "pluginID": "" }`; any other + shape is read as no caller. It names who called, not what they may do: + authorize with the plugin's own rules. + +| Failure (`SdkError::code()`) | Meaning | +| --- | --- | +| `sdk.functions.invalidCall` | The `call` or the function ID was malformed. Nothing was sent. | +| `plugin.host.unavailable` | The host negotiated protocol 3 or older. Nothing was sent. | +| `plugin.host.permissionDenied` | The plugin declares no `pluginFunctions.calls`. | +| `plugin.function.notDeclared`, `.providerDisabled`, `.notExported`, `.versionMismatch` | The call is not declared, the provider is missing or disabled, does not export the function now, or exports another version. | +| `plugin.function.callCycle`, `.depthExceeded`, `.tooManyCalls`, `.payloadTooLarge` | The chain, the caller's running calls, or a payload exceeded a limit. | +| `plugin.function.providerUnavailable`, `.invalidResult` | The provider's backend stopped or did not start in time, or answered with no result or one over 1 MiB. | +| `plugin.function.failed` | The provider's function returned an error. The `pluginErrorCode` entry of `details()` is the provider's own `PluginError` code, and `message()` is its message. | +| `plugin.host.contextExpired`, `plugin.host.authorityUnsupported`, `plugin.host.timeout`, `plugin.host.cancelled` | The context was closed or replaced, is not a local workspace, the deadline passed, or the call (or its parent) was cancelled. | +| `sdk.host.invalidResult` | The host answered with something other than an object. | + ## Results and errors | Handler outcome | What Lithe sees | @@ -300,7 +418,9 @@ panics on any other code. 5. **No calls back into Lithe's dispatch.** The SDK never sends `lithe/function/invoke` or `lithe/build/*`; Lithe would refuse them as `plugin.function.recursiveCall`. `Host::request` sends only - `lithe/host/*` and refuses anything else as `sdk.host.method`. + `lithe/host/*` and refuses anything else as `sdk.host.method`. To call + another plugin, use [`host().functions()`](#calling-other-plugins), which + sends `lithe/host/functions/invoke` and lets Core relay it. ## Testing diff --git a/sdk/rust/plugin-sdk/src/backend.rs b/sdk/rust/plugin-sdk/src/backend.rs index 1bcb4c2..aba68de 100644 --- a/sdk/rust/plugin-sdk/src/backend.rs +++ b/sdk/rust/plugin-sdk/src/backend.rs @@ -16,8 +16,9 @@ use crate::framing::{read_frame, write_frame, Frame}; use crate::host::{Channel, Credentials, Host, Outcome, Outgoing, WorkspaceContext}; use crate::panic_boundary::catch as catch_unwind; use crate::protocol::{ - bounded_text, error_codes, is_command_id, is_function_id, methods, CONTEXT_VERSION, - FUNCTIONS_VERSIONS, MAX_FUNCTIONS, MAX_MESSAGE_BYTES, MAX_RESULT_BYTES, + bounded_text, error_codes, is_command_id, is_function_id, is_parent_operation_id, methods, + CONTEXT_VERSION, FUNCTIONS_VERSIONS, MAX_FUNCTIONS, MAX_MESSAGE_BYTES, MAX_RESULT_BYTES, + RELAY_VERSION, }; use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; use serde_json::{json, Map, Value}; @@ -835,6 +836,7 @@ impl Shared { ), cancellation, workspace_context: None, + caller: None, reporting: AtomicBool::new(false), quiet_success: AtomicBool::new(false), }; @@ -860,6 +862,11 @@ impl Shared { .unwrap_or_default() .to_string(); let operation_id = params["operationID"].clone(); + // What nested cross-plugin calls name as the call they belong to. + let parent_operation = operation_id + .as_str() + .filter(|operation| is_parent_operation_id(operation)) + .map(str::to_string); let call_params = match params.get("params") { None | Some(Value::Null) => json!({}), Some(params) => params.clone(), @@ -872,6 +879,10 @@ impl Shared { .functions_version .filter(|version| *version >= CONTEXT_VERSION) .and_then(|_| WorkspaceContext::from_reference(¶ms["context"])); + let caller = state + .functions_version + .filter(|version| *version >= RELAY_VERSION) + .and_then(|_| Caller::from_value(¶ms["caller"])); let handler = state .functions_version .and_then(|_| state.functions.get(&function_id).cloned()); @@ -884,9 +895,9 @@ impl Shared { }, ); } - handler.map(|handler| (handler, workspace_context)) + handler.map(|handler| (handler, workspace_context, caller)) }; - let Some((handler, workspace_context)) = handler else { + let Some((handler, workspace_context, caller)) = handler else { return shared.respond( &id, Err(rpc_error( @@ -913,9 +924,11 @@ impl Shared { host: Host::new( Arc::clone(&worker), vec![cancellation.clone(), ended.clone()], - ), + ) + .with_parent_operation(parent_operation), cancellation, workspace_context, + caller, reporting: AtomicBool::new(true), quiet_success: AtomicBool::new(false), }; @@ -1115,10 +1128,39 @@ pub struct Context { cancellation: Cancellation, host: Host, workspace_context: Option, + caller: Option, reporting: AtomicBool, quiet_success: AtomicBool, } +/// The plugin that called a function through Core's relay (protocol 4). +/// Core writes it from the identity of the calling session and a caller +/// cannot supply it, so a provider may key per-caller state by it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Caller { + plugin_id: String, +} + +impl Caller { + /// The `caller` field of a relayed `lithe/function/invoke`, which is + /// exactly `{ pluginID }` with a non-empty string. + fn from_value(value: &Value) -> Option { + let fields = value.as_object().filter(|fields| fields.len() == 1)?; + let plugin_id = fields + .get("pluginID")? + .as_str() + .filter(|plugin_id| !plugin_id.is_empty())?; + Some(Self { + plugin_id: plugin_id.to_string(), + }) + } + + /// The calling plugin's ID, such as `dev.lithe.fixture.python`. + pub fn plugin_id(&self) -> &str { + &self.plugin_id + } +} + impl Context { /// Omits only this call's successful terminal fact; failures remain visible. pub fn suppress_success_diagnostics(&self) { @@ -1183,6 +1225,13 @@ impl Context { self.workspace_context.as_ref() } + /// The plugin that called this function through Core's relay (protocol + /// 4). `None` when the host started the call, which includes every call + /// before protocol 4, and for a `caller` of any other shape. + pub fn caller(&self) -> Option<&Caller> { + self.caller.as_ref() + } + /// `host().credentials()`. pub fn credentials(&self) -> Credentials { self.host.credentials() diff --git a/sdk/rust/plugin-sdk/src/error.rs b/sdk/rust/plugin-sdk/src/error.rs index 207440f..30fe88f 100644 --- a/sdk/rust/plugin-sdk/src/error.rs +++ b/sdk/rust/plugin-sdk/src/error.rs @@ -89,9 +89,9 @@ pub mod sdk_codes { pub const SESSION_CLOSED: &str = "sdk.session.closed"; } -/// Stable host failure codes of plugin-function protocol 3 and their -/// JSON-RPC numbers. A later host adds codes after `-32021`. -pub const HOST_ERROR_CODES: [(&str, i64); 16] = [ +/// Stable `plugin.host.*` failure codes as of plugin-function protocol 4 and +/// their JSON-RPC numbers. A later host adds codes after `-32026`. +pub const HOST_ERROR_CODES: [(&str, i64); 21] = [ ("plugin.host.unavailable", -32601), ("plugin.host.invalidParams", -32602), ("plugin.host.failed", -32603), @@ -108,6 +108,31 @@ pub const HOST_ERROR_CODES: [(&str, i64); 16] = [ ("plugin.host.preconditionFailed", -32019), ("plugin.host.busy", -32020), ("plugin.host.rateLimited", -32021), + ("plugin.host.languageServerUnavailable", -32022), + ("plugin.host.languageServerError", -32023), + ("plugin.host.methodNotDeclared", -32024), + ("plugin.host.toolUnresolved", -32025), + ("plugin.host.foreignContribution", -32026), +]; + +/// The `plugin.function.*` failures a relayed call from another plugin +/// (plugin-function protocol 4) may end with, and their JSON-RPC numbers. The +/// relay's `plugin.host.*` outcomes (`timeout`, `cancelled`, `contextExpired`, +/// `authorityUnsupported`) are in [`HOST_ERROR_CODES`]. A +/// `plugin.function.failed` answer names the provider's own code in its +/// `pluginErrorCode` detail. +pub const RELAY_ERROR_CODES: [(&str, i64); 11] = [ + ("plugin.function.notDeclared", -32027), + ("plugin.function.notExported", -32028), + ("plugin.function.providerDisabled", -32029), + ("plugin.function.versionMismatch", -32030), + ("plugin.function.callCycle", -32065), + ("plugin.function.depthExceeded", -32066), + ("plugin.function.tooManyCalls", -32067), + ("plugin.function.payloadTooLarge", -32068), + ("plugin.function.providerUnavailable", -32069), + ("plugin.function.failed", -32070), + ("plugin.function.invalidResult", -32071), ]; impl SdkError { @@ -129,7 +154,8 @@ impl SdkError { } /// The failure for a JSON-RPC error answer. A host method without a - /// known code is a host failure: from version 3 a number names one code, + /// known code is a host failure: from version 3 a number names one code + /// (of [`HOST_ERROR_CODES`], or from version 4 of [`RELAY_ERROR_CODES`]), /// and an unknown number is `plugin.host.failed`. pub(crate) fn from_response(method: &str, error: &Value) -> Self { let rpc_code = error["code"].as_i64(); @@ -137,6 +163,7 @@ impl SdkError { Some(code) => code, None if method.starts_with(methods::HOST_PREFIX) => HOST_ERROR_CODES .iter() + .chain(RELAY_ERROR_CODES.iter()) .find(|(_, number)| Some(*number) == rpc_code) .map_or("plugin.host.failed", |(code, _)| code), None => "sdk.host.error", @@ -306,6 +333,57 @@ mod tests { assert_eq!(other.code(), "sdk.host.error"); } + #[test] + fn the_host_and_relay_tables_hold_every_documented_code_once() { + assert_eq!(HOST_ERROR_CODES.len(), 21); + assert_eq!(RELAY_ERROR_CODES.len(), 11); + let mut codes = std::collections::BTreeSet::new(); + let mut numbers = std::collections::BTreeSet::new(); + for (prefix, table) in [ + ("plugin.host.", &HOST_ERROR_CODES[..]), + ("plugin.function.", &RELAY_ERROR_CODES[..]), + ] { + for (code, number) in table { + assert!(code.starts_with(prefix), "{code}"); + assert!(codes.insert(*code), "{code} is listed twice"); + assert!(numbers.insert(*number), "{number} names two codes"); + } + } + } + + #[test] + fn relay_failures_keep_their_code_number_and_the_providers_own_code() { + for (code, number) in RELAY_ERROR_CODES { + // A number alone names the code. + let error = + SdkError::from_response(methods::FUNCTIONS_INVOKE, &json!({ "code": number })); + assert_eq!(error.code(), code); + assert_eq!(error.rpc_code(), Some(number)); + assert_eq!(error.message(), "lithe/host/functions/invoke failed."); + } + let failed = SdkError::from_response( + methods::FUNCTIONS_INVOKE, + &json!({ + "code": -32070, + "message": "The plugin function failed.", + "data": { + "code": "plugin.function.failed", + "details": { "pluginErrorCode": "go.noModule" } + } + }), + ); + assert_eq!(failed.code(), "plugin.function.failed"); + assert_eq!(failed.message(), "The plugin function failed."); + assert_eq!( + failed.details(), + json!({ "pluginErrorCode": "go.noModule" }).as_object() + ); + // Outside a host method a relay number stays an unnamed answer. + let outside = + SdkError::from_response(methods::FUNCTION_REGISTER, &json!({ "code": -32070 })); + assert_eq!(outside.code(), "sdk.host.error"); + } + #[test] #[should_panic(expected = "must start with a letter")] fn an_invalid_plugin_error_code_is_a_programming_error() { diff --git a/sdk/rust/plugin-sdk/src/host.rs b/sdk/rust/plugin-sdk/src/host.rs index 6a95364..2986c93 100644 --- a/sdk/rust/plugin-sdk/src/host.rs +++ b/sdk/rust/plugin-sdk/src/host.rs @@ -1,4 +1,4 @@ -//! Host services of plugin-function protocols 2 and 3: the `lithe/host/*` +//! Host services of plugin-function protocols 2 to 4: the `lithe/host/*` //! requests a backend sends to Lithe, the subscriptions some of them create, //! and the [`Host`] handlers use. The backend owns the wire; this module owns //! what Core expects of a well-behaved client -- at most 16 unanswered host @@ -6,13 +6,14 @@ //! subscription events routed by ID. //! //! Contract: `contract/v1/README.md`, "Host services (protocol version 2)" -//! and the version 3 sections after it. +//! and the version 3 and 4 sections after it. use crate::backend::{lock, Cancellation, Phase, Shared, State}; use crate::error::{sdk_codes, SdkError}; use crate::protocol::{ - generation, is_credential_key, methods, CONTEXT_VERSION, HOST_SERVICES_VERSION, - MAX_CREDENTIAL_SECRET_BYTES, MAX_EARLY_EVENTS, MAX_PENDING_HOST_REQUESTS, + generation, is_credential_key, is_function_id, is_parent_operation_id, methods, + CONTEXT_VERSION, HOST_SERVICES_VERSION, MAX_CREDENTIAL_SECRET_BYTES, MAX_EARLY_EVENTS, + MAX_PENDING_HOST_REQUESTS, RELAY_VERSION, }; use serde_json::{json, Map, Value}; use std::collections::{HashMap, VecDeque}; @@ -340,11 +341,12 @@ impl Shared { } } -/// Host services (`lithe/host/*`) of plugin-function protocols 2 and 3: +/// Host services (`lithe/host/*`) of plugin-function protocols 2 to 4: /// credentials from version 2; workspace contexts, files, documents, the /// editor, previews, the browser, messages, and capability discovery from -/// version 3. Every method blocks until the host answers; failures the host -/// reports carry its `error.data.code`, such as `plugin.host.contextExpired`. +/// version 3; calls to other plugins' functions from version 4. Every method +/// blocks until the host answers; failures the host reports carry its +/// `error.data.code`, such as `plugin.host.contextExpired`. /// /// [`BackendHandle::host`](crate::BackendHandle::host) is usable once the /// session is ready. A handler's [`Context::host`](crate::Context::host) @@ -356,11 +358,25 @@ impl Shared { pub struct Host { shared: Arc, scope: Vec, + /// The `operationID` of the relayed call this host serves, if any; the + /// default `parentOperationID` of its cross-plugin calls. + parent_operation: Option, } impl Host { pub(crate) fn new(shared: Arc, scope: Vec) -> Self { - Self { shared, scope } + Self { + shared, + scope, + parent_operation: None, + } + } + + /// The same host for a call serving the relayed `lithe/function/invoke` + /// whose `operationID` is `parent`. + pub(crate) fn with_parent_operation(mut self, parent: Option) -> Self { + self.parent_operation = parent; + self } /// The negotiated plugin-function protocol version; `None` before @@ -375,6 +391,7 @@ impl Host { let mut scope = self.scope.clone(); scope.push(cancellation.clone()); Self::new(Arc::clone(&self.shared), scope) + .with_parent_operation(self.parent_operation.clone()) } /// Sends any `lithe/host/*` request, including methods a later host @@ -500,6 +517,11 @@ impl Host { Window { host: self.clone() } } + /// Calls to other plugins' functions through Core, from protocol 4. + pub fn functions(&self) -> Functions { + Functions { host: self.clone() } + } + fn send(&self, method: &str, params: Value, route: Option) -> Outcome { if !method.starts_with(methods::HOST_PREFIX) { return Err(SdkError::new( @@ -516,6 +538,12 @@ impl Host { "The host did not negotiate plugin-function protocol 2 or later, which offers host services.", )); } + if methods::is_relay(method) && self.version() < Some(RELAY_VERSION) { + // The answer Core would give, without the round trip. + return Err(SdkError::unavailable( + "The cross-plugin relay needs plugin-function protocol 4.", + )); + } if self.scope.iter().any(Cancellation::is_cancelled) { return Err(cancelled()); } @@ -535,6 +563,17 @@ impl Host { )), } } + + /// A method that always answers an object. + fn request_object(&self, method: &str, params: Value) -> Outcome { + match self.request(method, params)? { + result @ Value::Object(_) => Ok(result), + _ => Err(SdkError::new( + "sdk.host.invalidResult", + format!("{method} answered with something other than an object."), + )), + } + } } /// `params` with `context` added; params are an object or `Value::Null`. @@ -1092,6 +1131,100 @@ impl Terminal { } } +/// Calls to the functions other plugins export, relayed by Core (protocol 4). +/// Needs a call the plugin declares as `pluginFunctions.calls` in its +/// manifest; discover the methods with `Host::supports`. The relay accepts +/// local workspaces only. +pub struct Functions { + host: Host, +} + +impl Functions { + /// `{ providers }`: the enabled plugins other than this one that export + /// `function_id`, sorted by plugin ID, each `{ pluginID, version }`. This + /// plugin must have declared the function. + pub fn providers(&self, function_id: &str) -> Result { + if !is_function_id(function_id) { + return Err(invalid_call("functionID is a plugin function ID.")); + } + self.host.request_object( + methods::FUNCTIONS_PROVIDERS, + json!({ "functionID": function_id }), + ) + } + + /// Calls one provider and answers `{ value }`, the provider's result. + /// `call` is an object with `pluginID` and `functionID`, and optionally + /// `params` (an object), `timeoutMilliseconds` (a positive integer; the + /// host defaults it and caps it at 600000), and `parentOperationID`; any + /// other key is not sent. `context` is a workspace context this backend + /// learned from the workspace service. + /// + /// Inside a handler, `parentOperationID` defaults to the `operationID` + /// of the call the handler serves, which tells Core the new call belongs + /// to it. A [`BackendHandle::host`](crate::BackendHandle::host) serves no + /// call, so it sends the field only when `call` sets it. Cancelling the + /// handler's call cancels the relayed call. + pub fn invoke(&self, context: &WorkspaceContext, call: Value) -> Result { + let call = relay_params(call, self.host.parent_operation.as_deref())?; + let params = with_context(context, call)?; + self.host.request_object(methods::FUNCTIONS_INVOKE, params) + } +} + +fn invalid_call(message: &str) -> SdkError { + SdkError::new("sdk.functions.invalidCall", message) +} + +/// The params of `lithe/host/functions/invoke` for `call`, without its +/// context: the fields the method defines and no other, so the host never +/// answers `-32018`. `serving` is the `operationID` that `parentOperationID` +/// defaults to. +fn relay_params(call: Value, serving: Option<&str>) -> Result { + let Value::Object(mut call) = call else { + return Err(invalid_call("A cross-plugin call is an object.")); + }; + let mut relayed = Map::new(); + match call.remove("pluginID") { + Some(Value::String(plugin_id)) if !plugin_id.is_empty() => { + relayed.insert("pluginID".into(), Value::String(plugin_id)); + } + _ => return Err(invalid_call("pluginID is a non-empty string.")), + } + match call.remove("functionID") { + Some(Value::String(function_id)) if is_function_id(&function_id) => { + relayed.insert("functionID".into(), Value::String(function_id)); + } + _ => return Err(invalid_call("functionID is a plugin function ID.")), + } + match call.remove("params") { + Some(params @ Value::Object(_)) => { + relayed.insert("params".into(), params); + } + Some(_) => return Err(invalid_call("params is an object.")), + None => {} + } + match call.remove("timeoutMilliseconds") { + Some(timeout) if timeout.as_u64().is_some_and(|ms| ms > 0) => { + relayed.insert("timeoutMilliseconds".into(), timeout); + } + Some(_) => return Err(invalid_call("timeoutMilliseconds is a positive integer.")), + None => {} + } + match call.remove("parentOperationID") { + Some(Value::String(parent)) if is_parent_operation_id(&parent) => { + relayed.insert("parentOperationID".into(), Value::String(parent)); + } + Some(_) => return Err(invalid_call("parentOperationID is a 1-128 byte string.")), + None => { + if let Some(parent) = serving { + relayed.insert("parentOperationID".into(), json!(parent)); + } + } + } + Ok(Value::Object(relayed)) +} + #[cfg(test)] mod tests { use super::*; @@ -1122,4 +1255,75 @@ mod tests { } assert!(WorkspaceContext::new("", 1).is_err()); } + + #[test] + fn a_relayed_call_keeps_only_the_fields_the_method_defines() { + let call = json!({ + "pluginID": "dev.lithe.fixture.java", "functionID": "project.describe", + "params": { "caller": "data" }, "timeoutMilliseconds": 900000, + "parentOperationID": "17", "caller": { "pluginID": "forged" }, + "context": { "contextID": "x" }, "extra": true + }); + let expected = json!({ + "pluginID": "dev.lithe.fixture.java", "functionID": "project.describe", + "params": { "caller": "data" }, "timeoutMilliseconds": 900000, + "parentOperationID": "17" + }); + // Core passes a `caller` inside `params` unchanged; only a top-level one is refused. + assert_eq!(relay_params(call, Some("served")).unwrap(), expected); + } + + #[test] + fn optional_fields_stay_out_and_the_parent_defaults_to_the_call_being_served() { + let call = json!({ "pluginID": "a.b", "functionID": "x.y" }); + assert_eq!(relay_params(call.clone(), None).unwrap(), call); + let served = relay_params(call, Some("op-7")).unwrap(); + assert_eq!(served["parentOperationID"], "op-7"); + assert!(served.get("params").is_none()); + assert!(served.get("timeoutMilliseconds").is_none()); + let named = json!({ "pluginID": "a.b", "functionID": "x.y", "parentOperationID": "own" }); + let relayed = relay_params(named, Some("op-7")).unwrap(); + assert_eq!(relayed["parentOperationID"], "own"); + // The host caps the timeout; the SDK sends what the plugin chose. + let mut slow = json!({ "pluginID": "a.b", "functionID": "x.y" }); + slow["timeoutMilliseconds"] = json!(700000); + let relayed = relay_params(slow, None).unwrap(); + assert_eq!(relayed["timeoutMilliseconds"], 700000); + } + + #[test] + fn an_invalid_relayed_call_is_refused_before_anything_is_sent() { + let valid = json!({ "pluginID": "a.b", "functionID": "x.y" }); + let with = |key: &str, value: Value| { + let mut call = valid.clone(); + call[key] = value; + call + }; + let without = |key: &str| { + let mut call = valid.clone(); + call.as_object_mut().unwrap().remove(key); + call + }; + for invalid in [ + json!("x.y"), + json!(null), + without("pluginID"), + with("pluginID", json!("")), + with("pluginID", json!(7)), + without("functionID"), + with("functionID", json!("Not-Valid")), + with("params", json!([])), + with("params", json!(null)), + with("timeoutMilliseconds", json!(0)), + with("timeoutMilliseconds", json!(-1)), + with("timeoutMilliseconds", json!(1.5)), + with("timeoutMilliseconds", json!("5000")), + with("parentOperationID", json!("")), + with("parentOperationID", json!("p".repeat(129))), + with("parentOperationID", json!(12)), + ] { + let error = relay_params(invalid.clone(), Some("op-1")).unwrap_err(); + assert_eq!(error.code(), "sdk.functions.invalidCall", "{invalid}"); + } + } } diff --git a/sdk/rust/plugin-sdk/src/lib.rs b/sdk/rust/plugin-sdk/src/lib.rs index 5680ead..eca8440 100644 --- a/sdk/rust/plugin-sdk/src/lib.rs +++ b/sdk/rust/plugin-sdk/src/lib.rs @@ -6,13 +6,15 @@ //! stdio and implements, from //! [plugin contract v1](https://github.com/Lithe-IDEA/lithe-plugin-docs/blob/main/contract/v1/README.md): //! -//! - **`litheFunctions` 1 to 3**: plugin functions with progress, +//! - **`litheFunctions` 1 to 4**: plugin functions with progress, //! cancellation, and registration at runtime; -//! - **[`Host`] services** of versions 2 and 3: the plugin's own +//! - **[`Host`] services** of versions 2 to 4: the plugin's own //! [`Credentials`] in the platform's secret store from version 2; from //! version 3 workspace contexts, files, documents, the editor, previews, //! the browser, window messages, capability discovery, subscriptions, and -//! the commands and context changes the host notifies. +//! the commands and context changes the host notifies; from version 4 +//! calls to the functions other plugins export through Core's relay +//! ([`Functions`]), and the [`Caller`] of a function called that way. //! //! The SDK owns the wire format, negotiation, cancellation, and error //! mapping; plugin code supplies handlers. Each call runs on a thread of its @@ -34,14 +36,16 @@ mod protocol; mod stdio; pub use backend::{ - Backend, BackendHandle, Cancellation, Context, Initialize, KeymapImportRequest, + Backend, BackendHandle, Caller, Cancellation, Context, Initialize, KeymapImportRequest, KeymapSourceFile, }; pub use diagnostics::Diagnostics; -pub use error::{sdk_codes, Cancelled, FunctionError, PluginError, SdkError, HOST_ERROR_CODES}; +pub use error::{ + sdk_codes, Cancelled, FunctionError, PluginError, SdkError, HOST_ERROR_CODES, RELAY_ERROR_CODES, +}; pub use host::{ - Browser, Credentials, Documents, Editor, Event, Host, Preview, Subscription, Subscriptions, - Terminal, Window, Workspace, WorkspaceContext, + Browser, Credentials, Documents, Editor, Event, Functions, Host, Preview, Subscription, + Subscriptions, Terminal, Window, Workspace, WorkspaceContext, }; pub use protocol::{is_command_id, is_credential_key, is_function_id, is_plugin_error_code}; pub use serde_json::Value; diff --git a/sdk/rust/plugin-sdk/src/protocol.rs b/sdk/rust/plugin-sdk/src/protocol.rs index 1cf3655..de57166 100644 --- a/sdk/rust/plugin-sdk/src/protocol.rs +++ b/sdk/rust/plugin-sdk/src/protocol.rs @@ -8,12 +8,15 @@ use serde_json::Value; /// Plugin-function protocol versions this SDK answers, newest first. /// Version 2 keeps every rule of version 1 and adds host services; version 3 /// adds workspace contexts, capability discovery, host notifications, and -/// `$/cancelRequest` for host requests. -pub const FUNCTIONS_VERSIONS: [u64; 3] = [3, 2, 1]; +/// `$/cancelRequest` for host requests; version 4 adds calls to other +/// plugins' functions through Core's relay. +pub const FUNCTIONS_VERSIONS: [u64; 4] = [4, 3, 2, 1]; /// First plugin-function protocol version with host services. pub const HOST_SERVICES_VERSION: u64 = 2; /// First plugin-function protocol version with workspace contexts. pub const CONTEXT_VERSION: u64 = 3; +/// First plugin-function protocol version with the cross-plugin relay. +pub const RELAY_VERSION: u64 = 4; pub(crate) mod methods { pub const INITIALIZE: &str = "initialize"; @@ -63,6 +66,13 @@ pub(crate) mod methods { pub const DID_CHANGE_CONTEXTS: &str = "lithe/host/workspace/didChangeContexts"; pub const SUBSCRIPTION_NOTIFY: &str = "lithe/host/subscriptions/notify"; pub const COMMAND_DID_RUN: &str = "lithe/host/commands/didRun"; + pub const FUNCTIONS_PROVIDERS: &str = "lithe/host/functions/providers"; + pub const FUNCTIONS_INVOKE: &str = "lithe/host/functions/invoke"; + + /// The host methods only plugin-function protocol 4 offers. + pub fn is_relay(method: &str) -> bool { + matches!(method, FUNCTIONS_PROVIDERS | FUNCTIONS_INVOKE) + } } /// JSON-RPC and LSP error codes the SDK answers with. @@ -89,6 +99,8 @@ pub(crate) const MAX_PENDING_HOST_REQUESTS: usize = 16; /// Events kept for subscriptions whose answer has not been read yet. pub(crate) const MAX_EARLY_EVENTS: usize = 128; const MAX_COMMAND_ID_BYTES: usize = 128; +/// The longest `parentOperationID` Core accepts on a relayed call. +const MAX_PARENT_OPERATION_ID_BYTES: usize = 128; /// The largest integer JSON carries exactly. const MAX_SAFE_INTEGER: u64 = (1 << 53) - 1; @@ -134,6 +146,12 @@ pub fn is_command_id(value: &str) -> bool { }) } +/// A `parentOperationID`: the `operationID` of a relayed call, a string of +/// 1-128 bytes. +pub(crate) fn is_parent_operation_id(value: &str) -> bool { + !value.is_empty() && value.len() <= MAX_PARENT_OPERATION_ID_BYTES +} + /// A context generation: a positive integer JSON carries exactly. pub(crate) fn generation(value: &Value) -> Option { let generation = match value.as_u64() { @@ -167,6 +185,24 @@ pub(crate) fn bounded_text(value: &str, limit: usize) -> String { mod tests { use super::*; + #[test] + fn the_newest_offered_version_is_the_relay_and_only_two_methods_need_it() { + assert_eq!(FUNCTIONS_VERSIONS[0], RELAY_VERSION); + assert!(FUNCTIONS_VERSIONS.windows(2).all(|pair| pair[0] > pair[1])); + assert_eq!(FUNCTIONS_VERSIONS.last(), Some(&1)); + assert!(methods::is_relay("lithe/host/functions/providers")); + assert!(methods::is_relay("lithe/host/functions/invoke")); + for other in [ + methods::CREDENTIALS_GET, + methods::GET_CAPABILITIES, + methods::FUNCTION_INVOKE, + "lithe/host/functions/other", + "lithe/host/functions/", + ] { + assert!(!methods::is_relay(other), "{other}"); + } + } + #[test] fn function_ids_are_dot_separated_lower_camel_case() { assert!(is_function_id("snapshot.refresh")); @@ -202,6 +238,16 @@ mod tests { assert!(!is_command_id(&"a".repeat(129))); } + #[test] + fn parent_operation_ids_are_one_to_one_hundred_twenty_eight_bytes() { + assert!(is_parent_operation_id("17")); + assert!(is_parent_operation_id(&"o".repeat(128))); + assert!(is_parent_operation_id(&"é".repeat(64))); + assert!(!is_parent_operation_id("")); + assert!(!is_parent_operation_id(&"o".repeat(129))); + assert!(!is_parent_operation_id(&"é".repeat(65))); + } + #[test] fn generations_are_positive_integers_json_carries_exactly() { use serde_json::json; diff --git a/sdk/rust/plugin-sdk/tests/common/mod.rs b/sdk/rust/plugin-sdk/tests/common/mod.rs index fd69dec..63ead4b 100644 --- a/sdk/rust/plugin-sdk/tests/common/mod.rs +++ b/sdk/rust/plugin-sdk/tests/common/mod.rs @@ -103,6 +103,20 @@ impl Host { ); } + /// An invocation with extra top-level fields (`context`, `caller`, or a + /// different `operationID`) merged over the ones `invoke` sends. + pub fn invoke_with(&mut self, id: i64, function: &str, params: Value, extra: Value) { + let mut call = json!({ + "functionID": function, + "operationID": format!("op-{id}"), + "params": params, + }); + for (key, value) in extra.as_object().expect("extra fields are an object") { + call[key] = value.clone(); + } + self.request(id, "lithe/function/invoke", call); + } + /// Ends the input stream and returns the backend's exit code. pub fn close(mut self) -> i32 { self.input = None; diff --git a/sdk/rust/plugin-sdk/tests/host_services.rs b/sdk/rust/plugin-sdk/tests/host_services.rs index 3ce8c78..51d8e2c 100644 --- a/sdk/rust/plugin-sdk/tests/host_services.rs +++ b/sdk/rust/plugin-sdk/tests/host_services.rs @@ -1,4 +1,4 @@ -//! Host services of plugin-function protocols 2 and 3, driven by a scripted +//! Host services of plugin-function protocols 2 to 4, driven by a scripted //! host and the contract fixture; mirrors `sdk/node/test/host.test.mjs`. mod common; @@ -141,7 +141,8 @@ impl Session { fn the_sdk_answers_the_highest_plugin_function_version_the_host_offers() { let fixture = fixture(); for (offered, expected) in [ - (fixture["offer"]["versions"].clone(), 3), + (fixture["offer"]["versions"].clone(), 4), + (json!([1, 2, 3]), 3), (json!([1, 2]), 2), (json!([1]), 1), (json!([2, 99]), 2), @@ -161,7 +162,7 @@ fn the_sdk_answers_the_highest_plugin_function_version_the_host_offers() { assert_eq!(handle.host().version(), Some(expected)); host.close(); } - // The fixture's answers of versions 2 and 3 have the shape the SDK sends. + // The fixture's answers of versions 2 to 4 have the shape the SDK sends. let mut checked = 0; for case in cases(&fixture, "negotiation") { if case["expected"]["version"].as_u64() >= Some(2) { @@ -661,19 +662,23 @@ fn commands_context_changes_and_later_host_notifications_reach_their_handlers() .iter() .map(|case| { let params = &case["params"]; - if case["method"] == "lithe/host/commands/didRun" { - json!(["command", params["commandID"], params["context"]]).to_string() - } else { - json!(["contexts", params]).to_string() - } + let entry = match case["method"].as_str().unwrap() { + "lithe/host/commands/didRun" => { + json!(["command", params["commandID"], params["context"]]) + } + "lithe/host/workspace/didChangeContexts" => json!(["contexts", params]), + method => json!(["notification", method, params]), + }; + entry.to_string() }) .collect(); expected.insert(json!(["later", { "value": 1 }]).to_string()); - assert_eq!(expected.len(), 4); + // Every delivered case is told apart by its method and params. + assert_eq!(expected.len(), delivered.len() + 1); let (sender, seen) = mpsc::channel::(); let mut backend = Backend::new("test-backend"); - let (commands, contexts, later) = (sender.clone(), sender.clone(), sender); + let (commands, contexts, later) = (sender.clone(), sender.clone(), sender.clone()); backend .on_command("db.refresh", move |command_id, context| { let context = context.map_or(Value::Null, |context| context.to_value()); @@ -692,6 +697,30 @@ fn commands_context_changes_and_later_host_notifications_reach_their_handlers() Ok(()) }) .unwrap(); + // The other delivered methods (run exits, language server changes and + // forwarded notifications) use the generic registration, once each. + let routed = [ + "lithe/host/commands/didRun", + "lithe/host/workspace/didChangeContexts", + ]; + let generic: BTreeSet<&str> = delivered + .iter() + .filter_map(|case| case["method"].as_str()) + .filter(|method| !routed.contains(method)) + .collect(); + assert_eq!(generic.len(), 3); + for method in generic { + let sender = sender.clone(); + let method_name = method.to_string(); + backend + .on_host_notification(method, move |params| { + sender + .send(json!(["notification", method_name, params])) + .unwrap(); + Ok(()) + }) + .unwrap(); + } let refused = |result: Result<&mut Backend, SdkError>| { result.err().expect("a refusal").code().to_string() }; @@ -762,3 +791,404 @@ fn native_terminal_methods_keep_context_and_literal_arguments() { } session.close(); } + +#[test] +fn a_version_four_call_names_the_plugin_that_called_it_older_ones_and_the_host_do_not() { + let mut checked = 0; + for case in cases(&fixture(), "invocationCallers") { + let params = &case["expected"]["params"]; + let (sender, seen) = mpsc::channel::>(); + let mut backend = Backend::new("test-backend"); + backend + .function(params["functionID"].as_str().unwrap(), move |_, context| { + let caller = context.caller().map(|caller| caller.plugin_id().into()); + sender.send(caller).unwrap(); + Ok(Value::Null) + }) + .unwrap(); + let mut session = Session::start(case["version"].as_u64().unwrap(), backend); + // A callee of an older version is sent the caller here too, to show + // that the SDK does not report it. + let mut wire = params.clone(); + if let Some(caller) = case.get("caller") { + wire["caller"] = json!({ "pluginID": caller }); + } + session.host.request(9, "lithe/function/invoke", wire); + let response = session.response(9); + assert!( + response.get("error").is_none(), + "{}: {response}", + case["name"] + ); + assert_eq!( + seen.recv_timeout(WAIT).unwrap(), + params["caller"]["pluginID"].as_str().map(str::to_string), + "{}", + case["name"] + ); + session.close(); + checked += 1; + } + assert_eq!(checked, 4); +} + +#[test] +fn a_caller_that_is_not_exactly_a_plugin_id_is_not_reported() { + let (sender, seen) = mpsc::channel::>(); + let mut backend = Backend::new("test-backend"); + backend + .function("languageImport.settings", move |_, context| { + let caller = context.caller().map(|caller| caller.plugin_id().into()); + sender.send(caller).unwrap(); + Ok(Value::Null) + }) + .unwrap(); + let mut session = Session::start(4, backend); + let python = "dev.lithe.fixture.python"; + let attempts = [ + (json!({ "pluginID": python }), Some(python)), + (json!({ "pluginID": "" }), None), + (json!({ "pluginID": 7 }), None), + (json!({}), None), + (json!({ "pluginID": python, "version": 1 }), None), + (json!(python), None), + (Value::Null, None), + ]; + for (id, (caller, expected)) in (10..).zip(attempts) { + let extra = json!({ "caller": caller }); + session + .host + .invoke_with(id, "languageImport.settings", json!({}), extra); + assert_eq!(session.response(id)["result"], Value::Null); + let reported = seen.recv_timeout(WAIT).unwrap(); + assert_eq!(reported.as_deref(), expected, "{caller}"); + } + session.close(); +} + +#[test] +fn relay_requests_carry_the_fixture_params_and_only_the_fields_the_methods_define() { + let backend = Backend::new("test-backend"); + let host = backend.handle().host(); + let mut session = Session::start(4, backend); + let mut forwarded = 0; + for case in cases(&fixture(), "hostRequests") { + if case["expected"]["relay"] != true { + continue; + } + let method = case["method"].as_str().unwrap().to_string(); + let answer = if method.ends_with("/providers") { + json!({ "providers": [{ "pluginID": "dev.lithe.fixture.go", "version": 1 }] }) + } else { + json!({ "value": { "settings": [] } }) + }; + let sending = { + let host = host.clone(); + let (method, params) = (method.clone(), case["params"].clone()); + background(move || { + if method.ends_with("/providers") { + let function_id = params["functionID"].as_str().unwrap(); + host.functions().providers(function_id) + } else { + let context = WorkspaceContext::from_value(¶ms["context"]).unwrap(); + host.functions().invoke(&context, params) + } + }) + }; + let request = session.take(&method); + assert_eq!(request["params"], case["params"], "{}", case["name"]); + session.reply(&request, answer.clone()); + assert_eq!(done(&sending).unwrap(), answer, "{}", case["name"]); + forwarded += 1; + } + assert_eq!(forwarded, 3); + + // Fields the method does not define are not sent: Core would refuse a + // top-level `caller`, and the call's context is the argument. + let call = json!({ + "pluginID": "dev.lithe.fixture.go", "functionID": "languageImport.settings", + "caller": { "pluginID": "dev.lithe.fixture.other" }, + "context": { "contextID": "other", "generation": 9 }, "extra": true + }); + let sending = { + let host = host.clone(); + background(move || host.functions().invoke(&context(), call)) + }; + let request = session.take("lithe/host/functions/invoke"); + let expected = json!({ + "pluginID": "dev.lithe.fixture.go", "functionID": "languageImport.settings", + "context": context().to_value() + }); + assert_eq!(request["params"], expected); + session.reply(&request, json!({ "value": null })); + done(&sending).unwrap(); + session.close(); +} + +#[test] +fn a_relayed_call_from_a_handler_belongs_to_the_call_it_serves_unless_it_names_another() { + let mut backend = Backend::new("test-backend"); + backend + .function("project.collect", |params, served| { + let value = served.host().functions().invoke(&context(), params)?; + Ok(value) + }) + .unwrap(); + let mut session = Session::start(4, backend); + let call = json!({ + "pluginID": "dev.lithe.fixture.go", "functionID": "languageImport.settings" + }); + + session.host.invoke(5, "project.collect", call.clone()); + let request = session.take("lithe/host/functions/invoke"); + assert_eq!(request["params"]["parentOperationID"], "op-5"); + session.reply(&request, json!({ "value": { "files": 3 } })); + let response = session.response(5); + assert_eq!(response["result"], json!({ "value": { "files": 3 } })); + + let mut named = call.clone(); + named["parentOperationID"] = json!("op-elsewhere"); + session.host.invoke(6, "project.collect", named); + let request = session.take("lithe/host/functions/invoke"); + assert_eq!(request["params"]["parentOperationID"], "op-elsewhere"); + session.reply(&request, json!({ "value": null })); + let response = session.response(6); + assert_eq!(response["result"], json!({ "value": null })); + + // A parent the contract would refuse never leaves the SDK. + let mut invalid = call; + invalid["parentOperationID"] = json!(""); + session.host.invoke(7, "project.collect", invalid); + assert!(session.response(7).get("error").is_some()); + assert_eq!(session.sent("lithe/host/functions/invoke").len(), 2); + session.close(); +} + +#[test] +fn before_protocol_four_the_relay_is_refused_locally_and_nothing_is_sent() { + let call = json!({ + "pluginID": "dev.lithe.fixture.go", "functionID": "languageImport.settings" + }); + for version in [3, 2] { + let backend = Backend::new("test-backend"); + let host = backend.handle().host(); + let session = Session::start(version, backend); + let functions = host.functions(); + let providers = functions.providers("languageImport.settings"); + let invoked = functions.invoke(&context(), call.clone()); + // The generic request is held to the same rule. + let generic = host.request("lithe/host/functions/invoke", json!({})); + for result in [providers, invoked, generic] { + let error = result.unwrap_err(); + assert_eq!(error.code(), "plugin.host.unavailable", "v{version}"); + assert_eq!(error.rpc_code(), Some(-32601), "v{version}"); + } + session.host.assert_quiet(); + session.close(); + } + + // The fixture's case for the same refusal agrees. + let mut checked = 0; + for case in cases(&fixture(), "hostRequests") { + let method = case["method"].as_str().unwrap(); + let version = case["version"].as_u64().unwrap(); + if version >= 4 || !method.starts_with("lithe/host/functions/") { + continue; + } + let backend = Backend::new("test-backend"); + let host = backend.handle().host(); + let session = Session::start(version, backend); + let params = case["params"].clone(); + let error = host.request(method, params).unwrap_err(); + assert_eq!(error.code(), case["expected"]["code"], "{}", case["name"]); + assert_eq!(error.rpc_code(), case["expected"]["rpcCode"].as_i64()); + session.host.assert_quiet(); + session.close(); + checked += 1; + } + assert_eq!(checked, 1); +} + +#[test] +fn relay_refusals_keep_the_code_number_and_the_providers_own_code() { + let backend = Backend::new("test-backend"); + let host = backend.handle().host(); + let mut session = Session::start(4, backend); + let call = json!({ + "pluginID": "dev.lithe.fixture.go", "functionID": "languageImport.settings" + }); + let mut refused = |wire: Value| -> SdkError { + let pending = { + let (host, call) = (host.clone(), call.clone()); + background(move || host.functions().invoke(&context(), call)) + }; + let request = session.take("lithe/host/functions/invoke"); + session.refuse(&request, wire); + done(&pending).expect_err("a refusal") + }; + let relay_errors = cases(&fixture(), "relayErrors"); + assert_eq!(relay_errors.len(), 15); + for case in relay_errors { + let expected = case["code"].as_str().unwrap(); + let number = case["rpcCode"].as_i64().unwrap(); + let data = json!({ "code": expected }); + let named = refused(json!({ "code": number, "message": "Refused.", "data": data })); + assert_eq!(named.code(), expected, "{}", case["name"]); + assert_eq!(named.rpc_code(), Some(number), "{}", case["name"]); + // The number alone names the code. + let numbered = refused(json!({ "code": number, "message": "Refused." })); + assert_eq!(numbered.code(), expected, "{}", case["name"]); + assert_eq!(numbered.rpc_code(), Some(number), "{}", case["name"]); + } + let failed = refused(json!({ + "code": -32070, "message": "The provider failed.", + "data": { + "code": "plugin.function.failed", "details": { "pluginErrorCode": "db.locked" } + } + })); + assert_eq!(failed.code(), "plugin.function.failed"); + assert_eq!(failed.details().unwrap()["pluginErrorCode"], "db.locked"); + session.close(); +} + +#[test] +fn an_invalid_relayed_call_fails_locally_and_nothing_is_sent() { + let backend = Backend::new("test-backend"); + let host = backend.handle().host(); + let session = Session::start(4, backend); + let functions = host.functions(); + let valid = json!({ + "pluginID": "dev.lithe.fixture.go", "functionID": "languageImport.settings" + }); + let attempts = [ + ("pluginID", json!("")), + ("pluginID", json!(7)), + ("functionID", json!("Not-Valid")), + ("params", json!([1])), + ("timeoutMilliseconds", json!(0)), + ("timeoutMilliseconds", json!(-5)), + ("timeoutMilliseconds", json!(1.5)), + ("timeoutMilliseconds", json!("5")), + ("parentOperationID", json!(17)), + ("parentOperationID", json!("")), + ("parentOperationID", json!("p".repeat(129))), + ]; + for (field, bad) in attempts { + let label = format!("{field}: {bad}"); + let mut call = valid.clone(); + call[field] = bad; + let result = functions.invoke(&context(), call); + assert_eq!(code(result), "sdk.functions.invalidCall", "{label}"); + } + let incomplete = [ + json!([]), + json!({ "functionID": "languageImport.settings" }), + json!({ "pluginID": "dev.lithe.fixture.go" }), + ]; + for call in incomplete { + let label = call.to_string(); + let result = functions.invoke(&context(), call); + assert_eq!(code(result), "sdk.functions.invalidCall", "{label}"); + } + for bad in ["", "Not-Valid", "UPPER", "a..b"] { + let result = functions.providers(bad); + assert_eq!(code(result), "sdk.functions.invalidCall", "{bad:?}"); + } + + // The fixture's cases Core refuses as invalid params agree with these + // checks, whenever the call names a context. + let mut refused = 0; + for case in cases(&fixture(), "hostRequests") { + let params = &case["params"]; + if case["method"] != "lithe/host/functions/invoke" + || case["expected"]["code"] != "plugin.host.invalidParams" + || params.get("context").is_none() + { + continue; + } + let workspace = WorkspaceContext::from_value(¶ms["context"]).unwrap(); + let result = functions.invoke(&workspace, params.clone()); + assert_eq!( + code(result), + "sdk.functions.invalidCall", + "{}", + case["name"] + ); + refused += 1; + } + assert_eq!(refused, 4); + session.host.assert_quiet(); + session.close(); +} + +#[test] +fn relay_answers_are_the_hosts_objects_and_a_null_value_stays_inside_value() { + let backend = Backend::new("test-backend"); + let host = backend.handle().host(); + let mut session = Session::start(4, backend); + let listing = { + let host = host.clone(); + background(move || host.functions().providers("languageImport.settings")) + }; + let request = session.take("lithe/host/functions/providers"); + let answer = json!({ + "providers": [{ "pluginID": "dev.lithe.fixture.go", "version": 1 }] + }); + session.reply(&request, answer.clone()); + assert_eq!(done(&listing).unwrap(), answer); + + let call = json!({ + "pluginID": "dev.lithe.fixture.go", "functionID": "languageImport.settings" + }); + let calling = { + let host = host.clone(); + background(move || host.functions().invoke(&context(), call)) + }; + let request = session.take("lithe/host/functions/invoke"); + session.reply(&request, json!({ "value": null })); + assert_eq!(done(&calling).unwrap(), json!({ "value": null })); + + // An answer that is not an object is the host's mistake, not a value. + let broken = { + let host = host.clone(); + background(move || host.functions().providers("languageImport.settings")) + }; + let request = session.take("lithe/host/functions/providers"); + session.reply(&request, json!([])); + assert_eq!(code(done(&broken)), "sdk.host.invalidResult"); + session.close(); +} + +#[test] +fn cancelling_a_call_cancels_the_relayed_call_it_waits_on() { + let (sender, outcome) = mpsc::channel(); + let mut backend = Backend::new("test-backend"); + backend + .function("project.collect", move |params, served| { + let relay = served.host().functions().invoke(&context(), params); + let failure = relay.as_ref().err().map(|error| error.code().to_string()); + sender.send(failure).unwrap(); + Ok(relay?) + }) + .unwrap(); + let mut session = Session::start(4, backend); + let call = json!({ + "pluginID": "dev.lithe.fixture.go", "functionID": "languageImport.settings" + }); + session.host.invoke(41, "project.collect", call); + let request = session.take("lithe/host/functions/invoke"); + session.host.notify("$/cancelRequest", json!({ "id": 41 })); + assert_eq!(session.response(41)["error"]["code"], -32800); + // The relayed request fails with the call's cancellation, and Core is told. + assert_eq!( + outcome.recv_timeout(WAIT).unwrap().as_deref(), + Some("sdk.call.cancelled") + ); + let cancels: Vec<_> = session + .sent("$/cancelRequest") + .into_iter() + .map(|frame| frame["params"].clone()) + .collect(); + assert_eq!(cancels, [json!({ "id": request["id"] })]); + session.close(); +}