From ed4a4ffc5ea6ab4ea986b264ec8e51b63dff758b Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Fri, 23 Oct 2026 12:16:33 +0800 Subject: [PATCH 1/2] =?UTF-8?q?refactor(plugins):=20=E5=A5=91=E7=BA=A6?= =?UTF-8?q?=E5=8F=91=E5=B8=83=E5=88=B0=20lithe-plugin-docs=EF=BC=8Cfixture?= =?UTF-8?q?=20=E6=8C=89=E8=83=BD=E5=8A=9B=E6=94=B9=E5=90=8D=EF=BC=8CSDK=20?= =?UTF-8?q?=E7=A7=BB=E5=87=BA=E4=B8=BB=E4=BB=93=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 契约 fixture 按演示的能力改名(文件名和 dev.lithe.fixture.* ID),同步更新 标准答案、Rust Core 测试、macOS 测试与展示副本、功能矩阵和 Agent Notes。 - 两份 schema 的 $id 改为 lithe-plugin-docs 的 raw 地址;verify-shared-contracts.sh 要求 $id 完全相等,并拒绝离开契约目录的 README 相对链接。 - 契约 README 的目录外链接改为绝对地址,说明 lithe-plugin-docs 保存完整副本, 官方 SDK 一节指向 lithe-plugin-docs。 - 删除 sdk/、SDK 端到端测试和 Java SDK 发版工作流;Rust Core 的 CI 脚本、 ci-macos.yml、CI 分类和 .gitignore 去掉 SDK 相关内容。 - AGENTS.md 新增两仓库同步规则,新增 Agent Note 记录决策,并更新 SDK 相关 Note、develop-lithe 布局表、Plugins README 和 docs/ci-builds.md。 --- ...2026-10-17-build-system-plugin-contract.md | 6 +- ...6-10-20-host-plugin-function-invocation.md | 4 +- .../2026-10-20-plugin-backend-sdk.md | 37 +- .../2026-10-23-plugin-contract-publishing.md | 203 ++++ .../2026-10-17-build-system-provider-api.md | 6 +- .agents/skills/develop-lithe/SKILL.md | 1 - .github/workflows/ci-macos.yml | 18 - .github/workflows/release-java-plugin-sdk.yml | 81 -- .gitignore | 1 - AGENTS.md | 21 + Plugins/README.md | 6 + Plugins/mac/README.md | 4 + docs/ci-builds.md | 3 + .../BuildSystemToolWindowTests.swift | 18 +- ... => build-command-output-file.plugin.json} | 4 +- ...son => build-host-integration.plugin.json} | 4 +- ...nguageExtensionProcessLifecycleTests.swift | 2 +- .../MacBuildProviderSessionsTests.swift | 4 +- .../PluginBuildOptionPersistenceTests.swift | 2 +- .../PluginDiskInstallationTests.swift | 2 +- ...ginLanguageServerLaunchResolverTests.swift | 4 +- .../PluginMarketplaceServiceTests.swift | 6 +- .../LitheTests/PluginPackageStoreTests.swift | 4 +- .../LitheTests/PluginWebBridgeTests.swift | 2 +- .../RustBuildSystemOperationsTests.swift | 22 +- .../src/tests/build_runtime_versions.rs | 2 +- rust/lithe-core/src/tests/build_systems.rs | 106 +- .../src/tests/declarative_plugins.rs | 69 +- .../src/tests/marketplace_plugins.rs | 20 +- rust/lithe-core/tests/plugin_sdk/mod.rs | 550 --------- rust/lithe-core/tests/plugin_sdk_java.rs | 43 - rust/lithe-core/tests/plugin_sdk_node.rs | 19 - scripts/classify-ci-changes.sh | 5 - scripts/test-classify-ci-changes.sh | 4 - scripts/verify-rust-core-crates.sh | 19 +- scripts/verify-shared-contracts.sh | 16 +- sdk/java/README.md | 153 --- sdk/java/examples/sample-build/plugin.json | 87 -- sdk/java/examples/sample-build/pom.xml | 51 - .../org/example/samplebuild/SampleBuild.java | 241 ---- sdk/java/plugin-sdk/pom.xml | 41 - .../github/litheidea/plugin/sdk/Backend.java | 1062 ----------------- .../litheidea/plugin/sdk/CallContext.java | 88 -- .../litheidea/plugin/sdk/Cancellation.java | 104 -- .../plugin/sdk/CancelledException.java | 14 - .../github/litheidea/plugin/sdk/Framing.java | 138 --- .../io/github/litheidea/plugin/sdk/Json.java | 426 ------- .../litheidea/plugin/sdk/PluginError.java | 38 - .../litheidea/plugin/sdk/PluginSdkError.java | 32 - .../litheidea/plugin/sdk/Processes.java | 623 ---------- .../github/litheidea/plugin/sdk/Protocol.java | 109 -- .../litheidea/plugin/sdk/StateStore.java | 198 --- .../litheidea/plugin/sdk/BackendTest.java | 515 -------- .../github/litheidea/plugin/sdk/Fixtures.java | 133 --- .../litheidea/plugin/sdk/SupportTest.java | 370 ------ .../github/litheidea/plugin/sdk/TestHost.java | 215 ---- sdk/java/pom.xml | 77 -- sdk/node/README.md | 145 --- .../examples/sample-build/backend/main.mjs | 149 --- sdk/node/examples/sample-build/plugin.json | 49 - sdk/node/package.json | 24 - sdk/node/src/backend.mjs | 647 ---------- sdk/node/src/errors.mjs | 60 - sdk/node/src/framing.mjs | 74 -- sdk/node/src/index.d.ts | 146 --- sdk/node/src/index.mjs | 8 - sdk/node/src/process.mjs | 188 --- sdk/node/src/protocol.mjs | 92 -- sdk/node/src/state.mjs | 148 --- sdk/node/test/backend.test.mjs | 322 ----- sdk/node/test/harness.mjs | 106 -- sdk/node/test/sample-build.test.mjs | 64 - sdk/node/test/support.test.mjs | 321 ----- shared/contracts/plugins/v1/README.md | 52 +- ... => build-command-output-file.plugin.json} | 4 +- ....json => build-command-stdout.plugin.json} | 4 +- ...son => build-host-integration.plugin.json} | 4 +- .../plugins/v1/fixtures/build-plans.json | 130 +- ...uild-provider-language-server.plugin.json} | 4 +- ...plugin.json => build-provider.plugin.json} | 4 +- ...gin.json => build-static-file.plugin.json} | 4 +- .../plugins/v1/fixtures/command-plans.json | 60 +- .../v1/fixtures/invalid-manifests.json | 220 ++-- ... => language-host-integration.plugin.json} | 4 +- ...n => language-packaged-server.plugin.json} | 4 +- ...on => language-system-command.plugin.json} | 4 +- .../v1/fixtures/marketplace/index-cases.json | 12 +- .../v1/fixtures/marketplace/index.json | 4 +- .../fixtures/marketplace/install-reviews.json | 62 +- .../plugins/v1/plugin-index.schema.json | 2 +- .../plugins/v1/plugin-manifest.schema.json | 2 +- .../build-provider-refresh-and-tool.json | 3 +- .../features/build-system-plugins.json | 4 +- .../features/go-support-plugin.json | 2 +- .../features/java-support-plugin.json | 2 +- .../features/plugin-functions.json | 14 +- 96 files changed, 721 insertions(+), 8460 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-10-23-plugin-contract-publishing.md delete mode 100644 .github/workflows/release-java-plugin-sdk.yml rename macos/Tests/LitheTests/Fixtures/BuildSystems/{gradle-build.plugin.json => build-command-output-file.plugin.json} (98%) rename macos/Tests/LitheTests/Fixtures/BuildSystems/{maven-build.plugin.json => build-host-integration.plugin.json} (98%) delete mode 100644 rust/lithe-core/tests/plugin_sdk/mod.rs delete mode 100644 rust/lithe-core/tests/plugin_sdk_java.rs delete mode 100644 rust/lithe-core/tests/plugin_sdk_node.rs delete mode 100644 sdk/java/README.md delete mode 100644 sdk/java/examples/sample-build/plugin.json delete mode 100644 sdk/java/examples/sample-build/pom.xml delete mode 100644 sdk/java/examples/sample-build/src/main/java/org/example/samplebuild/SampleBuild.java delete mode 100644 sdk/java/plugin-sdk/pom.xml delete mode 100644 sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Backend.java delete mode 100644 sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/CallContext.java delete mode 100644 sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Cancellation.java delete mode 100644 sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/CancelledException.java delete mode 100644 sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Framing.java delete mode 100644 sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Json.java delete mode 100644 sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/PluginError.java delete mode 100644 sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/PluginSdkError.java delete mode 100644 sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Processes.java delete mode 100644 sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Protocol.java delete mode 100644 sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/StateStore.java delete mode 100644 sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/BackendTest.java delete mode 100644 sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/Fixtures.java delete mode 100644 sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/SupportTest.java delete mode 100644 sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/TestHost.java delete mode 100644 sdk/java/pom.xml delete mode 100644 sdk/node/README.md delete mode 100644 sdk/node/examples/sample-build/backend/main.mjs delete mode 100644 sdk/node/examples/sample-build/plugin.json delete mode 100644 sdk/node/package.json delete mode 100644 sdk/node/src/backend.mjs delete mode 100644 sdk/node/src/errors.mjs delete mode 100644 sdk/node/src/framing.mjs delete mode 100644 sdk/node/src/index.d.ts delete mode 100644 sdk/node/src/index.mjs delete mode 100644 sdk/node/src/process.mjs delete mode 100644 sdk/node/src/protocol.mjs delete mode 100644 sdk/node/src/state.mjs delete mode 100644 sdk/node/test/backend.test.mjs delete mode 100644 sdk/node/test/harness.mjs delete mode 100644 sdk/node/test/sample-build.test.mjs delete mode 100644 sdk/node/test/support.test.mjs rename shared/contracts/plugins/v1/fixtures/{gradle-build.plugin.json => build-command-output-file.plugin.json} (98%) rename shared/contracts/plugins/v1/fixtures/{go-build.plugin.json => build-command-stdout.plugin.json} (97%) rename shared/contracts/plugins/v1/fixtures/{maven-build.plugin.json => build-host-integration.plugin.json} (98%) rename shared/contracts/plugins/v1/fixtures/{mix-provider.plugin.json => build-provider-language-server.plugin.json} (93%) rename shared/contracts/plugins/v1/fixtures/{gradle-provider.plugin.json => build-provider.plugin.json} (97%) rename shared/contracts/plugins/v1/fixtures/{cargo-build.plugin.json => build-static-file.plugin.json} (97%) rename shared/contracts/plugins/v1/fixtures/{java-support.plugin.json => language-host-integration.plugin.json} (93%) rename shared/contracts/plugins/v1/fixtures/{php-support.plugin.json => language-packaged-server.plugin.json} (94%) rename shared/contracts/plugins/v1/fixtures/{go-support.plugin.json => language-system-command.plugin.json} (95%) diff --git a/.agents/notes/implemented/architecture/2026-10-17-build-system-plugin-contract.md b/.agents/notes/implemented/architecture/2026-10-17-build-system-plugin-contract.md index 1a8445531..13c45a8a5 100644 --- a/.agents/notes/implemented/architecture/2026-10-17-build-system-plugin-contract.md +++ b/.agents/notes/implemented/architecture/2026-10-17-build-system-plugin-contract.md @@ -35,7 +35,7 @@ Rust Core 负责校验声明、识别工程、展开每一条命令行,并把 没有声明的能力不报错,也不会出现在界面上。例如 Cargo fixture 没有声明依赖树,Build 工具窗口就不显示依赖区。 -字段全集只写在 `shared/contracts/plugins/v1/README.md` 的 “API level 3: build systems” 一节和 `plugin-manifest.schema.json` 里,本 Note 不重复。四个 fixture(`cargo-build`、`go-build`、`gradle-build`、`maven-build`)是完整示例,`gradle-provider`、`mix-provider` 分别示范 Provider 的两种写法。 +字段全集只写在 `shared/contracts/plugins/v1/README.md` 的 “API level 3: build systems” 一节和 `plugin-manifest.schema.json` 里,本 Note 不重复。四个 fixture 各示范一种模型来源,按能力命名而不是按真实插件命名:`build-static-file`(读项目文件,内容用 Cargo)、`build-command-stdout`(解码命令输出,内容用 Go)、`build-command-output-file`(解码命令写出的文件,内容用 Gradle)、`build-host-integration`(交给宿主集成,内容用 Maven)。`build-provider`、`build-provider-language-server` 分别示范 Provider 的两种写法。 ### 2. Core 与宿主的分工 @@ -333,11 +333,11 @@ macOS 接入规则(2026-10-18): ## 验证 - `cargo test --manifest-path rust/Cargo.toml -p lithe-core --lib build_systems`:四个 fixture 通过校验;`build-plans.json` 的全部标准答案;在临时工作区里验证 Cargo 运行入口与内置探测器逐字一致、嵌套、优先级与拒绝、Maven reactor 识别、Gradle 规范格式输出文件、JUnit 报告、同步指纹、Maven 任务参数与内置实现一致,以及互斥条件下允许重复引用选项。 -- `cargo test --manifest-path rust/Cargo.toml -p lithe-core --lib build_systems` 还覆盖 Provider:`build-plans.json` 里 `gradle-provider`、`mix-provider` 的请求计划和 `result` 解码;`provider_processes_are_install_review_programs`(自有进程和共享写法在安装审查中的条目);`launch_plans_name_exactly_one_process`;`provider_models_are_not_read_during_detection`。 +- `cargo test --manifest-path rust/Cargo.toml -p lithe-core --lib build_systems` 还覆盖 Provider:`build-plans.json` 里 `build-provider`、`build-provider-language-server` 的请求计划和 `result` 解码;`provider_processes_are_install_review_programs`(自有进程和共享写法在安装审查中的条目);`launch_plans_name_exactly_one_process`;`provider_models_are_not_read_during_detection`。 - `cargo test --manifest-path rust/Cargo.toml -p lithe-core --lib declarative_plugins`:`invalid-manifests.json` 里的非法清单,包括社区插件声明 `hostIntegration`、未声明权限、路径越界、未保护的模板变量和选项重复放置,以及 Provider 的十种错误写法(没有来源使用、混写、缺少标志、缺少权限、入口越界、不支持的运行时、未声明的设置等),和裁剪后不再接受的九种写法(静态 `yaml`、命令 `json`、任务规则、顶层目标、`parent`、映射标签、`parentDirectory`、依赖 `children`、`layout.generated`);`command-plans.json` 里按 `buildSystemID` 选中的启动计划。 - `declarative_plugins::build_providers_require_their_own_host_capability` 与 `marketplace_plugins::build_provider_versions_require_host_support_before_download` 验证 Provider 能力与声明式构建分开,安装审查的共享 fixture 同时覆盖自有进程和共享写法。 - `cargo test -p lithe-core --lib lsp`:`lsp/interface/build_provider.rs` 验证只有宿主提供过的版本算协商成功、版本 1 只接受两个方法;`engine.rs` 的测试验证协商后的会话写出 `lithe/build/*` 并返回结果,未协商或方法不在版本里时直接失败、不写入进程。 -- `./scripts/test-macos.sh --filter 'BuildSystemServiceTests'` 的 Provider 用例:`providerModelSendsThePlannedRequestAndDecodesItsReply`、`providerDependenciesDecodeTheProvidersReply` 验证请求原样发给按插件、构建系统和工作区确定的目标,回复作为 `providerResult` 交给 Core 解码且不启动命令;`workspaceSwitchAndDisabledPluginStopTheirProviders` 验证停用插件和切换工作区停止进程、插件列表不变时保留;`providerModelFailsWithoutAProviderHost`、`failedProviderRequestReportsItsMessage` 验证失败路径。`providerModelLoadCarriesTheResolvedToolWithoutRefresh`、`providerReloadsAskForARefresh`、`providerLoadsWithoutTheToolWhenResolutionFails`、`commandModelLoadsDoNotResolveAProviderTool` 验证加载原因和工具路径:首次加载不带 `refresh`、重新加载的模型和依赖都带,工具只在 Provider 请求时解析,解析失败时请求照常发出且 Build Output 写明原因;`MacBuildProviderSessionsTests.providerHintsReachTheProcessUnchanged` 验证这两个字段原样到达进程,`PluginWebBridgeTests.pageRefreshReloadsTheModel` 验证网页的 `build.refresh` 算重新加载。`build-plans.json` 的 `gradle provider … refresh …`、`… tool must be an absolute path`、`gradle model command ignores …` 用例覆盖 Core 的写入、校验和命令来源忽略。`PluginMarketplaceServiceTests.buildProvidersAreAcceptedByTheMacOSHost` 验证 `gradle-provider`、`mix-provider` 能通过 macOS 的安装审查。`MacBuildProviderSessionsTests` 用真实的 `MacBuildProviderSessions` 和脚本化 Core 验证:重复请求复用进程,按插件停止后下一次请求重新启动;设置变化后按新参数重启;包内入口不可执行时不启动进程。`PluginPackageStoreTests.unusablePackagedBuildProviderIsRefused`、`buildProviderRuntimeEntryMustBeInThePackage` 验证安装时校验 Provider 的包内入口:缺失、不可执行、经过符号链接都被拒绝,运行时入口缺失也被拒绝。 +- `./scripts/test-macos.sh --filter 'BuildSystemServiceTests'` 的 Provider 用例:`providerModelSendsThePlannedRequestAndDecodesItsReply`、`providerDependenciesDecodeTheProvidersReply` 验证请求原样发给按插件、构建系统和工作区确定的目标,回复作为 `providerResult` 交给 Core 解码且不启动命令;`workspaceSwitchAndDisabledPluginStopTheirProviders` 验证停用插件和切换工作区停止进程、插件列表不变时保留;`providerModelFailsWithoutAProviderHost`、`failedProviderRequestReportsItsMessage` 验证失败路径。`providerModelLoadCarriesTheResolvedToolWithoutRefresh`、`providerReloadsAskForARefresh`、`providerLoadsWithoutTheToolWhenResolutionFails`、`commandModelLoadsDoNotResolveAProviderTool` 验证加载原因和工具路径:首次加载不带 `refresh`、重新加载的模型和依赖都带,工具只在 Provider 请求时解析,解析失败时请求照常发出且 Build Output 写明原因;`MacBuildProviderSessionsTests.providerHintsReachTheProcessUnchanged` 验证这两个字段原样到达进程,`PluginWebBridgeTests.pageRefreshReloadsTheModel` 验证网页的 `build.refresh` 算重新加载。`build-plans.json` 的 `gradle provider … refresh …`、`… tool must be an absolute path`、`gradle model command ignores …` 用例覆盖 Core 的写入、校验和命令来源忽略。`PluginMarketplaceServiceTests.buildProvidersAreAcceptedByTheMacOSHost` 验证 `build-provider`、`build-provider-language-server` 能通过 macOS 的安装审查。`MacBuildProviderSessionsTests` 用真实的 `MacBuildProviderSessions` 和脚本化 Core 验证:重复请求复用进程,按插件停止后下一次请求重新启动;设置变化后按新参数重启;包内入口不可执行时不启动进程。`PluginPackageStoreTests.unusablePackagedBuildProviderIsRefused`、`buildProviderRuntimeEntryMustBeInThePackage` 验证安装时校验 Provider 的包内入口:缺失、不可执行、经过符号链接都被拒绝,运行时入口缺失也被拒绝。 - `./scripts/verify-shared-contracts.sh`:schema 与 fixture 一致;`build-plans.json` 的用例名唯一、操作合法、拒绝码都有文档、不含机器路径;`command-plans.json` 的每个用例只给出 `languageID`、`buildSystemID` 之一。 - `./scripts/verify-rust-core-comments.sh`:`build/` 模块的注释规范。 - `./scripts/test-macos.sh --filter 'BuildSystemToolWindowTests'`:选项根选择保留各实例的候选和生效值,原生菜单动作覆盖自定义单选替换、多选追加及取消已选自定义值;原生 `NSTextField` 提交验证 text/file/directory 清空后提交 `""` 而非重置。设置 `LITHE_BUILD_CAPTURE_DIR` 后可导出选项行的明暗截图。`BuildSystemServiceTests.instanceOptionEditsPreserveSiblingOnlySelections` 验证编辑一个实例不会丢掉另一个实例独有的选择;`languageImportPublishesTheSameEffectiveOptionsAsBuildPlansAndClearsDisabledPlugins` 验证插件配置与构建计划使用同一份生效值、修改后重新发布、停用及重置清理;`RustBuildSystemOperationsTests.languageImportMapsPluginOptionIDsWithoutBuildSystemNameAssumptions` 验证真实 Core 的导入映射和 Maven 可选执行字段解码。本地 Maven、Gradle fixture 副本经真实 Core 校验后读出 `icon`、`presentation`、选项图标和 `category`;模型任务自带的 `category`;每个构建系统推导出的窗口和命令(ID、标题、停靠、命令与窗口分开的动作),以及注册表校验通过。补充回归覆盖窗口所属模块激活、命令 pending 状态、独立窗口停靠与显隐恢复、旧 Maven 贡献及命令移除、参数任务根工程与目标作用范围。临时插件包中的窗口与选项明暗 SVG 加载后,目录清单和每个文件的字节必须完全不变。 diff --git a/.agents/notes/implemented/architecture/2026-10-20-host-plugin-function-invocation.md b/.agents/notes/implemented/architecture/2026-10-20-host-plugin-function-invocation.md index 8f7987e6c..0d0e0ba8d 100644 --- a/.agents/notes/implemented/architecture/2026-10-20-host-plugin-function-invocation.md +++ b/.agents/notes/implemented/architecture/2026-10-20-host-plugin-function-invocation.md @@ -148,13 +148,13 @@ if pluginID == "dev.lithe.maven" { 启动带函数的会话 } // 宿主按插 - 每个 Provider 带独有的 `LITHE_PLUGIN_BACKEND_SCOPE`,会话结束后宿主按该标记优雅结束遗留进程。 - `./scripts/verify-runtime-bundle-immutability.sh`:确认没有新增写入 bundle 的路径。 - macOS 原生验收(2026-10-20,PR #1366 合并 PR #1365 后的 debug 应用): - - 环境:`sdk/node/examples/sample-build` 加一个只用于验收的网页窗口,作为开发包从磁盘安装;另装一个带自己构建系统和网页窗口的验收插件;示例工作区含成功、失败(退出码 3)和长时间运行三个项目。页面把每一步结果写进 `ui.setState`,验收读取 Application Support 中的状态文件核对。 + - 环境:Node SDK 的示例插件 `sdk/node/examples/sample-build`(当时在主仓库,现位于 lithe-plugin-docs)加一个只用于验收的网页窗口,作为开发包从磁盘安装;另装一个带自己构建系统和网页窗口的验收插件;示例工作区含成功、失败(退出码 3)和长时间运行三个项目。页面把每一步结果写进 `ui.setState`,验收读取 Application Support 中的状态文件核对。 - `functions.list` 返回 `snapshot.read`、`workspace.build`;一次成功构建后再列出,多了运行时注册的 `snapshot.clear`。 - `functions.invoke` 带 `call` 时,页面依次收到 `Preparing`、`Building app`、`Done` 三个 `functions.progress`,结果作为 `{ value }` 到达;失败项目返回 `plugin.function.failed`,并带上插件自己的 `pluginErrorCode: sample.buildFailed`。 - `functions.cancel` 后调用以 `requestCancelled` 结束;构建子进程在取消后立刻退出(采样中最后一次出现早于取消约 0.1 秒)。对已结束的 `call` 再取消返回 `{}`。未注册的函数返回 `plugin.function.unknown`,`params` 不是对象返回 `plugin.web.invalidRequest`。 - 另一个插件的页面用 `sample:.` 调用 `functions.list` / `functions.invoke` 都得到 `plugin.web.forbidden`,并且在弹同意框之前就被拒绝;它调用自己实例上的 `snapshot.read` 得到 `plugin.function.unknown`,说明函数按进程隔离。 - 进程清理:调用进行中关闭项目、或在设置里停用插件,Provider 及其构建子进程都会退出(`ps`/`pgrep` 确认);从磁盘替换插件包时旧 Provider 也会停止;退出应用后没有任何 Lithe 或插件进程残留。重启应用后 `snapshot.read` 返回上次会话的快照。 - - 验收发现示例的路径检查在宿主 `rootUri` 带尾部斜杠时拒绝所有路径(macOS 传的是目录 URL 的 `absoluteString`),所有构建都报 `sample.config`。已在示例里用 `path.resolve` 规范化根目录,并让 `rust/lithe-core/tests/plugin_sdk_node.rs` 用带尾部斜杠的 `rootUri`;未修复时该测试失败,修复后通过。 + - 验收发现示例的路径检查在宿主 `rootUri` 带尾部斜杠时拒绝所有路径(macOS 传的是目录 URL 的 `absoluteString`),所有构建都报 `sample.config`。已在示例里用 `path.resolve` 规范化根目录,并让当时主仓库的 `rust/lithe-core/tests/plugin_sdk_node.rs`(现为 lithe-plugin-docs 的 `sdk/e2e/plugin_sdk_node.rs`)用带尾部斜杠的 `rootUri`;未修复时该测试失败,修复后通过。 - `./scripts/verify-platform-feature-matrix.sh`:`plugin-functions` 的 macOS 证据已更新。 ## 适用范围 diff --git a/.agents/notes/implemented/architecture/2026-10-20-plugin-backend-sdk.md b/.agents/notes/implemented/architecture/2026-10-20-plugin-backend-sdk.md index fd18a1587..7740604af 100644 --- a/.agents/notes/implemented/architecture/2026-10-20-plugin-backend-sdk.md +++ b/.agents/notes/implemented/architecture/2026-10-20-plugin-backend-sdk.md @@ -8,7 +8,9 @@ Lithe 有两个官方插件后端 SDK:`sdk/node`(包名 `@lithe/plugin-sdk` SDK 只**实现**契约,不**定义**契约。协议仍以 `shared/contracts/plugins/v1/README.md` 为准,SDK 不新增任何线上字段;不用 SDK 手写的后端同样合法。SDK 的价值是把“不能破坏 Lithe 统一管理”的规则变成默认行为:不发 `lithe/*` 回调、stdout 只走协议、状态只写宿主给的目录、调用结束时进程树一起结束。 -两个 SDK 各有一个示例插件(`sdk/*/examples/sample-build`),在 Rust Core CI lane 里经真实 Core 会话跑**同一套**端到端场景(`rust/lithe-core/tests/plugin_sdk/mod.rs`)。所以任一 SDK 与契约或另一个 SDK 的行为出现分叉时,测试都会失败。 +两个 SDK 各有一个示例插件(`sdk/*/examples/sample-build`),经真实 Core 会话跑**同一套**端到端场景(`sdk/e2e/plugin_sdk/mod.rs`)。所以任一 SDK 与契约或另一个 SDK 的行为出现分叉时,测试都会失败。 + +2026-10-23 起,两个 SDK 的源码、发版和端到端测试都在 [lithe-plugin-docs](https://github.com/Lithe-IDEA/lithe-plugin-docs) 的 `sdk/` 下,主仓库不再保存 SDK。插件开发者只看那个仓库就能拿到契约副本、SDK 和教程。迁移原因见 [插件契约、SDK 与开发者资料的发布](2026-10-23-plugin-contract-publishing.md);下文出现的 `sdk/...` 路径都指 lithe-plugin-docs。 ## 问题 @@ -23,13 +25,15 @@ Issue #1351 的目标是给插件开发者开放官方 SDK,同时“保留创 ## 决策 -### 1. 先做 Node,放在仓库根目录 `sdk/node` +### 1. 先做 Node,SDK 单独成一个目录 -Lithe 的 `runtime` 启动方式已支持 `node` 和 `java`。Node 无需编译、单文件即可分发、启动快,最适合作为第一个 SDK。目录放在根目录 `sdk/`,与 `shared/`(契约)、`Plugins/`(具体插件包)分开:`sdk/` 是可被插件复制使用的库代码,不属于任何一个平台,也不是契约本身。 +Lithe 的 `runtime` 启动方式已支持 `node` 和 `java`。Node 无需编译、单文件即可分发、启动快,最适合作为第一个 SDK。SDK 与 `shared/`(契约)、`Plugins/`(具体插件包)分开:它是可被插件复制使用的库代码,不属于任何一个平台,也不是契约本身。最初放在主仓库根目录 `sdk/`,后来整体移到 lithe-plugin-docs 的 `sdk/`。 ### 2. 零依赖、复制进插件包使用 -插件包安装后只读、没有 `npm install` 步骤、必须能离线运行。所以 SDK 不依赖任何 npm 包,插件作者把 `src/` 复制到包内用相对路径导入。`package.json` 标 `private: true`,暂不发布到 npm;等 API 稳定后再决定发布方式。 +插件包安装后只读、没有 `npm install` 步骤、必须能离线运行。所以 SDK 不依赖任何 npm 包,插件作者把 SDK 复制到包内用相对路径导入。 + +发布方式和 Java SDK 相同:lithe-plugin-docs 打 `sdk-node-v<版本>` 标签,它的 `.github/workflows/release-node-sdk.yml` 先在 Node 20 上跑 SDK 测试,再用 `npm pack` 生成 `lithe-plugin-sdk-node-<版本>.tgz` 和 `.sha256`,附在 GitHub Release 上。插件仓库在构建时按固定版本和 SHA-256 下载、校验并解开,把 `package/` 目录放进插件包;运行时不下载任何东西。这样外部开发者不用克隆主仓库,也不用给主仓库提 PR。`package.json` 仍标 `private: true`,`npm pack` 不受影响,但 SDK 不会被误发到 npm;等 API 稳定后再决定是否发布到 npm。 ### 3. 规则变成默认行为,而不是文档里的建议 @@ -57,7 +61,7 @@ Maven、Gradle 这类插件需要在后端里调用 JVM 生态的工具,并长 - **最低 Java 8。** 很多企业项目仍在 Java 8 上构建,后端最好能直接用项目那个 JDK 启动。代价是 SDK 只能用 Java 8 的 API。 - **零运行时依赖,自带最小 JSON 读写(`Json`)。** 插件要把 SDK shade(打进同一个 JAR)到自己的后端里。如果 SDK 依赖 Gson 或 lsp4j,就会和插件自己的同名依赖冲突,许可证审查范围也会变大。 -- **发布方式:主仓库打 `sdk-java-v<版本>` 标签,Release 附带 JAR 和 `.sha256`。** 工作流是 `.github/workflows/release-java-plugin-sdk.yml`,暂不发到 Maven 仓库。插件仓库在构建时按固定版本和 SHA-256 下载并校验,再 shade 进后端 JAR,运行时不下载任何东西。 +- **发布方式:打 `sdk-java-v<版本>` 标签,Release 附带 JAR 和 `.sha256`。** 0.1.0 是从主仓库发布的,已锁定它的插件不用改;之后的版本由 lithe-plugin-docs 的 `.github/workflows/release-java-sdk.yml` 发布。暂不发到 Maven 仓库。插件仓库在构建时按固定版本和 SHA-256 下载并校验,再 shade 进后端 JAR,运行时不下载任何东西。 - **处理器各自跑在守护线程池上,读循环只负责分帧和派发。** 取消不打断(interrupt)线程,只置位取消令牌,并结束该调用启动的进程。这是因为中断会让处理器里正在进行的文件写入半途失败。 - **进程按调用清理,用进程组,与 Node SDK 一致。** 常驻后端不会因为一次调用结束而退出,宿主按 `LITHE_PLUGIN_BACKEND_SCOPE` 的清理要等会话结束才发生,所以 SDK 必须在调用结束或取消时自己结束工具留下的进程。Java 没有进程组 API,macOS 和 Linux 上 SDK 通过系统 `perl`(`setpgrp(0, 0)` 后 `exec` 成工具,PID 不变)让每个工具成为自己进程组的组长:取消时先给整组发 SIGTERM,宽限期后发 SIGKILL;工具正常退出后立即对整组发 SIGKILL,结束它留下的后台进程。信号经 `/bin/kill -- -` 发出,因此 Java 8 和 Java 9+ 行为相同;只要组里还有成员,组 ID 就不会被复用,所以不会误伤其他并发调用。`PERL5OPT` 会让 perl 启动时加载模块,SDK 先从 perl 的环境里拿掉,再在 `exec` 前还给工具。启动失败时 launcher 以 127 退出并带固定前缀,SDK 仍抛 `IOException`,与 `ProcessBuilder` 一致。Windows 或没有 `perl` 时退回 `ProcessHandle.descendants()`(Java 9+,Windows 不会重挂父进程,工具退出后仍能找到子孙);Java 8 只能结束直接子进程。用 `setsid` 脱离进程组的进程,以及退回路径够不到的进程,仍由宿主按后端标记在会话结束时兜底。 - **取消监听器互相隔离。** 一个 `onCancel` 回调抛异常只记录到 stderr,不影响其余回调;调用收尾放在 `finally` 里,保证请求一定回复,宿主不会只等到超时。 @@ -106,28 +110,23 @@ Maven、Gradle 这类插件需要在后端里调用 JVM 生态的工具,并长 ## 后果 - 收益:插件作者只写处理器;取消、清理、错误映射和状态存储默认正确,Lithe 的统一管理规则不依赖作者自觉。 -- 收益:示例插件经过真实 Core 会话验证,契约改动若破坏 SDK,Rust Core lane 会直接失败。 +- 收益:示例插件经过真实 Core 会话验证。lithe-plugin-docs 每次提交都用契约副本锁定的那个主仓库 commit 跑端到端测试,每周再用主仓库默认分支的最新 Core 跑一次,Core 改动破坏 SDK 时会在这里失败。 - 代价:Node SDK 以复制源码方式分发,Java SDK 被 shade 进插件 JAR,插件里的副本都不会自动升级;修复安全问题时需要插件重新发布。 -- 代价:Rust Core lane 需要 Node、JDK 和 Maven;CI 用 Java 8 运行时跑 Java SDK 测试和示例后端,用 JDK 21 编译。 -- 代价:仓库新增一个顶层目录和一条 CI 测试,`sdk/*` 改动会触发 Rust Core lane。 +- 代价:SDK 移出主仓库后,主仓库的 PR 不再当场发现“Core 改动破坏 SDK”,最晚由 lithe-plugin-docs 的每周检查发现。改 Core 的插件后端协议时,应在合并前用 lithe-plugin-docs 的 `node scripts/sdk-e2e.mjs --source <本地主仓库> --ref <分支>` 手动跑一次。 +- 代价:lithe-plugin-docs 的端到端测试需要 Rust、Node、JDK 和 Maven,每次都要编译一遍 Core;CI 用 Java 8 运行时跑 Java SDK 测试和示例后端,用 JDK 21 编译。 - 代价:宿主还没有调用函数的界面入口,也没有宿主能力(`lithe/host/*`);Windows 没有插件后端宿主。SDK 目前对用户不可见,只给开发者和后续 PR 使用。 ## 验证 -- `node --test sdk/node/test/*.test.mjs`:协商、ID 规则(对照 `function-protocol.json`)、保留方法、调用/进度/错误/取消、运行时注册、构建结果、生命周期和退出码、分帧、进程树取消、状态存储与只读包拒绝、类型声明与运行时导出一致。 -- `LITHE_PLUGIN_SDK_NODE="$(command -v node)" cargo test --manifest-path rust/Cargo.toml -p lithe-core --test plugin_sdk_node`:示例插件经 Core 校验、启动计划、版本检查、构建模型/依赖、函数结果/进度/失败/取消、重启后状态恢复,最后确认插件包文件清单不变。 +以下命令都在 lithe-plugin-docs 中运行: + +- `node --test sdk/node/test/*.test.mjs`:协商、ID 规则(对照 `contract/v1/fixtures/function-protocol.json`)、保留方法、调用/进度/错误/取消、运行时注册、构建结果、生命周期和退出码、分帧、进程树取消、状态存储与只读包拒绝、类型声明与运行时导出一致。 - `mvn -B -f sdk/java/pom.xml verify [-Djvm=]`:Java SDK 的 JUnit 测试,覆盖与 Node 测试相同的协议场景,另含 JSON、分帧、进程树取消和状态存储。 -- `cargo test … --test plugin_sdk_java`(需要 `LITHE_PLUGIN_SDK_JAVA`、`LITHE_PLUGIN_SDK_JAVA_SAMPLE`、`LITHE_PLUGIN_SDK_NODE`):Java 示例按插件实际打包方式(`plugin.json` + shade 后的 JAR)装进临时包目录,跑同一套 Core 场景。 -- `./scripts/verify-rust-core-crates.sh`:CI 中运行以上全部;设置 `LITHE_PLUGIN_SDK_JAVA_RUNTIME` 时,Java 测试和示例后端在该运行时(CI 为 Java 8)上运行。 -- `bash scripts/test-classify-ci-changes.sh`:`sdk/*` 改动被分到 Rust Core lane。 +- `node scripts/sdk-e2e.mjs`:把 `sdk/e2e` 复制进锁定版本主仓库的 `rust/lithe-core/tests/`,运行 `plugin_sdk_node` 和 `plugin_sdk_java`。Node 示例经 Core 校验、启动计划、版本检查、构建模型/依赖、函数结果/进度/失败/取消、重启后状态恢复,最后确认插件包文件清单不变;Java 示例按插件实际打包方式(`plugin.json` + shade 后的 JAR)装进临时包目录,跑同一套场景。设置 `LITHE_PLUGIN_SDK_JAVA_RUNTIME` 时,Java 示例在该运行时(CI 为 Java 8)上运行。 +- `node scripts/sdk-e2e.mjs --ref HEAD`:同上,换成主仓库默认分支的最新 Core;CI 每周运行。 ## 适用范围 -- `sdk/node/` -- `sdk/java/` -- `rust/lithe-core/tests/plugin_sdk/`、`plugin_sdk_node.rs`、`plugin_sdk_java.rs` +- `https://github.com/Lithe-IDEA/lithe-plugin-docs` - `rust/lithe-core/src/plugins/declarative.rs`(运行时启动计划的版本探测参数) -- `.github/workflows/release-java-plugin-sdk.yml` -- `scripts/verify-rust-core-crates.sh` -- `scripts/classify-ci-changes.sh` - `shared/contracts/plugins/v1/README.md` diff --git a/.agents/notes/implemented/architecture/2026-10-23-plugin-contract-publishing.md b/.agents/notes/implemented/architecture/2026-10-23-plugin-contract-publishing.md new file mode 100644 index 000000000..f10571b63 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-23-plugin-contract-publishing.md @@ -0,0 +1,203 @@ +# Agent 笔记:插件契约在主仓库定义,完整副本、SDK 和开发者资料发布到 lithe-plugin-docs + +状态:已实现 + +## 先说结论 + +插件契约(`shared/contracts/plugins/v1/`,规定 `plugin.json` 每个字段的含义、错误码和标准答案用例)仍然在主仓库定义,由 Rust Core 执行。公开仓库 `Lithe-IDEA/lithe-plugin-docs` 的 `contract/v1/` 保存这个目录的**完整逐字节副本**,另外放开发指南、从 Lithe Store 搬过来的两篇教程、两个官方 SDK(源码、发版和端到端测试)和示例插件列表。插件开发者只看 lithe-plugin-docs 就能开发,写插件、发布插件都不需要克隆主仓库,也不需要给主仓库提 PR。 + +日常开发要记住四件事: + +1. 契约目录下任何文件合并后,都要到 lithe-plugin-docs 运行同步脚本。这条规则也写在根目录 `AGENTS.md`。 +2. 契约 README 里指向目录外的链接必须用绝对 URL,因为副本只包含这个目录。 +3. 契约 fixture(测试用的示例清单)按“演示什么能力”命名,不按真实插件命名。 +4. 主仓库不再保存 SDK。SDK 的修改和发版都在 lithe-plugin-docs;改 Core 的插件后端协议时,合并前要用它的 `scripts/sdk-e2e.mjs` 跑一次 SDK 端到端测试。 + +## 问题 + +主仓库里有很多插件 JSON,看起来像是各个插件仓库的配置副本。逐个盘点后,它们分成三类: + +1. **契约本身**:schema、README 和错误码。Rust Core 的测试用 `include_str!` 直接读取它们,Swift 测试也读取其中的 fixture,所以它们必须和执行它们的代码在同一个提交里修改。 +2. **契约 fixture**:文件名是 `maven-build`、`php-support`、`cargo-build` 这类真实插件名,ID 也是 `dev.lithe.fixture.maven-build`。实际上它们只是借 Cargo、Go、Gradle、Maven 当真实输入,用来演示某种能力,例如“从命令输出解码项目模型”。名字让人误以为主仓库在维护这些插件的配置。 +3. **确实属于插件仓库的副本**:例如 `rust/lithe-core/src/tests/fixtures/community_build_plugins/` 中 Maven、Gradle 插件的快照。这一类本次不处理,留作后续迁移。 + +除此之外,外部开发者需要的东西分散在主仓库和 Lithe Store: + +- 字段语义、错误码和标准用例只在主仓库。 +- schema 的 `$id` 是 `https://lithe.dev/...`,但这个域名不归我们所有,地址解析不到,编辑器没法用它。 +- 两个 SDK 的源码在主仓库,Node SDK 也没有正式发布,只能从主仓库复制 `src/`。 +- 教程在 Lithe Store,Store 还有一份已经过期的 schema 副本。没有一个“我要写插件,从哪里开始”的入口。 + +## 决策 + +### 契约在主仓库定义 + +契约和执行它的代码(Rust Core 的校验和标准答案测试)必须在同一个 PR 里修改。如果把契约的源头移到别的仓库,改一个字段就要两个 PR,中间还会出现“契约写的是 A、Core 做的是 B”的时间窗口。外部开发者只需要**读**契约、不需要**改**契约,读的需求由完整副本满足。 + +### lithe-plugin-docs 是开发者的唯一入口 + +- `README.md`:中文开发指南,讲插件结构、API 等级怎么选、编辑器校验、后端 SDK 和示例插件。 +- `guides/`:从 Lithe Store 搬来的《LSP 插件完整生命周期》和《构建、运行与测试接入》。契约链接改为本仓库的 `contract/v1/`,发布相关链接指向 Store。 +- `contract/v1/`:主仓库 `shared/contracts/plugins/v1/` 整个目录的逐字节副本,包括 README、两份 schema 和全部 fixture。 +- `contract.lock.json`:记录来源仓库、来源目录、commit 和每个文件的 SHA-256,格式与 Cargo 插件的 `sdk.lock.json` 相同。 +- `sdk/node`、`sdk/java`:两个官方 SDK 和各自的示例插件;`sdk/e2e`:两个示例共用的端到端场景(Rust 测试文件)。 +- `scripts/contract.mjs` 提供四个命令: + - `sync --revision `:按来源目录在该 commit 的 git tree 整体替换副本,新增、删除、修改的文件都跟上,然后重写锁文件。 + - `verify`:离线检查锁文件、哈希、schema `$id`、Markdown 相对链接和未锁定文件。 + - `verify --upstream`:用 GitHub API 取锁定 commit 的 tree,按 git blob ID 比较整个目录。 + - `drift`:检查主仓库默认分支上的契约是否已经变化。 + + docs 仓库的 CI 每次提交运行前两项检查和脚本测试,每周运行一次 `drift`。 +- `scripts/sdk-e2e.mjs`:SDK 端到端测试,见下文“SDK 整体移到 lithe-plugin-docs”。 + +只复制 schema 不够:字段之间的约束、错误码和预期结果都写在契约 README 和 fixture 里,只有 schema 的话,开发者仍然要回主仓库查。 + +### Lithe Store 只管收录和上架 + +Store 保留 `publishing.md`、`versioning-and-updates.md`、`maintainers.md` 和 `CONTRIBUTING.md`,讲怎么提交收录、怎么追踪版本。两篇教程搬到 lithe-plugin-docs,Store 的 `schema/plugin-index.schema.json` 副本删除。 + +Store 的 `validate-index.mjs` 仍然实现索引规则。它的 CI 原来比较本地 schema 副本和主仓库 preview,现在改为检查 lithe-plugin-docs 上 `plugin-index.schema.json` 的 SHA-256 是否等于工作流里记录的值。不相等说明索引契约变了,Store 维护者更新校验脚本后再更新记录的哈希。 + +### Schema 地址固定为 docs 仓库的 raw 地址 + +两份 schema 的 `$id` 是它在 docs 仓库里的地址: +`https://raw.githubusercontent.com/Lithe-IDEA/lithe-plugin-docs/main/contract/v1/<文件名>`。 + +两边都对这个地址做严格检查: + +- 主仓库的 `scripts/verify-shared-contracts.sh` 用常量 `published_plugin_schema_base` 要求 `$id` 完全相等。 +- docs 仓库的 `contract.mjs` 要求每份 schema 的 `$id` 等于它自己的发布地址。 + +因此,如果主仓库改了地址而 docs 仓库没跟上,或者 docs 仓库里的文件不是从主仓库同步来的,CI 都会失败。 + +### 契约 README 的外链写绝对地址 + +副本只包含契约目录,`../` 开头的相对链接在 docs 仓库里会失效。所以契约 README 指向 `rust-core-api.md`、Agent Notes 的链接都写成 `https://github.com/1lck/Lithe-IDEA/blob/preview/...`,指向 SDK 的链接写成 lithe-plugin-docs 的地址。 + +两边都检查这条规则: + +- 主仓库的 `verify-shared-contracts.sh` 拒绝离开目录或指向不存在文件的相对链接。 +- docs 仓库的 `contract.mjs` 在同步和校验时也拒绝这类链接。 + +### `plugin.json` 里不能写 `$schema` + +Core 解析清单时拒绝未知字段(serde `deny_unknown_fields`),schema 本身也是 `additionalProperties: false`。如果开发者按习惯加一行 `"$schema"`,插件会因为 `plugin.manifest.invalid` 装不上。所以指南要求在编辑器里按文件名关联 schema,例如 VS Code 的 `json.schemas`。是否在以后的契约版本里允许 `$schema` 另行决定:那需要提高能力版本,老版本 Lithe 会拒绝新格式。 + +### fixture 按能力命名 + +`shared/contracts/plugins/v1/fixtures/` 中的 fixture 改为按演示的能力命名,ID 同步改成 `dev.lithe.fixture.<新名字>`: + +| 原名 | 新名 | 演示什么 | +| --- | --- | --- | +| `php-support` | `language-packaged-server` | 随包附带语言服务器 | +| `go-support` | `language-system-command` | 使用用户安装的命令作为语言服务器 | +| `java-support` | `language-host-integration` | 交给宿主集成的语言服务器 | +| `cargo-build` | `build-static-file` | 从项目文件读取构建模型 | +| `go-build` | `build-command-stdout` | 解码命令的标准输出 | +| `gradle-build` | `build-command-output-file` | 解码命令写出的文件 | +| `maven-build` | `build-host-integration` | 把构建模型交给宿主集成 | +| `gradle-provider` | `build-provider` | 构建后端 | +| `mix-provider` | `build-provider-language-server` | 与语言服务器共用进程的构建后端 | + +fixture 的内容仍然用 Cargo、Go、Gradle、Maven 作为真实输入,契约 README 写明“它们不是这些插件”。macOS 测试中同 ID 的展示用副本(`macos/Tests/LitheTests/Fixtures/BuildSystems/`)一起改名。 + +`shared/fixtures/plugins/language-support-v1.json` 保留旧 ID `dev.lithe.fixture.go-support`。它是已退役的 schema 1 格式,只被 `rust/lithe-core/src/tests/plugins.rs` 的旧格式测试读取,与声明式契约无关。 + +### SDK 整体移到 lithe-plugin-docs + +SDK 是给插件开发者用的库,不是契约。开发者要读它的 README、看示例、提问题、下载发布包,这些都应该在 lithe-plugin-docs 完成,所以 `sdk/`、SDK 端到端测试和两个发版工作流都从主仓库移走,主仓库只保留契约。 + +- **发版**:lithe-plugin-docs 打 `sdk-node-v<版本>` 或 `sdk-java-v<版本>` 标签,`release-node-sdk.yml`、`release-java-sdk.yml` 发布包和 `.sha256`。具体规则见 [官方插件后端 SDK](2026-10-20-plugin-backend-sdk.md)。Java SDK 0.1.0 是迁移前从主仓库发布的,那个 Release 保留,Maven 插件锁定的下载地址不变。 +- **单元测试**:SDK 自己的测试读 `contract/v1/fixtures/function-protocol.json`,也就是契约副本,不再读主仓库。 +- **端到端测试**:场景仍是 Rust 测试,要链接 Rust Core。`sdk-e2e.mjs` 把锁文件记录的主仓库 commit 稀疏检出到 `.artifacts/lithe`(只取 `rust/` 和 `shared/`),把 `sdk/e2e` 复制进它的 `rust/lithe-core/tests/`,再用 `cargo test --locked` 运行。`LITHE_PLUGIN_SDK_ROOT` 告诉测试示例插件在哪里。这和 Cargo 插件测 Core 的方式相同。 +- **两个版本的 Core**:每次提交测契约副本锁定的那个 commit,保证 SDK、契约副本和 Core 是同一个版本;每周再用 `--ref HEAD` 测主仓库默认分支的最新 Core,提前发现 Core 的改动破坏了 SDK。 + +代价是主仓库的 PR 不再当场发现“Core 改动破坏 SDK”。改插件后端协议(`rust/lithe-core/src/lsp/interface/plugin_function.rs`、`litheBuild` 相关代码)时,合并前要在 lithe-plugin-docs 用 `--source <本地主仓库> --ref <分支>` 手动跑一次。 + +### 正确做法 + +- 修改契约:在同一个 PR 里改 `shared/contracts/plugins/v1/`、Core 和测试。合并后,在 lithe-plugin-docs 运行 `node scripts/contract.mjs sync --revision <合并后的 preview commit>`,然后提交。 +- 改了 `plugin-index.schema.json`:同步 docs 仓库之后,Lithe Store 的 CI 会失败,按 Store 的 `docs/maintainers.md` 更新索引校验和记录的哈希。 +- 在契约 README 里引用目录外的文件:写 `https://github.com/1lck/Lithe-IDEA/blob/preview/<路径>`。 +- 新增 fixture:按演示的能力命名,例如 `build-command-stdout.plugin.json`,ID 使用 `dev.lithe.fixture.` 前缀。 +- 新增面向插件开发者的说明:写进 lithe-plugin-docs 的 README 或 `guides/`。 +- 修改或发布 SDK:在 lithe-plugin-docs 提 PR、打标签。 +- 修改插件后端协议:合并前在 lithe-plugin-docs 运行 `node scripts/sdk-e2e.mjs --source <本地主仓库> --ref <分支>`。 + +### 不要这样做 + +- 在 lithe-plugin-docs 里直接编辑 `contract/` 下的文件,或者让主仓库 schema 的 `$id` 指向别的地址。两边的 CI 都会失败。 +- 在契约 README 里写 `../` 开头的相对链接。 +- 用真实插件名命名 fixture,例如 `maven-build.plugin.json`。读者会以为主仓库在维护那个插件的配置。 +- 在指南或示例里让开发者往 `plugin.json` 写 `$schema`。 +- 把插件开发教程写回 Lithe Store 或主仓库的 `Plugins/`。 +- 在主仓库重新加 `sdk/` 目录,或者让主仓库 CI 检出 lithe-plugin-docs 跑 SDK 测试。依赖方向是 docs 仓库依赖主仓库,反过来会让主仓库的结果取决于另一个仓库的 `main`。 + +## 考虑过的备选方案 + +### 把契约的源头移出主仓库 + +开发者只看一个仓库,这个目标现在由完整副本实现。真正把源头移走有两种做法,都没有采用: + +- **git submodule(子模块)**:主仓库测试读取子模块里的文件。每次改契约都要先在契约仓库合并,再在主仓库更新子模块指针;所有克隆和 CI 都要多一步初始化子模块。 +- **测试时按锁文件下载契约**:主仓库只保存锁文件,构建前下载。Rust 测试用 `include_str!` 在编译期读取 fixture,离线构建和每个 worktree 都要先准备下载物,还要纳入 worktree 资源复用清单。 + +两种做法都让“改契约、改 Core、改测试”变成跨仓库的多步操作,复杂度高于每次合并后同步一次副本。 + +### 只发布两份 schema 副本 + +这是最初的方案,docs 仓库只放 schema。没有采用,因为字段语义、错误码和标准用例都在契约 README 和 fixture 里,开发者遇到 schema 表达不了的规则时仍然要回主仓库。 + +### 把 schema 放到主仓库的 GitHub Pages 或主仓库 raw 地址 + +这样不用多建一个仓库。但 Pages 目前用来发布 Agent Notes 看板,自定义域名 `notes.lithe.top` 还没有 HTTPS 证书。如果用主仓库 raw 地址,开发者的入口仍然是主仓库,而且主仓库调整目录结构就会改变公开地址。 + +### 教程和契约副本都放在 Lithe Store + +Store 原来已经有教程和一份 `schema/plugin-index.schema.json`。没有采用,是因为 Store 负责收录和上架,它的 schema 副本已经和主仓库不一致(`$id` 还是 `lithe.dev`)。把“怎么写插件”和“怎么上架”分开,Store 的收录规则可以保持单一职责。 + +### SDK 留在主仓库,只单独发版 + +这是本次最初的做法:端到端测试留在 Rust Core lane,改 Core 时当场发现 SDK 被破坏。没有采用,因为开发者仍然要到主仓库读 SDK 的 README、看示例、提 issue,改 SDK 也要给主仓库提 PR,和“开发者只看 lithe-plugin-docs”的目标冲突。端到端测试改成按锁文件检出 Core 以后,跨仓库固定 Core 版本的成本可以接受。 + +### 主仓库 CI 检出 lithe-plugin-docs 跑 SDK 端到端测试 + +这样改 Core 时仍能当场发现问题。没有采用:主仓库的测试结果会取决于另一个仓库 `main` 分支的状态,docs 仓库合并一个坏提交就会让主仓库所有 PR 失败;同时形成两个仓库互相依赖。 + +### 把 Node SDK 发布到 npm + +npm 是 Node 开发者最熟悉的方式。但插件包安装后没有 `npm install` 步骤,开发者最终还是要把 SDK 文件复制进插件包。发布到 npm 还要管理组织和令牌,目前没有必要。 + +## 后果 + +- 收益:外部开发者只需要 lithe-plugin-docs;schema 地址可以真正解析,编辑器能直接提示;fixture 名字不再误导;两个 SDK 的源码、发版和问题都在同一个仓库,Node SDK 也有带哈希的正式下载。 +- 代价:契约变更后要多做一次同步。同步之前,docs 仓库的副本会落后于主仓库,每周的 `drift` 检查会失败提醒。 +- 代价:Core 改动破坏 SDK 时,主仓库 PR 不会失败,最晚由 docs 仓库的每周检查发现;docs 仓库的端到端测试每次都要编译一遍 Core。 +- 代价:契约 README 不能用相对链接引用主仓库的其他文件,这些绝对链接指向 `preview`,会随主仓库调整目录而失效。 +- 代价:schema `$id` 绑定了 docs 仓库 `main` 分支的 raw 地址。仓库改名或换默认分支都会改变公开地址,必须同时改两边的检查。 +- 后续工作: + - `community_build_plugins` 中 Maven、Gradle 快照迁回插件仓库。 + - Lua、Gradle、Maven 插件 README 里指向 Store 教程和主仓库契约的链接,改为指向 lithe-plugin-docs。 + - Windows PhpSupport 插件迁移。 + - 其他共享 schema 仍然使用 `lithe.dev` 作为 `$id`。 +- 重新评估的条件:如果契约开始需要外部贡献者直接修改,或者 Rust Core 拆成独立仓库,就重新考虑契约放在哪里。 + +## 验证 + +- `./scripts/verify-shared-contracts.sh`:两份 schema 的 `$id` 必须等于 docs 仓库的发布地址;契约 README 的相对链接不能离开目录;fixture ID 必须以 `dev.lithe.fixture.` 开头。 +- `cargo test --manifest-path rust/Cargo.toml -p lithe-core --lib -- declarative_plugins build_systems marketplace_plugins build_runtime_versions`:改名后的 fixture 仍然得到相同的标准答案。 +- `bash scripts/test-classify-ci-changes.sh`:主仓库的 CI 分类不再有 `sdk/` 路由。 +- 在 lithe-plugin-docs 运行 `node --test scripts/*.test.mjs` 和 `node scripts/contract.mjs verify --upstream`:同步脚本的测试通过,副本与锁定 commit 的契约目录完全一致。 +- 在 lithe-plugin-docs 运行 `node --test sdk/node/test/*.test.mjs`、`mvn -B -f sdk/java/pom.xml verify` 和 `node scripts/sdk-e2e.mjs`:两个 SDK 的单元测试读契约副本通过,两个示例后端在锁定版本的 Core 上跑完端到端场景。 + +## 适用范围 + +- `AGENTS.md` +- `shared/contracts/plugins/v1/` +- `scripts/verify-shared-contracts.sh` +- `scripts/verify-rust-core-crates.sh` +- `scripts/classify-ci-changes.sh` +- `.github/workflows/ci-macos.yml` +- `Plugins/README.md` +- `https://github.com/Lithe-IDEA/lithe-plugin-docs` +- `macos/Tests/LitheTests/Fixtures/BuildSystems/` diff --git a/.agents/notes/proposed/architecture/2026-10-17-build-system-provider-api.md b/.agents/notes/proposed/architecture/2026-10-17-build-system-provider-api.md index 826eb303a..ef72a2c79 100644 --- a/.agents/notes/proposed/architecture/2026-10-17-build-system-provider-api.md +++ b/.agents/notes/proposed/architecture/2026-10-17-build-system-provider-api.md @@ -164,7 +164,7 @@ BSP 本身也是 JSON-RPC 上的进程外构建协议,生命周期与 LSP 几 1. **P0 契约骨架(Core 已完成)**:API 3 的 `buildSystems`、13 个 `build.*` 命令、Cargo/Go/Gradle/Maven 四个 fixture 和 `build-plans.json` 标准答案。 2. **P1 通用工具窗口与运行配置**:两端实现第 2 节的宿主能力和 Build 工具窗口;`runConfig.generate` 接入构建模型,先迁移 Cargo;把 npm、just、Make、Composer 等现有探测器改写成内置声明;定下 `buildSystems.test` 与 `executionProviders.test` 在同一工程里谁优先。 -3. **P2 进程型 Provider 与 Gradle**:`provider` 声明、启动计划和回复解码的 Core 部分已完成(`gradle-provider`、`mix-provider` fixture 和标准答案)。macOS 宿主部分(协商、发送 `lithe/build/*`、进程生命周期)已完成,剩下 Windows 宿主。Gradle 先用插件包内的 init script 输出 `litheBuildModel`(fixture 已验证这条路)关闭 Issue #10,再评估改用 Tooling API 的 Provider 以获得结构化进度和测试事件;Go、Cargo 的依赖树走模型命令档。 +3. **P2 进程型 Provider 与 Gradle**:`provider` 声明、启动计划和回复解码的 Core 部分已完成(`build-provider`、`build-provider-language-server` fixture 和标准答案)。macOS 宿主部分(协商、发送 `lithe/build/*`、进程生命周期)已完成,剩下 Windows 宿主。Gradle 先用插件包内的 init script 输出 `litheBuildModel`(fixture 已验证这条路)关闭 Issue #10,再评估改用 Tooling API 的 Provider 以获得结构化进度和测试事件;Go、Cargo 的依赖树走模型命令档。 4. **P3 BSP**:有实际需求(Scala、Bazel、SwiftPM)时,在同一传输层上加入 `bsp/2` 协议。 5. **P4 Maven 收编**:官方插件 `dev.lithe.maven` 先以 `hostIntegration: maven` 进入同一领域模型,由通用工具窗口渲染,Windows 随后用同一组 fixture 切换。最后改为插件自带的 Provider,从 Core 删除 `maven` 集成。逐项对照见下一节。 @@ -179,7 +179,7 @@ BSP 本身也是 JSON-RPC 上的进程外构建协议,生命周期与 LSP 几 除特别标注外,各项都在 P4 迁移。 -Core 侧已经具备的部分:fixture `maven-build.plugin.json`(插件 ID 为 `dev.lithe.fixture.maven-build`)声明了 Wrapper、`MAVEN_HOME` 和 `PATH` 三类候选,Profile、`settings.xml`、本地仓库、离线和跳过测试五个选项,`clean`、`compile`、`package`、`install` 和自由输入的 goal,统一后的单测模板,以及诊断正则;`maven` 集成负责 reactor 模型、依赖树和 Surefire 结果。表里“迁移做法”剩下的主要是宿主工作、正式的 `dev.lithe.maven` 清单,以及删除旧命令。 +Core 侧已经具备的部分:fixture `build-host-integration.plugin.json`(插件 ID 为 `dev.lithe.fixture.build-host-integration`)声明了 Wrapper、`MAVEN_HOME` 和 `PATH` 三类候选,Profile、`settings.xml`、本地仓库、离线和跳过测试五个选项,`clean`、`compile`、`package`、`install` 和自由输入的 goal,统一后的单测模板,以及诊断正则;`maven` 集成负责 reactor 模型、依赖树和 Surefire 结果。表里“迁移做法”剩下的主要是宿主工作、正式的 `dev.lithe.maven` 清单,以及删除旧命令。 #### 工程模型 @@ -317,7 +317,7 @@ Core 契约部分的验收已在已实现的 Note 里完成。剩下的工作按 - 安装一个第三方 Gradle 构建插件后,它注入的每个运行任务各自生成一条运行配置,名字包含 project 路径和任务名,满足 Issue #10 的验收条件,Core 和宿主都不改代码。 - 在未信任的工作区打开项目时,没有任何 `command` 档进程被启动;Wrapper 只在用户操作后运行。 - 只有 `buildSystems: [1]` 的宿主可安装模板构建插件,但市场、清单校验和安装审查必须拒绝自有与共享 Provider;宿主真正实现 Provider 后才加入 `buildProviders: [1]`。已有 `declarative_plugins` 和 `marketplace_plugins` 回归覆盖这一边界,后续宿主实现要保留这些断言。 -- `gradle-provider` 这类自有 Provider 只在用户操作或工作区信任后启动,并登记进进程账本;`mix-provider` 这类共享写法不启动第二个进程,在工作区信任前不发送 `lithe/build/*`。 +- `build-provider` 这类自有 Provider 只在用户操作或工作区信任后启动,并登记进进程账本;`build-provider-language-server` 这类共享写法不启动第二个进程,在工作区信任前不发送 `lithe/build/*`。 - 任务通过现有执行链路运行,具备取消、期限和输出上限;禁用插件会停止它登记的守护进程,用户在终端启动的同一工具不受影响。 - `junitXml` 和 `lineMatcher` 解码出的诊断与测试结果能跳转到编辑器位置。 - 迁移过程中 Maven 工具窗口的生命周期、Profile、依赖树和执行目标行为不变;`build.*` 与 `maven.*` 并行期间,两者对同一组 Maven fixture 的结果一致。 diff --git a/.agents/skills/develop-lithe/SKILL.md b/.agents/skills/develop-lithe/SKILL.md index 4fe60eca0..0774017a1 100644 --- a/.agents/skills/develop-lithe/SKILL.md +++ b/.agents/skills/develop-lithe/SKILL.md @@ -80,7 +80,6 @@ individual upstream commands were called. | `Plugins/win/` | Windows-owned plugin packages | | `frontend/editor/` | Shared Monaco presentation, tokenization, and editor model lifecycle; no platform APIs | | `shared/` | Cross-platform contracts and fixtures, not compiled implementation | -| `sdk/` | Official plugin backend SDKs and their samples; they implement `shared/contracts/plugins/` and never define protocol | | `infra/` | Repository-level development and validation infrastructure | | `third_party/` | Upstream code; leave unchanged unless the task explicitly targets it | diff --git a/.github/workflows/ci-macos.yml b/.github/workflows/ci-macos.yml index 19c22e60d..717f51c1e 100644 --- a/.github/workflows/ci-macos.yml +++ b/.github/workflows/ci-macos.yml @@ -385,24 +385,7 @@ jobs: with: cargo: "true" - # The Java plugin SDK targets Java 8: its tests and sample backend run on - # a Java 8 runtime, while Maven builds with the newer JDK set up last. - - name: Set up Java 8 runtime for the plugin SDK - id: java8 - uses: actions/setup-java@v5 - with: - distribution: zulu - java-version: '8' - - - name: Set up Java 21 for Maven - uses: actions/setup-java@v5 - with: - distribution: temurin - java-version: '21' - - name: Run Rust Core tests - env: - LITHE_PLUGIN_SDK_JAVA_RUNTIME: ${{ steps.java8.outputs.path }}/bin/java run: ./scripts/verify-rust-core-crates.sh - name: Upload Rust test reports @@ -415,7 +398,6 @@ jobs: .artifacts/test-stability/agent-host-rust.* .artifacts/test-stability/ide-host-rust.* .artifacts/test-stability/rust-comment-checker.xml - .artifacts/test-stability/TEST-io.github.litheidea.plugin.sdk.*.xml if-no-files-found: ignore retention-days: 14 diff --git a/.github/workflows/release-java-plugin-sdk.yml b/.github/workflows/release-java-plugin-sdk.yml deleted file mode 100644 index 3bc5b9921..000000000 --- a/.github/workflows/release-java-plugin-sdk.yml +++ /dev/null @@ -1,81 +0,0 @@ -# Releases the official Java plugin SDK (`sdk/java`) through its own tag series -# (`sdk-java-v`), independent of Lithe app releases. The release holds -# `lithe-plugin-sdk-java-.jar` and its `.sha256`; plugin repositories -# pin both and shade the JAR into their backend at build time. -name: Release Java Plugin SDK - -on: - push: - tags: - - "sdk-java-v*" - -permissions: - contents: read - -concurrency: - group: release-java-plugin-sdk-${{ github.ref }} - cancel-in-progress: false - -jobs: - release: - name: Release ${{ github.ref_name }} - if: github.repository == '1lck/Lithe-IDEA' - runs-on: ubuntu-latest - timeout-minutes: 20 - permissions: - contents: write - - steps: - - name: Check out source - uses: actions/checkout@v7 - - # The SDK supports Java 8: the tests run on a Java 8 runtime, while - # Maven builds with the newer JDK set up last. - - name: Set up Java 8 runtime - id: java8 - uses: actions/setup-java@v5 - with: - distribution: temurin - java-version: '8' - - - name: Set up Java 21 for Maven - uses: actions/setup-java@v5 - with: - distribution: temurin - java-version: '21' - - - name: Require the tag to match the SDK version - id: version - env: - TAG: ${{ github.ref_name }} - run: | - version=$(mvn -B -ntp -q -f sdk/java/plugin-sdk/pom.xml help:evaluate -Dexpression=project.version -DforceStdout) - test "$TAG" = "sdk-java-v$version" || { echo "Tag $TAG does not match sdk/java version $version"; exit 1; } - echo "version=$version" >> "$GITHUB_OUTPUT" - - - name: Build and test on Java 8 - env: - JAVA8: ${{ steps.java8.outputs.path }}/bin/java - run: mvn -B -ntp -f sdk/java/pom.xml verify -Djvm="$JAVA8" - - - name: Collect the release assets - env: - VERSION: ${{ steps.version.outputs.version }} - run: | - mkdir -p dist - asset="lithe-plugin-sdk-java-$VERSION.jar" - cp "sdk/java/plugin-sdk/target/$asset" "dist/$asset" - (cd dist && sha256sum "$asset" | cut -d' ' -f1 > "$asset.sha256") - - - name: Publish the SDK release - env: - GH_TOKEN: ${{ github.token }} - TAG: ${{ github.ref_name }} - VERSION: ${{ steps.version.outputs.version }} - run: | - asset="lithe-plugin-sdk-java-$VERSION.jar" - # --latest=false keeps the app's own release as the repository's latest. - gh release create "$TAG" "dist/$asset" "dist/$asset.sha256" \ - --title "Java plugin SDK $VERSION" \ - --latest=false \ - --notes "Official Java plugin SDK $VERSION for Lithe plugin backends. Requires Java 8 or newer and has no runtime dependencies. Pin the JAR by its SHA-256 (\`$asset.sha256\`) and shade it into the backend JAR; see sdk/java/README.md." diff --git a/.gitignore b/.gitignore index 4c4467b88..0ad31b4d7 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,6 @@ DerivedData/ dist/ .artifacts/ shared/fixtures/projects/**/target/ -sdk/java/**/target/ *.xcuserstate *.xcuserdata/ design-qa-artifacts/ diff --git a/AGENTS.md b/AGENTS.md index a4a30a827..820fc0d57 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,6 +74,27 @@ [LSP 插件构建与语言服务器资源归属](.agents/notes/implemented/architecture/2026-09-30-lsp-plugin-build-and-distribution.md)。 所有 LSP 插件都必须在构建阶段固定来源并校验语言服务器,把完整资源放入插件包后签名;运行时只能从已安装插件发现入口,不能把下载物、解压物或插件状态写入 app bundle、安装目录或跨工作树共享缓存。 +## 插件契约的两仓库同步 + +插件契约在本仓库的 `shared/contracts/plugins/v1/` 定义,和执行它的 Rust Core 一起修改。 +公开仓库 [Lithe-IDEA/lithe-plugin-docs](https://github.com/Lithe-IDEA/lithe-plugin-docs) +的 `contract/v1/` 保存这个目录的逐字节副本,连同开发指南、教程和两个官方插件后端 SDK(Node、Java)的 +源码、发版和端到端测试。插件开发者只看 lithe-plugin-docs 就能开发,不需要克隆本仓库,所以两个仓库的契约 +必须保持一致: + +- 修改 `shared/contracts/plugins/v1/` 下任何文件(README、schema、fixture)的 PR 合并后,在 + lithe-plugin-docs 运行 `node scripts/contract.mjs sync --revision <合并后的 preview commit>` + 并提交。不要在 lithe-plugin-docs 里直接改副本。 +- 契约 README 里指向该目录以外的链接必须写成绝对 URL;`scripts/verify-shared-contracts.sh` + 会拒绝离开该目录的相对链接。 +- 面向插件开发者的新说明写进 lithe-plugin-docs,不写进本仓库或 Lithe Store。 +- 本仓库不保存 SDK。修改插件后端协议(例如 `rust/lithe-core/src/lsp/interface/plugin_function.rs`)时, + 合并前在 lithe-plugin-docs 运行 `node scripts/sdk-e2e.mjs --source <本仓库路径> --ref <分支>`,确认两个 + SDK 的示例后端仍能跑通。 + +决策原因、同步工具和被否方案见 +[插件契约、SDK 与开发者资料的发布](.agents/notes/implemented/architecture/2026-10-23-plugin-contract-publishing.md)。 + ## 跨平台功能同步 跨平台功能矩阵的字段、状态语义、更新流程和 CI 例外规则以 diff --git a/Plugins/README.md b/Plugins/README.md index 2207964e7..31f2329fe 100644 --- a/Plugins/README.md +++ b/Plugins/README.md @@ -8,3 +8,9 @@ Plugin packages are separated by host platform: Keep manifests, implementation source, platform resources, and focused tests inside the owning platform tree. Cross-platform plugin contracts and fixtures belong under the repository-level `shared/` directory. + +Community plugins live in their own repositories and never need a change here. +Their authors start at +[lithe-plugin-docs](https://github.com/Lithe-IDEA/lithe-plugin-docs), which has +the developer guide, tutorials, a pinned copy of the whole plugin contract, and +the official plugin backend SDKs. diff --git a/Plugins/mac/README.md b/Plugins/mac/README.md index 6d96c3730..0f69c20fe 100644 --- a/Plugins/mac/README.md +++ b/Plugins/mac/README.md @@ -65,6 +65,10 @@ key in a protected file or environment variable and never commit it. ## Local development packages +This section is for official plugins built in this repository. Community plugin +authors follow [lithe-plugin-docs](https://github.com/Lithe-IDEA/lithe-plugin-docs) +instead. + Plugin authors can test without the publisher key. Build an unsigned development package with `--development-unsigned`, then install it from disk in a debug build of Lithe: diff --git a/docs/ci-builds.md b/docs/ci-builds.md index 8ac41ae7f..693bd69e8 100644 --- a/docs/ci-builds.md +++ b/docs/ci-builds.md @@ -367,6 +367,9 @@ Cargo 社区插件在 [独立仓库](https://github.com/Lithe-IDEA/lithe-plugin- 不跨 worktree 共享,也不进入发布 ZIP。主仓库保留通用插件契约回归,不下载或组装 Cargo 插件。 插件仓库每次打包校验 `sdk.lock.json` 固定的 SDK 源码提交与文件 SHA-256, SDK 复制只发生在打包阶段,安装目录保持只读。 +官方插件 SDK 已移到 [lithe-plugin-docs](https://github.com/Lithe-IDEA/lithe-plugin-docs): +SDK 的 Maven 构建输出和端到端测试使用的 Lithe 稀疏检出(`.artifacts/lithe`)都属于该仓库, +主仓库不再构建 SDK,也不把这些目录登记为可复用资源。 `Lithe-Dev` 的运行时用户状态不属于可复用构建缓存。macOS 用 `app.lithe.desktop.dev` 隔离偏好和钥匙串,并在系统 Application Support、Caches、 diff --git a/macos/Tests/LitheTests/BuildSystemToolWindowTests.swift b/macos/Tests/LitheTests/BuildSystemToolWindowTests.swift index ef9744c7b..29753befb 100644 --- a/macos/Tests/LitheTests/BuildSystemToolWindowTests.swift +++ b/macos/Tests/LitheTests/BuildSystemToolWindowTests.swift @@ -16,7 +16,7 @@ import Testing struct BuildSystemToolWindowTests { @Test func mavenPresentationFieldsDecodeThroughCore() throws { - let descriptor = try Self.descriptor("maven-build") + let descriptor = try Self.descriptor("build-host-integration") let maven = try #require(descriptor.buildSystems.first) #expect(maven.icon?.host == "tool-window") @@ -31,7 +31,7 @@ struct BuildSystemToolWindowTests { @Test func gradleTemplateCategoryIsTheDefaultOfModelTasks() throws { - let gradle = try #require(try Self.descriptor("gradle-build").buildSystems.first) + let gradle = try #require(try Self.descriptor("build-command-output-file").buildSystems.first) #expect(gradle.option("offline")?.presentation == .toolbar) #expect(gradle.tasks.first { $0.id == "gradle-task" }?.category == ["Tasks", "other"]) @@ -56,7 +56,7 @@ struct BuildSystemToolWindowTests { @Test @MainActor func everyBuildSystemDerivesItsOwnWindowAndToggleCommand() throws { - let descriptor = try Self.descriptor("gradle-build") + let descriptor = try Self.descriptor("build-command-output-file") let module = try #require(descriptor.pluginManifest.modules.first { $0.manifest.id == .buildSystemsExtension(descriptor.id.rawValue) }) @@ -85,7 +85,7 @@ struct BuildSystemToolWindowTests { @Test @MainActor func enabledBuildSystemWindowShowsInAWorkspaceWithoutAnInstance() throws { - let descriptor = try Self.descriptor("gradle-build") + let descriptor = try Self.descriptor("build-command-output-file") let module = try #require(descriptor.pluginManifest.modules.first { $0.manifest.id == .buildSystemsExtension(descriptor.id.rawValue) }) @@ -150,7 +150,7 @@ struct BuildSystemToolWindowTests { @Test @MainActor func restoringBuildWindowActivatesItsOwnerAndPaletteTracksPendingState() async throws { - let descriptor = try Self.descriptor("gradle-build") + let descriptor = try Self.descriptor("build-command-output-file") let declaration = try #require(descriptor.pluginManifest.modules.first { $0.manifest.id == .buildSystemsExtension(descriptor.id.rawValue) }) @@ -182,8 +182,8 @@ struct BuildSystemToolWindowTests { @Test @MainActor func independentBuildWindowsRestoreDockAndVisibilityWithoutLegacyMavenEntries() throws { - let first = try Self.descriptor("maven-build") - let second = try Self.descriptor("gradle-build") + let first = try Self.descriptor("build-host-integration") + let second = try Self.descriptor("build-command-output-file") let declarations = [first, second].flatMap { $0.pluginManifest.modules }.filter { !$0.contributions.isEmpty } let owners = Dictionary(uniqueKeysWithValues: declarations.map { ($0.manifest.id, $0.contributions) }) let windows = declarations.flatMap(\.contributions).filter { $0.kind == .toolWindow } @@ -219,7 +219,7 @@ struct BuildSystemToolWindowTests { let root = FileManager.default.temporaryDirectory.appendingPathComponent("lithe-build-icons-" + UUID().uuidString) try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: root) } - var manifest = try #require(JSONSerialization.jsonObject(with: Self.descriptor("maven-build").normalizedManifest) as? [String: Any]) + var manifest = try #require(JSONSerialization.jsonObject(with: Self.descriptor("build-host-integration").normalizedManifest) as? [String: Any]) var builds = try #require(manifest["buildSystems"] as? [[String: Any]]) let icon: [String: Any] = ["path": "light.svg", "darkPath": "dark.svg", "mode": "original"] builds[0]["icon"] = icon @@ -250,7 +250,7 @@ struct BuildSystemToolWindowTests { @Test @MainActor func optionRootSelectionPreservesEachInstancesChoicesAndEffectiveValue() throws { - let descriptor = try Self.descriptor("maven-build") + let descriptor = try Self.descriptor("build-host-integration") let declaration = try #require(descriptor.buildSystems.first) let source = BuildSystemPluginSource( pluginID: descriptor.id.rawValue, manifest: descriptor.normalizedManifest, diff --git a/macos/Tests/LitheTests/Fixtures/BuildSystems/gradle-build.plugin.json b/macos/Tests/LitheTests/Fixtures/BuildSystems/build-command-output-file.plugin.json similarity index 98% rename from macos/Tests/LitheTests/Fixtures/BuildSystems/gradle-build.plugin.json rename to macos/Tests/LitheTests/Fixtures/BuildSystems/build-command-output-file.plugin.json index eb3c1890a..7b071b95e 100644 --- a/macos/Tests/LitheTests/Fixtures/BuildSystems/gradle-build.plugin.json +++ b/macos/Tests/LitheTests/Fixtures/BuildSystems/build-command-output-file.plugin.json @@ -1,7 +1,7 @@ { "schemaVersion": 2, - "id": "dev.lithe.fixture.gradle-build", - "displayName": "Gradle Build Fixture", + "id": "dev.lithe.fixture.build-command-output-file", + "displayName": "Command Output File Build Fixture", "version": "0.1.0", "pluginAPIVersion": 3, "requires": { diff --git a/macos/Tests/LitheTests/Fixtures/BuildSystems/maven-build.plugin.json b/macos/Tests/LitheTests/Fixtures/BuildSystems/build-host-integration.plugin.json similarity index 98% rename from macos/Tests/LitheTests/Fixtures/BuildSystems/maven-build.plugin.json rename to macos/Tests/LitheTests/Fixtures/BuildSystems/build-host-integration.plugin.json index b14826fa6..be8d48241 100644 --- a/macos/Tests/LitheTests/Fixtures/BuildSystems/maven-build.plugin.json +++ b/macos/Tests/LitheTests/Fixtures/BuildSystems/build-host-integration.plugin.json @@ -1,7 +1,7 @@ { "schemaVersion": 2, - "id": "dev.lithe.fixture.maven-build", - "displayName": "Maven Build Fixture", + "id": "dev.lithe.fixture.build-host-integration", + "displayName": "Host Integration Build Fixture", "version": "0.1.0", "pluginAPIVersion": 3, "requires": { diff --git a/macos/Tests/LitheTests/LanguageExtensionProcessLifecycleTests.swift b/macos/Tests/LitheTests/LanguageExtensionProcessLifecycleTests.swift index 59a57ad0a..49d6cbd96 100644 --- a/macos/Tests/LitheTests/LanguageExtensionProcessLifecycleTests.swift +++ b/macos/Tests/LitheTests/LanguageExtensionProcessLifecycleTests.swift @@ -152,7 +152,7 @@ struct LanguageExtensionProcessLifecycleTests { defer { try? FileManager.default.removeItem(at: root) } let operations = DeclarativePluginTestSupport.operations let descriptor = try operations.validate(manifest: Data(contentsOf: DeclarativePluginTestSupport.repositoryRoot - .appendingPathComponent("shared/contracts/plugins/v1/fixtures/java-support.plugin.json"))) + .appendingPathComponent("shared/contracts/plugins/v1/fixtures/language-host-integration.plugin.json"))) let packageURL = root.appendingPathComponent("package", isDirectory: true) let installed = InstalledPluginPackage( descriptor: descriptor, diff --git a/macos/Tests/LitheTests/MacBuildProviderSessionsTests.swift b/macos/Tests/LitheTests/MacBuildProviderSessionsTests.swift index 3ce46fa91..720c256be 100644 --- a/macos/Tests/LitheTests/MacBuildProviderSessionsTests.swift +++ b/macos/Tests/LitheTests/MacBuildProviderSessionsTests.swift @@ -325,7 +325,7 @@ private final class ProgressReports { /// setting in its arguments. @MainActor private struct ProviderFixture { - static let pluginID = "dev.lithe.fixture.gradle-provider" + static let pluginID = "dev.lithe.fixture.build-provider" static let executablePath = "provider/bin/provider" let root: URL @@ -348,7 +348,7 @@ private struct ProviderFixture { try FileManager.default.createDirectory(at: workspaceURL, withIntermediateDirectories: true) let fixtureURL = DeclarativePluginTestSupport.repositoryRoot - .appendingPathComponent("shared/contracts/plugins/v1/fixtures/gradle-provider.plugin.json") + .appendingPathComponent("shared/contracts/plugins/v1/fixtures/build-provider.plugin.json") var object = try #require( try JSONSerialization.jsonObject(with: Data(contentsOf: fixtureURL)) as? [String: Any] ) diff --git a/macos/Tests/LitheTests/PluginBuildOptionPersistenceTests.swift b/macos/Tests/LitheTests/PluginBuildOptionPersistenceTests.swift index a65ed1705..d2ec248b6 100644 --- a/macos/Tests/LitheTests/PluginBuildOptionPersistenceTests.swift +++ b/macos/Tests/LitheTests/PluginBuildOptionPersistenceTests.swift @@ -146,7 +146,7 @@ struct PluginBuildOptionPersistenceTests { try Data("[package]\nname = \"\(root)\"\nversion = \"0.1.0\"\n[features]\n\(feature) = []\n".utf8) .write(to: directory.appendingPathComponent("Cargo.toml")) } - let fixture = DeclarativePluginTestSupport.repositoryRoot.appendingPathComponent("shared/contracts/plugins/v1/fixtures/cargo-build.plugin.json") + let fixture = DeclarativePluginTestSupport.repositoryRoot.appendingPathComponent("shared/contracts/plugins/v1/fixtures/build-static-file.plugin.json") var manifest = try #require(JSONSerialization.jsonObject(with: Data(contentsOf: fixture)) as? [String: Any]) var systems = try #require(manifest["buildSystems"] as? [[String: Any]]) var options = try #require(systems[0]["options"] as? [[String: Any]]) diff --git a/macos/Tests/LitheTests/PluginDiskInstallationTests.swift b/macos/Tests/LitheTests/PluginDiskInstallationTests.swift index 6177a7ba5..caee8ddc2 100644 --- a/macos/Tests/LitheTests/PluginDiskInstallationTests.swift +++ b/macos/Tests/LitheTests/PluginDiskInstallationTests.swift @@ -73,7 +73,7 @@ struct PluginDiskInstallationTests { signatureRequirement: PluginSignatureRequirement = .unsigned ) throws -> URL { let data = try Data(contentsOf: DeclarativePluginTestSupport.repositoryRoot - .appendingPathComponent("shared/contracts/plugins/v1/fixtures/php-support.plugin.json")) + .appendingPathComponent("shared/contracts/plugins/v1/fixtures/language-packaged-server.plugin.json")) var manifest = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) manifest["id"] = id.rawValue manifest["displayName"] = "Disk Fixture" diff --git a/macos/Tests/LitheTests/PluginLanguageServerLaunchResolverTests.swift b/macos/Tests/LitheTests/PluginLanguageServerLaunchResolverTests.swift index 3e24a3c99..72996b9ad 100644 --- a/macos/Tests/LitheTests/PluginLanguageServerLaunchResolverTests.swift +++ b/macos/Tests/LitheTests/PluginLanguageServerLaunchResolverTests.swift @@ -402,7 +402,7 @@ struct PluginLanguageServerLaunchResolverTests { private func makeFixture( root: URL, - manifest: String = "java-support.plugin.json", + manifest: String = "language-host-integration.plugin.json", languageID: String = "java", settings: any PluginSettingsStoring = InMemoryPluginSettings() ) throws -> Fixture { @@ -458,7 +458,7 @@ struct PluginLanguageServerLaunchResolverTests { } private func javaFixtureManifest() throws -> Data { - try fixtureManifest("java-support.plugin.json") + try fixtureManifest("language-host-integration.plugin.json") } private func fixtureManifest(_ name: String) throws -> Data { diff --git a/macos/Tests/LitheTests/PluginMarketplaceServiceTests.swift b/macos/Tests/LitheTests/PluginMarketplaceServiceTests.swift index 8edbe9af4..32f73872b 100644 --- a/macos/Tests/LitheTests/PluginMarketplaceServiceTests.swift +++ b/macos/Tests/LitheTests/PluginMarketplaceServiceTests.swift @@ -361,7 +361,7 @@ struct PluginMarketplaceServiceTests { sourceKind: .official, platform: "darwin-arm64" ) - let php = try #require(index.plugins.first { $0.id == "dev.lithe.fixture.php-support" }) + let php = try #require(index.plugins.first { $0.id == "dev.lithe.fixture.language-packaged-server" }) #expect(php.latestVersion == "1.1.0") #expect(php.availability.download?.version == "1.0.0") #expect(php.installableVersion?.version == "1.0.0") @@ -374,7 +374,7 @@ struct PluginMarketplaceServiceTests { #expect(future.presentedVersion.version == "3.0.0") } - @Test(arguments: ["gradle-provider", "mix-provider"]) + @Test(arguments: ["build-provider", "build-provider-language-server"]) func buildProvidersAreAcceptedByTheMacOSHost(_ fixture: String) throws { let fixtures = DeclarativePluginTestSupport.repositoryRoot .appendingPathComponent("shared/contracts/plugins/v1/fixtures") @@ -393,7 +393,7 @@ struct PluginMarketplaceServiceTests { func changedRunArgumentsNeedApprovalAgainAfterStorage() throws { let fixtures = DeclarativePluginTestSupport.repositoryRoot .appendingPathComponent("shared/contracts/plugins/v1/fixtures") - let original = try Data(contentsOf: fixtures.appendingPathComponent("php-support.plugin.json")) + let original = try Data(contentsOf: fixtures.appendingPathComponent("language-packaged-server.plugin.json")) var manifest = try #require(JSONSerialization.jsonObject(with: original) as? [String: Any]) var providers = try #require(manifest["executionProviders"] as? [[String: Any]]) var run = try #require(providers[0]["run"] as? [String: Any]) diff --git a/macos/Tests/LitheTests/PluginPackageStoreTests.swift b/macos/Tests/LitheTests/PluginPackageStoreTests.swift index 99e0c3923..6793100f1 100644 --- a/macos/Tests/LitheTests/PluginPackageStoreTests.swift +++ b/macos/Tests/LitheTests/PluginPackageStoreTests.swift @@ -314,7 +314,7 @@ struct PluginPackageStoreTests { try FileManager.default.createSymbolicLink(atPath: executable.path, withDestinationPath: "/bin/sh") } - let pluginID = PluginID("dev.lithe.fixture.gradle-provider") + let pluginID = PluginID("dev.lithe.fixture.build-provider") #expect(throws: PluginPackageStoreError.invalidInstalledPlugin( pluginID, PluginPackageStoreError.buildProviderLaunchMissing( @@ -793,7 +793,7 @@ struct PluginPackageStoreTests { /// `launch` is given. A packaged launch gets an executable script. private func makeProviderPackage(root: URL, launch: [String: Any]?) throws -> URL { let fixtureURL = DeclarativePluginTestSupport.repositoryRoot - .appendingPathComponent("shared/contracts/plugins/v1/fixtures/gradle-provider.plugin.json") + .appendingPathComponent("shared/contracts/plugins/v1/fixtures/build-provider.plugin.json") var manifest = try #require( try JSONSerialization.jsonObject(with: Data(contentsOf: fixtureURL)) as? [String: Any] ) diff --git a/macos/Tests/LitheTests/PluginWebBridgeTests.swift b/macos/Tests/LitheTests/PluginWebBridgeTests.swift index 8979de7e6..02f190c72 100644 --- a/macos/Tests/LitheTests/PluginWebBridgeTests.swift +++ b/macos/Tests/LitheTests/PluginWebBridgeTests.swift @@ -392,7 +392,7 @@ struct PluginWebBridgeTests { @Test func cargoExampleSelectionReachesTheRealCorePlan() async throws { let manifest = try Data(contentsOf: DeclarativePluginTestSupport.repositoryRoot - .appendingPathComponent("shared/contracts/plugins/v1/fixtures/cargo-build.plugin.json")) + .appendingPathComponent("shared/contracts/plugins/v1/fixtures/build-static-file.plugin.json")) let descriptor = try DeclarativePluginTestSupport.operations.validate(manifest: manifest) let source = BuildSystemPluginSource( pluginID: descriptor.id.rawValue, manifest: descriptor.normalizedManifest, diff --git a/macos/Tests/LitheTests/RustBuildSystemOperationsTests.swift b/macos/Tests/LitheTests/RustBuildSystemOperationsTests.swift index 85cd84903..de0ed2713 100644 --- a/macos/Tests/LitheTests/RustBuildSystemOperationsTests.swift +++ b/macos/Tests/LitheTests/RustBuildSystemOperationsTests.swift @@ -12,7 +12,7 @@ struct RustBuildSystemOperationsTests { @Test func descriptorDeclaresBuildSystemsAndTheirModule() throws { - let descriptor = try Self.descriptor("cargo-build") + let descriptor = try Self.descriptor("build-static-file") let cargo = try #require(descriptor.buildSystems.first) #expect(cargo.id == "cargo") @@ -26,7 +26,7 @@ struct RustBuildSystemOperationsTests { #expect(descriptor.pluginManifest.modules.map(\.manifest.id) .contains(.buildSystemsExtension(descriptor.id.rawValue))) - let maven = try #require(Self.descriptor("maven-build").buildSystems.first) + let maven = try #require(Self.descriptor("build-host-integration").buildSystems.first) #expect(maven.modelSource == .hostIntegration) // A host-integration dependency source declares no scopes of its own. #expect(maven.dependencyScopes == [.workspace, .project]) @@ -36,7 +36,7 @@ struct RustBuildSystemOperationsTests { func detectionReadsStaticModelAndResolvesOptions() throws { let workspace = try Self.cargoWorkspace() defer { try? FileManager.default.removeItem(at: workspace) } - let source = try Self.source("cargo-build") + let source = try Self.source("build-static-file") let detection = try operations.detect( workspaceURL: workspace, @@ -70,7 +70,7 @@ struct RustBuildSystemOperationsTests { @Test func invocationAndVersionProbe() throws { - let target = try Self.target("cargo-build", "cargo") + let target = try Self.target("build-static-file", "cargo") let invocation = try operations.invocationPlan(target, root: ".") #expect(invocation.executables == [.command("cargo")]) @@ -82,7 +82,7 @@ struct RustBuildSystemOperationsTests { let old = try operations.checkToolVersion(target, runtime: nil, output: "cargo 1.60.0 (d1fd9fe 2022-03-01)\n") #expect(old.satisfied == false) - let go = try Self.target("go-build", "go") + let go = try Self.target("build-command-stdout", "go") let goInvocation = try operations.invocationPlan(go, root: ".") #expect(goInvocation.executables == [.runtime("go")]) #expect(goInvocation.runtimeDependencies.first?.hostManaged == true) @@ -93,7 +93,7 @@ struct RustBuildSystemOperationsTests { @Test func taskAndTestPlansCarryOptionsAndSelection() throws { - let target = try Self.target("cargo-build", "cargo") + let target = try Self.target("build-static-file", "cargo") let project = BuildModel.Project(id: "cargo:demo", path: "crates/demo", name: "demo") let context = BuildPlanContext(root: "crates/demo", options: ["release": .bool(true)]) @@ -134,7 +134,7 @@ struct RustBuildSystemOperationsTests { /// `refresh` and `tool` fields Core copies into the provider params. @Test func providerPlansCarryTheReloadReasonAndTheResolvedTool() throws { - let target = try Self.target("gradle-provider", "gradle") + let target = try Self.target("build-provider", "gradle") let context = BuildPlanContext(root: ".", options: [:]) let executable = URL(fileURLWithPath: "/opt/tools/gradle/bin/gradle") @@ -159,7 +159,7 @@ struct RustBuildSystemOperationsTests { @Test func commandModelPlansAndDecodesStandardOutput() throws { - let target = try Self.target("go-build", "go") + let target = try Self.target("build-command-stdout", "go") let workspace = URL(fileURLWithPath: "/tmp/lithe-go-workspace") let plan = try operations.modelPlan( @@ -189,7 +189,7 @@ struct RustBuildSystemOperationsTests { func diagnosticsAndSyncFingerprint() throws { let workspace = try Self.cargoWorkspace() defer { try? FileManager.default.removeItem(at: workspace) } - let target = try Self.target("cargo-build", "cargo") + let target = try Self.target("build-static-file", "cargo") let diagnostics = try operations.decodeDiagnostics( target, @@ -223,7 +223,7 @@ struct RustBuildSystemOperationsTests { to: workspace.appendingPathComponent("gradle/wrapper/gradle-wrapper.properties") ) for kind in ["maven", "gradle"] { - var manifest = try #require(JSONSerialization.jsonObject(with: Self.fixtureData("maven-build")) as? [String: Any]) + var manifest = try #require(JSONSerialization.jsonObject(with: Self.fixtureData("build-host-integration")) as? [String: Any]) var systems = try #require(manifest["buildSystems"] as? [[String: Any]]) systems[0]["languageImport"] = kind == "maven" ? ["kind": kind, "options": ["profiles": "profiles", "settingsFile": "settingsFile", "localRepository": "localRepository"]] @@ -251,7 +251,7 @@ struct RustBuildSystemOperationsTests { } } #expect(try operations.languageImport( - Self.target("cargo-build", "cargo"), workspaceURL: workspace, root: ".", options: [:], executableURL: nil + Self.target("build-static-file", "cargo"), workspaceURL: workspace, root: ".", options: [:], executableURL: nil ) == nil) } diff --git a/rust/lithe-core/src/tests/build_runtime_versions.rs b/rust/lithe-core/src/tests/build_runtime_versions.rs index 0c34076a5..d92dd50de 100644 --- a/rust/lithe-core/src/tests/build_runtime_versions.rs +++ b/rust/lithe-core/src/tests/build_runtime_versions.rs @@ -6,7 +6,7 @@ use serde_json::{json, Value}; fn manifest() -> Value { serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../shared/contracts/plugins/v1/fixtures/maven-build.plugin.json" + "/../../shared/contracts/plugins/v1/fixtures/build-host-integration.plugin.json" ))) .expect("Maven fixture is valid JSON") } diff --git a/rust/lithe-core/src/tests/build_systems.rs b/rust/lithe-core/src/tests/build_systems.rs index 33df31c95..f7bc3e17b 100644 --- a/rust/lithe-core/src/tests/build_systems.rs +++ b/rust/lithe-core/src/tests/build_systems.rs @@ -26,22 +26,23 @@ macro_rules! contract_file { }; } -const CARGO_FIXTURE: &str = contract_file!("fixtures/cargo-build.plugin.json"); -const GO_FIXTURE: &str = contract_file!("fixtures/go-build.plugin.json"); -const GRADLE_FIXTURE: &str = contract_file!("fixtures/gradle-build.plugin.json"); -const MAVEN_FIXTURE: &str = contract_file!("fixtures/maven-build.plugin.json"); -const GRADLE_PROVIDER_FIXTURE: &str = contract_file!("fixtures/gradle-provider.plugin.json"); -const MIX_PROVIDER_FIXTURE: &str = contract_file!("fixtures/mix-provider.plugin.json"); +const CARGO_FIXTURE: &str = contract_file!("fixtures/build-static-file.plugin.json"); +const GO_FIXTURE: &str = contract_file!("fixtures/build-command-stdout.plugin.json"); +const GRADLE_FIXTURE: &str = contract_file!("fixtures/build-command-output-file.plugin.json"); +const MAVEN_FIXTURE: &str = contract_file!("fixtures/build-host-integration.plugin.json"); +const GRADLE_PROVIDER_FIXTURE: &str = contract_file!("fixtures/build-provider.plugin.json"); +const MIX_PROVIDER_FIXTURE: &str = + contract_file!("fixtures/build-provider-language-server.plugin.json"); const BUILD_PLANS: &str = contract_file!("fixtures/build-plans.json"); fn fixture(name: &str) -> Value { let source = match name { - "cargo-build.plugin.json" => CARGO_FIXTURE, - "go-build.plugin.json" => GO_FIXTURE, - "gradle-build.plugin.json" => GRADLE_FIXTURE, - "maven-build.plugin.json" => MAVEN_FIXTURE, - "gradle-provider.plugin.json" => GRADLE_PROVIDER_FIXTURE, - "mix-provider.plugin.json" => MIX_PROVIDER_FIXTURE, + "build-static-file.plugin.json" => CARGO_FIXTURE, + "build-command-stdout.plugin.json" => GO_FIXTURE, + "build-command-output-file.plugin.json" => GRADLE_FIXTURE, + "build-host-integration.plugin.json" => MAVEN_FIXTURE, + "build-provider.plugin.json" => GRADLE_PROVIDER_FIXTURE, + "build-provider-language-server.plugin.json" => MIX_PROVIDER_FIXTURE, other => panic!("unknown fixture {other}"), }; serde_json::from_str(source).expect("fixture manifest is JSON") @@ -126,12 +127,12 @@ fn substitute(value: &mut Value, workspace: &str) { #[test] fn build_fixtures_validate() { for name in [ - "cargo-build.plugin.json", - "go-build.plugin.json", - "gradle-build.plugin.json", - "maven-build.plugin.json", - "gradle-provider.plugin.json", - "mix-provider.plugin.json", + "build-static-file.plugin.json", + "build-command-stdout.plugin.json", + "build-command-output-file.plugin.json", + "build-host-integration.plugin.json", + "build-provider.plugin.json", + "build-provider-language-server.plugin.json", ] { let outcome = validate(fixture(name)); assert_eq!(outcome["status"], "ok", "{name}: {outcome}"); @@ -228,7 +229,7 @@ fn cargo_run_entries_match_the_builtin_detector() { ); let detection = detect( &workspace, - vec![fixture("cargo-build.plugin.json")], + vec![fixture("build-static-file.plugin.json")], json!({}), ); let mut planned: Vec = detection["instances"] @@ -316,7 +317,7 @@ fn cargo_implicit_main_survives_test_and_example_targets() { .write("cli/src/bin/cli-bin.rs", "fn main() {}\n"); let detection = detect( &workspace, - vec![fixture("cargo-build.plugin.json")], + vec![fixture("build-static-file.plugin.json")], json!({}), ); let targets = |id: &str| -> Vec<(String, String)> { @@ -376,18 +377,18 @@ fn detection_applies_nesting_priority_and_rejections() { ) .write("target/debug/Cargo.toml", "[package]\nname = \"ignored\"\n"); - let mut preferred = fixture("cargo-build.plugin.json"); + let mut preferred = fixture("build-static-file.plugin.json"); preferred["id"] = json!("dev.lithe.fixture.cargo-preferred"); preferred["buildSystems"][0]["detection"]["priority"] = json!(80); - let mut broken = fixture("go-build.plugin.json"); + let mut broken = fixture("build-command-stdout.plugin.json"); broken["permissions"] = json!([]); let detection = detect( &workspace, vec![ - fixture("cargo-build.plugin.json"), + fixture("build-static-file.plugin.json"), broken, - fixture("gradle-build.plugin.json"), + fixture("build-command-output-file.plugin.json"), preferred, ], json!({}), @@ -408,7 +409,7 @@ fn detection_applies_nesting_priority_and_rejections() { assert_eq!( detection["shadowed"], json!([{ - "pluginID": "dev.lithe.fixture.cargo-build", + "pluginID": "dev.lithe.fixture.build-static-file", "buildSystemID": "cargo", "by": "dev.lithe.fixture.cargo-preferred", }]) @@ -455,7 +456,7 @@ fn maven_method_durations_survive_report_aggregation_and_build_decoding() { ); let decoded = call( "decodeTestResults", - fixture("maven-build.plugin.json"), + fixture("build-host-integration.plugin.json"), "maven", json!({ "workspaceRoot": workspace.root(), "root": ".", "project": ".", "output": "", "notBeforeMillis": 0 @@ -492,7 +493,7 @@ fn maven_reactor_detects_through_the_host_integration() { .write("backend/api/src/main/java/demo/App.java", "class App {}\n"); let detection = detect( &workspace, - vec![fixture("maven-build.plugin.json")], + vec![fixture("build-host-integration.plugin.json")], json!({ "maven": { "profiles": ["dev", "removed"] } }), ); assert_eq!(instance_ids(&detection), ["maven:backend"]); @@ -527,7 +528,7 @@ fn maven_task_plans_match_the_builtin_arguments() { let settings = "/opt/maven/settings.xml"; let outcome = call( "taskPlan", - fixture("maven-build.plugin.json"), + fixture("build-host-integration.plugin.json"), "maven", json!({ "root": ".", @@ -598,7 +599,7 @@ fn gradle_canonical_outputs_are_read_from_the_output_file() { let model = call( "decodeModel", - fixture("gradle-build.plugin.json"), + fixture("build-command-output-file.plugin.json"), "gradle", json!({ "workspaceRoot": workspace.root(), @@ -641,7 +642,7 @@ fn gradle_canonical_outputs_are_read_from_the_output_file() { let dependencies = call( "decodeDependencies", - fixture("gradle-build.plugin.json"), + fixture("build-command-output-file.plugin.json"), "gradle", json!({ "workspaceRoot": workspace.root(), @@ -661,7 +662,7 @@ fn gradle_canonical_outputs_are_read_from_the_output_file() { let missing = call( "decodeModel", - fixture("gradle-build.plugin.json"), + fixture("build-command-output-file.plugin.json"), "gradle", json!({ "workspaceRoot": workspace.root(), "root": "." }), ); @@ -692,7 +693,7 @@ fn junit_reports_become_case_results() { ); let outcome = call( "decodeTestResults", - fixture("gradle-build.plugin.json"), + fixture("build-command-output-file.plugin.json"), "gradle", json!({ "workspaceRoot": workspace.root(), @@ -737,7 +738,7 @@ fn junit_reports_become_case_results() { let later = call( "decodeTestResults", - fixture("gradle-build.plugin.json"), + fixture("build-command-output-file.plugin.json"), "gradle", json!({ "workspaceRoot": workspace.root(), @@ -770,7 +771,7 @@ fn workspace_test_results_cover_every_model_project() { let decode = |request: Value| { call( "decodeTestResults", - fixture("gradle-build.plugin.json"), + fixture("build-command-output-file.plugin.json"), "gradle", request, ) @@ -848,7 +849,7 @@ fn canonical_model_paths_stay_inside_the_instance_root() { workspace.write("model.json", &document.to_string()); let result = call( "decodeModel", - fixture("gradle-build.plugin.json"), + fixture("build-command-output-file.plugin.json"), "gradle", json!({ "workspaceRoot": workspace.root(), "root": root, "outputFile": workspace.0.join("model.json") @@ -881,7 +882,7 @@ fn canonical_model_paths_stay_inside_the_instance_root() { #[test] fn selector_model_paths_stay_inside_the_instance_root() { let workspace = TemporaryWorkspace::new("selector-model-boundary"); - let mut manifest = fixture("gradle-build.plugin.json"); + let mut manifest = fixture("build-command-output-file.plugin.json"); manifest["buildSystems"][0]["model"] = json!({ "source": "command", "arguments": ["model"], "output": "stdout", "decoder": "jsonStream", "executesProjectCode": false, "mayUseNetwork": false, @@ -942,7 +943,7 @@ fn selector_model_paths_stay_inside_the_instance_root() { #[test] fn rejected_selector_paths_do_not_suppress_valid_fallbacks() { let workspace = TemporaryWorkspace::new("selector-model-boundary-fallback"); - let mut manifest = fixture("gradle-build.plugin.json"); + let mut manifest = fixture("build-command-output-file.plugin.json"); manifest["buildSystems"][0]["model"] = json!({ "source": "command", "arguments": ["model"], "output": "stdout", "decoder": "jsonStream", "executesProjectCode": false, "mayUseNetwork": false, @@ -1001,7 +1002,7 @@ fn canonical_content_roots_are_relative_to_the_instance_root() { ); let outcome = call( "decodeModel", - fixture("gradle-build.plugin.json"), + fixture("build-command-output-file.plugin.json"), "gradle", json!({ "workspaceRoot": workspace.root(), @@ -1042,7 +1043,7 @@ fn sync_fingerprint_follows_watched_files() { let fingerprint = || { let outcome = call( "syncFingerprint", - fixture("cargo-build.plugin.json"), + fixture("build-static-file.plugin.json"), "cargo", json!({ "workspaceRoot": workspace.root(), "root": ".", "projects": ["."] }), ); @@ -1070,7 +1071,7 @@ fn sync_fingerprint_follows_watched_files() { fn resolve_options_describe_explicit_empty_text_values() { let outcome = call( "resolveOptions", - fixture("go-build.plugin.json"), + fixture("build-command-stdout.plugin.json"), "go", json!({ "options": { "tags": "" } }), ); @@ -1087,7 +1088,7 @@ fn resolve_options_describe_explicit_empty_text_values() { #[test] fn option_arguments_may_repeat_under_opposite_conditions() { - let mut manifest = fixture("cargo-build.plugin.json"); + let mut manifest = fixture("build-static-file.plugin.json"); manifest["buildSystems"][0]["test"]["arguments"] = json!([ "test", { "value": "${optionArguments}", "when": "targetSelected" }, @@ -1117,7 +1118,7 @@ fn provider_processes_are_install_review_programs() { assert_eq!(outcome["status"], "ok", "{outcome}"); outcome["review"]["programs"].as_array().unwrap().clone() }; - let own = programs("gradle-provider.plugin.json"); + let own = programs("build-provider.plugin.json"); let provider = own .iter() .find(|program| program["kind"] == "runtimeBuildProvider") @@ -1131,7 +1132,7 @@ fn provider_processes_are_install_review_programs() { assert_eq!(provider["executesProjectCode"], true); assert!(own.contains(&json!({ "kind": "runtime", "tool": "java", "minimumVersion": "17" }))); - let shared = programs("mix-provider.plugin.json"); + let shared = programs("build-provider-language-server.plugin.json"); let build_system = shared .iter() .find(|program| program["kind"] == "buildSystem") @@ -1158,7 +1159,7 @@ fn cargo_run_entries_drop_features_removed_from_the_model() { "[package]\nname = \"app\"\nversion = \"0.1.0\"\n[features]\ncli = []\n", ) .write("src/main.rs", "fn main() {}\n"); - let manifest = fixture("cargo-build.plugin.json"); + let manifest = fixture("build-static-file.plugin.json"); let options = json!({ "features": ["cli", "removed"] }); let detection = detect( &workspace, @@ -1192,7 +1193,7 @@ fn cargo_run_entries_drop_features_removed_from_the_model() { /// A changed default can select a different script without changing its argv template. #[test] fn build_option_defaults_require_install_review_again() { - let mut manifest = fixture("gradle-build.plugin.json"); + let mut manifest = fixture("build-command-output-file.plugin.json"); manifest["buildSystems"][0]["options"] .as_array_mut() .unwrap() @@ -1238,7 +1239,7 @@ fn static_models_and_sync_fingerprints_reject_escaping_symlinks() { let outside = TemporaryWorkspace::new("build-external-files"); outside.write("Cargo.toml", "[package]\nname = \"private\"\n"); symlink(outside.0.join("Cargo.toml"), workspace.0.join("Cargo.toml")).unwrap(); - let manifest = fixture("cargo-build.plugin.json"); + let manifest = fixture("build-static-file.plugin.json"); let decoded = call( "decodeModel", manifest.clone(), @@ -1299,7 +1300,7 @@ fn maven_models_do_not_read_poms_outside_the_workspace() { let outside = TemporaryWorkspace::new("build-external-maven"); outside.write("pom.xml", "4.0.0private-artifact9.9"); symlink(outside.0.join("pom.xml"), workspace.0.join("pom.xml")).unwrap(); - let manifest = fixture("maven-build.plugin.json"); + let manifest = fixture("build-host-integration.plugin.json"); let decoded = call( "decodeModel", manifest.clone(), @@ -1340,7 +1341,7 @@ fn junit_reports_do_not_follow_escaping_project_directories() { symlink(outside.0.join("app"), workspace.0.join("app")).unwrap(); let outcome = call( "decodeTestResults", - fixture("gradle-build.plugin.json"), + fixture("build-command-output-file.plugin.json"), "gradle", json!({ "workspaceRoot": workspace.root(), "root": ".", "project": "app", "output": "", @@ -1368,7 +1369,7 @@ fn reported_output_paths_reject_escaping_symlinks() { symlink(workspace.0.join("src"), workspace.0.join("alias")).unwrap(); symlink(workspace.0.join("src"), workspace.0.join("app/alias")).unwrap(); - let manifest = fixture("gradle-build.plugin.json"); + let manifest = fixture("build-command-output-file.plugin.json"); let diagnostics = |manifest: &Value, output: &str| { call( "decodeDiagnostics", @@ -1467,8 +1468,7 @@ fn launch_plans_name_exactly_one_process() { json!({}), json!({ "languageID": "elixir", "buildSystemID": "mix" }), ] { - let mut payload = - json!({ "manifest": fixture("mix-provider.plugin.json"), "request": request }); + let mut payload = json!({ "manifest": fixture("build-provider-language-server.plugin.json"), "request": request }); payload .as_object_mut() .unwrap() @@ -1489,7 +1489,7 @@ fn provider_models_are_not_read_during_detection() { workspace.write("mix.exs", "").write("apps/web/mix.exs", ""); let detection = detect( &workspace, - vec![fixture("mix-provider.plugin.json")], + vec![fixture("build-provider-language-server.plugin.json")], json!({}), ); assert_eq!(instance_ids(&detection), ["mix:."]); @@ -1504,7 +1504,7 @@ fn provider_models_are_not_read_during_detection() { #[test] fn task_categories_and_option_placements_are_display_only() { let workspace = TemporaryWorkspace::new("build-task-category"); - let mut gradle = fixture("gradle-build.plugin.json"); + let mut gradle = fixture("build-command-output-file.plugin.json"); let build_system = &mut gradle["buildSystems"][0]; build_system["icon"] = json!({ "path": "icons/gradle.svg" }); build_system["tasks"][0]["category"] = json!(["Tasks", "other"]); diff --git a/rust/lithe-core/src/tests/declarative_plugins.rs b/rust/lithe-core/src/tests/declarative_plugins.rs index 230b408bf..371ebeda0 100644 --- a/rust/lithe-core/src/tests/declarative_plugins.rs +++ b/rust/lithe-core/src/tests/declarative_plugins.rs @@ -15,18 +15,19 @@ macro_rules! contract_file { }; } -const PHP_FIXTURE: &str = contract_file!("fixtures/php-support.plugin.json"); +const PHP_FIXTURE: &str = contract_file!("fixtures/language-packaged-server.plugin.json"); const HELLO_FIXTURE: &str = contract_file!("fixtures/hello-lithe.plugin.json"); const WEB_PANEL_FIXTURE: &str = contract_file!("fixtures/web-panel.plugin.json"); -const GO_FIXTURE: &str = contract_file!("fixtures/go-support.plugin.json"); -const JAVA_FIXTURE: &str = contract_file!("fixtures/java-support.plugin.json"); +const GO_FIXTURE: &str = contract_file!("fixtures/language-system-command.plugin.json"); +const JAVA_FIXTURE: &str = contract_file!("fixtures/language-host-integration.plugin.json"); const NODE_RUNTIME_FIXTURE: &str = contract_file!("fixtures/node-runtime.plugin.json"); -const CARGO_BUILD_FIXTURE: &str = contract_file!("fixtures/cargo-build.plugin.json"); -const GO_BUILD_FIXTURE: &str = contract_file!("fixtures/go-build.plugin.json"); -const GRADLE_BUILD_FIXTURE: &str = contract_file!("fixtures/gradle-build.plugin.json"); -const MAVEN_BUILD_FIXTURE: &str = contract_file!("fixtures/maven-build.plugin.json"); -const GRADLE_PROVIDER_FIXTURE: &str = contract_file!("fixtures/gradle-provider.plugin.json"); -const MIX_PROVIDER_FIXTURE: &str = contract_file!("fixtures/mix-provider.plugin.json"); +const CARGO_BUILD_FIXTURE: &str = contract_file!("fixtures/build-static-file.plugin.json"); +const GO_BUILD_FIXTURE: &str = contract_file!("fixtures/build-command-stdout.plugin.json"); +const GRADLE_BUILD_FIXTURE: &str = contract_file!("fixtures/build-command-output-file.plugin.json"); +const MAVEN_BUILD_FIXTURE: &str = contract_file!("fixtures/build-host-integration.plugin.json"); +const GRADLE_PROVIDER_FIXTURE: &str = contract_file!("fixtures/build-provider.plugin.json"); +const MIX_PROVIDER_FIXTURE: &str = + contract_file!("fixtures/build-provider-language-server.plugin.json"); const SETTINGS_V2_FIXTURE: &str = contract_file!("fixtures/settings-v2.plugin.json"); const INVALID_MANIFESTS: &str = contract_file!("fixtures/invalid-manifests.json"); const COMMAND_PLANS: &str = contract_file!("fixtures/command-plans.json"); @@ -38,18 +39,18 @@ const OFFICIAL_JAVA: &str = include_str!(concat!( fn fixture(name: &str) -> Value { let source = match name { - "php-support.plugin.json" => PHP_FIXTURE, - "go-support.plugin.json" => GO_FIXTURE, + "language-packaged-server.plugin.json" => PHP_FIXTURE, + "language-system-command.plugin.json" => GO_FIXTURE, "hello-lithe.plugin.json" => HELLO_FIXTURE, "web-panel.plugin.json" => WEB_PANEL_FIXTURE, - "java-support.plugin.json" => JAVA_FIXTURE, + "language-host-integration.plugin.json" => JAVA_FIXTURE, "node-runtime.plugin.json" => NODE_RUNTIME_FIXTURE, - "cargo-build.plugin.json" => CARGO_BUILD_FIXTURE, - "go-build.plugin.json" => GO_BUILD_FIXTURE, - "gradle-build.plugin.json" => GRADLE_BUILD_FIXTURE, - "maven-build.plugin.json" => MAVEN_BUILD_FIXTURE, - "gradle-provider.plugin.json" => GRADLE_PROVIDER_FIXTURE, - "mix-provider.plugin.json" => MIX_PROVIDER_FIXTURE, + "build-static-file.plugin.json" => CARGO_BUILD_FIXTURE, + "build-command-stdout.plugin.json" => GO_BUILD_FIXTURE, + "build-command-output-file.plugin.json" => GRADLE_BUILD_FIXTURE, + "build-host-integration.plugin.json" => MAVEN_BUILD_FIXTURE, + "build-provider.plugin.json" => GRADLE_PROVIDER_FIXTURE, + "build-provider-language-server.plugin.json" => MIX_PROVIDER_FIXTURE, "settings-v2.plugin.json" => SETTINGS_V2_FIXTURE, other => panic!("unknown fixture {other}"), }; @@ -78,7 +79,7 @@ fn contract_fixtures_and_official_plugins_validate() { #[test] fn validation_normalizes_order_and_spelling_without_touching_arguments() { - let mut manifest = fixture("php-support.plugin.json"); + let mut manifest = fixture("language-packaged-server.plugin.json"); manifest["languages"][0]["fileExtensions"] = json!(["PHTML", ".php", "php"]); manifest["permissions"] = json!(["process.test", "process.run", "languageServer.launch"]); let outcome = validate(manifest); @@ -103,7 +104,7 @@ fn validation_normalizes_order_and_spelling_without_touching_arguments() { /// `.toolrc` keep matching after normalization. #[test] fn validation_keeps_the_leading_dot_of_hidden_file_names() { - let mut manifest = fixture("go-support.plugin.json"); + let mut manifest = fixture("language-system-command.plugin.json"); manifest["languages"][0]["fileExtensions"] = json!([".go"]); manifest["languages"][0]["fileNames"] = json!([".toolrc"]); manifest["languages"][0]["projectFileNames"] = json!(["go.mod", ".env.example"]); @@ -149,7 +150,7 @@ fn invalid_manifest_fixtures_report_their_stable_codes() { /// feature that integration has, with its files, and with no second owner. #[test] fn host_integration_capabilities_are_normalized_and_checked_against_the_integration() { - let mut manifest = fixture("java-support.plugin.json"); + let mut manifest = fixture("language-host-integration.plugin.json"); let capabilities = "/languageServers/0/hostIntegration/capabilities"; *manifest.pointer_mut(capabilities).unwrap() = json!(["testing", "debug", "testing"]); let outcome = validate(manifest.clone()); @@ -178,12 +179,12 @@ fn host_integration_capabilities_are_normalized_and_checked_against_the_integrat }; let field = "languageServers[0].hostIntegration.capabilities"; - let mut unknown = fixture("java-support.plugin.json"); + let mut unknown = fixture("language-host-integration.plugin.json"); *unknown.pointer_mut(capabilities).unwrap() = json!(["formatting"]); assert_eq!(rejected_field(unknown), field); // Debugging loads the debugger as a JDT LS extension bundle. - let mut without_bundles = fixture("java-support.plugin.json"); + let mut without_bundles = fixture("language-host-integration.plugin.json"); *without_bundles.pointer_mut(capabilities).unwrap() = json!(["debug"]); let resources = "/languageServers/0/hostIntegration/resources"; let resources = without_bundles.pointer_mut(resources).unwrap(); @@ -193,7 +194,7 @@ fn host_integration_capabilities_are_normalized_and_checked_against_the_integrat .remove("extensionBundleDirectories"); assert_eq!(rejected_field(without_bundles), field); - let mut owned_twice = fixture("java-support.plugin.json"); + let mut owned_twice = fixture("language-host-integration.plugin.json"); owned_twice["permissions"] = json!(["languageServer.launch", "process.run"]); owned_twice["activationEvents"] = json!(["onLanguage:java", "onRun:java"]); owned_twice["executionProviders"] = json!([{ @@ -248,7 +249,7 @@ fn command_plan_fixtures_match_golden_expansions() { /// would change the command line of an installed plugin. #[test] fn api_level_1_language_server_arguments_stay_literal() { - let mut manifest = fixture("php-support.plugin.json"); + let mut manifest = fixture("language-packaged-server.plugin.json"); manifest["languageServers"][0]["arguments"] = json!(["--stdio", "--log=${pluginDir}"]); assert_eq!(validate(manifest.clone())["status"], "ok"); let outcome = language_server_launch_plan_command(json!({ @@ -274,7 +275,7 @@ fn api_level_1_language_server_arguments_stay_literal() { #[test] fn runtime_launch_and_initialization_options_are_reviewed_programs() { let outcome = crate::plugins::marketplace::install_review_command(json!({ - "manifest": fixture("java-support.plugin.json"), + "manifest": fixture("language-host-integration.plugin.json"), })) .expect("payload is well formed"); let programs = outcome["review"]["programs"].as_array().unwrap(); @@ -327,7 +328,7 @@ fn runtime_version_fixtures_match_golden_outcomes() { /// so it cannot depend on one. #[test] fn validation_probe_cannot_use_settings() { - let mut manifest = fixture("go-support.plugin.json"); + let mut manifest = fixture("language-system-command.plugin.json"); manifest["pluginAPIVersion"] = json!(2); manifest["settings"] = json!([ { "id": "verbose", "type": "boolean", "title": "Verbose", "default": false } @@ -370,14 +371,14 @@ fn level_3_manifests_list_exactly_the_capabilities_they_use() { outcome["error"]["field"].as_str().unwrap().to_string(), ) }; - let mix = fixture("mix-provider.plugin.json"); + let mix = fixture("build-provider-language-server.plugin.json"); let outcome = validate(mix.clone()); assert_eq!(outcome["status"], "ok", "{outcome}"); assert_eq!( outcome["plugin"]["requires"], json!({ "buildProviders": 1, "buildSystems": 1, "languageServers": 1, "languages": 1, "serverProcess": 1 }) ); - let legacy = validate(fixture("php-support.plugin.json")); + let legacy = validate(fixture("language-packaged-server.plugin.json")); assert!(legacy["plugin"].get("requires").is_none(), "{legacy}"); let mut future = mix.clone(); @@ -404,7 +405,7 @@ fn level_3_manifests_list_exactly_the_capabilities_they_use() { ); // A level 3 plugin without build systems never depends on them. - let mut language_only = fixture("php-support.plugin.json"); + let mut language_only = fixture("language-packaged-server.plugin.json"); language_only["pluginAPIVersion"] = json!(3); language_only["requires"] = json!({ "executionProviders": 1, "languageServers": 1, "languages": 1, "serverProcess": 1 @@ -433,7 +434,7 @@ fn level_3_manifests_list_exactly_the_capabilities_they_use() { assert_eq!(validate_on(&mix, &host_with_builds)["status"], "ok"); // Capability versions do not apply below level 3. assert_eq!( - validate_on(&fixture("php-support.plugin.json"), &json!({}))["status"], + validate_on(&fixture("language-packaged-server.plugin.json"), &json!({}))["status"], "ok" ); } @@ -447,14 +448,14 @@ fn build_providers_require_their_own_host_capability() { "serverProcess": [1], "settings": [1] }); let template = validate_command(json!({ - "manifest": fixture("cargo-build.plugin.json"), "hostCapabilities": host + "manifest": fixture("build-static-file.plugin.json"), "hostCapabilities": host })) .unwrap(); assert_eq!(template["status"], "ok", "{template}"); for (name, build_system_id) in [ - ("gradle-provider.plugin.json", "gradle"), - ("mix-provider.plugin.json", "mix"), + ("build-provider.plugin.json", "gradle"), + ("build-provider-language-server.plugin.json", "mix"), ] { let manifest = fixture(name); for versions in [None, Some(json!([2])), Some(json!([1]))] { diff --git a/rust/lithe-core/src/tests/marketplace_plugins.rs b/rust/lithe-core/src/tests/marketplace_plugins.rs index 170b1b29a..38ec2e802 100644 --- a/rust/lithe-core/src/tests/marketplace_plugins.rs +++ b/rust/lithe-core/src/tests/marketplace_plugins.rs @@ -18,11 +18,11 @@ const INDEX_CASES: &str = contract_file!("fixtures/marketplace/index-cases.json" const INVALID_ENTRIES: &str = contract_file!("fixtures/marketplace/invalid-index-entries.json"); const SOURCE_URLS: &str = contract_file!("fixtures/marketplace/source-urls.json"); const INSTALL_REVIEWS: &str = contract_file!("fixtures/marketplace/install-reviews.json"); -const PHP_FIXTURE: &str = contract_file!("fixtures/php-support.plugin.json"); -const GO_FIXTURE: &str = contract_file!("fixtures/go-support.plugin.json"); -const MIX_FIXTURE: &str = contract_file!("fixtures/mix-provider.plugin.json"); -const GRADLE_FIXTURE: &str = contract_file!("fixtures/gradle-provider.plugin.json"); -const CARGO_FIXTURE: &str = contract_file!("fixtures/cargo-build.plugin.json"); +const PHP_FIXTURE: &str = contract_file!("fixtures/language-packaged-server.plugin.json"); +const GO_FIXTURE: &str = contract_file!("fixtures/language-system-command.plugin.json"); +const MIX_FIXTURE: &str = contract_file!("fixtures/build-provider-language-server.plugin.json"); +const GRADLE_FIXTURE: &str = contract_file!("fixtures/build-provider.plugin.json"); +const CARGO_FIXTURE: &str = contract_file!("fixtures/build-static-file.plugin.json"); const OFFICIAL_INDEX: &str = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), "/../../Plugins/marketplace/index.json" @@ -34,11 +34,11 @@ fn json_file(source: &str) -> Value { fn manifest_fixture(name: &str) -> Value { match name { - "php-support.plugin.json" => json_file(PHP_FIXTURE), - "go-support.plugin.json" => json_file(GO_FIXTURE), - "mix-provider.plugin.json" => json_file(MIX_FIXTURE), - "gradle-provider.plugin.json" => json_file(GRADLE_FIXTURE), - "cargo-build.plugin.json" => json_file(CARGO_FIXTURE), + "language-packaged-server.plugin.json" => json_file(PHP_FIXTURE), + "language-system-command.plugin.json" => json_file(GO_FIXTURE), + "build-provider-language-server.plugin.json" => json_file(MIX_FIXTURE), + "build-provider.plugin.json" => json_file(GRADLE_FIXTURE), + "build-static-file.plugin.json" => json_file(CARGO_FIXTURE), other => panic!("unknown fixture {other}"), } } diff --git a/rust/lithe-core/tests/plugin_sdk/mod.rs b/rust/lithe-core/tests/plugin_sdk/mod.rs deleted file mode 100644 index cad0fd45c..000000000 --- a/rust/lithe-core/tests/plugin_sdk/mod.rs +++ /dev/null @@ -1,550 +0,0 @@ -//! End-to-end scenario shared by the official plugin SDKs (`sdk/node`, -//! `sdk/java`), run against Rust Core through the same JSON commands a host -//! sends. -//! -//! Each SDK's sample backend is launched exactly as a host launches a build -//! provider: Core validates its manifest and expands the launch plan, the -//! runtime version is checked, and `lsp.startServer` starts it. The scenario -//! then drives the typed build requests, a multi-step plugin function with -//! progress, a plugin failure, cancellation of a running tool, runtime -//! registration, and state recovery across a restart. Both samples answer it -//! identically, which keeps the SDKs' behaviour aligned. - -// Each test binary includes this module and uses only the helpers its SDK needs. -#![allow(dead_code)] - -use lithe_core::execute_json; -use serde_json::{json, Value}; -use std::collections::BTreeMap; -use std::fs; -use std::path::{Path, PathBuf}; -use std::process::Command; -use std::time::{Duration, Instant}; - -const BUILD_SYSTEM: &str = "sample"; -/// Bound for any single wait; a broken backend fails here instead of in CI. -const WAIT: Duration = Duration::from_secs(20); - -fn core(command: &str, payload: Value) -> Value { - let request = json!({ "id": format!("sdk-{command}"), "command": command, "payload": payload }); - let response: Value = serde_json::from_str(&execute_json(&request.to_string())) - .expect("Core response should be JSON"); - assert_eq!(response["ok"], true, "{command} failed: {response}"); - response["data"].clone() -} - -fn repository_root() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../..") - .canonicalize() - .expect("repository root") -} - -/// Every file of a directory with its contents, to prove the package is unchanged. -fn listing(root: &Path) -> BTreeMap> { - let mut files = BTreeMap::new(); - let mut pending = vec![root.to_path_buf()]; - while let Some(directory) = pending.pop() { - for entry in fs::read_dir(&directory).expect("readable directory") { - let path = entry.expect("directory entry").path(); - if path.is_dir() { - pending.push(path); - } else { - let contents = fs::read(&path).expect("readable file"); - files.insert(path.strip_prefix(root).unwrap().to_path_buf(), contents); - } - } - } - files -} - -/// Temporary workspace and host state directories, removed even when an -/// assertion fails. -struct Fixture { - root: PathBuf, -} - -impl Fixture { - fn new(label: &str) -> Self { - let root = - std::env::temp_dir().join(format!("lithe-plugin-sdk-{label}-{}", std::process::id())); - let _ = fs::remove_dir_all(&root); - fs::create_dir_all(&root).expect("fixture directory"); - // The backend sees canonical paths (macOS /var is /private/var). - Self { - root: root.canonicalize().expect("fixture path"), - } - } - - fn workspace(&self) -> PathBuf { - self.root.join("workspace") - } -} - -impl Drop for Fixture { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.root); - } -} - -/// One running backend session. Dropping it stops the process and removes -/// the session, so a failed assertion leaves no backend behind. -struct Session { - id: String, - plugin_id: String, - events: Vec, -} - -impl Session { - fn start(plugin_id: &str, executable: &str, arguments: &[Value], workspace: &Path) -> Self { - let started = core( - "lsp.startServer", - json!({ - "providerId": format!("buildProvider:{plugin_id}:{BUILD_SYSTEM}"), - "pluginId": plugin_id, - "executablePath": executable, - "arguments": arguments, - // Directory URI with a trailing slash, as the macOS host sends it. - "rootUri": format!("file://{}/", workspace.display()), - "workingDirectory": workspace, - "initializeTimeoutMilliseconds": WAIT.as_millis() as u64, - "requestTimeoutMilliseconds": WAIT.as_millis() as u64, - "shutdownTimeoutMilliseconds": 5000, - }), - ); - let mut session = Self { - id: started["sessionId"] - .as_str() - .expect("session ID") - .to_string(), - plugin_id: plugin_id.to_string(), - events: Vec::new(), - }; - session.await_event("the session to become ready", |event| { - event["type"] == "stateChanged" && event["state"] == "ready" - }); - session - } - - /// Waits for the first event, already received or new, that `matches`. - fn await_event(&mut self, description: &str, matches: impl Fn(&Value) -> bool) -> Value { - let deadline = Instant::now() + WAIT; - loop { - if let Some(event) = self.events.iter().find(|event| matches(event)) { - return event.clone(); - } - let remaining = deadline.saturating_duration_since(Instant::now()); - assert!( - !remaining.is_zero(), - "timed out waiting for {description}; events: {:#?}", - self.events - ); - let drained = core( - "lsp.waitEvents", - json!({ "sessionId": self.id, "timeoutMilliseconds": remaining.as_millis().min(1000) as u64 }), - ); - self.events - .extend(drained["events"].as_array().cloned().unwrap_or_default()); - } - } - - fn completion(&mut self, operation: &str) -> Value { - self.await_event(&format!("operation {operation} to end"), |event| { - // Every terminal outcome, cancellation included, is one requestCompleted. - event["operationId"] == operation && event["type"] == "requestCompleted" - }) - } - - fn invoke(&mut self, operation: &str, function: &str, params: Value) { - core( - "lsp.functionRequest", - json!({ - "sessionId": self.id, - "operationId": operation, - "function": { "pluginID": self.plugin_id, "functionID": function }, - "params": params, - }), - ); - } - - fn call(&mut self, operation: &str, function: &str, params: Value) -> Value { - self.invoke(operation, function, params); - self.completion(operation) - } - - /// The registry after the latest `functionsChanged` event. - fn functions(&mut self) -> Vec { - let latest = self - .events - .iter() - .rev() - .find(|event| event["type"] == "functionsChanged") - .expect("a functionsChanged event"); - serde_json::from_value(latest["result"]["functions"].clone()).expect("function IDs") - } - - fn build_request(&mut self, operation: &str, plan: &Value) -> Value { - core( - "lsp.buildRequest", - json!({ - "sessionId": self.id, - "operationId": operation, - "method": plan["provider"]["method"], - "params": plan["provider"]["params"], - }), - ); - let completed = self.completion(operation); - assert!(completed.get("error").is_none(), "{completed}"); - completed["result"]["value"].clone() - } - - fn stop(&mut self) { - core("lsp.stopServer", json!({ "sessionId": self.id })); - self.await_event("the backend to stop", |event| { - event["type"] == "stateChanged" && event["state"] == "stopped" - }); - } -} - -impl Drop for Session { - fn drop(&mut self) { - let request = json!({ "id": "sdk-cleanup", "command": "lsp.stopServer", "payload": { "sessionId": self.id } }); - let _ = execute_json(&request.to_string()); - let deadline = Instant::now() + Duration::from_secs(10); - while Instant::now() < deadline { - let destroy = json!({ "id": "sdk-cleanup", "command": "lsp.destroyServer", "payload": { "sessionId": self.id } }); - let response: Value = - serde_json::from_str(&execute_json(&destroy.to_string())).unwrap_or(Value::Null); - if response["ok"] == true { - return; - } - let wait = json!({ "id": "sdk-cleanup", "command": "lsp.waitEvents", "payload": { "sessionId": self.id, "timeoutMilliseconds": 200 } }); - let _ = execute_json(&wait.to_string()); - } - } -} - -#[cfg(unix)] -fn is_alive(pid: u32) -> bool { - Command::new("kill") - .args(["-0", &pid.to_string()]) - .status() - .is_ok_and(|status| status.success()) -} - -/// One SDK's sample backend, packaged as a host installs it. -pub struct Sample { - /// `launch.tool` of the sample's provider: `node` or `java`. - pub tool: &'static str, - /// The runtime executable the host would resolve for that tool. - pub runtime: String, - /// The installed plugin package: `plugin.json` and the launch entry. - pub package: PathBuf, - /// Node.js, which runs the sample workspace's build tools for both SDKs. - pub node: String, -} - -/// Node.js from `LITHE_PLUGIN_SDK_NODE`, or `None` with a skip notice. -pub fn node_from_environment(test: &str) -> Option { - let Some(node) = std::env::var_os("LITHE_PLUGIN_SDK_NODE") else { - eprintln!("skipped: set LITHE_PLUGIN_SDK_NODE to run {test}"); - return None; - }; - Some(node.into_string().expect("UTF-8 Node.js path")) -} - -pub fn repository_path(relative: &str) -> PathBuf { - repository_root().join(relative) -} - -/// Copies `files` (package-relative destination, source) into a fresh package -/// directory under the system temporary directory, removed on drop. -pub struct TemporaryPackage { - pub root: PathBuf, -} - -impl TemporaryPackage { - pub fn new(label: &str, files: &[(&str, PathBuf)]) -> Self { - let root = std::env::temp_dir().join(format!( - "lithe-plugin-sdk-package-{label}-{}", - std::process::id() - )); - let _ = fs::remove_dir_all(&root); - for (destination, source) in files { - let target = root.join(destination); - fs::create_dir_all(target.parent().expect("package file parent")) - .expect("package directory"); - fs::copy(source, &target) - .unwrap_or_else(|error| panic!("copy {}: {error}", source.display())); - } - Self { - root: root.canonicalize().expect("package path"), - } - } -} - -impl Drop for TemporaryPackage { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.root); - } -} - -/// Runs the shared scenario against `sample`. -pub fn run_sample_scenario(sample: &Sample) { - let package = &sample.package; - let node = &sample.node; - let runtime = &sample.runtime; - let manifest: Value = - serde_json::from_slice(&fs::read(package.join("plugin.json")).expect("sample manifest")) - .expect("sample manifest JSON"); - let plugin_id = manifest["id"].as_str().expect("plugin ID").to_string(); - let package_before = listing(&package); - - // The manifest passes the same validation as an installed plugin. - let validated = core( - "plugins.declarative.validate", - json!({ "manifest": manifest }), - ); - assert_eq!(validated["status"], "ok", "{validated}"); - - let fixture = Fixture::new(sample.tool); - let workspace = fixture.workspace(); - for project in ["app", "broken", "slow"] { - fs::create_dir_all(workspace.join(project)).expect("project directory"); - } - let slow_pid = workspace.join("slow/slow.pid"); - fs::write( - workspace.join("lithe-sample.json"), - json!({ - "projects": [ - { - "name": "app", "path": "app", - "build": [node, "-e", "console.log('built app')"], - "dependencies": [{ "name": "left-pad", "version": "1.3.0" }] - }, - { "name": "broken", "path": "broken", "build": [node, "-e", "process.exit(2)"] }, - { - "name": "slow", "path": "slow", - "build": [node, "-e", "require('fs').writeFileSync('slow.pid', String(process.pid)); setInterval(() => {}, 1000)"] - } - ] - }) - .to_string(), - ) - .expect("workspace file"); - - // The host expands the launch plan and checks the runtime it found. - let plan = core( - "plugins.declarative.languageServerLaunchPlan", - json!({ - "manifest": manifest, - "buildSystemID": BUILD_SYSTEM, - "request": { - "pluginDirectory": package, - "stateDirectory": fixture.root.join("state"), - "workspaceStateDirectory": fixture.root.join("workspace-state"), - "architecture": if cfg!(target_arch = "aarch64") { "arm64" } else { "x86_64" }, - } - }), - ); - assert_eq!(plan["status"], "ok", "{plan}"); - let plan = &plan["plan"]; - assert_eq!(plan["launch"]["tool"], sample.tool); - let version = Command::new(runtime) - .args( - plan["launch"]["versionArguments"] - .as_array() - .expect("version probe") - .iter() - .map(|argument| argument.as_str().unwrap()), - ) - .output() - .expect("runtime version probe"); - // Hosts capture stderr with stdout: `java -version` prints to stderr. - let output = format!( - "{}{}", - String::from_utf8_lossy(&version.stdout), - String::from_utf8_lossy(&version.stderr) - ); - let check = core( - "plugins.declarative.checkRuntimeVersion", - json!({ - "tool": sample.tool, - "minimumVersion": plan["launch"]["minimumVersion"], - "output": output, - }), - ); - assert_eq!(check["check"]["satisfied"], true, "{check}"); - for directory in plan["requiredDirectories"].as_array().unwrap() { - let path = match directory.as_str() { - Some("state") => fixture.root.join("state"), - _ => fixture.root.join("workspace-state"), - }; - fs::create_dir_all(path).expect("host state directory"); - } - let arguments = plan["arguments"].as_array().unwrap().clone(); - - let mut session = Session::start(&plugin_id, runtime, &arguments, &workspace); - assert_eq!(session.functions(), ["snapshot.read", "workspace.build"]); - - // Typed build requests: Core plans them and decodes the answers. - let build = |operation: &str, request: Value| { - core( - operation, - json!({ "manifest": manifest, "buildSystemID": BUILD_SYSTEM, "request": request }), - ) - }; - let model_plan = build("build.modelPlan", json!({ "root": "." }))["plan"].clone(); - assert_eq!(model_plan["provider"]["method"], "lithe/build/model"); - let model_result = session.build_request("model", &model_plan); - let model = build( - "build.decodeModel", - json!({ "workspaceRoot": workspace, "root": ".", "result": model_result }), - ); - let names: Vec<_> = model["model"]["projects"] - .as_array() - .expect("decoded projects") - .iter() - .map(|project| project["name"].as_str().unwrap().to_string()) - .collect(); - assert_eq!(names, ["app", "broken", "slow"], "{model}"); - - let dependency_plan = build( - "build.dependencyPlan", - json!({ "root": ".", "project": { "name": "app", "path": "app" } }), - )["plan"] - .clone(); - let dependencies_result = session.build_request("dependencies", &dependency_plan); - let dependencies = build( - "build.decodeDependencies", - json!({ "workspaceRoot": workspace, "root": ".", "project": "app", "result": dependencies_result }), - ); - assert_eq!( - dependencies["dependencies"]["dependencies"][0]["name"], "left-pad", - "{dependencies}" - ); - - // A multi-step function: prepare, run the tool, record a snapshot. - let built = session.call( - "build-app", - "workspace.build", - json!({ "projects": ["app"] }), - ); - assert_eq!( - built["result"]["value"], - json!({ "generation": 1, "status": "succeeded", "projects": [{ "name": "app", "exitCode": 0 }] }), - "{built}" - ); - let progress: Vec<_> = session - .events - .iter() - .filter(|event| event["type"] == "requestProgress" && event["operationId"] == "build-app") - .map(|event| event["result"]["message"].clone()) - .collect(); - assert_eq!(progress, ["Preparing", "Building app", "Done"]); - // The backend registered snapshot.clear once a snapshot existed. - session.await_event("snapshot.clear to be registered", |event| { - event["type"] == "functionsChanged" - && event["result"]["functions"] - .as_array() - .is_some_and(|functions| functions.contains(&json!("snapshot.clear"))) - }); - - // A plugin failure keeps Lithe's stable code and forwards the plugin's own. - let failed = session.call( - "build-broken", - "workspace.build", - json!({ "projects": ["broken"] }), - ); - assert_eq!( - failed["error"]["code"], "plugin.function.failed", - "{failed}" - ); - assert_eq!(failed["error"]["pluginErrorCode"], "sample.buildFailed"); - - // Cancelling a running build ends the tool the backend started. - session.invoke( - "build-slow", - "workspace.build", - json!({ "projects": ["slow"] }), - ); - let deadline = Instant::now() + WAIT; - let pid = loop { - if let Some(pid) = fs::read_to_string(&slow_pid) - .ok() - .and_then(|text| text.trim().parse::().ok()) - { - break pid; - } - assert!(Instant::now() < deadline, "the slow tool never started"); - session.events.extend( - core( - "lsp.waitEvents", - json!({ "sessionId": session.id, "timeoutMilliseconds": 50 }), - )["events"] - .as_array() - .cloned() - .unwrap_or_default(), - ); - }; - core( - "lsp.cancelOperation", - json!({ "sessionId": session.id, "operationId": "build-slow" }), - ); - assert_eq!( - session.completion("build-slow")["error"]["code"], - "requestCancelled" - ); - #[cfg(unix)] - { - let deadline = Instant::now() + WAIT; - while is_alive(pid) { - assert!( - Instant::now() < deadline, - "the cancelled tool {pid} is still running" - ); - // test-stability: allow(rust-real-sleep) reason: the cancelled tool is a separate OS process that offers no notification channel to the test; the deadline above bounds the loop. - std::thread::sleep(Duration::from_millis(20)); - } - } - #[cfg(not(unix))] - let _ = pid; - - // State survives a restart: the new process reads the recorded snapshot. - session.stop(); - drop(session); - let mut restarted = Session::start(&plugin_id, runtime, &arguments, &workspace); - restarted.await_event("snapshot.clear to be offered again", |event| { - event["type"] == "functionsChanged" - && event["result"]["functions"] - .as_array() - .is_some_and(|functions| functions.contains(&json!("snapshot.clear"))) - }); - let snapshot = restarted.call("read", "snapshot.read", json!({})); - assert_eq!(snapshot["result"]["value"]["generation"], 2, "{snapshot}"); - assert_eq!(snapshot["result"]["value"]["status"], "failed"); - assert_eq!( - restarted.call("clear", "snapshot.clear", json!({}))["result"]["value"], - Value::Null - ); - assert_eq!( - restarted.call("read-again", "snapshot.read", json!({}))["result"]["value"], - Value::Null - ); - restarted.stop(); - drop(restarted); - - // The backend wrote only to the host's state directory, never the package. - assert!( - listing(&package) == package_before, - "the plugin package changed at runtime" - ); - let state: Vec<_> = fs::read_dir(fixture.root.join("workspace-state")) - .expect("workspace state directory") - .map(|entry| entry.unwrap().file_name()) - .collect(); - assert!( - state.is_empty(), - "cleared state left files behind: {state:?}" - ); -} diff --git a/rust/lithe-core/tests/plugin_sdk_java.rs b/rust/lithe-core/tests/plugin_sdk_java.rs deleted file mode 100644 index 09744d310..000000000 --- a/rust/lithe-core/tests/plugin_sdk_java.rs +++ /dev/null @@ -1,43 +0,0 @@ -//! End-to-end check of the official Java plugin SDK (`sdk/java`) against Rust -//! Core, with the scenario shared with the Node.js SDK in `plugin_sdk/mod.rs`. -//! -//! The sample is installed the way a plugin ships: `plugin.json` and the -//! shaded `backend/sample-build.jar` in one package. It runs only when -//! `LITHE_PLUGIN_SDK_JAVA` names a Java 8 or newer executable, -//! `LITHE_PLUGIN_SDK_JAVA_SAMPLE` the built sample JAR, and -//! `LITHE_PLUGIN_SDK_NODE` Node.js for the sample workspace's build tools; -//! `scripts/verify-rust-core-crates.sh` sets all three. - -mod plugin_sdk; - -#[test] -fn the_java_sdk_sample_backend_serves_build_requests_and_plugin_functions() { - let (Some(java), Some(jar)) = ( - std::env::var_os("LITHE_PLUGIN_SDK_JAVA"), - std::env::var_os("LITHE_PLUGIN_SDK_JAVA_SAMPLE"), - ) else { - eprintln!( - "skipped: set LITHE_PLUGIN_SDK_JAVA and LITHE_PLUGIN_SDK_JAVA_SAMPLE to run the Java SDK end-to-end check" - ); - return; - }; - let Some(node) = plugin_sdk::node_from_environment("the Java SDK end-to-end check") else { - return; - }; - let package = plugin_sdk::TemporaryPackage::new( - "java", - &[ - ( - "plugin.json", - plugin_sdk::repository_path("sdk/java/examples/sample-build/plugin.json"), - ), - ("backend/sample-build.jar", jar.into()), - ], - ); - plugin_sdk::run_sample_scenario(&plugin_sdk::Sample { - tool: "java", - runtime: java.into_string().expect("UTF-8 Java path"), - package: package.root.clone(), - node, - }); -} diff --git a/rust/lithe-core/tests/plugin_sdk_node.rs b/rust/lithe-core/tests/plugin_sdk_node.rs deleted file mode 100644 index 15322fd13..000000000 --- a/rust/lithe-core/tests/plugin_sdk_node.rs +++ /dev/null @@ -1,19 +0,0 @@ -//! End-to-end check of the official Node.js plugin SDK (`sdk/node`) against -//! Rust Core; the scenario is shared with the Java SDK in `plugin_sdk/mod.rs`. -//! It runs only when `LITHE_PLUGIN_SDK_NODE` names a Node.js 20.3 or newer -//! executable; `scripts/verify-rust-core-crates.sh` sets it. - -mod plugin_sdk; - -#[test] -fn the_node_sdk_sample_backend_serves_build_requests_and_plugin_functions() { - let Some(node) = plugin_sdk::node_from_environment("the Node.js SDK end-to-end check") else { - return; - }; - plugin_sdk::run_sample_scenario(&plugin_sdk::Sample { - tool: "node", - runtime: node.clone(), - package: plugin_sdk::repository_path("sdk/node/examples/sample-build"), - node, - }); -} diff --git a/scripts/classify-ci-changes.sh b/scripts/classify-ci-changes.sh index 738a6d2c6..a375b3199 100755 --- a/scripts/classify-ci-changes.sh +++ b/scripts/classify-ci-changes.sh @@ -414,11 +414,6 @@ classify_path() { infra/docker/*) rust_database=true ;; - sdk/*) - # Plugin backend SDKs are verified in the Rust Core lane, which runs - # their unit tests and drives their samples through Core sessions. - rust_core=true - ;; *) # New or unclassified repository areas are validated conservatively # until their ownership is made explicit above. diff --git a/scripts/test-classify-ci-changes.sh b/scripts/test-classify-ci-changes.sh index 545ee9704..1469f6946 100755 --- a/scripts/test-classify-ci-changes.sh +++ b/scripts/test-classify-ci-changes.sh @@ -127,7 +127,6 @@ modify_readme() { printf '%s\n' '# Updated' > README.md; } modify_rust_comment() { printf '%s\n' '//! Updated test module.' 'pub fn value() -> u8 { 1 }' > rust/lithe-core/src/lib.rs; } modify_rust_code() { printf '%s\n' '//! Test module.' 'pub fn value() -> u8 { 2 }' > rust/lithe-core/src/lib.rs; } modify_rust_test() { printf '%s\n' '#[test]' 'fn value_is_two() { assert_eq!(2, 2); }' > rust/lithe-core/tests/value.rs; } -modify_plugin_sdk() { mkdir -p sdk/node/src && printf '%s\n' 'export const value = 2;' > sdk/node/src/index.mjs; } modify_rust_source_test() { printf '%s\n' '#[test]' 'fn source_test() { assert_eq!(2, 2); }' > rust/lithe-core/src/lsp/tests.rs; } modify_database_rust() { printf '%s\n' 'fn main() { println!("updated"); }' > rust/lithe-db-sidecar/src/main.rs; } modify_database_docker() { printf '%s\n' 'services:' ' mariadb: {}' > infra/docker/database-validation/compose.yaml; } @@ -232,9 +231,6 @@ assert_classification rust-code \ assert_classification rust-test \ "$(classification false false false true false false false false false false)" \ modify_rust_test -assert_classification plugin-sdk \ - "$(classification false false false true false false false false false false)" \ - modify_plugin_sdk assert_classification rust-source-test \ "$(classification false false false true false false false false false false)" \ modify_rust_source_test diff --git a/scripts/verify-rust-core-crates.sh b/scripts/verify-rust-core-crates.sh index 3a195347f..7cf4806ed 100755 --- a/scripts/verify-rust-core-crates.sh +++ b/scripts/verify-rust-core-crates.sh @@ -30,21 +30,4 @@ node .agents/skills/write-stable-tests/scripts/run-rust-tests-with-timing.mjs \ --report .artifacts/test-stability/ide-host-rust.json cargo build --locked --manifest-path rust/Cargo.toml -p lithe-ide-host --features mcp --bin lithe-mcp node --test scripts/test-ide-mcp.mjs -node --test --test-reporter=spec --test-reporter-destination=stdout \ - --test-reporter=junit --test-reporter-destination=.artifacts/test-stability/plugin-sdk-node.xml \ - sdk/node/test/*.test.mjs -# The Java plugin SDK compiles for Java 8 with any newer JDK. When -# LITHE_PLUGIN_SDK_JAVA_RUNTIME names an older `java` (CI sets a Java 8 one), -# its tests and the sample backend run on that runtime instead of the build JDK. -for tool in java mvn; do - command -v "$tool" >/dev/null || { print -u2 "verify-rust-core-crates: $tool is required for sdk/java."; exit 1; } -done -java_runtime="${LITHE_PLUGIN_SDK_JAVA_RUNTIME:-$(command -v java)}" -mvn -B -ntp -f sdk/java/pom.xml verify -Djvm="$java_runtime" -cp sdk/java/plugin-sdk/target/surefire-reports/TEST-*.xml .artifacts/test-stability/ -# Opts the plugin SDK end-to-end tests in; they drive each sample backend -# through Core's real plugin session. -LITHE_PLUGIN_SDK_NODE="$(command -v node)" \ - LITHE_PLUGIN_SDK_JAVA="$java_runtime" \ - LITHE_PLUGIN_SDK_JAVA_SAMPLE="$ROOT_DIR/sdk/java/examples/sample-build/target/sample-build.jar" \ - cargo test --manifest-path rust/Cargo.toml -p lithe-core +cargo test --manifest-path rust/Cargo.toml -p lithe-core diff --git a/scripts/verify-shared-contracts.sh b/scripts/verify-shared-contracts.sh index 91b749033..e91c0e057 100755 --- a/scripts/verify-shared-contracts.sh +++ b/scripts/verify-shared-contracts.sh @@ -151,15 +151,25 @@ fi # Declarative plugin contract v1. Semantics are executed by the Rust Core # tests against these files; this gate keeps the contract self-consistent. declarative_plugin_contract="shared/contracts/plugins/v1" +# Plugin developers read this contract from the public lithe-plugin-docs +# repository, which mirrors this directory byte for byte under contract/v1. +published_plugin_schema_base="https://raw.githubusercontent.com/Lithe-IDEA/lithe-plugin-docs/main/contract/v1" for contract_file in "$declarative_plugin_contract"/**/*.json; do /usr/bin/ruby -rjson -e 'JSON.parse(File.read(ARGV.fetch(0)))' "$contract_file" done /usr/bin/ruby -rjson -e ' root = ARGV.fetch(0) + published_base = ARGV.fetch(1) schema = JSON.parse(File.read(File.join(root, "plugin-manifest.schema.json"))) - abort "plugin manifest schema ID mismatch" unless schema.fetch("$id").end_with?("/plugins/v1/plugin-manifest.schema.json") + abort "plugin manifest schema ID mismatch" unless schema.fetch("$id") == "#{published_base}/plugin-manifest.schema.json" abort "declarative manifests must use schemaVersion 2" unless schema.fetch("properties").fetch("schemaVersion").fetch("const") == 2 readme = File.read(File.join(root, "README.md"), encoding: "UTF-8") + # The mirror only holds this directory, so a relative link must resolve here. + readme.scan(/\]\(([^)\s]+)\)/).flatten.each do |target| + next if target.start_with?("#") || target.match?(%r{\A[a-z][a-z0-9+.-]*:}i) + path = File.expand_path(target.split("#").first, root) + abort "contract README link #{target} must be absolute or stay inside #{root}" unless path.start_with?("#{File.expand_path(root)}/") && File.exist?(path) + end documented = readme.scan(/`(plugin\.[a-zA-Z.]+)`/).flatten manifests = Dir[File.join(root, "fixtures", "*.plugin.json")] abort "declarative plugin fixtures are missing" if manifests.empty? @@ -220,7 +230,7 @@ done end abort "function protocol fixture contains a machine path" if JSON.generate(functions).match?(%r{/(?:Users|home|var/folders|private/var)/|[A-Za-z]:\\\\}) index_schema = JSON.parse(File.read(File.join(root, "plugin-index.schema.json"))) - abort "plugin index schema ID mismatch" unless index_schema.fetch("$id").end_with?("/plugins/v1/plugin-index.schema.json") + abort "plugin index schema ID mismatch" unless index_schema.fetch("$id") == "#{published_base}/plugin-index.schema.json" abort "plugin indexes must use schemaVersion 1" unless index_schema.fetch("properties").fetch("schemaVersion").fetch("const") == 1 marketplace = File.join(root, "fixtures", "marketplace") index = JSON.parse(File.read(File.join(marketplace, "index.json"))) @@ -246,7 +256,7 @@ done code = item["expectedCode"] abort "install review case #{item.fetch("name")} uses an undocumented code" if code && !documented.include?(code) end -' "$declarative_plugin_contract" +' "$declarative_plugin_contract" "$published_plugin_schema_base" /usr/bin/ruby -rjson -e ' data = JSON.parse(File.read(ARGV.fetch(0))) diff --git a/sdk/java/README.md b/sdk/java/README.md deleted file mode 100644 index 7231d3919..000000000 --- a/sdk/java/README.md +++ /dev/null @@ -1,153 +0,0 @@ -# Lithe plugin backend SDK for Java - -`lithe-plugin-sdk-java` is the official way to write a plugin backend in Java: -the process a plugin starts through a `runtime` launch -(`{ "kind": "runtime", "tool": "java", "minimumVersion": "8", "entry": "backend/app.jar" }`). -It is the Java twin of the [Node.js SDK](../node/README.md). Both implement the -same two protocols from -[plugin contract v1](../../shared/contracts/plugins/v1/README.md) with the -same rules and error mapping: - -- **`litheBuild` 1**: typed `lithe/build/model` and `lithe/build/dependencies` - requests, for a build system whose `model` or `dependencies` source is - `provider`. -- **`litheFunctions` 1**: plugin functions, which are plugin-private actions - that Lithe invokes for the same plugin. They support progress, - cancellation, and registration at runtime. - -The SDK requires Java 8 or newer and has no runtime dependencies. JSON values -are plain `Map`, `List`, `String`, `Long`, `Double`, -`Boolean`, and `null` (see `Json`). - -## A minimal backend - -```java -Backend backend = Backend.create("example-backend", "1.0.0"); - -backend.onModel((params, context) -> { - Map project = new LinkedHashMap<>(); - project.put("name", "app"); - project.put("path", Json.string(params, "root")); - Map model = new LinkedHashMap<>(); - model.put("projects", Collections.singletonList(project)); - return model; -}); - -backend.function("snapshot.refresh", (params, context) -> { - context.progress("Refreshing", 10.0); - Processes.Result result = context.run(Arrays.asList("example-tool", "refresh")); - if (result.exitCode() != 0) { - throw new PluginError("example.refreshFailed", "The refresh failed."); - } - return Collections.singletonMap("refreshedAt", System.currentTimeMillis()); -}); - -backend.listen(); -``` - -[`examples/sample-build`](examples/sample-build) is a complete plugin. It has -a build provider, functions, persistent state, a function registered at -runtime, and cancellation. Rust Core's -`rust/lithe-core/tests/plugin_sdk_java.rs` drives it through a real Core -session, and runs the same scenario that `plugin_sdk_node.rs` runs for the -Node.js sample. - -## Getting the SDK - -Each release is tagged `sdk-java-v`. A release ships -`lithe-plugin-sdk-java-.jar` and a `.sha256` file holding the JAR's -SHA-256. The SDK is not published to a Maven repository. Use it like this: - -1. Download the JAR during your plugin's build, never at runtime. -2. Check the JAR against the SHA-256 you pinned in your repository. -3. Shade it into your backend JAR, as the sample's `pom.xml` does. - -A plugin package is installed read-only and must run offline, so the backend -JAR it starts must hold everything it loads. - -## API - -| API | Purpose | -| --- | --- | -| `Backend.create(name, version[, capabilities])` | Creates the backend. `capabilities` are standard LSP server capabilities. `experimental.litheBuild` and `experimental.litheFunctions` are reserved for the SDK. | -| `onModel(handler)`, `onDependencies(handler)` | Implement the typed build requests. The SDK stamps `format` and `version`. Core validates the document. `litheBuild` is answered only when a handler exists. | -| `function(id, handler)` | Declares a function offered in `initialize`. | -| `registerFunction(id, handler)`, `unregisterFunction(id)` | Add or withdraw a function. Called before the `initialize` answer (for example from `onInitialize`), they change the initial list. Later calls send `lithe/function/register` or `lithe/function/unregister`, in order. Both return a `CompletableFuture`. Withdrawing a function revokes its running calls. | -| `onInitialize(hook)`, `onShutdown(hook)` | Lifecycle hooks. `onInitialize` receives the `initialize` params and what was negotiated. | -| `onRequest(method, handler)`, `onNotification(method, handler)` | Ordinary LSP methods. Lifecycle and `lithe/*` methods are reserved. | -| `sendRequest`, `sendNotification` | Ordinary LSP messages to the host. `lithe/*` methods are reserved. | -| `log(message[, error])` | Writes to stderr, which the host keeps in its log. | -| `listen()` | Serves stdio until `exit` or the end of input, then exits the JVM with the protocol's exit code. `listen(in, out, err)` serves other streams and returns the code instead. | -| `PluginError(code, message)` | A failure meant for your own UI. | -| `Processes.run(command[, options])` | Runs a process without a shell. Its stdin is closed. `Options` set the directory, environment, output limits, a cancellation, and output callbacks. | -| `StateStore.open(directory)` | A small atomic JSON store in a host-provided directory. | - -Handlers run on their own threads. Every handler receives a `CallContext`: - -| Member | Meaning | -| --- | --- | -| `cancellation()`, `isCancelled()` | Cancelled when any of these happens: Lithe cancels the call, its deadline passes, the function is withdrawn, the session ends, or the handler finishes. Finishing the handler stops the work it left running. | -| `throwIfCancelled()` | Throws `CancelledException` once the call is cancelled. | -| `run(command[, options])` | `Processes.run` bound to the call: cancelling or ending the call stops the process. | -| `log(message)` | Same as `Backend.log`. | -| `functionId()`, `operationId()` | Functions only. `operationId` is opaque; never derive anything from it. | -| `progress(message, percentage)` | Functions only. Either argument may be `null`. The message is cut to 1024 bytes and the percentage is clamped to 0–100. Reports sent after the call ends are dropped. | - -Results and errors map exactly as in the Node.js SDK: - -- A returned value becomes `result.value`. -- `PluginError` becomes `plugin.function.failed` with your message and - `pluginErrorCode`. -- Any other exception is reported with a generic message, and its stack trace - goes to stderr. -- `CancelledException` ends the call as cancelled. -- A result larger than 1 MiB of JSON fails like an unexpected error. - -## Rules that keep Lithe in charge - -These are the [Node.js SDK's rules](../node/README.md#rules-that-keep-lithe-in-charge). -In Java: - -1. **Domain capabilities stay typed.** Build models and dependency trees go - through `onModel` and `onDependencies`, never through a function. -2. **No calls back into Lithe's dispatch.** The SDK refuses to send any - `lithe/*` method. -3. **stdout belongs to the protocol.** `listen()` keeps the real stdout for - protocol frames and points `System.out` at stderr. Never write to - `FileDescriptor.out`. -4. **The package is read-only.** Keep state under the directories Lithe - passes through launch templates (`${stateDir}`, `${workspaceStateDir}`). - `StateStore.open` refuses a directory inside the plugin package. It finds - the package by walking up from the SDK's own JAR to `plugin.json`. -5. **Work ends with its call.** Start processes with `context.run`, and check - `isCancelled()` in long loops. On cancellation the SDK first asks the - process to stop, then stops it forcibly after a grace period. - - **macOS and Linux, every Java version:** each tool leads its own process - group, as in the Node SDK. The SDK starts it through the system `perl`, - which calls `setpgrp` and then becomes the tool. A stop reaches the whole - group, and when the tool exits on its own, whatever it left running in - its group is killed. - - **Windows, or no `perl`:** on Java 9 or newer the SDK stops the - descendants still attached to the tool; Java 8 stops only the direct - child. - - A descendant that leaves the group (`setsid`) or that the fallback cannot - reach is ended by Lithe's process scope together with the backend. - A cancellation listener that throws is logged and does not keep the other - listeners or the reply from running. - - On `shutdown`, `exit`, or the end of input the SDK ends every process it - started. `Processes.run` keeps Lithe's process-scope markers even when - `Options.environment` replaces the environment. -6. **Errors stay presentable.** Throw `PluginError` for failures the user - should see; every other exception is reported generically. - -## Testing - -```bash -mvn -B -f sdk/java/pom.xml verify -``` - -This builds the SDK and the sample for Java 8 with any newer JDK, then runs -the JUnit tests. Add `-Djvm=/path/to/java8/bin/java` to run the tests on a -Java 8 runtime, as CI does. diff --git a/sdk/java/examples/sample-build/plugin.json b/sdk/java/examples/sample-build/plugin.json deleted file mode 100644 index 8e93ebc10..000000000 --- a/sdk/java/examples/sample-build/plugin.json +++ /dev/null @@ -1,87 +0,0 @@ -{ - "schemaVersion": 2, - "id": "org.example.sample-build-java", - "displayName": "Sample Build (Java)", - "version": "0.1.0", - "pluginAPIVersion": 3, - "requires": { - "buildProviders": 1, - "buildSystems": 1, - "serverProcess": 1 - }, - "vendor": { - "id": "org.example", - "displayName": "Example", - "signatureRequirement": "publisherPackage" - }, - "permissions": [ - "languageServer.launch", - "process.build" - ], - "activationEvents": [ - "onBuildSystem:sample" - ], - "languages": [], - "buildSystems": [ - { - "id": "sample", - "displayName": "Sample", - "detection": { - "markers": [ - "lithe-sample.json" - ], - "nesting": "outermost" - }, - "invocation": { - "executables": [ - { - "kind": "command", - "command": "java" - } - ] - }, - "provider": { - "launch": { - "kind": "runtime", - "tool": "java", - "minimumVersion": "8", - "entry": "backend/sample-build.jar" - }, - "arguments": [ - "--state", - "${workspaceStateDir}" - ], - "executesProjectCode": true, - "mayUseNetwork": false - }, - "model": { - "source": "provider" - }, - "tasks": [ - { - "id": "build", - "displayName": "Build", - "group": "build", - "scopes": [ - "workspace" - ], - "arguments": [ - "-version" - ] - } - ], - "dependencies": { - "source": "provider", - "scopes": [ - "workspace", - "project" - ] - }, - "sync": { - "watch": [ - "lithe-sample.json" - ] - } - } - ] -} diff --git a/sdk/java/examples/sample-build/pom.xml b/sdk/java/examples/sample-build/pom.xml deleted file mode 100644 index cacbb00d0..000000000 --- a/sdk/java/examples/sample-build/pom.xml +++ /dev/null @@ -1,51 +0,0 @@ - - - 4.0.0 - - - io.github.lithe-idea - lithe-plugin-sdk-java-parent - 0.1.0 - ../../pom.xml - - - sample-build - Lithe sample build provider (Java) - - - - io.github.lithe-idea - lithe-plugin-sdk-java - ${project.version} - - - - - sample-build - - - - org.apache.maven.plugins - maven-shade-plugin - - - package - - shade - - - false - - - org.example.samplebuild.SampleBuild - - - - - - - - - diff --git a/sdk/java/examples/sample-build/src/main/java/org/example/samplebuild/SampleBuild.java b/sdk/java/examples/sample-build/src/main/java/org/example/samplebuild/SampleBuild.java deleted file mode 100644 index 492f9e210..000000000 --- a/sdk/java/examples/sample-build/src/main/java/org/example/samplebuild/SampleBuild.java +++ /dev/null @@ -1,241 +0,0 @@ -package org.example.samplebuild; - -import io.github.litheidea.plugin.sdk.Backend; -import io.github.litheidea.plugin.sdk.CallContext; -import io.github.litheidea.plugin.sdk.Json; -import io.github.litheidea.plugin.sdk.PluginError; -import io.github.litheidea.plugin.sdk.Processes; -import io.github.litheidea.plugin.sdk.StateStore; -import java.io.IOException; -import java.net.URI; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; - -/** - * Sample Lithe plugin backend built on the official Java SDK; the Java twin of - * {@code sdk/node/examples/sample-build}. - * - *

It is the build provider of the fictional "sample" build system, whose - * workspace file {@code lithe-sample.json} lists projects: - * - *

- * { "projects": [{ "name": "app", "path": "app",
- *                  "build": ["/usr/bin/make"],
- *                  "dependencies": [{ "name": "left-pad", "version": "1.3.0" }] }] }
- * 
- * - *

It shows the three things a backend does: - *

    - *
  • answers Lithe's typed build requests ({@code lithe/build/model}, - * {@code lithe/build/dependencies}), whose results Lithe itself understands;
  • - *
  • offers plugin functions for its own UI: {@code workspace.build} runs a - * multi-step build (prepare, run each project's tool, record a snapshot), - * and {@code snapshot.read} returns the last snapshot, also after a restart;
  • - *
  • registers {@code snapshot.clear} at runtime once there is a snapshot to clear.
  • - *
- * - *

A packaged plugin shades the SDK into its own JAR, as this module's POM does. - */ -public final class SampleBuild { - private final Backend backend = Backend.create("sample-build", "0.1.0"); - private final String stateDirectory; - private volatile Path workspaceRoot; - private volatile StateStore store; - - private SampleBuild(String stateDirectory) { - this.stateDirectory = stateDirectory; - } - - public static void main(String[] arguments) { - new SampleBuild(argument(arguments, "--state")).serve(); - } - - private void serve() { - backend.onInitialize((params, negotiated) -> { - // `Paths.get(URI)` accepts the trailing slash a directory URI may carry. - workspaceRoot = Paths.get(URI.create((String) params.get("rootUri"))).toAbsolutePath().normalize(); - // `--state ${workspaceStateDir}` in plugin.json: the host's writable - // directory for this workspace. The package itself is read-only. - store = StateStore.open(Paths.get(stateDirectory)); - if (store.read("snapshot", null) != null) { - offerClear(); - } - }); - - backend.onModel((params, context) -> { - List projects = new ArrayList<>(); - for (Map project : readConfig(Json.string(params, "root"))) { - projects.add(object("name", project.get("name"), "path", project.get("path"))); - } - return object("projects", projects); - }); - - backend.onDependencies((params, context) -> { - Object selected = params.get("project"); - String path = selected instanceof Map ? Json.string(Json.object(selected), "path") : null; - List dependencies = new ArrayList<>(); - for (Map project : readConfig(Json.string(params, "root"))) { - if (path != null && !path.equals(project.get("path"))) { - continue; - } - Object declared = project.get("dependencies"); - if (declared instanceof List) { - for (Object entry : (List) declared) { - Map dependency = Json.object(entry); - dependencies.add(object("name", dependency.get("name"), "version", dependency.get("version"), - "children", new ArrayList<>())); - } - } - } - return object("dependencies", dependencies); - }); - - backend.function("workspace.build", this::build); - backend.function("snapshot.read", (params, context) -> store.read("snapshot", null)); - backend.listen(); - } - - private Object build(Map params, CallContext context) throws IOException { - // Step 1: prepare. Read the workspace file and resolve the selection. - context.progress("Preparing", 0.0); - List> projects = selectProjects(readConfig("."), params.get("projects")); - - // Step 2: run each project's tool. `context.run` stops the tool when - // Lithe cancels the call, so a cancelled build leaves nothing behind. - List results = new ArrayList<>(); - for (int index = 0; index < projects.size(); index++) { - Map project = projects.get(index); - String name = (String) project.get("name"); - context.progress("Building " + name, 100.0 * index / projects.size()); - List command = new ArrayList<>(); - for (Object part : (List) project.get("build")) { - command.add((String) part); - } - Processes.Result result = context.run(command, new Processes.Options() - .directory(inside(workspaceRoot, (String) project.get("path"))) - .onStdout(text -> context.log("[" + name + "] " + text.trim()))); - results.add(object("name", name, "exitCode", result.exitCode())); - if (result.exitCode() != 0) { - record(results, "failed"); - throw new PluginError("sample.buildFailed", name + " failed with exit code " + result.exitCode() + "."); - } - } - - // Step 3: record the outcome, so `snapshot.read` returns it after a restart. - Object snapshot = record(results, "succeeded"); - context.progress("Done", 100.0); - return snapshot; - } - - /** - * Reads the latest snapshot when the build ends, not when it starts: two - * overlapping builds then get consecutive generations instead of the same - * one. Handlers run on their own threads, so the read and the write hold - * one lock. - */ - private synchronized Object record(List projects, String status) throws IOException { - Object previous = store.read("snapshot", null); - long generation = previous instanceof Map - ? ((Number) Json.object(previous).get("generation")).longValue() + 1 - : 1; - Map snapshot = object("generation", generation, "status", status, "projects", projects); - store.write("snapshot", snapshot); - offerClear(); - return snapshot; - } - - private void offerClear() { - if (backend.functionIds().contains("snapshot.clear")) { - return; - } - backend.registerFunction("snapshot.clear", (params, context) -> { - store.delete("snapshot"); - return null; - }).whenComplete((ignored, error) -> { - if (error != null) { - backend.log("snapshot.clear stays unavailable: " + error.getMessage()); - } - }); - } - - /** Every project, or exactly the named ones; an unknown name is an error, not an empty build. */ - private static List> selectProjects(List> projects, Object selected) { - if (selected == null) { - return projects; - } - if (!(selected instanceof List) || ((List) selected).isEmpty() - || !((List) selected).stream().allMatch(name -> name instanceof String)) { - throw new PluginError("sample.selection", "\"projects\" must be a non-empty list of project names."); - } - List unknown = new ArrayList<>(); - for (Object name : (List) selected) { - if (projects.stream().noneMatch(project -> name.equals(project.get("name")))) { - unknown.add((String) name); - } - } - if (!unknown.isEmpty()) { - throw new PluginError("sample.unknownProject", "Unknown project: " + String.join(", ", unknown) + "."); - } - List> chosen = new ArrayList<>(); - for (Map project : projects) { - if (((List) selected).contains(project.get("name"))) { - chosen.add(project); - } - } - return chosen; - } - - private List> readConfig(String root) { - Object config; - try { - Path file = inside(workspaceRoot, root == null ? "." : root).resolve("lithe-sample.json"); - config = Json.parse(new String(Files.readAllBytes(file), StandardCharsets.UTF_8)); - } catch (IOException | RuntimeException error) { - if (error instanceof PluginError) { - throw (PluginError) error; - } - throw new PluginError("sample.config", "lithe-sample.json is missing or is not valid JSON."); - } - Object projects = config instanceof Map ? ((Map) config).get("projects") : null; - if (!(projects instanceof List)) { - throw new PluginError("sample.config", "lithe-sample.json needs a \"projects\" list."); - } - List> result = new ArrayList<>(); - for (Object project : (List) projects) { - result.add(Json.object(project)); - } - return result; - } - - /** {@code relative} resolved inside {@code base}; workspace files never name paths outside it. */ - private static Path inside(Path base, String relative) { - Path path = base.resolve(relative).normalize(); - if (Paths.get(relative).isAbsolute() || !path.startsWith(base)) { - throw new PluginError("sample.path", relative + " is outside the workspace."); - } - return path; - } - - private static Map object(Object... entries) { - Map map = new LinkedHashMap<>(); - for (int index = 0; index < entries.length; index += 2) { - map.put((String) entries[index], entries[index + 1]); - } - return map; - } - - private static String argument(String[] arguments, String name) { - for (int index = 0; index + 1 < arguments.length; index++) { - if (arguments[index].equals(name)) { - return arguments[index + 1]; - } - } - return null; - } -} diff --git a/sdk/java/plugin-sdk/pom.xml b/sdk/java/plugin-sdk/pom.xml deleted file mode 100644 index 6ad56d196..000000000 --- a/sdk/java/plugin-sdk/pom.xml +++ /dev/null @@ -1,41 +0,0 @@ - - - 4.0.0 - - - io.github.lithe-idea - lithe-plugin-sdk-java-parent - 0.1.0 - - - lithe-plugin-sdk-java - Lithe plugin SDK for Java - Plugin functions and build providers over the LSP base protocol, for Lithe plugin backends. - - - - org.junit.jupiter - junit-jupiter - - - - - - - org.apache.maven.plugins - maven-jar-plugin - - - false - - lithe-plugin-sdk-java - ${project.version} - - - - - - - diff --git a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Backend.java b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Backend.java deleted file mode 100644 index ad32a637e..000000000 --- a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Backend.java +++ /dev/null @@ -1,1062 +0,0 @@ -package io.github.litheidea.plugin.sdk; - -import java.io.FileDescriptor; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.io.PrintStream; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionException; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.function.Supplier; - -/** - * The plugin backend session: one process the host starts from the plugin's - * manifest and talks to over stdio with the LSP base protocol. - * - *

The SDK owns everything Lithe manages uniformly -- the lifecycle, protocol - * negotiation, cancellation, error codes, and the {@code lithe/*} namespace -- - * and leaves the plugin free to decide what its handlers do. Declare handlers, - * then call {@link #listen()}. Handlers run on their own threads, so several - * calls can be in flight at once; shared plugin state needs its own locking. - * - *

Contract: {@code shared/contracts/plugins/v1/README.md} ("Build providers" - * and "Plugin functions"). Behaviour and error codes match the Node.js SDK. - */ -public final class Backend { - /** Answers a typed build request or a plugin function. */ - @FunctionalInterface - public interface Handler { - Object handle(Map params, CallContext context) throws Exception; - } - - /** Answers a standard LSP request, for a backend that is also a language server. */ - @FunctionalInterface - public interface RequestHandler { - Object handle(Object params, CallContext context) throws Exception; - } - - /** Handles a standard LSP notification. */ - @FunctionalInterface - public interface NotificationHandler { - void handle(Object params) throws Exception; - } - - /** Runs before the {@code initialize} answer. */ - @FunctionalInterface - public interface InitializeHook { - void run(Map params, Negotiated negotiated) throws Exception; - } - - /** Runs on {@code shutdown}, after running calls were cancelled. */ - @FunctionalInterface - public interface ShutdownHook { - void run() throws Exception; - } - - /** Protocols the host agreed to in {@code initialize}. */ - public static final class Negotiated { - static final Negotiated NONE = new Negotiated(false, false); - - private final boolean functions; - private final boolean build; - - Negotiated(boolean functions, boolean build) { - this.functions = functions; - this.build = build; - } - - /** Plugin functions, {@code litheFunctions} version 1. */ - public boolean functions() { - return functions; - } - - /** Typed build requests, {@code litheBuild} version 1. */ - public boolean build() { - return build; - } - } - - private enum Phase { IDLE, LISTENING, INITIALIZING, READY, SHUTTING_DOWN, EXITED } - - /** Canonical result format of each typed build request. */ - private static final Map BUILD_FORMATS; - - static { - Map formats = new HashMap<>(); - formats.put(Protocol.BUILD_MODEL, "litheBuildModel"); - formats.put(Protocol.BUILD_DEPENDENCIES, "litheDependencies"); - BUILD_FORMATS = Collections.unmodifiableMap(formats); - } - - private final String name; - private final String version; - private final Map capabilities; - private final Object lock = new Object(); - private final Object writeLock = new Object(); - private final Map functions = new HashMap<>(); - private final Map buildHandlers = new HashMap<>(); - private final Map requestHandlers = new HashMap<>(); - private final Map notificationHandlers = new HashMap<>(); - private final List initializeHooks = new ArrayList<>(); - private final List shutdownHooks = new ArrayList<>(); - /** Host requests being handled, by JSON-RPC ID. */ - private final Map incoming = new HashMap<>(); - /** Requests this backend sent to the host, by JSON-RPC ID. */ - private final Map outgoing = new HashMap<>(); - private final AtomicInteger nextRequestId = new AtomicInteger(1); - private final ExecutorService executor = Executors.newCachedThreadPool(new ThreadNamer()); - /** Serializes register/unregister so the host sees them in call order. */ - private CompletableFuture registrations = CompletableFuture.completedFuture(null); - private volatile Phase phase = Phase.IDLE; - private volatile Negotiated negotiated = Negotiated.NONE; - private volatile Map initializeParams; - private volatile OutputStream output; - private volatile PrintStream errorOutput = System.err; - private int exitCode = 1; - - private Backend(String name, String version, Map capabilities) { - Object experimental = capabilities.get("experimental"); - if (experimental instanceof Map - && (((Map) experimental).containsKey("litheBuild") - || ((Map) experimental).containsKey("litheFunctions"))) { - throw new PluginSdkError("sdk.capabilities.reserved", - "litheBuild and litheFunctions are negotiated by the SDK; declare handlers instead."); - } - this.name = name == null ? "lithe-plugin-backend" : name; - this.version = version; - this.capabilities = new LinkedHashMap<>(capabilities); - } - - /** - * Creates a backend. - * - * @param name reported as {@code serverInfo.name} and used as the log prefix - * @param version reported as {@code serverInfo.version}; may be null - */ - public static Backend create(String name, String version) { - return new Backend(name, version, Collections.emptyMap()); - } - - /** - * Creates a backend that also declares standard LSP server capabilities. - * {@code experimental.litheBuild} and {@code experimental.litheFunctions} - * are negotiated by the SDK and cannot be set here. - */ - public static Backend create(String name, String version, Map capabilities) { - return new Backend(name, version, capabilities == null ? Collections.emptyMap() : capabilities); - } - - /** Protocols the host agreed to in {@code initialize}; both false before it. */ - public Negotiated negotiated() { - return negotiated; - } - - /** The host's {@code initialize} params, or null before it arrives. */ - public Map initializeParams() { - return initializeParams; - } - - /** IDs of the functions this backend currently serves, sorted. */ - public List functionIds() { - synchronized (lock) { - List ids = new ArrayList<>(functions.keySet()); - Collections.sort(ids); - return ids; - } - } - - /** Answers {@code lithe/build/model} with a {@code litheBuildModel} document. */ - public Backend onModel(Handler handler) { - return declareBuild(Protocol.BUILD_MODEL, handler); - } - - /** Answers {@code lithe/build/dependencies} with a {@code litheDependencies} document. */ - public Backend onDependencies(Handler handler) { - return declareBuild(Protocol.BUILD_DEPENDENCIES, handler); - } - - /** - * Declares a plugin function offered from the start, in the {@code initialize} - * answer. To add one later, use {@link #registerFunction}. - * - * @param id dot-separated lowerCamelCase segments, such as {@code snapshot.refresh} - */ - public Backend function(String id, Handler handler) { - synchronized (lock) { - if (!beforeAnswer()) { - throw new PluginSdkError("sdk.function.lateDeclaration", - "After initialize, add functions with registerFunction()."); - } - checkNewFunction(id, handler); - functions.put(id, handler); - } - return this; - } - - /** Handles a standard LSP request, for a backend that is also a language server. */ - public Backend onRequest(String method, RequestHandler handler) { - checkDeclared(method, handler); - synchronized (lock) { - requestHandlers.put(method, handler); - } - return this; - } - - /** Handles a standard LSP notification. */ - public Backend onNotification(String method, NotificationHandler handler) { - checkDeclared(method, handler); - synchronized (lock) { - notificationHandlers.put(method, handler); - } - return this; - } - - /** Runs before the {@code initialize} answer, with the host's params and the negotiation. */ - public Backend onInitialize(InitializeHook hook) { - synchronized (lock) { - initializeHooks.add(hook); - } - return this; - } - - /** Runs on {@code shutdown}, after running calls were cancelled. */ - public Backend onShutdown(ShutdownHook hook) { - synchronized (lock) { - shutdownHooks.add(hook); - } - return this; - } - - /** - * Adds a function. Before the {@code initialize} answer (including from an - * {@link #onInitialize} hook) it joins the initial list; afterwards the host - * must accept it first. The future completes once the function is served. - */ - public CompletableFuture registerFunction(String id, Handler handler) { - synchronized (lock) { - if (beforeAnswer()) { - try { - checkNewFunction(id, handler); - functions.put(id, handler); - return CompletableFuture.completedFuture(null); - } catch (RuntimeException error) { - return failed(error); - } - } - } - return queueRegistration(() -> { - synchronized (lock) { - requireFunctions(); - checkNewFunction(id, handler); - // Installed before the request: the host may invoke it as soon as - // its registry changed, before this backend reads the reply. - functions.put(id, handler); - } - Map entry = new LinkedHashMap<>(); - entry.put("id", id); - Map params = new LinkedHashMap<>(); - params.put("functions", Collections.singletonList(entry)); - return request(Protocol.FUNCTION_REGISTER, params).handle((result, error) -> { - if (error != null) { - synchronized (lock) { - functions.remove(id); - } - throw new CompletionException(unwrap(error)); - } - return null; - }); - }); - } - - /** Withdraws a function. The host ends its running calls as revoked. */ - public CompletableFuture unregisterFunction(String id) { - synchronized (lock) { - if (beforeAnswer()) { - if (functions.remove(id) == null) { - return failed(unknownFunction(id)); - } - return CompletableFuture.completedFuture(null); - } - } - return queueRegistration(() -> { - synchronized (lock) { - requireFunctions(); - if (!functions.containsKey(id)) { - throw unknownFunction(id); - } - } - Map params = new LinkedHashMap<>(); - params.put("ids", Collections.singletonList(id)); - return request(Protocol.FUNCTION_UNREGISTER, params).thenApply(result -> { - List running = new ArrayList<>(); - synchronized (lock) { - functions.remove(id); - for (Call call : incoming.values()) { - if (id.equals(call.functionId)) { - running.add(call); - } - } - } - for (Call call : running) { - call.cancellation.cancel("The function was withdrawn."); - } - return null; - }); - }); - } - - /** Sends a standard LSP request to the host. {@code lithe/*} methods are reserved. */ - public CompletableFuture sendRequest(String method, Object params) { - checkOutgoing(method); - return request(method, params); - } - - /** Sends a standard LSP notification to the host. {@code lithe/*} methods are reserved. */ - public void sendNotification(String method, Object params) { - checkOutgoing(method); - Map message = message(null, method); - if (params != null) { - message.put("params", params); - } - write(message); - } - - /** Writes a diagnostic line to stderr, which the host keeps in its log. */ - public void log(String message) { - errorOutput.println("[" + name + "] " + message); - } - - /** Writes a diagnostic line and the failure's stack trace to stderr. */ - public void log(String message, Throwable error) { - PrintStream stream = errorOutput; - synchronized (stream) { - stream.println("[" + name + "] " + message + " " + error); - error.printStackTrace(stream); - } - } - - /** - * Serves the host over stdio until it sends {@code exit} or closes stdin, - * then exits the process with the session's exit code: 0 after a - * {@code shutdown}, 1 otherwise. {@code System.out} is redirected to stderr - * first, because stdout carries the protocol and a stray line there would - * end the session. - */ - public void listen() { - OutputStream protocol = new FileOutputStream(FileDescriptor.out); - System.setOut(System.err); - int code = listen(System.in, protocol, System.err); - System.exit(code); - } - - /** - * Serves the host over the given streams and returns the exit code once the - * session ended, without exiting the process. Blocks the calling thread. - */ - public int listen(InputStream input, OutputStream output, PrintStream errorOutput) { - synchronized (lock) { - if (phase != Phase.IDLE) { - throw new PluginSdkError("sdk.listen.repeated", "A backend listens once."); - } - phase = Phase.LISTENING; - this.output = output; - this.errorOutput = errorOutput; - } - Framing.Decoder decoder = new Framing.Decoder(); - byte[] chunk = new byte[64 * 1024]; - try { - while (phase != Phase.EXITED) { - int count = input.read(chunk); - if (count < 0) { - finish(phase == Phase.SHUTTING_DOWN ? 0 : 1); - break; - } - List messages; - try { - messages = decoder.push(chunk, 0, count); - } catch (Framing.FramingException error) { - log("Ending the session: " + error.getMessage()); - finish(1); - break; - } - for (Object message : messages) { - if (phase == Phase.EXITED) { - break; - } - dispatch(message); - } - } - } catch (IOException error) { - finish(1); - } - synchronized (lock) { - return exitCode; - } - } - - private void dispatch(Object message) { - if (message == Framing.MALFORMED) { - respond(null, null, error(Protocol.PARSE_ERROR, "The message is not valid JSON.")); - return; - } - // Valid JSON that is not a message object, such as `null` or a batch. - if (!(message instanceof Map)) { - respond(null, null, error(Protocol.INVALID_REQUEST, "The message is not a JSON-RPC object.")); - return; - } - Map object = Json.object(message); - Object id = object.get("id"); - Object method = object.get("method"); - if (!(method instanceof String)) { - if (id != null) { - settleOutgoing(object); - } - return; - } - if (id != null) { - handleRequest(id, (String) method, object.get("params")); - } else { - handleNotification((String) method, object.get("params")); - } - } - - private void handleRequest(Object id, String method, Object params) { - if (method.equals(Protocol.INITIALIZE)) { - initialize(id, params instanceof Map ? Json.object(params) : new LinkedHashMap<>()); - return; - } - if (method.equals(Protocol.SHUTDOWN)) { - shutdown(id); - return; - } - Phase current = phase; - if (current == Phase.LISTENING || current == Phase.INITIALIZING) { - respond(id, null, error(Protocol.SERVER_NOT_INITIALIZED, "The backend is not initialized.")); - return; - } - if (current != Phase.READY) { - respond(id, null, error(Protocol.INVALID_REQUEST, "The backend is shutting down.")); - return; - } - if (method.equals(Protocol.FUNCTION_INVOKE)) { - invokeFunction(id, params); - return; - } - Handler build; - RequestHandler handler; - synchronized (lock) { - build = negotiated.build() ? buildHandlers.get(method) : null; - handler = requestHandlers.get(method); - } - if (build != null) { - if (params != null && !(params instanceof Map)) { - respond(id, null, error(Protocol.INVALID_PARAMS, method + " params must be an object.")); - return; - } - Map arguments = Json.object(params); - run(id, null, null, context -> build.handle(arguments, context), result -> buildResult(method, result), null); - return; - } - if (handler != null) { - run(id, null, null, context -> handler.handle(params, context), result -> result, null); - return; - } - respond(id, null, error(Protocol.METHOD_NOT_FOUND, "Unsupported method: " + method)); - } - - private void handleNotification(String method, Object params) { - if (method.equals(Protocol.EXIT)) { - finish(phase == Phase.SHUTTING_DOWN ? 0 : 1); - return; - } - if (method.equals(Protocol.CANCEL_REQUEST)) { - Object id = params instanceof Map ? ((Map) params).get("id") : null; - Call call; - synchronized (lock) { - call = incoming.get(requestKey(id)); - } - if (call != null) { - call.cancellation.cancel("The request was cancelled."); - } - return; - } - if (phase != Phase.READY || method.equals(Protocol.INITIALIZED)) { - return; - } - NotificationHandler handler; - synchronized (lock) { - handler = notificationHandlers.get(method); - } - if (handler == null) { - return; - } - executor.execute(() -> { - try { - handler.handle(params); - } catch (Throwable error) { - log("Notification " + method + " failed:", error); - } - }); - } - - private void initialize(Object id, Map params) { - Negotiated agreed; - List hooks; - synchronized (lock) { - if (phase != Phase.LISTENING) { - respond(id, null, error(Protocol.INVALID_REQUEST, "initialize was already received.")); - return; - } - phase = Phase.INITIALIZING; - initializeParams = params; - Map offered = experimental(params.get("capabilities")); - agreed = new Negotiated( - offers(offered.get("litheFunctions"), Protocol.FUNCTIONS_VERSION), - !buildHandlers.isEmpty() && offers(offered.get("litheBuild"), Protocol.BUILD_VERSION)); - negotiated = agreed; - hooks = new ArrayList<>(initializeHooks); - } - executor.execute(() -> { - try { - for (InitializeHook hook : hooks) { - hook.run(params, agreed); - } - } catch (Throwable failure) { - log("initialize failed:", failure); - synchronized (lock) { - if (phase == Phase.INITIALIZING) { - phase = Phase.LISTENING; - } - } - respond(id, null, toResponseError(failure, null)); - return; - } - synchronized (lock) { - // A shutdown or exit that arrived while a hook ran ended the - // session; finishing initialize must not reopen it. - if (phase != Phase.INITIALIZING) { - respond(id, null, error(Protocol.INVALID_REQUEST, "The session ended during initialize.")); - return; - } - if (!agreed.functions() && !functions.isEmpty()) { - log("The host did not offer litheFunctions version 1; functions stay unavailable."); - } - Map experimental = new LinkedHashMap<>(experimental(capabilities)); - if (agreed.build()) { - experimental.put("litheBuild", singleton("version", Protocol.BUILD_VERSION)); - } - if (agreed.functions()) { - List offered = new ArrayList<>(); - for (String functionId : functionIds()) { - offered.add(singleton("id", functionId)); - } - Map entry = singleton("version", Protocol.FUNCTIONS_VERSION); - entry.put("functions", offered); - experimental.put("litheFunctions", entry); - } - Map answered = new LinkedHashMap<>(capabilities); - answered.put("experimental", experimental); - Map serverInfo = singleton("name", name); - if (version != null) { - serverInfo.put("version", version); - } - Map result = singleton("capabilities", answered); - result.put("serverInfo", serverInfo); - phase = Phase.READY; - respond(id, result, null); - } - }); - } - - private void shutdown(Object id) { - List hooks; - synchronized (lock) { - if (phase != Phase.READY && phase != Phase.LISTENING && phase != Phase.INITIALIZING) { - respond(id, null, error(Protocol.INVALID_REQUEST, "shutdown was already received.")); - return; - } - phase = Phase.SHUTTING_DOWN; - hooks = new ArrayList<>(shutdownHooks); - } - cancelAll("The backend is shutting down."); - executor.execute(() -> { - for (ShutdownHook hook : hooks) { - try { - hook.run(); - } catch (Throwable error) { - log("A shutdown hook failed:", error); - } - } - Processes.killAll(); - respond(id, null, null); - }); - } - - private void invokeFunction(Object id, Object raw) { - Map invocation = raw instanceof Map ? Json.object(raw) : new LinkedHashMap<>(); - Object functionId = invocation.get("functionID"); - Object operationId = invocation.get("operationID"); - Object params = invocation.get("params"); - Handler handler; - synchronized (lock) { - handler = negotiated.functions() && functionId instanceof String ? functions.get(functionId) : null; - } - if (handler == null) { - respond(id, null, error(Protocol.INVALID_PARAMS, "Function " + functionId + " is not registered.")); - return; - } - if (params != null && !(params instanceof Map)) { - respond(id, null, error(Protocol.INVALID_PARAMS, "Function " + functionId + " params must be an object.")); - return; - } - String function = (String) functionId; - String operation = operationId instanceof String ? (String) operationId : null; - Map arguments = Json.object(params); - AtomicBoolean reporting = new AtomicBoolean(true); - CallContext.ProgressSink progress = (message, percentage) -> { - if (!reporting.get()) { - return; - } - Map report = new LinkedHashMap<>(); - report.put("operationID", operationId); - if (message != null) { - report.put("message", Protocol.boundedText(message)); - } - if (percentage != null && !percentage.isNaN() && !percentage.isInfinite()) { - report.put("percentage", Math.min(100L, Math.max(0L, Math.round(percentage)))); - } - Map notification = message(null, Protocol.FUNCTION_PROGRESS); - notification.put("params", report); - write(notification); - }; - run(id, function, operation, context -> handler.handle(arguments, context), Backend::functionResult, - () -> reporting.set(false), progress); - } - - private interface Invocation { - Object invoke(CallContext context) throws Exception; - } - - private interface Shape { - Object apply(Object result); - } - - private void run(Object id, String functionId, String operationId, Invocation invoke, Shape shape, Runnable onSettled) { - run(id, functionId, operationId, invoke, shape, onSettled, null); - } - - /** Runs one handler with its own cancellation and answers exactly once. */ - private void run(Object id, String functionId, String operationId, Invocation invoke, Shape shape, - Runnable onSettled, CallContext.ProgressSink progress) { - Cancellation cancellation = new Cancellation(); - String key = requestKey(id); - synchronized (lock) { - incoming.put(key, new Call(cancellation, functionId)); - } - CallContext context = new CallContext(cancellation, this, functionId, operationId, progress); - executor.execute(() -> { - Object result = null; - Map error = null; - try { - Object value = invoke.invoke(context); - cancellation.throwIfCancelled(); - result = shape.apply(value); - } catch (Throwable failure) { - if (!cancellation.isCancelled()) { - log((functionId != null ? "Function " + functionId : "A request") + " failed:", failure); - } - error = toResponseError(failure, cancellation); - } - try { - if (onSettled != null) { - onSettled.run(); - } - } finally { - synchronized (lock) { - incoming.remove(key); - } - // Work belongs to its call: tools still running when the handler - // returned end now. Other calls have their own cancellation. - cancellation.cancel("The call ended."); - // Answered whatever the clean-up did, so Lithe never waits for - // its timeout. - respond(id, result, error); - } - }); - } - - private Backend declareBuild(String method, Handler handler) { - if (handler == null) { - throw new IllegalArgumentException("A build handler must not be null."); - } - synchronized (lock) { - if (phase != Phase.IDLE) { - throw new PluginSdkError("sdk.build.lateDeclaration", "Declare build handlers before listen()."); - } - buildHandlers.put(method, handler); - } - return this; - } - - private static void checkDeclared(String method, Object handler) { - if (method == null || Protocol.isReservedMethod(method)) { - throw new PluginSdkError("sdk.method.reserved", method + " is managed by the SDK and Lithe."); - } - if (handler == null) { - throw new IllegalArgumentException("A handler must not be null."); - } - } - - private void checkNewFunction(String id, Handler handler) { - if (!Protocol.isFunctionId(id)) { - throw new PluginSdkError("sdk.function.invalidID", "Function ID \"" + id - + "\" must be dot-separated lowerCamelCase segments of at most 128 bytes."); - } - if (handler == null) { - throw new IllegalArgumentException("A function handler must not be null."); - } - if (functions.containsKey(id)) { - throw new PluginSdkError("sdk.function.duplicate", "Function " + id + " is already declared."); - } - if (functions.size() >= Protocol.MAX_FUNCTIONS) { - throw new PluginSdkError("sdk.function.limit", - "A backend serves at most " + Protocol.MAX_FUNCTIONS + " functions."); - } - } - - /** True until the {@code initialize} answer fixes the initial function list. */ - private boolean beforeAnswer() { - return phase == Phase.IDLE || phase == Phase.LISTENING || phase == Phase.INITIALIZING; - } - - private void requireFunctions() { - if (phase != Phase.READY || !negotiated.functions()) { - throw new PluginSdkError("sdk.functions.unavailable", "The host has not negotiated plugin functions."); - } - } - - private static PluginSdkError unknownFunction(String id) { - return new PluginSdkError("sdk.function.unknown", "Function " + id + " is not registered."); - } - - private CompletableFuture queueRegistration(Supplier> change) { - synchronized (lock) { - CompletableFuture next = registrations.thenComposeAsync(ignored -> { - try { - return change.get(); - } catch (RuntimeException error) { - return failed(error); - } - }, executor); - registrations = next.handle((result, error) -> null); - return next; - } - } - - private void checkOutgoing(String method) { - if (method == null || Protocol.isReservedMethod(method)) { - throw new PluginSdkError("sdk.method.reserved", method - + " is reserved. A backend never asks the host to run a domain operation or a function."); - } - if (phase != Phase.READY) { - throw new PluginSdkError("sdk.session.notReady", "The session is not initialized."); - } - } - - private CompletableFuture request(String method, Object params) { - String id = "sdk-" + nextRequestId.getAndIncrement(); - CompletableFuture future = new CompletableFuture<>(); - synchronized (lock) { - if (phase == Phase.EXITED) { - future.completeExceptionally(new PluginSdkError("sdk.session.closed", "The session ended.")); - return future; - } - outgoing.put(id, new Pending(future, method)); - } - Map message = message(id, method); - if (params != null) { - message.put("params", params); - } - write(message); - return future; - } - - private void settleOutgoing(Map message) { - Object id = message.get("id"); - Pending pending; - synchronized (lock) { - pending = id instanceof String ? outgoing.remove(id) : null; - } - if (pending == null) { - return; - } - Object error = message.get("error"); - // Completed on a handler thread: the reader thread never runs plugin code. - if (error instanceof Map) { - Map failure = Json.object(error); - Object data = failure.get("data"); - String code = data instanceof Map ? Json.string(Json.object(data), "code") : null; - String text = Json.string(failure, "message"); - Object rpcCode = failure.get("code"); - PluginSdkError rejection = new PluginSdkError( - code == null ? "sdk.host.error" : code, - text == null ? pending.method + " failed." : text, - rpcCode instanceof Number ? ((Number) rpcCode).intValue() : null); - executor.execute(() -> pending.future.completeExceptionally(rejection)); - } else { - Object result = message.get("result"); - executor.execute(() -> pending.future.complete(result)); - } - } - - private void cancelAll(String reason) { - List calls; - synchronized (lock) { - calls = new ArrayList<>(incoming.values()); - } - for (Call call : calls) { - call.cancellation.cancel(reason); - } - } - - private void finish(int code) { - List pending; - synchronized (lock) { - if (phase == Phase.EXITED) { - return; - } - phase = Phase.EXITED; - exitCode = code; - pending = new ArrayList<>(outgoing.values()); - outgoing.clear(); - } - cancelAll("The session ended."); - Processes.killAll(); - for (Pending request : pending) { - request.future.completeExceptionally(new PluginSdkError("sdk.session.closed", "The session ended.")); - } - // Running handlers finish on their own threads; nothing new starts. - executor.shutdown(); - } - - private void respond(Object id, Object result, Map error) { - if (phase == Phase.EXITED) { - return; - } - Map message = message(id, null); - if (error != null) { - message.put("error", error); - } else { - message.put("result", result); - } - try { - writeEncoded(Framing.encode(message)); - } catch (Json.JsonException failure) { - // A result JSON cannot encode must not end the session. - log("The result could not be encoded:", failure); - Map fallback = message(id, null); - fallback.put("error", toResponseError(failure, null)); - write(fallback); - } - } - - private void write(Map message) { - writeEncoded(Framing.encode(message)); - } - - private void writeEncoded(byte[] frame) { - synchronized (writeLock) { - OutputStream stream = output; - if (stream == null || phase == Phase.EXITED) { - return; - } - try { - stream.write(frame); - stream.flush(); - } catch (IOException error) { - // The host closed the pipe; the input side ends the session. - log("Writing to the host failed: " + error.getMessage()); - } - } - } - - /** - * The JSON-RPC error object for a handler failure. Only {@link PluginError} - * carries a plugin code; any other exception becomes a generic internal - * error so that stack traces and machine paths never reach the host's UI. - */ - static Map toResponseError(Throwable failure, Cancellation cancellation) { - Throwable error = unwrap(failure); - if (cancellation != null && cancellation.isCancelled() && error instanceof CancelledException) { - return error(Protocol.REQUEST_CANCELLED, "The request was cancelled."); - } - if (error instanceof PluginError) { - Map response = error(Protocol.REQUEST_FAILED, Protocol.boundedText(error.getMessage())); - response.put("data", singleton("code", ((PluginError) error).code())); - return response; - } - // Unexpected exceptions often embed absolute paths or internals; the - // caller logs the details to stderr instead. - return error(Protocol.INTERNAL_ERROR, "The plugin backend failed unexpectedly."); - } - - /** - * Stamps the canonical format on a build result, so a handler can return the - * plain document. A result in another format is a backend bug: Core would - * refuse it as {@code plugin.build.invalidOutput}, so it fails here with a log. - */ - static Object buildResult(String method, Object result) { - String expected = BUILD_FORMATS.get(method); - if (!(result instanceof Map)) { - throw new PluginSdkError("sdk.build.invalidResult", method + " must return a " + expected + " object."); - } - Map document = new LinkedHashMap<>(Json.object(result)); - Object format = document.get("format"); - if (format != null && !expected.equals(format)) { - throw new PluginSdkError("sdk.build.invalidResult", - method + " returned format " + format + ", not " + expected + "."); - } - Object version = document.get("version"); - if (version != null && !(version instanceof Number && ((Number) version).doubleValue() == 1)) { - throw new PluginSdkError("sdk.build.invalidResult", method + " returned version " + version + ", not 1."); - } - document.put("format", expected); - document.put("version", 1); - return document; - } - - /** - * A function result as the JSON value Core accepts. A value JSON cannot - * encode, or one over Core's 1 MiB limit, fails here as a backend bug with a - * log line, instead of breaking the response frame or being refused by Core. - */ - static Object functionResult(Object result) { - String encoded; - try { - encoded = Json.stringify(result); - } catch (Json.JsonException error) { - throw new PluginSdkError("sdk.function.invalidResult", "The result is not JSON: " + error.getMessage()); - } - if (encoded.getBytes(StandardCharsets.UTF_8).length > Protocol.MAX_RESULT_BYTES) { - throw new PluginSdkError("sdk.function.invalidResult", - "The result exceeds " + Protocol.MAX_RESULT_BYTES + " bytes."); - } - return result; - } - - private static boolean offers(Object entry, int version) { - if (!(entry instanceof Map)) { - return false; - } - Object versions = ((Map) entry).get("versions"); - if (!(versions instanceof List)) { - return false; - } - for (Object offered : (List) versions) { - if (offered instanceof Number && ((Number) offered).doubleValue() == version) { - return true; - } - } - return false; - } - - /** {@code capabilities.experimental}, or an empty map. */ - private static Map experimental(Object capabilities) { - Object experimental = capabilities instanceof Map ? ((Map) capabilities).get("experimental") : null; - return experimental instanceof Map ? Json.object(experimental) : Collections.emptyMap(); - } - - /** The JSON-RPC ID as a map key; numbers and strings never collide. */ - private static String requestKey(Object id) { - if (id instanceof Number) { - Number number = (Number) id; - return "number:" + (number.doubleValue() == number.longValue() ? Long.toString(number.longValue()) : number); - } - return (id instanceof String ? "string:" : "other:") + id; - } - - private static Map message(Object id, String method) { - Map message = new LinkedHashMap<>(); - message.put("jsonrpc", "2.0"); - if (id != null || method == null) { - message.put("id", id); - } - if (method != null) { - message.put("method", method); - } - return message; - } - - private static Map error(int code, String text) { - Map error = new LinkedHashMap<>(); - error.put("code", code); - error.put("message", text); - return error; - } - - private static Map singleton(String key, Object value) { - Map map = new LinkedHashMap<>(); - map.put(key, value); - return map; - } - - private static Throwable unwrap(Throwable error) { - Throwable current = error; - while (current instanceof CompletionException && current.getCause() != null) { - current = current.getCause(); - } - return current; - } - - private static CompletableFuture failed(Throwable error) { - CompletableFuture future = new CompletableFuture<>(); - future.completeExceptionally(error); - return future; - } - - /** One host request being handled. */ - private static final class Call { - final Cancellation cancellation; - final String functionId; - - Call(Cancellation cancellation, String functionId) { - this.cancellation = cancellation; - this.functionId = functionId; - } - } - - /** One request this backend sent to the host. */ - private static final class Pending { - final CompletableFuture future; - final String method; - - Pending(CompletableFuture future, String method) { - this.future = future; - this.method = method; - } - } - - /** Daemon threads, so a handler still running never keeps the process alive after exit. */ - private static final class ThreadNamer implements java.util.concurrent.ThreadFactory { - private final AtomicInteger next = new AtomicInteger(1); - - @Override - public Thread newThread(Runnable runnable) { - Thread thread = new Thread(runnable, "lithe-sdk-call-" + next.getAndIncrement()); - thread.setDaemon(true); - return thread; - } - } -} diff --git a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/CallContext.java b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/CallContext.java deleted file mode 100644 index 519359082..000000000 --- a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/CallContext.java +++ /dev/null @@ -1,88 +0,0 @@ -package io.github.litheidea.plugin.sdk; - -import java.io.IOException; -import java.util.List; - -/** - * What a handler gets besides its params: the call's cancellation, tools that - * end with the call, the backend's log, and progress for plugin functions. - */ -public final class CallContext { - /** Sends one progress report; absent outside plugin functions. */ - interface ProgressSink { - void report(String message, Double percentage); - } - - private final Cancellation cancellation; - private final Backend backend; - private final String functionId; - private final String operationId; - private final ProgressSink progress; - - CallContext(Cancellation cancellation, Backend backend, String functionId, String operationId, ProgressSink progress) { - this.cancellation = cancellation; - this.backend = backend; - this.functionId = functionId; - this.operationId = operationId; - this.progress = progress; - } - - /** - * Cancelled when the host cancels the call, the backend shuts down, or the - * call ends; work a handler leaves running after it returned is stopped. - */ - public Cancellation cancellation() { - return cancellation; - } - - public boolean isCancelled() { - return cancellation.isCancelled(); - } - - /** Throws {@link CancelledException} once the call was cancelled. */ - public void throwIfCancelled() { - cancellation.throwIfCancelled(); - } - - /** - * Runs a tool that belongs to this call: cancelling the call or ending it - * stops the tool, and the backend shutting down ends it. - */ - public Processes.Result run(List command, Processes.Options options) throws IOException { - Processes.Options settings = options == null ? new Processes.Options() : options.copy(); - Cancellation own = settings.cancellation(); - settings.cancellation(own == null ? cancellation : cancellation.combinedWith(own)); - return Processes.run(command, settings); - } - - /** Runs a tool that belongs to this call with default options. */ - public Processes.Result run(List command) throws IOException { - return run(command, null); - } - - /** Writes a diagnostic line to stderr, which the host keeps in its log. */ - public void log(String message) { - backend.log(message); - } - - /** - * Reports progress of a plugin function. {@code message} is cut to 1024 - * bytes and {@code percentage} rounded into 0-100; either may be null. - * Reports after the call ended, and from build requests, are dropped. - */ - public void progress(String message, Double percentage) { - if (progress != null) { - progress.report(message, percentage); - } - } - - /** The function this call runs, or null for a build or LSP request. */ - public String functionId() { - return functionId; - } - - /** The host's operation ID of a function call, or null. */ - public String operationId() { - return operationId; - } -} diff --git a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Cancellation.java b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Cancellation.java deleted file mode 100644 index 007562ac2..000000000 --- a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Cancellation.java +++ /dev/null @@ -1,104 +0,0 @@ -package io.github.litheidea.plugin.sdk; - -import java.util.ArrayList; -import java.util.List; - -/** - * Cancellation of one call, or of work a plugin manages itself. Listeners run - * once, on the thread that cancels; a listener added after cancellation runs - * immediately. A listener that throws is logged to stderr and does not keep - * the others, or the thread that cancelled, from running on. Cancelling never interrupts threads, because an interrupt - * closes NIO channels a handler may be writing state through; long work checks - * {@link #isCancelled()} or {@link #throwIfCancelled()} instead. - */ -public final class Cancellation { - private final Object lock = new Object(); - private final List listeners = new ArrayList<>(); - private String reason; - - /** Cancels with {@code reason}; later calls have no effect. */ - public void cancel(String reason) { - List pending; - synchronized (lock) { - if (this.reason != null) { - return; - } - this.reason = reason == null ? "The request was cancelled." : reason; - pending = new ArrayList<>(listeners); - listeners.clear(); - } - for (Runnable listener : pending) { - runIsolated(listener); - } - } - - public boolean isCancelled() { - synchronized (lock) { - return reason != null; - } - } - - /** Throws {@link CancelledException} once cancelled. */ - public void throwIfCancelled() { - String current; - synchronized (lock) { - current = reason; - } - if (current != null) { - throw new CancelledException(current); - } - } - - /** - * Runs {@code listener} on cancellation. The returned handle removes it, - * for work that finished first. - */ - public Registration onCancel(Runnable listener) { - synchronized (lock) { - if (reason == null) { - listeners.add(listener); - return () -> { - synchronized (lock) { - listeners.remove(listener); - } - }; - } - } - runIsolated(listener); - return () -> {}; - } - - /** - * A token cancelled when either this one or {@code other} is; for work that - * a plugin may also stop for its own reasons. - */ - public Cancellation combinedWith(Cancellation other) { - Cancellation combined = new Cancellation(); - Registration first = onCancel(() -> combined.cancel(reasonOrDefault())); - Registration second = other.onCancel(() -> combined.cancel(other.reasonOrDefault())); - combined.onCancel(() -> { - first.remove(); - second.remove(); - }); - return combined; - } - - private static void runIsolated(Runnable listener) { - try { - listener.run(); - } catch (RuntimeException error) { - System.err.println("A cancellation listener failed: " + error); - } - } - - private String reasonOrDefault() { - synchronized (lock) { - return reason == null ? "The request was cancelled." : reason; - } - } - - /** Removes a cancellation listener. */ - public interface Registration { - void remove(); - } -} diff --git a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/CancelledException.java b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/CancelledException.java deleted file mode 100644 index dd016767e..000000000 --- a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/CancelledException.java +++ /dev/null @@ -1,14 +0,0 @@ -package io.github.litheidea.plugin.sdk; - -/** - * Thrown when the call a piece of work belongs to was cancelled or ended. - * A handler normally lets it propagate; the SDK then answers the host with - * {@code RequestCancelled}. - */ -public class CancelledException extends RuntimeException { - private static final long serialVersionUID = 1L; - - public CancelledException(String message) { - super(message); - } -} diff --git a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Framing.java b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Framing.java deleted file mode 100644 index 36f76f63d..000000000 --- a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Framing.java +++ /dev/null @@ -1,138 +0,0 @@ -package io.github.litheidea.plugin.sdk; - -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; - -/** - * LSP base-protocol framing: {@code Content-Length} headers followed by a UTF-8 - * JSON body. Lithe talks to every plugin backend this way, so the SDK owns the - * framing and plugin code never writes to stdout itself. - */ -public final class Framing { - /** Largest header block accepted from the host. */ - static final int MAX_HEADER_BYTES = 8192; - - /** Largest body accepted from the host. Larger frames end the session. */ - public static final int MAX_FRAME_BYTES = 64 * 1024 * 1024; - - /** Stands for a body that is not JSON; compared by identity, so no host message can mimic it. */ - public static final Object MALFORMED = new Object() { - @Override - public String toString() { - return "MALFORMED"; - } - }; - - private static final byte[] HEADER_END = {'\r', '\n', '\r', '\n'}; - - private Framing() {} - - /** A framing error the session cannot recover from. */ - public static final class FramingException extends RuntimeException { - private static final long serialVersionUID = 1L; - - FramingException(String message) { - super(message); - } - } - - /** Encodes one JSON-RPC message as a complete frame. */ - public static byte[] encode(Object message) { - byte[] body = Json.stringify(message).getBytes(StandardCharsets.UTF_8); - byte[] header = ("Content-Length: " + body.length + "\r\n\r\n").getBytes(StandardCharsets.US_ASCII); - byte[] frame = Arrays.copyOf(header, header.length + body.length); - System.arraycopy(body, 0, frame, header.length, body.length); - return frame; - } - - /** Incrementally splits a byte stream into decoded JSON-RPC messages. */ - public static final class Decoder { - private byte[] buffer = new byte[8192]; - private int length; - - /** - * Appends {@code count} bytes of {@code chunk} and returns every complete - * message they finished. A body that is not JSON is returned as - * {@link #MALFORMED} so the caller can report it without losing the - * framing position. - */ - public List push(byte[] chunk, int offset, int count) { - ensureCapacity(length + count); - System.arraycopy(chunk, offset, buffer, length, count); - length += count; - List messages = new ArrayList<>(); - int start = 0; - for (;;) { - int headerEnd = indexOfHeaderEnd(start); - int available = length - start; - // The same bound whether the header arrived whole or in pieces. - if ((headerEnd >= 0 && headerEnd - start > MAX_HEADER_BYTES) - || (headerEnd < 0 && available > MAX_HEADER_BYTES)) { - throw new FramingException("The message header is too long."); - } - if (headerEnd < 0) { - break; - } - int bodyLength = contentLength(new String(buffer, start, headerEnd - start, StandardCharsets.US_ASCII)); - int bodyStart = headerEnd + HEADER_END.length; - if ((long) length < (long) bodyStart + bodyLength) { - break; - } - String body = new String(buffer, bodyStart, bodyLength, StandardCharsets.UTF_8); - start = bodyStart + bodyLength; - try { - messages.add(Json.parse(body)); - } catch (Json.JsonException error) { - messages.add(MALFORMED); - } - } - if (start > 0) { - System.arraycopy(buffer, start, buffer, 0, length - start); - length -= start; - } - return messages; - } - - private int indexOfHeaderEnd(int from) { - for (int index = from; index + HEADER_END.length <= length; index++) { - if (buffer[index] == '\r' && buffer[index + 1] == '\n' - && buffer[index + 2] == '\r' && buffer[index + 3] == '\n') { - return index; - } - } - return -1; - } - - private void ensureCapacity(int required) { - if (required > buffer.length) { - buffer = Arrays.copyOf(buffer, Math.max(required, buffer.length * 2)); - } - } - } - - private static int contentLength(String header) { - for (String line : header.split("\r\n")) { - int separator = line.indexOf(':'); - if (separator < 0) { - continue; - } - if (!line.substring(0, separator).trim().equalsIgnoreCase("content-length")) { - continue; - } - String value = line.substring(separator + 1).trim(); - if (!value.matches("[0-9]+")) { - break; - } - // More than ten digits exceeds the limit whatever they are. - long length = value.length() > 10 ? Long.MAX_VALUE : Long.parseLong(value); - if (length > MAX_FRAME_BYTES) { - throw new FramingException("A message of " + value + " bytes exceeds the " - + MAX_FRAME_BYTES + "-byte limit."); - } - return (int) length; - } - throw new FramingException("A message has no valid Content-Length header."); - } -} diff --git a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Json.java b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Json.java deleted file mode 100644 index 64ff3f59c..000000000 --- a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Json.java +++ /dev/null @@ -1,426 +0,0 @@ -package io.github.litheidea.plugin.sdk; - -import java.math.BigDecimal; -import java.math.BigInteger; -import java.util.ArrayList; -import java.util.Collection; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; - -/** - * Minimal JSON reader and writer for protocol messages and stored state. - * - *

The SDK carries its own codec so that a plugin can shade it next to any - * JSON library without version conflicts. Values map to plain Java types: - * objects to {@code Map} (insertion-ordered), arrays to - * {@code List}, strings to {@code String}, booleans to - * {@code Boolean}, {@code null} to {@code null}, integers to {@code Long} (or - * {@code BigInteger} beyond its range), and other numbers to {@code Double}. - */ -public final class Json { - /** Deeper input is refused instead of overflowing the reader's stack. */ - private static final int MAX_DEPTH = 512; - - private Json() {} - - /** A value that is not JSON, or one JSON cannot represent. */ - public static final class JsonException extends RuntimeException { - private static final long serialVersionUID = 1L; - - public JsonException(String message) { - super(message); - } - } - - /** Parses one JSON document; trailing content other than whitespace is an error. */ - public static Object parse(String text) { - Reader reader = new Reader(text); - reader.skipWhitespace(); - Object value = reader.value(0); - reader.skipWhitespace(); - if (reader.position != text.length()) { - throw reader.error("Unexpected content after the value"); - } - return value; - } - - /** - * Encodes {@code value}. Maps need string keys; numbers must be finite. - * Arrays of objects and any {@link Collection} encode as JSON arrays. - */ - public static String stringify(Object value) { - StringBuilder output = new StringBuilder(); - write(output, value, 0); - return output.toString(); - } - - /** {@code value} as an object, or an empty map when it is {@code null}. */ - @SuppressWarnings("unchecked") - public static Map object(Object value) { - if (value == null) { - return new LinkedHashMap<>(); - } - if (value instanceof Map) { - return (Map) value; - } - throw new JsonException("Expected a JSON object."); - } - - /** The string at {@code key}, or {@code null} when it is missing or not a string. */ - public static String string(Map object, String key) { - Object value = object.get(key); - return value instanceof String ? (String) value : null; - } - - private static void write(StringBuilder output, Object value, int depth) { - if (depth > MAX_DEPTH) { - throw new JsonException("The value is nested too deeply, or contains itself."); - } - if (value == null) { - output.append("null"); - } else if (value instanceof String || value instanceof Character) { - quote(output, value.toString()); - } else if (value instanceof Boolean) { - output.append(value.toString()); - } else if (value instanceof Double || value instanceof Float) { - output.append(decimal(((Number) value).doubleValue())); - } else if (value instanceof Long || value instanceof Integer || value instanceof Short - || value instanceof Byte || value instanceof BigInteger) { - output.append(value.toString()); - } else if (value instanceof BigDecimal) { - output.append(((BigDecimal) value).toString()); - } else if (value instanceof Map) { - output.append('{'); - boolean first = true; - for (Map.Entry entry : ((Map) value).entrySet()) { - if (!(entry.getKey() instanceof String)) { - throw new JsonException("Object keys must be strings."); - } - if (!first) { - output.append(','); - } - first = false; - quote(output, (String) entry.getKey()); - output.append(':'); - write(output, entry.getValue(), depth + 1); - } - output.append('}'); - } else if (value instanceof Collection) { - writeArray(output, ((Collection) value).toArray(), depth); - } else if (value instanceof Object[]) { - writeArray(output, (Object[]) value, depth); - } else { - throw new JsonException("A " + value.getClass().getName() + " is not a JSON value."); - } - } - - private static void writeArray(StringBuilder output, Object[] values, int depth) { - output.append('['); - for (int index = 0; index < values.length; index++) { - if (index > 0) { - output.append(','); - } - write(output, values[index], depth + 1); - } - output.append(']'); - } - - private static String decimal(double value) { - if (Double.isNaN(value) || Double.isInfinite(value)) { - throw new JsonException("JSON has no representation for " + value + "."); - } - // Integral values print without a fraction, as JavaScript prints them. - if (value == Math.rint(value) && Math.abs(value) < 1e15) { - return Long.toString((long) value); - } - return Double.toString(value); - } - - private static void quote(StringBuilder output, String text) { - output.append('"'); - for (int index = 0; index < text.length(); index++) { - char character = text.charAt(index); - switch (character) { - case '"': - output.append("\\\""); - break; - case '\\': - output.append("\\\\"); - break; - case '\n': - output.append("\\n"); - break; - case '\r': - output.append("\\r"); - break; - case '\t': - output.append("\\t"); - break; - case '\b': - output.append("\\b"); - break; - case '\f': - output.append("\\f"); - break; - default: - if (character < 0x20 || isLoneSurrogate(text, index)) { - // A lone surrogate has no UTF-8 encoding; escaping it - // keeps the frame valid, as JSON.stringify does. - output.append(String.format("\\u%04x", (int) character)); - } else { - output.append(character); - } - } - } - output.append('"'); - } - - private static boolean isLoneSurrogate(String text, int index) { - char character = text.charAt(index); - if (Character.isHighSurrogate(character)) { - return index + 1 >= text.length() || !Character.isLowSurrogate(text.charAt(index + 1)); - } - if (Character.isLowSurrogate(character)) { - return index == 0 || !Character.isHighSurrogate(text.charAt(index - 1)); - } - return false; - } - - private static final class Reader { - private final String text; - private int position; - - Reader(String text) { - this.text = text; - } - - Object value(int depth) { - if (depth > MAX_DEPTH) { - throw error("The document is nested too deeply"); - } - if (position >= text.length()) { - throw error("Unexpected end of input"); - } - char character = text.charAt(position); - switch (character) { - case '{': - return object(depth); - case '[': - return array(depth); - case '"': - return string(); - case 't': - return literal("true", Boolean.TRUE); - case 'f': - return literal("false", Boolean.FALSE); - case 'n': - return literal("null", null); - default: - if (character == '-' || (character >= '0' && character <= '9')) { - return number(); - } - throw error("Unexpected character"); - } - } - - private Map object(int depth) { - Map result = new LinkedHashMap<>(); - position++; - skipWhitespace(); - if (peek() == '}') { - position++; - return result; - } - for (;;) { - skipWhitespace(); - if (peek() != '"') { - throw error("Expected a property name"); - } - String key = string(); - skipWhitespace(); - expect(':'); - skipWhitespace(); - result.put(key, value(depth + 1)); - skipWhitespace(); - char next = peek(); - position++; - if (next == '}') { - return result; - } - if (next != ',') { - throw error("Expected ',' or '}'"); - } - } - } - - private List array(int depth) { - List result = new ArrayList<>(); - position++; - skipWhitespace(); - if (peek() == ']') { - position++; - return result; - } - for (;;) { - skipWhitespace(); - result.add(value(depth + 1)); - skipWhitespace(); - char next = peek(); - position++; - if (next == ']') { - return result; - } - if (next != ',') { - throw error("Expected ',' or ']'"); - } - } - } - - private String string() { - position++; - StringBuilder result = new StringBuilder(); - for (;;) { - if (position >= text.length()) { - throw error("Unterminated string"); - } - char character = text.charAt(position++); - if (character == '"') { - return result.toString(); - } - if (character < 0x20) { - throw error("Control character in string"); - } - if (character != '\\') { - result.append(character); - continue; - } - if (position >= text.length()) { - throw error("Unterminated escape"); - } - char escape = text.charAt(position++); - switch (escape) { - case '"': - case '\\': - case '/': - result.append(escape); - break; - case 'b': - result.append('\b'); - break; - case 'f': - result.append('\f'); - break; - case 'n': - result.append('\n'); - break; - case 'r': - result.append('\r'); - break; - case 't': - result.append('\t'); - break; - case 'u': - if (position + 4 > text.length()) { - throw error("Incomplete unicode escape"); - } - int code = 0; - for (int index = 0; index < 4; index++) { - int digit = Character.digit(text.charAt(position++), 16); - if (digit < 0) { - throw error("Invalid unicode escape"); - } - code = code * 16 + digit; - } - result.append((char) code); - break; - default: - throw error("Invalid escape"); - } - } - } - - private Object number() { - int start = position; - if (peek() == '-') { - position++; - } - if (peek() == '0') { - position++; - } else if (isDigit(peek())) { - digits(); - } else { - throw error("Invalid number"); - } - boolean integral = true; - if (peek() == '.') { - integral = false; - position++; - if (!isDigit(peek())) { - throw error("Invalid number"); - } - digits(); - } - if (peek() == 'e' || peek() == 'E') { - integral = false; - position++; - if (peek() == '+' || peek() == '-') { - position++; - } - if (!isDigit(peek())) { - throw error("Invalid number"); - } - digits(); - } - String literal = text.substring(start, position); - if (integral) { - BigInteger value = new BigInteger(literal); - return value.bitLength() < 64 ? (Object) value.longValue() : value; - } - return Double.parseDouble(literal); - } - - private void digits() { - while (isDigit(peek())) { - position++; - } - } - - private Object literal(String word, Object value) { - if (!text.startsWith(word, position)) { - throw error("Unexpected literal"); - } - position += word.length(); - return value; - } - - private void expect(char character) { - if (peek() != character) { - throw error("Expected '" + character + "'"); - } - position++; - } - - private char peek() { - return position < text.length() ? text.charAt(position) : '\0'; - } - - void skipWhitespace() { - while (position < text.length()) { - char character = text.charAt(position); - if (character != ' ' && character != '\t' && character != '\n' && character != '\r') { - return; - } - position++; - } - } - - JsonException error(String message) { - return new JsonException(message + " at offset " + position + "."); - } - - private static boolean isDigit(char character) { - return character >= '0' && character <= '9'; - } - } -} diff --git a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/PluginError.java b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/PluginError.java deleted file mode 100644 index aa7b972de..000000000 --- a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/PluginError.java +++ /dev/null @@ -1,38 +0,0 @@ -package io.github.litheidea.plugin.sdk; - -/** - * A failure a handler reports on purpose. Core ends the call with the stable - * {@code plugin.function.failed} code and forwards {@link #code()} as - * {@code pluginErrorCode}, so the plugin's own UI can tell its failures apart. - * Lithe never branches on it. - * - *

The message is shown to the user as written (Core keeps its first 1024 - * bytes), so it must not contain stack traces or machine paths. - */ -public class PluginError extends RuntimeException { - private static final long serialVersionUID = 1L; - - private final String code; - - /** - * @param code identifier of at most 64 bytes, such as {@code maven.snapshotLocked} - * @param message user-facing text - */ - public PluginError(String code, String message) { - this(code, message, null); - } - - /** As {@link #PluginError(String, String)}; {@code cause} is kept for logs only. */ - public PluginError(String code, String message, Throwable cause) { - super(message, cause); - if (!Protocol.isPluginErrorCode(code)) { - throw new IllegalArgumentException("Plugin error code \"" + code - + "\" must start with a letter, use letters, digits, \".\", \"_\" or \"-\", and be at most 64 bytes."); - } - this.code = code; - } - - public String code() { - return code; - } -} diff --git a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/PluginSdkError.java b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/PluginSdkError.java deleted file mode 100644 index 043ba52c6..000000000 --- a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/PluginSdkError.java +++ /dev/null @@ -1,32 +0,0 @@ -package io.github.litheidea.plugin.sdk; - -/** - * An SDK usage error raised to the plugin author, or a failure the host - * reported for a request this backend sent. {@link #rpcCode()} carries the - * host's JSON-RPC error code in the latter case. - */ -public class PluginSdkError extends RuntimeException { - private static final long serialVersionUID = 1L; - - private final String code; - private final Integer rpcCode; - - public PluginSdkError(String code, String message) { - this(code, message, null); - } - - public PluginSdkError(String code, String message, Integer rpcCode) { - super(message); - this.code = code; - this.rpcCode = rpcCode; - } - - public String code() { - return code; - } - - /** The host's JSON-RPC error code, or {@code null} for a local usage error. */ - public Integer rpcCode() { - return rpcCode; - } -} diff --git a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Processes.java b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Processes.java deleted file mode 100644 index 76c36ca7a..000000000 --- a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Processes.java +++ /dev/null @@ -1,623 +0,0 @@ -package io.github.litheidea.plugin.sdk; - -import java.io.ByteArrayOutputStream; -import java.io.File; -import java.io.IOException; -import java.io.InputStream; -import java.lang.reflect.Field; -import java.lang.reflect.Method; -import java.nio.ByteBuffer; -import java.nio.CharBuffer; -import java.nio.charset.CharsetDecoder; -import java.nio.charset.CodingErrorAction; -import java.nio.charset.StandardCharsets; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.ScheduledFuture; -import java.util.concurrent.TimeUnit; -import java.util.function.Consumer; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -/** - * Child processes a backend starts for one call. - * - *

Lithe's host offers no process capability in protocol version 1, so a - * backend runs its tools itself; this helper makes sure that the call ending, - * cancelling it, or the backend shutting down also ends the tools it started. - * - *

Process trees: on macOS and Linux every tool leads its own process group, - * as in the Node SDK, on every Java version. Java has no API for that, so the - * tool starts through the system {@code perl}, which calls {@code setpgrp} and - * then replaces itself with the tool, keeping its PID. A stop signals the whole - * group, and once the tool exits on its own the members it left behind, such - * as a background worker, are killed too; descendants that leave the group - * with {@code setsid} are out of reach. Windows, and a system without - * {@code perl}, fall back to {@code ProcessHandle} on Java 9 and newer, found - * by reflection so the SDK still loads on Java 8; it reaches only the - * descendants still attached to the tool. Lithe starts every backend inside - * its own process scope ({@code LITHE_PLUGIN_BACKEND_SCOPE}) and ends whatever - * is left of it when the session ends; that remains the final boundary. - */ -public final class Processes { - /** - * Variables Lithe sets on the backend so it can find and end every process - * the backend started, even one that left its parent. A tool always - * inherits them, also when the plugin replaces the environment. - */ - static final String[] HOST_MARKERS = {"LITHE_PROCESS_SCOPE", "LITHE_PLUGIN_BACKEND_SCOPE"}; - - /** Every tool started through {@link #run} that has not exited or been stopped. */ - private static final Set RUNNING = Collections.newSetFromMap(new ConcurrentHashMap<>()); - - private static final ScheduledExecutorService TIMERS = Executors.newSingleThreadScheduledExecutor(runnable -> { - Thread thread = new Thread(runnable, "lithe-sdk-process-timer"); - thread.setDaemon(true); - return thread; - }); - - private static final ProcessTree TREE = ProcessTree.load(); - - private static final ProcessGroups GROUPS = ProcessGroups.load(); - - private Processes() {} - - /** Options of one {@link #run}; every setter returns this object. */ - public static final class Options { - private Path directory; - private Map environment; - private Cancellation cancellation; - private int maxOutputBytes = 1 << 20; - private long killGraceMillis = 2000; - private Consumer onStdout; - private Consumer onStderr; - - /** Working directory; defaults to the backend's. */ - public Options directory(Path value) { - directory = value; - return this; - } - - /** Replaces the environment; the host's process markers are carried over regardless. */ - public Options environment(Map value) { - environment = value; - return this; - } - - /** Cancelling it stops the tool and makes {@link #run} throw {@link CancelledException}. */ - public Options cancellation(Cancellation value) { - cancellation = value; - return this; - } - - /** Captured bytes per stream; default 1 MiB. Listeners still see everything. */ - public Options maxOutputBytes(int value) { - maxOutputBytes = value; - return this; - } - - /** Delay between the polite and the forced stop; default 2000. */ - public Options killGraceMillis(long value) { - killGraceMillis = value; - return this; - } - - /** Receives stdout as it arrives, decoded as UTF-8 without splitting characters. */ - public Options onStdout(Consumer value) { - onStdout = value; - return this; - } - - /** Receives stderr as it arrives, decoded as UTF-8 without splitting characters. */ - public Options onStderr(Consumer value) { - onStderr = value; - return this; - } - - Cancellation cancellation() { - return cancellation; - } - - Options copy() { - Options copy = new Options(); - copy.directory = directory; - copy.environment = environment; - copy.cancellation = cancellation; - copy.maxOutputBytes = maxOutputBytes; - copy.killGraceMillis = killGraceMillis; - copy.onStdout = onStdout; - copy.onStderr = onStderr; - return copy; - } - } - - /** Exit status and captured output of a tool. */ - public static final class Result { - private final int exitCode; - private final String stdout; - private final String stderr; - private final boolean truncated; - - Result(int exitCode, String stdout, String stderr, boolean truncated) { - this.exitCode = exitCode; - this.stdout = stdout; - this.stderr = stderr; - this.truncated = truncated; - } - - /** The exit code; on POSIX a tool ended by signal N reports 128 + N. */ - public int exitCode() { - return exitCode; - } - - public String stdout() { - return stdout; - } - - public String stderr() { - return stderr; - } - - /** True when either stream exceeded {@code maxOutputBytes}. */ - public boolean truncated() { - return truncated; - } - } - - /** - * Runs {@code command} without a shell and returns its exit status and - * captured output. A non-zero exit returns normally; the caller decides - * what it means for its tool. Cancellation stops the tool and throws - * {@link CancelledException} once it ended. - */ - public static Result run(List command, Options options) throws IOException { - Options settings = options == null ? new Options() : options; - Cancellation cancellation = settings.cancellation; - if (cancellation != null) { - cancellation.throwIfCancelled(); - } - if (command.isEmpty()) { - throw new IllegalArgumentException("A command needs an executable."); - } - ProcessBuilder builder = new ProcessBuilder(new ArrayList<>(command)); - if (settings.directory != null) { - builder.directory(settings.directory.toFile()); - } - if (settings.environment != null) { - Map environment = builder.environment(); - environment.clear(); - environment.putAll(settings.environment); - for (String name : HOST_MARKERS) { - String value = System.getenv(name); - if (value == null) { - environment.remove(name); - } else { - environment.put(name, value); - } - } - } - boolean grouped = GROUPS.available(); - if (grouped) { - builder.command(GROUPS.launcher(command, builder.environment())); - } - Process process = builder.start(); - // Tools get no input; an open pipe would let one wait on it forever. - process.getOutputStream().close(); - Running entry = new Running(process, grouped ? GROUPS.leader(process) : 0); - RUNNING.add(entry); - Capture stdout = new Capture(process.getInputStream(), settings.maxOutputBytes, settings.onStdout, "stdout"); - Capture stderr = new Capture(process.getErrorStream(), settings.maxOutputBytes, settings.onStderr, "stderr"); - Cancellation.Registration registration = cancellation == null - ? () -> {} - : cancellation.onCancel(() -> entry.stop(settings.killGraceMillis)); - int exitCode; - try { - exitCode = waitFor(process, entry); - registration.remove(); - // The tool ended on its own; what it left running, such as a - // background worker, ends with it. A stopped tool stays registered - // until its forced stop, which reaches the same members. - entry.endLeftovers(); - // A descendant out of reach can keep the inherited pipes open after - // the tool exited; the output then ends at the grace period. - stdout.join(settings.killGraceMillis); - stderr.join(settings.killGraceMillis); - } finally { - registration.remove(); - if (!entry.stopping()) { - RUNNING.remove(entry); - } - } - if (cancellation != null) { - cancellation.throwIfCancelled(); - } - String errors = stderr.text(); - if (grouped && exitCode == ProcessGroups.EXEC_FAILED && errors.startsWith(ProcessGroups.EXEC_FAILURE)) { - // The launcher could not start the tool; report it as ProcessBuilder would. - throw new IOException("Cannot run program \"" + command.get(0) + "\": " - + errors.substring(ProcessGroups.EXEC_FAILURE.length()).trim()); - } - return new Result(exitCode, stdout.text(), errors, stdout.truncated() || stderr.truncated()); - } - - /** Runs {@code command} with default options. */ - public static Result run(List command) throws IOException { - return run(command, null); - } - - /** - * Forcibly ends every tool still running. The backend calls it on shutdown, - * on {@code exit}, and when the host closes stdin, so no tool outlives the - * session that started it. - */ - public static void killAll() { - for (Running entry : new ArrayList<>(RUNNING)) { - entry.forceStop(); - } - RUNNING.clear(); - } - - /** Number of tools still running or being stopped, for tests and diagnostics. */ - public static int runningCount() { - return RUNNING.size(); - } - - private static int waitFor(Process process, Running entry) { - boolean interrupted = false; - try { - for (;;) { - try { - return process.waitFor(); - } catch (InterruptedException error) { - // The tool still belongs to this call; end it before giving - // the interrupt back to the caller. - interrupted = true; - entry.forceStop(); - } - } - } finally { - if (interrupted) { - Thread.currentThread().interrupt(); - } - } - } - - /** One started tool and its stop state. */ - private static final class Running { - private final Process process; - /** The tool's process group, or 0 when it has none of its own. */ - private final long group; - private final Object lock = new Object(); - private boolean stopping; - private List tree = Collections.emptyList(); - private ScheduledFuture forceTimer; - - Running(Process process, long group) { - this.process = process; - this.group = group; - } - - /** Kills what the tool left after exiting on its own, unless a stop owns that. */ - void endLeftovers() { - synchronized (lock) { - if (stopping) { - return; - } - } - if (group > 0) { - // While any member lives the group ID cannot be reused, so this - // reaches only what this tool started; an empty group is ignored. - GROUPS.signal(group, true); - } else { - // Windows keeps the parent link of an exited tool, so its - // descendants are still found there. - TREE.destroy(TREE.descendants(process), true); - } - } - - boolean stopping() { - synchronized (lock) { - return stopping; - } - } - - /** Asks the tree to stop, then forces it after {@code graceMillis}. */ - void stop(long graceMillis) { - synchronized (lock) { - if (stopping) { - return; - } - stopping = true; - if (group > 0) { - GROUPS.signal(group, false); - } else { - // Descendants are listed before the tool is signalled: once - // it exits they are reparented and no longer reachable from it. - tree = TREE.descendants(process); - TREE.destroy(tree, false); - } - // Also covers a tool that has not formed its group yet. - process.destroy(); - forceTimer = TIMERS.schedule(this::forceStop, graceMillis, TimeUnit.MILLISECONDS); - } - } - - void forceStop() { - List known; - synchronized (lock) { - stopping = true; - if (forceTimer != null) { - forceTimer.cancel(false); - } - known = new ArrayList<>(tree); - if (group <= 0) { - known.addAll(TREE.descendants(process)); - } - } - if (group > 0) { - GROUPS.signal(group, true); - } - TREE.destroy(known, true); - process.destroyForcibly(); - RUNNING.remove(this); - } - } - - /** Reads one output stream on its own thread, keeping at most {@code limit} bytes. */ - private static final class Capture { - private final ByteArrayOutputStream kept = new ByteArrayOutputStream(); - private final InputStream input; - private final int limit; - private final Consumer listener; - private final CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder() - .onMalformedInput(CodingErrorAction.REPLACE) - .onUnmappableCharacter(CodingErrorAction.REPLACE); - private final Thread thread; - private volatile boolean truncated; - - Capture(InputStream input, int limit, Consumer listener, String name) { - this.input = input; - this.limit = limit; - this.listener = listener; - thread = new Thread(this::read, "lithe-sdk-" + name); - thread.setDaemon(true); - thread.start(); - } - - private void read() { - byte[] chunk = new byte[8192]; - // Holds a multi-byte character split across reads until it is whole. - ByteBuffer pending = ByteBuffer.allocate(chunk.length + 8); - try { - int count; - while ((count = input.read(chunk)) >= 0) { - synchronized (kept) { - int room = Math.max(0, limit - kept.size()); - kept.write(chunk, 0, Math.min(room, count)); - if (count > room) { - truncated = true; - } - } - if (listener != null) { - pending.put(chunk, 0, count); - pending.flip(); - deliver(pending, false); - pending.compact(); - } - } - if (listener != null) { - pending.flip(); - deliver(pending, true); - } - } catch (IOException error) { - // The stream was closed after the grace period; what was read is kept. - } - } - - private void deliver(ByteBuffer bytes, boolean end) { - CharBuffer characters = CharBuffer.allocate(bytes.remaining() + 1); - decoder.decode(bytes, characters, end); - if (end) { - decoder.flush(characters); - } - characters.flip(); - if (characters.hasRemaining()) { - try { - listener.accept(characters.toString()); - } catch (RuntimeException error) { - System.err.println("An output listener failed: " + error); - } - } - } - - void join(long millis) { - try { - thread.join(Math.max(1, millis)); - if (thread.isAlive()) { - input.close(); - thread.join(millis); - } - } catch (InterruptedException error) { - Thread.currentThread().interrupt(); - } catch (IOException error) { - // Closing an output pipe that already failed changes nothing. - } - } - - String text() { - synchronized (kept) { - return new String(kept.toByteArray(), StandardCharsets.UTF_8); - } - } - - boolean truncated() { - return truncated; - } - } - - /** - * A process group per tool on macOS and Linux. The launcher is - * {@code perl -e SCRIPT -- command...}: it calls - * {@code setpgrp(0, 0)} and replaces itself with the tool, so the PID Java - * reports is the tool's and also its group ID. {@code PERL5OPT} would make - * perl load modules before the script runs, so it is withheld from perl and - * handed back to the tool. Signals go through {@code /bin/kill}, which - * Java 8 needs for a negative PID. - */ - private static final class ProcessGroups { - /** Exit code and stderr prefix of a launcher that could not start the tool. */ - static final int EXEC_FAILED = 127; - static final String EXEC_FAILURE = "lithe-sdk-exec-failed: "; - - private static final String SCRIPT = "my $has = shift; my $opt = shift;" - + " $ENV{PERL5OPT} = $opt if $has;" - + " setpgrp(0, 0);" - + " exec { $ARGV[0] } @ARGV;" - + " print STDERR '" + EXEC_FAILURE + "', \"$!\\n\"; exit " + EXEC_FAILED + ";"; - - /** A signal that has not run after this long is abandoned; the host scope remains. */ - private static final long SIGNAL_TIMEOUT_MILLIS = 2000; - - private final String perl; - private final String kill; - private final Method pid; - - private ProcessGroups(String perl, String kill, Method pid) { - this.perl = perl; - this.kill = kill; - this.pid = pid; - } - - static ProcessGroups load() { - if (System.getProperty("os.name", "").startsWith("Windows")) { - return new ProcessGroups(null, null, null); - } - Method pid; - try { - pid = Process.class.getMethod("pid"); - } catch (NoSuchMethodException error) { - // Java 8 keeps the PID in a private field of UNIXProcess. - pid = null; - } - return new ProcessGroups(executable("/usr/bin/perl", "/bin/perl"), - executable("/bin/kill", "/usr/bin/kill"), pid); - } - - private static String executable(String... candidates) { - for (String candidate : candidates) { - if (new File(candidate).canExecute()) { - return candidate; - } - } - return null; - } - - boolean available() { - return perl != null && kill != null; - } - - List launcher(List command, Map environment) { - String option = environment.remove("PERL5OPT"); - List launcher = new ArrayList<>(Arrays.asList(perl, "-e", SCRIPT, "--", - option == null ? "0" : "1", option == null ? "" : option)); - launcher.addAll(command); - return launcher; - } - - /** The tool's PID, which is its group ID; 0 when it cannot be read. */ - long leader(Process process) { - try { - if (pid != null) { - return ((Number) pid.invoke(process)).longValue(); - } - Field field = process.getClass().getDeclaredField("pid"); - field.setAccessible(true); - return field.getInt(process); - } catch (ReflectiveOperationException | RuntimeException error) { - return 0; - } - } - - /** Signals every member of {@code group}; an empty group is ignored. */ - void signal(long group, boolean forcibly) { - ProcessBuilder builder = new ProcessBuilder(kill, "-s", forcibly ? "KILL" : "TERM", "--", "-" + group); - File discard = new File("/dev/null"); - builder.redirectOutput(discard).redirectError(discard); - try { - Process signal = builder.start(); - signal.getOutputStream().close(); - if (!signal.waitFor(SIGNAL_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)) { - signal.destroyForcibly(); - } - } catch (IOException error) { - // The host's process scope ends what this could not reach. - } catch (InterruptedException error) { - Thread.currentThread().interrupt(); - } - } - } - - /** - * {@code ProcessHandle} through reflection: present from Java 9, absent on - * Java 8, where only the direct child can be stopped. - */ - private static final class ProcessTree { - private final Method descendants; - private final Method destroy; - private final Method destroyForcibly; - - private ProcessTree(Method descendants, Method destroy, Method destroyForcibly) { - this.descendants = descendants; - this.destroy = destroy; - this.destroyForcibly = destroyForcibly; - } - - static ProcessTree load() { - try { - Class handle = Class.forName("java.lang.ProcessHandle"); - return new ProcessTree( - Process.class.getMethod("descendants"), - handle.getMethod("destroy"), - handle.getMethod("destroyForcibly")); - } catch (ReflectiveOperationException error) { - return new ProcessTree(null, null, null); - } - } - - List descendants(Process process) { - if (descendants == null) { - return Collections.emptyList(); - } - try { - @SuppressWarnings("unchecked") - Stream stream = (Stream) descendants.invoke(process); - return stream.collect(Collectors.toList()); - } catch (ReflectiveOperationException | RuntimeException error) { - return Collections.emptyList(); - } - } - - void destroy(List handles, boolean forcibly) { - Method method = forcibly ? destroyForcibly : destroy; - if (method == null) { - return; - } - for (Object handle : handles) { - try { - method.invoke(handle); - } catch (ReflectiveOperationException | RuntimeException error) { - // The process already exited, or the platform refused; the - // host's process scope ends what is left. - } - } - } - } -} diff --git a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Protocol.java b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Protocol.java deleted file mode 100644 index 0c7ce3b6a..000000000 --- a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Protocol.java +++ /dev/null @@ -1,109 +0,0 @@ -package io.github.litheidea.plugin.sdk; - -import java.nio.charset.StandardCharsets; -import java.util.regex.Pattern; - -/** - * Wire constants and validation shared with Rust Core - * ({@code rust/lithe-core/src/lsp/interface/plugin_function.rs}) and the Node.js - * SDK. The SDK checks the same rules locally so a plugin author sees a mistake - * when registering a handler instead of as a refused session at runtime. - */ -public final class Protocol { - /** Protocol versions this SDK can answer. */ - public static final int FUNCTIONS_VERSION = 1; - public static final int BUILD_VERSION = 1; - - public static final String INITIALIZE = "initialize"; - public static final String INITIALIZED = "initialized"; - public static final String SHUTDOWN = "shutdown"; - public static final String EXIT = "exit"; - public static final String CANCEL_REQUEST = "$/cancelRequest"; - public static final String FUNCTION_INVOKE = "lithe/function/invoke"; - public static final String FUNCTION_REGISTER = "lithe/function/register"; - public static final String FUNCTION_UNREGISTER = "lithe/function/unregister"; - public static final String FUNCTION_PROGRESS = "lithe/function/progress"; - public static final String BUILD_MODEL = "lithe/build/model"; - public static final String BUILD_DEPENDENCIES = "lithe/build/dependencies"; - - /** JSON-RPC and LSP error codes the SDK answers with. */ - public static final int PARSE_ERROR = -32700; - public static final int INVALID_REQUEST = -32600; - public static final int METHOD_NOT_FOUND = -32601; - public static final int INVALID_PARAMS = -32602; - public static final int INTERNAL_ERROR = -32603; - public static final int SERVER_NOT_INITIALIZED = -32002; - public static final int REQUEST_FAILED = -32000; - public static final int REQUEST_CANCELLED = -32800; - - public static final int MAX_FUNCTIONS = 256; - public static final int MAX_FUNCTION_ID_BYTES = 128; - public static final int MAX_PLUGIN_ERROR_CODE_BYTES = 64; - public static final int MAX_MESSAGE_BYTES = 1024; - public static final int MAX_RESULT_BYTES = 1 << 20; - - private static final Pattern FUNCTION_SEGMENT = Pattern.compile("[a-z][A-Za-z0-9]*"); - private static final Pattern PLUGIN_ERROR_CODE = Pattern.compile("[A-Za-z][A-Za-z0-9._-]*"); - - private Protocol() {} - - /** Dot-separated lowerCamelCase segments, for example {@code snapshot.refresh}. */ - public static boolean isFunctionId(String value) { - if (value == null || value.isEmpty() || utf8Length(value) > MAX_FUNCTION_ID_BYTES) { - return false; - } - for (String segment : value.split("\\.", -1)) { - if (!FUNCTION_SEGMENT.matcher(segment).matches()) { - return false; - } - } - return true; - } - - /** The plugin error code alphabet Core forwards as {@code pluginErrorCode}. */ - public static boolean isPluginErrorCode(String value) { - return value != null - && utf8Length(value) <= MAX_PLUGIN_ERROR_CODE_BYTES - && PLUGIN_ERROR_CODE.matcher(value).matches(); - } - - /** Cuts {@code value} to {@link #MAX_MESSAGE_BYTES} UTF-8 bytes without splitting a character. */ - public static String boundedText(String value) { - return boundedText(value, MAX_MESSAGE_BYTES); - } - - /** Cuts {@code value} to {@code limit} UTF-8 bytes without splitting a character. */ - public static String boundedText(String value, int limit) { - String text = value == null ? "" : value; - int used = 0; - int index = 0; - while (index < text.length()) { - int codePoint = text.codePointAt(index); - int size = codePoint < 0x80 ? 1 : codePoint < 0x800 ? 2 : codePoint < 0x10000 ? 3 : 4; - if (used + size > limit) { - return text.substring(0, index); - } - used += size; - index += Character.charCount(codePoint); - } - return text; - } - - /** - * Methods reserved for the SDK. A plugin cannot install its own handler for - * these, or send them to the host: the lifecycle belongs to the host's - * session management, and {@code lithe/*} belongs to Lithe's typed contracts. - */ - public static boolean isReservedMethod(String method) { - return method.equals(INITIALIZE) - || method.equals(INITIALIZED) - || method.equals(SHUTDOWN) - || method.equals(EXIT) - || method.equals(CANCEL_REQUEST) - || method.startsWith("lithe/"); - } - - static int utf8Length(String value) { - return value.getBytes(StandardCharsets.UTF_8).length; - } -} diff --git a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/StateStore.java b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/StateStore.java deleted file mode 100644 index 24761e254..000000000 --- a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/StateStore.java +++ /dev/null @@ -1,198 +0,0 @@ -package io.github.litheidea.plugin.sdk; - -import java.io.IOException; -import java.net.URISyntaxException; -import java.net.URL; -import java.nio.ByteBuffer; -import java.nio.channels.FileChannel; -import java.nio.charset.StandardCharsets; -import java.nio.file.AtomicMoveNotSupportedException; -import java.nio.file.FileAlreadyExistsException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.nio.file.StandardCopyOption; -import java.nio.file.StandardOpenOption; -import java.nio.file.attribute.PosixFilePermissions; -import java.security.CodeSource; -import java.security.SecureRandom; -import java.util.LinkedHashMap; -import java.util.Map; -import java.util.regex.Pattern; - -/** - * Durable plugin state, in a directory the host gives the backend. - * - *

The host passes writable directories through the {@code ${stateDir}} and - * {@code ${workspaceStateDir}} launch variables; the installed package and the - * host's installation are read-only, because signatures and delta updates are - * computed over their exact bytes. Values are JSON; each key is one file - * replaced atomically, so a crash leaves either the old or the new value, - * never half of one. The envelope matches the Node.js SDK's, so either SDK - * reads what the other wrote. - */ -public final class StateStore { - /** Version of the envelope each value is stored in. */ - private static final int STORE_FORMAT = 1; - private static final Pattern KEY = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,127}"); - private static final SecureRandom RANDOM = new SecureRandom(); - - private final Path root; - - private StateStore(Path root) { - this.root = root; - } - - /** - * Opens a store in {@code directory}, creating the directory. Refuses a - * directory inside the plugin package, found as the nearest directory with - * a {@code plugin.json} above the JAR this class was loaded from. - * - * @param directory absolute path, normally the value of {@code ${workspaceStateDir}} - */ - public static StateStore open(Path directory) throws IOException { - return open(directory, findPackageDirectory()); - } - - /** - * Opens a store in {@code directory}, refusing one inside - * {@code packageDirectory}; null skips that check. - */ - public static StateStore open(Path directory, Path packageDirectory) throws IOException { - if (directory == null || !directory.isAbsolute()) { - throw new PluginSdkError("sdk.state.invalidDirectory", - "The state directory must be an absolute path passed by the host."); - } - // Checked before creating anything, so a refused path leaves the package untouched. - Path resolved = canonical(directory); - if (packageDirectory != null && resolved.startsWith(canonical(packageDirectory))) { - throw new PluginSdkError("sdk.state.readOnlyPackage", - "The state directory is inside the plugin package, which is read-only. Use ${stateDir} or ${workspaceStateDir}."); - } - Files.createDirectories(resolved); - return new StateStore(resolved); - } - - /** The resolved directory, for a backend that keeps its own files next to the store. */ - public Path directory() { - return root; - } - - /** The stored value, or {@code fallback} when the key is missing or unreadable. */ - public Object read(String key, Object fallback) { - Path path = path(key); - if (!Files.isRegularFile(path)) { - return fallback; - } - try { - Object envelope = Json.parse(new String(Files.readAllBytes(path), StandardCharsets.UTF_8)); - if (!(envelope instanceof Map)) { - return fallback; - } - Map fields = (Map) envelope; - Object format = fields.get("litheStateStore"); - if (!(format instanceof Number) || ((Number) format).doubleValue() != STORE_FORMAT - || !fields.containsKey("value")) { - return fallback; - } - return fields.get("value"); - } catch (IOException | RuntimeException error) { - return fallback; - } - } - - /** Stores {@code value}, which must be JSON-encodable, replacing the previous one. */ - public void write(String key, Object value) throws IOException { - Path path = path(key); - Map envelope = new LinkedHashMap<>(); - envelope.put("litheStateStore", STORE_FORMAT); - envelope.put("value", value); - byte[] body; - try { - body = Json.stringify(envelope).getBytes(StandardCharsets.UTF_8); - } catch (Json.JsonException error) { - // Refused before touching the file, so the stored value stays. - throw new PluginSdkError("sdk.state.invalidValue", "The value for " + key + " is not JSON-encodable."); - } - Path temporary = root.resolve(key + ".json." + Long.toHexString(RANDOM.nextLong()) + ".tmp"); - try { - createPrivate(temporary); - try (FileChannel channel = FileChannel.open(temporary, StandardOpenOption.WRITE)) { - ByteBuffer buffer = ByteBuffer.wrap(body); - while (buffer.hasRemaining()) { - channel.write(buffer); - } - channel.force(true); - } - try { - Files.move(temporary, path, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); - } catch (AtomicMoveNotSupportedException error) { - Files.move(temporary, path, StandardCopyOption.REPLACE_EXISTING); - } - } catch (IOException | RuntimeException error) { - Files.deleteIfExists(temporary); - throw error; - } - } - - /** Removes the key; a missing key is not an error. */ - public void delete(String key) throws IOException { - Files.deleteIfExists(path(key)); - } - - private Path path(String key) { - if (key == null || !KEY.matcher(key).matches() || key.endsWith(".tmp")) { - throw new PluginSdkError("sdk.state.invalidKey", "State key \"" + key - + "\" must be 1-128 letters, digits, \".\", \"_\" or \"-\", start with a letter or digit, and not end with \".tmp\"."); - } - return root.resolve(key + ".json"); - } - - /** Creates an empty file only the user can read, where the platform supports it. */ - private static void createPrivate(Path file) throws IOException { - try { - Files.createFile(file, PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------"))); - } catch (UnsupportedOperationException error) { - Files.createFile(file); - } catch (FileAlreadyExistsException error) { - throw new IOException("A temporary state file already exists: " + file.getFileName(), error); - } - } - - /** Resolves symbolic links of the longest existing prefix of {@code path}. */ - static Path canonical(Path path) throws IOException { - Path absolute = path.toAbsolutePath().normalize(); - Path existing = absolute; - while (existing != null && !Files.exists(existing)) { - existing = existing.getParent(); - } - if (existing == null) { - return absolute; - } - return existing.toRealPath().resolve(existing.relativize(absolute)); - } - - /** The nearest directory with a {@code plugin.json} above the JAR this class came from. */ - static Path findPackageDirectory() { - try { - CodeSource source = StateStore.class.getProtectionDomain().getCodeSource(); - URL location = source == null ? null : source.getLocation(); - if (location == null) { - return null; - } - Path current = Paths.get(location.toURI()).toAbsolutePath(); - if (Files.isRegularFile(current)) { - current = current.getParent(); - } - while (current != null) { - if (Files.isRegularFile(current.resolve("plugin.json"))) { - return current; - } - current = current.getParent(); - } - return null; - } catch (URISyntaxException | RuntimeException error) { - return null; - } - } -} diff --git a/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/BackendTest.java b/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/BackendTest.java deleted file mode 100644 index db6e9dff0..000000000 --- a/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/BackendTest.java +++ /dev/null @@ -1,515 +0,0 @@ -package io.github.litheidea.plugin.sdk; - -import static io.github.litheidea.plugin.sdk.Fixtures.map; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Paths; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionException; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicReference; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.Timeout; - -/** Protocol behaviour of {@link Backend}; mirrors {@code sdk/node/test/backend.test.mjs}. */ -@Timeout(value = 30, unit = TimeUnit.SECONDS) -class BackendTest { - private static Backend functionBackend() { - return Backend.create("test-backend", "1.0.0") - .function("snapshot.refresh", (params, context) -> map("echoed", params)) - .function("install.prepare", (params, context) -> null); - } - - private static Map invoke(String functionId, String operationId, Object params) { - return map("functionID", functionId, "operationID", operationId, "params", params); - } - - @SuppressWarnings("unchecked") - private static Map object(Object value) { - return (Map) value; - } - - private static Object errorCode(Map response) { - Object error = response.get("error"); - return error == null ? null : object(error).get("code"); - } - - @Test - void initializeAnswersTheOfferedProtocolsWithSortedFunctions() { - Backend backend = functionBackend().onModel((params, context) -> map("projects", Collections.emptyList())); - TestHost host = new TestHost(backend); - Map result = object(host.initialize().get("result")); - assertEquals( - map("litheBuild", map("version", 1L), - "litheFunctions", map("version", 1L, "functions", - Arrays.asList(map("id", "install.prepare"), map("id", "snapshot.refresh")))), - object(result.get("capabilities")).get("experimental")); - assertEquals(map("name", "test-backend", "version", "1.0.0"), result.get("serverInfo")); - assertTrue(backend.negotiated().functions()); - assertTrue(backend.negotiated().build()); - assertEquals(1, host.close()); - } - - @Test - void aHostThatOffersNothingGetsNoLitheCapabilities() { - TestHost quiet = new TestHost(functionBackend()); - assertEquals(map(), object(object(quiet.initialize(map()).get("result")).get("capabilities")).get("experimental")); - quiet.close(); - - // No build handler: litheBuild is never claimed. Version 2 only: no functions. - TestHost other = new TestHost(functionBackend()); - Map answer = object(other.initialize(map( - "litheBuild", map("versions", Arrays.asList(1L)), - "litheFunctions", map("versions", Arrays.asList(2L)))).get("result")); - assertEquals(map(), object(answer.get("capabilities")).get("experimental")); - other.close(); - } - - @Test - void functionIdsFollowTheSharedContractIncludingTheFixtureCases() throws IOException { - for (String id : Arrays.asList("snapshot.refresh", "install.prepare", "a", "x1.y2Z")) { - assertTrue(Protocol.isFunctionId(id), id); - } - StringBuilder long129 = new StringBuilder(); - for (int index = 0; index < 129; index++) { - long129.append('a'); - } - for (String id : Arrays.asList("", "Not-Valid", "snapshot..refresh", "1abc", "a_b", "é", long129.toString(), "a.")) { - assertFalse(Protocol.isFunctionId(id), id); - } - // Every ID the fixture accepts is accepted locally, and the fixture's - // malformed ID refused, so the SDK never declares what Core rejects. - Map fixture = object(Json.parse(new String(Files.readAllBytes( - Paths.get("../../../shared/contracts/plugins/v1/fixtures/function-protocol.json")), StandardCharsets.UTF_8))); - for (Object entry : (List) fixture.get("negotiation")) { - Map testCase = object(entry); - List ids = new ArrayList<>(); - Object experimental = object(testCase.get("capabilities")).get("experimental"); - Object functions = experimental == null ? null : object(experimental).get("litheFunctions"); - Object declared = functions == null ? null : object(functions).get("functions"); - if (declared != null) { - for (Object function : (List) declared) { - ids.add((String) object(function).get("id")); - } - } - String name = (String) testCase.get("name"); - if (name.contains("malformed")) { - assertTrue(ids.stream().anyMatch(id -> !Protocol.isFunctionId(id)), name); - } else if (object(testCase.get("expected")).get("functions") != null) { - assertTrue(ids.stream().allMatch(Protocol::isFunctionId), name); - } - } - PluginSdkError invalid = assertThrows(PluginSdkError.class, - () -> Backend.create(null, null).function("Bad-ID", (params, context) -> null)); - assertEquals("sdk.function.invalidID", invalid.code()); - PluginSdkError duplicate = assertThrows(PluginSdkError.class, () -> Backend.create(null, null) - .function("a", (params, context) -> null) - .function("a", (params, context) -> null)); - assertEquals("sdk.function.duplicate", duplicate.code()); - } - - @Test - void theLifecycleAndTheLitheNamespaceStayReservedForTheSdk() { - Backend backend = Backend.create(null, null); - for (String method : Arrays.asList("initialize", "shutdown", "exit", "$/cancelRequest", "lithe/build/model", "lithe/anything")) { - assertThrows(PluginSdkError.class, () -> backend.onRequest(method, (params, context) -> null), method); - } - PluginSdkError reserved = assertThrows(PluginSdkError.class, () -> Backend.create(null, null, - map("experimental", map("litheFunctions", map("version", 1))))); - assertEquals("sdk.capabilities.reserved", reserved.code()); - TestHost host = new TestHost(backend); - host.initialize(); - // A backend never asks the host to dispatch a domain operation or a function. - for (String method : Arrays.asList("lithe/function/invoke", "lithe/build/model", "lithe/host/process.run")) { - PluginSdkError error = assertThrows(PluginSdkError.class, () -> backend.sendRequest(method, map()), method); - assertEquals("sdk.method.reserved", error.code()); - } - host.close(); - } - - @Test - void requestsBeforeInitializeAreRefusedWithServerNotInitialized() { - TestHost host = new TestHost(functionBackend()); - Map response = host.request("lithe/function/invoke", invoke("snapshot.refresh", "op", map())); - assertEquals(-32002L, errorCode(response)); - host.close(); - } - - @Test - void anInvocationReturnsItsResultAndNullStaysNull() { - TestHost host = new TestHost(functionBackend()); - host.initialize(); - Map response = host.request("lithe/function/invoke", - invoke("snapshot.refresh", "op-1", map("project", "app"))); - assertEquals(map("echoed", map("project", "app")), response.get("result")); - Map empty = host.request("lithe/function/invoke", invoke("install.prepare", "op-2", null)); - assertTrue(empty.containsKey("result")); - assertNull(empty.get("result")); - Map unknown = host.request("lithe/function/invoke", invoke("missing", "op-3", null)); - assertEquals(-32602L, errorCode(unknown)); - host.close(); - } - - @Test - void progressEchoesTheOperationIsBoundedAndStopsOnceTheCallEnded() { - CountDownLatch release = new CountDownLatch(1); - AtomicReference late = new AtomicReference<>(); - StringBuilder longMessage = new StringBuilder(); - for (int index = 0; index < 2000; index++) { - longMessage.append('x'); - } - Backend backend = Backend.create(null, null).function("long.task", (params, context) -> { - context.progress(longMessage.toString(), 140.4); - context.progress("half", 49.6); - late.set(context); - Fixtures.await(release, "the test to release the call"); - return "done"; - }); - TestHost host = new TestHost(backend); - host.initialize(); - CompletableFuture> response = host.requestAsync("lithe/function/invoke", - invoke("long.task", "wire-7", null), 1001L); - Map first = object(host.waitFor( - message -> "lithe/function/progress".equals(message.get("method")), "progress").get("params")); - assertEquals("wire-7", first.get("operationID")); - assertEquals(1024, ((String) first.get("message")).length()); - assertEquals(100L, first.get("percentage")); - Map second = object(host.waitFor(message -> message.get("params") instanceof Map - && "half".equals(object(message.get("params")).get("message")), "the second report").get("params")); - assertEquals(50L, second.get("percentage")); - release.countDown(); - assertEquals("done", Fixtures.await(response, "the response").get("result")); - late.get().progress("too late", null); - host.close(); - assertTrue(host.messages().stream().noneMatch(message -> message.get("params") instanceof Map - && "too late".equals(object(message.get("params")).get("message")))); - } - - @Test - void aPluginErrorCarriesItsCodeAndOtherFailuresHideTheirDetails() { - Backend backend = Backend.create(null, null) - .function("locked", (params, context) -> { - throw new PluginError("maven.snapshotLocked", "Snapshot is locked."); - }) - .function("crash", (params, context) -> { - throw new IOException("No such file: /Users/someone/secret"); - }); - TestHost host = new TestHost(backend); - host.initialize(); - Map locked = host.request("lithe/function/invoke", invoke("locked", "a", null)); - assertEquals(map("code", -32000L, "message", "Snapshot is locked.", "data", map("code", "maven.snapshotLocked")), - locked.get("error")); - Map crash = object(host.request("lithe/function/invoke", invoke("crash", "b", null)).get("error")); - assertEquals(-32603L, crash.get("code")); - assertFalse(((String) crash.get("message")).contains("/Users/")); - assertFalse(crash.containsKey("data")); - assertThrows(IllegalArgumentException.class, () -> new PluginError("bad code!", "x")); - host.close(); - } - - @Test - void aFailingCancellationListenerNeitherSkipsTheOthersNorTheReply() { - CountDownLatch laterListenerRan = new CountDownLatch(1); - CountDownLatch started = new CountDownLatch(1); - CountDownLatch cancelled = new CountDownLatch(1); - Backend backend = Backend.create(null, null) - .function("listener.settles", (params, context) -> { - context.cancellation().onCancel(() -> { - throw new IllegalStateException("clean-up failed"); - }); - context.cancellation().onCancel(laterListenerRan::countDown); - return "ok"; - }) - .function("listener.cancelled", (params, context) -> { - context.cancellation().onCancel(() -> { - throw new IllegalStateException("clean-up failed"); - }); - context.cancellation().onCancel(cancelled::countDown); - started.countDown(); - Fixtures.await(cancelled, "the cancellation"); - context.throwIfCancelled(); - return "not reached"; - }); - TestHost host = new TestHost(backend); - host.initialize(); - // The call ending runs the listeners before the reply. - assertEquals("ok", host.request("lithe/function/invoke", invoke("listener.settles", "s", null)).get("result")); - Fixtures.await(laterListenerRan, "the listener after the failing one"); - // $/cancelRequest runs them on the read loop, which must keep reading. - CompletableFuture> response = host.requestAsync("lithe/function/invoke", - invoke("listener.cancelled", "c", null), 42L); - Fixtures.await(started, "the handler to start"); - host.notify("$/cancelRequest", map("id", 42L)); - assertEquals(-32800L, errorCode(Fixtures.await(response, "the cancelled call"))); - assertEquals("ok", host.request("lithe/function/invoke", invoke("listener.settles", "t", null)).get("result")); - host.close(); - } - - @Test - void cancelRequestCancelsTheHandlerAndAnswersRequestCancelled() { - CountDownLatch started = new CountDownLatch(1); - CountDownLatch cancelled = new CountDownLatch(1); - Backend backend = Backend.create(null, null).function("wait.forever", (params, context) -> { - context.cancellation().onCancel(cancelled::countDown); - started.countDown(); - Fixtures.await(cancelled, "the cancellation"); - context.throwIfCancelled(); - return "not reached"; - }); - TestHost host = new TestHost(backend); - host.initialize(); - CompletableFuture> response = host.requestAsync("lithe/function/invoke", - invoke("wait.forever", "c", null), 41L); - Fixtures.await(started, "the handler to start"); - host.notify("$/cancelRequest", map("id", 41L)); - assertEquals(-32800L, errorCode(Fixtures.await(response, "the response"))); - host.close(); - } - - @Test - void registerAndUnregisterFunctionGoThroughTheHostAllOrNothing() throws Exception { - Backend backend = Backend.create(null, null); - TestHost host = new TestHost(backend); - host.initialize(); - - CompletableFuture registering = backend.registerFunction("late.one", (params, context) -> "late"); - Map register = host.waitFor( - message -> "lithe/function/register".equals(message.get("method")), "the registration"); - assertEquals(map("functions", Arrays.asList(map("id", "late.one"))), register.get("params")); - host.send(map("id", register.get("id"), "result", null)); - Fixtures.await(registering, "the registration to settle"); - assertEquals("late", host.request("lithe/function/invoke", invoke("late.one", "d", null)).get("result")); - - // A refused registration leaves no handler behind. - CompletableFuture refused = backend.registerFunction("other.one", (params, context) -> 0); - Map second = host.waitFor(message -> "lithe/function/register".equals(message.get("method")) - && object(message.get("params")).toString().contains("other.one"), "the second registration"); - host.send(map("id", second.get("id"), "error", - map("code", -32602L, "message", "No.", "data", map("code", "plugin.function.invalidRegistration")))); - ExecutionException failure = assertThrows(ExecutionException.class, - () -> refused.get(TestHost.WAIT_MILLIS, TimeUnit.MILLISECONDS)); - assertEquals("plugin.function.invalidRegistration", ((PluginSdkError) unwrap(failure)).code()); - assertEquals(Arrays.asList("late.one"), backend.functionIds()); - - CompletableFuture unregistering = backend.unregisterFunction("late.one"); - Map unregister = host.waitFor( - message -> "lithe/function/unregister".equals(message.get("method")), "the withdrawal"); - assertEquals(map("ids", Arrays.asList("late.one")), unregister.get("params")); - host.send(map("id", unregister.get("id"), "result", null)); - Fixtures.await(unregistering, "the withdrawal to settle"); - assertEquals(Collections.emptyList(), backend.functionIds()); - host.close(); - } - - @Test - void buildHandlersGetTheCanonicalFormatStampedAndAWrongFormatFails() { - Backend backend = Backend.create(null, null) - .onModel((params, context) -> map("projects", Arrays.asList(map("name", "app", "path", params.get("root"))))) - .onDependencies((params, context) -> map("format", "litheBuildModel", "projects", Collections.emptyList())); - TestHost host = new TestHost(backend); - host.initialize(); - Map model = host.request("lithe/build/model", map("buildSystemID", "sample", "root", ".")); - assertEquals(map("projects", Arrays.asList(map("name", "app", "path", ".")), "format", "litheBuildModel", "version", 1L), - model.get("result")); - Map dependencies = host.request("lithe/build/dependencies", map("buildSystemID", "sample", "root", ".")); - assertEquals(-32603L, errorCode(dependencies)); - host.close(); - } - - @Test - void aBuildResultCannotReplaceTheVersionTheSdkStamps() { - Backend backend = Backend.create(null, null) - .onModel((params, context) -> map("version", 2, "projects", Collections.emptyList())) - .onDependencies((params, context) -> map("version", 1, "dependencies", Collections.emptyList())); - TestHost host = new TestHost(backend); - host.initialize(); - assertEquals(-32603L, errorCode(host.request("lithe/build/model", map("buildSystemID", "sample", "root", ".")))); - assertEquals(map("version", 1L, "dependencies", Collections.emptyList(), "format", "litheDependencies"), - host.request("lithe/build/dependencies", map("buildSystemID", "sample", "root", ".")).get("result")); - host.close(); - } - - @Test - void aResultJsonCannotEncodeOrOneOverOneMebibyteFailsAndTheSessionContinues() { - Map cycle = map(); - cycle.put("self", cycle); - char[] large = new char[1 << 20]; - Arrays.fill(large, 'x'); - Backend backend = Backend.create(null, null) - .function("result.object", (params, context) -> new Object()) - .function("result.cycle", (params, context) -> cycle) - .function("result.nan", (params, context) -> Double.NaN) - .function("result.large", (params, context) -> new String(large)) - .function("result.fine", (params, context) -> map("ok", true)) - .onModel((params, context) -> map("projects", Arrays.asList(map("name", "app", "path", ".", "size", new Object())))); - TestHost host = new TestHost(backend); - host.initialize(); - int operation = 0; - for (String functionId : Arrays.asList("result.object", "result.cycle", "result.nan", "result.large")) { - Map response = host.request("lithe/function/invoke", invoke(functionId, "op-" + operation++, null)); - assertEquals(-32603L, errorCode(response), functionId); - } - assertEquals(-32603L, errorCode(host.request("lithe/build/model", map("buildSystemID", "sample", "root", ".")))); - assertEquals(map("ok", true), host.request("lithe/function/invoke", invoke("result.fine", "op-last", null)).get("result")); - assertEquals(1, host.close()); - } - - @Test - void buildRequestsAreRefusedWhenTheHostDidNotNegotiateLitheBuild() { - Backend backend = Backend.create(null, null).onModel((params, context) -> map("projects", Collections.emptyList())); - TestHost host = new TestHost(backend); - host.initialize(map("litheFunctions", map("versions", Arrays.asList(1L)))); - assertEquals(-32601L, errorCode(host.request("lithe/build/model", map("buildSystemID", "sample", "root", ".")))); - host.close(); - } - - @Test - void shutdownCancelsRunningCallsAndRunsHooksAndExitAfterShutdownIsZero() { - CountDownLatch started = new CountDownLatch(1); - List order = Collections.synchronizedList(new ArrayList<>()); - Backend backend = Backend.create(null, null) - .function("wait.forever", (params, context) -> { - CountDownLatch cancelled = new CountDownLatch(1); - context.cancellation().onCancel(cancelled::countDown); - started.countDown(); - Fixtures.await(cancelled, "the shutdown to cancel the call"); - context.throwIfCancelled(); - return null; - }) - .onShutdown(() -> order.add("hook")); - TestHost host = new TestHost(backend); - host.initialize(); - CompletableFuture> call = host.requestAsync("lithe/function/invoke", - invoke("wait.forever", "e", null), 2001L); - Fixtures.await(started, "the handler to start"); - Map shutdown = host.request("shutdown", null); - assertTrue(shutdown.containsKey("result")); - assertNull(shutdown.get("result")); - assertEquals(-32800L, errorCode(Fixtures.await(call, "the cancelled call"))); - assertEquals(Arrays.asList("hook"), order); - assertEquals(-32600L, errorCode(host.request("lithe/function/invoke", invoke("wait.forever", "f", null)))); - host.notify("exit", null); - assertEquals(0, host.exitCode()); - } - - @Test - void exitWithoutShutdownOrAClosedInputEndsWithCodeOne() { - TestHost host = new TestHost(Backend.create(null, null)); - host.initialize(); - host.notify("exit", null); - assertEquals(1, host.exitCode()); - assertEquals(1, new TestHost(Backend.create(null, null)).close()); - } - - @Test - void aMalformedBodyIsAnsweredWithAParseErrorAndTheSessionContinues() { - TestHost host = new TestHost(functionBackend()); - byte[] body = "{not json".getBytes(StandardCharsets.UTF_8); - host.sendBytes(("Content-Length: " + body.length + "\r\n\r\n{not json").getBytes(StandardCharsets.UTF_8)); - Map parseError = host.waitFor(message -> message.get("error") instanceof Map - && Long.valueOf(-32700L).equals(object(message.get("error")).get("code")), "the parse error"); - assertTrue(parseError.containsKey("id")); - assertNull(parseError.get("id")); - assertNotNull(host.initialize().get("result")); - host.close(); - } - - @Test - void aJsonValueThatIsNotAMessageObjectIsRefusedAndTheSessionContinues() { - TestHost host = new TestHost(functionBackend()); - host.sendBytes(Framing.encode(Arrays.asList(1, 2))); - host.waitFor(message -> message.get("error") instanceof Map - && Long.valueOf(-32600L).equals(object(message.get("error")).get("code")), "the refusal"); - assertNotNull(host.initialize().get("result")); - host.close(); - } - - @Test - void registeringFromAnInitializeHookJoinsTheInitialListWithoutARequest() { - Backend backend = Backend.create(null, null).function("a.one", (params, context) -> 1); - backend.onInitialize((params, negotiated) -> { - backend.registerFunction("b.two", (arguments, context) -> 2); - backend.unregisterFunction("a.one"); - }); - TestHost host = new TestHost(backend); - Map experimental = object(object(object(host.initialize().get("result")).get("capabilities")).get("experimental")); - assertEquals(Arrays.asList(map("id", "b.two")), object(experimental.get("litheFunctions")).get("functions")); - assertTrue(host.messages().stream().noneMatch(message -> message.get("method") instanceof String - && ((String) message.get("method")).startsWith("lithe/function/"))); - host.close(); - } - - @Test - void runtimeRegistrationNeedsTheNegotiatedProtocol() { - Backend backend = Backend.create(null, null); - TestHost host = new TestHost(backend); - host.initialize(map()); - ExecutionException failure = assertThrows(ExecutionException.class, () -> backend - .registerFunction("late.one", (params, context) -> 1).get(TestHost.WAIT_MILLIS, TimeUnit.MILLISECONDS)); - assertEquals("sdk.functions.unavailable", ((PluginSdkError) unwrap(failure)).code()); - host.close(); - } - - @Test - void aShutdownDuringASlowInitializeHookKeepsTheSessionClosed() { - CountDownLatch hookStarted = new CountDownLatch(1); - CountDownLatch release = new CountDownLatch(1); - Backend backend = Backend.create(null, null).onInitialize((params, negotiated) -> { - hookStarted.countDown(); - Fixtures.await(release, "the test to release initialize"); - }); - TestHost host = new TestHost(backend); - CompletableFuture> initialize = host.requestAsync("initialize", - map("capabilities", map()), 3001L); - Fixtures.await(hookStarted, "the initialize hook"); - assertNull(host.request("shutdown", null).get("error")); - release.countDown(); - assertEquals(-32600L, errorCode(Fixtures.await(initialize, "the initialize answer"))); - assertEquals(-32600L, errorCode(host.request("lithe/function/invoke", invoke("x", "y", null)))); - host.notify("exit", null); - assertEquals(0, host.exitCode()); - } - - @Test - void notificationsReachTheirHandlerOnlyOnceReady() { - CountDownLatch received = new CountDownLatch(1); - AtomicBoolean early = new AtomicBoolean(); - Backend backend = Backend.create(null, null).onNotification("workspace/didChangeConfiguration", params -> { - if (!(params instanceof Map)) { - early.set(true); - } - received.countDown(); - }); - TestHost host = new TestHost(backend); - host.notify("workspace/didChangeConfiguration", "before"); - host.initialize(); - host.notify("workspace/didChangeConfiguration", map("settings", map())); - Fixtures.await(received, "the notification"); - assertFalse(early.get()); - host.close(); - } - - private static Throwable unwrap(Throwable error) { - Throwable current = error; - while ((current instanceof ExecutionException || current instanceof CompletionException) && current.getCause() != null) { - current = current.getCause(); - } - return current; - } -} diff --git a/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/Fixtures.java b/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/Fixtures.java deleted file mode 100644 index c7f13e43d..000000000 --- a/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/Fixtures.java +++ /dev/null @@ -1,133 +0,0 @@ -package io.github.litheidea.plugin.sdk; - -import java.io.IOException; -import java.io.UncheckedIOException; -import java.nio.file.FileVisitResult; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.SimpleFileVisitor; -import java.nio.file.attribute.BasicFileAttributes; -import java.util.LinkedHashMap; -import java.util.Map; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; -import java.util.function.BooleanSupplier; - -/** Small helpers shared by the SDK tests. */ -final class Fixtures { - private Fixtures() {} - - /** A map from alternating keys and values, in order. */ - static Map map(Object... entries) { - Map map = new LinkedHashMap<>(); - for (int index = 0; index < entries.length; index += 2) { - map.put((String) entries[index], entries[index + 1]); - } - return map; - } - - /** The future's value within {@link TestHost#WAIT_MILLIS}. */ - static T await(CompletableFuture future, String description) { - try { - return future.get(TestHost.WAIT_MILLIS, TimeUnit.MILLISECONDS); - } catch (TimeoutException error) { - throw new AssertionError("Timed out waiting for " + description + ".", error); - } catch (InterruptedException error) { - Thread.currentThread().interrupt(); - throw new AssertionError("Interrupted waiting for " + description + ".", error); - } catch (ExecutionException error) { - throw new AssertionError(description + " failed.", error.getCause()); - } - } - - /** Waits for {@code latch} within {@link TestHost#WAIT_MILLIS}. */ - static void await(CountDownLatch latch, String description) { - try { - if (!latch.await(TestHost.WAIT_MILLIS, TimeUnit.MILLISECONDS)) { - throw new AssertionError("Timed out waiting for " + description + "."); - } - } catch (InterruptedException error) { - Thread.currentThread().interrupt(); - throw new AssertionError("Interrupted waiting for " + description + ".", error); - } - } - - /** - * Bounded poll of an OS-level fact, such as a process having exited, that - * offers the test no notification channel. - */ - static void eventually(BooleanSupplier check, String description) { - long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(TestHost.WAIT_MILLIS); - while (!check.getAsBoolean()) { - if (System.nanoTime() > deadline) { - throw new AssertionError("Timed out waiting for " + description + "."); - } - try { - // test-stability: allow(real-sleep) reason: the polled fact is a separate OS process's state with no notification channel; the deadline above bounds the loop. - Thread.sleep(10); - } catch (InterruptedException error) { - Thread.currentThread().interrupt(); - throw new AssertionError("Interrupted waiting for " + description + ".", error); - } - } - } - - /** True while the POSIX process {@code pid} exists. */ - static boolean isAlive(long pid) { - try { - Process probe = new ProcessBuilder("kill", "-0", Long.toString(pid)).start(); - if (!probe.waitFor(TestHost.WAIT_MILLIS, TimeUnit.MILLISECONDS)) { - probe.destroyForcibly(); - throw new AssertionError("kill -0 did not answer."); - } - return probe.exitValue() == 0; - } catch (IOException error) { - throw new UncheckedIOException(error); - } catch (InterruptedException error) { - Thread.currentThread().interrupt(); - throw new AssertionError(error); - } - } - - /** The PID a tool wrote to {@code file}, once it is there. */ - static long awaitPid(Path file) { - eventually(() -> { - try { - return Files.isRegularFile(file) && !new String(Files.readAllBytes(file)).trim().isEmpty(); - } catch (IOException error) { - return false; - } - }, "a PID in " + file.getFileName()); - try { - return Long.parseLong(new String(Files.readAllBytes(file)).trim()); - } catch (IOException error) { - throw new UncheckedIOException(error); - } - } - - static void deleteTree(Path root) { - if (!Files.exists(root)) { - return; - } - try { - Files.walkFileTree(root, new SimpleFileVisitor() { - @Override - public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) throws IOException { - Files.delete(file); - return FileVisitResult.CONTINUE; - } - - @Override - public FileVisitResult postVisitDirectory(Path directory, IOException error) throws IOException { - Files.delete(directory); - return FileVisitResult.CONTINUE; - } - }); - } catch (IOException error) { - throw new UncheckedIOException(error); - } - } -} diff --git a/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/SupportTest.java b/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/SupportTest.java deleted file mode 100644 index ce9771eb9..000000000 --- a/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/SupportTest.java +++ /dev/null @@ -1,370 +0,0 @@ -package io.github.litheidea.plugin.sdk; - -import static io.github.litheidea.plugin.sdk.Fixtures.map; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.junit.jupiter.api.Assumptions.assumeTrue; - -import java.io.IOException; -import java.math.BigInteger; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.stream.Collectors; -import java.util.stream.Stream; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.Timeout; -import org.junit.jupiter.api.condition.DisabledOnOs; -import org.junit.jupiter.api.condition.OS; - -/** Framing, JSON, tools, and state; mirrors {@code sdk/node/test/support.test.mjs}. */ -@Timeout(value = 30, unit = TimeUnit.SECONDS) -class SupportTest { - private Path directory; - - @BeforeEach - void createDirectory() throws IOException { - directory = Files.createTempDirectory("lithe-sdk-").toRealPath(); - } - - @AfterEach - void removeDirectory() { - Processes.killAll(); - Fixtures.deleteTree(directory); - } - - private static List shell(String script) { - return Arrays.asList("/bin/sh", "-c", script); - } - - /** The SDK gives each tool its own process group through the system perl. */ - private static boolean hasProcessGroups() { - return new java.io.File("/usr/bin/perl").canExecute() || new java.io.File("/bin/perl").canExecute(); - } - - private static boolean hasProcessHandle() { - try { - Class.forName("java.lang.ProcessHandle"); - return true; - } catch (ClassNotFoundException error) { - return false; - } - } - - @Test - void framesSplitAcrossChunksOrSeveralInOneChunkDecodeInOrder() { - Framing.Decoder decoder = new Framing.Decoder(); - byte[] first = Framing.encode(map("id", 1, "text", "é✓")); - byte[] second = Framing.encode(map("id", 2)); - byte[] bytes = Arrays.copyOf(first, first.length + second.length); - System.arraycopy(second, 0, bytes, first.length, second.length); - List messages = new ArrayList<>(); - for (int index = 0; index < bytes.length; index += 3) { - messages.addAll(decoder.push(bytes, index, Math.min(3, bytes.length - index))); - } - assertEquals(Arrays.asList(map("id", 1L, "text", "é✓"), map("id", 2L)), messages); - byte[] oversized = "Content-Length: 999999999999\r\n\r\n".getBytes(StandardCharsets.US_ASCII); - assertThrows(Framing.FramingException.class, () -> new Framing.Decoder().push(oversized, 0, oversized.length)); - byte[] missing = "X-Other: 1\r\n\r\n".getBytes(StandardCharsets.US_ASCII); - assertThrows(Framing.FramingException.class, () -> new Framing.Decoder().push(missing, 0, missing.length)); - } - - @Test - void aHeaderOverEightKibibytesIsRefusedAlsoWhenTheWholeFrameArrivesAtOnce() { - StringBuilder header = new StringBuilder("Content-Length: 2\r\nX-Padding: "); - while (header.length() <= 8200) { - header.append('x'); - } - byte[] frame = (header + "\r\n\r\n{}").getBytes(StandardCharsets.US_ASCII); - assertThrows(Framing.FramingException.class, () -> new Framing.Decoder().push(frame, 0, frame.length)); - } - - @Test - void jsonRoundTripsProtocolValuesAndRefusesWhatItCannotRepresent() { - String text = "{\"a\":[1,-2.5,true,false,null,\"\\u00e9\\n\\\"\"],\"big\":123456789012345678901234567890,\"e\":1e3}"; - Map value = Json.object(Json.parse(text)); - assertEquals(Arrays.asList(1L, -2.5, true, false, null, "é\n\""), value.get("a")); - assertEquals(new BigInteger("123456789012345678901234567890"), value.get("big")); - assertEquals(1000.0, value.get("e")); - // Integral doubles print without a fraction, as JavaScript prints them. - String encoded = Json.stringify(value); - assertEquals("{\"a\":[1,-2.5,true,false,null,\"é\\n\\\"\"],\"big\":123456789012345678901234567890,\"e\":1000}", encoded); - assertEquals(encoded, Json.stringify(Json.parse(encoded))); - assertEquals("\"\\ud800x\"", Json.stringify("\ud800x")); - assertEquals("[1,2.5]", Json.stringify(Arrays.asList(1.0, 2.5))); - for (String malformed : Arrays.asList("", "{", "[1,]", "{\"a\" 1}", "01", "1.", "\"\u0001\"", "tru", "{} x")) { - assertThrows(Json.JsonException.class, () -> Json.parse(malformed), malformed); - } - Map numericKeys = new HashMap<>(); - numericKeys.put(1, "x"); - assertThrows(Json.JsonException.class, () -> Json.stringify(numericKeys)); - assertThrows(Json.JsonException.class, () -> Json.stringify(Double.POSITIVE_INFINITY)); - StringBuilder deep = new StringBuilder(); - for (int index = 0; index < 600; index++) { - deep.append('['); - } - assertThrows(Json.JsonException.class, () -> Json.parse(deep.toString())); - } - - @Test - void boundedTextCutsOnCharacterBoundaries() { - assertEquals("ab", Protocol.boundedText("abc", 2)); - assertEquals("é", Protocol.boundedText("éé", 3)); - assertEquals("", Protocol.boundedText("😀", 3)); - assertEquals("", Protocol.boundedText(null)); - assertTrue(Protocol.isPluginErrorCode("maven.snapshotLocked")); - assertFalse(Protocol.isPluginErrorCode("bad code")); - } - - @Test - @DisabledOnOs(OS.WINDOWS) - void runCapturesOutputAndReturnsANonZeroExit() throws IOException { - Processes.Result result = Processes.run(shell("printf out; printf err >&2; exit 3")); - assertEquals(3, result.exitCode()); - assertEquals("out", result.stdout()); - assertEquals("err", result.stderr()); - assertFalse(result.truncated()); - Processes.Result limited = Processes.run(shell("printf xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"), - new Processes.Options().maxOutputBytes(10)); - assertEquals("xxxxxxxxxx", limited.stdout()); - assertTrue(limited.truncated()); - } - - @Test - @DisabledOnOs(OS.WINDOWS) - void streamedOutputKeepsACharacterSplitAcrossReadsWhole() throws IOException { - // 3-byte characters never align with the 8 KiB reads, so reads split them. - String expected = String.join("", Collections.nCopies(50_000, "✓")); - Path file = directory.resolve("checks.txt"); - Files.write(file, expected.getBytes(StandardCharsets.UTF_8)); - StringBuilder streamed = new StringBuilder(); - Processes.Result result = Processes.run(Arrays.asList("cat", file.toString()), - new Processes.Options().onStdout(streamed::append)); - assertEquals(expected, streamed.toString()); - assertEquals(expected, result.stdout()); - } - - @Test - @DisabledOnOs(OS.WINDOWS) - void aReplacedEnvironmentStillCarriesTheHostProcessMarkers() throws IOException { - Map environment = new HashMap<>(); - environment.put("PATH", System.getenv("PATH")); - environment.put("LITHE_PROCESS_SCOPE", "forged"); - environment.put("OWN", "kept"); - Processes.Result result = Processes.run(shell("printf '%s|%s' \"${LITHE_PROCESS_SCOPE-unset}\" \"$OWN\""), - new Processes.Options().environment(environment)); - String inherited = System.getenv("LITHE_PROCESS_SCOPE"); - assertEquals((inherited == null ? "unset" : inherited) + "|kept", result.stdout()); - } - - @Test - @DisabledOnOs(OS.WINDOWS) - void cancellingACallEndsTheToolTreeItStarted() { - assumeTrue(hasProcessGroups() || hasProcessHandle(), - "without perl, Java 8 ends only the direct child; the host scope ends the rest"); - Path grandchildPid = directory.resolve("grandchild.pid"); - // The child starts a grandchild that ignores SIGTERM, so only the forced - // stop can end it. - String grandchild = "trap '' TERM; echo $$ > '" + grandchildPid + "'; while :; do sleep 1; done"; - String child = "/bin/sh -c \"" + grandchild.replace("$", "\\$") + "\" & wait"; - CountDownLatch started = new CountDownLatch(1); - Backend backend = Backend.create(null, null).function("tool.run", (params, context) -> { - started.countDown(); - return context.run(shell(child), new Processes.Options().killGraceMillis(50)).exitCode(); - }); - TestHost host = new TestHost(backend); - host.initialize(); - CompletableFuture> response = host.requestAsync("lithe/function/invoke", - map("functionID", "tool.run", "operationID", "g"), 7L); - Fixtures.await(started, "the tool to start"); - long pid = Fixtures.awaitPid(grandchildPid); - host.notify("$/cancelRequest", map("id", 7L)); - Map answer = Fixtures.await(response, "the cancelled call"); - assertEquals(-32800L, ((Map) answer.get("error")).get("code")); - Fixtures.eventually(() -> !Fixtures.isAlive(pid), "the grandchild to be stopped"); - Fixtures.eventually(() -> Processes.runningCount() == 0, "the registry to drain"); - host.close(); - } - - @Test - @DisabledOnOs(OS.WINDOWS) - void aToolThatExitsTakesItsBackgroundWorkersButNotAnotherCallsTool() throws Exception { - assumeTrue(hasProcessGroups(), "without perl the tool has no process group of its own"); - Path workerPid = directory.resolve("worker.pid"); - Path otherPid = directory.resolve("other.pid"); - Cancellation other = new Cancellation(); - CompletableFuture otherCall = CompletableFuture.supplyAsync(() -> { - try { - return Processes.run(shell("echo $$ > '" + otherPid + "'; exec sleep 600"), - new Processes.Options().cancellation(other).killGraceMillis(50)); - } catch (IOException | CancelledException error) { - return error; - } - }); - long otherTool = Fixtures.awaitPid(otherPid); - // The tool succeeds at once and leaves a worker that holds no pipe. - Processes.Result result = Processes.run(shell( - "sleep 600 >/dev/null 2>&1 & echo $! > '" + workerPid + "'; exit 0")); - assertEquals(0, result.exitCode()); - long worker = Fixtures.awaitPid(workerPid); - Fixtures.eventually(() -> !Fixtures.isAlive(worker), "the left worker to be killed"); - assertTrue(Fixtures.isAlive(otherTool), "a concurrent call's tool keeps running"); - other.cancel("done"); - assertTrue(Fixtures.await(otherCall, "the other call") instanceof CancelledException); - Fixtures.eventually(() -> !Fixtures.isAlive(otherTool), "the other tool to be stopped"); - } - - @Test - @DisabledOnOs(OS.WINDOWS) - void aMissingExecutableFailsToStartAndPerlOptionsReachOnlyTheTool() throws IOException { - IOException missing = assertThrows(IOException.class, - () -> Processes.run(Arrays.asList(directory.resolve("missing").toString()))); - assertTrue(missing.getMessage().contains("missing"), missing.getMessage()); - Map environment = new HashMap<>(); - environment.put("PATH", System.getenv("PATH")); - // Perl would refuse to start with this; the tool only sees it. - environment.put("PERL5OPT", "-MLithe::Missing::Module"); - Processes.Result result = Processes.run(shell("printf %s \"$PERL5OPT\""), - new Processes.Options().environment(environment)); - assertEquals(0, result.exitCode(), result.stderr()); - assertEquals("-MLithe::Missing::Module", result.stdout()); - } - - @Test - @DisabledOnOs(OS.WINDOWS) - void closingTheSessionEndsToolsStillRunning() { - Path childPid = directory.resolve("child.pid"); - CountDownLatch started = new CountDownLatch(1); - Backend backend = Backend.create(null, null).function("tool.run", (params, context) -> { - started.countDown(); - // `exec` keeps the PID: the tool itself ignores SIGTERM. - return context.run(shell("trap '' TERM; echo $$ > '" + childPid + "'; exec sleep 600")).exitCode(); - }); - TestHost host = new TestHost(backend); - host.initialize(); - // No reply is awaited: the session ends while the call is still running. - host.send(map("id", 8L, "method", "lithe/function/invoke", - "params", map("functionID", "tool.run", "operationID", "h"))); - Fixtures.await(started, "the tool to start"); - long pid = Fixtures.awaitPid(childPid); - assertEquals(1, host.close()); - Fixtures.eventually(() -> !Fixtures.isAlive(pid), "the tool to be killed"); - } - - @Test - @DisabledOnOs(OS.WINDOWS) - void aCallThatSettlesStopsTheToolsItsHandlerLeftRunningAndOnlyThose() { - Path leftPid = directory.resolve("left.pid"); - Path otherPid = directory.resolve("other.pid"); - CountDownLatch otherStarted = new CountDownLatch(1); - CountDownLatch releaseOther = new CountDownLatch(1); - Backend backend = Backend.create(null, null) - .function("tool.leave", (params, context) -> { - Thread background = new Thread(() -> { - try { - context.run(shell("echo $$ > '" + leftPid + "'; exec sleep 600")); - } catch (IOException | CancelledException error) { - // Stopped with its call, as intended. - } - }); - background.setDaemon(true); - background.start(); - Fixtures.awaitPid(leftPid); - return "returned"; - }) - .function("tool.other", (params, context) -> { - Thread background = new Thread(() -> { - try { - context.run(shell("echo $$ > '" + otherPid + "'; exec sleep 600")); - } catch (IOException | CancelledException error) { - // Ends with its own call. - } - }); - background.setDaemon(true); - background.start(); - otherStarted.countDown(); - Fixtures.await(releaseOther, "the test to release the other call"); - return null; - }); - TestHost host = new TestHost(backend); - host.initialize(); - CompletableFuture> other = host.requestAsync("lithe/function/invoke", - map("functionID", "tool.other", "operationID", "o"), 11L); - Fixtures.await(otherStarted, "the other call"); - long otherTool = Fixtures.awaitPid(otherPid); - assertEquals("returned", host.request("lithe/function/invoke", - map("functionID", "tool.leave", "operationID", "l")).get("result")); - long leftTool = Fixtures.awaitPid(leftPid); - Fixtures.eventually(() -> !Fixtures.isAlive(leftTool), "the left tool to be stopped"); - assertTrue(Fixtures.isAlive(otherTool), "the other call's tool keeps running"); - releaseOther.countDown(); - Fixtures.await(other, "the other call"); - Fixtures.eventually(() -> !Fixtures.isAlive(otherTool), "the other tool to be stopped"); - host.close(); - } - - @Test - void theStateStoreWritesAtomicallyAndSurvivesReopening() throws IOException { - Path state = directory.resolve("workspace-state"); - StateStore store = StateStore.open(state, null); - assertEquals("none", store.read("snapshot", "none")); - store.write("snapshot", map("generation", 1)); - store.write("snapshot", map("generation", 2)); - assertEquals(map("generation", 2L), StateStore.open(state, null).read("snapshot", null)); - try (Stream files = Files.list(state)) { - assertEquals(Arrays.asList("snapshot.json"), - files.map(path -> path.getFileName().toString()).collect(Collectors.toList())); - } - // The same envelope as the Node.js SDK, so either reads the other's state. - assertEquals("{\"litheStateStore\":1,\"value\":{\"generation\":2}}", - new String(Files.readAllBytes(state.resolve("snapshot.json")), StandardCharsets.UTF_8)); - Files.write(state.resolve("broken.json"), "{".getBytes(StandardCharsets.UTF_8)); - assertNull(store.read("broken", null)); - store.delete("snapshot"); - store.delete("snapshot"); - assertNull(store.read("snapshot", null)); - for (String key : Arrays.asList("", "../escape", "a/b", "x.tmp", ".hidden")) { - PluginSdkError error = assertThrows(PluginSdkError.class, () -> store.write(key, 1), key); - assertEquals("sdk.state.invalidKey", error.code()); - } - } - - @Test - void theStateStoreRefusesAValueJsonCannotEncodeAndKeepsTheStoredOne() throws IOException { - StateStore store = StateStore.open(directory.resolve("state"), null); - store.write("kept", map("ok", true)); - PluginSdkError error = assertThrows(PluginSdkError.class, () -> store.write("kept", new Object())); - assertEquals("sdk.state.invalidValue", error.code()); - assertEquals(map("ok", true), store.read("kept", null)); - } - - @Test - void theStateStoreRefusesTheReadOnlyPluginPackage() throws IOException { - Path packageDirectory = directory.resolve("package"); - Files.createDirectories(packageDirectory); - Files.write(packageDirectory.resolve("plugin.json"), "{}".getBytes(StandardCharsets.UTF_8)); - PluginSdkError refused = assertThrows(PluginSdkError.class, - () -> StateStore.open(packageDirectory.resolve("state"), packageDirectory)); - assertEquals("sdk.state.readOnlyPackage", refused.code()); - // Refused before anything was created inside the package. - assertFalse(Files.exists(packageDirectory.resolve("state"))); - PluginSdkError relative = assertThrows(PluginSdkError.class, - () -> StateStore.open(java.nio.file.Paths.get("relative/state"), null)); - assertEquals("sdk.state.invalidDirectory", relative.code()); - StateStore.open(directory.resolve("state"), packageDirectory); - } -} diff --git a/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/TestHost.java b/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/TestHost.java deleted file mode 100644 index 8fc2c5cd3..000000000 --- a/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/TestHost.java +++ /dev/null @@ -1,215 +0,0 @@ -package io.github.litheidea.plugin.sdk; - -import java.io.ByteArrayOutputStream; -import java.io.InputStream; -import java.io.OutputStream; -import java.io.PrintStream; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.LinkedBlockingQueue; -import java.util.concurrent.TimeUnit; -import java.util.function.Predicate; - -/** - * A host for SDK tests: it writes framed messages to an in-memory backend and - * decodes the answers, with a bounded wait on each. - */ -final class TestHost { - /** Upper bound for any single wait; a broken backend fails fast instead of hanging. */ - static final long WAIT_MILLIS = 5000; - - private final QueueInput input = new QueueInput(); - private final List> messages = new ArrayList<>(); - private final ByteArrayOutputStream errors = new ByteArrayOutputStream(); - private final CompletableFuture done = new CompletableFuture<>(); - private int nextId = 1; - - TestHost(Backend backend) { - Framing.Decoder decoder = new Framing.Decoder(); - OutputStream output = new OutputStream() { - @Override - public void write(int value) { - write(new byte[] {(byte) value}, 0, 1); - } - - @Override - public void write(byte[] bytes, int offset, int length) { - for (Object message : decoder.push(bytes, offset, length)) { - synchronized (messages) { - messages.add(Json.object(message)); - messages.notifyAll(); - } - } - } - }; - PrintStream errorOutput = new PrintStream(errors, true); - Thread thread = new Thread(() -> { - try { - done.complete(backend.listen(input, output, errorOutput)); - } catch (Throwable error) { - done.completeExceptionally(error); - } - }, "test-host-backend"); - thread.setDaemon(true); - thread.start(); - } - - /** Sends one message; {@code jsonrpc} is added. */ - void send(Map message) { - Map framed = new LinkedHashMap<>(); - framed.put("jsonrpc", "2.0"); - framed.putAll(message); - input.push(Framing.encode(framed)); - } - - /** Writes raw bytes, for framing tests. */ - void sendBytes(byte[] bytes) { - input.push(bytes); - } - - /** Sends a request and waits for the backend's response. */ - Map request(String method, Object params) { - return requestAsync(method, params, nextId()).join(); - } - - Map request(String method, Object params, Object id) { - return requestAsync(method, params, id).join(); - } - - /** Sends a request now and resolves with the response once it arrives. */ - CompletableFuture> requestAsync(String method, Object params, Object id) { - send(Fixtures.map("id", id, "method", method, "params", params)); - return CompletableFuture.supplyAsync(() -> waitFor( - message -> id.equals(message.get("id")) && !message.containsKey("method"), - "the response to " + method)); - } - - void notify(String method, Object params) { - send(Fixtures.map("method", method, "params", params)); - } - - Map initialize() { - return initialize(Fixtures.map( - "litheBuild", Fixtures.map("versions", Arrays.asList(1)), - "litheFunctions", Fixtures.map("versions", Arrays.asList(1)))); - } - - Map initialize(Map experimental) { - Map response = request("initialize", Fixtures.map( - "processId", null, - "rootUri", "file:///workspace", - "capabilities", Fixtures.map("experimental", experimental))); - notify("initialized", Fixtures.map()); - return response; - } - - /** The first message, past or future, that {@code matches}. */ - Map waitFor(Predicate> matches, String description) { - long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(WAIT_MILLIS); - synchronized (messages) { - for (;;) { - for (Map message : messages) { - if (matches.test(message)) { - return message; - } - } - long remaining = TimeUnit.NANOSECONDS.toMillis(deadline - System.nanoTime()); - if (remaining <= 0) { - throw new AssertionError("Timed out waiting for " + description + "; messages: " + messages - + "; stderr: " + errors()); - } - try { - messages.wait(remaining); - } catch (InterruptedException error) { - Thread.currentThread().interrupt(); - throw new AssertionError("Interrupted waiting for " + description, error); - } - } - } - } - - List> messages() { - synchronized (messages) { - return new ArrayList<>(messages); - } - } - - String errors() { - return new String(errors.toByteArray(), StandardCharsets.UTF_8); - } - - /** The backend's exit code once the session ended. */ - int exitCode() { - return Fixtures.await(done, "the backend to exit"); - } - - /** Closes the input and returns the backend's exit code. */ - int close() { - input.close(); - return exitCode(); - } - - private Object nextId() { - synchronized (this) { - return (long) nextId++; - } - } - - /** An input stream fed by the test; closing it is the host closing stdin. */ - private static final class QueueInput extends InputStream { - private static final byte[] END = new byte[0]; - private final LinkedBlockingQueue chunks = new LinkedBlockingQueue<>(); - private byte[] current = new byte[0]; - private int position; - private boolean ended; - - void push(byte[] bytes) { - chunks.add(bytes.clone()); - } - - @Override - public void close() { - chunks.add(END); - } - - @Override - public int read() { - byte[] one = new byte[1]; - int count = read(one, 0, 1); - return count < 0 ? -1 : one[0] & 0xff; - } - - @Override - public int read(byte[] buffer, int offset, int length) { - if (ended) { - return -1; - } - while (position >= current.length) { - byte[] next; - try { - // The backend's reader blocks here as it blocks on stdin; the - // test bounds every wait on the backend's answers instead. - next = chunks.take(); - } catch (InterruptedException error) { - Thread.currentThread().interrupt(); - return -1; - } - if (next == END) { - ended = true; - return -1; - } - current = next; - position = 0; - } - int count = Math.min(length, current.length - position); - System.arraycopy(current, position, buffer, offset, count); - position += count; - return count; - } - } -} diff --git a/sdk/java/pom.xml b/sdk/java/pom.xml deleted file mode 100644 index d4f11ee08..000000000 --- a/sdk/java/pom.xml +++ /dev/null @@ -1,77 +0,0 @@ - - - - 4.0.0 - - io.github.lithe-idea - lithe-plugin-sdk-java-parent - 0.1.0 - pom - - - plugin-sdk - examples/sample-build - - - - UTF-8 - 8 - 5.14.0 - - 2026-10-20T00:00:00Z - - - - - - org.junit.jupiter - junit-jupiter - ${junit.version} - test - - - - - - - - - org.apache.maven.plugins - maven-compiler-plugin - 3.14.0 - - - -Xlint:all,-options - - - - - org.apache.maven.plugins - maven-surefire-plugin - 3.5.4 - - - 300 - - - - org.apache.maven.plugins - maven-jar-plugin - 3.4.2 - - - org.apache.maven.plugins - maven-shade-plugin - 3.6.0 - - - - - diff --git a/sdk/node/README.md b/sdk/node/README.md deleted file mode 100644 index f11e666fc..000000000 --- a/sdk/node/README.md +++ /dev/null @@ -1,145 +0,0 @@ -# Lithe plugin backend SDK for Node.js - -`@lithe/plugin-sdk` is the official way to write a plugin backend in -JavaScript: the process a plugin starts through a `runtime` launch -(`{ "kind": "runtime", "tool": "node", … }`). It speaks the LSP base protocol -on stdio and implements Lithe's two backend protocols from -[plugin contract v1](../../shared/contracts/plugins/v1/README.md): - -- **`litheBuild` 1**: typed `lithe/build/model` and `lithe/build/dependencies` - requests, for a build system whose `model` or `dependencies` source is - `provider`. -- **`litheFunctions` 1**: plugin functions, which are plugin-private actions - that Lithe invokes for the same plugin, with progress, cancellation, and - registration at runtime. - -The SDK owns the wire format, negotiation, cancellation, process cleanup, and -error mapping. Your code supplies only handlers. Node 20.3 or newer is -required; declare it as the runtime's `minimumVersion`. - -## A minimal backend - -```js -import { createBackend, PluginError } from './vendor/lithe-plugin-sdk/src/index.mjs'; - -const backend = createBackend({ name: 'example-backend', version: '1.0.0' }); - -backend.build.model(async ({ root }) => ({ - buildSystemID: 'example', - root, - projects: [{ name: 'app', path: root }], -})); - -backend.function('snapshot.refresh', async (params, context) => { - context.progress({ message: 'Refreshing', percentage: 10 }); - const result = await context.run('example-tool', ['refresh'], { cwd: params.root }); - if (result.exitCode !== 0) throw new PluginError('example.refreshFailed', 'The refresh failed.'); - return { refreshedAt: Date.now() }; -}); - -backend.listen(); -``` - -[`examples/sample-build`](examples/sample-build) is a complete plugin with a -build provider, functions, persistent state, a function registered at runtime, -and cancellation. Rust Core's -`rust/lithe-core/tests/plugin_sdk_node.rs` drives it through a real Core -session. - -## Packaging - -The SDK is not published to a registry yet. Copy `src/` into your plugin -package (for example as `backend/vendor/lithe-plugin-sdk/src/`) and import it -by relative path. A plugin package is installed read-only, has no -`node_modules` install step, and must run offline, so everything the backend -imports has to be inside the package. The SDK has no dependencies. - -## API - -| API | Purpose | -| --- | --- | -| `createBackend({ name?, version?, capabilities? })` | Creates the backend. `capabilities` are standard LSP server capabilities; `experimental.litheBuild` and `experimental.litheFunctions` are reserved for the SDK. | -| `backend.build.model(handler)`, `backend.build.dependencies(handler)` | Implements the typed build requests. The SDK stamps `format` and `version`; a handler returning another format or a version other than 1 fails. Core validates the document. `litheBuild` is answered only when a handler exists. | -| `backend.function(id, handler)` | Declares a function offered in `initialize`. | -| `backend.registerFunction(id, handler)`, `backend.unregisterFunction(id)` | Adds or withdraws a function. Called before the `initialize` answer (for example from `onInitialize`), they change the initial list; later they send `lithe/function/register` / `unregister`. Withdrawing revokes running calls. | -| `backend.onInitialize(hook)`, `backend.onShutdown(hook)` | Lifecycle hooks. `onInitialize` receives the `initialize` params and what was negotiated. | -| `backend.onRequest(method, handler)`, `backend.onNotification(method, handler)` | Ordinary LSP methods, such as `textDocument/hover` for a language server. Lifecycle and `lithe/*` methods are reserved. | -| `backend.sendRequest`, `backend.sendNotification` | Ordinary LSP messages to the host; `lithe/*` methods are reserved. | -| `backend.log(...)` | Writes a line to stderr, which the host keeps in its log. | -| `backend.listen()` | Serves stdio until `exit` or the end of input. | -| `PluginError(code, message)` | A failure meant for your own UI. | -| `runProcess(command, args, options)` | Runs a process without a shell, as one cancellable process tree. | -| `openStateStore(directory)` | A small atomic JSON store in a host-provided directory. | - -Every handler receives a context: - -| Member | Meaning | -| --- | --- | -| `signal` | Aborted when Lithe cancels the call, its deadline passes, the function is withdrawn, or the session ends; also once the handler has settled, so work it left running stops. | -| `throwIfCancelled()` | Throws the cancellation if `signal` is aborted. | -| `run(command, args, options)` | `runProcess` bound to the call: cancelling or ending the call ends the whole process tree. | -| `log(...)` | Same as `backend.log`. | -| `functionID`, `operationID` | Functions only. `operationID` is opaque; never derive anything from it. | -| `progress({ message?, percentage? })` | Functions only. The message is cut to 1024 bytes and the percentage clamped to 0–100; reports after the call ends are dropped. | - -## Results and errors - -| Handler outcome | What Lithe sees | -| --- | --- | -| Returns a value (including `null`) | `result.value` | -| Throws `PluginError(code, message)` | `plugin.function.failed` with your `message` and `pluginErrorCode`. The code is for your plugin's own UI; Lithe never branches on it. | -| Throws anything else | `plugin.function.failed` with a generic message, so paths and stack traces never reach the UI. The real error goes to stderr. | -| Ends because of `signal` | `requestCancelled` (or Lithe's timeout/revocation outcome) | - -A result must serialize to at most 1 MiB of JSON. A value JSON cannot encode -(a `BigInt`, a cycle) or a larger one fails like any other unexpected error and -is logged to stderr; the session keeps running. A function ID is dot-separated -lowerCamelCase segments (`snapshot.refresh`), at most 128 bytes, and a session -holds at most 256 functions. A `PluginError` code matches -`[A-Za-z][A-Za-z0-9._-]*`, at most 64 bytes. - -## Rules that keep Lithe in charge - -The SDK leaves your plugin free to define its own functions, params, results, -UI, and state, but these rules keep Lithe's workflows, permissions, and -lifecycle unified. The SDK enforces the ones it can. - -1. **Domain capabilities stay typed.** Build models and dependency trees go - through `backend.build.*`, never through a function, so every host and IDE - workflow reads one contract. Functions are for actions only your plugin - understands. -2. **No calls back into Lithe's dispatch.** A backend never sends - `lithe/function/invoke` or `lithe/build/*`; Lithe would route it back to the - same process, which would then wait for itself. Lithe refuses these with - `plugin.function.recursiveCall`, and the SDK refuses to send any `lithe/*` - method. -3. **stdout belongs to the protocol.** When serving stdio, the SDK moves - `console.log`, `console.info`, and `console.debug` to stderr. Never write to - `process.stdout` yourself. -4. **The package is read-only.** Keep caches, snapshots, and logs under the - directories Lithe passes through launch templates (`${stateDir}`, - `${workspaceStateDir}`). `openStateStore` refuses any directory inside the - plugin package. Writing to the package breaks code signing and update - deltas. -5. **Work ends with its call.** Start processes with `context.run`, and check - `signal` in long loops. On cancellation the SDK stops the process tree - politely, then forcibly. When the handler returns or throws, tools of that - call still running (an unawaited `context.run`, the siblings of a failed - `Promise.all`) are stopped the same way. A tool that exits on its own takes - the workers it left in its process group with it. On `shutdown`, `exit`, or - the end of input the SDK ends every process it started. If the backend - itself is killed, no JavaScript runs; ending what it left is the host's - job, not the SDK's. Lithe finds those processes by the markers it puts in - the backend's environment, so `runProcess` carries them over even when - `env` replaces the environment. -6. **Errors stay presentable.** Throw `PluginError` for failures the user - should see; every other exception is reported generically. - -## Testing - -```bash -npm test --prefix sdk/node -``` - -The tests use a bounded in-memory host (`test/harness.mjs`), which plugin -authors can copy to test their own backends without starting Lithe. diff --git a/sdk/node/examples/sample-build/backend/main.mjs b/sdk/node/examples/sample-build/backend/main.mjs deleted file mode 100644 index 0d3a8267a..000000000 --- a/sdk/node/examples/sample-build/backend/main.mjs +++ /dev/null @@ -1,149 +0,0 @@ -// Sample Lithe plugin backend built on the official Node.js SDK. -// -// It is the build provider of the fictional "sample" build system, whose -// workspace file `lithe-sample.json` lists projects: -// -// { "projects": [{ "name": "app", "path": "app", -// "build": ["node", "build.mjs"], -// "dependencies": [{ "name": "left-pad", "version": "1.3.0" }] }] } -// -// It shows the three things a backend does: -// - answers Lithe's typed build requests (`lithe/build/model`, -// `lithe/build/dependencies`), whose results Lithe itself understands; -// - offers plugin functions for its own UI: `workspace.build` runs a -// multi-step build (prepare, run each project's tool, record a snapshot), -// and `snapshot.read` returns the last snapshot, also after a restart; -// - registers `snapshot.clear` at runtime once there is a snapshot to clear. -// -// A packaged plugin vendors the SDK inside its package, for example as -// `backend/vendor/lithe-plugin-sdk/`, and imports it from there. - -import { readFile } from 'node:fs/promises'; -import { isAbsolute, join, normalize, resolve, sep } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { createBackend, openStateStore, PluginError } from '../../../src/index.mjs'; - -const backend = createBackend({ name: 'sample-build', version: '0.1.0' }); -let workspaceRoot; -let store; - -backend.onInitialize(async ({ params }) => { - // Hosts may send a directory URI with a trailing slash (macOS does); - // `resolve` drops it so `inside` compares like paths. - workspaceRoot = resolve(fileURLToPath(params.rootUri)); - // `--state ${workspaceStateDir}` in plugin.json: the host's writable - // directory for this workspace. The package itself is read-only. - store = openStateStore(argument('--state')); - if (store.read('snapshot', null)) offerClear(); -}); - -backend.build.model(async ({ root }) => { - const config = await readConfig(root); - return { - projects: config.projects.map(({ name, path }) => ({ name, path })), - }; -}); - -backend.build.dependencies(async ({ root, project }) => { - const config = await readConfig(root); - const projects = project ? config.projects.filter(({ path }) => path === project.path) : config.projects; - return { - dependencies: projects.flatMap(({ dependencies = [] }) => - dependencies.map(({ name, version }) => ({ name, version, children: [] })), - ), - }; -}); - -backend.function('workspace.build', async ({ projects: selected }, context) => { - // Step 1: prepare. Read the workspace file and resolve the selection. - context.progress({ message: 'Preparing', percentage: 0 }); - const config = await readConfig('.'); - const projects = selectProjects(config, selected); - - // Step 2: run each project's tool. `context.run` ends the whole process - // tree when Lithe cancels the call, so a cancelled build leaves nothing behind. - const results = []; - for (const [index, project] of projects.entries()) { - context.progress({ message: `Building ${project.name}`, percentage: (100 * index) / projects.length }); - const [tool, ...args] = project.build; - const { exitCode } = await context.run(tool === 'node' ? process.execPath : tool, args, { - cwd: inside(workspaceRoot, project.path), - onStdout: (text) => context.log(`[${project.name}] ${text.trimEnd()}`), - }); - results.push({ name: project.name, exitCode }); - if (exitCode !== 0) { - record(results, 'failed'); - throw new PluginError('sample.buildFailed', `${project.name} failed with exit code ${exitCode}.`); - } - } - - // Step 3: record the outcome, so `snapshot.read` returns it after a restart. - const snapshot = record(results, 'succeeded'); - context.progress({ message: 'Done', percentage: 100 }); - return snapshot; -}); - -backend.function('snapshot.read', () => store.read('snapshot', null)); - -// Reads the latest snapshot when the build ends, not when it starts: two -// overlapping builds then get consecutive generations instead of the same one. -// The read and the write run without an `await` between them. -function record(projects, status) { - const previous = store.read('snapshot', null); - const snapshot = { generation: (previous?.generation ?? 0) + 1, status, projects }; - store.write('snapshot', snapshot); - offerClear(); - return snapshot; -} - -function offerClear() { - if (backend.functionIDs.includes('snapshot.clear')) return; - backend - .registerFunction('snapshot.clear', () => { - store.delete('snapshot'); - return null; - }) - .catch((error) => backend.log('snapshot.clear stays unavailable:', error.message)); -} - -/** Every project, or exactly the named ones; an unknown name is an error, not an empty build. */ -function selectProjects(config, selected) { - if (selected === undefined || selected === null) return config.projects; - if (!Array.isArray(selected) || selected.length === 0 || !selected.every((name) => typeof name === 'string')) { - throw new PluginError('sample.selection', '"projects" must be a non-empty list of project names.'); - } - const unknown = selected.filter((name) => !config.projects.some((project) => project.name === name)); - if (unknown.length > 0) { - throw new PluginError('sample.unknownProject', `Unknown project: ${unknown.join(', ')}.`); - } - return config.projects.filter(({ name }) => selected.includes(name)); -} - -async function readConfig(root) { - let config; - try { - config = JSON.parse(await readFile(join(inside(workspaceRoot, root), 'lithe-sample.json'), 'utf8')); - } catch { - throw new PluginError('sample.config', 'lithe-sample.json is missing or is not valid JSON.'); - } - if (!Array.isArray(config.projects)) { - throw new PluginError('sample.config', 'lithe-sample.json needs a "projects" list.'); - } - return config; -} - -/** `relative` resolved inside `base`; workspace files never name paths outside it. */ -function inside(base, relative) { - const path = normalize(join(base, relative)); - if (isAbsolute(relative) || (path !== base && !path.startsWith(base + sep))) { - throw new PluginError('sample.path', `${relative} is outside the workspace.`); - } - return path; -} - -function argument(name) { - const index = process.argv.indexOf(name); - return index >= 0 ? process.argv[index + 1] : undefined; -} - -await backend.listen(); diff --git a/sdk/node/examples/sample-build/plugin.json b/sdk/node/examples/sample-build/plugin.json deleted file mode 100644 index 57f2aac87..000000000 --- a/sdk/node/examples/sample-build/plugin.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "schemaVersion": 2, - "id": "org.example.sample-build", - "displayName": "Sample Build", - "version": "0.1.0", - "pluginAPIVersion": 3, - "requires": { "buildProviders": 1, "buildSystems": 1, "serverProcess": 1 }, - "vendor": { - "id": "org.example", - "displayName": "Example", - "signatureRequirement": "publisherPackage" - }, - "permissions": ["languageServer.launch", "process.build"], - "activationEvents": ["onBuildSystem:sample"], - "languages": [], - "buildSystems": [ - { - "id": "sample", - "displayName": "Sample", - "detection": { "markers": ["lithe-sample.json"], "nesting": "outermost" }, - "invocation": { - "executables": [{ "kind": "command", "command": "node" }] - }, - "provider": { - "launch": { - "kind": "runtime", - "tool": "node", - "minimumVersion": "20.3", - "entry": "backend/main.mjs" - }, - "arguments": ["--state", "${workspaceStateDir}"], - "executesProjectCode": true, - "mayUseNetwork": false - }, - "model": { "source": "provider" }, - "tasks": [ - { - "id": "build", - "displayName": "Build", - "group": "build", - "scopes": ["workspace"], - "arguments": ["--version"] - } - ], - "dependencies": { "source": "provider", "scopes": ["workspace", "project"] }, - "sync": { "watch": ["lithe-sample.json"] } - } - ] -} diff --git a/sdk/node/package.json b/sdk/node/package.json deleted file mode 100644 index cdf782f21..000000000 --- a/sdk/node/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "@lithe/plugin-sdk", - "version": "0.1.0", - "description": "Official SDK for Lithe plugin backends: plugin functions and build providers over the LSP base protocol.", - "license": "Apache-2.0", - "private": true, - "type": "module", - "engines": { - "node": ">=20.3" - }, - "exports": { - ".": { - "types": "./src/index.d.ts", - "default": "./src/index.mjs" - } - }, - "files": [ - "src", - "README.md" - ], - "scripts": { - "test": "node --test test/*.test.mjs" - } -} diff --git a/sdk/node/src/backend.mjs b/sdk/node/src/backend.mjs deleted file mode 100644 index e9eb1ebc3..000000000 --- a/sdk/node/src/backend.mjs +++ /dev/null @@ -1,647 +0,0 @@ -// The plugin backend session: one process the host starts from the plugin's -// manifest and talks to over stdio with the LSP base protocol. The SDK owns -// everything Lithe manages uniformly -- the lifecycle, protocol negotiation, -// cancellation, error codes, and the `lithe/*` namespace -- and leaves the -// plugin free to decide what its handlers do. -// -// Contract: shared/contracts/plugins/v1/README.md ("Build providers" and -// "Plugin functions"). - -import { format } from 'node:util'; -import { encodeFrame, FrameDecoder, FramingError, MALFORMED } from './framing.mjs'; -import { PluginSdkError, toResponseError } from './errors.mjs'; -import { killAllProcesses, runProcess } from './process.mjs'; -import { - BUILD_VERSION, - boundedText, - ERROR_CODES, - FUNCTIONS_VERSION, - isFunctionId, - isReservedMethod, - LIMITS, - METHODS, -} from './protocol.mjs'; - -/** Canonical result format of each typed build request. */ -const BUILD_FORMATS = Object.freeze({ - [METHODS.buildModel]: 'litheBuildModel', - [METHODS.buildDependencies]: 'litheDependencies', -}); - -/** - * Creates a backend. Declare handlers, then call `listen()`. - * - * @param {object} [options] - * @param {string} [options.name] Reported as `serverInfo.name` and used as the log prefix. - * @param {string} [options.version] Reported as `serverInfo.version`. - * @param {object} [options.capabilities] Standard LSP server capabilities, for a - * backend that is also a language server. `experimental.litheBuild` and - * `experimental.litheFunctions` are negotiated by the SDK and cannot be set here. - */ -export function createBackend(options = {}) { - return new Backend(options); -} - -class Backend { - #name; - #version; - #capabilities; - #functions = new Map(); - #buildHandlers = new Map(); - #requestHandlers = new Map(); - #notificationHandlers = new Map(); - #initializeHooks = []; - #shutdownHooks = []; - /** `idle` → `listening` → `initializing` → `ready` → `shuttingDown` → `exited`. */ - #phase = 'idle'; - #negotiated = Object.freeze({ functions: false, build: false }); - #initializeParams = null; - /** Host requests being handled, by JSON-RPC ID. */ - #incoming = new Map(); - /** Requests this backend sent to the host, by JSON-RPC ID. */ - #outgoing = new Map(); - #nextRequestId = 1; - /** Serializes register/unregister so the host sees them in call order. */ - #registrations = Promise.resolve(); - #output = null; - #errorOutput = process.stderr; - #finish = null; - - /** Typed build-provider handlers (`lithe/build/*`). */ - build; - - constructor({ name = 'lithe-plugin-backend', version, capabilities = {} } = {}) { - const experimental = capabilities.experimental ?? {}; - if ('litheBuild' in experimental || 'litheFunctions' in experimental) { - throw new PluginSdkError( - 'sdk.capabilities.reserved', - 'litheBuild and litheFunctions are negotiated by the SDK; declare handlers instead.', - ); - } - this.#name = String(name); - this.#version = version === undefined ? undefined : String(version); - this.#capabilities = capabilities; - this.build = Object.freeze({ - /** Answers `lithe/build/model` with a `litheBuildModel` document. */ - model: (handler) => this.#declareBuild(METHODS.buildModel, handler), - /** Answers `lithe/build/dependencies` with a `litheDependencies` document. */ - dependencies: (handler) => this.#declareBuild(METHODS.buildDependencies, handler), - }); - } - - /** Protocols the host agreed to in `initialize`; both false before it. */ - get negotiated() { - return this.#negotiated; - } - - /** The host's `initialize` params, or null before it arrives. */ - get initializeParams() { - return this.#initializeParams; - } - - /** IDs of the functions this backend currently serves, sorted. */ - get functionIDs() { - return [...this.#functions.keys()].sort(); - } - - /** - * Declares a plugin function offered from the start, in the `initialize` - * answer. To add one later, use `registerFunction`. - * - * @param {string} id Dot-separated lowerCamelCase segments, such as `snapshot.refresh`. - * @param {(params: object, context: object) => unknown} handler - */ - function(id, handler) { - if (!this.#beforeAnswer()) { - throw new PluginSdkError('sdk.function.lateDeclaration', 'After initialize, add functions with registerFunction().'); - } - this.#checkNewFunction(id, handler); - this.#functions.set(id, handler); - return this; - } - - /** Handles a standard LSP request, for a backend that is also a language server. */ - onRequest(method, handler) { - this.#declareHandler(this.#requestHandlers, method, handler); - return this; - } - - /** Handles a standard LSP notification. */ - onNotification(method, handler) { - this.#declareHandler(this.#notificationHandlers, method, handler); - return this; - } - - /** Runs before the `initialize` answer, with `{ params, negotiated }`. */ - onInitialize(hook) { - this.#initializeHooks.push(hook); - return this; - } - - /** Runs on `shutdown`, after running calls were cancelled. */ - onShutdown(hook) { - this.#shutdownHooks.push(hook); - return this; - } - - /** - * Adds a function. Before the `initialize` answer (including from an - * `onInitialize` hook) it joins the initial list; afterwards the host must - * accept it first. - */ - registerFunction(id, handler) { - if (this.#beforeAnswer()) { - return settle(() => { - this.#checkNewFunction(id, handler); - this.#functions.set(id, handler); - }); - } - return this.#queueRegistration(async () => { - this.#requireFunctions(); - this.#checkNewFunction(id, handler); - // Installed before the request: the host may invoke it as soon as its - // registry changed, before this backend reads the reply. - this.#functions.set(id, handler); - try { - await this.#request(METHODS.functionRegister, { functions: [{ id }] }); - } catch (error) { - this.#functions.delete(id); - throw error; - } - }); - } - - /** Withdraws a function. The host ends its running calls as revoked. */ - unregisterFunction(id) { - if (this.#beforeAnswer()) { - return settle(() => { - if (!this.#functions.delete(id)) { - throw new PluginSdkError('sdk.function.unknown', `Function ${id} is not registered.`); - } - }); - } - return this.#queueRegistration(async () => { - this.#requireFunctions(); - if (!this.#functions.has(id)) { - throw new PluginSdkError('sdk.function.unknown', `Function ${id} is not registered.`); - } - await this.#request(METHODS.functionUnregister, { ids: [id] }); - this.#functions.delete(id); - for (const call of this.#incoming.values()) { - if (call.functionID === id) call.controller.abort(); - } - }); - } - - /** Sends a standard LSP request to the host. `lithe/*` methods are reserved. */ - sendRequest(method, params) { - this.#checkOutgoing(method); - return this.#request(method, params); - } - - /** Sends a standard LSP notification to the host. `lithe/*` methods are reserved. */ - sendNotification(method, params) { - this.#checkOutgoing(method); - this.#write({ jsonrpc: '2.0', method, ...(params === undefined ? {} : { params }) }); - } - - /** Writes a diagnostic line to stderr, which the host keeps in its log. */ - log(...parts) { - this.#errorOutput.write(`[${this.#name}] ${format(...parts)}\n`); - } - - /** - * Serves the host until it sends `exit` or closes the input stream, and - * resolves with the process exit code. With the default `exit`, the process - * then exits with that code. - * - * @param {object} [io] - * @param {NodeJS.ReadableStream} [io.input] Defaults to stdin. - * @param {NodeJS.WritableStream} [io.output] Defaults to stdout. - * @param {NodeJS.WritableStream} [io.errorOutput] Defaults to stderr. - * @param {((code: number) => void) | null} [io.exit] Defaults to `process.exit`; null only resolves. - */ - listen({ input = process.stdin, output = process.stdout, errorOutput = process.stderr, exit = (code) => process.exit(code) } = {}) { - if (this.#phase !== 'idle') { - throw new PluginSdkError('sdk.listen.repeated', 'A backend listens once.'); - } - this.#phase = 'listening'; - this.#output = output; - this.#errorOutput = errorOutput; - if (output === process.stdout) redirectConsole(errorOutput); - - return new Promise((resolve) => { - this.#finish = (code) => { - if (this.#phase === 'exited') return; - this.#phase = 'exited'; - this.#abortAll(); - killAllProcesses(); - for (const pending of this.#outgoing.values()) { - pending.reject(new PluginSdkError('sdk.session.closed', 'The session ended.')); - } - this.#outgoing.clear(); - input.off?.('data', onData); - resolve(code); - exit?.(code); - }; - const decoder = new FrameDecoder(); - const onData = (chunk) => { - let messages; - try { - messages = decoder.push(chunk); - } catch (error) { - if (!(error instanceof FramingError)) throw error; - this.log('Ending the session:', error.message); - this.#finish(1); - return; - } - for (const message of messages) this.#dispatch(message); - }; - input.on('data', onData); - input.once('end', () => this.#finish(this.#phase === 'shuttingDown' ? 0 : 1)); - input.once('error', () => this.#finish(1)); - }); - } - - #dispatch(message) { - if (message === MALFORMED) { - this.#respond(null, undefined, { code: ERROR_CODES.parseError, message: 'The message is not valid JSON.' }); - return; - } - // Valid JSON that is not a message object, such as `null` or a batch. - if (typeof message !== 'object' || message === null || Array.isArray(message)) { - this.#respond(null, undefined, { code: ERROR_CODES.invalidRequest, message: 'The message is not a JSON-RPC object.' }); - return; - } - const hasID = message.id !== undefined && message.id !== null; - if (typeof message.method !== 'string') { - if (hasID) this.#settleOutgoing(message); - return; - } - if (hasID) { - this.#handleRequest(message); - } else { - this.#handleNotification(message); - } - } - - #handleRequest({ id, method, params }) { - if (method === METHODS.initialize) { - void this.#initialize(id, params ?? {}); - return; - } - if (method === METHODS.shutdown) { - void this.#shutdown(id); - return; - } - if (this.#phase === 'listening' || this.#phase === 'initializing') { - this.#respond(id, undefined, { code: ERROR_CODES.serverNotInitialized, message: 'The backend is not initialized.' }); - return; - } - if (this.#phase !== 'ready') { - this.#respond(id, undefined, { code: ERROR_CODES.invalidRequest, message: 'The backend is shutting down.' }); - return; - } - if (method === METHODS.functionInvoke) { - this.#invokeFunction(id, params ?? {}); - return; - } - const build = this.#buildHandlers.get(method); - if (build && this.#negotiated.build) { - this.#run(id, null, (context) => build(params ?? {}, context), (result) => buildResult(method, result)); - return; - } - const handler = this.#requestHandlers.get(method); - if (handler) { - this.#run(id, null, (context) => handler(params, context), (result) => result ?? null); - return; - } - this.#respond(id, undefined, { code: ERROR_CODES.methodNotFound, message: `Unsupported method: ${method}` }); - } - - #handleNotification({ method, params }) { - if (method === METHODS.exit) { - this.#finish(this.#phase === 'shuttingDown' ? 0 : 1); - return; - } - if (method === METHODS.cancelRequest) { - this.#incoming.get(requestKey(params?.id))?.controller.abort(); - return; - } - if (this.#phase !== 'ready' || method === METHODS.initialized) return; - const handler = this.#notificationHandlers.get(method); - if (!handler) return; - Promise.resolve() - .then(() => handler(params)) - .catch((error) => this.log(`Notification ${method} failed:`, error)); - } - - async #initialize(id, params) { - if (this.#phase !== 'listening') { - this.#respond(id, undefined, { code: ERROR_CODES.invalidRequest, message: 'initialize was already received.' }); - return; - } - this.#phase = 'initializing'; - this.#initializeParams = params; - const offered = params.capabilities?.experimental ?? {}; - const offers = (entry, version) => Array.isArray(entry?.versions) && entry.versions.includes(version); - this.#negotiated = Object.freeze({ - functions: offers(offered.litheFunctions, FUNCTIONS_VERSION), - build: this.#buildHandlers.size > 0 && offers(offered.litheBuild, BUILD_VERSION), - }); - try { - for (const hook of this.#initializeHooks) { - await hook({ params, negotiated: this.#negotiated }); - } - } catch (error) { - this.log('initialize failed:', error); - if (this.#phase === 'initializing') this.#phase = 'listening'; - this.#respond(id, undefined, toResponseError(error)); - return; - } - // A shutdown or exit that arrived while a hook ran ended the session; - // finishing initialize must not reopen it. - if (this.#phase !== 'initializing') { - this.#respond(id, undefined, { code: ERROR_CODES.invalidRequest, message: 'The session ended during initialize.' }); - return; - } - if (!this.#negotiated.functions && this.#functions.size > 0) { - this.log('The host did not offer litheFunctions version 1; functions stay unavailable.'); - } - const experimental = { ...(this.#capabilities.experimental ?? {}) }; - if (this.#negotiated.build) experimental.litheBuild = { version: BUILD_VERSION }; - if (this.#negotiated.functions) { - experimental.litheFunctions = { - version: FUNCTIONS_VERSION, - functions: this.functionIDs.map((functionID) => ({ id: functionID })), - }; - } - this.#phase = 'ready'; - this.#respond(id, { - capabilities: { ...this.#capabilities, experimental }, - serverInfo: { name: this.#name, ...(this.#version ? { version: this.#version } : {}) }, - }); - } - - async #shutdown(id) { - if (this.#phase !== 'ready' && this.#phase !== 'listening' && this.#phase !== 'initializing') { - this.#respond(id, undefined, { code: ERROR_CODES.invalidRequest, message: 'shutdown was already received.' }); - return; - } - this.#phase = 'shuttingDown'; - this.#abortAll(); - for (const hook of this.#shutdownHooks) { - try { - await hook(); - } catch (error) { - this.log('A shutdown hook failed:', error); - } - } - killAllProcesses(); - this.#respond(id, null); - } - - #invokeFunction(id, { functionID, operationID, params }) { - const handler = this.#negotiated.functions ? this.#functions.get(functionID) : undefined; - if (!handler) { - this.#respond(id, undefined, { code: ERROR_CODES.invalidParams, message: `Function ${functionID} is not registered.` }); - return; - } - let reporting = true; - const progress = ({ message, percentage } = {}) => { - if (!reporting) return; - const report = { operationID }; - if (message !== undefined) report.message = boundedText(message); - if (Number.isFinite(percentage)) report.percentage = Math.min(100, Math.max(0, Math.round(percentage))); - this.#write({ jsonrpc: '2.0', method: METHODS.functionProgress, params: report }); - }; - this.#run( - id, - functionID, - (context) => handler(params ?? {}, { ...context, functionID, operationID, progress }), - functionResult, - () => { - reporting = false; - }, - ); - } - - /** Runs one handler with its own cancellation and answers exactly once. */ - #run(id, functionID, invoke, shape, onSettled) { - const controller = new AbortController(); - const key = requestKey(id); - this.#incoming.set(key, { controller, functionID }); - const { signal } = controller; - const context = { - signal, - throwIfCancelled: () => signal.throwIfAborted(), - run: (command, args, options = {}) => { - const running = runProcess(command, args, { - ...options, - signal: options.signal ? AbortSignal.any([signal, options.signal]) : signal, - }); - // A tool the handler did not await is stopped when the call ends; its - // rejection then must not end the backend as an unhandled rejection. - running.catch(() => {}); - return running; - }, - log: (...parts) => this.log(...parts), - }; - Promise.resolve() - .then(() => invoke(context)) - .then( - (value) => { - signal.throwIfAborted(); - return { result: shape(value) }; - }, - (error) => { - if (!signal.aborted) this.log(`${functionID ? `Function ${functionID}` : 'A request'} failed:`, error); - return { error: toResponseError(error, signal) }; - }, - ) - .catch((error) => { - // Shaping the result failed, for example a build result in another format. - if (!signal.aborted) this.log('The result could not be sent:', error); - return { error: toResponseError(error, signal) }; - }) - .then(({ result, error }) => { - onSettled?.(); - this.#incoming.delete(key); - // Work belongs to its call: tools still running when the handler - // settled, such as the siblings of a failed `Promise.all`, end now. - // Other calls have their own controllers and keep running. - controller.abort(new PluginSdkError('sdk.call.ended', 'The call ended.')); - this.#respond(id, result, error); - }); - } - - #declareBuild(method, handler) { - if (this.#phase !== 'idle') { - throw new PluginSdkError('sdk.build.lateDeclaration', 'Declare build handlers before listen().'); - } - if (typeof handler !== 'function') throw new TypeError('A build handler must be a function.'); - this.#buildHandlers.set(method, handler); - return this; - } - - #declareHandler(handlers, method, handler) { - if (typeof method !== 'string' || isReservedMethod(method)) { - throw new PluginSdkError('sdk.method.reserved', `${method} is managed by the SDK and Lithe.`); - } - if (typeof handler !== 'function') throw new TypeError('A handler must be a function.'); - handlers.set(method, handler); - } - - #checkNewFunction(id, handler) { - if (!isFunctionId(id)) { - throw new PluginSdkError( - 'sdk.function.invalidID', - `Function ID ${JSON.stringify(id)} must be dot-separated lowerCamelCase segments of at most 128 bytes.`, - ); - } - if (typeof handler !== 'function') throw new TypeError('A function handler must be a function.'); - if (this.#functions.has(id)) { - throw new PluginSdkError('sdk.function.duplicate', `Function ${id} is already declared.`); - } - if (this.#functions.size >= LIMITS.maxFunctions) { - throw new PluginSdkError('sdk.function.limit', `A backend serves at most ${LIMITS.maxFunctions} functions.`); - } - } - - /** True until the `initialize` answer fixes the initial function list. */ - #beforeAnswer() { - return this.#phase === 'idle' || this.#phase === 'listening' || this.#phase === 'initializing'; - } - - #requireFunctions() { - if (this.#phase !== 'ready' || !this.#negotiated.functions) { - throw new PluginSdkError('sdk.functions.unavailable', 'The host has not negotiated plugin functions.'); - } - } - - #queueRegistration(change) { - const next = this.#registrations.then(change); - this.#registrations = next.catch(() => {}); - return next; - } - - #checkOutgoing(method) { - if (typeof method !== 'string' || isReservedMethod(method)) { - throw new PluginSdkError( - 'sdk.method.reserved', - `${method} is reserved. A backend never asks the host to run a domain operation or a function.`, - ); - } - if (this.#phase !== 'ready') { - throw new PluginSdkError('sdk.session.notReady', 'The session is not initialized.'); - } - } - - #request(method, params) { - const id = `sdk-${this.#nextRequestId++}`; - return new Promise((resolve, reject) => { - this.#outgoing.set(id, { resolve, reject, method }); - this.#write({ jsonrpc: '2.0', id, method, ...(params === undefined ? {} : { params }) }); - }); - } - - #settleOutgoing({ id, result, error }) { - const pending = this.#outgoing.get(id); - if (!pending) return; - this.#outgoing.delete(id); - if (error) { - const failure = new PluginSdkError(error.data?.code ?? 'sdk.host.error', error.message ?? `${pending.method} failed.`); - failure.rpcCode = error.code; - pending.reject(failure); - } else { - pending.resolve(result ?? null); - } - } - - #abortAll() { - for (const call of this.#incoming.values()) call.controller.abort(); - } - - #respond(id, result, error) { - if (this.#phase === 'exited') return; - try { - this.#write(error ? { jsonrpc: '2.0', id, error } : { jsonrpc: '2.0', id, result: result ?? null }); - } catch (failure) { - // A result JSON cannot encode, such as a BigInt or a cycle, must not - // become an unhandled rejection that ends the whole session. - this.log('The result could not be encoded:', failure); - this.#write({ jsonrpc: '2.0', id, error: toResponseError(failure) }); - } - } - - #write(message) { - if (!this.#output || this.#phase === 'exited') return; - this.#output.write(encodeFrame(message)); - } -} - -/** Runs `change` now and reports its outcome as a promise. */ -function settle(change) { - try { - change(); - return Promise.resolve(); - } catch (error) { - return Promise.reject(error); - } -} - -/** The JSON-RPC ID as a map key; numbers and strings never collide. */ -function requestKey(id) { - return `${typeof id}:${id}`; -} - -/** - * Stamps the canonical format on a build result, so a handler can return the - * plain document. A result in another format is a backend bug: Core would - * refuse it as `plugin.build.invalidOutput`, so it fails here with a clear log. - */ -function buildResult(method, result) { - const expected = BUILD_FORMATS[method]; - if (result === null || typeof result !== 'object' || Array.isArray(result)) { - throw new PluginSdkError('sdk.build.invalidResult', `${method} must return a ${expected} object.`); - } - if (result.format !== undefined && result.format !== expected) { - throw new PluginSdkError('sdk.build.invalidResult', `${method} returned format ${result.format}, not ${expected}.`); - } - if (result.version !== undefined && result.version !== 1) { - throw new PluginSdkError('sdk.build.invalidResult', `${method} returned version ${result.version}, not 1.`); - } - return { ...result, format: expected, version: 1 }; -} - -/** - * A function result as the JSON value Core accepts. A value JSON cannot - * encode, or one over Core's 1 MiB limit, fails here as a backend bug with a - * log line, instead of breaking the response frame or being refused by Core. - */ -function functionResult(result) { - let encoded; - try { - encoded = JSON.stringify(result ?? null); - } catch (error) { - throw new PluginSdkError('sdk.function.invalidResult', `The result is not JSON: ${error.message}`); - } - if (encoded === undefined) { - throw new PluginSdkError('sdk.function.invalidResult', 'The result is not a JSON value.'); - } - if (Buffer.byteLength(encoded) > LIMITS.maxResultBytes) { - throw new PluginSdkError('sdk.function.invalidResult', `The result exceeds ${LIMITS.maxResultBytes} bytes.`); - } - return result ?? null; -} - -/** - * Sends `console.log`, `info`, and `debug` to stderr. Stdout carries the - * protocol; a stray line there would corrupt the stream and end the session. - */ -function redirectConsole(errorOutput) { - const write = (...parts) => errorOutput.write(`${format(...parts)}\n`); - console.log = write; - console.info = write; - console.debug = write; -} diff --git a/sdk/node/src/errors.mjs b/sdk/node/src/errors.mjs deleted file mode 100644 index 7df9ace51..000000000 --- a/sdk/node/src/errors.mjs +++ /dev/null @@ -1,60 +0,0 @@ -import { boundedText, ERROR_CODES, isPluginErrorCode } from './protocol.mjs'; - -/** - * A failure a handler reports on purpose. Core ends the call with the stable - * `plugin.function.failed` code and forwards `code` as `pluginErrorCode`, so - * the plugin's own UI can tell its failures apart. Lithe never branches on it. - */ -export class PluginError extends Error { - /** - * @param {string} code Identifier of at most 64 bytes, such as `maven.snapshotLocked`. - * @param {string} message User-facing text; Core keeps the first 1024 bytes. - * @param {object} [options] `{ cause }`, kept for logs only. - */ - constructor(code, message, options) { - super(message, options); - if (!isPluginErrorCode(code)) { - throw new TypeError( - `Plugin error code ${JSON.stringify(code)} must start with a letter, use letters, digits, ".", "_" or "-", and be at most 64 bytes.`, - ); - } - this.name = 'PluginError'; - this.code = code; - } -} - -/** An SDK usage error, raised synchronously to the plugin author. */ -export class PluginSdkError extends Error { - constructor(code, message) { - super(message); - this.name = 'PluginSdkError'; - this.code = code; - } -} - -/** True for the rejection an aborted `AbortSignal` produces. */ -export function isAbort(error, signal) { - return Boolean(signal?.aborted) && (error === signal.reason || error?.name === 'AbortError'); -} - -/** - * The JSON-RPC error object for a handler failure. Only `PluginError` carries - * a plugin code; any other exception becomes a generic internal error so that - * stack traces and machine paths never reach the host's UI. A `PluginError` - * message is shown to the user as written, so it must not contain them either. - */ -export function toResponseError(error, signal) { - if (isAbort(error, signal)) { - return { code: ERROR_CODES.requestCancelled, message: 'The request was cancelled.' }; - } - if (error instanceof PluginError) { - return { - code: ERROR_CODES.requestFailed, - message: boundedText(error.message), - data: { code: error.code }, - }; - } - // Unexpected exceptions often embed absolute paths (ENOENT) or internals; - // the caller logs the details to stderr instead. - return { code: ERROR_CODES.internalError, message: 'The plugin backend failed unexpectedly.' }; -} diff --git a/sdk/node/src/framing.mjs b/sdk/node/src/framing.mjs deleted file mode 100644 index ccfe7742e..000000000 --- a/sdk/node/src/framing.mjs +++ /dev/null @@ -1,74 +0,0 @@ -// LSP base-protocol framing: `Content-Length` headers followed by a UTF-8 -// JSON body. Lithe talks to every plugin backend this way, so the SDK owns -// the framing and plugin code never writes to stdout itself. - -const HEADER_END = Buffer.from('\r\n\r\n', 'ascii'); - -/** Largest header block accepted from the host. */ -const MAX_HEADER_BYTES = 8192; - -/** Largest body accepted from the host. Larger frames end the session. */ -export const MAX_FRAME_BYTES = 64 * 1024 * 1024; - -/** Stands for a body that is not JSON; compared by identity, so no host message can mimic it. */ -export const MALFORMED = Object.freeze({ malformed: true }); - -/** A framing error the session cannot recover from. */ -export class FramingError extends Error {} - -/** Incrementally splits a byte stream into decoded JSON-RPC messages. */ -export class FrameDecoder { - #buffer = Buffer.alloc(0); - - /** - * Appends `chunk` and returns every complete message it finished. A body - * that is not JSON is returned as `MALFORMED` so the caller can - * report it without losing the framing position. - */ - push(chunk) { - this.#buffer = this.#buffer.length === 0 ? Buffer.from(chunk) : Buffer.concat([this.#buffer, chunk]); - const messages = []; - for (;;) { - const headerEnd = this.#buffer.indexOf(HEADER_END); - // The same bound whether the header arrived whole or in pieces. - if (headerEnd > MAX_HEADER_BYTES || (headerEnd < 0 && this.#buffer.length > MAX_HEADER_BYTES)) { - throw new FramingError('The message header is too long.'); - } - if (headerEnd < 0) return messages; - const length = contentLength(this.#buffer.subarray(0, headerEnd).toString('ascii')); - const bodyStart = headerEnd + HEADER_END.length; - if (this.#buffer.length < bodyStart + length) { - return messages; - } - const body = this.#buffer.subarray(bodyStart, bodyStart + length).toString('utf8'); - this.#buffer = this.#buffer.subarray(bodyStart + length); - try { - messages.push(JSON.parse(body)); - } catch { - messages.push(MALFORMED); - } - } - } -} - -function contentLength(header) { - for (const line of header.split('\r\n')) { - const separator = line.indexOf(':'); - if (separator < 0) continue; - if (line.slice(0, separator).trim().toLowerCase() !== 'content-length') continue; - const value = line.slice(separator + 1).trim(); - if (!/^\d+$/.test(value)) break; - const length = Number(value); - if (length > MAX_FRAME_BYTES) { - throw new FramingError(`A message of ${length} bytes exceeds the ${MAX_FRAME_BYTES}-byte limit.`); - } - return length; - } - throw new FramingError('A message has no valid Content-Length header.'); -} - -/** Encodes one JSON-RPC message as a complete frame. */ -export function encodeFrame(message) { - const body = Buffer.from(JSON.stringify(message), 'utf8'); - return Buffer.concat([Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, 'ascii'), body]); -} diff --git a/sdk/node/src/index.d.ts b/sdk/node/src/index.d.ts deleted file mode 100644 index 8841d2461..000000000 --- a/sdk/node/src/index.d.ts +++ /dev/null @@ -1,146 +0,0 @@ -// Type declarations for the Lithe plugin backend SDK. Keep in sync with the -// `.mjs` sources; `sdk/node/test/support.test.mjs` checks the exported names. - -/** Any value JSON can carry. */ -export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }; -export type JsonObject = { [key: string]: JsonValue }; - -export interface RunProcessOptions { - cwd?: string; - /** Replaces the environment when given; Lithe's process markers are carried over regardless. */ - env?: Record; - /** Aborting it ends the whole process tree and rejects with its reason. */ - signal?: AbortSignal; - /** Captured bytes per stream; default 1 MiB. */ - maxOutputBytes?: number; - onStdout?: (chunk: string) => void; - onStderr?: (chunk: string) => void; - /** Delay between the polite and forced stop; default 2000. */ - killGraceMilliseconds?: number; -} - -export interface ProcessResult { - exitCode: number | null; - signal: string | null; - stdout: string; - stderr: string; - truncated: boolean; -} - -/** Shared by every handler. */ -export interface RequestContext { - /** Aborted when the host cancels the call, the call times out, the session ends, or the call has settled. */ - signal: AbortSignal; - throwIfCancelled(): void; - /** `runProcess` bound to this call: its tree ends when the call is cancelled or settles. */ - run(command: string, args?: string[], options?: RunProcessOptions): Promise; - /** Writes a line to stderr, which the host keeps in its log. */ - log(...parts: unknown[]): void; -} - -export interface ProgressReport { - /** Cut to 1024 bytes. */ - message?: string; - /** Rounded and clamped to 0-100. */ - percentage?: number; -} - -export interface FunctionContext extends RequestContext { - functionID: string; - /** Opaque per-call ID chosen by Lithe. */ - operationID: string; - /** Reports progress; ignored once the call has ended. */ - progress(report: ProgressReport): void; -} - -export type FunctionHandler = (params: JsonObject, context: FunctionContext) => unknown | Promise; -export type BuildHandler = (params: BuildRequestParams, context: RequestContext) => JsonObject | Promise; - -/** Params of `lithe/build/model` and `lithe/build/dependencies`. */ -export interface BuildRequestParams { - buildSystemID: string; - root: string; - options: JsonObject; - optionArguments: string[]; - environment: Record; - /** Dependencies only: the selected project, or null for the workspace. */ - project?: { name: string; path: string } | null; - /** True when the user or a sync-file change asked to reload; skip any snapshot. */ - refresh?: boolean; - /** The build tool the host resolved; absent when it could not, so look it up yourself. */ - tool?: { executable: string }; - [key: string]: unknown; -} - -export interface Negotiated { - functions: boolean; - build: boolean; -} - -export interface BackendOptions { - name?: string; - version?: string; - /** Standard LSP server capabilities; `experimental.litheBuild` and `experimental.litheFunctions` are reserved. */ - capabilities?: JsonObject; -} - -export interface ListenOptions { - input?: NodeJS.ReadableStream; - output?: NodeJS.WritableStream; - errorOutput?: NodeJS.WritableStream; - /** Defaults to `process.exit`; null only resolves the returned promise. */ - exit?: ((code: number) => void) | null; -} - -export interface Backend { - readonly negotiated: Negotiated; - readonly initializeParams: JsonObject | null; - readonly functionIDs: string[]; - readonly build: { - model(handler: BuildHandler): Backend; - dependencies(handler: BuildHandler): Backend; - }; - function(id: string, handler: FunctionHandler): Backend; - registerFunction(id: string, handler: FunctionHandler): Promise; - unregisterFunction(id: string): Promise; - onRequest(method: string, handler: (params: unknown, context: RequestContext) => unknown): Backend; - onNotification(method: string, handler: (params: unknown) => unknown): Backend; - onInitialize(hook: (event: { params: JsonObject; negotiated: Negotiated }) => unknown): Backend; - onShutdown(hook: () => unknown): Backend; - sendRequest(method: string, params?: unknown): Promise; - sendNotification(method: string, params?: unknown): void; - log(...parts: unknown[]): void; - listen(options?: ListenOptions): Promise; -} - -export function createBackend(options?: BackendOptions): Backend; - -export class PluginError extends Error { - constructor(code: string, message: string, options?: { cause?: unknown }); - readonly code: string; -} - -export class PluginSdkError extends Error { - readonly code: string; -} - -export function runProcess(command: string, args?: string[], options?: RunProcessOptions): Promise; - -export interface StateStore { - readonly directory: string; - read(key: string, fallback?: T): T | undefined; - write(key: string, value: JsonValue): void; - delete(key: string): void; -} - -export function openStateStore(directory: string, options?: { packageDirectory?: string | null }): StateStore; - -export function isFunctionId(value: unknown): boolean; - -export const LIMITS: Readonly<{ - maxFunctions: number; - maxFunctionIdBytes: number; - maxPluginErrorCodeBytes: number; - maxMessageBytes: number; - maxResultBytes: number; -}>; diff --git a/sdk/node/src/index.mjs b/sdk/node/src/index.mjs deleted file mode 100644 index 6263ac828..000000000 --- a/sdk/node/src/index.mjs +++ /dev/null @@ -1,8 +0,0 @@ -// Official Node.js SDK for Lithe plugin backends. -// Guide: sdk/node/README.md. Wire contract: shared/contracts/plugins/v1/README.md. - -export { createBackend } from './backend.mjs'; -export { PluginError, PluginSdkError } from './errors.mjs'; -export { runProcess } from './process.mjs'; -export { openStateStore } from './state.mjs'; -export { isFunctionId, LIMITS } from './protocol.mjs'; diff --git a/sdk/node/src/process.mjs b/sdk/node/src/process.mjs deleted file mode 100644 index 03f59df6b..000000000 --- a/sdk/node/src/process.mjs +++ /dev/null @@ -1,188 +0,0 @@ -// Child processes a backend starts for one call. Lithe's host offers no -// process capability in protocol version 1, so a backend runs its tools -// itself; this helper makes sure that the call ending, cancelling it, or the -// backend shutting down also ends the whole process tree it started. When the -// backend itself is killed no JavaScript runs; the host then ends what is left -// through its own process lifecycle, which owns that boundary. - -import { spawn } from 'node:child_process'; -import { StringDecoder } from 'node:string_decoder'; - -const isWindows = process.platform === 'win32'; - -/** Every process tree started through `runProcess` that has not exited. */ -const running = new Set(); - -/** - * Variables Lithe sets on the backend so it can find and end every process - * the backend started, even one that left its process group. A tool always - * inherits them, also when the plugin replaces the environment. - */ -const HOST_MARKERS = ['LITHE_PROCESS_SCOPE', 'LITHE_PLUGIN_BACKEND_SCOPE']; - -function withHostMarkers(env) { - const result = { ...env }; - for (const name of HOST_MARKERS) { - if (process.env[name] === undefined) delete result[name]; - else result[name] = process.env[name]; - } - return result; -} - -/** - * Runs `command` without a shell and resolves with its exit status and - * captured output. Aborting `signal` ends the process tree and rejects with - * the signal's reason. A non-zero exit resolves; the caller decides what it - * means for its tool. - * - * @param {string} command Executable path or name. - * @param {string[]} [args] - * @param {object} [options] - * @param {string} [options.cwd] - * @param {Record} [options.env] Replaces the environment when given; the - * host's process markers are carried over regardless. - * @param {AbortSignal} [options.signal] - * @param {number} [options.maxOutputBytes] Captured bytes per stream; default 1 MiB. - * @param {(chunk: string) => void} [options.onStdout] - * @param {(chunk: string) => void} [options.onStderr] - * @param {number} [options.killGraceMilliseconds] Delay between the polite and forced stop; default 2000. - */ -export function runProcess(command, args = [], options = {}) { - const { cwd, env, signal, onStdout, onStderr } = options; - const maxOutputBytes = options.maxOutputBytes ?? 1 << 20; - const killGraceMilliseconds = options.killGraceMilliseconds ?? 2000; - if (signal?.aborted) return Promise.reject(signal.reason); - - return new Promise((resolve, reject) => { - const child = spawn(command, args, { - cwd, - env: env && withHostMarkers(env), - shell: false, - windowsHide: true, - // Its own process group on POSIX, so a stop reaches grandchildren too. - detached: !isWindows, - stdio: ['ignore', 'pipe', 'pipe'], - }); - const entry = { child, stopping: false, forceTimer: null }; - running.add(entry); - const stdout = capture(maxOutputBytes, onStdout); - const stderr = capture(maxOutputBytes, onStderr); - child.stdout.on('data', stdout.push); - child.stderr.on('data', stderr.push); - - const onAbort = () => stopTree(entry, killGraceMilliseconds); - signal?.addEventListener('abort', onAbort, { once: true }); - - let settled = false; - const finish = (outcome) => { - if (settled) return; - settled = true; - // A stopped tree stays registered until its forced stop: the leader - // can exit on SIGTERM while a grandchild in its group ignores it. - if (!entry.stopping) { - // The leader ended on its own; members it left behind in its group, - // such as a background worker, end with it. - signalTree(child, 'SIGKILL', { evenAfterExit: true }); - running.delete(entry); - } - signal?.removeEventListener('abort', onAbort); - stdout.end(); - stderr.end(); - outcome(); - }; - child.once('error', (error) => finish(() => reject(error))); - child.once('close', (exitCode, exitSignal) => - finish(() => { - if (signal?.aborted) { - reject(signal.reason); - return; - } - resolve({ - exitCode, - signal: exitSignal, - stdout: stdout.text(), - stderr: stderr.text(), - truncated: stdout.truncated() || stderr.truncated(), - }); - }), - ); - }); -} - -/** - * Forcibly ends every process tree still running. The backend calls it on - * shutdown, on `exit`, and when the host closes stdin, so no tool outlives - * the session that started it. - */ -export function killAllProcesses() { - for (const entry of running) { - clearTimeout(entry.forceTimer); - signalTree(entry.child, 'SIGKILL', { evenAfterExit: true }); - } - running.clear(); -} - -/** Number of process trees still running or being stopped, for tests and diagnostics. */ -export function runningProcessCount() { - return running.size; -} - -function stopTree(entry, graceMilliseconds) { - if (entry.stopping) return; - entry.stopping = true; - signalTree(entry.child, 'SIGTERM'); - entry.forceTimer = setTimeout(() => { - running.delete(entry); - signalTree(entry.child, 'SIGKILL', { evenAfterExit: true }); - }, graceMilliseconds); - entry.forceTimer.unref?.(); -} - -/** - * Signals the child's whole tree. `evenAfterExit` still signals the POSIX - * process group after its leader exited, which reaches members that survived - * the polite stop. While any member lives the group ID cannot be reused; - * once all exited the signal fails with ESRCH, which is ignored. - */ -function signalTree(child, signalName, { evenAfterExit = false } = {}) { - if (child.pid === undefined) return; - const exited = child.exitCode !== null || child.signalCode !== null; - if (exited && (isWindows || !evenAfterExit)) return; - try { - if (isWindows) { - // taskkill /T is the only stock way to end a Windows process tree. - spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' }); - } else { - process.kill(-child.pid, signalName); - } - } catch { - // The group already exited between the check and the signal. - } -} - -function capture(limit, listener) { - const chunks = []; - // Keeps a multi-byte character split across chunks whole in `listener`. - const decoder = new StringDecoder('utf8'); - let size = 0; - let truncated = false; - const deliver = (text) => { - if (text) listener?.(text); - }; - return { - push(chunk) { - deliver(decoder.write(chunk)); - if (size >= limit) { - truncated = true; - return; - } - const kept = chunk.subarray(0, limit - size); - truncated ||= kept.length < chunk.length; - chunks.push(kept); - size += kept.length; - }, - end: () => deliver(decoder.end()), - text: () => Buffer.concat(chunks).toString('utf8'), - truncated: () => truncated, - }; -} diff --git a/sdk/node/src/protocol.mjs b/sdk/node/src/protocol.mjs deleted file mode 100644 index cb13ce88b..000000000 --- a/sdk/node/src/protocol.mjs +++ /dev/null @@ -1,92 +0,0 @@ -// Wire constants and validation shared with Rust Core -// (rust/lithe-core/src/lsp/interface/plugin_function.rs). The SDK checks the -// same rules locally so a plugin author sees a mistake when registering a -// handler instead of as a refused session at runtime. - -/** Protocol versions this SDK can answer. */ -export const FUNCTIONS_VERSION = 1; -export const BUILD_VERSION = 1; - -export const METHODS = Object.freeze({ - initialize: 'initialize', - initialized: 'initialized', - shutdown: 'shutdown', - exit: 'exit', - cancelRequest: '$/cancelRequest', - functionInvoke: 'lithe/function/invoke', - functionRegister: 'lithe/function/register', - functionUnregister: 'lithe/function/unregister', - functionProgress: 'lithe/function/progress', - buildModel: 'lithe/build/model', - buildDependencies: 'lithe/build/dependencies', -}); - -/** JSON-RPC and LSP error codes the SDK answers with. */ -export const ERROR_CODES = Object.freeze({ - parseError: -32700, - invalidRequest: -32600, - methodNotFound: -32601, - invalidParams: -32602, - internalError: -32603, - serverNotInitialized: -32002, - requestFailed: -32000, - requestCancelled: -32800, -}); - -export const LIMITS = Object.freeze({ - maxFunctions: 256, - maxFunctionIdBytes: 128, - maxPluginErrorCodeBytes: 64, - maxMessageBytes: 1024, - maxResultBytes: 1 << 20, -}); - -/** Dot-separated lowerCamelCase segments, for example `snapshot.refresh`. */ -export function isFunctionId(value) { - return ( - typeof value === 'string' && - value.length > 0 && - Buffer.byteLength(value) <= LIMITS.maxFunctionIdBytes && - value.split('.').every((segment) => /^[a-z][A-Za-z0-9]*$/.test(segment)) - ); -} - -/** The plugin error code alphabet Core forwards as `pluginErrorCode`. */ -export function isPluginErrorCode(value) { - return ( - typeof value === 'string' && - Buffer.byteLength(value) <= LIMITS.maxPluginErrorCodeBytes && - /^[A-Za-z][A-Za-z0-9._-]*$/.test(value) - ); -} - -/** Cuts `value` to `limit` UTF-8 bytes without splitting a character. */ -export function boundedText(value, limit = LIMITS.maxMessageBytes) { - const text = String(value ?? ''); - if (Buffer.byteLength(text) <= limit) return text; - let used = 0; - let end = 0; - for (const character of text) { - const size = Buffer.byteLength(character); - if (used + size > limit) break; - used += size; - end += character.length; - } - return text.slice(0, end); -} - -/** - * Methods reserved for the SDK. A plugin cannot install its own handler for - * these, or send them to the host: the lifecycle belongs to the host's - * session management, and `lithe/*` belongs to Lithe's typed contracts. - */ -export function isReservedMethod(method) { - return ( - method === METHODS.initialize || - method === METHODS.initialized || - method === METHODS.shutdown || - method === METHODS.exit || - method === METHODS.cancelRequest || - method.startsWith('lithe/') - ); -} diff --git a/sdk/node/src/state.mjs b/sdk/node/src/state.mjs deleted file mode 100644 index f648bb3da..000000000 --- a/sdk/node/src/state.mjs +++ /dev/null @@ -1,148 +0,0 @@ -// Durable plugin state. The host gives every backend writable directories -// through the `${stateDir}` and `${workspaceStateDir}` launch variables; the -// installed package and the host's installation are read-only, because -// signatures and delta updates are computed over their exact bytes. - -import { - closeSync, - existsSync, - fsyncSync, - mkdirSync, - openSync, - readFileSync, - realpathSync, - renameSync, - rmSync, - writeFileSync, -} from 'node:fs'; -import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; -import { randomBytes } from 'node:crypto'; -import { PluginSdkError } from './errors.mjs'; - -/** Version of the envelope each value is stored in. */ -const STORE_FORMAT = 1; -const KEY = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; - -/** - * Opens a key-value store in `directory`, creating the directory. Values are - * JSON; each key is one file replaced atomically, so a crash leaves either the - * old or the new value, never half of one. - * - * @param {string} directory Absolute path, normally the value of `${workspaceStateDir}`. - * @param {object} [options] - * @param {string | null} [options.packageDirectory] The installed plugin package, - * which the store refuses to write into. Defaults to the nearest directory - * containing `plugin.json` above the running entry script. - */ -export function openStateStore(directory, options = {}) { - if (typeof directory !== 'string' || !isAbsolute(directory)) { - throw new PluginSdkError('sdk.state.invalidDirectory', 'The state directory must be an absolute path passed by the host.'); - } - const packageDirectory = options.packageDirectory === undefined ? findPackageDirectory() : options.packageDirectory; - // Checked before creating anything, so a refused path leaves the package untouched. - const root = canonical(directory); - if (packageDirectory && isInside(root, canonical(packageDirectory))) { - throw new PluginSdkError( - 'sdk.state.readOnlyPackage', - 'The state directory is inside the plugin package, which is read-only. Use ${stateDir} or ${workspaceStateDir}.', - ); - } - mkdirSync(root, { recursive: true }); - return new StateStore(root); -} - -class StateStore { - #root; - - constructor(root) { - this.#root = root; - } - - /** The resolved directory, for a backend that keeps its own files next to the store. */ - get directory() { - return this.#root; - } - - /** The stored value, or `fallback` when the key is missing or unreadable. */ - read(key, fallback = undefined) { - const path = this.#path(key); - if (!existsSync(path)) return fallback; - try { - const envelope = JSON.parse(readFileSync(path, 'utf8')); - if (envelope?.litheStateStore !== STORE_FORMAT || !('value' in envelope)) return fallback; - return envelope.value; - } catch { - return fallback; - } - } - - /** Stores `value`, which must be JSON-serializable, replacing the previous one. */ - write(key, value) { - const path = this.#path(key); - // `undefined`, a function, or a symbol would vanish from the envelope and - // silently replace the stored value with nothing; refuse it and keep the file. - if (JSON.stringify(value) === undefined) { - throw new PluginSdkError('sdk.state.invalidValue', `The value for ${key} is not JSON-serializable.`); - } - const body = JSON.stringify({ litheStateStore: STORE_FORMAT, value }); - const temporary = `${path}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`; - try { - writeFileSync(temporary, body, { encoding: 'utf8', mode: 0o600 }); - const handle = openSync(temporary, 'r'); - try { - fsyncSync(handle); - } finally { - closeSync(handle); - } - renameSync(temporary, path); - } catch (error) { - rmSync(temporary, { force: true }); - throw error; - } - } - - /** Removes the key; a missing key is not an error. */ - delete(key) { - rmSync(this.#path(key), { force: true }); - } - - #path(key) { - if (typeof key !== 'string' || !KEY.test(key) || key.endsWith('.tmp')) { - throw new PluginSdkError( - 'sdk.state.invalidKey', - `State key ${JSON.stringify(key)} must be 1-128 letters, digits, ".", "_" or "-", start with a letter or digit, and not end with ".tmp".`, - ); - } - return join(this.#root, `${key}.json`); - } -} - -function isInside(path, parent) { - const difference = relative(parent, path); - return difference === '' || (!difference.startsWith(`..${sep}`) && difference !== '..' && !isAbsolute(difference)); -} - -/** Resolves symbolic links of the longest existing prefix of `path`. */ -function canonical(path) { - const missing = []; - let current = resolve(path); - while (!existsSync(current)) { - const parent = dirname(current); - if (parent === current) break; - missing.unshift(basename(current)); - current = parent; - } - return join(realpathSync(current), ...missing); -} - -function findPackageDirectory() { - const entry = process.argv[1]; - if (!entry) return null; - let current = dirname(resolve(entry)); - for (;;) { - if (existsSync(join(current, 'plugin.json'))) return current; - const parent = dirname(current); - if (parent === current) return null; - current = parent; - } -} diff --git a/sdk/node/test/backend.test.mjs b/sdk/node/test/backend.test.mjs deleted file mode 100644 index 937a680c3..000000000 --- a/sdk/node/test/backend.test.mjs +++ /dev/null @@ -1,322 +0,0 @@ -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import { test } from 'node:test'; -import { createBackend, isFunctionId, PluginError, PluginSdkError } from '../src/index.mjs'; -import { gate, TestHost, withTimeout } from './harness.mjs'; - -const fixture = JSON.parse( - readFileSync(new URL('../../../shared/contracts/plugins/v1/fixtures/function-protocol.json', import.meta.url), 'utf8'), -); - -function functionBackend() { - return createBackend({ name: 'test-backend', version: '1.0.0' }) - .function('snapshot.refresh', (params) => ({ echoed: params })) - .function('install.prepare', () => undefined); -} - -test('initialize answers the protocols the host offered, with sorted functions', async () => { - const backend = functionBackend(); - backend.build.model(() => ({ projects: [] })); - const host = new TestHost(backend); - const { result } = await host.initialize(); - assert.deepEqual(result.capabilities.experimental, { - litheBuild: { version: 1 }, - litheFunctions: { version: 1, functions: [{ id: 'install.prepare' }, { id: 'snapshot.refresh' }] }, - }); - assert.deepEqual(result.serverInfo, { name: 'test-backend', version: '1.0.0' }); - assert.deepEqual(backend.negotiated, { functions: true, build: true }); - assert.equal(await host.close(), 1); -}); - -test('a host that offers nothing gets no Lithe capabilities, and a backend without build handlers never claims litheBuild', async () => { - const quiet = new TestHost(functionBackend()); - assert.deepEqual((await quiet.initialize({})).result.capabilities.experimental, {}); - await quiet.close(); - - const other = new TestHost(functionBackend()); - const answer = (await other.initialize({ litheBuild: { versions: [1] }, litheFunctions: { versions: [2] } })).result; - assert.deepEqual(answer.capabilities.experimental, {}); - await other.close(); -}); - -test('function IDs follow the shared contract, including the fixture cases', () => { - for (const id of ['snapshot.refresh', 'install.prepare', 'a', 'x1.y2Z']) assert.ok(isFunctionId(id), id); - for (const id of ['', 'Not-Valid', 'snapshot..refresh', '1abc', 'a_b', 'é', 'a'.repeat(129)]) { - assert.ok(!isFunctionId(id), id); - } - // Every ID the fixture accepts must also be accepted locally, and the - // fixture's malformed ID refused, so the SDK never declares what Core rejects. - for (const testCase of fixture.negotiation) { - const ids = testCase.capabilities.experimental?.litheFunctions?.functions?.map(({ id }) => id) ?? []; - if (testCase.name.includes('malformed')) { - assert.ok(ids.some((id) => !isFunctionId(id)), testCase.name); - } else if (testCase.expected.functions) { - assert.ok(ids.every(isFunctionId), testCase.name); - } - } - assert.throws(() => createBackend().function('Bad-ID', () => {}), (error) => error.code === 'sdk.function.invalidID'); - assert.throws( - () => createBackend().function('a', () => {}).function('a', () => {}), - (error) => error.code === 'sdk.function.duplicate', - ); -}); - -test('the lifecycle and the lithe namespace stay reserved for the SDK', async () => { - const backend = createBackend(); - for (const method of ['initialize', 'shutdown', 'exit', '$/cancelRequest', 'lithe/build/model', 'lithe/anything']) { - assert.throws(() => backend.onRequest(method, () => {}), PluginSdkError, method); - } - assert.throws( - () => createBackend({ capabilities: { experimental: { litheFunctions: { version: 1 } } } }), - (error) => error.code === 'sdk.capabilities.reserved', - ); - const host = new TestHost(backend); - await host.initialize(); - // A backend never asks the host to dispatch a domain operation or a function. - for (const method of ['lithe/function/invoke', 'lithe/build/model', 'lithe/host/process.run']) { - assert.throws(() => backend.sendRequest(method, {}), (error) => error.code === 'sdk.method.reserved', method); - } - await host.close(); -}); - -test('requests before initialize are refused with ServerNotInitialized', async () => { - const host = new TestHost(functionBackend()); - const response = await host.request('lithe/function/invoke', { functionID: 'snapshot.refresh', operationID: 'op', params: {} }); - assert.equal(response.error.code, -32002); - await host.close(); -}); - -test('an invocation returns its result; undefined becomes null', async () => { - const host = new TestHost(functionBackend()); - await host.initialize(); - const response = await host.request('lithe/function/invoke', { - functionID: 'snapshot.refresh', - operationID: 'op-1', - params: { project: 'app' }, - }); - assert.deepEqual(response.result, { echoed: { project: 'app' } }); - const empty = await host.request('lithe/function/invoke', { functionID: 'install.prepare', operationID: 'op-2' }); - assert.equal(empty.result, null); - const unknown = await host.request('lithe/function/invoke', { functionID: 'missing', operationID: 'op-3' }); - assert.equal(unknown.error.code, -32602); - await host.close(); -}); - -test('progress echoes the operation ID, is bounded, and stops once the call ended', async () => { - const release = gate(); - let late; - const backend = createBackend().function('long.task', async (_params, context) => { - context.progress({ message: 'x'.repeat(2000), percentage: 140.4 }); - context.progress({ message: 'half', percentage: 49.6 }); - late = context.progress; - await release.opened; - return 'done'; - }); - const host = new TestHost(backend); - await host.initialize(); - const response = host.request('lithe/function/invoke', { functionID: 'long.task', operationID: 'wire-7' }); - const first = await host.waitFor((message) => message.method === 'lithe/function/progress'); - assert.equal(first.params.operationID, 'wire-7'); - assert.equal(first.params.message.length, 1024); - assert.equal(first.params.percentage, 100); - const second = await host.waitFor((message) => message.params?.message === 'half'); - assert.equal(second.params.percentage, 50); - release.open(); - assert.equal((await response).result, 'done'); - late({ message: 'too late' }); - await host.close(); - assert.ok(!host.messages.some((message) => message.params?.message === 'too late')); -}); - -test('a PluginError carries its code; other failures hide their details', async () => { - const backend = createBackend() - .function('locked', () => { - throw new PluginError('maven.snapshotLocked', 'Snapshot is locked.'); - }) - .function('crash', () => { - throw new Error('ENOENT: no such file, open /Users/someone/secret'); - }); - const host = new TestHost(backend); - await host.initialize(); - const locked = await host.request('lithe/function/invoke', { functionID: 'locked', operationID: 'a' }); - assert.deepEqual(locked.error, { code: -32000, message: 'Snapshot is locked.', data: { code: 'maven.snapshotLocked' } }); - const crash = await host.request('lithe/function/invoke', { functionID: 'crash', operationID: 'b' }); - assert.equal(crash.error.code, -32603); - assert.ok(!crash.error.message.includes('/Users/')); - assert.equal(crash.error.data, undefined); - assert.throws(() => new PluginError('bad code!', 'x'), TypeError); - await host.close(); -}); - -test('$/cancelRequest aborts the handler and answers RequestCancelled', async () => { - const started = gate(); - let observed; - const backend = createBackend().function('wait.forever', (_params, { signal }) => { - started.open(); - return new Promise((_, reject) => { - signal.addEventListener('abort', () => { - observed = true; - reject(signal.reason); - }); - }); - }); - const host = new TestHost(backend); - await host.initialize(); - const response = host.request('lithe/function/invoke', { functionID: 'wait.forever', operationID: 'c' }, 41); - await withTimeout(started.opened, 'the handler to start'); - host.notify('$/cancelRequest', { id: 41 }); - assert.equal((await response).error.code, -32800); - assert.ok(observed); - await host.close(); -}); - -test('registerFunction and unregisterFunction go through the host, all or nothing', async () => { - const backend = createBackend(); - const host = new TestHost(backend); - await host.initialize(); - - const registering = backend.registerFunction('late.one', () => 'late'); - const register = await host.waitFor((message) => message.method === 'lithe/function/register'); - assert.deepEqual(register.params, { functions: [{ id: 'late.one' }] }); - host.send({ id: register.id, result: null }); - await registering; - const call = await host.request('lithe/function/invoke', { functionID: 'late.one', operationID: 'd' }); - assert.equal(call.result, 'late'); - - // A refused registration leaves no handler behind. - const refused = backend.registerFunction('other.one', () => 0); - const second = await host.waitFor((message) => message.params?.functions?.[0]?.id === 'other.one'); - host.send({ id: second.id, error: { code: -32602, message: 'No.', data: { code: 'plugin.function.invalidRegistration' } } }); - await assert.rejects(refused, (error) => error.code === 'plugin.function.invalidRegistration'); - assert.deepEqual(backend.functionIDs, ['late.one']); - - const unregistering = backend.unregisterFunction('late.one'); - const unregister = await host.waitFor((message) => message.method === 'lithe/function/unregister'); - assert.deepEqual(unregister.params, { ids: ['late.one'] }); - host.send({ id: unregister.id, result: null }); - await unregistering; - assert.deepEqual(backend.functionIDs, []); - await host.close(); -}); - -test('build handlers get the canonical format stamped, and a wrong format fails', async () => { - const backend = createBackend(); - backend.build.model(({ root }) => ({ projects: [{ name: 'app', path: root }] })); - backend.build.dependencies(() => ({ format: 'litheBuildModel', projects: [] })); - const host = new TestHost(backend); - await host.initialize(); - const model = await host.request('lithe/build/model', { buildSystemID: 'sample', root: '.' }); - assert.deepEqual(model.result, { format: 'litheBuildModel', version: 1, projects: [{ name: 'app', path: '.' }] }); - const dependencies = await host.request('lithe/build/dependencies', { buildSystemID: 'sample', root: '.' }); - assert.equal(dependencies.error.code, -32603); - await host.close(); -}); - -test('a build result cannot replace the version the SDK stamps', async () => { - const backend = createBackend(); - backend.build.model(() => ({ version: 2, projects: [] })); - backend.build.dependencies(() => ({ version: 1, dependencies: [] })); - const host = new TestHost(backend); - await host.initialize(); - const model = await host.request('lithe/build/model', { buildSystemID: 'sample', root: '.' }); - assert.equal(model.error.code, -32603); - const dependencies = await host.request('lithe/build/dependencies', { buildSystemID: 'sample', root: '.' }); - assert.deepEqual(dependencies.result, { format: 'litheDependencies', version: 1, dependencies: [] }); - await host.close(); -}); - -test('a result JSON cannot encode, or one over 1 MiB, fails the call and the session continues', async () => { - const cycle = {}; - cycle.self = cycle; - const backend = createBackend() - .function('result.bigint', () => 1n) - .function('result.cycle', () => cycle) - .function('result.symbol', () => Symbol('value')) - .function('result.large', () => 'x'.repeat(1 << 20)) - .function('result.fine', () => ({ ok: true })); - backend.build.model(() => ({ projects: [{ name: 'app', path: '.', size: 1n }] })); - const host = new TestHost(backend); - await host.initialize(); - let operation = 0; - for (const functionID of ['result.bigint', 'result.cycle', 'result.symbol', 'result.large']) { - const response = await host.request('lithe/function/invoke', { functionID, operationID: `op-${operation++}` }); - assert.equal(response.error?.code, -32603, functionID); - } - const model = await host.request('lithe/build/model', { buildSystemID: 'sample', root: '.' }); - assert.equal(model.error?.code, -32603); - const fine = await host.request('lithe/function/invoke', { functionID: 'result.fine', operationID: 'op-last' }); - assert.deepEqual(fine.result, { ok: true }); - assert.equal(await host.close(), 1); -}); - -test('build requests are refused when the host did not negotiate litheBuild', async () => { - const backend = createBackend(); - backend.build.model(() => ({ projects: [] })); - const host = new TestHost(backend); - await host.initialize({ litheFunctions: { versions: [1] } }); - const model = await host.request('lithe/build/model', { buildSystemID: 'sample', root: '.' }); - assert.equal(model.error.code, -32601); - await host.close(); -}); - -test('shutdown cancels running calls and runs hooks; exit after shutdown is code 0', async () => { - const started = gate(); - const order = []; - const backend = createBackend() - .function('wait.forever', (_params, { signal }) => { - started.open(); - return new Promise((_, reject) => signal.addEventListener('abort', () => reject(signal.reason))); - }) - .onShutdown(() => order.push('hook')); - const host = new TestHost(backend); - await host.initialize(); - const call = host.request('lithe/function/invoke', { functionID: 'wait.forever', operationID: 'e' }); - await withTimeout(started.opened, 'the handler to start'); - const shutdown = await host.request('shutdown'); - assert.equal(shutdown.result, null); - assert.equal((await call).error.code, -32800); - assert.deepEqual(order, ['hook']); - const after = await host.request('lithe/function/invoke', { functionID: 'wait.forever', operationID: 'f' }); - assert.equal(after.error.code, -32600); - host.notify('exit'); - assert.equal(await withTimeout(host.done, 'exit'), 0); -}); - -test('exit without shutdown, or a closed input, ends with code 1', async () => { - const host = new TestHost(createBackend()); - await host.initialize(); - host.notify('exit'); - assert.equal(await withTimeout(host.done, 'exit'), 1); - assert.equal(await new TestHost(createBackend()).close(), 1); -}); - -test('a malformed body is answered with a parse error and the session continues', async () => { - const host = new TestHost(functionBackend()); - const body = Buffer.from('{not json'); - host.input.write(Buffer.concat([Buffer.from(`Content-Length: ${body.length}\r\n\r\n`), body])); - const parseError = await host.waitFor((message) => message.error?.code === -32700); - assert.equal(parseError.id, null); - assert.ok((await host.initialize()).result); - await host.close(); -}); - -test('registering from an initialize hook joins the initial list without a request', async () => { - const backend = createBackend().function('a.one', () => 1); - backend.onInitialize(() => { - void backend.registerFunction('b.two', () => 2); - void backend.unregisterFunction('a.one'); - }); - const host = new TestHost(backend); - const { result } = await host.initialize(); - assert.deepEqual(result.capabilities.experimental.litheFunctions.functions, [{ id: 'b.two' }]); - assert.ok(!host.messages.some((message) => message.method?.startsWith('lithe/function/'))); - await host.close(); -}); - -test('runtime registration needs the negotiated protocol', async () => { - const backend = createBackend(); - const host = new TestHost(backend); - await host.initialize({}); - await assert.rejects(backend.registerFunction('late.one', () => 1), (error) => error.code === 'sdk.functions.unavailable'); - await host.close(); -}); diff --git a/sdk/node/test/harness.mjs b/sdk/node/test/harness.mjs deleted file mode 100644 index 4988ed5d7..000000000 --- a/sdk/node/test/harness.mjs +++ /dev/null @@ -1,106 +0,0 @@ -// A host for SDK tests: it writes framed messages to a backend, in memory or -// in a child process, and decodes the answers with a bounded wait on each. - -import { ChildProcess } from 'node:child_process'; -import { PassThrough } from 'node:stream'; -import { encodeFrame, FrameDecoder } from '../src/framing.mjs'; - -/** Upper bound for any single wait; a broken backend fails fast instead of hanging. */ -export const WAIT_MILLISECONDS = 5000; - -export class TestHost { - messages = []; - #waiters = []; - #nextID = 1; - exitCode; - - /** Hosts `backend` in memory, or talks to a backend child process over its stdio as Lithe does. */ - constructor(backend) { - if (backend instanceof ChildProcess) { - this.input = backend.stdin; - this.output = backend.stdout; - this.errorOutput = backend.stderr; - this.done = new Promise((resolve) => backend.once('exit', (code) => resolve(code))); - } else { - this.input = new PassThrough(); - this.output = new PassThrough(); - this.errorOutput = new PassThrough(); - this.done = backend.listen({ input: this.input, output: this.output, errorOutput: this.errorOutput, exit: null }); - } - const decoder = new FrameDecoder(); - this.output.on('data', (chunk) => { - for (const message of decoder.push(chunk)) { - this.messages.push(message); - this.#waiters = this.#waiters.filter((waiter) => !waiter(message)); - } - }); - this.errorOutput.resume(); - } - - send(message) { - this.input.write(encodeFrame({ jsonrpc: '2.0', ...message })); - } - - /** Sends a request and resolves with the backend's response. */ - request(method, params, id = this.#nextID++) { - this.send({ id, method, params }); - return this.waitFor((message) => message.id === id && message.method === undefined, `response to ${method}`); - } - - notify(method, params) { - this.send({ method, params }); - } - - async initialize( - experimental = { litheBuild: { versions: [1] }, litheFunctions: { versions: [1] } }, - rootUri = 'file:///workspace', - ) { - const response = await this.request('initialize', { - processId: null, - rootUri, - capabilities: { experimental }, - }); - this.notify('initialized', {}); - return response; - } - - /** Resolves with the first message, past or future, that `matches`. */ - waitFor(matches, description = 'message') { - const existing = this.messages.find(matches); - if (existing) return Promise.resolve(existing); - return new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error(`Timed out waiting for ${description}.`)), WAIT_MILLISECONDS); - this.#waiters.push((message) => { - if (!matches(message)) return false; - clearTimeout(timer); - resolve(message); - return true; - }); - }); - } - - /** Ends the session and resolves with the backend's exit code. */ - async close() { - this.input.end(); - return withTimeout(this.done, 'the backend to exit'); - } -} - -export function withTimeout(promise, description) { - let timer; - return Promise.race([ - promise, - new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error(`Timed out waiting for ${description}.`)), WAIT_MILLISECONDS); - }), - ]).finally(() => clearTimeout(timer)); -} - -/** A promise with its resolver, for handlers a test releases explicitly. */ -export function gate() { - let open; - const opened = new Promise((resolve) => { - open = resolve; - }); - return { open, opened }; -} diff --git a/sdk/node/test/sample-build.test.mjs b/sdk/node/test/sample-build.test.mjs deleted file mode 100644 index cc66be2c2..000000000 --- a/sdk/node/test/sample-build.test.mjs +++ /dev/null @@ -1,64 +0,0 @@ -import assert from 'node:assert/strict'; -import { spawn } from 'node:child_process'; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { test } from 'node:test'; -import { fileURLToPath, pathToFileURL } from 'node:url'; -import { TestHost } from './harness.mjs'; - -const sampleBackend = fileURLToPath(new URL('../examples/sample-build/backend/main.mjs', import.meta.url)); - -/** Starts the sample backend on a workspace with two quick projects, as Lithe would. */ -async function startSample(t) { - const root = mkdtempSync(join(tmpdir(), 'lithe-sample-build-')); - const workspace = join(root, 'workspace'); - const state = join(root, 'state'); - const quick = ['node', '-e', 'process.exit(0)']; - for (const name of ['app', 'lib']) mkdirSync(join(workspace, name), { recursive: true }); - mkdirSync(state); - writeFileSync( - join(workspace, 'lithe-sample.json'), - JSON.stringify({ projects: [{ name: 'app', path: 'app', build: quick }, { name: 'lib', path: 'lib', build: quick }] }), - ); - const child = spawn(process.execPath, [sampleBackend, '--state', state], { stdio: 'pipe' }); - t.after(() => { - child.kill(); - rmSync(root, { recursive: true, force: true }); - }); - const host = new TestHost(child); - await host.initialize(undefined, pathToFileURL(workspace).href); - return host; -} - -function build(host, operationID, params) { - return host.request('lithe/function/invoke', { functionID: 'workspace.build', operationID, params }); -} - -test('overlapping workspace builds record consecutive snapshot generations', async (t) => { - const host = await startSample(t); - // Both calls are in flight before either tool finishes. - const [first, second] = await Promise.all([build(host, 'op-1', {}), build(host, 'op-2', {})]); - assert.deepEqual([first.result.generation, second.result.generation].sort(), [1, 2]); - const latest = await host.request('lithe/function/invoke', { functionID: 'snapshot.read', operationID: 'op-3' }); - assert.equal(latest.result.generation, 2); - await host.close(); -}); - -test('a selection naming an unknown project fails instead of building nothing', async (t) => { - const host = await startSample(t); - for (const [operationID, projects, code] of [ - ['op-1', ['typo'], 'sample.unknownProject'], - ['op-2', ['app', 'typo'], 'sample.unknownProject'], - ['op-3', [], 'sample.selection'], - ['op-4', 'app', 'sample.selection'], - ]) { - const response = await build(host, operationID, { projects }); - assert.equal(response.error?.data?.code, code, JSON.stringify(projects)); - } - const snapshot = await host.request('lithe/function/invoke', { functionID: 'snapshot.read', operationID: 'op-5' }); - assert.equal(snapshot.result, null); - const selected = await build(host, 'op-6', { projects: ['lib'] }); - assert.deepEqual(selected.result.projects, [{ name: 'lib', exitCode: 0 }]); - await host.close(); -}); diff --git a/sdk/node/test/support.test.mjs b/sdk/node/test/support.test.mjs deleted file mode 100644 index efc52820f..000000000 --- a/sdk/node/test/support.test.mjs +++ /dev/null @@ -1,321 +0,0 @@ -import assert from 'node:assert/strict'; -import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { test } from 'node:test'; -import { encodeFrame, FrameDecoder, FramingError } from '../src/framing.mjs'; -import { createBackend, openStateStore, runProcess } from '../src/index.mjs'; -import { runningProcessCount } from '../src/process.mjs'; -import { gate, TestHost, WAIT_MILLISECONDS, withTimeout } from './harness.mjs'; - -function temporaryDirectory(t) { - const directory = mkdtempSync(join(tmpdir(), 'lithe-sdk-')); - t.after(() => rmSync(directory, { recursive: true, force: true })); - return directory; -} - -/** Bounded poll of an OS-level fact the test cannot be notified about. */ -async function eventually(check, description) { - const deadline = Date.now() + WAIT_MILLISECONDS; - while (!check()) { - if (Date.now() > deadline) throw new Error(`Timed out waiting for ${description}.`); - await new Promise((resolve) => setTimeout(resolve, 10)); - } -} - -function isAlive(pid) { - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } -} - -test('frames split across chunks, or several in one chunk, decode in order', () => { - const decoder = new FrameDecoder(); - const bytes = Buffer.concat([encodeFrame({ id: 1, text: 'é✓' }), encodeFrame({ id: 2 })]); - const messages = []; - for (let index = 0; index < bytes.length; index += 3) { - messages.push(...decoder.push(bytes.subarray(index, index + 3))); - } - assert.deepEqual(messages, [{ id: 1, text: 'é✓' }, { id: 2 }]); - assert.throws(() => new FrameDecoder().push(Buffer.from('Content-Length: 999999999999\r\n\r\n')), FramingError); - assert.throws(() => new FrameDecoder().push(Buffer.from('X-Other: 1\r\n\r\n')), FramingError); -}); - -test('runProcess captures output and resolves a non-zero exit', async () => { - const result = await runProcess(process.execPath, ['-e', 'process.stdout.write("out"); process.stderr.write("err"); process.exit(3)']); - assert.deepEqual( - { exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr, truncated: result.truncated }, - { exitCode: 3, stdout: 'out', stderr: 'err', truncated: false }, - ); - const limited = await runProcess(process.execPath, ['-e', 'process.stdout.write("x".repeat(100))'], { maxOutputBytes: 10 }); - assert.equal(limited.stdout, 'xxxxxxxxxx'); - assert.ok(limited.truncated); -}); - -test('cancelling a call ends the process tree it started', { skip: process.platform === 'win32' && 'POSIX process groups' }, async (t) => { - const directory = temporaryDirectory(t); - const grandchildPid = join(directory, 'grandchild.pid'); - // The child starts a grandchild that ignores SIGTERM, so only the group's - // forced stop can end it. - const grandchild = `process.on("SIGTERM", () => {}); require("fs").writeFileSync(${JSON.stringify(grandchildPid)}, String(process.pid)); setInterval(() => {}, 1000);`; - const child = `require("child_process").spawn(process.execPath, ["-e", ${JSON.stringify(grandchild)}], { stdio: "ignore" }); setInterval(() => {}, 1000);`; - const started = gate(); - const backend = createBackend().function('tool.run', async (_params, context) => { - const running = context.run(process.execPath, ['-e', child], { killGraceMilliseconds: 50 }); - started.open(); - return running; - }); - const host = new TestHost(backend); - await host.initialize(); - const response = host.request('lithe/function/invoke', { functionID: 'tool.run', operationID: 'g' }, 7); - await withTimeout(started.opened, 'the tool to start'); - await eventually(() => existsSync(grandchildPid) && readFileSync(grandchildPid, 'utf8').length > 0, 'the grandchild'); - const pid = Number(readFileSync(grandchildPid, 'utf8')); - host.notify('$/cancelRequest', { id: 7 }); - assert.equal((await response).error.code, -32800); - await eventually(() => !isAlive(pid), 'the grandchild to be stopped'); - await eventually(() => runningProcessCount() === 0, 'the registry to drain'); - await host.close(); -}); - -test('closing the session ends tools still running', { skip: process.platform === 'win32' && 'POSIX process groups' }, async (t) => { - const directory = temporaryDirectory(t); - const childPid = join(directory, 'child.pid'); - const started = gate(); - const backend = createBackend().function('tool.run', (_params, context) => { - const running = context.run(process.execPath, [ - '-e', - `process.on("SIGTERM", () => {}); require("fs").writeFileSync(${JSON.stringify(childPid)}, String(process.pid)); setInterval(() => {}, 1000);`, - ]); - started.open(); - return running; - }); - const host = new TestHost(backend); - await host.initialize(); - // No reply is awaited: the session ends while the call is still running. - host.send({ id: 8, method: 'lithe/function/invoke', params: { functionID: 'tool.run', operationID: 'h' } }); - await withTimeout(started.opened, 'the tool to start'); - await eventually(() => existsSync(childPid) && readFileSync(childPid, 'utf8').length > 0, 'the child'); - const pid = Number(readFileSync(childPid, 'utf8')); - assert.equal(await host.close(), 1); - await eventually(() => !isAlive(pid), 'the child to be killed'); -}); - -test('the state store writes atomically and survives reopening', (t) => { - const directory = join(temporaryDirectory(t), 'workspace-state'); - const store = openStateStore(directory, { packageDirectory: null }); - assert.equal(store.read('snapshot', 'none'), 'none'); - store.write('snapshot', { generation: 1 }); - store.write('snapshot', { generation: 2 }); - assert.deepEqual(openStateStore(directory, { packageDirectory: null }).read('snapshot'), { generation: 2 }); - assert.deepEqual(readdirSync(directory), ['snapshot.json']); - writeFileSync(join(directory, 'broken.json'), '{'); - assert.equal(store.read('broken', null), null); - store.delete('snapshot'); - store.delete('snapshot'); - assert.equal(store.read('snapshot'), undefined); - for (const key of ['', '../escape', 'a/b', 'x.tmp', '.hidden']) { - assert.throws(() => store.write(key, 1), (error) => error.code === 'sdk.state.invalidKey', key); - } -}); - -test('the state store refuses the read-only plugin package', (t) => { - const root = temporaryDirectory(t); - const packageDirectory = join(root, 'package'); - mkdirSync(packageDirectory); - writeFileSync(join(packageDirectory, 'plugin.json'), '{}'); - assert.throws( - () => openStateStore(join(packageDirectory, 'state'), { packageDirectory }), - (error) => error.code === 'sdk.state.readOnlyPackage', - ); - // Refused before anything was created inside the package. - assert.ok(!existsSync(join(packageDirectory, 'state'))); - assert.throws(() => openStateStore('relative/state'), (error) => error.code === 'sdk.state.invalidDirectory'); - assert.ok(openStateStore(join(root, 'state'), { packageDirectory })); -}); - -test('the type declarations name exactly the runtime exports', async () => { - const runtime = Object.keys(await import('../src/index.mjs')).sort(); - const declarations = readFileSync(new URL('../src/index.d.ts', import.meta.url), 'utf8'); - const declared = [...declarations.matchAll(/^export (?:function|class|const) (\w+)/gm)].map((match) => match[1]); - assert.deepEqual([...new Set(declared)].sort(), runtime); -}); - -const posixOnly = { skip: process.platform === 'win32' && 'POSIX process groups' }; - -/** A Node one-liner that records its PID in `file` and runs until stopped. */ -function recordsPidAndWaits(file) { - return `require("fs").writeFileSync(${JSON.stringify(file)}, String(process.pid)); setInterval(() => {}, 1000);`; -} - -/** Every PID a test tool recorded, so cleanup still finds it after the temporary directory is gone. */ -const recordedPids = new Set(); - -function recordedPid(file) { - if (!existsSync(file) || readFileSync(file, 'utf8').length === 0) return undefined; - const pid = Number(readFileSync(file, 'utf8')); - recordedPids.add(pid); - return pid; -} - -/** Kills recorded tools still alive, so a failed assertion leaves nothing behind. */ -function killRecordedOnExit(t) { - t.after(() => { - for (const pid of recordedPids) { - if (isAlive(pid)) process.kill(pid, 'SIGKILL'); - } - }); -} - -test('a call that settles stops the tools its handler left running, and only those', posixOnly, async (t) => { - const directory = temporaryDirectory(t); - const failedSibling = join(directory, 'failed-sibling.pid'); - const floating = join(directory, 'floating.pid'); - const other = join(directory, 'other.pid'); - killRecordedOnExit(t); - const startTool = (context, file) => - context.run(process.execPath, ['-e', recordsPidAndWaits(file)], { killGraceMilliseconds: 50 }); - const backend = createBackend() - .function('parallel.fail', async (_params, context) => { - const slow = startTool(context, failedSibling); - await eventually(() => recordedPid(failedSibling) !== undefined, 'the sibling tool'); - await Promise.all([slow, context.run(join(directory, 'missing-tool'))]); - }) - .function('floating.succeed', async (_params, context) => { - startTool(context, floating); - await eventually(() => recordedPid(floating) !== undefined, 'the floating tool'); - return 'done'; - }) - .function('other.run', (_params, context) => startTool(context, other)); - const host = new TestHost(backend); - await host.initialize(); - const otherCall = host.request('lithe/function/invoke', { functionID: 'other.run', operationID: 'o' }, 40); - await eventually(() => recordedPid(other) !== undefined, 'the other call’s tool'); - - const failed = await host.request('lithe/function/invoke', { functionID: 'parallel.fail', operationID: 'f' }); - assert.equal(failed.error?.code, -32603); - await eventually(() => !isAlive(recordedPid(failedSibling)), 'the failed call’s sibling tool to stop'); - const succeeded = await host.request('lithe/function/invoke', { functionID: 'floating.succeed', operationID: 's' }); - assert.equal(succeeded.result, 'done'); - await eventually(() => !isAlive(recordedPid(floating)), 'the unawaited tool to stop'); - - // Another call's tool was not touched; it ends with its own call. - assert.ok(isAlive(recordedPid(other))); - host.notify('$/cancelRequest', { id: 40 }); - assert.equal((await otherCall).error.code, -32800); - await eventually(() => runningProcessCount() === 0, 'the registry to drain'); - assert.equal(await host.close(), 1); -}); - -test('a tool that exits normally also ends the workers it left in its group', posixOnly, async (t) => { - const directory = temporaryDirectory(t); - const worker = join(directory, 'worker.pid'); - killRecordedOnExit(t); - // The leader starts a background worker in its own group, waits until it - // runs, and exits 0 without stopping it. - const leader = ` - const { existsSync, readFileSync } = require("fs"); - const child = require("child_process").spawn(process.execPath, ["-e", ${JSON.stringify(recordsPidAndWaits(worker))}], { stdio: "ignore" }); - child.unref(); - const deadline = Date.now() + 5000; - const wait = () => existsSync(${JSON.stringify(worker)}) && readFileSync(${JSON.stringify(worker)}, "utf8") ? process.exit(0) - : Date.now() > deadline ? process.exit(2) : setTimeout(wait, 10); - wait();`; - const result = await runProcess(process.execPath, ['-e', leader]); - assert.equal(result.exitCode, 0); - assert.equal(runningProcessCount(), 0); - await eventually(() => !isAlive(recordedPid(worker)), 'the worker to stop'); -}); - - -test('streamed output keeps a character split across chunks whole', async (t) => { - const directory = temporaryDirectory(t); - const released = join(directory, 'released'); - // The child writes "a" and the first byte of "中", waits until the parent - // saw that chunk, then writes the other two bytes, so "中" spans two chunks. - const child = ` - const { existsSync } = require("fs"); - process.stdout.write(Buffer.from([0x61, 0xe4])); - const deadline = Date.now() + 5000; - const wait = () => existsSync(${JSON.stringify(released)}) ? process.stdout.write(Buffer.from([0xb8, 0xad])) - : Date.now() > deadline ? process.exit(2) : setTimeout(wait, 10); - wait();`; - const streamed = []; - const result = await runProcess(process.execPath, ['-e', child], { - onStdout: (text) => { - streamed.push(text); - writeFileSync(released, ''); - }, - }); - assert.equal(result.exitCode, 0); - assert.equal(result.stdout, 'a中'); - assert.deepEqual(streamed, ['a', '中']); -}); - -test('the state store refuses a value JSON would drop and keeps the stored one', (t) => { - const store = openStateStore(join(temporaryDirectory(t), 'state'), { packageDirectory: null }); - store.write('snapshot', { generation: 8 }); - for (const value of [undefined, () => 1, Symbol('value'), 1n]) { - assert.throws(() => store.write('snapshot', value), (error) => error.code === 'sdk.state.invalidValue' || error instanceof TypeError); - } - assert.deepEqual(store.read('snapshot'), { generation: 8 }); -}); - -test('a header over 8 KiB is refused also when the whole frame arrives at once', () => { - const header = `Content-Length: 2\r\nX-Padding: ${'x'.repeat(9000)}\r\n\r\n{}`; - assert.throws(() => new FrameDecoder().push(Buffer.from(header)), FramingError); -}); - -test('a JSON value that is not a message object is refused and the session continues', async () => { - const host = new TestHost(createBackend().function('ping', () => 'pong')); - await host.initialize(); - host.input.write(encodeFrame(null)); - const refused = await host.waitFor((message) => message.id === null && message.error, 'the refusal'); - assert.equal(refused.error.code, -32600); - const answered = await host.request('lithe/function/invoke', { functionID: 'ping', operationID: 'p' }); - assert.equal(answered.result, 'pong'); - await host.close(); -}); - -test('a shutdown during a slow initialize hook keeps the session closed', async () => { - const hook = gate(); - const backend = createBackend() - .function('ping', () => 'pong') - .onInitialize(() => hook.opened); - const host = new TestHost(backend); - const initializing = host.request('initialize', { - processId: null, - rootUri: 'file:///workspace', - capabilities: { experimental: { litheFunctions: { versions: [1] } } }, - }); - const early = await host.request('lithe/function/invoke', { functionID: 'ping', operationID: 'early' }); - assert.equal(early.error.code, -32002); - assert.equal((await host.request('shutdown')).result, null); - hook.open(); - assert.equal((await initializing).error?.code, -32600); - const late = await host.request('lithe/function/invoke', { functionID: 'ping', operationID: 'late' }); - assert.equal(late.error.code, -32600); - host.notify('exit'); - assert.equal(await withTimeout(host.done, 'the backend to exit'), 0); -}); - -test('a replaced environment still carries the host process markers', async (t) => { - const saved = { scope: process.env.LITHE_PROCESS_SCOPE, backend: process.env.LITHE_PLUGIN_BACKEND_SCOPE }; - t.after(() => { - for (const [name, value] of [['LITHE_PROCESS_SCOPE', saved.scope], ['LITHE_PLUGIN_BACKEND_SCOPE', saved.backend]]) { - if (value === undefined) delete process.env[name]; - else process.env[name] = value; - } - }); - process.env.LITHE_PROCESS_SCOPE = 'instance-1'; - process.env.LITHE_PLUGIN_BACKEND_SCOPE = 'backend-1'; - const print = 'process.stdout.write(JSON.stringify([process.env.LITHE_PROCESS_SCOPE, process.env.LITHE_PLUGIN_BACKEND_SCOPE, process.env.PLUGIN_VALUE]))'; - // A plugin can neither drop nor forge the markers. - const result = await runProcess(process.execPath, ['-e', print], { - env: { PLUGIN_VALUE: 'kept', LITHE_PLUGIN_BACKEND_SCOPE: 'forged' }, - }); - assert.deepEqual(JSON.parse(result.stdout), ['instance-1', 'backend-1', 'kept']); -}); diff --git a/shared/contracts/plugins/v1/README.md b/shared/contracts/plugins/v1/README.md index ba81f07cd..e60287fc8 100644 --- a/shared/contracts/plugins/v1/README.md +++ b/shared/contracts/plugins/v1/README.md @@ -11,7 +11,7 @@ This directory is the cross-platform source of truth: | File | Purpose | | --- | --- | | `plugin-manifest.schema.json` | JSON shape of `plugin.json` | -| `fixtures/*.plugin.json` | Valid example manifests: language plugins (PHP, Go, Java, a discovered Node runtime), UI (`hello-lithe`), and level 3 build systems (Cargo, Go, Gradle, Maven, and two build providers) | +| `fixtures/*.plugin.json` | Valid example manifests, named after the capability they exercise rather than a published plugin: `language-*` (packaged, system-command, and host-integration language servers; `node-runtime` for a discovered runtime), `hello-lithe` and `web-panel` (UI), `settings-v2`, and `build-*` (level 3 build systems and the two forms of a build provider) | | `fixtures/invalid-manifests.json` | Edits to a valid manifest and the rejection code every host must report | | `fixtures/command-plans.json` | Golden run/test command expansions and language-server launch plans | | `fixtures/runtime-versions.json` | Golden outcomes of the launch runtime version check | @@ -23,9 +23,18 @@ This directory is the cross-platform source of truth: Validation and template expansion are implemented once in Rust Core (`rust/lithe-core/src/plugins/`, with build systems in `rust/lithe-core/src/build/`) and exposed through the -`plugins.declarative.*` commands in `../../rust-core-api.md`. Hosts must not +`plugins.declarative.*` commands in [`shared/contracts/rust-core-api.md`](https://github.com/1lck/Lithe-IDEA/blob/preview/shared/contracts/rust-core-api.md). Hosts must not reimplement them. +Plugin authors do not need this repository. The public +[`Lithe-IDEA/lithe-plugin-docs`](https://github.com/Lithe-IDEA/lithe-plugin-docs) +repository holds the developer guide and a byte-identical copy of this whole +directory under `contract/v1/`, pinned to a revision of this repository; each +schema's `$id` is its URL there. Contract changes land here first and must then +be synced to that repository, so links in this file that leave this directory +are absolute. `plugin.json` may not carry a `$schema` key, because unknown +fields are rejected; editors associate the schema by file name instead. + ## Package layout ```text @@ -716,8 +725,12 @@ commands: Core validates the declaration, detects instances, decodes the model, and expands every command line; the host only runs the processes Core planned and passes their output back. A community plugin therefore needs no host change to add a build tool, and no host code branches on a build-system -ID. The fixtures `cargo-build`, `go-build`, `gradle-build`, and `maven-build` -are complete examples, `gradle-provider` and `mix-provider` show the two +ID. One complete fixture covers each model source: `build-static-file` reads +a project file, `build-command-stdout` decodes a command's standard output, +`build-command-output-file` decodes a file the command writes, and +`build-host-integration` hands the model to a host integration. They use +Cargo, Go, Gradle, and Maven as realistic inputs; they are not those +plugins. `build-provider` and `build-provider-language-server` show the two forms of a build provider, and `fixtures/build-plans.json` is the golden list of plans and decoded outputs every host gets from them. @@ -1126,25 +1139,30 @@ running on that session, is dropped. ### Official backend SDK -[`sdk/node`](../../../../sdk/node/README.md) is the official SDK for backends +The official SDKs live in +[lithe-plugin-docs](https://github.com/Lithe-IDEA/lithe-plugin-docs), which +also releases them; they implement this contract and never define protocol. + +[`sdk/node`](https://github.com/Lithe-IDEA/lithe-plugin-docs/blob/main/sdk/node/README.md) is the official SDK for backends started through a `node` runtime launch. It implements this protocol and -`litheBuild` 1, so a plugin author writes only handlers. It enforces the -rules above on the backend side: `lithe/*` methods are never sent, stdout +`litheBuild` 1, so a plugin author writes only handlers. It is released +(`sdk-node-v`) as a `.tgz` with its SHA-256 for plugins to vendor +into their package. It enforces the rules above on the backend side: `lithe/*` methods are never sent, stdout carries only protocol frames, `PluginError` is the only way to set a `pluginErrorCode`, processes started for a call end with it, and its state -store refuses the plugin package. Its sample plugin -(`sdk/node/examples/sample-build`) is driven through a real Core session by -`rust/lithe-core/tests/plugin_sdk_node.rs`, which the Rust Core lane runs with -`LITHE_PLUGIN_SDK_NODE` set. +store refuses the plugin package. -[`sdk/java`](../../../../sdk/java/README.md) is the Java twin for backends +[`sdk/java`](https://github.com/Lithe-IDEA/lithe-plugin-docs/blob/main/sdk/java/README.md) is the Java twin for backends started through a `java` runtime launch. It needs Java 8 or newer, has no runtime dependencies, and is released as a JAR with its SHA-256 for plugins to shade into their backend. It enforces the same rules with the same error mapping, with one difference: on Java 8 it stops only a cancelled tool's -direct child, and the host's process scope ends the rest. Its sample plugin -(`sdk/java/examples/sample-build`) runs the same Core scenario through -`rust/lithe-core/tests/plugin_sdk_java.rs`. +direct child, and the host's process scope ends the rest. + +Each SDK has a sample plugin (`sdk/*/examples/sample-build`), and both samples +answer one end-to-end scenario driven through a real Core session. That +repository runs it against the Lithe commit its contract copy pins, and weekly +against Lithe's latest default branch. Neither SDK adds a wire field: a backend written without one is equally valid. @@ -1376,8 +1394,8 @@ existing Debug UI. Each must verify disable/re-enable, resource ownership and restoration; view-bearing samples also verify layout persistence. These samples are future acceptance criteria, not claims about current runtime support. -The design source is the [proposed public Extension Contract and Provider API](../../../../.agents/notes/proposed/architecture/2026-10-13-public-extension-contract-and-provider-api.md). -Current runtime behavior is recorded in the [first-stage workbench UI Note](../../../../.agents/notes/implemented/architecture/2026-10-12-workbench-ui-contributions.md). +The design source is the [proposed public Extension Contract and Provider API](https://github.com/1lck/Lithe-IDEA/blob/preview/.agents/notes/proposed/architecture/2026-10-13-public-extension-contract-and-provider-api.md). +Current runtime behavior is recorded in the [first-stage workbench UI Note](https://github.com/1lck/Lithe-IDEA/blob/preview/.agents/notes/implemented/architecture/2026-10-12-workbench-ui-contributions.md). ### Current Windows availability Windows can validate API level 2 through Rust Core but its UI adapter has not diff --git a/shared/contracts/plugins/v1/fixtures/gradle-build.plugin.json b/shared/contracts/plugins/v1/fixtures/build-command-output-file.plugin.json similarity index 98% rename from shared/contracts/plugins/v1/fixtures/gradle-build.plugin.json rename to shared/contracts/plugins/v1/fixtures/build-command-output-file.plugin.json index 508ed70ef..7d5c974c0 100644 --- a/shared/contracts/plugins/v1/fixtures/gradle-build.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/build-command-output-file.plugin.json @@ -1,7 +1,7 @@ { "schemaVersion": 2, - "id": "dev.lithe.fixture.gradle-build", - "displayName": "Gradle Build Fixture", + "id": "dev.lithe.fixture.build-command-output-file", + "displayName": "Command Output File Build Fixture", "version": "0.1.0", "pluginAPIVersion": 3, "requires": { "buildSystems": 1 }, diff --git a/shared/contracts/plugins/v1/fixtures/go-build.plugin.json b/shared/contracts/plugins/v1/fixtures/build-command-stdout.plugin.json similarity index 97% rename from shared/contracts/plugins/v1/fixtures/go-build.plugin.json rename to shared/contracts/plugins/v1/fixtures/build-command-stdout.plugin.json index 84a6587ab..8ed8855ec 100644 --- a/shared/contracts/plugins/v1/fixtures/go-build.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/build-command-stdout.plugin.json @@ -1,7 +1,7 @@ { "schemaVersion": 2, - "id": "dev.lithe.fixture.go-build", - "displayName": "Go Build Fixture", + "id": "dev.lithe.fixture.build-command-stdout", + "displayName": "Command Stdout Build Fixture", "version": "0.1.0", "pluginAPIVersion": 3, "requires": { "buildSystems": 1 }, diff --git a/shared/contracts/plugins/v1/fixtures/maven-build.plugin.json b/shared/contracts/plugins/v1/fixtures/build-host-integration.plugin.json similarity index 98% rename from shared/contracts/plugins/v1/fixtures/maven-build.plugin.json rename to shared/contracts/plugins/v1/fixtures/build-host-integration.plugin.json index 683075e17..01c58909f 100644 --- a/shared/contracts/plugins/v1/fixtures/maven-build.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/build-host-integration.plugin.json @@ -1,7 +1,7 @@ { "schemaVersion": 2, - "id": "dev.lithe.fixture.maven-build", - "displayName": "Maven Build Fixture", + "id": "dev.lithe.fixture.build-host-integration", + "displayName": "Host Integration Build Fixture", "version": "0.1.0", "pluginAPIVersion": 3, "requires": { "buildSystems": 1 }, diff --git a/shared/contracts/plugins/v1/fixtures/build-plans.json b/shared/contracts/plugins/v1/fixtures/build-plans.json index 48f09e012..4c7635778 100644 --- a/shared/contracts/plugins/v1/fixtures/build-plans.json +++ b/shared/contracts/plugins/v1/fixtures/build-plans.json @@ -4,7 +4,7 @@ "cases": [ { "name": "cargo invocation is the cargo command", - "plugin": "cargo-build.plugin.json", + "plugin": "build-static-file.plugin.json", "buildSystemID": "cargo", "operation": "invocationPlan", "request": { @@ -35,7 +35,7 @@ }, { "name": "cargo version meets the minimum", - "plugin": "cargo-build.plugin.json", + "plugin": "build-static-file.plugin.json", "buildSystemID": "cargo", "operation": "checkToolVersion", "request": { @@ -51,7 +51,7 @@ }, { "name": "cargo version below the minimum", - "plugin": "cargo-build.plugin.json", + "plugin": "build-static-file.plugin.json", "buildSystemID": "cargo", "operation": "checkToolVersion", "request": { @@ -67,7 +67,7 @@ }, { "name": "cargo build puts options after the subcommand", - "plugin": "cargo-build.plugin.json", + "plugin": "build-static-file.plugin.json", "buildSystemID": "cargo", "operation": "taskPlan", "request": { @@ -107,7 +107,7 @@ }, { "name": "cargo example needs its target", - "plugin": "cargo-build.plugin.json", + "plugin": "build-static-file.plugin.json", "buildSystemID": "cargo", "operation": "taskPlan", "request": { @@ -143,7 +143,7 @@ }, { "name": "cargo example refuses a binary target", - "plugin": "cargo-build.plugin.json", + "plugin": "build-static-file.plugin.json", "buildSystemID": "cargo", "operation": "taskPlan", "request": { @@ -170,7 +170,7 @@ }, { "name": "cargo example refuses the project scope", - "plugin": "cargo-build.plugin.json", + "plugin": "build-static-file.plugin.json", "buildSystemID": "cargo", "operation": "taskPlan", "request": { @@ -191,7 +191,7 @@ }, { "name": "cargo test selects one exact case", - "plugin": "cargo-build.plugin.json", + "plugin": "build-static-file.plugin.json", "buildSystemID": "cargo", "operation": "testPlan", "request": { @@ -223,7 +223,7 @@ }, { "name": "cargo test selects an integration target", - "plugin": "cargo-build.plugin.json", + "plugin": "build-static-file.plugin.json", "buildSystemID": "cargo", "operation": "testPlan", "request": { @@ -263,7 +263,7 @@ }, { "name": "cargo test refuses a binary target", - "plugin": "cargo-build.plugin.json", + "plugin": "build-static-file.plugin.json", "buildSystemID": "cargo", "operation": "testPlan", "request": { @@ -289,7 +289,7 @@ }, { "name": "cargo test cannot select a suite", - "plugin": "cargo-build.plugin.json", + "plugin": "build-static-file.plugin.json", "buildSystemID": "cargo", "operation": "testPlan", "request": { @@ -310,7 +310,7 @@ }, { "name": "cargo options keep known values and drop the rest", - "plugin": "cargo-build.plugin.json", + "plugin": "build-static-file.plugin.json", "buildSystemID": "cargo", "operation": "resolveOptions", "request": { @@ -382,7 +382,7 @@ }, { "name": "cargo options accept model values before a model exists", - "plugin": "cargo-build.plugin.json", + "plugin": "build-static-file.plugin.json", "buildSystemID": "cargo", "operation": "resolveOptions", "request": { @@ -433,7 +433,7 @@ }, { "name": "cargo short diagnostics", - "plugin": "cargo-build.plugin.json", + "plugin": "build-static-file.plugin.json", "buildSystemID": "cargo", "operation": "decodeDiagnostics", "request": { @@ -463,7 +463,7 @@ }, { "name": "go runs through the host-managed runtime", - "plugin": "go-build.plugin.json", + "plugin": "build-command-stdout.plugin.json", "buildSystemID": "go", "operation": "invocationPlan", "request": { @@ -503,7 +503,7 @@ }, { "name": "go runtime version", - "plugin": "go-build.plugin.json", + "plugin": "build-command-stdout.plugin.json", "buildSystemID": "go", "operation": "checkToolVersion", "request": { @@ -520,7 +520,7 @@ }, { "name": "go runtime below the minimum", - "plugin": "go-build.plugin.json", + "plugin": "build-command-stdout.plugin.json", "buildSystemID": "go", "operation": "checkToolVersion", "request": { @@ -537,7 +537,7 @@ }, { "name": "go model is a json stream on stdout", - "plugin": "go-build.plugin.json", + "plugin": "build-command-stdout.plugin.json", "buildSystemID": "go", "operation": "modelPlan", "request": { @@ -566,7 +566,7 @@ }, { "name": "go list output becomes projects, targets, and run entries", - "plugin": "go-build.plugin.json", + "plugin": "build-command-stdout.plugin.json", "buildSystemID": "go", "operation": "decodeModel", "request": { @@ -680,7 +680,7 @@ }, { "name": "go dependencies of a module", - "plugin": "go-build.plugin.json", + "plugin": "build-command-stdout.plugin.json", "buildSystemID": "go", "operation": "dependencyPlan", "request": { @@ -714,7 +714,7 @@ }, { "name": "go module list skips the main module", - "plugin": "go-build.plugin.json", + "plugin": "build-command-stdout.plugin.json", "buildSystemID": "go", "operation": "decodeDependencies", "request": { @@ -748,7 +748,7 @@ }, { "name": "go test escapes the case name", - "plugin": "go-build.plugin.json", + "plugin": "build-command-stdout.plugin.json", "buildSystemID": "go", "operation": "testPlan", "request": { @@ -786,7 +786,7 @@ }, { "name": "go diagnostics relative to the module", - "plugin": "go-build.plugin.json", + "plugin": "build-command-stdout.plugin.json", "buildSystemID": "go", "operation": "decodeDiagnostics", "request": { @@ -815,7 +815,7 @@ }, { "name": "gradle prefers the unix wrapper", - "plugin": "gradle-build.plugin.json", + "plugin": "build-command-output-file.plugin.json", "buildSystemID": "gradle", "operation": "invocationPlan", "request": { @@ -874,7 +874,7 @@ }, { "name": "gradle prefers the batch wrapper on windows", - "plugin": "gradle-build.plugin.json", + "plugin": "build-command-output-file.plugin.json", "buildSystemID": "gradle", "operation": "invocationPlan", "request": { @@ -933,7 +933,7 @@ }, { "name": "gradle two-part version", - "plugin": "gradle-build.plugin.json", + "plugin": "build-command-output-file.plugin.json", "buildSystemID": "gradle", "operation": "checkToolVersion", "request": { @@ -949,7 +949,7 @@ }, { "name": "gradle model runs the init script into the output file", - "plugin": "gradle-build.plugin.json", + "plugin": "build-command-output-file.plugin.json", "buildSystemID": "gradle", "operation": "modelPlan", "request": { @@ -987,7 +987,7 @@ }, { "name": "gradle model needs an output file", - "plugin": "gradle-build.plugin.json", + "plugin": "build-command-output-file.plugin.json", "buildSystemID": "gradle", "operation": "modelPlan", "request": { @@ -1004,7 +1004,7 @@ }, { "name": "gradle build of one project", - "plugin": "gradle-build.plugin.json", + "plugin": "build-command-output-file.plugin.json", "buildSystemID": "gradle", "operation": "taskPlan", "request": { @@ -1041,7 +1041,7 @@ }, { "name": "gradle build of the whole build", - "plugin": "gradle-build.plugin.json", + "plugin": "build-command-output-file.plugin.json", "buildSystemID": "gradle", "operation": "taskPlan", "request": { @@ -1066,7 +1066,7 @@ }, { "name": "gradle model task runs by its path", - "plugin": "gradle-build.plugin.json", + "plugin": "build-command-output-file.plugin.json", "buildSystemID": "gradle", "operation": "taskPlan", "request": { @@ -1106,7 +1106,7 @@ }, { "name": "gradle arbitrary tasks need arguments", - "plugin": "gradle-build.plugin.json", + "plugin": "build-command-output-file.plugin.json", "buildSystemID": "gradle", "operation": "taskPlan", "request": { @@ -1138,7 +1138,7 @@ }, { "name": "gradle build takes no user arguments", - "plugin": "gradle-build.plugin.json", + "plugin": "build-command-output-file.plugin.json", "buildSystemID": "gradle", "operation": "taskPlan", "request": { @@ -1158,7 +1158,7 @@ }, { "name": "gradle test of one method", - "plugin": "gradle-build.plugin.json", + "plugin": "build-command-output-file.plugin.json", "buildSystemID": "gradle", "operation": "testPlan", "request": { @@ -1199,7 +1199,7 @@ }, { "name": "gradle test refuses a case without its suite", - "plugin": "gradle-build.plugin.json", + "plugin": "build-command-output-file.plugin.json", "buildSystemID": "gradle", "operation": "testPlan", "request": { @@ -1223,7 +1223,7 @@ }, { "name": "gradle workspace test collects every model project's reports", - "plugin": "gradle-build.plugin.json", + "plugin": "build-command-output-file.plugin.json", "buildSystemID": "gradle", "operation": "testPlan", "request": { @@ -1259,7 +1259,7 @@ }, { "name": "gradle dependencies of the root project", - "plugin": "gradle-build.plugin.json", + "plugin": "build-command-output-file.plugin.json", "buildSystemID": "gradle", "operation": "dependencyPlan", "request": { @@ -1293,7 +1293,7 @@ }, { "name": "gradle javac and kotlin diagnostics", - "plugin": "gradle-build.plugin.json", + "plugin": "build-command-output-file.plugin.json", "buildSystemID": "gradle", "operation": "decodeDiagnostics", "request": { @@ -1328,7 +1328,7 @@ }, { "name": "maven package of a nested module", - "plugin": "maven-build.plugin.json", + "plugin": "build-host-integration.plugin.json", "buildSystemID": "maven", "operation": "taskPlan", "request": { @@ -1373,7 +1373,7 @@ }, { "name": "maven clean of the reactor root", - "plugin": "maven-build.plugin.json", + "plugin": "build-host-integration.plugin.json", "buildSystemID": "maven", "operation": "taskPlan", "request": { @@ -1408,7 +1408,7 @@ }, { "name": "maven goal of a reactor below the workspace root", - "plugin": "maven-build.plugin.json", + "plugin": "build-host-integration.plugin.json", "buildSystemID": "maven", "operation": "taskPlan", "request": { @@ -1443,7 +1443,7 @@ }, { "name": "maven test of one method", - "plugin": "maven-build.plugin.json", + "plugin": "build-host-integration.plugin.json", "buildSystemID": "maven", "operation": "testPlan", "request": { @@ -1481,7 +1481,7 @@ }, { "name": "maven test refuses a case without its suite", - "plugin": "maven-build.plugin.json", + "plugin": "build-host-integration.plugin.json", "buildSystemID": "maven", "operation": "testPlan", "request": { @@ -1498,7 +1498,7 @@ }, { "name": "maven dependency tree of a nested module", - "plugin": "maven-build.plugin.json", + "plugin": "build-host-integration.plugin.json", "buildSystemID": "maven", "operation": "dependencyPlan", "request": { @@ -1552,7 +1552,7 @@ }, { "name": "maven dependency tree of the reactor root", - "plugin": "maven-build.plugin.json", + "plugin": "build-host-integration.plugin.json", "buildSystemID": "maven", "operation": "dependencyPlan", "request": { @@ -1592,7 +1592,7 @@ }, { "name": "maven compiler diagnostics", - "plugin": "maven-build.plugin.json", + "plugin": "build-host-integration.plugin.json", "buildSystemID": "maven", "operation": "decodeDiagnostics", "request": { @@ -1622,7 +1622,7 @@ }, { "name": "unknown build system", - "plugin": "maven-build.plugin.json", + "plugin": "build-host-integration.plugin.json", "buildSystemID": "gradle", "operation": "taskPlan", "request": { @@ -1639,7 +1639,7 @@ }, { "name": "gradle provider model is a request for the provider with the option values", - "plugin": "gradle-provider.plugin.json", + "plugin": "build-provider.plugin.json", "buildSystemID": "gradle", "operation": "modelPlan", "request": { @@ -1676,7 +1676,7 @@ }, { "name": "gradle provider model covers the whole instance", - "plugin": "gradle-provider.plugin.json", + "plugin": "build-provider.plugin.json", "buildSystemID": "gradle", "operation": "modelPlan", "request": { @@ -1696,7 +1696,7 @@ }, { "name": "gradle provider dependencies of a project name the project", - "plugin": "gradle-provider.plugin.json", + "plugin": "build-provider.plugin.json", "buildSystemID": "gradle", "operation": "dependencyPlan", "request": { @@ -1737,7 +1737,7 @@ }, { "name": "gradle provider model reload carries refresh and the resolved tool", - "plugin": "gradle-provider.plugin.json", + "plugin": "build-provider.plugin.json", "buildSystemID": "gradle", "operation": "modelPlan", "request": { @@ -1782,7 +1782,7 @@ }, { "name": "gradle provider model without a reload leaves refresh out", - "plugin": "gradle-provider.plugin.json", + "plugin": "build-provider.plugin.json", "buildSystemID": "gradle", "operation": "modelPlan", "request": { @@ -1820,7 +1820,7 @@ }, { "name": "gradle provider dependency reload carries refresh and the resolved tool", - "plugin": "gradle-provider.plugin.json", + "plugin": "build-provider.plugin.json", "buildSystemID": "gradle", "operation": "dependencyPlan", "request": { @@ -1869,7 +1869,7 @@ }, { "name": "gradle provider tool must be an absolute path", - "plugin": "gradle-provider.plugin.json", + "plugin": "build-provider.plugin.json", "buildSystemID": "gradle", "operation": "modelPlan", "request": { @@ -1891,7 +1891,7 @@ }, { "name": "gradle model command ignores the provider refresh and tool", - "plugin": "gradle-build.plugin.json", + "plugin": "build-command-output-file.plugin.json", "buildSystemID": "gradle", "operation": "modelPlan", "request": { @@ -1933,7 +1933,7 @@ }, { "name": "gradle provider model result becomes projects, tasks, and run entries", - "plugin": "gradle-provider.plugin.json", + "plugin": "build-provider.plugin.json", "buildSystemID": "gradle", "operation": "decodeModel", "request": { @@ -2079,7 +2079,7 @@ }, { "name": "gradle provider model needs the provider's result", - "plugin": "gradle-provider.plugin.json", + "plugin": "build-provider.plugin.json", "buildSystemID": "gradle", "operation": "decodeModel", "request": { @@ -2096,7 +2096,7 @@ }, { "name": "gradle provider dependency result is a litheDependencies tree", - "plugin": "gradle-provider.plugin.json", + "plugin": "build-provider.plugin.json", "buildSystemID": "gradle", "operation": "decodeDependencies", "request": { @@ -2149,7 +2149,7 @@ }, { "name": "gradle provider dependency result of another format is refused", - "plugin": "gradle-provider.plugin.json", + "plugin": "build-provider.plugin.json", "buildSystemID": "gradle", "operation": "decodeDependencies", "request": { @@ -2205,7 +2205,7 @@ }, { "name": "mix model goes to the shared language server", - "plugin": "mix-provider.plugin.json", + "plugin": "build-provider-language-server.plugin.json", "buildSystemID": "mix", "operation": "modelPlan", "request": { @@ -2235,7 +2235,7 @@ }, { "name": "mix provider lists dependencies of the workspace only", - "plugin": "mix-provider.plugin.json", + "plugin": "build-provider-language-server.plugin.json", "buildSystemID": "mix", "operation": "dependencyPlan", "request": { @@ -2255,7 +2255,7 @@ }, { "name": "mix dependencies of the workspace", - "plugin": "mix-provider.plugin.json", + "plugin": "build-provider-language-server.plugin.json", "buildSystemID": "mix", "operation": "dependencyPlan", "request": { @@ -2286,7 +2286,7 @@ }, { "name": "maven accepts Java 8 stderr with environment options", - "plugin": "maven-build.plugin.json", + "plugin": "build-host-integration.plugin.json", "buildSystemID": "maven", "operation": "checkToolVersion", "request": { @@ -2303,7 +2303,7 @@ }, { "name": "maven rejects Java 7 against minimum 8", - "plugin": "maven-build.plugin.json", + "plugin": "build-host-integration.plugin.json", "buildSystemID": "maven", "operation": "checkToolVersion", "request": { @@ -2320,7 +2320,7 @@ }, { "name": "gradle reports Java 8 as below minimum 17", - "plugin": "gradle-build.plugin.json", + "plugin": "build-command-output-file.plugin.json", "buildSystemID": "gradle", "operation": "checkToolVersion", "request": { diff --git a/shared/contracts/plugins/v1/fixtures/mix-provider.plugin.json b/shared/contracts/plugins/v1/fixtures/build-provider-language-server.plugin.json similarity index 93% rename from shared/contracts/plugins/v1/fixtures/mix-provider.plugin.json rename to shared/contracts/plugins/v1/fixtures/build-provider-language-server.plugin.json index 9f963638c..39967483b 100644 --- a/shared/contracts/plugins/v1/fixtures/mix-provider.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/build-provider-language-server.plugin.json @@ -1,7 +1,7 @@ { "schemaVersion": 2, - "id": "dev.lithe.fixture.mix-provider", - "displayName": "Mix Provider Fixture", + "id": "dev.lithe.fixture.build-provider-language-server", + "displayName": "Build Provider With Language Server Fixture", "version": "0.1.0", "pluginAPIVersion": 3, "requires": { "buildProviders": 1, "buildSystems": 1, "languageServers": 1, "languages": 1, "serverProcess": 1 }, diff --git a/shared/contracts/plugins/v1/fixtures/gradle-provider.plugin.json b/shared/contracts/plugins/v1/fixtures/build-provider.plugin.json similarity index 97% rename from shared/contracts/plugins/v1/fixtures/gradle-provider.plugin.json rename to shared/contracts/plugins/v1/fixtures/build-provider.plugin.json index 68e352a32..512f7010f 100644 --- a/shared/contracts/plugins/v1/fixtures/gradle-provider.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/build-provider.plugin.json @@ -1,7 +1,7 @@ { "schemaVersion": 2, - "id": "dev.lithe.fixture.gradle-provider", - "displayName": "Gradle Provider Fixture", + "id": "dev.lithe.fixture.build-provider", + "displayName": "Build Provider Fixture", "version": "0.1.0", "pluginAPIVersion": 3, "requires": { "buildProviders": 1, "buildSystems": 1, "serverProcess": 1, "settings": 1 }, diff --git a/shared/contracts/plugins/v1/fixtures/cargo-build.plugin.json b/shared/contracts/plugins/v1/fixtures/build-static-file.plugin.json similarity index 97% rename from shared/contracts/plugins/v1/fixtures/cargo-build.plugin.json rename to shared/contracts/plugins/v1/fixtures/build-static-file.plugin.json index c8b1f7ab4..784417717 100644 --- a/shared/contracts/plugins/v1/fixtures/cargo-build.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/build-static-file.plugin.json @@ -1,7 +1,7 @@ { "schemaVersion": 2, - "id": "dev.lithe.fixture.cargo-build", - "displayName": "Cargo Build Fixture", + "id": "dev.lithe.fixture.build-static-file", + "displayName": "Static File Build Fixture", "version": "0.1.0", "pluginAPIVersion": 3, "requires": { "buildSystems": 1 }, diff --git a/shared/contracts/plugins/v1/fixtures/command-plans.json b/shared/contracts/plugins/v1/fixtures/command-plans.json index dd3c9afba..5c1994c4b 100644 --- a/shared/contracts/plugins/v1/fixtures/command-plans.json +++ b/shared/contracts/plugins/v1/fixtures/command-plans.json @@ -4,7 +4,7 @@ "cases": [ { "name": "php run passes user arguments after the file", - "plugin": "php-support.plugin.json", + "plugin": "language-packaged-server.plugin.json", "languageID": "php", "operation": "runPlan", "request": { "relativeFilePath": "public/index.php", "arguments": ["--verbose", "two words"] }, @@ -15,7 +15,7 @@ }, { "name": "php run protects a file name that looks like an option", - "plugin": "php-support.plugin.json", + "plugin": "language-packaged-server.plugin.json", "languageID": "php", "operation": "runPlan", "request": { "relativeFilePath": "-weird.php" }, @@ -26,7 +26,7 @@ }, { "name": "php run keeps surrounding spaces in a file name", - "plugin": "php-support.plugin.json", + "plugin": "language-packaged-server.plugin.json", "languageID": "php", "operation": "runPlan", "request": { "relativeFilePath": " spaced.php" }, @@ -37,7 +37,7 @@ }, { "name": "php run refuses a path outside the workspace", - "plugin": "php-support.plugin.json", + "plugin": "language-packaged-server.plugin.json", "languageID": "php", "operation": "runPlan", "request": { "relativeFilePath": "../outside.php" }, @@ -45,7 +45,7 @@ }, { "name": "php discovery lists the workspace then sorted test files", - "plugin": "php-support.plugin.json", + "plugin": "language-packaged-server.plugin.json", "languageID": "php", "operation": "discoverTests", "request": { "relativeProjectFilePaths": ["tests/StoreTest.php", "composer.json", "src/Store.php", "tests/Helpers.php", "tests/LegacyTEST.php"] }, @@ -60,7 +60,7 @@ }, { "name": "php discovery returns nothing outside a PHP project", - "plugin": "php-support.plugin.json", + "plugin": "language-packaged-server.plugin.json", "languageID": "php", "operation": "discoverTests", "request": { "relativeProjectFilePaths": ["README.md", "index.php", "tests/IndexTest.php"] }, @@ -68,7 +68,7 @@ }, { "name": "php workspace test", - "plugin": "php-support.plugin.json", + "plugin": "language-packaged-server.plugin.json", "languageID": "php", "operation": "testPlan", "request": { "scope": { "kind": "workspace" }, "relativeProjectFilePaths": ["phpunit.xml.dist"] }, @@ -79,7 +79,7 @@ }, { "name": "php file test", - "plugin": "php-support.plugin.json", + "plugin": "language-packaged-server.plugin.json", "languageID": "php", "operation": "testPlan", "request": { "scope": { "kind": "file", "relativeFilePath": "tests/StoreTest.php" }, "relativeProjectFilePaths": ["composer.json"] }, @@ -90,7 +90,7 @@ }, { "name": "php single test case with its file", - "plugin": "php-support.plugin.json", + "plugin": "language-packaged-server.plugin.json", "languageID": "php", "operation": "testPlan", "request": { "scope": { "kind": "testCase", "identifier": " testKeepsInsertionOrder ", "relativeFilePath": "tests/StoreTest.php" }, "relativeProjectFilePaths": ["composer.json"] }, @@ -101,7 +101,7 @@ }, { "name": "php single test case escapes regex characters and omits the missing file", - "plugin": "php-support.plugin.json", + "plugin": "language-packaged-server.plugin.json", "languageID": "php", "operation": "testPlan", "request": { "scope": { "kind": "testCase", "identifier": "testA.b/c" }, "relativeProjectFilePaths": ["composer.json"] }, @@ -112,7 +112,7 @@ }, { "name": "php blank test identifier", - "plugin": "php-support.plugin.json", + "plugin": "language-packaged-server.plugin.json", "languageID": "php", "operation": "testPlan", "request": { "scope": { "kind": "testCase", "identifier": " " }, "relativeProjectFilePaths": ["composer.json"] }, @@ -120,7 +120,7 @@ }, { "name": "php test outside a PHP project", - "plugin": "php-support.plugin.json", + "plugin": "language-packaged-server.plugin.json", "languageID": "php", "operation": "testPlan", "request": { "scope": { "kind": "workspace" }, "relativeProjectFilePaths": ["index.php"] }, @@ -128,7 +128,7 @@ }, { "name": "language without an execution provider", - "plugin": "php-support.plugin.json", + "plugin": "language-packaged-server.plugin.json", "languageID": "python", "operation": "runPlan", "request": { "relativeFilePath": "main.py" }, @@ -136,7 +136,7 @@ }, { "name": "go run uses the project toolchain", - "plugin": "go-support.plugin.json", + "plugin": "language-system-command.plugin.json", "languageID": "go", "operation": "runPlan", "request": { "relativeFilePath": "cmd/app/main.go", "arguments": ["-port", "8080"] }, @@ -147,7 +147,7 @@ }, { "name": "go discovery", - "plugin": "go-support.plugin.json", + "plugin": "language-system-command.plugin.json", "languageID": "go", "operation": "discoverTests", "request": { "relativeProjectFilePaths": ["go.mod", "main.go", "pkg/store/store_test.go", "main_test.go"] }, @@ -162,7 +162,7 @@ }, { "name": "go file test runs the file's package", - "plugin": "go-support.plugin.json", + "plugin": "language-system-command.plugin.json", "languageID": "go", "operation": "testPlan", "request": { "scope": { "kind": "file", "relativeFilePath": "pkg/store/store_test.go" }, "relativeProjectFilePaths": ["go.mod"] }, @@ -173,7 +173,7 @@ }, { "name": "go test case in a root file targets the root package", - "plugin": "go-support.plugin.json", + "plugin": "language-system-command.plugin.json", "languageID": "go", "operation": "testPlan", "request": { "scope": { "kind": "testCase", "identifier": "TestMain", "relativeFilePath": "main_test.go" }, "relativeProjectFilePaths": ["go.work"] }, @@ -184,7 +184,7 @@ }, { "name": "go test case without a file searches every package", - "plugin": "go-support.plugin.json", + "plugin": "language-system-command.plugin.json", "languageID": "go", "operation": "testPlan", "request": { "scope": { "kind": "testCase", "identifier": "TestStore/sub(case)" }, "relativeProjectFilePaths": ["go.mod"] }, @@ -195,7 +195,7 @@ }, { "name": "java launch pins the packaged runtime and lists the jdt integration resources", - "plugin": "java-support.plugin.json", + "plugin": "language-host-integration.plugin.json", "languageID": "java", "operation": "languageServerLaunchPlan", "request": { @@ -252,7 +252,7 @@ }, { "name": "java launch selects per-architecture package paths and drops a trailing separator", - "plugin": "java-support.plugin.json", + "plugin": "language-host-integration.plugin.json", "languageID": "java", "operation": "languageServerLaunchPlan", "request": { @@ -579,7 +579,7 @@ }, { "name": "php launch keeps API 1 arguments literal and names the packaged executable", - "plugin": "php-support.plugin.json", + "plugin": "language-packaged-server.plugin.json", "languageID": "php", "operation": "languageServerLaunchPlan", "request": { @@ -613,7 +613,7 @@ }, { "name": "go launch resolves a system command and needs nothing from the package", - "plugin": "go-support.plugin.json", + "plugin": "language-system-command.plugin.json", "languageID": "go", "operation": "languageServerLaunchPlan", "request": { @@ -647,7 +647,7 @@ }, { "name": "launch plan refuses an unknown architecture", - "plugin": "java-support.plugin.json", + "plugin": "language-host-integration.plugin.json", "languageID": "java", "operation": "languageServerLaunchPlan", "request": { @@ -663,7 +663,7 @@ }, { "name": "launch plan refuses an empty plugin directory", - "plugin": "java-support.plugin.json", + "plugin": "language-host-integration.plugin.json", "languageID": "java", "operation": "languageServerLaunchPlan", "request": { @@ -679,7 +679,7 @@ }, { "name": "launch plan refuses a language without a server", - "plugin": "java-support.plugin.json", + "plugin": "language-host-integration.plugin.json", "languageID": "kotlin", "operation": "languageServerLaunchPlan", "request": { @@ -695,7 +695,7 @@ }, { "name": "gradle provider launch is a java runtime launch selected by build system", - "plugin": "gradle-provider.plugin.json", + "plugin": "build-provider.plugin.json", "buildSystemID": "gradle", "operation": "languageServerLaunchPlan", "request": { @@ -757,7 +757,7 @@ }, { "name": "mix provider launch is the elixir language server plan and names that server", - "plugin": "mix-provider.plugin.json", + "plugin": "build-provider-language-server.plugin.json", "buildSystemID": "mix", "operation": "languageServerLaunchPlan", "request": { @@ -793,7 +793,7 @@ }, { "name": "elixir language server launch carries no build provider", - "plugin": "mix-provider.plugin.json", + "plugin": "build-provider-language-server.plugin.json", "languageID": "elixir", "operation": "languageServerLaunchPlan", "request": { @@ -823,7 +823,7 @@ }, { "name": "launch plan refuses a build system without a provider", - "plugin": "cargo-build.plugin.json", + "plugin": "build-static-file.plugin.json", "buildSystemID": "cargo", "operation": "languageServerLaunchPlan", "request": { @@ -839,7 +839,7 @@ }, { "name": "launch plan refuses an undeclared build system", - "plugin": "gradle-provider.plugin.json", + "plugin": "build-provider.plugin.json", "buildSystemID": "maven", "operation": "languageServerLaunchPlan", "request": { diff --git a/shared/contracts/plugins/v1/fixtures/invalid-manifests.json b/shared/contracts/plugins/v1/fixtures/invalid-manifests.json index 967ce21f2..bdf92e6df 100644 --- a/shared/contracts/plugins/v1/fixtures/invalid-manifests.json +++ b/shared/contracts/plugins/v1/fixtures/invalid-manifests.json @@ -4,133 +4,133 @@ "cases": [ { "name": "legacy native manifest schema", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [{ "op": "set", "path": "/schemaVersion", "value": 1 }], "expectedCode": "plugin.schema.unsupported" }, { "name": "future plugin API", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [{ "op": "set", "path": "/pluginAPIVersion", "value": 99 }], "expectedCode": "plugin.api.unsupported" }, { "name": "unknown top-level field", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [{ "op": "set", "path": "/entrypoint", "value": { "kind": "nativeBundle" } }], "expectedCode": "plugin.manifest.invalid" }, { "name": "uppercase plugin id", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [{ "op": "set", "path": "/id", "value": "Dev.Lithe.Fixture" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "packaged language server escapes the package", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [{ "op": "set", "path": "/languageServers/0/launch/path", "value": "../bin/intelephense" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "system command is a path", - "base": "go-support.plugin.json", + "base": "language-system-command.plugin.json", "edits": [{ "op": "set", "path": "/languageServers/0/launch/commands", "value": ["/usr/local/bin/gopls"] }], "expectedCode": "plugin.manifest.invalid" }, { "name": "language server for an undeclared language", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [{ "op": "set", "path": "/languageServers/0/languageID", "value": "hack" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "unsupported runtime dependency", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [{ "op": "set", "path": "/languageServers/0/runtimeDependencies", "value": [{ "tool": "python" }] }], "expectedCode": "plugin.runtime.unsupported" }, { "name": "unknown permission", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [{ "op": "set", "path": "/permissions/0", "value": "network.fetch" }], "expectedCode": "plugin.permission.unknown" }, { "name": "language server without its permission", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [{ "op": "set", "path": "/permissions", "value": ["process.run", "process.test"] }], "expectedCode": "plugin.permission.undeclared" }, { "name": "missing activation event", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [{ "op": "set", "path": "/activationEvents", "value": ["onLanguage:php", "onRun:php"] }], "expectedCode": "plugin.activation.invalid" }, { "name": "activation event without a provider", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [{ "op": "remove", "path": "/executionProviders/0/run" }], "expectedCode": "plugin.activation.invalid" }, { "name": "unknown template variable", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [{ "op": "set", "path": "/executionProviders/0/run/arguments", "value": ["${workspaceRoot}/${file}"] }], "expectedCode": "plugin.template.invalid" }, { "name": "user arguments embedded in an element", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [{ "op": "set", "path": "/executionProviders/0/run/arguments", "value": ["${file}", "--args=${userArguments}"] }], "expectedCode": "plugin.template.invalid" }, { "name": "test case uses the file without a guard", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [{ "op": "set", "path": "/executionProviders/0/test/testCaseArguments", "value": ["vendor/bin/phpunit", "--filter", "${testName:regex}", "${file}"] }], "expectedCode": "plugin.template.invalid" }, { "name": "test case never names the test", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [{ "op": "set", "path": "/executionProviders/0/test/testCaseArguments", "value": ["vendor/bin/phpunit"] }], "expectedCode": "plugin.template.invalid" }, { "name": "executable is an absolute path", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [{ "op": "set", "path": "/executionProviders/0/run/executable", "value": { "command": "/usr/bin/php" } }], "expectedCode": "plugin.manifest.invalid" }, { "name": "runtime launch in an API 1 manifest", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [{ "op": "set", "path": "/languageServers/0/launch", "value": { "kind": "runtime", "tool": "node", "entry": "language-server/lib/intelephense.js" } }, { "op": "set", "path": "/languageServers/0/runtimeDependencies", "value": [] }], "expectedCode": "plugin.manifest.invalid" }, { "name": "initialization options in an API 1 manifest", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [{ "op": "set", "path": "/languageServers/0/initializationOptions", "value": { "storagePath": "cache" } }], "expectedCode": "plugin.manifest.invalid" }, { "name": "host integration in an API 1 manifest", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [{ "op": "set", "path": "/languageServers/0/hostIntegration", "value": { "id": "jdt" } }], "expectedCode": "plugin.manifest.invalid" }, { "name": "runtime dependency states a minimum version", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [{ "op": "set", "path": "/languageServers/0/runtimeDependencies", "value": [{ "tool": "node", "minimumVersion": "18" }] }], "expectedCode": "plugin.manifest.invalid" }, { "name": "java runtime in an API 1 manifest", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [{ "op": "set", "path": "/languageServers/0/runtimeDependencies", "value": [{ "tool": "java" }] }], "expectedCode": "plugin.runtime.unsupported" }, @@ -166,19 +166,19 @@ }, { "name": "packaged runtime escapes the package", - "base": "java-support.plugin.json", + "base": "language-host-integration.plugin.json", "edits": [{ "op": "set", "path": "/languageServers/0/launch/packagedRuntime", "value": "../jdk/bin/java" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "packaged runtime varies by something other than the architecture", - "base": "java-support.plugin.json", + "base": "language-host-integration.plugin.json", "edits": [{ "op": "set", "path": "/languageServers/0/launch/packagedRuntime", "value": "${stateDir}/jdk/bin/java" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "packaged runtime also states a minimum version", - "base": "java-support.plugin.json", + "base": "language-host-integration.plugin.json", "edits": [{ "op": "set", "path": "/languageServers/0/launch/minimumVersion", "value": "21" }], "expectedCode": "plugin.manifest.invalid" }, @@ -226,61 +226,61 @@ }, { "name": "unknown host integration", - "base": "java-support.plugin.json", + "base": "language-host-integration.plugin.json", "edits": [{ "op": "set", "path": "/languageServers/0/hostIntegration/id", "value": "roslyn" }], "expectedCode": "plugin.integration.unsupported" }, { "name": "host integration requested outside the official namespace", - "base": "java-support.plugin.json", + "base": "language-host-integration.plugin.json", "edits": [{ "op": "set", "path": "/id", "value": "io.example.java" }, { "op": "set", "path": "/vendor/signatureRequirement", "value": "none" }], "expectedCode": "plugin.integration.unsupported" }, { "name": "jdt integration without a java runtime launch", - "base": "java-support.plugin.json", + "base": "language-host-integration.plugin.json", "edits": [{ "op": "set", "path": "/languageServers/0/launch", "value": { "kind": "packaged", "path": "language-server/bin/jdtls" } }], "expectedCode": "plugin.manifest.invalid" }, { "name": "jdt integration misses a required resource", - "base": "java-support.plugin.json", + "base": "language-host-integration.plugin.json", "edits": [{ "op": "remove", "path": "/languageServers/0/hostIntegration/resources/configuration" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "jdt integration declares a resource it does not use", - "base": "java-support.plugin.json", + "base": "language-host-integration.plugin.json", "edits": [{ "op": "set", "path": "/languageServers/0/hostIntegration/resources/indexer", "value": "language-server/indexer.jar" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "integration resource leaves the package", - "base": "java-support.plugin.json", + "base": "language-host-integration.plugin.json", "edits": [{ "op": "set", "path": "/languageServers/0/hostIntegration/resources/lombokAgent", "value": "../lombok.jar" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "integration resource list given as a single path", - "base": "java-support.plugin.json", + "base": "language-host-integration.plugin.json", "edits": [{ "op": "set", "path": "/languageServers/0/hostIntegration/resources/extensionBundleDirectories", "value": "language-server/java-debug" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "integration capability the integration does not provide", - "base": "java-support.plugin.json", + "base": "language-host-integration.plugin.json", "edits": [{ "op": "set", "path": "/languageServers/0/hostIntegration/capabilities", "value": ["formatting"] }], "expectedCode": "plugin.manifest.invalid" }, { "name": "integration capability without the resource its host code reads", - "base": "java-support.plugin.json", + "base": "language-host-integration.plugin.json", "edits": [{ "op": "remove", "path": "/languageServers/0/hostIntegration/resources/testRunner" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "integration capability that the plugin's execution provider already provides", - "base": "java-support.plugin.json", + "base": "language-host-integration.plugin.json", "edits": [ { "op": "set", "path": "/permissions", "value": ["languageServer.launch", "process.run"] }, { "op": "set", "path": "/activationEvents", "value": ["onLanguage:java", "onRun:java"] }, @@ -295,7 +295,7 @@ ], "expectedCode": "plugin.manifest.invalid" }, - {"name": "settings on API level 1", "base": "php-support.plugin.json", "edits": [{"op": "set", "path": "/settings", "value": [{"id": "x", "type": "boolean", "title": "X", "default": true}]}], "expectedCode": "plugin.manifest.invalid"}, + {"name": "settings on API level 1", "base": "language-packaged-server.plugin.json", "edits": [{"op": "set", "path": "/settings", "value": [{"id": "x", "type": "boolean", "title": "X", "default": true}]}], "expectedCode": "plugin.manifest.invalid"}, {"name": "launch template names an undeclared setting", "base": "node-runtime.plugin.json", "edits": [{"op": "set", "path": "/languageServers/0/arguments/1", "value": "--log-level=${setting:verbosity}"}], "expectedCode": "plugin.template.invalid"}, {"name": "setting shapes a package path", "base": "node-runtime.plugin.json", "edits": [{"op": "set", "path": "/languageServers/0/launch/runtimeArguments/1", "value": "--require=${pluginDir}/language-server/${setting:logLevel}.js"}], "expectedCode": "plugin.template.invalid"}, {"name": "setting identifier with a separator", "base": "node-runtime.plugin.json", "edits": [{"op": "set", "path": "/settings/0/id", "value": "heap-mb"}], "expectedCode": "plugin.manifest.invalid"}, @@ -449,7 +449,7 @@ }, { "name": "build systems need plugin API 3", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [ { "op": "set", "path": "/pluginAPIVersion", "value": 2 }, { "op": "remove", "path": "/requires" } @@ -458,25 +458,25 @@ }, { "name": "build systems need the build permission", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/permissions", "value": [] }], "expectedCode": "plugin.permission.undeclared" }, { "name": "build systems need their activation event", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/activationEvents", "value": [] }], "expectedCode": "plugin.activation.invalid" }, { "name": "build activation names an undeclared build system", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/activationEvents", "value": ["onBuildSystem:cargo", "onBuildSystem:bazel"] }], "expectedCode": "plugin.activation.invalid" }, { "name": "build system ID is not an identifier", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [ { "op": "set", "path": "/buildSystems/0/id", "value": "Cargo" }, { "op": "set", "path": "/activationEvents", "value": ["onBuildSystem:Cargo"] } @@ -485,7 +485,7 @@ }, { "name": "host integration is reserved to official plugins", - "base": "maven-build.plugin.json", + "base": "build-host-integration.plugin.json", "edits": [ { "op": "set", "path": "/id", "value": "org.example.maven-build" }, { "op": "set", "path": "/vendor/id", "value": "org.example" } @@ -494,97 +494,97 @@ }, { "name": "host integration the host does not provide", - "base": "maven-build.plugin.json", + "base": "build-host-integration.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/hostIntegration", "value": "sbt" }], "expectedCode": "plugin.integration.unsupported" }, { "name": "host-integration model without a host integration", - "base": "maven-build.plugin.json", + "base": "build-host-integration.plugin.json", "edits": [{ "op": "remove", "path": "/buildSystems/0/hostIntegration" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "option arguments placed twice", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/tasks/0/arguments", "value": ["build", "${optionArguments}", "${optionArguments:release}"] }], "expectedCode": "plugin.template.invalid" }, { "name": "option arguments name an undeclared option", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/tasks/2/arguments", "value": ["clean", "${optionArguments:profile}"] }], "expectedCode": "plugin.template.invalid" }, { "name": "target variable without its guard", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/test/arguments", "value": ["test", "${target.name}"] }], "expectedCode": "plugin.template.invalid" }, { "name": "test case variable without its guard", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/test/arguments", "value": ["test", "--", "${test.case}"] }], "expectedCode": "plugin.template.invalid" }, { "name": "test variables outside a test command", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/tasks/0/arguments", "value": ["build", "${test.case}"] }], "expectedCode": "plugin.template.invalid" }, { "name": "user arguments in a model command", - "base": "go-build.plugin.json", + "base": "build-command-stdout.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/model/arguments", "value": ["list", "-json", "${userArguments}"] }], "expectedCode": "plugin.template.invalid" }, { "name": "output file without the file channel", - "base": "gradle-build.plugin.json", + "base": "build-command-output-file.plugin.json", "edits": [{ "op": "remove", "path": "/buildSystems/0/model/output" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "file channel without the output file", - "base": "gradle-build.plugin.json", + "base": "build-command-output-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/model/arguments", "value": ["-q", "litheModel"] }], "expectedCode": "plugin.manifest.invalid" }, { "name": "plugin directory in a task", - "base": "gradle-build.plugin.json", + "base": "build-command-output-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/tasks/1/arguments", "value": ["--init-script", "${pluginDir}/x.gradle", "build"] }], "expectedCode": "plugin.template.invalid" }, { "name": "user arguments required but never placed", - "base": "maven-build.plugin.json", + "base": "build-host-integration.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/tasks/4/arguments", "value": ["validate"] }], "expectedCode": "plugin.manifest.invalid" }, { "name": "run entries from a task that is not a model template", - "base": "gradle-build.plugin.json", + "base": "build-command-output-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/run/0/fromTasks/templates", "value": ["build"] }], "expectedCode": "plugin.manifest.invalid" }, { "name": "host-managed runtime with its own executables", - "base": "go-build.plugin.json", + "base": "build-command-stdout.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/invocation/runtimeDependencies/0/executables", "value": [{ "kind": "command", "command": "go" }] }], "expectedCode": "plugin.manifest.invalid" }, { "name": "runtime executable without its dependency", - "base": "go-build.plugin.json", + "base": "build-command-stdout.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/invocation/runtimeDependencies", "value": [] }], "expectedCode": "plugin.manifest.invalid" }, { "name": "plugin-described runtime without executables", - "base": "go-build.plugin.json", + "base": "build-command-stdout.plugin.json", "edits": [ { "op": "set", "path": "/buildSystems/0/invocation/executables/0/runtime", "value": "zig" }, { "op": "set", "path": "/buildSystems/0/invocation/runtimeDependencies/0/id", "value": "zig" } @@ -593,229 +593,229 @@ }, { "name": "environment executable not declared as an input", - "base": "gradle-build.plugin.json", + "base": "build-command-output-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/invocation/environmentInputs", "value": [] }], "expectedCode": "plugin.manifest.invalid" }, { "name": "project-file executable leaves the root", - "base": "gradle-build.plugin.json", + "base": "build-command-output-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/invocation/executables/0/path", "value": "../gradlew" }], "expectedCode": "plugin.path.invalid" }, { "name": "version pattern without a capture group", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/invocation/version/pattern", "value": "cargo \\d+" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "minimum version without a probe", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "remove", "path": "/buildSystems/0/invocation/version" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "static model file leaves the root", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/model/file", "value": "../Cargo.toml" }], "expectedCode": "plugin.path.invalid" }, { "name": "selector without a pointer", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/model/map/projects/0/name", "value": "name" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "literal field with a control character", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/model/map/projects/0/contentRoots/0/path", "value": { "value": "src\n" } }], "expectedCode": "plugin.manifest.invalid" }, { "name": "predicate with both equals and exists", - "base": "go-build.plugin.json", + "base": "build-command-stdout.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/dependencies/map/where/0/equals", "value": true }], "expectedCode": "plugin.manifest.invalid" }, { "name": "a static model in a format the map does not read", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/model/decoder", "value": "yaml" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "a model command printing a single JSON document reads it as jsonStream", - "base": "go-build.plugin.json", + "base": "build-command-stdout.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/model/decoder", "value": "json" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "a model map lists no tasks", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/model/map/projects/0/tasks", "value": [] }], "expectedCode": "plugin.manifest.invalid" }, { "name": "a model map has no top-level targets", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/model/map/targets", "value": [] }], "expectedCode": "plugin.manifest.invalid" }, { "name": "a project rule names no parent", - "base": "go-build.plugin.json", + "base": "build-command-stdout.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/model/map/projects/0/parent", "value": "/Dir" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "target tags are a literal list", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/model/map/projects/0/targets/0/tags", "value": { "from": "/kind", "map": { "bin": ["application"] } } }], "expectedCode": "plugin.manifest.invalid" }, { "name": "lastSegment is the only transform", - "base": "go-build.plugin.json", + "base": "build-command-stdout.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/model/map/projects/0/targets/0/properties/short/transform", "value": "parentDirectory" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "a dependency map is flat", - "base": "go-build.plugin.json", + "base": "build-command-stdout.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/dependencies/map/children", "value": "/Deps/*" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "layout lists only excluded directories", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/layout/generated", "value": ["target/generated"] }], "expectedCode": "plugin.manifest.invalid" }, { "name": "diagnostic capture group beyond the pattern", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/diagnostics/patterns/0/message", "value": 9 }], "expectedCode": "plugin.manifest.invalid" }, { "name": "option environment name is not uppercase", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/options/0/environment", "value": { "cargo_release": "1" } }], "expectedCode": "plugin.manifest.invalid" }, { "name": "sync watch leaves the root", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/sync/watch", "value": ["../Cargo.toml"] }], "expectedCode": "plugin.path.invalid" }, { "name": "test target tags without the target scope", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/test/scopes", "value": ["project"] }], "expectedCode": "plugin.manifest.invalid" }, { "name": "provider that no source uses", - "base": "gradle-build.plugin.json", + "base": "build-command-output-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/provider", "value": { "launch": { "kind": "systemCommand", "commands": ["gradle-provider"] }, "executesProjectCode": true, "mayUseNetwork": true } }], "expectedCode": "plugin.manifest.invalid" }, { "name": "provider source without a provider", - "base": "gradle-provider.plugin.json", + "base": "build-provider.plugin.json", "edits": [{ "op": "remove", "path": "/buildSystems/0/provider" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "shared provider names a language without a language server", - "base": "mix-provider.plugin.json", + "base": "build-provider-language-server.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/provider/languageServer", "value": "erlang" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "provider mixes the shared and own forms", - "base": "mix-provider.plugin.json", + "base": "build-provider-language-server.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/provider/launch", "value": { "kind": "systemCommand", "commands": ["mix-provider"] } }], "expectedCode": "plugin.manifest.invalid" }, { "name": "provider without its execution flags", - "base": "gradle-provider.plugin.json", + "base": "build-provider.plugin.json", "edits": [{ "op": "remove", "path": "/buildSystems/0/provider/executesProjectCode" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "own provider without the language server permission", - "base": "gradle-provider.plugin.json", + "base": "build-provider.plugin.json", "edits": [{ "op": "set", "path": "/permissions", "value": ["process.build"] }], "expectedCode": "plugin.permission.undeclared" }, { "name": "provider entry leaves the package", - "base": "gradle-provider.plugin.json", + "base": "build-provider.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/provider/launch/entry", "value": "../provider.jar" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "provider runtime the host cannot start", - "base": "gradle-provider.plugin.json", + "base": "build-provider.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/provider/launch/tool", "value": "go" }], "expectedCode": "plugin.runtime.unsupported" }, { "name": "provider argument names an undeclared setting", - "base": "gradle-provider.plugin.json", + "base": "build-provider.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/provider/arguments", "value": ["--heap=${setting:missing}"] }], "expectedCode": "plugin.template.invalid" }, { "name": "provider dependencies of a target", - "base": "gradle-provider.plugin.json", + "base": "build-provider.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/dependencies/scopes", "value": ["target"] }], "expectedCode": "plugin.manifest.invalid" }, { "name": "plugin API 3 without requires", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "remove", "path": "/requires" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "requires below plugin API 3", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [{ "op": "set", "path": "/requires", "value": { "languageServers": 1, "languages": 1, "serverProcess": 1 } }], "expectedCode": "plugin.manifest.invalid" }, { "name": "empty requires", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/requires", "value": {} }], "expectedCode": "plugin.manifest.invalid" }, { "name": "capability version is not a positive integer", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/requires/buildSystems", "value": 0 }], "expectedCode": "plugin.manifest.invalid" }, { "name": "malformed capability ID", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/requires/build-systems", "value": 1 }], "expectedCode": "plugin.manifest.invalid" }, { "name": "future capability", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/requires/debuggers", "value": 1 }], "expectedCode": "plugin.api.unsupported" }, { "name": "future capability is reported before its unknown section", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [ { "op": "set", "path": "/requires/debuggers", "value": 1 }, { "op": "set", "path": "/debuggers", "value": [{ "id": "lldb" }] } @@ -824,43 +824,43 @@ }, { "name": "future capability version", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/requires/buildSystems", "value": 2 }], "expectedCode": "plugin.api.unsupported" }, { "name": "used capability missing from requires", - "base": "mix-provider.plugin.json", + "base": "build-provider-language-server.plugin.json", "edits": [{ "op": "remove", "path": "/requires/languageServers" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "requires lists an unused capability", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/requires/ui", "value": 1 }], "expectedCode": "plugin.manifest.invalid" }, { "name": "own provider without buildProviders capability", - "base": "gradle-provider.plugin.json", + "base": "build-provider.plugin.json", "edits": [{ "op": "remove", "path": "/requires/buildProviders" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "shared provider without buildProviders capability", - "base": "mix-provider.plugin.json", + "base": "build-provider-language-server.plugin.json", "edits": [{ "op": "remove", "path": "/requires/buildProviders" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "template build lists unused buildProviders capability", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "edits": [{ "op": "set", "path": "/requires/buildProviders", "value": 1 }], "expectedCode": "plugin.manifest.invalid" }, { "name": "own provider process without serverProcess", - "base": "gradle-provider.plugin.json", + "base": "build-provider.plugin.json", "edits": [{ "op": "remove", "path": "/requires/serverProcess" }], "expectedCode": "plugin.manifest.invalid" }, @@ -920,43 +920,43 @@ }, { "name": "build system icon leaves the package", - "base": "gradle-build.plugin.json", + "base": "build-command-output-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/icon", "value": {"path": "../gradle.svg"} }], "expectedCode": "plugin.manifest.invalid" }, { "name": "toolbar option without an icon", - "base": "gradle-build.plugin.json", + "base": "build-command-output-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/options/0/presentation", "value": "toolbar" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "toolbar option that is not boolean", - "base": "maven-build.plugin.json", + "base": "build-host-integration.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/options/0/presentation", "value": "toolbar" }, { "op": "set", "path": "/buildSystems/0/options/0/icon", "value": {"host": "settings"} }], "expectedCode": "plugin.manifest.invalid" }, { "name": "tree option that is not multiSelect", - "base": "gradle-build.plugin.json", + "base": "build-command-output-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/options/0/presentation", "value": "tree" }], "expectedCode": "plugin.manifest.invalid" }, { "name": "option icon outside the toolbar", - "base": "gradle-build.plugin.json", + "base": "build-command-output-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/options/0/icon", "value": {"host": "settings"} }], "expectedCode": "plugin.manifest.invalid" }, { "name": "task category deeper than four headings", - "base": "gradle-build.plugin.json", + "base": "build-command-output-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/tasks/0/category", "value": ["a", "b", "c", "d", "e"] }], "expectedCode": "plugin.manifest.invalid" }, { "name": "task category with an empty heading", - "base": "gradle-build.plugin.json", + "base": "build-command-output-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/tasks/0/category", "value": ["Tasks", " "] }], "expectedCode": "plugin.manifest.invalid" } diff --git a/shared/contracts/plugins/v1/fixtures/java-support.plugin.json b/shared/contracts/plugins/v1/fixtures/language-host-integration.plugin.json similarity index 93% rename from shared/contracts/plugins/v1/fixtures/java-support.plugin.json rename to shared/contracts/plugins/v1/fixtures/language-host-integration.plugin.json index bfae664da..5a2ec18a5 100644 --- a/shared/contracts/plugins/v1/fixtures/java-support.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/language-host-integration.plugin.json @@ -1,7 +1,7 @@ { "schemaVersion": 2, - "id": "dev.lithe.fixture.java-support", - "displayName": "Java Support Fixture", + "id": "dev.lithe.fixture.language-host-integration", + "displayName": "Host Integration Language Server Fixture", "version": "1.0.0", "pluginAPIVersion": 2, "vendor": { diff --git a/shared/contracts/plugins/v1/fixtures/php-support.plugin.json b/shared/contracts/plugins/v1/fixtures/language-packaged-server.plugin.json similarity index 94% rename from shared/contracts/plugins/v1/fixtures/php-support.plugin.json rename to shared/contracts/plugins/v1/fixtures/language-packaged-server.plugin.json index d0abff123..30aacbd2e 100644 --- a/shared/contracts/plugins/v1/fixtures/php-support.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/language-packaged-server.plugin.json @@ -1,7 +1,7 @@ { "schemaVersion": 2, - "id": "dev.lithe.fixture.php-support", - "displayName": "PHP Support Fixture", + "id": "dev.lithe.fixture.language-packaged-server", + "displayName": "Packaged Language Server Fixture", "version": "0.1.0", "pluginAPIVersion": 1, "vendor": { diff --git a/shared/contracts/plugins/v1/fixtures/go-support.plugin.json b/shared/contracts/plugins/v1/fixtures/language-system-command.plugin.json similarity index 95% rename from shared/contracts/plugins/v1/fixtures/go-support.plugin.json rename to shared/contracts/plugins/v1/fixtures/language-system-command.plugin.json index a37203487..299f341b1 100644 --- a/shared/contracts/plugins/v1/fixtures/go-support.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/language-system-command.plugin.json @@ -1,7 +1,7 @@ { "schemaVersion": 2, - "id": "dev.lithe.fixture.go-support", - "displayName": "Go Support Fixture", + "id": "dev.lithe.fixture.language-system-command", + "displayName": "System Command Language Server Fixture", "version": "0.1.0", "pluginAPIVersion": 1, "vendor": { diff --git a/shared/contracts/plugins/v1/fixtures/marketplace/index-cases.json b/shared/contracts/plugins/v1/fixtures/marketplace/index-cases.json index bee4b0929..c4f5960bf 100644 --- a/shared/contracts/plugins/v1/fixtures/marketplace/index-cases.json +++ b/shared/contracts/plugins/v1/fixtures/marketplace/index-cases.json @@ -8,7 +8,7 @@ "platform": "darwin-arm64", "expected": { "plugins": [ - { "id": "dev.lithe.fixture.php-support", "latestVersion": "1.1.0", "platforms": ["darwin-arm64", "darwin-x64"], "availability": { "status": "available", "version": "1.0.0", "sha256": "5555555555555555555555555555555555555555555555555555555555555555" } }, + { "id": "dev.lithe.fixture.language-packaged-server", "latestVersion": "1.1.0", "platforms": ["darwin-arm64", "darwin-x64"], "availability": { "status": "available", "version": "1.0.0", "sha256": "5555555555555555555555555555555555555555555555555555555555555555" } }, { "id": "io.example.elixir-tools", "latestVersion": "3.0.0", "platforms": ["darwin-arm64", "windows-x64"], "availability": { "status": "unsupported", "code": "plugin.index.apiUnsupported" } }, { "id": "io.example.future-tools", "latestVersion": "3.0.0", "platforms": ["darwin-arm64", "linux-x64"], "availability": { "status": "unsupported", "code": "plugin.index.apiUnsupported" } }, { "id": "io.example.zig-support", "latestVersion": "2.0.0", "platforms": ["darwin-arm64", "windows-x64"], "availability": { "status": "available", "version": "2.0.0", "downloadURL": "https://github.com/example/lithe-zig/releases/download/v2.0.0/zig-support-2.0.0-darwin-arm64.zip" } } @@ -29,7 +29,7 @@ { "id": "io.example.zig-support", "availability": { "status": "unsupported", "code": "plugin.index.platformUnsupported", "platform": "darwin-x64" } } ], "rejected": [ - { "id": "dev.lithe.fixture.php-support", "code": "plugin.index.officialNamespace" }, + { "id": "dev.lithe.fixture.language-packaged-server", "code": "plugin.index.officialNamespace" }, { "id": "io.example.zig-support", "code": "plugin.index.duplicate" } ] } @@ -45,7 +45,7 @@ { "id": "io.example.zig-support", "availability": { "status": "available", "version": "2.0.0", "sha256": "3333333333333333333333333333333333333333333333333333333333333333" } } ], "rejected": [ - { "id": "dev.lithe.fixture.php-support", "code": "plugin.index.officialNamespace" }, + { "id": "dev.lithe.fixture.language-packaged-server", "code": "plugin.index.officialNamespace" }, { "id": "io.example.zig-support", "code": "plugin.index.duplicate" } ] } @@ -62,7 +62,7 @@ { "id": "io.example.zig-support", "availability": { "status": "available", "version": "2.0.0" } } ], "rejected": [ - { "id": "dev.lithe.fixture.php-support", "code": "plugin.index.officialNamespace" }, + { "id": "dev.lithe.fixture.language-packaged-server", "code": "plugin.index.officialNamespace" }, { "id": "io.example.zig-support", "code": "plugin.index.duplicate" } ] } @@ -79,7 +79,7 @@ { "id": "io.example.zig-support", "availability": { "status": "available", "version": "2.0.0" } } ], "rejected": [ - { "id": "dev.lithe.fixture.php-support", "code": "plugin.index.officialNamespace" }, + { "id": "dev.lithe.fixture.language-packaged-server", "code": "plugin.index.officialNamespace" }, { "id": "io.example.zig-support", "code": "plugin.index.duplicate" } ] } @@ -96,7 +96,7 @@ { "id": "io.example.zig-support", "availability": { "status": "available", "version": "2.0.0", "sha256": "3333333333333333333333333333333333333333333333333333333333333333" } } ], "rejected": [ - { "id": "dev.lithe.fixture.php-support", "code": "plugin.index.officialNamespace" }, + { "id": "dev.lithe.fixture.language-packaged-server", "code": "plugin.index.officialNamespace" }, { "id": "io.example.zig-support", "code": "plugin.index.duplicate" } ] } diff --git a/shared/contracts/plugins/v1/fixtures/marketplace/index.json b/shared/contracts/plugins/v1/fixtures/marketplace/index.json index b4ca8a7a4..640b5ec3f 100644 --- a/shared/contracts/plugins/v1/fixtures/marketplace/index.json +++ b/shared/contracts/plugins/v1/fixtures/marketplace/index.json @@ -45,8 +45,8 @@ ] }, { - "id": "dev.lithe.fixture.php-support", - "name": "PHP Support Fixture", + "id": "dev.lithe.fixture.language-packaged-server", + "name": "Packaged Language Server Fixture", "author": "Lithe", "repository": "https://github.com/Lithe-IDEA/Lithe-IDEA", "description": "PHP language server, run and test support.", diff --git a/shared/contracts/plugins/v1/fixtures/marketplace/install-reviews.json b/shared/contracts/plugins/v1/fixtures/marketplace/install-reviews.json index 2d50fa7f7..066306750 100644 --- a/shared/contracts/plugins/v1/fixtures/marketplace/install-reviews.json +++ b/shared/contracts/plugins/v1/fixtures/marketplace/install-reviews.json @@ -4,8 +4,8 @@ "cases": [ { "name": "first installation lists every program", - "base": "php-support.plugin.json", - "expectedID": "dev.lithe.fixture.php-support", + "base": "language-packaged-server.plugin.json", + "expectedID": "dev.lithe.fixture.language-packaged-server", "expectedVersion": "0.1.0", "expected": { "permissions": [ @@ -67,7 +67,7 @@ }, { "name": "unchanged update needs no new approval", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [ { "op": "set", @@ -75,14 +75,14 @@ "value": "0.2.0" } ], - "approvedFrom": "php-support.plugin.json", + "approvedFrom": "language-packaged-server.plugin.json", "expected": { "newProgramCount": 0 } }, { "name": "changed language server arguments need approval", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [ { "op": "set", @@ -93,7 +93,7 @@ ] } ], - "approvedFrom": "php-support.plugin.json", + "approvedFrom": "language-packaged-server.plugin.json", "expected": { "newPrograms": [ { @@ -112,7 +112,7 @@ }, { "name": "changed run arguments with the same executable need approval", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [ { "op": "set", @@ -123,7 +123,7 @@ ] } ], - "approvedFrom": "php-support.plugin.json", + "approvedFrom": "language-packaged-server.plugin.json", "expected": { "newPrograms": [ { @@ -143,7 +143,7 @@ }, { "name": "changed test arguments with the same executable need approval", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [ { "op": "set", @@ -153,14 +153,14 @@ ] } ], - "approvedFrom": "php-support.plugin.json", + "approvedFrom": "language-packaged-server.plugin.json", "expected": { "newProgramCount": 1 } }, { "name": "changed language server validation probe needs approval", - "base": "php-support.plugin.json", + "base": "language-packaged-server.plugin.json", "edits": [ { "op": "set", @@ -170,21 +170,21 @@ ] } ], - "approvedFrom": "php-support.plugin.json", + "approvedFrom": "language-packaged-server.plugin.json", "expected": { "newProgramCount": 1 } }, { "name": "package does not match the index entry", - "base": "php-support.plugin.json", - "expectedID": "dev.lithe.fixture.php-support", + "base": "language-packaged-server.plugin.json", + "expectedID": "dev.lithe.fixture.language-packaged-server", "expectedVersion": "9.9.9", "expectedCode": "plugin.package.mismatch" }, { "name": "unsigned package in the official namespace", - "base": "go-support.plugin.json", + "base": "language-system-command.plugin.json", "edits": [ { "op": "set", @@ -196,7 +196,7 @@ }, { "name": "unsigned community package", - "base": "go-support.plugin.json", + "base": "language-system-command.plugin.json", "edits": [ { "op": "set", @@ -271,19 +271,19 @@ }, { "name": "template build review does not require buildProviders", - "base": "cargo-build.plugin.json", + "base": "build-static-file.plugin.json", "hostCapabilities": { "buildSystems": [ 1 ] }, "expected": { - "id": "dev.lithe.fixture.cargo-build" + "id": "dev.lithe.fixture.build-static-file" } }, { "name": "gradle provider review requires buildProviders even with buildSystems", - "base": "gradle-provider.plugin.json", + "base": "build-provider.plugin.json", "hostCapabilities": { "buildSystems": [ 1 @@ -306,7 +306,7 @@ }, { "name": "gradle provider review rejects unsupported buildProviders version", - "base": "gradle-provider.plugin.json", + "base": "build-provider.plugin.json", "hostCapabilities": { "buildProviders": [ 2 @@ -332,14 +332,14 @@ }, { "name": "gradle provider review without a host table uses Core capabilities", - "base": "gradle-provider.plugin.json", + "base": "build-provider.plugin.json", "expected": { - "id": "dev.lithe.fixture.gradle-provider" + "id": "dev.lithe.fixture.build-provider" } }, { "name": "mix provider review requires buildProviders even with buildSystems", - "base": "mix-provider.plugin.json", + "base": "build-provider-language-server.plugin.json", "hostCapabilities": { "buildSystems": [ 1 @@ -362,7 +362,7 @@ }, { "name": "mix provider review rejects unsupported buildProviders version", - "base": "mix-provider.plugin.json", + "base": "build-provider-language-server.plugin.json", "hostCapabilities": { "buildProviders": [ 2 @@ -388,14 +388,14 @@ }, { "name": "mix provider review without a host table uses Core capabilities", - "base": "mix-provider.plugin.json", + "base": "build-provider-language-server.plugin.json", "expected": { - "id": "dev.lithe.fixture.mix-provider" + "id": "dev.lithe.fixture.build-provider-language-server" } }, { "name": "own provider review accepts implemented buildProviders", - "base": "gradle-provider.plugin.json", + "base": "build-provider.plugin.json", "hostCapabilities": { "buildProviders": [ 1 @@ -411,21 +411,21 @@ ] }, "expected": { - "id": "dev.lithe.fixture.gradle-provider" + "id": "dev.lithe.fixture.build-provider" } }, { "name": "host without build systems refuses a build plugin", - "base": "mix-provider.plugin.json", + "base": "build-provider-language-server.plugin.json", "hostCapabilities": { "languages": [1], "languageServers": [1], "serverProcess": [1] }, "expectedCode": "plugin.api.unsupported" }, { "name": "host with every required capability reviews a build plugin", - "base": "mix-provider.plugin.json", + "base": "build-provider-language-server.plugin.json", "hostCapabilities": { "buildProviders": [1], "buildSystems": [1], "languages": [1], "languageServers": [1], "serverProcess": [1] }, "expected": { - "id": "dev.lithe.fixture.mix-provider", + "id": "dev.lithe.fixture.build-provider-language-server", "permissions": ["languageServer.launch", "process.build"], "newProgramCount": 2 } diff --git a/shared/contracts/plugins/v1/plugin-index.schema.json b/shared/contracts/plugins/v1/plugin-index.schema.json index 4d4f53af5..26ae06a5d 100644 --- a/shared/contracts/plugins/v1/plugin-index.schema.json +++ b/shared/contracts/plugins/v1/plugin-index.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://lithe.dev/contracts/plugins/v1/plugin-index.schema.json", + "$id": "https://raw.githubusercontent.com/Lithe-IDEA/lithe-plugin-docs/main/contract/v1/plugin-index.schema.json", "title": "Lithe plugin marketplace index (plugin contract v1)", "description": "Shape of a marketplace source index. Semantic rules that JSON Schema cannot express (download URLs inside the entry's own repository, duplicate IDs and versions, the official namespace, platform and API filtering) are defined in README.md and enforced by plugins.marketplace.validateIndex.", "type": "object", diff --git a/shared/contracts/plugins/v1/plugin-manifest.schema.json b/shared/contracts/plugins/v1/plugin-manifest.schema.json index 1b45e5dad..1be2c5ef2 100644 --- a/shared/contracts/plugins/v1/plugin-manifest.schema.json +++ b/shared/contracts/plugins/v1/plugin-manifest.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://lithe.dev/contracts/plugins/v1/plugin-manifest.schema.json", + "$id": "https://raw.githubusercontent.com/Lithe-IDEA/lithe-plugin-docs/main/contract/v1/plugin-manifest.schema.json", "title": "Lithe declarative plugin manifest (plugin contract v1)", "description": "Shape of plugin.json for a declarative plugin. Semantic rules that JSON Schema cannot express (cross references, permissions, activation events, template variables) are defined in README.md and enforced by plugins.declarative.validate.", "type": "object", diff --git a/shared/platform-feature-matrix/features/build-provider-refresh-and-tool.json b/shared/platform-feature-matrix/features/build-provider-refresh-and-tool.json index b37d7040b..4896e1950 100644 --- a/shared/platform-feature-matrix/features/build-provider-refresh-and-tool.json +++ b/shared/platform-feature-matrix/features/build-provider-refresh-and-tool.json @@ -15,8 +15,7 @@ "macos/Sources/Lithe/Application/Features/PluginWebBridge.swift", "macos/Tests/LitheExecutionModuleTests/BuildSystemServiceTests.swift", "macos/Tests/LitheTests/MacBuildProviderSessionsTests.swift", - "macos/Tests/LitheTests/PluginWebBridgeTests.swift", - "sdk/node/src/index.d.ts" + "macos/Tests/LitheTests/PluginWebBridgeTests.swift" ], "implementationStatus": "implemented", "verificationStatus": "pending" diff --git a/shared/platform-feature-matrix/features/build-system-plugins.json b/shared/platform-feature-matrix/features/build-system-plugins.json index 801252cc6..d8ec9864c 100644 --- a/shared/platform-feature-matrix/features/build-system-plugins.json +++ b/shared/platform-feature-matrix/features/build-system-plugins.json @@ -32,11 +32,11 @@ "evidence": [ "rust/lithe-core/src/build", "shared/contracts/plugins/v1/fixtures/build-plans.json", - "shared/contracts/plugins/v1/fixtures/gradle-provider.plugin.json" + "shared/contracts/plugins/v1/fixtures/build-provider.plugin.json" ], "implementationStatus": "missing", "verificationStatus": "not-applicable" }, "owner": "Extensions", - "verification": "macOS 已接入 static、command、hostIntegration、provider 四种模型来源:宿主向 Core 声明 buildSystems 与 buildProviders 能力,插件市场因此列出兼容的 API 3 声明式和 Provider 版本;Provider 进程按插件、构建系统和工作区各自独立,经各自 stdio 用 JSON-RPC(LSP 基础协议)与宿主通信,initialize 协商 capabilities.experimental.litheBuild 后宿主才经 lsp.buildRequest 发送 lithe/build/model、lithe/build/dependencies,回复交给 Core 解码;共享写法只复用同一插件的语言服务器会话;进程只在用户加载模型或依赖时启动,切换工作区、关闭项目窗口、停止执行模块、停用或更新插件、退出应用时停止;ExecutionFeatureGraph.stop 经 BuildSystemService.stop 同时回收已就绪和初始化中的 Provider,停止后的初始化完成也不得重新登记会话;状态写入插件状态目录;每个已启用的构建系统插件在打开工作区后各有一个右侧工具窗口(标题为 displayName、图标为 icon),不依赖识别结果,工作区没有对应工程时窗口内显示未检测到该构建系统,工具栏放运行、参数任务、刷新和 presentation=toolbar 的开关,树顶部是 presentation=tree 的勾选选项,工程下按 category(缺省按 group)分级列出任务,再列测试、目标和依赖;内置 Maven 窗口与共享 Build 窗口已删除,Build Output 显示输出、诊断与测试结果。窗口恢复先激活所属插件模块,激活中的命令面板项禁用;参数任务遵守 workspace/project/target 作用范围与目标标签,模型或工作区变化后清除旧选择。隔离回归覆盖这些边界、窗口布局恢复及包内图标读取前后文件清单与字节不变;多实例的根目录选择器保留每个实例的模型候选和生效值,编辑多选时保留兄弟实例独有的已选值;显式清空按实例根持久化为空覆盖,检测、同步指纹、任务计划和语言服务器导入使用同一覆盖,兄弟编辑或重开不会恢复非空默认值,非空编辑清当前实例标记而共享编辑/重置清该选项全部标记;真实 Core、生产存储与服务回归核对最终 argv 和导入值;列表单选与多选支持 allowCustom 原生输入和移除已选自定义值,文本/文件/目录清空提交显式空字符串,只有重置删除覆盖。插件选项在项目打开、选项修改和模型加载完成后通过 Core build.languageImport 归一化为 mavenContext/javaGradle,与构建计划使用同一生效值,并在 Java 启动前等待同一工作区代次的最新配置;运行时准备后再等一次,覆盖等待期间失效。唯一发布回调优先 Maven 原地热更新,被拒绝时串行恢复重启;停用最后一个 Maven 插件时,仅清除曾带 Maven 输入的同一会话,纯 Gradle 首次启动保持 nil。Gradle 在下次启动生效,取消/重置拒绝旧检测与恢复任务,被动工具路径发现不执行 Wrapper/版本探测;没有对应插件导入输入时兼容原 Maven 设置。模型 run 入口与 lithe/build/execute 尚未接入,Provider 无操作系统级沙盒;Windows 宿主尚未调用 build.* 命令。自动化:cargo test -p lithe-core --lib build_systems、cargo test -p lithe-core --lib lsp、scripts/verify-shared-contracts.sh、./scripts/test-macos.sh --filter 'BuildSystemToolWindowTests|RustBuildSystemOperationsTests|BuildSystemServiceTests|PluginMarketplaceServiceTests'。手动验证:安装 fixtures 中 cargo-build、go-build 形态的插件包,确认每个构建系统各出现一个工具窗口,列出工程、分级任务、选项和依赖,停用插件后窗口消失;执行任务与测试的 argv 与 build-plans.json 一致,停止与输出上限生效;JUnit 报告与 lineMatcher 诊断可跳转到源码;命令模型和项目内 Wrapper 只在用户点击加载或运行后启动;禁用插件后实例消失;Windows 接入后另需验证未信任工作区不启动任何模型命令或 Wrapper;gradle-provider 形态的自有 Provider 只在用户操作或工作区信任后启动、停用插件时停止,mix-provider 形态复用语言服务器会话而不启动第二个进程;运行前后 app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。" + "verification": "macOS 已接入 static、command、hostIntegration、provider 四种模型来源:宿主向 Core 声明 buildSystems 与 buildProviders 能力,插件市场因此列出兼容的 API 3 声明式和 Provider 版本;Provider 进程按插件、构建系统和工作区各自独立,经各自 stdio 用 JSON-RPC(LSP 基础协议)与宿主通信,initialize 协商 capabilities.experimental.litheBuild 后宿主才经 lsp.buildRequest 发送 lithe/build/model、lithe/build/dependencies,回复交给 Core 解码;共享写法只复用同一插件的语言服务器会话;进程只在用户加载模型或依赖时启动,切换工作区、关闭项目窗口、停止执行模块、停用或更新插件、退出应用时停止;ExecutionFeatureGraph.stop 经 BuildSystemService.stop 同时回收已就绪和初始化中的 Provider,停止后的初始化完成也不得重新登记会话;状态写入插件状态目录;每个已启用的构建系统插件在打开工作区后各有一个右侧工具窗口(标题为 displayName、图标为 icon),不依赖识别结果,工作区没有对应工程时窗口内显示未检测到该构建系统,工具栏放运行、参数任务、刷新和 presentation=toolbar 的开关,树顶部是 presentation=tree 的勾选选项,工程下按 category(缺省按 group)分级列出任务,再列测试、目标和依赖;内置 Maven 窗口与共享 Build 窗口已删除,Build Output 显示输出、诊断与测试结果。窗口恢复先激活所属插件模块,激活中的命令面板项禁用;参数任务遵守 workspace/project/target 作用范围与目标标签,模型或工作区变化后清除旧选择。隔离回归覆盖这些边界、窗口布局恢复及包内图标读取前后文件清单与字节不变;多实例的根目录选择器保留每个实例的模型候选和生效值,编辑多选时保留兄弟实例独有的已选值;显式清空按实例根持久化为空覆盖,检测、同步指纹、任务计划和语言服务器导入使用同一覆盖,兄弟编辑或重开不会恢复非空默认值,非空编辑清当前实例标记而共享编辑/重置清该选项全部标记;真实 Core、生产存储与服务回归核对最终 argv 和导入值;列表单选与多选支持 allowCustom 原生输入和移除已选自定义值,文本/文件/目录清空提交显式空字符串,只有重置删除覆盖。插件选项在项目打开、选项修改和模型加载完成后通过 Core build.languageImport 归一化为 mavenContext/javaGradle,与构建计划使用同一生效值,并在 Java 启动前等待同一工作区代次的最新配置;运行时准备后再等一次,覆盖等待期间失效。唯一发布回调优先 Maven 原地热更新,被拒绝时串行恢复重启;停用最后一个 Maven 插件时,仅清除曾带 Maven 输入的同一会话,纯 Gradle 首次启动保持 nil。Gradle 在下次启动生效,取消/重置拒绝旧检测与恢复任务,被动工具路径发现不执行 Wrapper/版本探测;没有对应插件导入输入时兼容原 Maven 设置。模型 run 入口与 lithe/build/execute 尚未接入,Provider 无操作系统级沙盒;Windows 宿主尚未调用 build.* 命令。自动化:cargo test -p lithe-core --lib build_systems、cargo test -p lithe-core --lib lsp、scripts/verify-shared-contracts.sh、./scripts/test-macos.sh --filter 'BuildSystemToolWindowTests|RustBuildSystemOperationsTests|BuildSystemServiceTests|PluginMarketplaceServiceTests'。手动验证:安装 fixtures 中 build-static-file、build-command-stdout 形态的插件包,确认每个构建系统各出现一个工具窗口,列出工程、分级任务、选项和依赖,停用插件后窗口消失;执行任务与测试的 argv 与 build-plans.json 一致,停止与输出上限生效;JUnit 报告与 lineMatcher 诊断可跳转到源码;命令模型和项目内 Wrapper 只在用户点击加载或运行后启动;禁用插件后实例消失;Windows 接入后另需验证未信任工作区不启动任何模型命令或 Wrapper;build-provider 形态的自有 Provider 只在用户操作或工作区信任后启动、停用插件时停止,build-provider-language-server 形态复用语言服务器会话而不启动第二个进程;运行前后 app bundle 文件清单不变(scripts/verify-runtime-bundle-immutability.sh)。" } diff --git a/shared/platform-feature-matrix/features/go-support-plugin.json b/shared/platform-feature-matrix/features/go-support-plugin.json index a6cc660ff..3bf0e78ab 100644 --- a/shared/platform-feature-matrix/features/go-support-plugin.json +++ b/shared/platform-feature-matrix/features/go-support-plugin.json @@ -20,7 +20,7 @@ }, "windows": { "evidence": [ - "shared/contracts/plugins/v1/fixtures/go-support.plugin.json" + "shared/contracts/plugins/v1/fixtures/language-system-command.plugin.json" ], "implementationStatus": "missing", "verificationStatus": "not-applicable" diff --git a/shared/platform-feature-matrix/features/java-support-plugin.json b/shared/platform-feature-matrix/features/java-support-plugin.json index b8224fd67..aad20b01e 100644 --- a/shared/platform-feature-matrix/features/java-support-plugin.json +++ b/shared/platform-feature-matrix/features/java-support-plugin.json @@ -12,7 +12,7 @@ "macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift", "macos/Tests/LitheTests/PluginLanguageServerLaunchResolverTests.swift", "macos/Tests/LitheTests/RealJavaDebugIntegrationTests.swift", - "shared/contracts/plugins/v1/fixtures/java-support.plugin.json", + "shared/contracts/plugins/v1/fixtures/language-host-integration.plugin.json", "macos/Sources/Lithe/Core/Language/PluginLanguageProviderCatalogSource.swift", "macos/Tests/LitheTests/LanguageProviderCatalogSourceTests.swift", "macos/Tests/LitheTests/RunWorkflowCoordinatorTests.swift" diff --git a/shared/platform-feature-matrix/features/plugin-functions.json b/shared/platform-feature-matrix/features/plugin-functions.json index 43b5620a8..77f40eb88 100644 --- a/shared/platform-feature-matrix/features/plugin-functions.json +++ b/shared/platform-feature-matrix/features/plugin-functions.json @@ -2,7 +2,7 @@ "id": "plugin-functions", "area": "扩展", "group": "插件与主题", - "capability": "插件私有函数:插件后端通过 litheFunctions v1 协商和注册,宿主按插件归属调用,接收结果与进度,并支持取消、超时和撤回;领域操作仍使用类型化契约;官方 Node 与 Java(Java 8+)后端 SDK 实现后端一侧", + "capability": "插件私有函数:插件后端通过 litheFunctions v1 协商和注册,宿主按插件归属调用,接收结果与进度,并支持取消、超时和撤回;领域操作仍使用类型化契约;官方 Node 与 Java(Java 8+)后端 SDK(在 lithe-plugin-docs 仓库)实现后端一侧", "macos": { "implementationStatus": "partial", "verificationStatus": "verified", @@ -10,10 +10,6 @@ "rust/lithe-core/src/lsp/interface/plugin_function.rs", "rust/lithe-core/src/lsp/interface/engine.rs", "shared/contracts/plugins/v1/fixtures/function-protocol.json", - "sdk/node/src/backend.mjs", - "rust/lithe-core/tests/plugin_sdk_node.rs", - "sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Backend.java", - "rust/lithe-core/tests/plugin_sdk_java.rs", "macos/Sources/Lithe/Application/Features/PluginWebBridge.swift", "macos/Sources/Lithe/Platform/MacOS/Build/MacBuildProviderSessions.swift", "macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift", @@ -27,12 +23,10 @@ "evidence": [ "rust/lithe-core/src/lsp/interface/plugin_function.rs", "rust/lithe-core/src/lsp/interface/engine.rs", - "shared/contracts/plugins/v1/README.md", - "sdk/node/src/backend.mjs", - "sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Backend.java" + "shared/contracts/plugins/v1/README.md" ] }, "owner": "Extensions", - "verification": "共享 Core:cargo test --manifest-path rust/Cargo.toml -p lithe-core --lib plugin_function 及 --lib lsp::interface::engine,核对共享 fixture、初始化发现事件、动态增删、归属拒绝、结果与插件错误码、超时取消撤回。插件会话成功完成 initialize 函数协议协商后发布初始 functionsChanged(含空注册表),无 pluginId 的宿主会话不协商、不保留或广播函数。每次后端调用使用唯一且不透明的 operationID,进度转换回宿主 operationId,所有终止路径清除关联;复用宿主 ID 后,旧进度与撤回不得影响新函数或构建请求。后端请求 lithe/function/invoke 或 lithe/build/* 时拒绝递归派发,lithe/host/* 在 v1 全部拒绝。平台验收:宿主接入后,从同一插件的界面发现并调用函数,验证另一插件无法调用、终止后的旧进度不污染新调用,关闭项目和停用插件后不残留进程;分别在 macOS 和 Windows 完成原生验收后才标记 verified。 macOS 宿主:scripts/test-macos.sh --filter \"PluginWebBridgeTests|MacBuildProviderSessionsTests\",覆盖插件网页只能访问本插件实例并经同意、结果/进度/pluginErrorCode 到达页面、取消与关闭页面取消函数、构建 Provider 以插件会话启动并带 pluginId、Core 拒绝码原样保留、停用插件停止 Provider。 macOS 原生验收:debug 应用安装带网页窗口的 sample-build(PR #1365)与另一个带构建系统的插件,在示例工作区的页面上依次调用 functions.list、functions.invoke(带 call 观察 functions.progress)、functions.cancel,核对结果与 pluginErrorCode;从另一插件页面访问该实例应得到 plugin.web.forbidden;调用进行中关闭项目、停用插件后用 pgrep 确认 Provider 与构建子进程不残留。 官方 Node SDK:node --test sdk/node/test/*.test.mjs,并以 LITHE_PLUGIN_SDK_NODE 指向 Node 运行 cargo test -p lithe-core --test plugin_sdk_node,示例插件经真实 Core 会话完成构建模型/依赖、函数结果/进度/失败/取消、重启恢复状态,且插件包文件清单不变。 官方 Java SDK:mvn -B -f sdk/java/pom.xml verify(CI 以 -Djvm 指向 Java 8 运行测试),并设置 LITHE_PLUGIN_SDK_JAVA、LITHE_PLUGIN_SDK_JAVA_SAMPLE、LITHE_PLUGIN_SDK_NODE 运行 cargo test -p lithe-core --test plugin_sdk_java,Java 示例按插件打包形态跑与 Node 相同的 Core 场景。", - "notes": "macOS 已接入宿主入口:插件网页通过 functions.list / functions.invoke / functions.cancel 调用自己构建 Provider 进程的函数,共享语言服务器不提供函数;2026-10-20 已在 macOS debug 应用中用 sdk/node/examples/sample-build(PR #1365,加一个只用于验收的网页窗口)完成原生验收:functions.list/invoke 的结果、functions.progress、动态注册的 snapshot.clear、pluginErrorCode(sample.buildFailed)、取消后的 requestCancelled 都到达页面,取消会结束构建子进程;另一插件的页面访问该实例得到 plugin.web.forbidden;调用进行中关闭项目或停用插件后 Provider 及其子进程均不残留。Windows 尚无插件后端宿主;不能以共享测试通过替代平台功能验收。函数不新增清单字段,不改变 pluginAPIVersion 或 requires;litheBuild v1 与旧后端继续独立工作,lithe/host/* 暂不开放,协议不提供操作系统级沙盒。 官方 Node SDK(sdk/node)只实现契约、不新增线上字段,进程树清理在 POSIX 由端到端测试覆盖,Windows 的 taskkill 路径尚未在 Windows 上验证。 官方 Java SDK(sdk/java)同样只实现契约;Java 9+ 取消时结束整棵进程树,Java 8 只结束直接子进程,其余由宿主的后端进程标记兜底;目前只经共享 Core 端到端验证,macOS 原生验收随 Maven 插件的 Java 后端一起完成。运行时启动计划的 java 版本探测改用 -version,Java 8 后端不再被误判为不可用。" + "verification": "共享 Core:cargo test --manifest-path rust/Cargo.toml -p lithe-core --lib plugin_function 及 --lib lsp::interface::engine,核对共享 fixture、初始化发现事件、动态增删、归属拒绝、结果与插件错误码、超时取消撤回。插件会话成功完成 initialize 函数协议协商后发布初始 functionsChanged(含空注册表),无 pluginId 的宿主会话不协商、不保留或广播函数。每次后端调用使用唯一且不透明的 operationID,进度转换回宿主 operationId,所有终止路径清除关联;复用宿主 ID 后,旧进度与撤回不得影响新函数或构建请求。后端请求 lithe/function/invoke 或 lithe/build/* 时拒绝递归派发,lithe/host/* 在 v1 全部拒绝。平台验收:宿主接入后,从同一插件的界面发现并调用函数,验证另一插件无法调用、终止后的旧进度不污染新调用,关闭项目和停用插件后不残留进程;分别在 macOS 和 Windows 完成原生验收后才标记 verified。 macOS 宿主:scripts/test-macos.sh --filter \"PluginWebBridgeTests|MacBuildProviderSessionsTests\",覆盖插件网页只能访问本插件实例并经同意、结果/进度/pluginErrorCode 到达页面、取消与关闭页面取消函数、构建 Provider 以插件会话启动并带 pluginId、Core 拒绝码原样保留、停用插件停止 Provider。 macOS 原生验收:debug 应用安装带网页窗口的 sample-build(PR #1365)与另一个带构建系统的插件,在示例工作区的页面上依次调用 functions.list、functions.invoke(带 call 观察 functions.progress)、functions.cancel,核对结果与 pluginErrorCode;从另一插件页面访问该实例应得到 plugin.web.forbidden;调用进行中关闭项目、停用插件后用 pgrep 确认 Provider 与构建子进程不残留。 官方 SDK(在 lithe-plugin-docs 仓库中运行):node --test sdk/node/test/*.test.mjs;mvn -B -f sdk/java/pom.xml verify(CI 以 -Djvm 指向 Java 8 运行测试);node scripts/sdk-e2e.mjs 把 sdk/e2e 复制进契约副本锁定的主仓库 commit 并运行 plugin_sdk_node、plugin_sdk_java,两个示例插件经真实 Core 会话完成构建模型/依赖、函数结果/进度/失败/取消、重启恢复状态,且插件包文件清单不变,Java 示例按插件打包形态(plugin.json + shade 后的 JAR)运行;CI 每周再以 --ref HEAD 对主仓库最新 Core 运行一次。", + "notes": "macOS 已接入宿主入口:插件网页通过 functions.list / functions.invoke / functions.cancel 调用自己构建 Provider 进程的函数,共享语言服务器不提供函数;2026-10-20 已在 macOS debug 应用中用 Node SDK 的示例插件 sample-build(PR #1365,当时在主仓库 sdk/node/examples,加一个只用于验收的网页窗口)完成原生验收:functions.list/invoke 的结果、functions.progress、动态注册的 snapshot.clear、pluginErrorCode(sample.buildFailed)、取消后的 requestCancelled 都到达页面,取消会结束构建子进程;另一插件的页面访问该实例得到 plugin.web.forbidden;调用进行中关闭项目或停用插件后 Provider 及其子进程均不残留。Windows 尚无插件后端宿主;不能以共享测试通过替代平台功能验收。函数不新增清单字段,不改变 pluginAPIVersion 或 requires;litheBuild v1 与旧后端继续独立工作,lithe/host/* 暂不开放,协议不提供操作系统级沙盒。 官方 Node SDK(lithe-plugin-docs 的 sdk/node)只实现契约、不新增线上字段,进程树清理在 POSIX 由端到端测试覆盖,Windows 的 taskkill 路径尚未在 Windows 上验证。 官方 Java SDK(lithe-plugin-docs 的 sdk/java)同样只实现契约;Java 9+ 取消时结束整棵进程树,Java 8 只结束直接子进程,其余由宿主的后端进程标记兜底;目前只经共享 Core 端到端验证,macOS 原生验收随 Maven 插件的 Java 后端一起完成。运行时启动计划的 java 版本探测改用 -version,Java 8 后端不再被误判为不可用。" } From 9c86a1789ef4d2d221b5cddc0ddc1e9ff63354a7 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Fri, 23 Oct 2026 17:03:03 +0800 Subject: [PATCH 2/2] =?UTF-8?q?docs(plugins):=20SDK=20=E4=B8=8E=E5=A5=91?= =?UTF-8?q?=E7=BA=A6=E7=9A=84=E6=AF=8F=E5=91=A8=E6=A3=80=E6=9F=A5=E8=B7=9F?= =?UTF-8?q?=E8=B8=AA=20preview=20=E8=80=8C=E4=B8=8D=E6=98=AF=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E5=88=86=E6=94=AF=20main?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../implemented/architecture/2026-10-20-plugin-backend-sdk.md | 4 ++-- .../architecture/2026-10-23-plugin-contract-publishing.md | 4 ++-- shared/contracts/plugins/v1/README.md | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-10-20-plugin-backend-sdk.md b/.agents/notes/implemented/architecture/2026-10-20-plugin-backend-sdk.md index 7740604af..93ad659a8 100644 --- a/.agents/notes/implemented/architecture/2026-10-20-plugin-backend-sdk.md +++ b/.agents/notes/implemented/architecture/2026-10-20-plugin-backend-sdk.md @@ -110,7 +110,7 @@ Maven、Gradle 这类插件需要在后端里调用 JVM 生态的工具,并长 ## 后果 - 收益:插件作者只写处理器;取消、清理、错误映射和状态存储默认正确,Lithe 的统一管理规则不依赖作者自觉。 -- 收益:示例插件经过真实 Core 会话验证。lithe-plugin-docs 每次提交都用契约副本锁定的那个主仓库 commit 跑端到端测试,每周再用主仓库默认分支的最新 Core 跑一次,Core 改动破坏 SDK 时会在这里失败。 +- 收益:示例插件经过真实 Core 会话验证。lithe-plugin-docs 每次提交都用契约副本锁定的那个主仓库 commit 跑端到端测试,每周再用主仓库 `preview` 分支的最新 Core 跑一次,Core 改动破坏 SDK 时会在这里失败。 - 代价:Node SDK 以复制源码方式分发,Java SDK 被 shade 进插件 JAR,插件里的副本都不会自动升级;修复安全问题时需要插件重新发布。 - 代价:SDK 移出主仓库后,主仓库的 PR 不再当场发现“Core 改动破坏 SDK”,最晚由 lithe-plugin-docs 的每周检查发现。改 Core 的插件后端协议时,应在合并前用 lithe-plugin-docs 的 `node scripts/sdk-e2e.mjs --source <本地主仓库> --ref <分支>` 手动跑一次。 - 代价:lithe-plugin-docs 的端到端测试需要 Rust、Node、JDK 和 Maven,每次都要编译一遍 Core;CI 用 Java 8 运行时跑 Java SDK 测试和示例后端,用 JDK 21 编译。 @@ -123,7 +123,7 @@ Maven、Gradle 这类插件需要在后端里调用 JVM 生态的工具,并长 - `node --test sdk/node/test/*.test.mjs`:协商、ID 规则(对照 `contract/v1/fixtures/function-protocol.json`)、保留方法、调用/进度/错误/取消、运行时注册、构建结果、生命周期和退出码、分帧、进程树取消、状态存储与只读包拒绝、类型声明与运行时导出一致。 - `mvn -B -f sdk/java/pom.xml verify [-Djvm=]`:Java SDK 的 JUnit 测试,覆盖与 Node 测试相同的协议场景,另含 JSON、分帧、进程树取消和状态存储。 - `node scripts/sdk-e2e.mjs`:把 `sdk/e2e` 复制进锁定版本主仓库的 `rust/lithe-core/tests/`,运行 `plugin_sdk_node` 和 `plugin_sdk_java`。Node 示例经 Core 校验、启动计划、版本检查、构建模型/依赖、函数结果/进度/失败/取消、重启后状态恢复,最后确认插件包文件清单不变;Java 示例按插件实际打包方式(`plugin.json` + shade 后的 JAR)装进临时包目录,跑同一套场景。设置 `LITHE_PLUGIN_SDK_JAVA_RUNTIME` 时,Java 示例在该运行时(CI 为 Java 8)上运行。 -- `node scripts/sdk-e2e.mjs --ref HEAD`:同上,换成主仓库默认分支的最新 Core;CI 每周运行。 +- `node scripts/sdk-e2e.mjs --ref preview`:同上,换成主仓库 `preview` 分支的最新 Core;CI 每周运行。 ## 适用范围 diff --git a/.agents/notes/implemented/architecture/2026-10-23-plugin-contract-publishing.md b/.agents/notes/implemented/architecture/2026-10-23-plugin-contract-publishing.md index f10571b63..37783dfe7 100644 --- a/.agents/notes/implemented/architecture/2026-10-23-plugin-contract-publishing.md +++ b/.agents/notes/implemented/architecture/2026-10-23-plugin-contract-publishing.md @@ -45,7 +45,7 @@ - `sync --revision `:按来源目录在该 commit 的 git tree 整体替换副本,新增、删除、修改的文件都跟上,然后重写锁文件。 - `verify`:离线检查锁文件、哈希、schema `$id`、Markdown 相对链接和未锁定文件。 - `verify --upstream`:用 GitHub API 取锁定 commit 的 tree,按 git blob ID 比较整个目录。 - - `drift`:检查主仓库默认分支上的契约是否已经变化。 + - `drift`:检查主仓库 `preview` 分支上的契约是否已经变化。不用默认分支:主仓库的默认分支是稳定版 `main`,只在发版时追上 `preview`,拿它比较会让刚同步过的副本每周都报差异。 docs 仓库的 CI 每次提交运行前两项检查和脚本测试,每周运行一次 `drift`。 - `scripts/sdk-e2e.mjs`:SDK 端到端测试,见下文“SDK 整体移到 lithe-plugin-docs”。 @@ -110,7 +110,7 @@ SDK 是给插件开发者用的库,不是契约。开发者要读它的 README - **发版**:lithe-plugin-docs 打 `sdk-node-v<版本>` 或 `sdk-java-v<版本>` 标签,`release-node-sdk.yml`、`release-java-sdk.yml` 发布包和 `.sha256`。具体规则见 [官方插件后端 SDK](2026-10-20-plugin-backend-sdk.md)。Java SDK 0.1.0 是迁移前从主仓库发布的,那个 Release 保留,Maven 插件锁定的下载地址不变。 - **单元测试**:SDK 自己的测试读 `contract/v1/fixtures/function-protocol.json`,也就是契约副本,不再读主仓库。 - **端到端测试**:场景仍是 Rust 测试,要链接 Rust Core。`sdk-e2e.mjs` 把锁文件记录的主仓库 commit 稀疏检出到 `.artifacts/lithe`(只取 `rust/` 和 `shared/`),把 `sdk/e2e` 复制进它的 `rust/lithe-core/tests/`,再用 `cargo test --locked` 运行。`LITHE_PLUGIN_SDK_ROOT` 告诉测试示例插件在哪里。这和 Cargo 插件测 Core 的方式相同。 -- **两个版本的 Core**:每次提交测契约副本锁定的那个 commit,保证 SDK、契约副本和 Core 是同一个版本;每周再用 `--ref HEAD` 测主仓库默认分支的最新 Core,提前发现 Core 的改动破坏了 SDK。 +- **两个版本的 Core**:每次提交测契约副本锁定的那个 commit,保证 SDK、契约副本和 Core 是同一个版本;每周再用 `--ref preview` 测主仓库 `preview` 分支的最新 Core,提前发现 Core 的改动破坏了 SDK。 代价是主仓库的 PR 不再当场发现“Core 改动破坏 SDK”。改插件后端协议(`rust/lithe-core/src/lsp/interface/plugin_function.rs`、`litheBuild` 相关代码)时,合并前要在 lithe-plugin-docs 用 `--source <本地主仓库> --ref <分支>` 手动跑一次。 diff --git a/shared/contracts/plugins/v1/README.md b/shared/contracts/plugins/v1/README.md index e2932df62..981f4c0e9 100644 --- a/shared/contracts/plugins/v1/README.md +++ b/shared/contracts/plugins/v1/README.md @@ -1162,7 +1162,7 @@ direct child, and the host's process scope ends the rest. Each SDK has a sample plugin (`sdk/*/examples/sample-build`), and both samples answer one end-to-end scenario driven through a real Core session. That repository runs it against the Lithe commit its contract copy pins, and weekly -against Lithe's latest default branch. +against the latest `preview`, the branch Lithe changes merge into. Neither SDK adds a wire field: a backend written without one is equally valid.