From 7984294c5804f20b39a9f0540a8c95158a77e0c2 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Thu, 5 Nov 2026 19:32:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(settings):=20=E6=8E=A5=E5=85=A5=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E5=87=BD=E6=95=B0=E5=8D=8F=E8=AE=AE=204=20=E7=9A=84?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E4=B8=AD=E8=BD=AC=EF=BC=8CMaven/Gradle=20?= =?UTF-8?q?=E6=96=B0=E5=BB=BA=E9=A1=B9=E7=9B=AE=E5=86=85=E5=AE=B9=E7=A7=BB?= =?UTF-8?q?=E4=BA=A4=E5=AF=B9=E5=BA=94=E6=8F=92=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端升级 Rust SDK 0.3.0,JDT LS running 后收集 languageImport.settings 并推送 didChangeConfiguration - 导出 languageImport.settingsChanged,按调用方分槽替换,可选 projects 触发 java.project.updateSettings - 打包加入 Rust 后端 bin/lithe-java-backend,verify-package 校验架构 - Maven/Gradle 新建项目入口、模板与 Maven 图标移到 lithe-plugin-maven、lithe-plugin-gradle --- .github/workflows/ci.yml | 5 + .github/workflows/release.yml | 34 +- Cargo.lock | 4 +- MIGRATION.md | 260 +-- NOTICE | 4 +- README.md | 70 +- backend/Cargo.toml | 2 +- backend/src/lib.rs | 148 +- backend/src/plugin_functions.rs | 533 ++++-- backend/src/settings_session.rs | 1601 +++++++++++++++++ backend/tests/repository_pins.rs | 19 +- crates/java-domain/src/lsp/languages/jdt.rs | 38 +- .../src/lsp/languages/jdt_contributions.rs | 42 +- .../icons/NOTICE.txt | 7 +- .../icons/maven.svg | 4 - .../icons/maven_dark.svg | 4 - io.github.lithe-idea.java-support/plugin.json | 229 +-- .../templates/java/gradle-groovy/build.gradle | 26 - .../java/gradle-groovy/dot-gitignore | 23 - .../java/gradle-groovy/settings.gradle | 1 - .../java/gradle-kotlin/build.gradle.kts | 26 - .../java/gradle-kotlin/dot-gitignore | 23 - .../java/gradle-kotlin/settings.gradle.kts | 1 - .../templates/java/maven/dot-gitignore | 19 - .../templates/java/maven/pom.xml | 43 - .../standard-empty/src/main/java/dot-gitkeep | 0 .../src/main/resources/dot-gitkeep | 0 .../standard-empty/src/test/java/dot-gitkeep | 0 .../java/standard-sample-main/Main.java | 7 - .../src/main/resources/dot-gitkeep | 0 .../standard-sample/src/test/java/dot-gitkeep | 0 scripts/assemble-package.sh | 65 +- scripts/build-package.sh | 6 +- scripts/verify-package.sh | 27 +- sdk.lock.json | 10 +- 35 files changed, 2498 insertions(+), 783 deletions(-) create mode 100644 backend/src/settings_session.rs delete mode 100644 io.github.lithe-idea.java-support/icons/maven.svg delete mode 100644 io.github.lithe-idea.java-support/icons/maven_dark.svg delete mode 100644 io.github.lithe-idea.java-support/templates/java/gradle-groovy/build.gradle delete mode 100644 io.github.lithe-idea.java-support/templates/java/gradle-groovy/dot-gitignore delete mode 100644 io.github.lithe-idea.java-support/templates/java/gradle-groovy/settings.gradle delete mode 100644 io.github.lithe-idea.java-support/templates/java/gradle-kotlin/build.gradle.kts delete mode 100644 io.github.lithe-idea.java-support/templates/java/gradle-kotlin/dot-gitignore delete mode 100644 io.github.lithe-idea.java-support/templates/java/gradle-kotlin/settings.gradle.kts delete mode 100644 io.github.lithe-idea.java-support/templates/java/maven/dot-gitignore delete mode 100644 io.github.lithe-idea.java-support/templates/java/maven/pom.xml delete mode 100644 io.github.lithe-idea.java-support/templates/java/standard-empty/src/main/java/dot-gitkeep delete mode 100644 io.github.lithe-idea.java-support/templates/java/standard-empty/src/main/resources/dot-gitkeep delete mode 100644 io.github.lithe-idea.java-support/templates/java/standard-empty/src/test/java/dot-gitkeep delete mode 100644 io.github.lithe-idea.java-support/templates/java/standard-sample-main/Main.java delete mode 100644 io.github.lithe-idea.java-support/templates/java/standard-sample/src/main/resources/dot-gitkeep delete mode 100644 io.github.lithe-idea.java-support/templates/java/standard-sample/src/test/java/dot-gitkeep diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aceeec8..0bd0031 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,6 +46,11 @@ jobs: - uses: actions/checkout@v4 with: persist-credentials: false + # scripts/assemble-package.sh compiles the plugin backend for each + # architecture it packages. + - uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-apple-darwin, x86_64-apple-darwin # Downloads are named by their SHA-256 and verified again before use. - name: Restore verified third-party downloads uses: actions/cache@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d65bca0..a34aeb0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,21 +5,26 @@ name: Release # # No step receives a signing key. Checkouts do not keep the workflow token. # -# Releases stay pre-releases, which Lithe Store's tracker skips, for three +# Releases stay pre-releases, which Lithe Store's tracker skips, for these # reasons: # 1. Lithe still bundles Java Support under the old ID # dev.lithe.plugin.java-support. -# 2. Current Lithe hosts reserve languageServers[].hostIntegration (the JDT -# integration) to dev.lithe.* plugin IDs and accept the short tool ID -# "java" only from verified official provenance, so they reject this -# package. -# 3. The backend links Rust plugin SDK 0.2.0, which requires the host to -# offer litheLogging 1 and refuses to start without it. Only the latest -# Lithe preview offers it so far; no stable Lithe does. +# 2. The package declares the Rust backend (backend 2), plugin-owned +# language server sessions (languageServers 3, requestMethods and +# commands) and plugin functions (pluginFunctions 1), and the backend +# links the Rust plugin SDK 0.3.0 (plugin function protocol 4). A host +# that predates the Lithe changes #1745 and #1746 does not offer them and +# rejects the package. Only the latest Lithe preview contains them so far; +# no stable Lithe does. The SDK also requires the host to offer +# litheLogging 1 and refuses to start without it. +# 3. The Maven and Gradle project templates have moved to lithe-plugin-maven +# and lithe-plugin-gradle. Until those plugins carry them, a release +# would drop the Maven and Gradle choices from New Project. # Remove --prerelease below once a stable Lithe accepts the package (stage E in -# MIGRATION.md), stops bundling Java Support, and offers litheLogging 1 (Rust -# SDK 0.2.0 requires it). The Store then lists stable releases of this -# repository automatically. +# MIGRATION.md), stops bundling Java Support, and offers backend 2, +# languageServers 3, pluginFunctions 1 and litheLogging 1, and once the Maven +# and Gradle plugins are published. The Store then lists stable releases of +# this repository automatically. on: push: tags: ["v*"] @@ -63,6 +68,11 @@ jobs: - uses: actions/checkout@v4 with: persist-credentials: false + # scripts/assemble-package.sh compiles the plugin backend for each + # architecture it packages. + - uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-apple-darwin, x86_64-apple-darwin - name: Check the tag matches plugin.json run: | set -euo pipefail @@ -91,7 +101,7 @@ jobs: { echo "Java Support(\`io.github.lithe-idea.java-support\`)${GITHUB_REF_NAME#v}。每个 macOS 架构一个归档,附 SHA-256 文件。" echo - echo "兼容性:目前的 Lithe 仍内置 Java Support,并且只允许 \`dev.lithe.*\` 插件使用 JDT 宿主集成和短工具 ID \`java\`,所以会拒绝安装这个包;要等 Lithe 主仓库完成 Java 拆分的阶段 E。" + echo "兼容性:目前的 Lithe 仍内置 Java Support;这个包还需要宿主提供 \`backend\` 2、\`languageServers\` 3、\`pluginFunctions\` 1 和 \`litheLogging\` 1(含 #1745、#1746 的 Lithe 预览版之后),不满足的版本会拒绝安装;稳定版要等 Lithe 主仓库完成 Java 拆分的阶段 E。Maven 与 Gradle 的新建项目模板已移交 lithe-plugin-maven 和 lithe-plugin-gradle,这两个插件接手前,这个包的新建项目里没有 Maven 和 Gradle 选项。" echo echo "安装:预发布版本,Lithe Store 自动追踪会跳过。宿主支持后下载对应架构的 ZIP,在 设置 → 插件 的 ⚙ 菜单中选择 从磁盘安装插件…。" echo diff --git a/Cargo.lock b/Cargo.lock index 91ae657..8f540e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -339,8 +339,8 @@ dependencies = [ [[package]] name = "lithe-plugin-sdk" -version = "0.2.0" -source = "git+https://github.com/Lithe-IDEA/lithe-plugin-docs?tag=sdk-rust-v0.2.0#940394fcd8455186ee985e6c34a319032414607f" +version = "0.3.0" +source = "git+https://github.com/Lithe-IDEA/lithe-plugin-docs?tag=sdk-rust-v0.3.0#db17c719bad755b420600c897615c67ed8cab67c" dependencies = [ "base64", "serde_json", diff --git a/MIGRATION.md b/MIGRATION.md index 173c699..4b7fb69 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -18,15 +18,17 @@ - 来源快照:Lithe `origin/preview` 的提交 `1daaeb525`。表里的路径都写成 `路径@1daaeb525`,只读,不在本仓库改动。 - 本仓库产物: - Rust 工作区共约 23,500 行 Rust:`crates/java-domain`(领域规则,不依赖宿主)和 `backend`(基于已固定版本 SDK 的插件后端进程)。 - - 312 个测试:领域 crate 280 个,后端 32 个(单元 23 个、`repository_pins` 6 个、`session` 3 个)。全部是有界、确定性的: - 不联网,不读全局时间,不占固定端口。其中跨插件函数相关的 30 个(`jdt_contributions.rs` 10 个、`plugin_functions.rs` 20 个) - 只验证插件内部逻辑,对照的仍是旧草稿里的中转形状,见第 10 节。 + - 344 个测试:领域 crate 281 个,后端 63 个(单元 54 个、`repository_pins` 6 个、`session` 3 个)。全部是有界、确定性的: + 不联网,不读全局时间,不占固定端口。其中设置中转相关的测试(`jdt_contributions.rs`、`plugin_functions.rs`、`settings_session.rs`) + 用桩实现验证收集、替换和推送逻辑,不连宿主,见第 10 节。 - 18 个 fixture(共 1,234 行)放在 `fixtures/`。其中 12 个被 Rust 测试读取;另外 6 个是从主仓库复制来的契约样例, 暂时没有 Rust 测试读取(见第 7 节)。 - 插件包:`io.github.lithe-idea.java-support/`,加上组装、校验、归档脚本、CI 和发布工作流。 - 领域代码的注释(`//!`、`///`)全部是英文。每个来自 Core 或 Swift 的模块在模块文档里写明来源。 -- 宿主侧还没有接线:`plugin.json` 暂时不声明 `backend`(`backend/tests/repository_pins.rs` 里有测试断言这一点), - 后端目前只把领域命令注册成插件函数,并带着跨插件函数的调用方一侧(第 5 节),等第 6 节列出的宿主 API 就位后再声明。 +- 宿主侧的接线是分步的:`plugin.json` 现在声明 `backend`(`backend.launch` 为 `packaged`,路径 `bin/lithe-java-backend`),后端把领域命令注册成插件函数, + 并承担 API-35 的设置中转(第 5 节:调用方一侧发现并调用 `languageImport.settings`,导出方一侧接收 `languageImport.settingsChanged`, + 再用 `languageServer/notify` 推送给 JDT LS)。语言服务器仍由宿主的 JDT 集成启动(`launch.kind` 为 `runtime`,带 `hostIntegration`), + 改为后端启动要等第 6 节列出的 API-23 等宿主 API 就位,见第 10 节。 ## 2. 边界决定 @@ -48,15 +50,15 @@ - 接收方在清单里显式导出函数,调用方声明它要调用的函数 ID(接口名),不写对方的插件 ID。 - Java 插件定义接口函数 ID(例如 `languageImport.settings`),Maven 和 Gradle 插件各自导出同一个 ID。 -- 启动 JDT LS 之前,Java 的后端向宿主发现所有已启用且导出该 ID 的插件,逐个调用,合并结果,交给 JDT LS。 +- Java 的语言服务器进入运行状态(宿主通知 `lithe/host/languageServer/didChangeState`)后,Java 的后端向宿主发现所有已启用且导出该 ID 的插件, + 逐个调用,合并结果,用 `languageServer/notify` 以 `workspace/didChangeConfiguration` 交给 JDT LS。 - Maven 设置变化时,Maven 插件反过来调用 Java 导出的"变化通知"函数,并把新值放在调用参数里。Java 不在这次调用里回调 Maven, 否则调用链 A→B→A 会被 Core 当作环路拒绝。 - 信任不传递:被调方用自己的权限执行,调用方不获得任何权限。 -中转本身已由主仓库 [#1742](https://github.com/1lck/Lithe-IDEA/pull/1742) 在 Core 里实现并合入。本仓库实现了 Java 一侧:接口常量、答案校验与合并(`jdt_contributions.rs`),以及发现并调用提供方的 -后端模块(`plugin_functions.rs`,集中了全部中转协议细节,见第 5 节)。这个模块仍按合入前的设计草稿写,与已合入的方法形状不同; -中转方法属于插件函数协议 4,而 Rust SDK 0.2.0 只协商协议 1–3,所以要等支持协议 4 的 SDK 发布后一起对齐。 -在那之前这些代码不会被宿主调用,缺的部分在代码里写 `TODO(host-api: API-35 跨插件函数)`。 +中转本身已由主仓库 [#1742](https://github.com/1lck/Lithe-IDEA/pull/1742) 在 Core 里实现并合入。本仓库实现了 Java 一侧:接口常量、答案校验与合并(`jdt_contributions.rs`), +发现并调用提供方的后端模块(`plugin_functions.rs`,集中了全部中转协议细节,见第 5 节),以及按工作区维护贡献并推送给 JDT LS 的 +`settings_session.rs`。后端链接支持插件函数协议 4 的 Rust SDK 0.3.0,按已合入的方法形状调用中转;`plugin.json` 已声明 `backend` 和 `pluginFunctions`(第 5.7 节)。 ### 2.3 计划编号约定 @@ -121,9 +123,9 @@ API-30(调试适配器)、API-37(环境与 l10n)、API-42(内置 Git | `…/execution/workspace_scan.rs@1daaeb525`、`project/indexing.rs@1daaeb525` 里的 Java 分支 | 无;`dj/host/indexing.rs` 只有占位 | 等待宿主 API(API-25) | 索引排除规则必须由宿主交给语言服务器的所有配置通道,插件没有这个通道 | | `…/project/{maven,maven_dependency_tree,maven_test_reports}.rs@1daaeb525` | 无 | 不移植(Maven 插件) | Windows 仍在用,见第 8 节 | | `…/build/language_import.rs@1daaeb525`、`build/integrations/{mod,maven}.rs@1daaeb525` | 无 | 不移植(Maven/Gradle 插件,被 API-35 取代) | 见第 4 节 | -| `…/build/language_import.rs@1daaeb525` 的调用方一侧(启动 JDT LS 前收集各构建系统的导入设置) | `dj/lsp/languages/jdt_contributions.rs`(新写) | 已移植并有测试 | 10 个测试。接口常量 `languageImport.settings` 与 `languageImport.settingsChanged`、答案校验、按提供方分槽的合并与状态;不含任何 Maven/Gradle 逻辑,见第 5 节 | -| 同上(跨进程调用部分) | `backend/src/plugin_functions.rs`(新写) | 已移植并有测试 | 20 个测试。经 Core 中转发现并调用提供方,全部用桩中转测试;宿主方法名与形状仍是合入前的草稿,要随支持协议 4 的 SDK 对齐,见第 5.7 节 | -| `…/lsp/interface/engine.rs@1daaeb525`:`update_maven_configuration` 的接收一侧(Maven 设置变化后通知 Java) | `languageImport.settingsChanged` 的常量与分槽逻辑已在 `jdt_contributions.rs`;函数注册还没有 | 等待宿主 API(API-35) | 中转已在 Lithe [#1742](https://github.com/1lck/Lithe-IDEA/pull/1742) 合入,调用方插件 ID 在插件函数协议 4 的 `caller` 字段里;Rust SDK 0.2.0 只协商协议 1–3,所以还要等支持协议 4 的 SDK。分槽需要可信的提供方 ID,见第 5.7 节 | +| `…/build/language_import.rs@1daaeb525` 的调用方一侧(启动 JDT LS 前收集各构建系统的导入设置) | `dj/lsp/languages/jdt_contributions.rs`(新写) | 已移植并有测试 | 11 个测试。接口常量 `languageImport.settings` 与 `languageImport.settingsChanged`、答案校验、按提供方分槽的合并与状态;不含任何 Maven/Gradle 逻辑,见第 5 节 | +| 同上(跨进程调用部分) | `backend/src/plugin_functions.rs`(新写) | 已移植并有测试 | 经 Core 中转发现并调用提供方(`lithe/host/functions/providers`、`lithe/host/functions/invoke`),按已合入的 [#1742](https://github.com/1lck/Lithe-IDEA/pull/1742) 协议实现,用桩中转测试,见第 5.7 节 | +| `…/lsp/interface/engine.rs@1daaeb525`:`update_maven_configuration` 的接收一侧(Maven 设置变化后通知 Java) | `languageImport.settingsChanged` 的常量与分槽逻辑在 `jdt_contributions.rs`;导出函数与会话触发在 `backend/src/lib.rs`、`backend/src/settings_session.rs` | 已实现,宿主侧接线随阶段 J2 | 中转在 Lithe [#1742](https://github.com/1lck/Lithe-IDEA/pull/1742) 合入;调用方插件 ID 在插件函数协议 4 的 `caller` 字段里,由 Rust SDK 0.3.0 经 `Context::caller()` 提供,按它分槽,调用方无法伪造,见第 5.7 节 | | `…/plugins/declarative.rs@1daaeb525`、`build/{invocation,manifest,detect,plans}.rs@1daaeb525` 里的 Java 特判(`KNOWN_RUNTIME_TOOLS*`、`HOST_INTEGRATION_*`、`ModelSource::HostIntegration`) | 无 | 不移植(宿主通用代码里的 Java 特判,不是插件逻辑) | 在主仓库删,见第 8 节 | | `…/protocol/cancellation.rs@1daaeb525` | `dj/protocol/cancellation.rs` | 已移植并有测试 | 8 个测试 | | `…/protocol/contracts.rs@1daaeb525`(Java、Spring、MyBatis 模型) | `dj/protocol/contracts.rs` | 已移植无测试 | 只复制 Java 相关请求与响应类型,由各命令的测试间接覆盖;`Maven*Response` 不复制 | @@ -133,7 +135,7 @@ API-30(调试适配器)、API-37(环境与 l10n)、API-42(内置 Git | `…/protocol/command.rs@1daaeb525`:`maven.*`、`lsp.retryMavenProfiles`、`lsp.updateMavenConfiguration`、`build.languageImport` | 无 | 不移植(Maven/Gradle 插件) | | | `…/lib.rs@1daaeb525`:公开函数 `jdt_workspace_key` | `lsp.jdtWorkspaceKey` 命令(`dj/commands.rs`) | 已移植并有测试 | `tests/java_commands.rs` 里读 `jdt-workspace-key-v1.json` | | `…/lib.rs@1daaeb525`:公开函数 `settings_with_local_repository` | 无(交付目录,见第 4 节) | 不移植(Maven 插件) | | -| `…/diagnostics/catalog_generated.rs@1daaeb525` 里的 Java 日志事件 | 后端经 Rust SDK 0.2.0 接入宿主统一日志 `litheLogging` 1:`backend/src/lib.rs`(`KEY_OPERATIONS` 与成功抑制)、`backend/src/plugin_functions.rs`(中转降级类别)、`dj/host/diagnostics.rs`(清理失败) | 已移植并有测试 | 删除了 `Context::log` 和所有 stderr 日志。级别、类别和内容见 README 的“日志”一节;SDK 0.2.0 要求宿主提供 `litheLogging` 1,见第 9.2、9.3 节 | +| `…/diagnostics/catalog_generated.rs@1daaeb525` 里的 Java 日志事件 | 后端经 Rust SDK(0.2.0 起,当前 0.3.0)接入宿主统一日志 `litheLogging` 1:`backend/src/lib.rs`(`KEY_OPERATIONS` 与成功抑制)、`backend/src/plugin_functions.rs`(中转降级类别)、`dj/host/diagnostics.rs`(清理失败) | 已移植并有测试 | 删除了 `Context::log` 和所有 stderr 日志。级别、类别和内容见 README 的“日志”一节;SDK 要求宿主提供 `litheLogging` 1,见第 9.2、9.3 节 | | Swift `MacJavaLaunchArgumentPreparer` 的规则部分(Core 里没有) | `dj/execution/argfile.rs` | 已移植并有测试 | 4 个测试:只在命令过长时写 argfile,仅属主可读,租约释放时删除 | ### 3.2 Rust Core 测试与 fixture @@ -220,9 +222,9 @@ API-30(调试适配器)、API-37(环境与 l10n)、API-42(内置 Git | 来源 | 新位置 | 状态 | 说明 | | --- | --- | --- | --- | -| `Plugins/mac/Official/JavaSupport/plugin.json@1daaeb525` | `io.github.lithe-idea.java-support/plugin.json` | 已移植并有测试 | 身份改为普通插件:id 从 `dev.lithe.plugin.java-support` 变为 `io.github.lithe-idea.java-support`,`vendor` 改为 `Lithe Community`(`signatureRequirement: none`),删除 `publisherPackage`、`bundledSignatureRequirement` 和 `requires.packageTrust`,见第 9 节;版本仍是 1.1.0,权限、设置和其余 `requires` 不变;`backend` 暂不声明。`repository_pins.rs` 的 `the_manifest_keeps_the_community_identity_and_declares_no_backend_yet` 断言这两点 | -| `Plugins/mac/Official/JavaSupport/{icons,templates}/**@1daaeb525` | `io.github.lithe-idea.java-support/{icons,templates}/` | 已移植并有测试 | `backend/tests/repository_pins.rs` 检查清单里每个文件都存在;Maven 图标和模板的归属见第 7 节 | -| `Plugins/mac/Official/JavaSupport/assemble-package.sh@1daaeb525` | `scripts/assemble-package.sh` | 已移植无测试 | 由 `scripts/verify-package.sh` 校验产物 | +| `Plugins/mac/Official/JavaSupport/plugin.json@1daaeb525` | `io.github.lithe-idea.java-support/plugin.json` | 已移植并有测试 | 身份改为普通插件:id 从 `dev.lithe.plugin.java-support` 变为 `io.github.lithe-idea.java-support`,`vendor` 改为 `Lithe Community`(`signatureRequirement: none`),删除 `publisherPackage`、`bundledSignatureRequirement` 和 `requires.packageTrust`,见第 9 节;版本仍是 1.1.0。新增后端声明:顶层 `backend`、`pluginFunctions`,`requires.backend: 2`、`pluginFunctions: 1`、`languageServers: 3`,权限 `backend.launch`、`languageServer.request`、`workspace.metadata`,激活事件 `onWorkspaceOpen`,语言服务器的 `requestMethods` 与 `commands`(第 5.7 节);`launch` 和 `hostIntegration` 不变。`repository_pins.rs` 断言社区身份 | +| `Plugins/mac/Official/JavaSupport/{icons,templates}/**@1daaeb525` | `io.github.lithe-idea.java-support/{icons,templates}/` | 已移植并有测试 | `backend/tests/repository_pins.rs` 检查清单里每个文件都存在。Maven 图标(`icons/maven*.svg`)、Maven 与 Gradle 构建文件模板(`templates/java/{maven,gradle-kotlin,gradle-groovy,standard-*}`)已移交 Maven、Gradle 插件,本仓库只保留 `templates/java/plain*` 与 Java、Spring Boot 图标,见第 7 节 | +| `Plugins/mac/Official/JavaSupport/assemble-package.sh@1daaeb525` | `scripts/assemble-package.sh` | 已移植无测试 | 新增后端二进制:按 `--arch` 用 `cargo build --release --locked -p lithe-java-backend --target ` 编译,`universal` 用 `lipo -create` 合并,放到 `plugin.json` 的 `backend.launch.path`(`bin/lithe-java-backend`)。由 `scripts/verify-package.sh` 校验产物,包括后端存在、可执行、架构与 `--arch` 一致 | | `scripts/macos/prepare/prepare-jdtls.sh@1daaeb525`、`prepare-jdk.sh@1daaeb525` | `scripts/prepare-jdtls.sh`、`scripts/prepare-jdk.sh` | 已移植无测试 | 来源地址和 SHA-256 固定在 `third_party/*/manifest.json`,`repository_pins.rs` 检查格式 | | `third_party/jdtls/manifest.json@1daaeb525`、`third_party/jdk/manifest.json@1daaeb525` | `third_party/jdtls/manifest.json`、`third_party/jdk/manifest.json` | 已移植并有测试 | 每个地址是 HTTPS,每个校验和是小写 SHA-256 | | `.github/workflows/ci-plugins.yml@1daaeb525`、`release-official-plugins.yml@1daaeb525` 里 Java 的部分 | `.github/workflows/ci.yml`、`release.yml` | 已移植无测试 | 发布流程没有签名步骤,不读任何密钥,见第 9 节 | @@ -277,15 +279,17 @@ API-30(调试适配器)、API-37(环境与 l10n)、API-42(内置 Git | `java.import.maven.*` | Maven 插件 | 同上 | | `java.project.sourcePaths` | Maven 插件(从 POM 读出的源码目录) | 同上 | | `java.import.gradle.enabled`、`wrapper.enabled`、`offline.enabled`、`user.home` | Gradle 插件(offline、gradleUserHome) | 同上 | -| Maven profiles(`java.project.updateSettings` 命令,参数 `org.eclipse.m2e.core.selectedProfiles`) | Maven 插件 | 缺口:settings 片段无法表达,见第 5.4 节 | +| Maven profiles(`java.project.updateSettings` 命令,参数 `org.eclipse.m2e.core.selectedProfiles`) | Maven 插件 | settings 片段无法表达;经 `languageImport.settingsChanged` 的 `projects` 条目交给 Java 后端,由后端发命令,见第 5.4 节 | | `java.maven.downloadSources: false` | Java 插件自有 | 随 Java 自己的 `settings.java` 推送,不属于 Maven 逻辑 | | 补全、导入排除、索引根、`java.configuration.runtimes` 等 | Java 插件 | 随 Java 自己的 `settings.java` 推送 | ## 5. 跨插件函数设计(API-35) -Java 一侧已经实现:接口常量、答案校验与合并在 `dj/lsp/languages/jdt_contributions.rs`(纯逻辑,不依赖 SDK), -发现并调用提供方在 `backend/src/plugin_functions.rs`。宿主中转已由 Core 的 [#1742](https://github.com/1lck/Lithe-IDEA/pull/1742) 实现并合入; -本仓库的协议形状仍是合入前的草稿,与已合入形状的差异见第 5.7 节,缺失的宿主能力在代码里是 `TODO(host-api: API-35 跨插件函数)`。 +状态:**已实现**(Java 一侧)。接口常量、答案校验与合并在 `dj/lsp/languages/jdt_contributions.rs`(纯逻辑,不依赖 SDK), +发现并调用提供方在 `backend/src/plugin_functions.rs`,按工作区维护贡献、推送给 JDT LS、接收 `languageImport.settingsChanged` 的在 +`backend/src/settings_session.rs`。宿主中转已由 Core 的 [#1742](https://github.com/1lck/Lithe-IDEA/pull/1742) 实现并合入,后端按已合入的 +方法形状(插件函数协议 4,Rust SDK 0.3.0)调用它,清单已声明(第 5.7 节)。仍缺的只有 `java.configuration.runtimes` 的推送, +它依赖 API-23,见第 6 节。 ### 5.1 接口函数 ID @@ -295,23 +299,32 @@ Java 一侧已经实现:接口常量、答案校验与合并在 `dj/lsp/langua - 参数:`{ "workspaceRoot": "<工作区根>" }`(`settings_request`)。 - 返回:`{ "settings": { "java.import.maven.enabled": true } }`。`settings` 可以嵌套,也可以用点分键;未知的同级字段被忽略, 以后加字段不破坏这一版。 -- `languageImport.settingsChanged`:Java 导出的变化通知函数。Maven/Gradle 在设置变化时调用,参数与上面的返回值同形, - 携带完整的新设置(不是差量),Java 用它替换该提供方的槽位。收到后 Java 用 `workspace/didChangeConfiguration` 把合并后的设置 - 推给 JDT LS(API-25 的语言服务器通道)。 - -### 5.2 启动时序 - -1. 语言服务器启动解析函数调用 `plugin_functions::collect_for_call`(API-25 的启动解析器就位后接入;现在没有调用方)。 -2. 向宿主发现已启用且导出 `languageImport.settings` 的插件。结果按插件 ID 排序、去重,最多取 16 个(`MAX_PROVIDERS`), - 避免插件很多时拖慢 JDT LS 启动。 -3. 逐个调用,每个提供方最多等 10 秒(`PROVIDER_TIMEOUT`,宿主另有自己的上限)。超时只取消那一次请求,不影响其他提供方。 +- `languageImport.settingsChanged`:Java 导出的变化通知函数(`context: "workspace"`)。Maven/Gradle 在设置变化时调用, + 参数是 `{ "settings": {…}, "projects"?: [{ "projectUri", "selectedProfiles": [...] }] }`,`settings` 与上面的返回值同形, + 携带完整的新设置(不是差量)。Java 后端按 Core 写在调用里的调用方插件 ID(`caller`,不能由参数伪造)**整份替换**该调用方的槽位, + 不影响别的提供方;随后用 `workspace/didChangeConfiguration` 把合并后的设置推给 JDT LS(`languageServer/notify`)。 + `projects` 见第 5.4 节。 + +### 5.2 时序 + +1. 宿主通知 `lithe/host/languageServer/didChangeState`(`serverID` 为 `java`)说语言服务器进入 `running`,并给出该服务器进程的代号。 + 后端记下代号;远程工作区(取不到本地根路径)不收集。`stopped`、`crashed` 只标记不再运行,保留已有贡献,下次 `running` 重新收集后覆盖。 +2. 向宿主发现已启用且导出 `languageImport.settings` 的插件(`lithe/host/functions/providers`)。结果按插件 ID 排序、去重, + 只保留导出版本等于契约版本(1)的提供方,最多取 16 个(`MAX_PROVIDERS`),避免插件很多时拖慢收集。 +3. 逐个调用(`lithe/host/functions/invoke`),每个提供方最多等 10 秒(`PROVIDER_TIMEOUT`,经 `timeoutMilliseconds` 传给宿主)。 + 超时只取消那一次请求,不影响其他提供方。 4. 每个答案交给 `accept_answer`:落在 `CONTRIBUTED_SETTINGS_KEYS` 里的叶子键被采用,其余记入 `rejected`,永远不会到达 JDT LS; 日志里只上报类别 `relay.keys_rejected` 和计数,不写键名。 -5. 合并按插件 ID 排序,叶子键相同时后者覆盖前者。合并结果(`Contributions::fragments`)放进 `JdtSettings::contributed`,再启动 JDT LS。 +5. 合并按插件 ID 排序,叶子键相同时后者覆盖前者。收集期间代号变了(服务器重启过)则丢弃结果。 +6. **只有至少一个提供方参与(回答或失败)时才推送**:用 `languageServer/notify` 发 `workspace/didChangeConfiguration`, + `settings` 整份替换会话里的插件配置。没有任何提供方时什么都不推送——过渡期主仓库的 jdt 宿主集成仍在推送它自己的设置, + 插件这边的整份替换会把它覆盖掉;在 Maven/Gradle 插件导出 `languageImport.settings` 之前必须零行为变化。 +7. 推送里 `java.configuration.runtimes` 为空:插件后端目前没有列出 JDK 安装的宿主方法(API-23 `toolchains.list`), + 等它提供后把宿主的候选列表填进来;JDT LS 不会因为缺这个键而删除已登记的 VM。索引根也不在插件推送里,因为 Core 会在插件配置上叠加宿主的索引策略。 ### 5.3 超时、失败与状态 -- 超时或失败都不阻塞打开窗口:按已收到的设置启动。一个提供方失败不影响其他提供方。 +- 超时或失败都不阻塞打开窗口:按已收到的设置推送。一个提供方失败不影响其他提供方。 - 调用函数自己被取消是唯一的错误(`Cancelled`);其余失败都记在 `Contributions` 里。 - `ContributedSettingsStatus` 表示状态:`Settled`(全部回答,或没有关联插件)、`Pending`(仍在收集或应用,由调用方在收集期间持有)、 `Failed`(有提供方失败、超时、答案不可用,或宿主根本无法列出提供方)。`Pending` 时 `java_build` 的阻塞项是 `ContributedSettings` @@ -321,16 +334,20 @@ Java 一侧已经实现:接口常量、答案校验与合并在 `dj/lsp/langua 中转降级另有 `relay.*` 类别。级别与类别见 README 的“日志”一节。 - 不在 `languageImport.settings` 的调用里回调 Maven,避免调用链环路。 -### 5.4 Maven profiles 缺口 +### 5.4 Maven profiles:经 `projects` 交付 JDT LS 1.61.0 没有 Maven profiles 的设置键。Core 原来只能用 `java.project.updateSettings` 命令加 `org.eclipse.m2e.core.selectedProfiles` 逐工程应用。settings 片段表达不了这件事,所以: - profiles 仍由 Maven 插件计算。 -- 提议:Java 导出的变化通知函数除了 settings 片段之外,还接受一个逐工程的条目,形如 `{projectUri, selectedProfiles}`, - Java 后端收到后发 `java.project.updateSettings`;`project_update_notification(project_uris)` 已经在 - `dj/lsp/languages/jdt.rs` 里实现了通用部分(`java/projectConfigurationsUpdate`)。 -- 条目形状需要在 API-35 的 PR 里与 Maven 插件一起敲定,本仓库没有实现,只留 TODO。 +- `languageImport.settingsChanged` 除了 `settings`,还接受 `projects`:逐工程的条目 `{projectUri, selectedProfiles}`。 + Java 后端按 `projectUri` 去重(保留第一次出现),把 `selectedProfiles` 用 `,` 连接,经 `languageServer/request` 发 + `workspace/executeCommand`,命令为 `java.project.updateSettings`,参数是 `[projectUri, {"org.eclipse.m2e.core.selectedProfiles": "<连接结果>"}]`。 + `plugin.json` 的语言服务器因此声明 `requestMethods: ["workspace/executeCommand"]` 和 `commands: ["java.project.updateSettings"]` + (权限 `languageServer.request`)。 +- 语言服务器尚未运行时,`projects` 先暂存,等进入 `running` 并推送一次设置之后再发。 +- 条目形状与 Maven 插件的对接仍要在 lithe-plugin-maven 一侧确认;`project_update_notification(project_uris)` 保留着通用部分 + (`java/projectConfigurationsUpdate`),在 `dj/lsp/languages/jdt.rs`。 ### 5.5 工具链要求 @@ -343,34 +360,38 @@ Java 的编辑器标记(API-27)被点击后,Java 后端只产生测试选 Java 后端向宿主查询导出测试运行函数 ID 的已启用插件,把选择器发过去,不写死 Maven 或 Gradle。结果显示在宿主原生测试面板的落点, 等 API-27 的编辑器动作 API 就位后确定。 -### 5.7 中转协议形状:草稿与已合入版本的差异,以及清单声明 +### 5.7 中转协议形状与清单声明(已实现) -`backend/src/plugin_functions.rs` 是后端里唯一知道中转协议的地方。它仍按合入前的设计草稿写;Core 的中转 -[#1742](https://github.com/1lck/Lithe-IDEA/pull/1742) 已经合入,方法名相同,参数和结果形状不同(以主仓库 +`backend/src/plugin_functions.rs` 是后端里唯一知道中转协议的地方,按 Core 已合入的中转 +[#1742](https://github.com/1lck/Lithe-IDEA/pull/1742) 的形状调用 Rust SDK 0.3.0 的 `Host::functions()`(以主仓库 `shared/contracts/plugins/v1/README.md` 的 “Discovery and invocation” 一节为准): -| 宿主方法 | 本仓库草稿 | 已合入的形状 | -| --- | --- | --- | -| `lithe/host/functions/providers` | 参数 `{ context, functionID, version }`,结果 `{ providers: [{ pluginID }] }` | 参数 `{ functionID }`,结果 `{ providers: [{ pluginID, version }] }`,按插件 ID 排序、不含调用方自己 | -| `lithe/host/functions/invoke` | 参数 `{ context, pluginID, functionID, version, params }`,结果 `{ result }` | 参数 `{ pluginID, functionID, context: { contextID, generation }, params?, timeoutMilliseconds?, parentOperationID? }`,结果 `{ value }`;未定义的字段是 `-32018`(`plugin.host.unsupportedOption`) | - -两个方法都只对协商到插件函数协议 4 的后端开放,协议 3 及以下得到 `-32601`。Rust SDK 0.2.0 只协商协议 1–3, -所以对齐要等支持协议 4 的 SDK:届时只改这个文件里的两个方法名常量、两个参数构造函数和两个结果解析函数, -版本检查改为读取 `providers` 结果里的 `version`,嵌套调用时带上 `parentOperationID`;收集逻辑和它的测试不变。 -收集逻辑只依赖 `FunctionRelay` 这个 trait,测试用桩实现,不连宿主。 - -Java 的清单将来要这样声明(具体写法以已合入的契约 README 为准;现在 `plugin.json` 没有 `backend`,所以还没有写): - -- 调用方声明 `languageImport.settings`(版本 1); -- 导出 `languageImport.settingsChanged`(版本 1,`context: "workspace"`); -- `requires` 写 `pluginFunctions: 1`、`backend: 2` 和 `serverProcess: 1`,`pluginAPIVersion` 不低于 3; - 后端运行时还要协商到插件函数协议 4 才能调用中转方法。 - -**为什么变化通知函数还没有注册。** 槽位是按提供方分的:一次变化调用只替换发送者自己的那一份。可信的发送者只能来自宿主。 -中转已在 [#1742](https://github.com/1lck/Lithe-IDEA/pull/1742) 合入,调用方插件 ID 由宿主放在插件函数协议 4 的 `caller` 字段里; -Rust SDK 0.2.0 只协商协议 1–3,处理函数拿不到这个字段。如果从调用参数里读一个"我是谁",任何插件都能覆盖别人的设置。 -所以 `Contributions::record_settings`、`accept_answer` 和 `plugin_functions::record_answer` 已就位,等有支持协议 4 的 SDK 之后, -注册函数只需几行。这一项记为 `TODO(host-api: API-35 跨插件函数)`。 +| 宿主方法 | 形状 | +| --- | --- | +| `lithe/host/functions/providers` | 参数 `{ functionID }`,结果 `{ providers: [{ pluginID, version }] }`,按插件 ID 排序、不含调用方自己;`version` 是对方导出的函数版本 | +| `lithe/host/functions/invoke` | 参数 `{ pluginID, functionID, context: { contextID, generation }, params?, timeoutMilliseconds?, parentOperationID? }`,结果 `{ value }`;未定义的字段是 `-32018`(`plugin.host.unsupportedOption`) | + +两个方法都只对协商到插件函数协议 4 的后端开放,协议 3 及以下得到 `-32601`;Rust SDK 0.3.0 协商协议 4,`context` 和嵌套调用的 `parentOperationID` +由 SDK 填。收集逻辑只依赖 `FunctionRelay` 这个 trait,测试用桩实现,不连宿主。SDK 按已发布的标签 `sdk-rust-v0.3.0` 固定(提交 `db17c719`, +见 `sdk.lock.json`)。 + +Java 的清单现在这样声明(以已合入的契约 README 为准): + +- 顶层 `backend.launch`:`{ "kind": "packaged", "path": "bin/lithe-java-backend" }`。后端不读命令行参数,所以没有 `arguments` + (不再用 `--state ${stateDir}`;需要工作区状态目录时读宿主工作区上下文里的 `stateDirectory`)。打包脚本把按架构编译好的二进制放到这个路径,`verify-package.sh` 检查它。 +- `pluginFunctions.calls` 为 `languageImport.settings`(版本 1);`pluginFunctions.exports` 为 `languageImport.settingsChanged`(版本 1,`context: "workspace"`), + 两个数组按 `id` 排序。 +- `requires` 写 `backend: 2`、`pluginFunctions: 1`、`languageServers: 3` 和 `serverProcess: 1`,`pluginAPIVersion` 为 4; + 后端运行时协商到插件函数协议 4 才能调用中转方法。 +- 权限:`backend.launch`、`languageServer.request`(语言服务器列了 `requestMethods`)、`workspace.metadata`(读取工作区根路径需要 `backend` 2)。 +- `languageServers[0]` 声明 `requestMethods: ["workspace/executeCommand"]` 和 `commands: ["java.project.updateSettings"]`; + `launch`(`kind: "runtime"`)和 `hostIntegration`(`id: "jdt"`)保持不变,直到 API-23 就位(第 10 节)。 +- 激活事件加 `onWorkspaceOpen`:本插件要主动调用 `languageImport.settings` 并接收语言服务器的状态通知,只导出函数不足以启动后端。 + 后端是全机一个进程,所有工作区共享。 + +**变化通知函数的调用方来源。** 槽位按提供方分:一次变化调用只替换发送者自己的那一份。可信的发送者只能来自宿主, +调用方插件 ID 由 Core 放在插件函数协议 4 的 `caller` 字段里,SDK 0.3.0 经 `Context::caller()` 提供;后端不读调用参数里的“我是谁”, +否则任何插件都能覆盖别人的设置。缺 `caller` 的调用(不是 Core 中转来的)被拒绝。 **信任边界。** 宿主只中转,不传递信任:Maven/Gradle 插件用自己的权限执行,Java 不因此获得权限;Java 只接受 `CONTRIBUTED_SETTINGS_KEYS` 里的键,所以一个提供方既不能改 `java.home`,也不能改别的插件的槽位。 @@ -397,9 +418,11 @@ Rust SDK 0.2.0 只协商协议 1–3,处理函数拿不到这个字段。如 - `dj/host/indexing.rs`:索引排除规则必须经宿主送到语言服务器的所有配置通道。 - `dj/testing/debug_launch.rs`:真实解析要向 JDT LS 发 Java 测试请求。 - `dj/jdt_integration/mod.rs`:宿主仍在自己运行这些启动与状态规则,要由语言服务器启动声明取代。 -- `backend/src/lib.rs`(与 API-27、API-29 并列):宿主能为编辑器提供者、运行配置、Java 测试流程调用插件函数之后, - 在 `plugin.json` 声明 `backend`(`requires.backend: 2`,`requires.serverProcess: 1`, - `backend.launch {kind: "packaged", path: "bin/lithe-java-backend"}`,`--state ${stateDir}`),并把宿主每个调用点从 Core 切到这些函数。 +- `backend/src/lib.rs`(与 API-27、API-29 并列):`plugin.json` 已声明 `backend`(`requires.backend: 2`,`requires.serverProcess: 1`, + `backend.launch {kind: "packaged", path: "bin/lithe-java-backend"}`,第 5.7 节)。宿主能为编辑器提供者、运行配置、Java 测试流程调用插件函数之后, + 再把宿主每个调用点从 Core 切到这些函数。 +- 语言服务器仍用 `launch.kind: "runtime"` 加 `hostIntegration` 启动。改为 `launch.kind: "backend"`、去掉 `hostIntegration`、 + 由插件接管运行/调试/测试(阶段 J2),依赖 API-23(见下文“来自计划里其他编号的占位”)。 ### API-27 编辑器动作与标记 @@ -415,27 +438,31 @@ Rust SDK 0.2.0 只协商协议 1–3,处理函数拿不到这个字段。如 ### API-33 项目模板 -- 本仓库没有 `TODO(host-api: API-33)` 占位:三个项目生成器(`java`、`maven-archetype`、`spring-boot`)已经是 `plugin.json` 里的 +- 本仓库没有 `TODO(host-api: API-33)` 占位:两个项目生成器(`java`、`spring-boot`)是 `plugin.json` 里的 声明式生成器,用的是已有的 `projectGenerators` 1。API-33 在计划里是"既有能力"。 -- 与它相关的判断见第 7 节(`maven-archetype` 生成器、pom 与 gradle 模板的归属)。 +- `maven-archetype` 生成器、Maven 与 Gradle 构建文件模板、Maven 图标已经移交 Maven、Gradle 插件(第 7 节)。 + +### API-35 跨插件函数(已完成) -### API-35 跨插件函数 +Java 一侧的中转已经实现并在清单里声明(第 5 节、第 5.7 节),本节原来的占位已清除: -- `backend/src/plugin_functions.rs`:中转形状仍是合入前的草稿,要按已合入的 [#1742](https://github.com/1lck/Lithe-IDEA/pull/1742) 对齐,前提是支持插件函数协议 4 的 SDK(第 5.7 节)。 -- `backend/src/plugin_functions.rs`:`languageImport.settingsChanged` 还没有注册。中转已合入,调用方插件 ID 在插件函数协议 4 的 `caller` 字段里, - Rust SDK 0.2.0 只协商协议 1–3,要等支持协议 4 的 SDK(第 5.7 节)。 -- `dj/lsp/languages/jdt.rs`:没有中转时 `CONTRIBUTED_SETTINGS_KEYS` 的贡献者不会出现(第 5.1 到 5.3 节)。 -- `dj/lsp/languages/jdt.rs`:Maven profiles 缺口(第 5.4 节)。 -- `dj/lsp/languages/jdt.rs`:`ContributedSettingsStatus` 的 `Pending` 由调用方在收集期间持有,变化后的 `workspace/didChangeConfiguration` - 还需要 API-25 的通道(第 5.3 节)。 -- `dj/execution/toolchain_requirements.rs`:构建插件通过函数提供工具链要求(第 5.5 节)。 -- `dj/testing/jdt_test_items.rs`:测试运行委托给构建插件(第 5.6 节)。 +- 中转形状:后端按已合入的 [#1742](https://github.com/1lck/Lithe-IDEA/pull/1742) 经 Rust SDK 0.3.0(插件函数协议 4)调用,已完成。 +- `languageImport.settingsChanged`:已注册,调用方插件 ID 取自 `caller`,已完成。 +- 变化后的 `workspace/didChangeConfiguration` 与 Maven profiles(`java.project.updateSettings`):经 `languageServer/notify` 与 + `languageServer/request` 发出(#1745),已完成(第 5.2、5.4 节)。 +- 还剩下的两项属于其他编号:`java.configuration.runtimes` 的推送等 API-23(`toolchains.list`),见下文; + 构建插件提供工具链要求和测试运行(第 5.5、5.6 节)仍是设计,没有实现。 + - `dj/execution/toolchain_requirements.rs`:构建插件通过函数提供工具链要求(第 5.5 节)。 + - `dj/testing/jdt_test_items.rs`:测试运行委托给构建插件(第 5.6 节)。 ### 来自计划里其他编号的占位(`†`,不在指定的五个之内) - API-20 存储:`dj/execution/argfile.rs`(后端没有插件自有的临时目录)、`dj/jdt_integration/workspace_state.rs`(缓存目录来自宿主)。 - API-22 日志与进度:日志部分已完成,后端接入 `litheLogging` 1,不再有占位(第 3.1 节“Java 日志事件”一行,级别表见 README 的“日志”一节)。 - API-23 工具链与进程:`dj/jdt_integration/runtime.rs`(探测 `java -version` 的进程执行)。 + `toolchains.list`(列出已安装 JDK)也属于它:插件后端没有这个宿主方法,所以设置中转暂不推送 `java.configuration.runtimes` + (`backend/src/settings_session.rs` 里的 `TODO(host-api: API-23 toolchains.list)`),JDT LS 的 VM 登记保持现状。 + 这是阶段 J2(语言服务器改为 `launch.kind: "backend"`、去掉 `hostIntegration`、插件接管运行/调试/测试)的前置条件。 - API-37 环境与 l10n:`dj/jdt_integration/runtime.rs`、`dj/jdt_integration/launch_resources.rs`、`backend/src/lib.rs`(用户可见文案要走宿主 i18n)。 - API-42 内置 Git 只读:`dj/host/git.rs`、`dj/languages/code_vision_hints.rs`(blame 作者)、`dj/lsp/languages/jdt_project_metadata.rs`(一条测试注释,Git 忽略查询)。 - API-30 调试适配器:第 3.1 与 3.4 节表里的 `debug/engine.rs` 特例与 `RealJavaDebugIntegrationTests`,代码里没有单独占位。 @@ -450,9 +477,17 @@ Rust SDK 0.2.0 只协商协议 1–3,处理函数拿不到这个字段。如 - **`java.project.sourcePaths` 键**:Core 当时用这个键发 POM 里的源码目录,所以把它放进接受列表;键本身属于 JDT LS 的命名空间,值由 Maven 插件给。 - **`project_update_notification`**:是 JDT LS 的通用协议(`java/projectConfigurationsUpdate`),保留在 Java 一侧,Maven 插件改了设置后由 Java 发出。 - **`java.maven.downloadSources: false`**:留在 Java 自己的设置里。它是 JDT LS 的行为开关,不涉及 Maven 的配置值。 -- **模板、图标与 `maven-archetype` 生成器**:`plugin.json` 里 `java` 生成器带 Maven/Gradle 构建文件模板,`maven-archetype` 生成器运行 - `mvn archetype:generate`,`icons/maven*.svg` 是 Maven 图标。它们是"新建项目"(API-33),不是"导入工程",这次按已有行为一起移植。 - 这是否应该归 Maven 插件,由协调者决定;归过去时只需移动文件和 `plugin.json` 声明。 +- **模板、图标与 `maven-archetype` 生成器(已移交)**:原来 `plugin.json` 里 `java` 生成器带 Maven/Gradle 构建文件模板,`maven-archetype` + 生成器运行 `mvn archetype:generate`,`icons/maven*.svg` 是 Maven 图标。它们是"新建项目"(API-33),不是"导入工程",最初按已有行为一起移植, + 现在已按协调者的决定移交: + - 移交 lithe-plugin-maven:`maven-archetype` 生成器、Maven 项目模板(`templates/java/maven`,即 `pom.xml`)、Maven 图标(`icons/maven.svg`、`maven_dark.svg`), + 以及 Maven 项目用的标准目录模板(`templates/java/standard-sample`、`standard-sample-main`、`standard-empty`)。 + - 移交 lithe-plugin-gradle:Gradle 模板(`templates/java/gradle-kotlin`、`gradle-groovy`)。原来 Maven 和 Gradle 项目共用同一组标准目录模板 + (`standard-*` 与 `src/main/java//Main.java` 的打开文件规则),Gradle 插件需要自带一份,不能指望引用 Maven 插件的文件。 + - 本仓库保留:`java` 生成器只剩“无构建系统”一种(字段 `jdk`、`addSampleCode`,模板 `templates/java/plain`、`plain-sample`、`plain-empty`), + 以及 `spring-boot` 生成器(Spring 不拆)。`buildSystem`、`gradleDsl`、`groupId`、`artifactId` 字段随之删除。 + - **在这两个插件接手模板之前不要发布本插件的新版本**,否则“新建项目”会少掉 Maven 和 Gradle 选项。 + - 依赖本仓库旧版 `java` 生成器(带 `buildSystem` 字段)的调用方需要改为由 Maven/Gradle 插件各自的生成器提供。 - **6 个没有 Rust 测试读取的 fixture**:`lsp/java-build-report-v1.json`、`lsp/java-navigation-v1.json`、`lsp/jdt-direct-launch-v1.json`、 `java/basic.json`、`mybatis/basic.json`、`spring/basic.json`。它们是主仓库里的共享契约样例,复制过来为了让两边保持同一份样例; 本仓库没有消费它们的类型测试。`repository_pins.rs` 只断言 fixture 数量不少于 18 且都是合法 JSON。 @@ -466,19 +501,18 @@ Rust SDK 0.2.0 只协商协议 1–3,处理函数拿不到这个字段。如 - Core 的跨插件函数中转(API-35)合并,Maven/Gradle 插件导出 `languageImport.settings`。 - 宿主的语言服务器通道(API-25)、编辑器标记(API-27)、运行与测试(API-29)合并,Java 插件声明 `backend` 并通过 e2e。 -- 本仓库发布一个支持 API 4 的预发布版本(普通插件,不带发布者签名);转为正式发布要等第 8.2 节列出的两条宿主限制取消、 - 主仓库不再内置 Java Support、Lithe 稳定版提供 `litheLogging` 1(见第 9 节)。 +- 本仓库发布一个支持 API 4 的预发布版本(普通插件,不带发布者签名),且 Maven、Gradle 插件已经接手项目模板(第 7 节);转为正式发布要等 + 主仓库不再内置 Java Support、Lithe 稳定版提供 `backend` 2、`languageServers` 3、`pluginFunctions` 1 和 `litheLogging` 1(见第 9 节)。 + 原来的两条按官方身份设的宿主限制已由主仓库 [#1763](https://github.com/1lck/Lithe-IDEA/pull/1763) 取消(第 8.2 节)。 ### 8.2 macOS 阶段(阶段 E):Core 与 macOS 应用 本仓库的 README、发布工作流和 Release 说明里说的"主仓库阶段 E",指的就是这一节的删除。 - Core: - - 取消两条按官方身份设的宿主限制,本插件才能作为普通插件被宿主接受: - - `rust/lithe-core/src/plugins/declarative.rs` 把 `languageServers[].hostIntegration`(JDT)限定给 `dev.lithe.*` 插件 ID, - 其他 ID 得到错误 `plugin.integration.unsupported`。这个限制随 JDT 宿主集成一起,由通用 API 取代后消失。 - - `rust/lithe-core/src/toolchain/definitions.rs` 只对带宿主验证过的官方来源的包接受 `java` 这样的短 `toolDefinitions` ID。 - 要改成对 `java` 工具 ID 使用一条不依赖官方身份的规则(Maven 插件依赖工具 ID `java`)。 + - 两条按官方身份设的宿主限制已由主仓库 [#1763](https://github.com/1lck/Lithe-IDEA/pull/1763) 取消,本插件可以作为普通插件被宿主接受: + 普通插件可以声明 `languageServers[].hostIntegration`(JDT)和短 `toolDefinitions` ID(例如 `java`); + 多个插件争用同一个短工具 ID 时都不归属。`hostIntegration` 本身仍在 Core 里,随 JDT 宿主集成一起,由通用 API 取代后删除(阶段 J2)。 - `lsp/languages/{java_*,jdt*,project_preparation}.rs` 与 `mod.rs` 里的 re-export,`languages/{java,java_syntax,spring,mybatis}.rs`, `debug/java_test.rs`,`execution/{java_selection,launch_command,classpath_jar}.rs` 的 Java 部分,`protocol/contracts.rs` 里的 Java、Spring、MyBatis、Maven 模型。 - `protocol/command.rs` 与 `runtime/dispatcher.rs` 里的 Java 命令;`engine.rs` 里的 Java 函数与 `StartServerRequest` 的 Java 字段; @@ -523,8 +557,7 @@ Rust SDK 0.2.0 只协商协议 1–3,处理函数拿不到这个字段。如 它在主仓库里原来是 `dev.lithe.plugin.java-support`,`vendor` 为 `dev.lithe`,带 `publisherPackage` 和 `bundledSignatureRequirement: sameTeamAsHost`; 这两项随官方身份一起删除。`requires.packageTrust` 也删除,因为它只服务 `vendor.bundledSignatureRequirement`。 权限、设置和其余 `requires` 不变。版本仍是 1.1.0,新 ID 下还没有发布过任何版本。 -- `backend/tests/repository_pins.rs` 的 `the_manifest_keeps_the_community_identity_and_declares_no_backend_yet` 断言这个身份 - (`vendor`、没有 `bundledSignatureRequirement` 和 `packageTrust`)以及清单里没有 `backend`; +- `backend/tests/repository_pins.rs` 断言这个身份(`vendor`、没有 `bundledSignatureRequirement` 和 `packageTrust`); `scripts/verify-package.sh` 要求 `vendor.signatureRequirement` 为 `none`,并拒绝包里的 `lithe-plugin-signature.json`。 两者共同保证插件包既不携带发布者签名,也不要求宿主去验证发布者签名。 - 主仓库里的签名器(`LithePluginPackageSigner`)和官方校验器(`LitheOfficialPluginVerifier`)没有移植,本仓库不构建也不使用它们, @@ -535,10 +568,13 @@ Rust SDK 0.2.0 只协商协议 1–3,处理函数拿不到这个字段。如 - 标签是 `v`,必须等于 `plugin.json` 的 `version`(当前 1.1.0)。发布资产是 `Lithe-Java-Support-{version}-darwin-{arm64,x64}.zip` 加 `.sha256`,由 `scripts/archive-package.sh` 用 `ditto --keepParent` 制作;本地开发、CI 和发布用的是同一个脚本、同一种归档。 - `Release` 工作流先在 Ubuntu 上跑 Rust 检查,再在 macOS 上构建、校验并归档 arm64 与 x86_64 两个包,最后发布。没有任何步骤读取签名密钥。 -- CI 的 `package` 任务(macOS,草稿 PR 不跑)构建并归档同样的两个架构,把 `dist/` 作为 `java-support-archives` 上传。 -- SDK 固定在 `sdk.lock.json`(`lithe_plugin_sdk` 0.2.0,标签 `sdk-rust-v0.2.0`,提交 `940394fcd8455186ee985e6c34a319032414607f`), - `scripts/verify-sdk-lock.sh` 检查 `Cargo.lock` 解析到的是同一个提交。0.2.0 起 SDK 要求宿主提供 `litheLogging` 1: - 不提供的 Lithe 版本会拒绝启动后端。目前只有 Lithe 最新 `preview` 提供,稳定版还没有。日志的级别与类别见 README 的“日志”一节。 + macOS 作业用 `dtolnay/rust-toolchain@stable` 安装 Rust 和 `aarch64-apple-darwin`、`x86_64-apple-darwin` 两个 target, + 因为 `scripts/assemble-package.sh` 会按架构编译后端并放进包的 `bin/`(`universal` 用 `lipo -create` 合并)。 +- CI 的 `package` 任务(macOS,草稿 PR 不跑)同样安装两个 target,构建并归档同样的两个架构,把 `dist/` 作为 `java-support-archives` 上传。 +- SDK 固定在 `sdk.lock.json`,`scripts/verify-sdk-lock.sh` 检查 `Cargo.lock` 解析到的是同一个提交,加 `--download` 时再按 SHA-256 校验发布的 crate 包。 + 后端依赖已发布的 Rust SDK 0.3.0(标签 `sdk-rust-v0.3.0`,提交 `db17c719`,插件函数协议 4)。 + SDK 从 0.2.0 起要求宿主提供 `litheLogging` 1:不提供的 Lithe 版本会拒绝启动后端。目前只有 Lithe 最新 `preview` 提供,稳定版还没有。 + 日志的级别与类别见 README 的“日志”一节。 ### 9.3 为什么仍是预发布 @@ -546,32 +582,38 @@ Rust SDK 0.2.0 只协商协议 1–3,处理函数拿不到这个字段。如 1. 主仓库仍然以旧 ID `dev.lithe.plugin.java-support` 内置 Java Support(`Plugins/marketplace/official-plugins.json` 里的条目 `releaseEnabled: false`,`bundledUntilPublishedVersion: "1.1.0"`)。 -2. 当前的宿主会拒绝这个包: - - Lithe Core 把 `languageServers[].hostIntegration`(JDT)保留给 `dev.lithe.*` 插件 ID(`rust/lithe-core/src/plugins/declarative.rs`, - 错误 `plugin.integration.unsupported`); - - 短 `toolDefinitions` ID(例如 `java`)只在宿主验证过官方来源时才接受(`rust/lithe-core/src/toolchain/definitions.rs`)。 - 本包两者都用到了。 -3. 后端链接的 Rust SDK 0.2.0 需要宿主提供 `litheLogging` 1,不提供的 Lithe 版本会拒绝启动后端。目前只有 Lithe 最新 `preview` 提供, - 稳定版还没有,所以在 Lithe 稳定版提供它之前,Release 保持预发布。 - -转为正式发布的条件:主仓库完成阶段 E(第 8.2 节),用通用 API 取代 JDT 宿主集成,给 `java` 工具 ID 一条不依赖官方身份的规则 -(Maven 插件依赖工具 ID `java`),不再内置 Java Support,并且 Lithe 稳定版已经提供 `litheLogging` 1。 -之后在 `release.yml` 里去掉 `--prerelease`,商店自动上架稳定版。 +2. 包声明了 `backend` 2、`languageServers` 3(插件自有会话、`requestMethods`、`commands`)和 `pluginFunctions` 1,后端链接的 Rust SDK 0.3.0 + (插件函数协议 4)还要求宿主提供 `litheLogging` 1,不提供的 Lithe 版本会拒绝这个包或拒绝启动后端。这些接口来自主仓库的 + [#1745](https://github.com/1lck/Lithe-IDEA/pull/1745)、[#1746](https://github.com/1lck/Lithe-IDEA/pull/1746) 和中转 [#1742](https://github.com/1lck/Lithe-IDEA/pull/1742), + 目前只有包含它们的 Lithe 最新 `preview` 提供,稳定版还没有,所以在 Lithe 稳定版提供它们之前,Release 保持预发布。 + (原来的第二个原因,即 `hostIntegration` 和短工具 ID `java` 只给 `dev.lithe.*` 或官方来源,已由主仓库 [#1763](https://github.com/1lck/Lithe-IDEA/pull/1763) 取消。) +3. Maven 与 Gradle 的新建项目模板和 Maven 原型生成器已经移交 lithe-plugin-maven 和 lithe-plugin-gradle(第 7 节)。在这两个插件接手之前发布新版本, + “新建项目”会少掉 Maven 和 Gradle 选项,所以**在它们接手模板之前不要发布本插件的新版本**。 + +转为正式发布的条件:主仓库完成阶段 E(第 8.2 节),不再内置 Java Support,Lithe 稳定版已经提供 `backend` 2、`languageServers` 3、 +`pluginFunctions` 1 和 `litheLogging` 1,并且 Maven、Gradle 插件已发布并带着模板。JDT 宿主集成由通用 API 取代是阶段 J2 的事(依赖 API-23), +不阻塞正式发布。之后在 `release.yml` 里去掉 `--prerelease`,商店自动上架稳定版。 下载的 ZIP 通过 设置 → 插件 的 ⚙ 菜单 → 从磁盘安装插件… 安装,但在此之前当前宿主会按上面的原因拒绝它。 阶段 E 还要退役内置的 `dev.lithe.plugin.java-support` 条目并把用户迁移到新 ID,见第 8.2 节。 ### 9.4 运行时只读 插件包在运行时只读:JDT LS、Lombok、java-debug、vscode-java-test 运行器和 JDK 都在构建阶段固定来源、校验哈希后放进包里; -运行时的状态只写宿主给的状态目录(`--state ${stateDir}`),不写包目录,保证 Sparkle 增量更新的基线不变。 +后端二进制 `bin/lithe-java-backend` 也在构建阶段由 `scripts/assemble-package.sh` 按架构编译后放进包里(`universal` 用 `lipo -create` 合并), +`scripts/verify-package.sh` 检查它是可执行文件、`lipo -archs` 与包的架构一致,且 `bin/` 里只有它一个文件。 +运行时的状态只写宿主给的状态目录(后端不接收命令行参数,需要工作区状态目录时读宿主工作区上下文里的 `stateDirectory`),不写包目录,保证 Sparkle 增量更新的基线不变。 包里的 JDK 保留上游的 Apple 代码签名(macOS 只在有这个签名时才会启动它),`scripts/verify-package.sh` 检查这一点; 这是 JDK 自己的签名,不是插件发布者的签名。 ## 10. 未完成事项 -- 宿主没有接线:`plugin.json` 没有 `backend` 声明,第 6 节的 API 全部没有实现,三个会话命令只回答 `NotSupported`。 -- API-35 的 Java 一侧大部分已实现(第 5 节),但中转形状仍是合入前的草稿,要等支持插件函数协议 4 的 SDK 再对齐;变化通知函数没有注册;没有任何地方调用 `collect_for_call`(要等 API-25 的启动解析器)。Maven profiles 的逐工程交付形状没有定。 -- `jdt_contributions.rs` 和 `plugin_functions.rs` 的 30 个测试只验证了插件内部逻辑:它们已经编译并通过,但对照的是草稿形状。[#1742](https://github.com/1lck/Lithe-IDEA/pull/1742) 已合入,等 SDK 支持协议 4、形状对齐后要按已合入的协议复核。 +- 宿主侧(阶段 J2)还没有切换:`plugin.json` 已声明 `backend` 2、`languageServers` 3 和 `pluginFunctions` 1,设置中转(API-25 / API-35)已在后端实现, + 但语言服务器仍走 `hostIntegration`(JDT),启动、运行时选择和 `java.configuration.runtimes` 还由宿主决定。切换要等 API-23 `toolchains.list` + (后端的 `TODO(host-api: API-23 toolchains.list)`),第 6 节其余 API 没有实现,三个会话命令仍只回答 `NotSupported`。 +- Maven 与 Gradle 插件还没有接手新建项目模板(第 7 节):lithe-plugin-maven 要带 `maven-archetype` 生成器、`pom.xml` 模板、Maven 图标和 `standard-*` 目录模板, + lithe-plugin-gradle 要带 `gradle-kotlin`、`gradle-groovy` 模板和自己的一份 `standard-*` 目录模板。在它们接手之前不要发布本插件的新版本。 +- 设置中转只由后端单元测试和桩中转测试验证,没有对着真实宿主跑过。变化通知(`languageImport.settingsChanged`)触发 `java.project.updateSettings` + 的完整链路,以及“没有提供方就不推送、沿用主仓库 `hostIntegration` 设置”的过渡规则,要在宿主合入 #1745、#1746 后用真实 JDT 验证。 - 没有真实 JDT 的端到端测试,也没有在 Linux 上实跑过(本机没有 Docker)。 - CI 的 `package` 任务构建的归档和正式发布的是同一种(同一组脚本、同一个文件名规则),但还没有真正跑过一次发布工作流: 新 ID 下没有发布过任何版本,标签触发、预发布 Release 的创建和资产上传都没有被实际验证过。 diff --git a/NOTICE b/NOTICE index 49e5f2f..5f36415 100644 --- a/NOTICE +++ b/NOTICE @@ -20,10 +20,10 @@ copied into the package next to the component: Classpath Exception https://adoptium.net/ -The icons java.svg, java_dark.svg, maven.svg and maven_dark.svg in +The icons java.svg and java_dark.svg in io.github.lithe-idea.java-support/icons/ come from the JetBrains IntelliJ Platform Community repository (https://github.com/JetBrains/intellij-community), -platform/icons/src/fileTypes and plugins/maven resources, licensed under the +platform/icons/src/fileTypes, licensed under the Apache License, Version 2.0 (a copy is in io.github.lithe-idea.java-support/icons/LICENSE-APACHE-2.0.txt). Copyright 2000-2024 JetBrains s.r.o. and contributors. diff --git a/README.md b/README.md index 58899e1..2e6cca4 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,13 @@ > Lithe Core relays between plugins. > **进行中**:本仓库正在从 Lithe 主仓库拆分出来,目标是 Lithe 宿主(Rust Core 和 macOS 应用)不再包含任何 Java 专属逻辑, -> Java 的领域规则全部由本插件提供,宿主只提供通用插件接口。插件后端还没有接入宿主:`plugin.json` 暂不声明 `backend`, -> 要等 `MIGRATION.md` 第 6 节列出的宿主插件接口合并。在那之前 Lithe 仍然内置 Java Support(旧 ID `dev.lithe.plugin.java-support`), -> 本仓库发布的版本标为预发布,原因见 `MIGRATION.md` 第 9 节。 +> Java 的领域规则全部由本插件提供,宿主只提供通用插件接口。插件现在带一个 Rust 后端:`plugin.json` 声明了 `backend` 2, +> 后端目前承担 Maven/Gradle 导入设置的中转(见“工作方式”)。语言服务器仍然由宿主的 JDT 集成启动 +> (`launch.kind` 为 `runtime`,带 `hostIntegration`);改为后端启动、去掉 `hostIntegration`、由插件接管运行/调试/测试, +> 要等宿主提供 API-23(`toolchains.list`),清单见 `MIGRATION.md` 第 6 节和第 10 节。在那之前 Lithe 仍然内置 Java Support +> (旧 ID `dev.lithe.plugin.java-support`),本仓库发布的版本标为预发布,原因见 `MIGRATION.md` 第 9 节。 +> Maven Archetype 生成器、Maven 与 Gradle 的项目模板已经移交 lithe-plugin-maven 和 lithe-plugin-gradle; +> 在这两个插件接手模板之前不要发布本插件的新版本,否则“新建项目”会少掉 Maven 和 Gradle 选项。 ## 功能 @@ -25,36 +29,49 @@ | 运行与调试入口 | 识别 `main` 方法和测试方法,生成运行/调试标记 | | 测试 | 发现 JUnit 测试项,生成测试选择器,并驱动 java-debug 与 vscode-java-test 运行器 | | Spring Boot / MyBatis | 索引 Spring 端点与 MyBatis 映射,支持跳转 | -| 新建项目 | 三个声明式项目生成器:Java、Maven Archetype、Spring Boot | -| Maven / Gradle 工程 | 导入设置(settings 文件、本地仓库、offline、Gradle user home 等)由 Maven、Gradle 插件通过 `languageImport.settings` 提供,Java 不计算也不解析它们 | +| 新建项目 | 两个声明式项目生成器:Java(无构建系统,可选示例代码)、Spring Boot;Maven 原型和 Maven/Gradle 项目模板由 Maven、Gradle 插件提供 | +| Maven / Gradle 工程 | 导入设置(settings 文件、本地仓库、offline、Gradle user home 等)由 Maven、Gradle 插件通过 `languageImport.settings` 提供,Java 不计算也不解析它们;后端收下后推给 JDT LS,Maven profiles 变化时触发 `java.project.updateSettings` | ## 工作方式 - **领域规则**在 `crates/java-domain`,不依赖宿主:命令的 JSON 载荷和错误码与主仓库 Core 一一对应。 所有宿主差异(存储、Git、语言服务器通道)都是带 `TODO(host-api: API-NN)` 的占位;日志已接入宿主统一日志,见“日志”一节。 - **插件后端**在 `backend/`:基于固定版本的 Rust SDK(`sdk.lock.json`),把每条领域命令注册为同名插件函数, - 并带着跨插件函数的调用方一侧。 -- **跨插件函数**:启动 JDT LS 之前,后端通过 Core 中转发现所有导出 `languageImport.settings` 的插件(Maven、Gradle), - 逐个调用,只接受落在 JDT LS 导入设置命名空间里的键,合并后交给语言服务器。 - Maven 设置变化时调用 Java 导出的 `languageImport.settingsChanged`;Java 不会反过来调用正在调用它的插件。 + 并带着跨插件函数的调用方与导出方两侧。`plugin.json` 用 `backend.launch`(`kind: "packaged"`,路径 `bin/lithe-java-backend`)声明它, + 打包脚本把按架构编译好的二进制放进包的 `bin/`。后端是全机一个进程,所有工作区共享。 +- **设置中转**(Maven/Gradle 导入设置到 JDT LS): + - 调用方向:宿主通知 `lithe/host/languageServer/didChangeState` 说某个工作区的 Java 语言服务器进入 `running` 后,后端通过 Core 中转发现所有导出 + `languageImport.settings` 的插件(Maven、Gradle),逐个调用,只接受落在 JDT LS 导入设置命名空间里的键,合并后用 + `languageServer/notify` 向语言服务器推送 `workspace/didChangeConfiguration`。语言服务器重启(代号变化)后会重新收集。 + - 导出方向:Maven/Gradle 设置变化时调用 Java 导出的 `languageImport.settingsChanged`(`context: "workspace"`)。后端按 Core 写入的调用方插件 ID + **整份替换**该调用方自己的槽位,不会影响别的提供方;调用里可以附带 `projects`(`{projectUri, selectedProfiles}`), + 后端据此向 JDT LS 发 `java.project.updateSettings`,应用 Maven profiles。Java 不会反过来调用正在调用它的插件,否则调用链会被 Core 当作环路拒绝。 + - 过渡规则:没有任何提供方时后端什么都不推送,主仓库 jdt 宿主集成推送的设置保持生效;后端的整份替换只在至少有一个提供方参与后才发生。 + - 清单声明:`requires.backend: 2`、`pluginFunctions: 1`、`languageServers: 3`;`pluginFunctions.calls` 为 `languageImport.settings`, + `exports` 为 `languageImport.settingsChanged`;权限 `backend.launch`、`languageServer.request`、`workspace.metadata`; + 语言服务器声明 `requestMethods: ["workspace/executeCommand"]` 和 `commands: ["java.project.updateSettings"]`;激活事件 `onWorkspaceOpen`, + 这样打开工作区时后端就会启动,Maven/Gradle 才能随时调用它。 + - 当前不推送 `java.configuration.runtimes`:插件后端还没有列出 JDK 安装的宿主方法,等 API-23(`toolchains.list`)提供。JDT LS 不会因为缺这个键而删除已登记的 VM。 + 细节见 `MIGRATION.md` 第 5 节。 -- **运行时只读**:插件包安装后在运行时只读。JDT LS、Lombok、java-debug、测试运行器和 JDK 都在构建阶段固定来源、校验哈希后放进包里, +- **运行时只读**:插件包安装后在运行时只读。JDT LS、Lombok、java-debug、测试运行器、JDK 和后端二进制都在构建阶段固定来源、校验哈希(或由锁定的源码编译)后放进包里, 运行时状态只写宿主提供的状态目录,不写包目录。 ## 日志 后端把日志交给宿主的统一日志 `litheLogging` 1,不写 stderr。函数 ID 都是编译期常量,注册只会因 ID 写错、重复或超出 SDK 上限而失败,单元测试 `the_backend_builds_with_every_command_registered` 在 CI 里挡住这种构建;万一发生,进程在握手前以状态 1 退出, -由宿主记录启动失败(`backend/src/main.rs`)。Rust SDK 0.2.0 要求宿主提供 `litheLogging` 1, +由宿主记录启动失败(`backend/src/main.rs`)。Rust SDK(0.2.0 起,当前 0.3.0)要求宿主提供 `litheLogging` 1, 不提供的 Lithe 版本会拒绝启动后端;目前只有 Lithe 最新 `preview` 提供,稳定版还没有。 | 事实 | 级别 | 来源 | 内容 | | --- | --- | --- | --- | -| 关键操作成功:`runConfig.selectJava`、`debug.javaTestLaunch`、`execution.planLaunchCommand`、`java.prepareLaunch`(`backend/src/lib.rs` 的 `KEY_OPERATIONS`) | INFO | SDK 自动 | 操作 ID、耗时 | +| 关键操作成功:`runConfig.selectJava`、`debug.javaTestLaunch`、`execution.planLaunchCommand`、`java.prepareLaunch`、`languageImport.settingsChanged`(`backend/src/lib.rs` 的 `KEY_OPERATIONS`) | INFO | SDK 自动 | 操作 ID、耗时 | | 其余函数成功(编辑器驱动或启动时的读取,如 `java.runMarkers`、`java.codeVision`、`spring.index`) | 不记录 | `suppress_success_diagnostics`(`backend/src/lib.rs`) | — | -| 任一函数失败 | ERROR | SDK 自动 | 类别为 `pluginErrorCode`(如 `java.invalidRequest`)、耗时;不含消息与 `details` | +| 任一函数失败 | ERROR | SDK 自动 | 类别为 `pluginErrorCode`(如 `java.invalidRequest`;`languageImport.settingsChanged` 的 `relay.caller_missing`、`relay.context_missing`、`relay.invalid_params`)、耗时;不含消息与 `details` | | 取消 | 不记录 | SDK | — | -| 列出 `languageImport.settings` 提供方失败、单个提供方失败或超时、答复不可用、答复含越界键 | WARN(`degraded`) | `backend/src/plugin_functions.rs` | 类别 `relay.providers_unavailable`、`relay.provider_failed`、`relay.provider_timed_out`、`relay.settings_rejected`、`relay.keys_rejected`,计数;不含提供方 ID | +| 列出 `languageImport.settings` 提供方失败、单个提供方失败或超时、答复不可用、答复含越界键、提供方导出的函数版本不符 | WARN(`degraded`) | `backend/src/plugin_functions.rs` | 类别 `relay.providers_unavailable`、`relay.provider_failed`、`relay.provider_timed_out`、`relay.settings_rejected`、`relay.keys_rejected`、`relay.provider_version_mismatch`,计数;不含提供方 ID | +| 取不到工作区根目录、设置推给 JDT LS 失败、单个工程的 profiles 更新失败、语言服务器状态通知格式不对 | WARN(`degraded`) | `backend/src/settings_session.rs` | 类别 `session.root_unavailable`、`session.push_failed`、`session.project_update_failed`、`session.state_invalid`,计数;操作 ID 为 `notification`(状态通知触发)或 `languageImport.settingsChanged` | | 插件存储清理失败 | WARN(`degraded`) | `crates/java-domain/src/host/diagnostics.rs`(`host::diagnostics`) | 类别 `storage.cleanup_failed.<阶段>`(阶段为 `jdt-configuration`、`jdt-settings`、`java-argfile-remove`),计数 | | JDT LS 自身日志 | 上游,默认 WARNING | `plugin.json` 的 `-Dosgi.logfile=${logDir}/jdt.log` | 只写宿主提供的会话日志目录,单文件 1 MB、保留 2 个备份;用户可在设置里调 `logLevel` | @@ -66,15 +83,17 @@ Lithe 不再区分官方插件和普通插件,所有插件都按普通插件 区别只在 [Lithe Store](https://github.com/Lithe-IDEA/Lithe-store) 怎样收录:Lithe-IDEA 组织里 `lithe-plugin-*` 仓库的稳定版(非预发布) GitHub Release 由商店机器人自动跟踪并上架;组织之外的插件向 Lithe-store 提交 pull request,维护者审核后上架。 -目前本仓库只发布预发布版,商店机器人会跳过,所以还不能从 Lithe Store 安装。当前版本的 Lithe 也会拒绝这个包: -它只允许 `dev.lithe.*` 插件使用 JDT 宿主集成(`languageServers[].hostIntegration`),只对带宿主验证过的官方来源的包接受短工具 ID `java`, -而本包两者都用到了;此外 Lithe 仍然内置旧 ID `dev.lithe.plugin.java-support` 的 Java Support。 +目前本仓库只发布预发布版,商店机器人会跳过,所以还不能从 Lithe Store 安装。当前版本的 Lithe 也会拒绝或无法完整运行这个包: +本包声明了 `backend` 2、`languageServers` 3(插件自有语言服务器会话)和 `pluginFunctions` 1,宿主要包含主仓库的 +[#1745](https://github.com/1lck/Lithe-IDEA/pull/1745)、[#1746](https://github.com/1lck/Lithe-IDEA/pull/1746) 之后的预览版才提供它们,稳定版还没有; +此外 Lithe 仍然内置旧 ID `dev.lithe.plugin.java-support` 的 Java Support。 +(普通插件使用 JDT 宿主集成和短工具 ID `java` 的限制已由主仓库 [#1763](https://github.com/1lck/Lithe-IDEA/pull/1763) 取消。) 具体原因和转为正式发布的条件见 `MIGRATION.md` 第 9 节。下载的 ZIP 通过 **设置 → 插件** 的 ⚙ 菜单 → **从磁盘安装插件…** 安装。 ## 开发 ```text -io.github.lithe-idea.java-support/ # 插件包的静态部分:plugin.json、icons/、templates/,目录名必须等于插件 id +io.github.lithe-idea.java-support/ # 插件包的静态部分:plugin.json、icons/、templates/,目录名必须等于插件 id(包里的 bin/ 由打包脚本编译后端后生成) crates/java-domain/ # Java 领域规则(Rust),测试在各模块内和 tests/ 下 backend/ # 插件后端进程(lithe-java-backend),基于 Lithe Rust 插件 SDK fixtures/ # 与主仓库共享的契约样例(JSON) @@ -87,6 +106,8 @@ MIGRATION.md # 主仓库路径到本仓库的映射、待宿 ``` Rust 部分需要 Rust 工具链和 Cargo;打包脚本需要 macOS、`zsh`、`curl`、`ditto`,不需要 Xcode 或 Swift 工具链。 +打包脚本会用 `cargo build --release --locked -p lithe-java-backend --target ` 编译后端,所以还要安装两个 Apple target: +`rustup target add aarch64-apple-darwin x86_64-apple-darwin`(`--arch arm64` 用前者,`x86_64` 用后者,`universal` 两个都编译再用 `lipo -create` 合并)。 ```bash cargo fmt --all --check @@ -106,7 +127,8 @@ scripts/archive-package.sh --package <上一步打印的目录> --arch arm64 # `build-package.sh` 会在输出包目录前校验它;`archive-package.sh` 把归档解开后再校验一次。单独校验一个包目录用 `scripts/verify-package.sh --package <目录> --arch arm64|x86_64|universal`:它要求 `plugin.json` 的 `vendor.signatureRequirement` 为 `none`, -包里不能有 `lithe-plugin-signature.json`,并检查打包的 JDK 保留着上游的 Apple 代码签名。归档不需要签名密钥,也不调用任何签名工具。 +包里不能有 `lithe-plugin-signature.json`,`bin/` 里只能有 `backend.launch.path` 指向的后端二进制,它必须可执行且包含请求的架构(`lipo -archs`), +并检查打包的 JDK 保留着上游的 Apple 代码签名。归档不需要签名密钥,也不调用任何签名工具。 `plugin.json` 的完整格式见 lithe-plugin-docs 里的 [插件契约](https://github.com/Lithe-IDEA/lithe-plugin-docs/blob/main/contract/v1/README.md)。 @@ -122,10 +144,12 @@ scripts/archive-package.sh --package <上一步打印的目录> --arch arm64 # 3. `Release` 工作流先在 Ubuntu 上跑 Rust 检查,再在 macOS 上构建、校验并归档 arm64 与 x86_64 两个包,然后发布。 发布资产是 `Lithe-Java-Support-<版本>-darwin-{arm64,x64}.zip` 和对应的 `.sha256`。没有任何步骤读取签名密钥,包也不带发布者签名。 -4. 发布保持为预发布(`--prerelease`):Lithe Store 的自动跟踪会跳过预发布,原因有三个。目前的 Lithe 一是仍然内置旧 ID 的 Java Support, - 二是只允许 `dev.lithe.*` 插件使用 JDT 宿主集成、只对带官方来源的包接受短工具 ID `java`, - 三是后端链接的 Rust SDK 0.2.0 需要宿主提供 `litheLogging` 1,目前只有 Lithe 最新 `preview` 提供,稳定版还没有。 - 等 Lithe 主仓库的阶段 E 完成(`MIGRATION.md` 第 8.2 节)、并且 Lithe 稳定版提供 `litheLogging` 1 后,去掉 `--prerelease`, +4. 发布保持为预发布(`--prerelease`):Lithe Store 的自动跟踪会跳过预发布,原因有三个。一是目前的 Lithe 仍然内置旧 ID 的 Java Support; + 二是包声明了 `backend` 2、`languageServers` 3、`pluginFunctions` 1,后端链接的 Rust SDK 0.3.0(插件函数协议 4)还要求宿主提供 `litheLogging` 1, + 需要包含主仓库 #1745、#1746 的预览版,稳定版还没有; + 三是 Maven 与 Gradle 的新建项目模板已经移交 lithe-plugin-maven 和 lithe-plugin-gradle,这两个插件接手之前发布新版本, + “新建项目”会少掉 Maven 和 Gradle 选项,所以**在它们接手模板之前不要发布本插件的新版本**。 + 等 Lithe 主仓库的阶段 E 完成(`MIGRATION.md` 第 8.2 节)、Lithe 稳定版提供上述接口、两个构建插件发布后,去掉 `--prerelease`, 商店就会自动上架稳定版。细节见 `MIGRATION.md` 第 9 节。 ## 许可 diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 78c6a6f..77e49ae 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -15,5 +15,5 @@ lithe-java-domain = { path = "../crates/java-domain" } # Pinned to the published Rust SDK release; ../sdk.lock.json records the # matching commit, crate archive and SHA-256; scripts/verify-sdk-lock.sh # checks that Cargo.lock resolved this exact commit. -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" } serde_json.workspace = true diff --git a/backend/src/lib.rs b/backend/src/lib.rs index aebc4ce..38db997 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -2,21 +2,26 @@ //! //! The backend is a [`lithe_plugin_sdk`] process that offers the commands of //! [`lithe_java_domain::commands`] as plugin functions with the same IDs and -//! JSON payloads Rust Core accepted. It has no host wiring yet: the host -//! still runs these rules itself, and the plugin manifest does not declare a -//! `backend` until the host APIs listed in `MIGRATION.md` exist. +//! JSON payloads Rust Core accepted, and that connects JDT LS to the settings +//! other plugins contribute. The plugin manifest declares it as the plugin's +//! `backend`. //! -//! TODO(host-api: API-25 language server, API-27 editor actions and markers, -//! API-29 build and test): declare the backend in `plugin.json` -//! (`requires.backend` 2, `backend.launch`, `bin/lithe-java-backend`) once the -//! host can call plugin functions for editor providers, run configurations -//! and the Java test workflow, and move each host call site from Core to -//! these functions. +//! The Java rules themselves still run in the host for the editor providers, +//! run configurations and the Java test workflow; the backend is where they +//! move, one host API at a time (see `MIGRATION.md`). //! -//! [`plugin_functions`] is the backend's side of the cross-plugin interface -//! `languageImport.settings` (API-35): it asks the Maven and Gradle plugins -//! for the settings JDT LS imports with. It is the only module that knows the -//! host relay's wire. +//! # Cross-plugin settings (API-35) +//! +//! Two modules cover the interface `languageImport.settings` and its change +//! function, and they are wired together in [`backend`]: +//! +//! - [`plugin_functions`] asks the Maven and Gradle plugins for the settings +//! JDT LS imports with. It is the only module that knows the host relay's +//! wire. +//! - [`settings_session`] keeps the answers per workspace. It collects them +//! when the host reports that the `java` language server is running, serves +//! the exported function `languageImport.settingsChanged`, and sends the +//! merged settings to JDT LS. //! //! # Logging //! @@ -27,15 +32,22 @@ //! `operationFinished` except for the functions outside [`KEY_OPERATIONS`], //! whose success is suppressed. Failures the domain layer handled and //! recovered from are reported as `degraded` through -//! `lithe_java_domain::host::diagnostics`. The backend never writes to +//! `lithe_java_domain::host::diagnostics`; the settings modules report +//! theirs through the SDK diagnostics directly, with the categories listed in +//! `plugin_functions::RELAY_CATEGORIES` and +//! `settings_session::SESSION_CATEGORIES`. The backend never writes to //! stderr, and a fact carries no path, source or tool output. pub mod plugin_functions; +pub mod settings_session; +use crate::settings_session::{ + HostServices, SettingsSession, LANGUAGE_SERVER_STATE_NOTIFICATION, SETTINGS_CHANGED_FUNCTION_ID, +}; use lithe_java_domain::commands::{self, ExecuteOptions, SESSION_COMMANDS, SUPPORTED_COMMANDS}; use lithe_java_domain::host::diagnostics::{scoped, Reporter}; use lithe_java_domain::protocol::{CoreError, ErrorCode}; -use lithe_plugin_sdk::{Backend, Context, FunctionError, PluginError, SdkError, Value}; +use lithe_plugin_sdk::{Backend, Caller, Context, FunctionError, PluginError, SdkError, Value}; use std::rc::Rc; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::Arc; @@ -43,13 +55,22 @@ use std::sync::Arc; /// Name reported as `serverInfo.name`. pub const BACKEND_NAME: &str = "lithe-java-backend"; +/// Operation name of diagnostics that belong to a host notification and not +/// to a function call. It is one of the SDK's fixed operation names. +const NOTIFICATION_OPERATION: &str = "notification"; + /// Functions whose success is a key user operation and is logged at INFO. /// Every other function is an editor-driven or startup read; its success is /// suppressed, and its failures are still logged. +/// +/// `languageImport.settingsChanged` is a key operation: another plugin +/// reports a change a user made, it happens rarely, and its success is the +/// only trace that the change reached the language server. pub const KEY_OPERATIONS: &[&str] = &[ "debug.javaTestLaunch", "execution.planLaunchCommand", "java.prepareLaunch", + SETTINGS_CHANGED_FUNCTION_ID, "runConfig.selectJava", ]; @@ -57,12 +78,13 @@ pub const KEY_OPERATIONS: &[&str] = &[ /// /// A function can only be suppressed once it is registered, so the successes /// of the functions outside [`KEY_OPERATIONS`] are suppressed after the -/// registration loop. +/// registration loop. `languageImport.settingsChanged` is registered after +/// that loop and is a key operation, so it needs no suppression. /// /// Every input is a compile-time constant, so the result is the same on each /// run: it fails only when a function ID is invalid, repeated or over the -/// SDK's limit, which the test `the_backend_builds_with_every_command_registered` -/// rejects. +/// SDK's limit, or when the notification method is refused, which the test +/// `the_backend_builds_with_every_command_registered` rejects. pub fn backend() -> Result { let mut backend = Backend::new(BACKEND_NAME).version(env!("CARGO_PKG_VERSION")); for command in SUPPORTED_COMMANDS.iter().chain(SESSION_COMMANDS) { @@ -76,9 +98,63 @@ pub fn backend() -> Result { backend.suppress_success_diagnostics(command)?; } } + + // The session works outside any function call (a server state + // notification belongs to none), so it reaches the host through the + // backend handle. + let session = Arc::new(SettingsSession::new(Arc::new(HostServices::new( + backend.handle(), + )))); + let handle = backend.handle(); + backend.on_host_notification(LANGUAGE_SERVER_STATE_NOTIFICATION, { + let session = Arc::clone(&session); + move |params| { + // An error from `degraded` only means the arguments were invalid, + // and the session passes controlled categories and counts. + let report = |code: &str, count: u64| { + let _ = handle + .diagnostics() + .degraded(NOTIFICATION_OPERATION, code, Some(count)); + }; + session.did_change_state(¶ms, &report); + Ok(()) + } + })?; + backend.function(SETTINGS_CHANGED_FUNCTION_ID, move |params, context| { + settings_changed(&session, params, context) + })?; Ok(backend) } +/// Runs a call of `languageImport.settingsChanged`. +/// +/// The caller and the workspace come from the host's relay, never from the +/// params. The call blocks while the session delivers the new settings to the +/// language server, which is the work the caller asked for. Delivery +/// failures are reported as `degraded` facts of this call and do not fail it. +fn settings_changed( + session: &SettingsSession, + params: Value, + context: &Context, +) -> Result { + context.check_cancelled()?; + let diagnostics = context.diagnostics(); + let operation = context.function_id().to_owned(); + // An error from `degraded` only means the arguments were invalid, so the + // call's own result stands. + let report = move |code: &str, count: u64| { + let _ = diagnostics.degraded(&operation, code, Some(count)); + }; + session + .settings_changed( + context.caller().map(Caller::plugin_id), + context.workspace_context(), + ¶ms, + &report, + ) + .map_err(FunctionError::from) +} + /// Runs one domain command for a plugin function call. /// /// The call's SDK cancellation is forwarded to the command's cooperative @@ -187,6 +263,38 @@ mod tests { .all(|code| lithe_plugin_sdk::is_plugin_error_code(code))); } + /// An error code names one cause. The codes `settingsChanged` answers + /// with, the domain codes and the diagnostics categories of the settings + /// modules must never share a name. + #[test] + fn settings_relay_codes_never_share_a_name_with_another_code() { + let domain: Vec<&str> = [ + ErrorCode::InvalidRequest, + ErrorCode::WorkspaceNotFound, + ErrorCode::PermissionDenied, + ErrorCode::NotSupported, + ErrorCode::RuntimeMissing, + ErrorCode::ProcessStartFailed, + ErrorCode::ProcessFailed, + ErrorCode::ParseFailed, + ErrorCode::Cancelled, + ErrorCode::TimedOut, + ErrorCode::ConnectionClosed, + ErrorCode::Unknown, + ] + .iter() + .map(plugin_error_code) + .collect(); + let mut seen: std::collections::BTreeSet<&str> = domain.iter().copied().collect(); + for name in settings_session::CHANGE_ERROR_CODES + .iter() + .chain(settings_session::SESSION_CATEGORIES) + .chain(plugin_functions::RELAY_CATEGORIES) + { + assert!(seen.insert(*name), "{name} names two causes"); + } + } + /// `main` exits without a message when [`backend`] fails, so this test is /// what keeps that branch out of every build that passed CI. #[test] @@ -200,11 +308,13 @@ mod tests { } #[test] - fn key_operations_are_registered_commands_without_duplicates() { + fn key_operations_are_registered_functions_without_duplicates() { + // The commands, and the one function registered next to them. let commands: std::collections::BTreeSet<&str> = SUPPORTED_COMMANDS .iter() .chain(SESSION_COMMANDS) .copied() + .chain([SETTINGS_CHANGED_FUNCTION_ID]) .collect(); let distinct: std::collections::BTreeSet<&str> = KEY_OPERATIONS.iter().copied().collect(); assert_eq!( diff --git a/backend/src/plugin_functions.rs b/backend/src/plugin_functions.rs index a578c81..81ac616 100644 --- a/backend/src/plugin_functions.rs +++ b/backend/src/plugin_functions.rs @@ -3,40 +3,23 @@ //! A plugin function is private to the plugin that registered it. The host //! relays calls between plugins (this plugin, Core, the other plugin), but //! only for functions the receiver exports and the caller declares. This -//! module is the one place that knows the relay's wire: the host method -//! names, their params and their results. If the relay protocol is renamed -//! or reshaped, only this file changes. +//! module is the one place that knows what the relay answers: it sends the +//! two relay requests through the SDK's `Host::functions()` service, which +//! owns the wire shape of their params, and parses their results. If the +//! relay protocol is reshaped, only this file changes. //! -//! What it does for the Java plugin: before JDT LS starts, ask every enabled +//! What it does for the Java plugin: once JDT LS is running, ask every enabled //! provider of `languageImport.settings` (a Maven or Gradle plugin, never //! named here) for the settings JDT LS should import with, and collect the //! answers in [`Contributions`]. The interface itself is defined by -//! `lithe_java_domain::lsp::languages::jdt_contributions`. +//! `lithe_java_domain::lsp::languages::jdt_contributions`. The +//! [`settings_session`](crate::settings_session) module decides when to +//! collect and what to do with the result. //! -//! TODO(host-api: API-35 cross-plugin function): the relay was merged in -//! Lithe #1742 and its real wire differs from the shapes this module still -//! uses, so the module cannot call it yet: -//! -//! - `lithe/host/functions/providers` takes `{ functionID }` and answers -//! `{ providers: [{ pluginID, version }] }`. -//! - `lithe/host/functions/invoke` takes -//! `{ pluginID, functionID, context: { contextID, generation }, params?, -//! timeoutMilliseconds?, parentOperationID? }` and answers `{ value }`. -//! Fields the host does not define are refused with -//! `plugin.host.unsupportedOption`. -//! -//! Both methods require plugin function protocol 4, while Rust SDK 0.2.0 -//! negotiates only protocols 1 to 3. The method constants and the two -//! `*_params` / parse functions therefore change together with an upgrade to -//! an SDK that speaks protocol 4; the collector and its tests do not change. -//! -//! TODO(host-api: API-35 cross-plugin function): the change function -//! `languageImport.settingsChanged` is not registered yet. It must store the -//! new settings in the slot of the *calling plugin*. Protocol 4 names the -//! caller in the `caller` field of an invocation, and SDK 0.2.0 does not -//! expose that field to a handler. Register the function with -//! `jdt_contributions::SETTINGS_CHANGED_FUNCTION` and [`record_answer`] once -//! the SDK exposes the caller. +//! The relay requires plugin function protocol 4, which Rust SDK 0.3.0 +//! negotiates. A host that offers an older protocol answers the discovery +//! with `plugin.host.unavailable`; the collector records that as a failed +//! discovery and JDT LS starts without contributed settings. use lithe_java_domain::lsp::languages::jdt_contributions::{ accept_answer, settings_request, Contributions, FunctionContract, MAX_PROVIDERS, @@ -47,17 +30,19 @@ use lithe_plugin_sdk::{ WorkspaceContext, }; use serde_json::json; -use std::thread; use std::time::Duration; -/// Host method that lists the enabled plugins exporting a function. +/// Host method that lists the enabled plugins exporting a function. The SDK +/// sends it; the name appears here only in diagnostics about its answer. pub const PROVIDERS_METHOD: &str = "lithe/host/functions/providers"; -/// Host method that calls an exported function of another plugin. +/// Host method that calls an exported function of another plugin. The SDK +/// sends it; the name appears here only in diagnostics about its answer. pub const INVOKE_METHOD: &str = "lithe/host/functions/invoke"; -/// Longest wait for one provider before JDT LS starts without its settings. -/// The host applies its own, larger limit on top. +/// Longest wait for one provider; JDT LS then gets the settings without it. +/// It is sent to the host as the call's `timeoutMilliseconds`, so the host +/// ends a slow provider's call and answers `plugin.host.timeout`. pub const PROVIDER_TIMEOUT: Duration = Duration::from_secs(10); /// Category reported when the host could not list the settings providers. @@ -76,6 +61,10 @@ pub const RELAY_SETTINGS_REJECTED: &str = "relay.settings_rejected"; /// namespace. The count is the number of rejected keys. pub const RELAY_KEYS_REJECTED: &str = "relay.keys_rejected"; +/// Category reported when a provider exports another version of +/// `languageImport.settings` than this plugin speaks, so it was not called. +pub const RELAY_PROVIDER_VERSION_MISMATCH: &str = "relay.provider_version_mismatch"; + /// Every category this module reports. Each one is a controlled code that /// names no provider, path or message. pub const RELAY_CATEGORIES: &[&str] = &[ @@ -84,8 +73,13 @@ pub const RELAY_CATEGORIES: &[&str] = &[ RELAY_PROVIDER_TIMED_OUT, RELAY_SETTINGS_REJECTED, RELAY_KEYS_REJECTED, + RELAY_PROVIDER_VERSION_MISMATCH, ]; +/// The code of a host request that the host ended because its deadline +/// passed. +const HOST_TIMEOUT_CODE: &str = "plugin.host.timeout"; + /// A relay request that was refused or failed. #[derive(Debug, Clone, PartialEq, Eq)] pub struct RelayFailure { @@ -104,10 +98,15 @@ impl RelayFailure { } /// Whether the request was cut short by cancellation: either the calling - /// function was cancelled, or only this request ran out of time. + /// function was cancelled, or only this request was cancelled. fn is_cancelled(&self) -> bool { self.code == sdk_codes::CALL_CANCELLED } + + /// Whether the host ended the request because its deadline passed. + fn is_timed_out(&self) -> bool { + self.code == HOST_TIMEOUT_CODE + } } impl From for RelayFailure { @@ -116,10 +115,21 @@ impl From for RelayFailure { } } +/// One enabled plugin that exports a function, as the host lists it. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct Provider { + /// ID of the exporting plugin. + pub plugin_id: String, + /// Version of the function the plugin exports. The caller can only use a + /// provider whose version is the one its own contract names. + pub version: u64, +} + /// The two relay requests the collector needs. pub trait FunctionRelay { - /// Plugin IDs of the enabled plugins that export `function`. - fn providers(&self, function: &FunctionContract) -> Result, RelayFailure>; + /// The enabled plugins that export `function`, with the version each + /// one exports. + fn providers(&self, function: &FunctionContract) -> Result, RelayFailure>; /// Calls `function` of `provider` and returns the function's result. fn invoke( @@ -130,33 +140,30 @@ pub trait FunctionRelay { ) -> Result; } -/// The relay as the host offers it to one running function call. -pub struct HostRelay<'a> { - host: &'a Host, - workspace: &'a WorkspaceContext, +/// The relay as the host offers it for one workspace. +/// +/// It owns its host handle and workspace context, so it serves both a running +/// function call (`Context::host()`, whose cross-plugin calls belong to that +/// call) and background work (`BackendHandle::host()`). +pub struct HostRelay { + host: Host, + workspace: WorkspaceContext, call_timeout: Duration, } -impl<'a> HostRelay<'a> { - pub fn new(host: &'a Host, workspace: &'a WorkspaceContext, call_timeout: Duration) -> Self { +impl HostRelay { + pub fn new(host: Host, workspace: WorkspaceContext, call_timeout: Duration) -> Self { Self { host, workspace, call_timeout, } } - - /// Adds the workspace context every relayed request names. - fn scoped(&self, mut params: Value) -> Value { - params["context"] = self.workspace.to_value(); - params - } } -impl FunctionRelay for HostRelay<'_> { - fn providers(&self, function: &FunctionContract) -> Result, RelayFailure> { - let params = self.scoped(providers_params(function)); - let result = self.host.request(PROVIDERS_METHOD, params)?; +impl FunctionRelay for HostRelay { + fn providers(&self, function: &FunctionContract) -> Result, RelayFailure> { + let result = self.host.functions().providers(function.id)?; parse_providers(&result) } @@ -166,55 +173,70 @@ impl FunctionRelay for HostRelay<'_> { function: &FunctionContract, params: Value, ) -> Result { - // The deadline cancels only this request, so one slow provider does - // not end the whole function call. - let attempt = Cancellation::new(); - let _deadline = Deadline::start(&attempt, self.call_timeout); - let params = self.scoped(invoke_params(provider, function, params)); - let result = self - .host - .with_cancellation(&attempt) - .request(INVOKE_METHOD, params)?; + let call = invoke_call(provider, function, params, self.call_timeout); + let result = self.host.functions().invoke(&self.workspace, call)?; parse_invoke_result(result) } } -/// Params of [`PROVIDERS_METHOD`], without the workspace context. -fn providers_params(function: &FunctionContract) -> Value { - json!({ "functionID": function.id, "version": function.version }) -} - -/// Params of [`INVOKE_METHOD`], without the workspace context. -fn invoke_params(provider: &str, function: &FunctionContract, params: Value) -> Value { +/// The call `Functions::invoke` relays: only the keys the host method +/// defines. The SDK adds the workspace context and, inside a function call, +/// the parent operation, and sends nothing else, so the host never refuses it +/// with `plugin.host.unsupportedOption`. The contract version is not a key: +/// the host checks it against the provider's export, and +/// [`collect_contributions`] has already skipped providers of another version. +fn invoke_call( + provider: &str, + function: &FunctionContract, + params: Value, + timeout: Duration, +) -> Value { json!({ "pluginID": provider, "functionID": function.id, - "version": function.version, "params": params, + "timeoutMilliseconds": timeout_milliseconds(timeout), }) } -fn parse_providers(result: &Value) -> Result, RelayFailure> { +/// The call timeout as the host takes it: a positive whole number of +/// milliseconds. A shorter wait still gets one millisecond, because the host +/// refuses zero. +fn timeout_milliseconds(timeout: Duration) -> u64 { + u64::try_from(timeout.as_millis()) + .unwrap_or(u64::MAX) + .max(1) +} + +fn parse_providers(result: &Value) -> Result, RelayFailure> { let entries = result .get("providers") .and_then(Value::as_array) .ok_or_else(|| invalid_result(PROVIDERS_METHOD))?; entries .iter() - .map(|entry| { - entry - .get("pluginID") - .and_then(Value::as_str) - .map(str::to_string) - .ok_or_else(|| invalid_result(PROVIDERS_METHOD)) - }) + .map(|entry| parse_provider(entry).ok_or_else(|| invalid_result(PROVIDERS_METHOD))) .collect() } +fn parse_provider(entry: &Value) -> Option { + Some(Provider { + plugin_id: entry + .get("pluginID")? + .as_str() + .filter(|plugin_id| !plugin_id.is_empty())? + .to_string(), + version: entry.get("version")?.as_u64()?, + }) +} + +/// The `value` of an invoke result. The value is the provider's own result +/// and may be `null`, so a missing `value` is a malformed answer while a +/// `null` one is returned as it is. fn parse_invoke_result(result: Value) -> Result { match result { Value::Object(mut fields) => fields - .remove("result") + .remove("value") .ok_or_else(|| invalid_result(INVOKE_METHOD)), _ => Err(invalid_result(INVOKE_METHOD)), } @@ -227,45 +249,15 @@ fn invalid_result(method: &str) -> RelayFailure { ) } -/// Cancels one request after a timeout, unless the request ends first. -/// -/// Dropping the guard cancels the token, which wakes and ends the timer -/// thread; the token is not used again afterwards. -struct Deadline { - attempt: Cancellation, -} - -impl Deadline { - fn start(attempt: &Cancellation, timeout: Duration) -> Self { - let watched = attempt.clone(); - thread::spawn(move || { - // `wait_timeout` answers whether the token was cancelled; only a - // token that is still live after the timeout needs cancelling. - if !watched.wait_timeout(timeout) { - watched.cancel(); - } - }); - Self { - attempt: attempt.clone(), - } - } -} - -impl Drop for Deadline { - fn drop(&mut self) { - self.attempt.cancel(); - } -} - /// Collects the settings every enabled `languageImport.settings` provider /// contributes for `workspace_root`. /// -/// A provider that fails, times out or answers with something unusable is -/// recorded as failed and does not stop the others, so JDT LS can always -/// start. The only error is the calling function's own cancellation. -/// `report` receives the category and count of every failure that was handled -/// this way (one of [`RELAY_CATEGORIES`]), and never a provider ID, a path or -/// a message. +/// A provider that fails, times out, exports another version or answers with +/// something unusable is recorded as failed and does not stop the others, so +/// JDT LS can always start. The only error is the calling function's own +/// cancellation. `report` receives the category and count of every failure +/// that was handled this way (one of [`RELAY_CATEGORIES`]), and never a +/// provider ID, a path or a message. pub fn collect_contributions( relay: &dyn FunctionRelay, workspace_root: &str, @@ -284,23 +276,36 @@ pub fn collect_contributions( } }; providers.sort(); - providers.dedup(); + providers.dedup_by(|later, kept| later.plugin_id == kept.plugin_id); providers.truncate(MAX_PROVIDERS); + let expected_version = u64::from(SETTINGS_FUNCTION.version); for provider in providers { cancellation.check()?; + if provider.version != expected_version { + // The provider is listed, so the user should see that its + // settings are missing, but it is never called: it would answer + // with a shape this plugin does not know. + report(RELAY_PROVIDER_VERSION_MISMATCH, 1); + contributions.record_failure( + &provider.plugin_id, + version_mismatch_reason(provider.version, expected_version), + ); + continue; + } let request = settings_request(workspace_root); - match relay.invoke(&provider, &SETTINGS_FUNCTION, request) { - Ok(answer) => record_answer(&mut contributions, &provider, &answer, report), - Err(failure) if failure.is_cancelled() => { - // The calling function was cancelled, or only this request - // ran out of time. + match relay.invoke(&provider.plugin_id, &SETTINGS_FUNCTION, request) { + Ok(answer) => record_answer(&mut contributions, &provider.plugin_id, &answer, report), + Err(failure) if failure.is_cancelled() || failure.is_timed_out() => { + // Either the calling function was cancelled, which ends the + // collection, or only this request was cut short: the host's + // deadline for it passed, or a cancellation scoped to it. cancellation.check()?; report(RELAY_PROVIDER_TIMED_OUT, 1); - contributions.record_failure(&provider, "The call timed out."); + contributions.record_failure(&provider.plugin_id, "The call timed out."); } Err(failure) => { report(RELAY_PROVIDER_FAILED, 1); - contributions.record_failure(&provider, failure_reason(&failure)); + contributions.record_failure(&provider.plugin_id, failure_reason(&failure)); } } } @@ -341,10 +346,17 @@ fn failure_reason(failure: &RelayFailure) -> String { format!("The call failed ({}).", failure.code) } +/// Status-line reason for a provider that exports another function version. +/// Both versions are numbers, so the sentence names no path. +fn version_mismatch_reason(offered: u64, expected: u64) -> String { + format!("The provider exports version {offered} of the function; this plugin uses version {expected}.") +} + /// Collects the contributions for the workspace a function call runs for. /// -/// This is the call the language-server launch resolver makes before JDT LS -/// starts; its result feeds `JdtSettings::contributed`. +/// Nothing calls it yet. It is kept for the backend launch resolver, which +/// will collect the contributions inside its own function call before JDT LS +/// starts and pass them in `JdtSettings::contributed`. pub fn collect_for_call( context: &Context, workspace_root: &str, @@ -356,7 +368,7 @@ pub fn collect_for_call( ) .into()); }; - let relay = HostRelay::new(context.host(), workspace, PROVIDER_TIMEOUT); + let relay = HostRelay::new(context.host().clone(), workspace.clone(), PROVIDER_TIMEOUT); let diagnostics = context.diagnostics(); let operation = context.function_id(); let report = |code: &str, count: u64| { @@ -380,7 +392,7 @@ mod tests { /// A relay that answers from a script and records the calls it received. struct FakeRelay { - providers: Result, RelayFailure>, + providers: Result, RelayFailure>, answers: BTreeMap>, calls: RefCell>, /// Cancels this token when a provider is called, like a host that @@ -389,9 +401,27 @@ mod tests { } impl FakeRelay { + /// Providers that export the version this plugin speaks. fn new(providers: &[&str]) -> Self { + let version = u64::from(SETTINGS_FUNCTION.version); + Self::exporting( + &providers + .iter() + .map(|id| (*id, version)) + .collect::>(), + ) + } + + /// Providers with the version each one exports. + fn exporting(providers: &[(&str, u64)]) -> Self { Self { - providers: Ok(providers.iter().map(|id| id.to_string()).collect()), + providers: Ok(providers + .iter() + .map(|(id, version)| Provider { + plugin_id: id.to_string(), + version: *version, + }) + .collect()), answers: BTreeMap::new(), calls: RefCell::new(Vec::new()), cancel_on_invoke: None, @@ -413,7 +443,7 @@ mod tests { } impl FunctionRelay for FakeRelay { - fn providers(&self, _function: &FunctionContract) -> Result, RelayFailure> { + fn providers(&self, _function: &FunctionContract) -> Result, RelayFailure> { self.providers.clone() } @@ -442,6 +472,16 @@ mod tests { RelayFailure::new(sdk_codes::CALL_CANCELLED, "Cancelled.") } + /// A provider call the host ended because its deadline passed. + fn timed_out() -> RelayFailure { + RelayFailure::new(HOST_TIMEOUT_CODE, "The provider took too long.") + } + + /// A provider call that failed for a reason other than time. + fn failed(message: &str) -> RelayFailure { + RelayFailure::new("plugin.function.failed", message) + } + fn collect(relay: &FakeRelay) -> Contributions { collect_contributions(relay, "/work/app", &Cancellation::new(), &|_, _| {}) .expect("the call is not cancelled") @@ -471,52 +511,110 @@ mod tests { } #[test] - fn the_request_params_name_the_function_and_its_contract_version() { - assert_eq!( - providers_params(&SETTINGS_FUNCTION), - json!({ "functionID": "languageImport.settings", "version": 1 }) + fn the_invoke_call_carries_only_the_keys_the_relay_defines() { + let call = invoke_call( + "io.github.lithe-idea.maven-build", + &SETTINGS_FUNCTION, + json!({ "workspaceRoot": "/work/app" }), + PROVIDER_TIMEOUT, ); assert_eq!( - invoke_params( - "io.github.lithe-idea.maven-build", - &SETTINGS_FUNCTION, - json!({ "workspaceRoot": "/work/app" }) - ), + call, json!({ "pluginID": "io.github.lithe-idea.maven-build", "functionID": "languageImport.settings", - "version": 1, "params": { "workspaceRoot": "/work/app" }, + "timeoutMilliseconds": 10_000, }) ); + // The version and the context are not keys of the call: the host + // refuses a key it does not define, and the SDK adds the context. + let allowed = [ + "pluginID", + "functionID", + "params", + "timeoutMilliseconds", + "parentOperationID", + ]; + for key in call.as_object().expect("the call is an object").keys() { + assert!(allowed.contains(&key.as_str()), "{key}"); + } + } + + #[test] + fn the_call_timeout_is_a_positive_whole_number_of_milliseconds() { + assert_eq!(timeout_milliseconds(PROVIDER_TIMEOUT), 10_000); + assert_eq!(timeout_milliseconds(Duration::from_millis(1500)), 1500); + // Sub-millisecond and zero waits still ask for one millisecond. + assert_eq!(timeout_milliseconds(Duration::from_nanos(1)), 1); + assert_eq!(timeout_milliseconds(Duration::ZERO), 1); + assert_eq!(timeout_milliseconds(Duration::MAX), u64::MAX); } #[test] fn provider_lists_and_invoke_results_must_have_the_expected_shape() { assert_eq!( - parse_providers( - &json!({ "providers": [{ "pluginID": "a.b" }, { "pluginID": "c.d" }] }) - ), - Ok(vec!["a.b".to_string(), "c.d".to_string()]) + parse_providers(&json!({ "providers": [ + { "pluginID": "a.b", "version": 1 }, + { "pluginID": "c.d", "version": 2 }, + ] })), + Ok(vec![ + Provider { + plugin_id: "a.b".to_string(), + version: 1 + }, + Provider { + plugin_id: "c.d".to_string(), + version: 2 + }, + ]) ); + assert_eq!(parse_providers(&json!({ "providers": [] })), Ok(vec![])); for malformed in [ json!({}), json!({ "providers": "a.b" }), - json!({ "providers": [{ "id": "a.b" }] }), - json!({ "providers": [{ "pluginID": 7 }] }), + json!({ "providers": [{ "id": "a.b", "version": 1 }] }), + json!({ "providers": [{ "pluginID": 7, "version": 1 }] }), + json!({ "providers": [{ "pluginID": "", "version": 1 }] }), + json!({ "providers": [{ "pluginID": "a.b" }] }), + json!({ "providers": [{ "pluginID": "a.b", "version": "1" }] }), + json!({ "providers": [{ "pluginID": "a.b", "version": -1 }] }), ] { let failure = parse_providers(&malformed).expect_err("malformed list"); - assert_eq!(failure.code, "java.relay.invalidResult"); + assert_eq!(failure.code, "java.relay.invalidResult", "{malformed}"); } assert_eq!( - parse_invoke_result(json!({ "result": { "settings": {} } })), + parse_invoke_result(json!({ "value": { "settings": {} } })), Ok(json!({ "settings": {} })) ); + // The key of the result is `value`; the draft protocol called it `result`. + assert!(parse_invoke_result(json!({ "result": { "settings": {} } })).is_err()); assert!(parse_invoke_result(json!({})).is_err()); assert!(parse_invoke_result(json!("done")).is_err()); } + #[test] + fn a_null_value_is_a_result_and_a_missing_value_is_not() { + assert_eq!( + parse_invoke_result(json!({ "value": null })), + Ok(Value::Null) + ); + let failure = parse_invoke_result(json!({ "other": null })).expect_err("no value"); + assert_eq!(failure.code, "java.relay.invalidResult"); + } + + #[test] + fn a_provider_that_answers_null_is_recorded_as_failed() { + let relay = FakeRelay::new(&["io.github.lithe-idea.maven-build"]) + .answering("io.github.lithe-idea.maven-build", Ok(Value::Null)); + let (contributions, reports) = collect_reporting(&relay); + assert_eq!(contributions.status(), ContributedSettingsStatus::Failed); + assert_eq!(contributions.failures().len(), 1); + assert!(contributions.fragments().is_empty()); + assert_eq!(reports, [(RELAY_SETTINGS_REJECTED.to_string(), 1)]); + } + #[test] fn providers_are_called_once_each_in_plugin_id_order() { let relay = FakeRelay::new(&[ @@ -539,6 +637,54 @@ mod tests { } } + #[test] + fn a_provider_of_another_version_is_not_called_and_is_reported() { + let relay = FakeRelay::exporting(&[ + ("io.github.lithe-idea.gradle-build", 2), + ("io.github.lithe-idea.maven-build", 1), + ("io.github.lithe-idea.sbt-build", 0), + ]) + .answering("io.github.lithe-idea.gradle-build", settings(json!({}))) + .answering( + "io.github.lithe-idea.maven-build", + settings(json!({ "java.import.maven.enabled": true })), + ) + .answering("io.github.lithe-idea.sbt-build", settings(json!({}))); + let (contributions, reports) = collect_reporting(&relay); + + // Only the provider of the contract version is called. + assert_eq!( + relay.called_providers(), + ["io.github.lithe-idea.maven-build"] + ); + assert_eq!( + contributions.fragments(), + [json!({ "java.import.maven.enabled": true })] + ); + // The others are failed providers, so the status line names them. + assert_eq!(contributions.status(), ContributedSettingsStatus::Failed); + assert_eq!( + contributions.failures(), + [ + ( + "io.github.lithe-idea.gradle-build", + "The provider exports version 2 of the function; this plugin uses version 1." + ), + ( + "io.github.lithe-idea.sbt-build", + "The provider exports version 0 of the function; this plugin uses version 1." + ), + ] + ); + assert_eq!( + reports, + [ + (RELAY_PROVIDER_VERSION_MISMATCH.to_string(), 1), + (RELAY_PROVIDER_VERSION_MISMATCH.to_string(), 1), + ] + ); + } + #[test] fn answers_are_merged_in_plugin_id_order() { let relay = FakeRelay::new(&[ @@ -570,6 +716,7 @@ mod tests { let contributions = collect(&relay); assert_eq!(contributions.status(), ContributedSettingsStatus::Settled); assert!(contributions.fragments().is_empty()); + assert!(!contributions.has_providers()); assert!(relay.called_providers().is_empty()); } @@ -581,10 +728,7 @@ mod tests { ]) .answering( "io.github.lithe-idea.gradle-build", - Err(RelayFailure::new( - "plugin.host.timeout", - "/home/dev/.gradle is slow", - )), + Err(failed("/home/dev/.gradle is broken")), ) .answering( "io.github.lithe-idea.maven-build", @@ -601,7 +745,7 @@ mod tests { contributions.failures(), [( "io.github.lithe-idea.gradle-build", - "The call failed (plugin.host.timeout)." + "The call failed (plugin.function.failed)." )] ); } @@ -665,23 +809,35 @@ mod tests { let relay = FakeRelay::new(&[ "io.github.lithe-idea.gradle-build", "io.github.lithe-idea.maven-build", + "io.github.lithe-idea.sbt-build", ]) .answering( "io.github.lithe-idea.gradle-build", - Err(RelayFailure::new( - "plugin.host.timeout", - "/home/dev/.gradle is slow", - )), + Err(failed("/home/dev/.gradle is broken")), ) // The request was cancelled but the calling function was not. - .answering("io.github.lithe-idea.maven-build", Err(cancelled())); - let (_, reports) = collect_reporting(&relay); - // Providers are called in plugin ID order: gradle first, then maven. + .answering("io.github.lithe-idea.maven-build", Err(cancelled())) + // The host ended the request when its deadline passed. + .answering("io.github.lithe-idea.sbt-build", Err(timed_out())); + let (contributions, reports) = collect_reporting(&relay); + // Providers are called in plugin ID order: gradle, maven, then sbt. assert_eq!( reports, [ (RELAY_PROVIDER_FAILED.to_string(), 1), (RELAY_PROVIDER_TIMED_OUT.to_string(), 1), + (RELAY_PROVIDER_TIMED_OUT.to_string(), 1), + ] + ); + assert_eq!( + contributions.failures(), + [ + ( + "io.github.lithe-idea.gradle-build", + "The call failed (plugin.function.failed)." + ), + ("io.github.lithe-idea.maven-build", "The call timed out."), + ("io.github.lithe-idea.sbt-build", "The call timed out."), ] ); } @@ -709,18 +865,16 @@ mod tests { #[test] fn reports_never_name_a_provider_a_path_or_a_key() { - let relay = FakeRelay::new(&[ - "io.github.lithe-idea.gradle-build", - "io.github.lithe-idea.maven-build", - "io.github.lithe-idea.sbt-build", - "io.github.lithe-idea.zeta-build", + let relay = FakeRelay::exporting(&[ + ("io.github.lithe-idea.gradle-build", 1), + ("io.github.lithe-idea.maven-build", 1), + ("io.github.lithe-idea.sbt-build", 1), + ("io.github.lithe-idea.zeta-build", 1), + ("io.github.lithe-idea.zulu-build", 3), ]) .answering( "io.github.lithe-idea.gradle-build", - Err(RelayFailure::new( - "plugin.host.timeout", - "/home/dev/.gradle is slow", - )), + Err(failed("/home/dev/.gradle is broken")), ) .answering("io.github.lithe-idea.maven-build", Err(cancelled())) .answering( @@ -743,6 +897,7 @@ mod tests { (RELAY_PROVIDER_TIMED_OUT.to_string(), 1), (RELAY_SETTINGS_REJECTED.to_string(), 1), (RELAY_KEYS_REJECTED.to_string(), 2), + (RELAY_PROVIDER_VERSION_MISMATCH.to_string(), 1), ] ); let forbidden = [ @@ -752,11 +907,12 @@ mod tests { "maven", "sbt", "zeta", + "zulu", "/home/dev", "/opt/other-jdk", "java.home", "unrelated", - "plugin.host.timeout", + "plugin.function.failed", ]; for (code, _) in &reports { assert!(RELAY_CATEGORIES.contains(&code.as_str()), "{code}"); @@ -787,9 +943,11 @@ mod tests { RELAY_PROVIDER_TIMED_OUT, RELAY_SETTINGS_REJECTED, RELAY_KEYS_REJECTED, + RELAY_PROVIDER_VERSION_MISMATCH, ] { assert!(seen.contains(category), "{category} is not listed"); } + assert_eq!(seen.len(), 6); } #[test] @@ -802,6 +960,7 @@ mod tests { contributions.discovery_failure(), Some("The call failed (plugin.host.unavailable).") ); + assert!(!contributions.has_providers()); assert!(relay.called_providers().is_empty()); } @@ -860,15 +1019,31 @@ mod tests { } #[test] - fn a_deadline_cancels_its_request_and_dropping_it_stops_the_timer() { - let expired = Cancellation::new(); - let _deadline = Deadline::start(&expired, Duration::ZERO); - // The wait ends as soon as the timer thread cancels; the bound only - // matters when the deadline is broken. - assert!(expired.wait_timeout(Duration::from_secs(30))); - - let finished = Cancellation::new(); - drop(Deadline::start(&finished, Duration::from_secs(3600))); - assert!(finished.is_cancelled()); + fn a_deadline_that_passed_in_the_host_does_not_end_the_collection() { + // `plugin.host.timeout` is the host's answer to one slow provider; the + // calling function is still live, so the next provider is asked. + let relay = FakeRelay::new(&[ + "io.github.lithe-idea.gradle-build", + "io.github.lithe-idea.maven-build", + ]) + .answering("io.github.lithe-idea.gradle-build", Err(timed_out())) + .answering( + "io.github.lithe-idea.maven-build", + settings(json!({ "java.import.maven.enabled": true })), + ); + let outcome = collect_contributions(&relay, "/work/app", &Cancellation::new(), &|_, _| {}); + let contributions = outcome.expect("only the function's own cancellation ends it"); + assert_eq!(relay.called_providers().len(), 2); + assert_eq!(contributions.fragments().len(), 1); + } + + #[test] + fn cancellation_and_a_passed_deadline_are_told_apart_by_code() { + assert!(cancelled().is_cancelled()); + assert!(!cancelled().is_timed_out()); + assert!(timed_out().is_timed_out()); + assert!(!timed_out().is_cancelled()); + assert!(!failed("broken").is_cancelled()); + assert!(!failed("broken").is_timed_out()); } } diff --git a/backend/src/settings_session.rs b/backend/src/settings_session.rs new file mode 100644 index 0000000..01a5408 --- /dev/null +++ b/backend/src/settings_session.rs @@ -0,0 +1,1601 @@ +//! The settings other plugins contribute to JDT LS, kept per workspace and +//! delivered to the running language server (API-35 on top of API-25). +//! +//! Two things feed one [`Contributions`] per workspace: +//! +//! - When the host reports that the `java` server is `running`, the backend +//! asks every enabled provider of `languageImport.settings` for its +//! settings ([`collect_contributions`]). +//! - When a build-system plugin changes its settings, it calls the function +//! this plugin exports, `languageImport.settingsChanged` +//! ([`SettingsSession::settings_changed`]). The call carries the complete +//! new settings, and the host writes the caller's plugin ID, so the call +//! replaces exactly the caller's slot. This plugin never calls the caller +//! back: the host judges that a call cycle. +//! +//! Either way the merged result goes to JDT LS as one +//! `workspace/didChangeConfiguration`. That notification replaces the whole +//! plugin configuration of the session, so the backend always sends the +//! complete merged settings and never a delta. +//! +//! # No provider, no push +//! +//! The backend pushes only when at least one provider took part, whether its +//! answer was usable or not. While the main application still runs its own +//! Java integration, that integration pushes JDT LS settings itself, and the +//! plugin's whole-configuration notification would overwrite them. A +//! workspace without Maven or Gradle plugins must therefore see no change in +//! behavior, and the push becomes unconditional only when the integration +//! moves into this plugin. +//! +//! # Ordering and locks +//! +//! The SDK runs every notification handler on a thread of its own, so the +//! state notifications of one server can be handled out of order. Each event +//! is ordered by `(generation, phase)`, where the phase is `starting`, +//! `running` or `ended` (`stopped` and `crashed`). An event that is not +//! greater than the last applied one is stale or a duplicate and is ignored. +//! +//! The state lock is never held across a host call. Pushes are serialized by +//! a separate `gate` lock, and each push takes its snapshot of the state +//! after it entered the gate, so a push that started earlier can never arrive +//! at the server after a push with newer settings. The lock order is always +//! `gate`, then the state lock. + +use crate::plugin_functions::{ + collect_contributions, FunctionRelay, HostRelay, RelayFailure, PROVIDER_TIMEOUT, + RELAY_KEYS_REJECTED, +}; +use lithe_java_domain::lsp::languages::jdt::{settings_notification, JdtSettings}; +use lithe_java_domain::lsp::languages::jdt_contributions::{ + accept_answer, AcceptedSettings, Contributions, SETTINGS_CHANGED_FUNCTION, +}; +use lithe_plugin_sdk::{BackendHandle, Cancellation, PluginError, Value, WorkspaceContext}; +use serde_json::{json, Map}; +use std::collections::HashMap; +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; + +/// Host notification that reports the state of a plugin-declared language +/// server: `{context, serverID, state, generation}`. +pub const LANGUAGE_SERVER_STATE_NOTIFICATION: &str = "lithe/host/languageServer/didChangeState"; + +/// The exported function a build-system plugin calls when its settings change. +/// The ID comes from the interface definition so it is written down once. +pub const SETTINGS_CHANGED_FUNCTION_ID: &str = SETTINGS_CHANGED_FUNCTION.id; + +/// `languageID` of the language server this plugin declares. +pub const SERVER_ID: &str = "java"; + +/// The JDT LS command that applies per-project settings. +pub const UPDATE_PROJECT_SETTINGS_COMMAND: &str = "java.project.updateSettings"; + +/// The project setting, in the `java.project.updateSettings` command, that +/// holds the selected Maven profiles as one comma-separated string. +pub const SELECTED_PROFILES_KEY: &str = "org.eclipse.m2e.core.selectedProfiles"; + +/// Plugin error code of a `settingsChanged` call that no plugin made through +/// the host's relay, so there is no caller whose slot it could replace. +pub const RELAY_CALLER_MISSING: &str = "relay.caller_missing"; + +/// Plugin error code of a `settingsChanged` call without a workspace +/// context, so there is no workspace whose settings it could change. +pub const RELAY_CONTEXT_MISSING: &str = "relay.context_missing"; + +/// Plugin error code of a `settingsChanged` call whose params do not have the +/// documented shape. +pub const RELAY_INVALID_PARAMS: &str = "relay.invalid_params"; + +/// Every plugin error code `settingsChanged` answers with. +pub const CHANGE_ERROR_CODES: &[&str] = &[ + RELAY_CALLER_MISSING, + RELAY_CONTEXT_MISSING, + RELAY_INVALID_PARAMS, +]; + +/// Category reported when the host could not tell where the workspace is, so +/// the providers were not asked. +pub const SESSION_ROOT_UNAVAILABLE: &str = "session.root_unavailable"; + +/// Category reported when the merged settings could not be sent to JDT LS. +pub const SESSION_PUSH_FAILED: &str = "session.push_failed"; + +/// Category reported when updating one project in JDT LS failed. +pub const SESSION_PROJECT_UPDATE_FAILED: &str = "session.project_update_failed"; + +/// Category reported when a server state notification was malformed. +pub const SESSION_STATE_INVALID: &str = "session.state_invalid"; + +/// Every category this module reports. Each one is a controlled code that +/// names no plugin, path or message. +pub const SESSION_CATEGORIES: &[&str] = &[ + SESSION_ROOT_UNAVAILABLE, + SESSION_PUSH_FAILED, + SESSION_PROJECT_UPDATE_FAILED, + SESSION_STATE_INVALID, +]; + +/// Host method that sends a notification to a plugin-declared server. +const NOTIFY_METHOD: &str = "lithe/host/languageServer/notify"; + +/// Host method that sends a request to a plugin-declared server. +const REQUEST_METHOD: &str = "lithe/host/languageServer/request"; + +/// LSP notification that replaces the plugin configuration of the session. +const DID_CHANGE_CONFIGURATION: &str = "workspace/didChangeConfiguration"; + +/// LSP request that runs a server command. +const EXECUTE_COMMAND: &str = "workspace/executeCommand"; + +/// What the session needs from the host, behind a trait so tests can script +/// it. Every failure is the host's answer to one request. +pub trait SessionHost: Send + Sync { + /// The local path of the workspace's first folder, or `None` when the + /// workspace has no local path (a remote workspace). + fn workspace_root(&self, workspace: &WorkspaceContext) -> Result, RelayFailure>; + + /// Sends `workspace/didChangeConfiguration` with `params` to the server. + fn push_configuration( + &self, + workspace: &WorkspaceContext, + params: Value, + ) -> Result<(), RelayFailure>; + + /// Sends `workspace/executeCommand` with `params` to the server and waits + /// for its answer. + fn execute_command( + &self, + workspace: &WorkspaceContext, + params: Value, + ) -> Result<(), RelayFailure>; + + /// The cross-plugin relay for `workspace`, used outside any function call. + fn relay(&self, workspace: &WorkspaceContext) -> Box; +} + +/// The real host, reached through the backend handle so that the work of one +/// notification does not belong to any function call. +pub struct HostServices { + handle: BackendHandle, +} + +impl HostServices { + pub fn new(handle: BackendHandle) -> Self { + Self { handle } + } +} + +impl SessionHost for HostServices { + fn workspace_root(&self, workspace: &WorkspaceContext) -> Result, RelayFailure> { + let context = self.handle.host().workspace().get_context(workspace)?; + Ok(first_folder_path(&context)) + } + + fn push_configuration( + &self, + workspace: &WorkspaceContext, + params: Value, + ) -> Result<(), RelayFailure> { + self.handle.host().request( + NOTIFY_METHOD, + json!({ + "context": workspace.to_value(), + "serverID": SERVER_ID, + "method": DID_CHANGE_CONFIGURATION, + "params": params, + }), + )?; + Ok(()) + } + + fn execute_command( + &self, + workspace: &WorkspaceContext, + params: Value, + ) -> Result<(), RelayFailure> { + self.handle.host().request( + REQUEST_METHOD, + json!({ + "context": workspace.to_value(), + "serverID": SERVER_ID, + "method": EXECUTE_COMMAND, + "params": params, + }), + )?; + Ok(()) + } + + fn relay(&self, workspace: &WorkspaceContext) -> Box { + Box::new(HostRelay::new( + self.handle.host(), + workspace.clone(), + PROVIDER_TIMEOUT, + )) + } +} + +/// The local path of the first folder in a `getContext` answer. A remote +/// workspace's folders carry no `path`. +fn first_folder_path(context: &Value) -> Option { + context + .get("folders")? + .as_array()? + .first()? + .get("path")? + .as_str() + .filter(|path| !path.is_empty()) + .map(str::to_string) +} + +/// Where a server process stands. The derived order is the order in which a +/// process moves through the phases, which is what makes events comparable. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum ServerPhase { + Starting, + Running, + /// Stopped or crashed. + Ended, +} + +/// The Maven profiles selected for one project, from a `settingsChanged` call. +#[derive(Debug, Clone, PartialEq, Eq)] +struct ProjectProfiles { + project_uri: String, + selected_profiles: Vec, +} + +impl ProjectProfiles { + /// The params of the `workspace/executeCommand` that applies the profiles. + /// JDT LS takes them as one comma-separated string, which is empty when + /// no profile is selected. + fn command_params(&self) -> Value { + let mut settings = Map::new(); + settings.insert( + SELECTED_PROFILES_KEY.to_string(), + Value::String(self.selected_profiles.join(",")), + ); + json!({ + "command": UPDATE_PROJECT_SETTINGS_COMMAND, + "arguments": [self.project_uri, Value::Object(settings)], + }) + } +} + +/// Changes a provider made while its workspace's collection was in flight. +type LateChanges = Vec<(String, AcceptedSettings)>; + +/// Everything the session knows about one workspace. +struct WorkspaceState { + /// The newest context reference seen for the workspace. Requests with an + /// older generation fail with `plugin.host.contextExpired`. + context: WorkspaceContext, + /// The last applied server event, which orders the next one. + seen: Option<(u64, ServerPhase)>, + contributions: Contributions, + /// Project updates waiting for a running server. + pending_projects: Vec, + /// Set from the moment the server is running until its collection is + /// finished. It holds the changes that arrive meanwhile: the collection + /// asked the providers before such a change, so its answer would + /// otherwise overwrite the change. + collecting: Option, +} + +impl WorkspaceState { + fn new(context: WorkspaceContext) -> Self { + Self { + context, + seen: None, + contributions: Contributions::default(), + pending_projects: Vec::new(), + collecting: None, + } + } + + fn is_running(&self) -> bool { + matches!(self.seen, Some((_, ServerPhase::Running))) + } + + /// Remembers `context` when it is not older than the one held. + fn remember_context(&mut self, context: &WorkspaceContext) { + if context.generation() >= self.context.generation() { + self.context = context.clone(); + } + } + + /// Applies a server event unless it is stale or repeated. Returns whether + /// it was applied. + fn advance(&mut self, context: &WorkspaceContext, generation: u64, phase: ServerPhase) -> bool { + let event = (generation, phase); + if self.seen.is_some_and(|seen| event <= seen) { + return false; + } + self.seen = Some(event); + self.remember_context(context); + self.collecting = (phase == ServerPhase::Running).then(Vec::new); + true + } +} + +/// Queues a project update, replacing an earlier one for the same project. +fn stash_project(pending: &mut Vec, project: ProjectProfiles) { + match pending + .iter_mut() + .find(|entry| entry.project_uri == project.project_uri) + { + Some(entry) => *entry = project, + None => pending.push(project), + } +} + +/// A server state notification after validation. +enum StateEvent { + /// About another plugin's server, or a state this version does not know. + Ignored, + /// Not shaped like the documented notification. + Invalid, + Change { + workspace: WorkspaceContext, + generation: u64, + phase: ServerPhase, + }, +} + +fn parse_state_event(params: &Value) -> StateEvent { + match params.get("serverID").and_then(Value::as_str) { + Some(SERVER_ID) => {} + Some(_) => return StateEvent::Ignored, + None => return StateEvent::Invalid, + } + let Ok(workspace) = WorkspaceContext::from_value(¶ms["context"]) else { + return StateEvent::Invalid; + }; + let Some(generation) = params + .get("generation") + .and_then(Value::as_u64) + .filter(|generation| *generation > 0) + else { + return StateEvent::Invalid; + }; + let phase = match params.get("state").and_then(Value::as_str) { + Some("starting") => ServerPhase::Starting, + Some("running") => ServerPhase::Running, + Some("stopped" | "crashed") => ServerPhase::Ended, + Some(_) => return StateEvent::Ignored, + None => return StateEvent::Invalid, + }; + StateEvent::Change { + workspace, + generation, + phase, + } +} + +/// A `settingsChanged` call after validation. +struct SettingsChange { + accepted: AcceptedSettings, + projects: Vec, +} + +/// Validates the params of `languageImport.settingsChanged`: +/// `{settings: object, projects?: [{projectUri, selectedProfiles: [string]}]}`. +/// +/// Fields the contract does not define are refused instead of ignored: a +/// change that carries more than this version understands would otherwise be +/// applied only in part, and a new field means a new contract version. The +/// error is a sentence without any value from the call. +fn parse_change_params(params: &Value) -> Result { + let fields = params + .as_object() + .ok_or_else(|| "The params are not an object.".to_string())?; + if fields + .keys() + .any(|key| key != "settings" && key != "projects") + { + return Err("The params have a field this version does not define.".to_string()); + } + let accepted = accept_answer(params)?; + let projects = match fields.get("projects") { + None => Vec::new(), + Some(projects) => parse_projects(projects)?, + }; + Ok(SettingsChange { accepted, projects }) +} + +/// Parses `projects`, keeping the first entry of every project URI. +fn parse_projects(projects: &Value) -> Result, String> { + let entries = projects + .as_array() + .ok_or_else(|| "`projects` is not an array.".to_string())?; + let mut parsed: Vec = Vec::new(); + for entry in entries { + let project = parse_project(entry)?; + if !parsed + .iter() + .any(|kept| kept.project_uri == project.project_uri) + { + parsed.push(project); + } + } + Ok(parsed) +} + +fn parse_project(entry: &Value) -> Result { + let fields = entry + .as_object() + .ok_or_else(|| "A project entry is not an object.".to_string())?; + if fields + .keys() + .any(|key| key != "projectUri" && key != "selectedProfiles") + { + return Err("A project entry has a field this version does not define.".to_string()); + } + let project_uri = fields + .get("projectUri") + .and_then(Value::as_str) + .filter(|uri| !uri.is_empty()) + .ok_or_else(|| "A project entry has no `projectUri` text.".to_string())?; + let profiles = fields + .get("selectedProfiles") + .and_then(Value::as_array) + .ok_or_else(|| "A project entry has no `selectedProfiles` array.".to_string())?; + // A comma separates the profiles in the command, so a profile that + // contains one would silently turn into two. + let selected_profiles = profiles + .iter() + .map(|profile| { + profile + .as_str() + .filter(|profile| !profile.is_empty() && !profile.contains(',')) + .map(str::to_string) + .ok_or_else(|| { + "A selected profile is not text, is empty or contains a comma.".to_string() + }) + }) + .collect::, _>>()?; + Ok(ProjectProfiles { + project_uri: project_uri.to_string(), + selected_profiles, + }) +} + +/// The params of the `workspace/didChangeConfiguration` for `fragments`. +fn configuration_params(fragments: &[Value]) -> Value { + settings_notification(JdtSettings { + // The host overlays its own indexing policy on the plugin + // configuration it forwards, so the plugin must not compute one. + indexing_root_uri: None, + // Plugin-declared completion options and import exclusions belong to + // the session's initialization, which this module does not see; the + // notification carries their defaults. + completion: None, + import_exclusions: None, + contributed: fragments, + // TODO(host-api: API-23 toolchains.list): the backend has no host + // method that lists the JDKs installed on this machine. When API-23 + // provides one, build the candidates from its answer, pass them + // through `jdt_java_runtimes`, and fill this list. JDT LS does not + // unregister the VMs it already knows because the key is absent, so + // the runtimes stay as the host configured them until then. + java_runtimes: &[], + }) + .params +} + +/// Locks `mutex` even when a panic poisoned it: the guarded data stays +/// consistent because every critical section here only swaps whole values. +fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { + mutex.lock().unwrap_or_else(PoisonError::into_inner) +} + +/// The contributions and project updates of every workspace, and their +/// delivery to JDT LS. +pub struct SettingsSession { + host: Arc, + /// Keyed by `contextID`. + states: Mutex>, + /// Held while a push and its project updates are delivered; see the + /// module documentation. + gate: Mutex<()>, +} + +impl SettingsSession { + pub fn new(host: Arc) -> Self { + Self { + host, + states: Mutex::new(HashMap::new()), + gate: Mutex::new(()), + } + } + + fn lock_states(&self) -> MutexGuard<'_, HashMap> { + lock(&self.states) + } + + /// Handles `lithe/host/languageServer/didChangeState`. + /// + /// When the `java` server is running, this collects the providers' + /// settings and, if any provider took part, pushes the merged result. + /// The call blocks on the host, which is allowed on a notification + /// thread. `report` receives the categories in [`SESSION_CATEGORIES`] and + /// [`crate::plugin_functions::RELAY_CATEGORIES`]. + pub fn did_change_state(&self, params: &Value, report: &dyn Fn(&str, u64)) { + let (workspace, generation, phase) = match parse_state_event(params) { + StateEvent::Ignored => return, + StateEvent::Invalid => { + report(SESSION_STATE_INVALID, 1); + return; + } + StateEvent::Change { + workspace, + generation, + phase, + } => (workspace, generation, phase), + }; + let key = workspace.context_id().to_string(); + { + let mut states = self.lock_states(); + let state = states + .entry(key.clone()) + .or_insert_with(|| WorkspaceState::new(workspace.clone())); + if !state.advance(&workspace, generation, phase) { + return; + } + } + // A stopped or crashed server keeps its contributions: the next + // `running` collects again and replaces them. + if phase != ServerPhase::Running { + return; + } + let collected = self.collect(&workspace, report); + if self.finish_collection(&key, generation, collected) { + self.flush(&key, report); + } + } + + /// Asks the providers for their settings. `None` when there is nothing to + /// collect: a remote workspace, or a workspace whose location the host + /// would not tell. + fn collect( + &self, + workspace: &WorkspaceContext, + report: &dyn Fn(&str, u64), + ) -> Option { + let root = match self.host.workspace_root(workspace) { + Ok(Some(root)) => root, + // A remote workspace has no local folder, and the providers can + // only describe local paths, so they are not asked. + Ok(None) => return None, + Err(_) => { + report(SESSION_ROOT_UNAVAILABLE, 1); + return None; + } + }; + let relay = self.host.relay(workspace); + // The collection belongs to no function call, so nothing cancels it; + // a provider that does not answer ends at the relay's own timeout. + collect_contributions(&*relay, &root, &Cancellation::new(), report).ok() + } + + /// Stores the collected contributions, unless the server moved on while + /// they were collected. Returns whether the collection is still current, + /// which is when the caller should push. + fn finish_collection( + &self, + key: &str, + generation: u64, + collected: Option, + ) -> bool { + let mut states = self.lock_states(); + let Some(state) = states.get_mut(key) else { + return false; + }; + // Only the event that started this collection may finish it. A later + // event made the answers stale, and its own collection (if any) owns + // the `collecting` list now. + if state.seen != Some((generation, ServerPhase::Running)) { + return false; + } + let late = state.collecting.take().unwrap_or_default(); + if let Some(mut contributions) = collected { + for (provider, accepted) in late { + contributions.record_settings(&provider, accepted); + } + state.contributions = contributions; + } + true + } + + /// Handles a call of `languageImport.settingsChanged`. + /// + /// `caller` and `workspace` come from the host, not from `params`. + /// The call replaces the caller's slot, pushes the merged settings when + /// the server is running, and then applies the project updates. While the + /// server is not running the settings stay recorded and the project + /// updates wait for the next push. Delivery failures are reported, not + /// returned: the caller cannot act on them, and the next change or + /// restart sends the state again. The answer is `{}`. + pub fn settings_changed( + &self, + caller: Option<&str>, + workspace: Option<&WorkspaceContext>, + params: &Value, + report: &dyn Fn(&str, u64), + ) -> Result { + let caller = caller.ok_or_else(|| { + PluginError::new( + RELAY_CALLER_MISSING, + "The call did not come from another plugin.", + ) + })?; + let workspace = workspace.ok_or_else(|| { + PluginError::new( + RELAY_CONTEXT_MISSING, + "The call carries no workspace context.", + ) + })?; + let change = parse_change_params(params) + .map_err(|reason| PluginError::new(RELAY_INVALID_PARAMS, reason))?; + let rejected = u64::try_from(change.accepted.rejected.len()).unwrap_or(u64::MAX); + if rejected > 0 { + report(RELAY_KEYS_REJECTED, rejected); + } + let key = workspace.context_id().to_string(); + { + let mut states = self.lock_states(); + let state = states + .entry(key.clone()) + .or_insert_with(|| WorkspaceState::new(workspace.clone())); + state.remember_context(workspace); + state + .contributions + .record_settings(caller, change.accepted.clone()); + if let Some(late) = state.collecting.as_mut() { + late.push((caller.to_string(), change.accepted)); + } + for project in change.projects { + stash_project(&mut state.pending_projects, project); + } + } + self.flush(&key, report); + Ok(json!({})) + } + + /// Sends the merged settings and the pending project updates, if the + /// workspace is ready for them. + /// + /// Nothing is sent while the server is not running, while the collection + /// is still in flight (a push then would carry a partial result), or while + /// no provider has taken part. Whatever is not sent stays recorded for a + /// later flush. + fn flush(&self, key: &str, report: &dyn Fn(&str, u64)) { + let _delivery = lock(&self.gate); + let (workspace, params, projects) = { + let mut states = self.lock_states(); + let Some(state) = states.get_mut(key) else { + return; + }; + if !state.is_running() + || state.collecting.is_some() + || !state.contributions.has_providers() + { + return; + } + ( + state.context.clone(), + configuration_params(&state.contributions.fragments()), + std::mem::take(&mut state.pending_projects), + ) + }; + if self.host.push_configuration(&workspace, params).is_err() { + report(SESSION_PUSH_FAILED, 1); + // The server did not get the settings the updates build on, so + // the updates wait for the next successful push. + self.requeue_projects(key, projects); + return; + } + for project in &projects { + if self + .host + .execute_command(&workspace, project.command_params()) + .is_err() + { + report(SESSION_PROJECT_UPDATE_FAILED, 1); + } + } + } + + /// Puts project updates back in front of the ones that arrived while they + /// were being sent; a newer update of the same project wins. + fn requeue_projects(&self, key: &str, mut projects: Vec) { + if projects.is_empty() { + return; + } + let mut states = self.lock_states(); + if let Some(state) = states.get_mut(key) { + for newer in std::mem::take(&mut state.pending_projects) { + stash_project(&mut projects, newer); + } + state.pending_projects = projects; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::plugin_functions::{Provider, RELAY_CATEGORIES}; + use lithe_java_domain::lsp::languages::jdt_contributions::FunctionContract; + use std::cell::RefCell; + use std::collections::{BTreeMap, BTreeSet}; + use std::sync::atomic::{AtomicUsize, Ordering}; + + const MAVEN: &str = "io.github.lithe-idea.maven-build"; + const GRADLE: &str = "io.github.lithe-idea.gradle-build"; + + /// What the fake host received, in order. + #[derive(Debug, Clone, PartialEq)] + enum Sent { + Configuration(WorkspaceContext, Value), + Command(WorkspaceContext, Value), + } + + /// The script a test gives the fake host, and the log it reads back. + struct Script { + root: Mutex, RelayFailure>>, + root_calls: AtomicUsize, + providers: Mutex>, + provider_calls: AtomicUsize, + answers: Mutex>>, + /// Runs once, in the first provider call, after that call's answer + /// was read. It lets a test change the world mid-collection. + on_invoke: Mutex>>, + push_failure: Mutex>, + failing_project: Mutex>, + sent: Mutex>, + } + + impl Script { + fn new() -> Arc { + Arc::new(Self { + root: Mutex::new(Ok(Some("/work/app".to_string()))), + root_calls: AtomicUsize::new(0), + providers: Mutex::new(Vec::new()), + provider_calls: AtomicUsize::new(0), + answers: Mutex::new(BTreeMap::new()), + on_invoke: Mutex::new(None), + push_failure: Mutex::new(None), + failing_project: Mutex::new(None), + sent: Mutex::new(Vec::new()), + }) + } + + /// A provider of the interface version this plugin speaks that + /// answers with `settings`. + fn provide(&self, plugin: &str, settings: Value) { + let mut providers = lock(&self.providers); + if !providers.iter().any(|known| known.plugin_id == plugin) { + providers.push(Provider { + plugin_id: plugin.to_string(), + version: 1, + }); + } + lock(&self.answers).insert(plugin.to_string(), Ok(json!({ "settings": settings }))); + } + + fn on_invoke(&self, hook: impl FnOnce() + Send + 'static) { + *lock(&self.on_invoke) = Some(Box::new(hook)); + } + + fn sent(&self) -> Vec { + lock(&self.sent).clone() + } + + /// The params of every configuration push. + fn configurations(&self) -> Vec { + self.sent() + .into_iter() + .filter_map(|sent| match sent { + Sent::Configuration(_, params) => Some(params), + Sent::Command(..) => None, + }) + .collect() + } + + /// The params of every project command. + fn commands(&self) -> Vec { + self.sent() + .into_iter() + .filter_map(|sent| match sent { + Sent::Command(_, params) => Some(params), + Sent::Configuration(..) => None, + }) + .collect() + } + } + + struct FakeHost(Arc