diff --git a/README.md b/README.md index c18003c..5d61d2e 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,7 @@ SDK 的源码都在本仓库的 `sdk/` 下,发布在[本仓库的 Releases](ht | [原生构建工作流(开发中)](guides/native-build-workflows.md) | Node/Java SDK 的构建执行、精确测试、运行准备,以及未提交源码的真实 Core 验证 | | [构建、运行与测试接入](guides/build-tool-integration.md) | 可用的执行提供者示例、工程后端边界,以及开发中的 API 3 状态 | | [编写调试适配器插件](guides/debug-adapter-plugin.md) | API 4:从最小清单到启动方式、先编译再调试的 `executable`、调试控制台补全,以及预发布与转正 | +| [插件后端:工作区上下文与宿主服务(开发中)](guides/plugin-backend-workspace.md) | `backend` 2 与协议 3:免页面启动、工作区上下文、宿主通知、失败码,以及协议 3 只增不改时后端要遵守的规则 | 收录和上架的规则由 Lithe Store 维护:[发布社区插件](https://github.com/Lithe-IDEA/Lithe-store/blob/main/docs/publishing.md)讲怎么打包、提交收录和发布新版本,[版本追踪与更新](https://github.com/Lithe-IDEA/Lithe-store/blob/main/docs/versioning-and-updates.md)讲 Lithe 怎么选择可安装的版本。 diff --git a/contract.lock.json b/contract.lock.json index 5e9e96c..10c669e 100644 --- a/contract.lock.json +++ b/contract.lock.json @@ -3,12 +3,16 @@ "source": { "repository": "https://github.com/1lck/Lithe-IDEA", "directory": "shared/contracts/plugins/v1", - "revision": "32021a86f0c6c4ad2964dbbe3c2bb7854d84ad67" + "revision": "f20601ee6194f32d597031556ea628de586ac2e2" }, "files": [ { "path": "README.md", - "sha256": "75cf6add5c473eb333caca793dc9add6394476c7f4c5196b2bf5f1aea7873d59" + "sha256": "1ac89012a6e620ddeec63b0fc82460c56f05623a1d2a29dcf45e87fa123a4806" + }, + { + "path": "fixtures/backend-workspace.plugin.json", + "sha256": "6eef7a0735e4cd0c66b32a1075ad8f689554e26f929d3a1f436249026e06037b" }, { "path": "fixtures/backend.plugin.json", @@ -22,26 +26,70 @@ "path": "fixtures/build-command-stdout.plugin.json", "sha256": "cb24aedb59cdcec6b358dbeb95213143a4fc229aea8973ddd3f154f09335b9dc" }, + { + "path": "fixtures/build-debug-preparation.json", + "sha256": "73d3362fac7302c5698b440536821784350fbf6eb267b94d144994d403f0efed" + }, + { + "path": "fixtures/build-execution-protocol.json", + "sha256": "556779c92b7b43970ff882cce6fd278dbe0d16476403778a9240d61c4a15b3da" + }, { "path": "fixtures/build-host-integration.plugin.json", "sha256": "36361a98a86de1c6c9c4f0694accab3adadefd53f344b4ac3a85b8906d749b13" }, + { + "path": "fixtures/build-model-consumer.plugin.json", + "sha256": "c41ac0f6fa6600f5bc0ee29d7d89f8257d9f9fec9add8d4c5ac448ad753177f5" + }, + { + "path": "fixtures/build-model-producer.plugin.json", + "sha256": "2bed5e883cd21d37599cdaf69f2663b903e1149b58a7f33f7c01658ec081a455" + }, { "path": "fixtures/build-plans.json", "sha256": "b1ebdab5d1e7d0b6f5fc948335581c41814dac83937b8452722ad5da6836d6ab" }, + { + "path": "fixtures/build-provider-debug-preparation.plugin.json", + "sha256": "07db3ced65ab5f73318de59c8857b48f7b68d731bae928bc2954e3e47f585170" + }, + { + "path": "fixtures/build-provider-execution.plugin.json", + "sha256": "6978c19d7f4353c5dadc833ac723dad76c001aae2c92286d4dd4a9ff2bd67101" + }, { "path": "fixtures/build-provider-language-server.plugin.json", "sha256": "fc6fae12c07715ad5017484cf9a17905d51ffa7ee40ccf4200613b720644f2ce" }, + { + "path": "fixtures/build-provider-run-preparation.plugin.json", + "sha256": "14fca4d8fba7a2bb66f3d6fb7ae81c745bc323d44d9958d8fdfc5c1adbcc7ac1" + }, + { + "path": "fixtures/build-provider-tests.plugin.json", + "sha256": "071cef77007fbde34e1ff971c10bbe693e9275f10de1c070801baf880c4e19f8" + }, { "path": "fixtures/build-provider.plugin.json", "sha256": "bbb19c3d201be64af5262be549fe92822c14e77efae616e64c4f32ddd56b2758" }, + { + "path": "fixtures/build-run-preparation.json", + "sha256": "3609997b65e0322ae16756f1af376c041c55aa1a20187cad0c5a9bf1444a10cd" + }, { "path": "fixtures/build-static-file.plugin.json", "sha256": "0380748f151cf2b154133dac5447c8665801f16bf005314ab7ac1d4d11475c92" }, + { + "path": "fixtures/build-test-discovery.json", + "sha256": "62384b0d2c31cd2fed877a421dd90eb1fff1e7d991e1622567004bc2eace5dd6" + }, + { + "path": "fixtures/build-test-protocol.json", + "sha256": "a67b9b6448aff53792cb791b0fa6994fcb0e94729a5c5a85a524398ebe6e58fd" + }, { "path": "fixtures/build-tool-path.plugin.json", "sha256": "685144e417bbf01b013144dec109d51b56922f98811e9ea6eda34da26749165c" @@ -50,9 +98,17 @@ "path": "fixtures/command-plans.json", "sha256": "a188fd65f6cdb47f9e9ad4b0de3a7ddcd17e4e577b34f6e25b96d2c1f2bdca7c" }, + { + "path": "fixtures/debug-adapter-language-server.plugin.json", + "sha256": "c29d1e94a69840a63317e5d4b15a0a9cd8267a980fa2b21c37528bc08bb3ee96" + }, { "path": "fixtures/debug-adapter-rejections.json", - "sha256": "0aaa586c19884025eb9bee36dc213fbebacc4d0503c3f5ed10a196c922cfaeaf" + "sha256": "ec6023c48236ec3f59f8c8c7bfd4cefea0180f14e11fd4823fb2e48ac36567a6" + }, + { + "path": "fixtures/debug-adapter-remote-attach.plugin.json", + "sha256": "66b4e0326f3b99027865869742ec98afd7da0df6133bc0a23115749edde13e3b" }, { "path": "fixtures/debug-adapter.plugin.json", @@ -60,7 +116,7 @@ }, { "path": "fixtures/function-protocol.json", - "sha256": "980e8d39db2af9d7f922cdc01b0ec06e2cb8c33da2142ec8040befa975b7eae5" + "sha256": "d8f11ab3287d24210e9369ec6b5fcdb65316d6afe82c60583455c84065ae583b" }, { "path": "fixtures/hello-lithe.plugin.json", @@ -68,7 +124,7 @@ }, { "path": "fixtures/invalid-manifests.json", - "sha256": "107afd7d3f440a87aab3611a80aa5e92c067cd719019dafd94bda8cdbd6b842f" + "sha256": "3bbd1ed851b916d1e006d0403bb9eb2d5cd122e65070da91890aa6b7154b0454" }, { "path": "fixtures/language-host-integration.plugin.json", @@ -142,6 +198,10 @@ "path": "fixtures/project-plans.json", "sha256": "14c6fa8b073732f93b85b30e4fa9e8384ce094f2fb6fe833e41476f12c8d2132" }, + { + "path": "fixtures/publisher-bundled.plugin.json", + "sha256": "195c3f7c9712f44711602c059e32af88b3e7b7285b600cffd38f116e5b1150eb" + }, { "path": "fixtures/runtime-versions.json", "sha256": "a19c1c057ab54a2b24a4a0e5a0d7764f0b9063bb6dd356baf19f5f6e37a76e19" @@ -168,7 +228,7 @@ }, { "path": "plugin-manifest.schema.json", - "sha256": "f35d510bf76feeff2978289bebbeba19453cd60b1e101b7677c6106934a27f95" + "sha256": "789a340f03a598f9e1505ff887a90ee5a0b8a004071ef331d6382a97ea1b9f11" } ] } diff --git a/contract/v1/README.md b/contract/v1/README.md index 35d2fd5..cd3d02e 100644 --- a/contract/v1/README.md +++ b/contract/v1/README.md @@ -219,7 +219,7 @@ process does not use `${workspaceStateDir}`; otherwise the request is | `settings` | Effective value of every declared setting that produced this plan; absent when the plugin declares none. | | `buildProvider` | Present only for a `buildSystemID` selection: `{buildSystemID, executesProjectCode, mayUseNetwork, languageServer?}`. `languageServer` names the language whose server is the provider; the rest of the plan is then that server's plan, and the host reuses its running session instead of starting a second process. | | `vcsProvider` | Present only for a `vcsProviderID` selection: the [provider descriptor](https://github.com/1lck/Lithe-IDEA/blob/preview/shared/contracts/vcs-provider-v1.md#provider-descriptor) the host keeps for the session. | -| `backend` | Present only for a `backend` selection: `{hostServices}`, the sorted [host services](#host-services-protocol-version-2) the plugin's permissions grant. The host passes them to `lsp.startServer.hostServices` unchanged. | +| `backend` | Present only for a `backend` selection: `{hostServices}`, the sorted [host services](#host-services-protocol-version-2) the plugin's permissions grant, plus `version: 2` for a [`backend` 2](#backend-2-workspace-contexts) process. The host passes `hostServices` to `lsp.startServer.hostServices` unchanged. | A language without a server, an undeclared build system, a build system without a provider, an undeclared VCS provider, and a plugin without a @@ -400,17 +400,19 @@ hosts. Level 3 versions each section on its own. A level 3 manifest lists in | Capability | Covers | Versions | | --- | --- | --- | | `languages` | `languages[]` | `1` | +| `packageTrust` | `vendor.bundledSignatureRequirement`: origin-scoped trust for a host-sealed bundled copy of a publisher-signed plugin | `1` | | `languageServers` | `languageServers[]` apart from their launch fields | `1`, `2` (adds [`workspaceConfiguration`](#workspace-configuration-languageservers-2)) | | `serverProcess` | The launch fields of language servers, of build providers with a process of their own, of VCS providers, and of the plugin backend: `launch`, `arguments`, `validationArguments`, `runtimeDependencies`, `initializationOptions` | `1` | | `executionProviders` | `executionProviders[]` | `1` | | `settings` | `settings[]` and `settingActions[]` | `1`, `2` (adds [placement, scope, effect, page buttons](#settings-v2-placement-scope-effect-and-page-buttons)) | | `ui` | `ui` | `1`, `2` (adds [web content](#web-content-ui-2)) | | `buildSystems` | Declarative build detection, models, and command plans | `1` | -| `buildProviders` | `buildSystems[].provider`, including own-process and shared language-server providers | `1` | +| `buildProviders` | `buildSystems[].provider`, including own-process and shared language-server providers; v2 tasks, v3 tests, v4 run, v5 native debug preparation, v6 read-only model inputs | `1`, `2`, `3`, `4`, `5`, `6` | +| `buildModelInputs` | Optional public model sharing between enabled workspace plugins | `1` | | `vcsProviders` | `vcsProviders[]` | `1` | -| `debugAdapters` | [`debugAdapters[]`](#api-level-4-debug-adapters) | `1` | +| `debugAdapters` | [`debugAdapters[]`](#api-level-4-debug-adapters); v2 adds target arguments/environment | `1`, `2` | | `projectGenerators` | [`projectGenerators[]`](#project-generators): entries of the New Project window | `1` | -| `backend` | [`backend`](#plugin-backend): the plugin's own long-lived process for its pages | `1` | +| `backend` | [`backend`](#plugin-backend): the plugin's own long-lived process | `1`, `2` (adds [start without a page and workspace contexts](#backend-2-workspace-contexts)) | Version 1 of every capability means what its section means at level 2 (runtime launches, launch templates, `initializationOptions`, and so on), so @@ -454,17 +456,13 @@ in `plugins.declarative.validate`, `plugins.marketplace.validateIndex`, and therefore offered on a host that cannot run them. - `hostCapabilities` has no effect on levels 1 and 2. -The macOS host runs levels 1 to 3 and sends its capability table, including -`buildSystems: [1]`, with all three commands. It does not implement build -providers and does not declare `buildProviders`. Provider plugins are therefore -filtered from the market and refused by declarative validation and install -review; template-only build plugins remain available. It does not declare -`vcsProviders` either, so VCS provider plugins are filtered the same way until -the macOS host starts providers. It declares `backend: [1]`: it starts -plugin backends and serves their credentials from the Keychain. Core supports `backend: 1`, `buildProviders: 1`, and -`vcsProviders: 1` for validation and plans without a host table. Windows has -no declarative plugin host and sends no `hostCapabilities`, so it offers no -level 3 version. +The macOS host sends its capability table with validation, marketplace filtering +and install review. It advertises `buildSystems: [1, 2]` and +`buildProviders: [1, 2, 3, 4, 5, 6]`; native Provider models, dependencies, tasks, tests and launch preparation +share workspace-owned sessions. Backend and VCS support use their own entries +in the same table. Core validates supported versions without a host table, +but admission must use the actual host table. Windows does not yet advertise +Build Provider orchestration; Core protocol support alone does not enable it. Rules for publishing across levels and capability versions: @@ -497,12 +495,13 @@ Rules for publishing across levels and capability versions: | `process.build` | `buildSystems` is not empty | | `process.vcs` | `vcsProviders` is not empty | | `debug.launch` | `debugAdapters` is not empty | -| `debug.attach` | an adapter lists `attach` in `requests` (and only then) | +| `debug.attach` | an adapter lists `attach` in `requests` (and only then); it also covers attaching to a remote host and port | | `project.generate` | `projectGenerators` is not empty | | `ui.contribute` | `ui` is declared | | `ui.web` | a tool window shows [web content](#web-content-ui-2) | | `backend.launch` | `backend` is declared | | `credentials.store` | the backend uses the [credentials host service](#host-services-protocol-version-2); refused without a `backend` | +| `workspace.metadata` | the backend uses the [workspace host service](#workspace-contexts-and-capability-discovery-protocol-version-3); refused without `backend` 2 | | Activation event | Meaning | | --- | --- | @@ -512,8 +511,12 @@ Rules for publishing across levels and capability versions: | `onBuildSystem:` | Activate the build system before its first plan, model, or decode. | | `onVcsRoot:` | Start the VCS provider once a root of its VCS is detected in the workspace. | | `onDebug:` | Start the debug adapter when a debug session needs it (API level 4). | +| `onWorkspaceOpen` | Start the [`backend` 2](#backend-2-workspace-contexts) process once a workspace is open. Optional, names no ID, refused without `backend` 2. | -Each provider needs its event and each event needs a provider. Enabling a +Each provider needs its event and each event needs a provider; the only +optional event is `onWorkspaceOpen`. There is no event that starts a plugin +on every launch (`onStartupFinished`, `*`): it is +`plugin.activation.invalid`. Enabling a plugin only permits activation; nothing starts until its event occurs. ## Trust @@ -534,6 +537,20 @@ plugin only permits activation; nothing starts until its event occurs. A signature document, when present, must still be verified; community trust cannot silently ignore an invalid signature. +API level 3 packages can declare `requires.packageTrust: 1` and +`vendor.bundledSignatureRequirement: "sameTeamAsHost"`, together with +`vendor.signatureRequirement: "publisherPackage"`. Only official-namespace IDs +may use this declaration. Both the primary and bundled policies must match the +host's pinned official catalog. Only a package whose actual resolved path is +inside the host's sealed installation and whose origin is `bundled` may omit +the publisher signature. Every independently installed, imported or rolled-back +copy still needs the publisher signature. A present signature is always checked, +even inside the host seal. The optional declaration alone grants no trust. +Hosts without `packageTrust: 1` filter this version before download and reject +its manifest; no new application-version field is needed. An older manifest +without the declaration keeps its existing policy. The valid example is +`fixtures/publisher-bundled.plugin.json`. + A host pins the signature requirement of its official plugin IDs so a package cannot downgrade itself. Selecting a file from disk never overrides that pin, namespace restrictions, publisher-key checks, or the installation seal required @@ -886,6 +903,316 @@ Canonical formats let a plugin's own script print the shape Core expects: Unknown fields in canonical documents are ignored so newer scripts work with older hosts. Output that does not fit its decoder is `plugin.build.invalidOutput`. +### Provider task execution (buildProviders 2) + +A task with `source: "provider"` requires `requires.buildProviders: 2` or newer. +It keeps the ordinary task ID, scopes, target tags and model selection, but +`arguments` must be `[]` and `execution` must be `task`. Application/service +launch and test discovery are separate capabilities. Provider model-task +templates cannot be referenced by `run[].fromTasks`; they need a typed Run +capability rather than an empty command plan. A provider task never +falls back to a host command or a plugin-private function. Command tasks +retain their current shape and omit `source`. + +`build.taskPlan` returns a `provider` request instead of argv for this source. +`lithe/build/execute` receives the effective `buildSystemID`, workspace-relative +`root`, `options`, `optionArguments`, option `environment`, `task` declaration ID, +bounded `userArguments`, and optional `project`, `target`, `modelTask` selections +(`name`, workspace-relative `path`, `tags`, `properties`). Scope/path validation +is identical to command tasks. The provider owns build semantics; options +are not a second host-generated command. As with model/dependency requests, +the host may add `tool: { executable: }` from the +same invocation resolver; explicit invalid tool/runtime selection is a failure, +never permission for independent discovery. It must reject stale project facts +rather than guess a target. + +The host offers `litheBuild.versions: [6, 5, 4, 3, 2, 1]`. Version 2 adds task execution +and its progress notification to the two version 1 methods. A process that +chooses version 1 cannot receive execution; unsupported negotiation fails +explicitly. A plugin that needs this behavior declares capability version 2 +so unsupported hosts refuse installation. The two version numbers serve +different purposes: installation admission and per-process negotiation. + +Core sets `params.operationID` to the unique JSON-RPC request ID (as a string); +callers cannot choose it. `lithe/build/progress` notifications echo that ID and +may include `message` (up to 4 KiB UTF-8), integer `percentage` (0–100), and +`output` (up to 64 KiB UTF-8 per notification). Malformed reports and reports +for ended, cancelled, timed-out or other requests are dropped. Core maps the +wire ID back to the host operation ID; reused host IDs cannot accept old +progress. No plugin-function negotiation is needed. + +The reply is `{ "exitCode": , "output": }`. +`output` is the complete bounded diagnostic text (at most 32 MiB UTF-8), +including text streamed in progress; the host displays progress output and +uses final output for the existing diagnostic decoder without duplicating it +in the console. Nonzero exit codes are completed tool failures, not protocol +errors. Malformed replies end with `plugin.build.invalidResult`; JSON-RPC +errors end with `plugin.build.failed`. Timeout and cancellation use the +existing request codes and `$/cancelRequest`. Request completion occurs once; +late replies/progress do not revive it. + +The host binds each call to the plugin package, build-system/workspace +session, current run, effective option fingerprint and watched configuration revision. +Settings, package replacement, disable, workspace close/reopen, cancellation +and backend failure invalidate or stop the owning operation. The existing +Provider session owns children and writable workspace state; no page, +additional runtime, directory or cross-plugin private cache is required. +Only public typed results may cross domain boundaries. + +Canonical examples: `fixtures/build-provider-execution.plugin.json` and +`fixtures/build-execution-protocol.json`. Protocol v2 is a construction +primitive for #1495; it does not by itself migrate Maven or Java consumers. + +### Provider test execution (buildProviders 3) + +A build system's `test` may declare `source: "provider"` with +`requires.buildProviders: 3`. It retains the existing scopes, target tags and +supported filters, but `arguments` must be `[]`. It requires a plugin-owned +Provider process; `results` may be omitted or use `junitXml`/`litheTestResults` +reports. `hostIntegration` is refused: the plugin owns test execution and +report production, and the host reuses the existing report decoder. + +`build.testPlan` returns `provider: { method: "lithe/build/test", params }` +and the usual `results` metadata instead of argv. The effective +`buildSystemID`, `root`, `options`, `optionArguments`, `environment`, bounded +`userArguments`, optional `project`/`target`, and optional `suite`/`case` are +typed fields. A case filter does not require a suite when the provider +supports it; providers must reject selectors they cannot honor rather than +widening the test run. Paths, scopes and declared filters are validated before +the request is sent. The host's report project list only resolves report globs; +it is not a second backend project model. + +Only a process choosing protocol version 3 or 4 receives `lithe/build/test`. +Versions 1 and 2 explicitly refuse tests. Version 3 retains every version 2 +task behavior. Tests use the same Core-assigned operation identity, +`lithe/build/progress`, tool hint, bounded `{ exitCode, output }` result, +timeout, cancellation and late-result rules as task execution. Reports are +read after execution using the existing freshness, path and size checks. +Workspace-wide reports still require a current project model when the build +system has one. Configuration validity is retained through diagnostic and +report decoding; late results cannot update a changed workspace/configuration. + +Java/Maven migration remains separate. No new resource/cache location is introduced. +Canonical examples: `fixtures/build-provider-tests.plugin.json` and +`fixtures/build-test-protocol.json`. + +#### Test discovery + +Provider tests may additionally declare `discovery: true`. This requires both +`suite` and `case` filters: every discovered selector must be exactly runnable. +`build.testDiscoveryPlan` accepts the common plan fields and optional `refresh` +and `tool`, returning a request for the whole instance: +`lithe/build/discoverTests` with its effective options. Project/output-file +selection is refused. This method also requires protocol version 3 or 4 and uses +the existing bounded request/cancellation lifecycle, without execution progress. + +The provider returns `{ "format": "litheTestDiscovery", "version": 1, +"tests": [{ "suite", "case", "project"?, "target"? }] }`. Project and target +are the usual typed selections with workspace-relative paths. There may be at +most 10,000 items and 32 MiB UTF-8 JSON. Unknown document/item fields are +ignored; malformed selectors, invalid paths/scopes, unsupported versions and +oversized lists reject the entire result with `plugin.build.invalidOutput`. +An empty array is a successful discovery with no tests. + +The host passes the reply and original plan configuration to +`build.decodeTestDiscovery`. Core validates every selector through the same +test-plan rules, deterministically orders the list, collapses exact duplicates, +and derives an opaque `id` including project/target identity. Equal suite/case +names in sibling modules therefore remain distinct. The returned `discovery` +contains `tests: [{ id, suite, case, project?, target? }]`. No source parsing, +framework detection, or second project graph occurs in the host/Core. + +Native discovery starts only on a user action, reuses the workspace Provider, +and is independent of plugin pages. Source edits, configuration changes, +package changes and workspace teardown revoke the cached list and in-flight +discovery. A native action must revalidate the selected ID against the current +list before submitting its exact selectors to `lithe/build/test`; stale +toolbar selections cannot run. The backend must reject a selector whose facts +have become stale externally. Canonical example: `fixtures/build-test-discovery.json`. + +### Provider run preparation (buildProviders 4) + +A run declaration may use `source: "provider"` with +`requires.buildProviders: 4`. This increment supports application targets only: +`fromTargets.tags` is required; `fromTasks`, service execution, nonempty +`arguments`, and extension templates are refused. The optional `name` is a +literal display name. `workingDirectory` is supplied by the prepared result; +the declaration's command-source default has no effect. These declarations +never generate empty command-based persisted Run entries. + +`build.prepareRunPlan` validates a selected `run` declaration ID, required +`project` and `target` selections, effective options, and bounded +`userArguments`. It returns a provider request for `lithe/build/prepareRun`, +with the common instance fields, options/option arguments/environment, +`run`, `project`, `target`, and `userArguments`. An output file is refused. +The host may add the resolved build tool hint. The provider owns upstream +synchronization, build, classpath and launch facts; Core does not reconstruct +those facts from source files or build configuration. + +Protocol versions 4 and 5 accept this method. Version 4 retains task and test +support. Preparation shares the Core-assigned wire `operationID`, progress, +timeout, cancellation and stale-result handling of execution. It ends when +preparation finishes; the application is a separate host-owned process. +A JSON-RPC error aborts preparation and cannot launch an application. It retains +the common LSP `serverError` category; it is not an execution exit-code result +and does not forward a private-function `pluginErrorCode`. + +The successful result is a portable launch plan: + +```json +{ + "executable": { "kind": "toolchain", "value": "project-jdk" }, + "arguments": ["-cp", "classes", "example.Main"], + "workingDirectory": "app", + "environment": {} +} +``` + +The executable is either a host toolchain identifier (`toolchain`) or a bare +command resolved by the host (`command`); absolute paths and shell command +strings are refused. Arguments are separate strings, at most 1,024 entries +and 64 KiB UTF-8 each, without NUL. The environment has at most 64 entries; +keys match `[A-Za-z_][A-Za-z0-9_]*` with at most 128 bytes, values are strings +of at most 64 KiB without NUL. The complete JSON is bounded at 32 MiB. +`workingDirectory` must be an existing workspace-relative directory inside +the selected instance, including after resolving filesystem aliases. +Unknown fields are ignored. The host calls `build.decodeRunPreparation` +with its workspace root, instance root and raw result; any malformed plan +fails as `plugin.build.invalidOutput` before executable resolution. + +#### Native debug preparation (version 5) + +A provider run may additionally declare `debug: {"adapter":"native"}` with +`requires.buildProviders: 5` and `requires.debugAdapters: 2`. `adapter` is a +local ID declared by this plugin; qualified cross-package references are refused. +The adapter must serve launch of executable targets, with bindings `program` ← +`target.path`, `cwd` ← `target.workingDirectory`, `args` ← `target.arguments`, +and `env` ← `target.environment`. These fields cannot be overridden by adapter +default arguments. This first increment covers native executable launch only; +it does not accept attach endpoints, backend-selected adapters or arbitrary DAP JSON. + +`build.prepareDebugPlan` validates the same selected run/project/target and +arguments as run preparation and returns `lithe/build/prepareDebug`. Only wire +version 5 accepts it. Progress, cancellation, timeouts, tool hints, workspace +ownership and JSON-RPC failure handling follow run preparation. A successful result +contains exactly these four fields: + +```json +{"program":"app/bin/sample","arguments":["a b","x#y"],"workingDirectory":"app","environment":{}} +``` + +The provider owns synchronization/build before returning. `program` is an existing +workspace-relative regular file contained in the selected instance, including +canonical symlink resolution. Arguments, environment and directory retain run +preparation's size and containment limits. Unknown fields are refused here; there +is no adapter process, socket, shell, private function or DAP argument channel. +`build.decodeDebugPreparation` takes the selected `run` separately and returns +`preparation` with its validated `root`, declaration-owned `adapter`, and these +four fields. Missing, malformed and escaping targets fail before adapter startup. + +The native host saves documents before preparation, retains model/configuration/ +source/plugin identity through decode, and drops stale results. It checks executable +permissions and canonical containment again at handoff. `debug.adapters.startArguments` +binds the validated target facts; the existing Registry launches the adapter using +its own permissions and package process declaration. The launch configuration retains +`pluginID/adapterID` for routing and retry. Stopping preparation cancels only its +operation; the Debug host owns launched sessions and plugin withdrawal cleanup. +Saved Run configurations keep their existing selectors and prepare again for Debug. + +Fixtures: [native declaration](fixtures/build-provider-debug-preparation.plugin.json) +and [native result](fixtures/build-debug-preparation.json). This does not provide +Java/JDT preparation, long-running services or cross-plugin collaboration. + + +The native Build tree re-resolves model IDs before preparation. Source/model, +option, settings, package and workspace changes revoke a pending preparation. +After validation the existing Run host resolves the executable and owns output, +stop, exit status, and argument-resource cleanup. Workspace teardown and plugin +disable stop the application by its exact host operation ID; an old owner +cannot stop a replacement process. The Run host can explicitly save a selector to the existing local Run v2 layer +as `provider: "build.provider"` with `extensions.build`. The reference contains +`pluginID`, `buildSystemID`, instance `root`, `run`, `projectID`, `targetID`, and +raw `userArguments`, never an executable, environment, or cached classpath. +Every restart re-resolves the enabled instance/model and sends a fresh preparation. +Missing plugins/targets/models fail explicitly; there is no command fallback. +Saving and running work before detector-generated Run configurations exist. +Service coordination, debug target registration and Java/Maven migration remain +separate. See `fixtures/build-provider-run-preparation.plugin.json` +and `fixtures/build-run-preparation.json`. + +### Optional read-only model inputs (buildModelInputs 1) + +A producer opts into `buildSystems[].modelSharing: { options: [] }`. +Only normalized `projects`, `targets` and `contentRoots`, including their +ordinary public metadata, are published. Tasks, run entries, settings, +`optionValues`, process handles and private functions are excluded. The +options list names existing ordinary build options whose **effective values** +may be shared; omitted options stay private. A configuration fingerprint +identifies all effective options without publishing their values. + +A consumer declares `modelInputs: [{ pluginID, buildSystemID }]`, requires +`buildModelInputs: 1` and `buildProviders: 6`, and owns a provider and model. +Slots are exact optional identities; Core sorts them by plugin ID and build +system ID and rejects duplicates. Version 1 producers cannot also consume +inputs, so consumers cannot recursively re-export a model. + +The native host calls `build.resolveModelInputs` with the consumer manifest +and `{revision, sources: [{manifest, instances: [{buildSystemID, root, state, +model?, options?}]}]}`. Sources are enabled installed packages in the current +workspace activation, never identities supplied by another backend. +Input states are `ready`, `loading`, `stale`, or `failed`; only ready +instances include a complete normalized model. A truncated model becomes stale. +Core revalidates source manifests, ownership, shape and path containment and +returns `modelInputs: {version: 1, revision, inputs}`, where each input is: + +```json +{ + "pluginID": "dev.example.source", + "buildSystemID": "source", + "state": "available", + "instances": [{ + "root": "app", + "state": "ready", + "model": {"projects": [], "targets": [], "contentRoots": []}, + "configurationFingerprint": "<64 lowercase hex characters>", + "options": {} + }] +} +``` + +A missing, disabled, non-public or mismatched producer gives +`state: "unavailable", instances: []`. An installed public producer with no +detected instance is available with an empty list. A non-ready instance has +only `root` and `state`; no old model or options travel with it. Roots and +model paths remain workspace-relative, and instances are deterministically +ordered. There are at most 8 slots, 64 instances per source, 2,000 projects and +10,000 targets/content roots per instance, and 8 MiB of resolved JSON. The +revision is a nonnegative integer no larger than 9,007,199,254,740,991. + +The host supplies this snapshot as the common plan request's optional +`modelInputs`. It is required exactly for a consumer with declared slots. +Every Provider plan copies it unchanged into `params.modelInputs`; Core +revalidates the snapshot and rejects extra fields or mismatched slots. +The running backend must negotiate `litheBuild` version 6 independently of +manifest admission. Older versions reject an input-bearing request even for +an existing method such as `lithe/build/model`. + +A revision belongs to one workspace activation. The host revokes consumer +model/dependency loads, discovery and in-flight execution/preparation when a +producer's model, options, watched configuration or package availability +changes. Revisions also detect changing an option away and back before a reply +arrives. Guard the result after decoding as well as after the RPC; do not +publish an old reply merely because cancellation was ignored. Already launched +applications retain their existing native ownership. + +This facility never installs or activates a producer, invokes its private +functions, grants permissions, leases a process or resolves a package version. +There is no general plugin-dependency/export API. Backend authors must handle +unavailable and non-ready slots explicitly. Language integration and existing +Java/Maven business migration remain separate work. Shared fixtures: +`fixtures/build-model-producer.plugin.json` and +`fixtures/build-model-consumer.plugin.json`. + ### Build providers Some tools expose their model only through their own API (the Gradle Tooling @@ -936,11 +1263,14 @@ another format is `plugin.build.invalidOutput`. The manifest only says how to start the provider and which sources use it. Everything else is negotiated in the LSP `initialize` exchange, so a provider -can grow without a new manifest field or capability version: +can add optional data within a negotiated protocol version: -- The host sends `capabilities.experimental.litheBuild: { "versions": [1] }`, +- The host sends `capabilities.experimental.litheBuild: { "versions": [6, 5, 4, 3, 2, 1] }`, the build-provider protocol versions it speaks. Version 1 is the two - requests above with the params and result formats of this section. + requests above with the params and result formats of this section; + version 2 adds typed task execution; version 3 adds typed test execution + and discovery; version 4 adds application run preparation; version 5 adds native debug preparation; + version 6 accepts the host's explicitly declared read-only model inputs. - The provider answers `capabilities.experimental.litheBuild: { "version": 1 }` with the version it chose from that list. A provider that answers no version, or one the host did not offer, is not used for build requests; a @@ -1015,7 +1345,8 @@ defined in [the VCS provider contract](https://github.com/1lck/Lithe-IDEA/blob/p ## API level 4: debug adapters `debugAdapters[]` declares debug adapters the plugin implements. Each one is a -DAP process the host starts and talks to over stdio or a loopback TCP port. +DAP process the host starts and talks to over stdio or a loopback TCP port, or +a debug server hosted by the plugin's own language server. The declaration is data only: there is no resolver, workflow, host integration, or `initializationOptions`, and any such field is refused. Requires `requires.debugAdapters` 1, `debug.launch` (plus `debug.attach` when an @@ -1029,20 +1360,120 @@ A manifest below level 4 may not declare `debugAdapters`, and a level 3 manifest | `requests` | `launch` (default) and/or `attach`. | | `selectors[]` | Optional automatic matching by `targetKinds` and `languageIDs`; absent means explicit selection only. | | `configurationTypes` | Imported `launch.json` `type` values the adapter answers to. | -| `configurations.launch/attach` | Default DAP `arguments`, a packaged `parametersSchema`, and `targetBindings` (`program` ← `target.path`, `processId` ← `target.processID`, `cwd` ← `target.workingDirectory`). A configuration for an undeclared request is refused. | +| `configurations.launch/attach` | Default DAP `arguments`, a packaged `parametersSchema`, and `targetBindings` (`program` ← `target.path`, `processId` ← `target.processID`, `cwd` ← `target.workingDirectory`, and for `attach` only `connection` ← `target.host`, `target.port`, `target.pathMappings`; version 2 also supports bounded `args` ← `target.arguments` and `env` ← `target.environment`; see [Remote attach](#remote-attach)). A configuration for an undeclared request is refused. | | `configurations.launch.executable` | For a target that is a source file the adapter cannot debug directly (C, C++, Swift): `setting` names a `file` setting of the plugin, `candidates[]` (up to 8) are the paths a host looks for when the user chose nothing. Each candidate starts with `${workspaceFolder}/` or `${fileDirectory}/`, may use `${fileBasenameNoExtension}`, and has no `..`. Declare it together with `targetBindings.program: "target.executable"` and neither without the other; a launch fills `program` with the path the host settled on. The user's setting always wins: a chosen path that is not a runnable file is an error, not a reason to try the candidates. The host shows the path it found in that setting, and the setting's `description` is where the plugin teaches how to produce the program. | | `capabilities.disabled` | Standard DAP `supports*` names or `exceptionBreakpointFilters` the host treats as absent. | | `console.commandPrefix` | Text (1–8 characters, no spaces; for lldb-dap a backtick) the adapter's console reads as "what follows is a debugger command". The host puts it before the typed line only when it asks the adapter to complete, and moves the adapter's answer back, so the user never sees it. An adapter that treats a bare word as an expression (lldb-dap) otherwise mixes language keywords such as `long` into the first word's completions; the prefix limits them to commands. Declare at least this or `console.commands[]` when `console` is present. | | `console.commands[]` | Debugger commands (`name`, optional one-line `description`) the Debug Console offers as completions. An adapter that answers the DAP `completions` request completes live, so list commands only for an adapter that does not (or whose live answers lack some commands); the host merges both. Names are inserted as written, unique, and not padded with spaces; descriptions are shown as written and not translated. | -| `launch.process` | The shared `serverProcess` launch fields without `initializationOptions`, or `{"launch": {"kind": "runSelectedRuntime", "tool": "python" \| "java" \| "node"}}`, which needs `serverProcess` 2 and is the only use of it. | -| `launch.transport` | `stdio`, `tcpAnnounced` (`endpoint`: `stream`, one-line `prefix`, `value` = `port` or `hostPort`), or `tcpPort` (`endpoint.port`). TCP endpoints are loopback only. | +| `launch.process` | The shared `serverProcess` launch fields without `initializationOptions`, or `{"launch": {"kind": "runSelectedRuntime", "tool": "python" \| "java" \| "node"}}`, which needs `serverProcess` 2 and is the only use of it. Required for every transport except `tcpLanguageServer`, which must not have it. | +| `launch.transport` | `stdio`, `tcpAnnounced` (`endpoint`: `stream`, one-line `prefix`, `value` = `port` or `hostPort`), `tcpPort` (`endpoint.port`), or `tcpLanguageServer` (`endpoint`: `languageID`, `command`). TCP endpoints are loopback only. | +| `launch.endpoint` for `tcpLanguageServer` | For an adapter that lives inside a language server and has no process of its own (the Java Debug Server runs in JDT LS). `languageID` names a `languageServers[]` entry of the same plugin; `command` (1–128 characters of letters, digits, `.`, `_`, `:`, `-`) is the `workspace/executeCommand` the host sends to that server for each session. The server answers with the loopback port to connect to, as an integer or a numeric string from 1 to 65535. The host starts the language server for the workspace first, as it does for editing. The command is part of what the user reviews at install and update, and changing it asks again. | + +### Remote attach + +An adapter that attaches to a debuggee on another machine (a JVM started with +`-agentlib:jdwp`, a Node inspector, gdbserver) is an +ordinary `attach` adapter whose `targetBindings` carry a `connection`. The +adapter itself still runs on this machine, stdio or loopback as before; only +the debuggee is remote. This is the adapter-to-debuggee endpoint, not the +IDE-to-adapter DAP endpoint. By default, debugpy `--listen` starts a DAP adapter +on the target machine: the IDE must connect to that adapter, not start another +local adapter and merely pass it `connect.host` and `connect.port`. + +`connection` does not declare an external DAP endpoint or remote adapter +execution (for example `ssh host gdb -i=dap`). These require separate host +integration; this contract and its validation do not establish that support. +The current transport scope is ordinary TCP connection and lifecycle, reusing +the existing socket implementation and retaining stdio. It does not add proxy, +tunnel, port-forwarding or SSH execution settings, or relax the loopback rule +for language-server-provided DAP endpoints. Adapter-native source mapping should +be reused where applicable, without translating the same path pair again in Core. + +The following illustrates nested argument binding and mapping shape, not a +complete debugpy `--listen` connection configuration: + +```json +"configurations": { + "attach": { + "arguments": { "timeout": 5000 }, + "targetBindings": { + "connection": { + "host": "connect.host", + "port": "connect.port", + "pathMappings": { "shape": "objects", "argument": "pathMappings", + "localKey": "localRoot", "remoteKey": "remoteRoot" } + } + } + } +} +``` + +- The values of `connection` are paths into the adapter's `arguments`, because + adapters name and nest them differently: java-debug takes `hostName` and + `port`, nested arguments may use `connect.host` and `connect.port`, js-debug + `address` and `port`, gdb one `host:port` string. A path is 1–4 dot-separated names of + letters, digits, `_`, and `-` (up to 64 characters each) and Core creates the + intermediate objects. +- `host` and `port` are declared together. `address` takes the endpoint as one + `host:port` string, with an IPv6 host in brackets (`[2001:db8::1]:2345`); it + stands in for `host` and `port`, or is a further destination beside them. + At least one of the two forms is required. +- `pathMappings` is optional and has four shapes, one per way adapters take + source roots: + - `objects` fills a list of objects, one per mapping: `argument` names the + list, `localKey` and `remoteKey` the keys of each object + (`[{ "localRoot": …, "remoteRoot": … }]`). + - `first` is for an adapter with a single pair of roots and fills `local` + and `remote` from the one mapping. It takes exactly one: a target with + more is refused rather than truncated. + - `dictionary` fills an object at `argument` whose keys are the roots of one + side and whose values are the other (`"keyedBy": "remote"` gives + `{ "": "" }`). A target with the same root twice on the + keyed side is refused, since an object holds one value per key. + - `pairs` fills a list of two-element arrays at `argument`, in the order + `"order": "remoteLocal"` (`[["", ""]]`) or `"localRemote"`. + An adapter whose mapping is none of these declares no `pathMappings`, and a + start that sends mappings to it is refused. +- A destination may not equal, contain, or sit inside another destination, the + `processId` or `cwd` argument a declared binding of the same configuration + fills, or a name that the configuration's own `arguments` already sets. Defaults win over bindings, so without this rule + the adapter would connect somewhere other than the endpoint the user + confirmed. A manifest that does it is `plugin.manifest.invalid`. +- `connection` is allowed under `attach` only. `selectors[].targetKinds` may + list `connection`, and only for an adapter that declares the binding. + +The target of a start is `{ "kind": "connection", "host", "port", +"pathMappings"?: [{ "local", "remote" }] }`. `host` is a DNS name or an IP +literal of 1–253 characters, with labels of up to 63 letters, digits, and +hyphens, none leading or trailing: no scheme, port, brackets, whitespace, or +user. A dotted run of digits must be a valid IPv4 address, and IPv6 zone IDs and +internationalized names (use punycode) are not accepted. `port` is 1–65535. +There are at most 16 mappings; each root is 1–1024 characters without control +characters, and a `local` root must be an absolute path on the host's +platform. + +`debug.adapters.startArguments` answers with the filled `arguments` and a +`connection: { host, port, remote }`. `remote` is `false` only for what +Core can prove local from the text: `localhost` (any case), `127.0.0.0/8`, and +`::1`. Every other host, including `0.0.0.0`, `::ffff:127.0.0.1`, and +`localhost.example.org`, is `remote: true`. A host asks the user to confirm each +`remote` endpoint it has not been told to trust, and does not remember the +answer by default. `debug.attach` covers remote attach; there is no separate +permission. `plan.debugAdapter.remoteAttach` in the launch plan is +`{ "pathMappings": boolean, "maxPathMappings": number }` for an adapter that +takes a connection (`maxPathMappings` is 0 without mappings, 1 for `first`, and +16 otherwise) and `null` otherwise, so a host knows whether to offer the +host-and-port form and the mapping list, and how many rows the list may hold. The cases in `fixtures/debug-adapter-rejections.json` state the stable code and field for each refusal and the layer that reports it: `schema`, `coreManifest`, `corePlan` (one start: stale registration or runtime -selection, a target missing a fact a binding reads), and `coreInstall` +selection, a target missing a fact a binding reads; the request defaults to +`launch` and `context.request` sets `attach`), and `coreInstall` (a `parametersSchema` that is missing or links outside the package). -`fixtures/debug-adapter.plugin.json` is a valid example. +`fixtures/debug-adapter.plugin.json` is a valid example, and +`fixtures/debug-adapter-remote-attach.plugin.json` declares six adapters that +differ in how they take a connection. ## Plugin backend @@ -1082,6 +1513,38 @@ language, or workspace: - A plugin publishes one archive per platform, so its packaged path does not vary by `${arch}`; a macOS archive may carry a universal binary. +### Backend 2: workspace contexts + +`requires.backend: 2` keeps every rule of version 1 and changes when the +process starts and what it learns about workspaces. +`fixtures/backend-workspace.plugin.json` is the example. + +- It may start without a page. It needs at least one way to start: a web + tool window (first page call, as in version 1), a `ui.commands[]` entry + (its `onCommand:` also starts the backend), or the `onWorkspaceOpen` + event. A backend 2 with none of them is `plugin.manifest.invalid`. +- It is still one process per plugin, shared by every open workspace and + window, on the machine that runs Lithe, also for remote workspaces. No + workspace is process state: each call names its workspace explicitly + (a context, below), so `${workspaceStateDir}` stays refused. +- Starting on `onWorkspaceOpen` never delays opening the workspace. The host + starts such backends after the window is shown, one plugin at a time, and + a backend that does not finish `initialize` within its initialize timeout + is stopped and reported like any failed server start. A failed or + disabled backend never blocks the workspace. +- The `workspace.metadata` permission grants the + [`workspace` host service](#workspace-contexts-and-capability-discovery-protocol-version-3). + It reveals the open workspaces' names, root folders and IDs, their + authority, and the plugin's per-workspace state directory; it reads no + file content. +- Activation is gated only by the plugin being installed, enabled, and + reviewed. Lithe has no workspace trust state at the moment: an operation + that runs or changes something asks the user per operation, as the host's + own controls do. +- A host that offers only `backend: [1]` refuses the manifest with + `plugin.api.unsupported` (`requires.backend`), so an older host never + starts a process that expects contexts. + ## Project generators `projectGenerators` (requires `projectGenerators` 1 and the `project.generate` @@ -1273,7 +1736,7 @@ Two rules keep these directions from forming a loop: ### Negotiation For a session owned by a plugin (`lsp.startServer.pluginId`), the host offers -`capabilities.experimental.litheFunctions: { "versions": [1, 2] }` in +`capabilities.experimental.litheFunctions: { "versions": [1, 2, 3] }` in `initialize`, next to and independent of `litheBuild`. Host-owned sessions do not offer or accept this protocol, retain no plugin-function registry, and reject register/unregister with `-32601`. A plugin backend that offers @@ -1323,7 +1786,8 @@ backend that did not negotiate the protocol gets `-32601` for both. ### Invocation `lsp.functionRequest { sessionId, operationId?, function, params?, -timeoutMilliseconds? }` returns `{ operationId }` and sends: +timeoutMilliseconds?, context? }` (`context` from protocol version 3, see +[workspace contexts](#workspace-contexts-and-capability-discovery-protocol-version-3)) returns `{ operationId }` and sends: ```json { "method": "lithe/function/invoke", "params": { "functionID": "snapshot.refresh", "operationID": "…", "params": { … } } } @@ -1396,9 +1860,13 @@ granted services as `lsp.startServer.hostServices` (from the launch plan's the plugin and are deleted when it is uninstalled. - Core refuses, without involving the host, in this order: a session that negotiated version 1 or a method version 2 does not define (`-32601`, - `plugin.host.unavailable`), a service the plugin is not granted (`-32601`, - `plugin.host.permissionDenied`), and malformed params (`-32602`, - `plugin.host.invalidParams`). The code is in `error.data.code`. + `plugin.host.unavailable`), a service the plugin is not granted (`-32601` + in version 2, `-32010` from version 3, `plugin.host.permissionDenied`), + and malformed params (`-32602`, `plugin.host.invalidParams`; from + version 3 a field the method does not define is `-32018`, + `plugin.host.unsupportedOption`, see + [Extending version 3](#extending-version-3)). The code is in + `error.data.code`. - An accepted request becomes the runtime event `hostRequest` with `method` and `result: { hostRequestID, service, params }`. The host answers it with `lsp.hostResponse { sessionId, hostRequestID, result? | error: { message } }`; @@ -1407,8 +1875,201 @@ granted services as `lsp.startServer.hostServices` (from the launch plan's `hostRequestID`, including one already answered, is `invalid_request`. - A session holds at most 16 unanswered host requests; a further one is refused with `plugin.host.failed`. Unanswered requests end with the session. -- `hostServices` needs `pluginId` and names only services this Core defines; - otherwise `lsp.startServer` is `invalid_request`. +- `hostServices` needs `pluginId` and names only services this Core defines + (`credentials`, `workspace`); otherwise `lsp.startServer` is + `invalid_request`. + +### Workspace contexts and capability discovery (protocol version 3) + +Version 3 keeps every rule of version 2 and adds capability discovery, +subscription disposal, workspace contexts, a context on each call, typed host +failures with details, cancellation of host requests, and host +notifications. + +| Request | Params | Result | Granted by | +| --- | --- | --- | --- | +| `lithe/host/getCapabilities` | none or `{}` | `{ protocolVersion, backendAuthority, services, limits }` | every plugin-owned session | +| `lithe/host/subscriptions/dispose` | `{ subscriptionID }` | `null`, also for an unknown or already disposed ID | every plugin-owned session | +| `lithe/host/workspace/listContexts` | none or `{}` | `{ revision, contexts: [] }` | `workspace.metadata` | +| `lithe/host/workspace/getContext` | `{ context: { contextID, generation } }` | `` | `workspace.metadata` | +| `lithe/host/workspace/getFolders` | `{ context: { contextID, generation } }` | `{ folders: [] }` | `workspace.metadata` | + +- Core answers `getCapabilities` itself. `services` maps each service the + session may use (the implicit `host`, `subscriptions`, and `commands`, and + the granted ones) to `{ methods, notifications }`: the requests it may + send and the [host notifications](#host-notifications-protocol-version-3) + it may receive. It lists nothing the session may not use, and a host + grants only the services it has handlers for, so a listed method never + fails for want of one. `backendAuthority` is `"local"`: the backend runs on + the machine that runs Lithe. `limits` gives `pendingHostRequests`, + `functionResultBytes`, `errorMessageBytes` (the cut of a host failure's + message), `errorDetailsBytes`, and `notificationBytes`. A backend must + check `services` instead of assuming a method from the version. +- A context is one open workspace in one window: + + ```json + { + "contextID": "w-3f2a9c", + "generation": 2, + "name": "orders", + "authority": { "kind": "local" }, + "folders": [{ "rootID": "r0c1d2e3f4a5b6c7d", "name": "orders", "path": "/Users/me/orders" }], + "stateDirectory": "/Users/me/Library/Application Support/Lithe/PluginState//backend/workspaces/" + } + ``` + + `contextID` uses the key alphabet and lives as long as the window shows + that workspace: a window that switches to another workspace closes the + context and opens one with a new `contextID`, so state a backend keyed by + the ID never carries over to a different workspace. `generation` starts at + 1 and grows every time the same workspace's folders or authority change; a + request naming an older generation, or a closed context, is + `plugin.host.contextExpired`, and the backend asks again. `revision` of + `listContexts` grows whenever a context opens, closes, or changes. +- `rootID` identifies one root folder stably for the same location, across + restarts, without revealing the path. It is keyed per plugin with a host + secret, so two plugins see different IDs for the same folder and an ID + cannot be computed from a guessed path. `path` is present only for a + `local` authority. +- `authority.kind` is `local` or `remote` (a remote workspace, which also + has `authority.id`, an opaque connection ID). For a `remote` context the + folders carry no `path`: the local directory the host keeps for a remote + workspace is an empty placeholder, not the workspace. File, search, and + process services answer a request for a context whose authority they do + not implement with `plugin.host.authorityUnsupported`; they never read the + placeholder or run the operation locally instead. +- `stateDirectory` is private to the plugin and the workspace, outside the + plugin package and the application bundle. Only `getContext` returns it, + and creates it then; `listContexts` entries carry every other field and + create nothing, so listing many windows leaves no directories behind. + `${stateDir}` is the plugin-wide state. Both survive disabling and + updating the plugin and are deleted when it is uninstalled. +- A page call may name its context: `lsp.functionRequest.context` + (`{ contextID, generation }`) reaches a version 3 backend as + `params.context` of `lithe/function/invoke`. A malformed reference is + `plugin.function.invalidParams`; a backend that negotiated version 1 or 2 + receives the call without it. The host fills it from the page's window. +- A host failure may carry a code and details: + `lsp.hostResponse.error: { message, code?, details? }`. Core passes these + codes to a version 3 backend, each with its own JSON-RPC error code so a + backend can branch on the number alone; any other code, no code, or a + version 2 backend gets `plugin.host.failed`. `details` is an object of at + most 4 KiB serialized (for example `{ currentVersion }` with + `versionConflict`, or `{ retryAfterMilliseconds }` with `rateLimited`); + it reaches a version 3 backend as `error.data.details` and is dropped + otherwise. Details that are not an object, or larger, are dropped too; + the failure itself still reaches the backend. `fixtures/function-protocol.json` (`hostFailures`) + lists the mapping. + + | Code | JSON-RPC | Meaning | + | --- | --- | --- | + | `plugin.host.unavailable` | `-32601` | The host offers no such service or method | + | `plugin.host.invalidParams` | `-32602` | Params malformed or out of range | + | `plugin.host.failed` | `-32603` | Any other failure | + | `plugin.host.cancelled` | `-32800` | The request was cancelled | + | `plugin.host.permissionDenied` | `-32010` | Permission not declared, or the user withheld consent | + | `plugin.host.contextExpired` | `-32011` | The context is closed or its generation replaced | + | `plugin.host.authorityUnsupported` | `-32012` | The service does not support the workspace authority | + | `plugin.host.versionConflict` | `-32013` | A document or resource version differs from the expected one | + | `plugin.host.limitExceeded` | `-32014` | A host limit was exceeded | + | `plugin.host.timeout` | `-32015` | The host's deadline passed | + | `plugin.host.notFound` | `-32016` | The named resource does not exist | + | `plugin.host.readOnly` | `-32017` | The resource or workspace cannot be written | + | `plugin.host.unsupportedOption` | `-32018` | The host does not implement a requested option; `details.option` names the field | + | `plugin.host.preconditionFailed` | `-32019` | A state the request requires does not hold, such as a missing user confirmation | + | `plugin.host.busy` | `-32020` | The resource is in use; retry later | + | `plugin.host.rateLimited` | `-32021` | Too many requests in a period; retry after `details.retryAfterMilliseconds` when given | + + JSON-RPC reserves `-32768` to `-32000` for the protocol and lets + implementations define `-32099` to `-32000` as server errors. `-32000` + to `-32009` stay free for backends' own failures, which many JSON-RPC + libraries already use from `-32000`, so host codes start at `-32010` and + grow by one; a code is never reused for another meaning. Versions 1 and + 2 keep their numbers. +- A backend may cancel its own pending host request with `$/cancelRequest`. + Core answers it at once with `-32800` and `plugin.host.cancelled`, frees + the slot, and publishes `hostRequestCancelled { hostRequestID }` so the host + stops the work; a later `lsp.hostResponse` for that ID is + `invalid_request`. The 17th pending request of a version 3 session is + `plugin.host.limitExceeded`. +- A service that creates a subscription returns a `subscriptionID` of the key + alphabet; its notifications name it. Ending the session, disabling the + plugin, or closing the subscription's context disposes it. +- A backend still cannot ask the host to call a function or a domain + operation (`plugin.function.recursiveCall`), so a page call never waits on + itself through the host. + +### Host notifications (protocol version 3) + +The host tells a version 3 backend about changes with JSON-RPC +notifications; the backend answers none of them. The host sends them through +Core with `lsp.hostNotify { sessionId, method, params }`, which returns +`{ delivered }`: `false` when the session negotiated an older version, which +then never sees them. + +| Notification | Params | Granted by | +| --- | --- | --- | +| `lithe/host/workspace/didChangeContexts` | `{ revision }` | `workspace.metadata` | +| `lithe/host/subscriptions/notify` | `{ subscriptionID, sequence, event }` | every plugin-owned session | +| `lithe/host/commands/didRun` | `{ commandID, context? }` | every plugin-owned session | + +- `didChangeContexts` follows every change `revision` of `listContexts` + counts; the backend calls `listContexts` for the contexts. A backend that + starts later reads them itself. +- `subscriptions/notify` carries one `event` object of the named + subscription. `sequence` starts at 1 and grows by one per subscription, so + a backend can detect a gap; nothing follows a disposal. +- `commands/didRun` reaches the plugin's backend when the user runs one of + the plugin's commands. `commandID` is the command's ID; `context` + (`{ contextID, generation }`) is the window's context when it has one. A + running backend receives it in order with the other notifications; a + stopped one is started for it only when it declares + `onCommand:`, and receives it once ready. +- `revision` and `sequence` are positive integers, `event` is an object, + and serialized params are at most `limits.notificationBytes`. Core refuses + any other method, a service the session is not granted, malformed params, + a session that is not plugin-owned, or one that is not ready, with + `invalid_request`. `fixtures/function-protocol.json` + (`hostNotifications`) holds the cases. + +### Extending version 3 + +From version 3, host capabilities are added within the version, and a +backend discovers them with `getCapabilities` instead of a version number. A later host adds, without a +new version: + +- a service with its methods and notifications, granted by a new manifest + permission, or a method or notification of an existing service; +- an optional field of request params (an *option*) whose absence keeps the + earlier behavior, or a field of a result, a notification, `details`, or + `limits`; +- a failure code, which takes the next unused JSON-RPC number after + `-32021`; a number never changes meaning. + +A new protocol version is needed only for a change an existing backend +would misread: removing or renaming a method, notification, or field, +changing a field's meaning or type, making an option required, changing a +code's number, or weakening an ordering or lifecycle guarantee above. + +So that a version 3 backend works with every host of version 3: + +- It calls a method only when `getCapabilities.services` lists it; another + is `-32601`, `plugin.host.unavailable`. +- It ignores result, notification, `details`, and `limits` fields it does + not know, and any `lithe/host/*` notification it does not know. +- Core refuses a field the method does not define with `-32018`, + `plugin.host.unsupportedOption`, and `details: { option }` naming it, before + the host sees the request; the backend may retry without that option. A + service whose params Core does not type answers the same way itself. +- It treats a JSON-RPC number it does not know as a host failure, and + branches on `error.data.code` when it knows that code. +- A package whose backend needs a new service declares the new permission. + A host that does not know the permission refuses the package at install + (`plugin.permission.unknown`), so a permission is never granted or + ignored silently. + +`fixtures/function-protocol.json` (`hostRequests`) holds the +unsupported-option cases. ### Compatibility @@ -1416,12 +2077,21 @@ granted services as `lsp.startServer.hostServices` (from the launch plan's - A backend written before this protocol does not answer `litheFunctions`. It keeps serving its language and its `litheBuild` requests, receives no function calls, and its function requests are answered with `-32601`. -- A new host capability or wire field the other side must not ignore needs a - new protocol version, offered next to the old one. +- Up to version 3, every protocol version added host capabilities. From + version 3 a new host capability or optional field is added within the + version ([Extending version 3](#extending-version-3)); only a change an + existing backend would misread needs a new protocol version, offered next + to the old one. - A backend that answers `version: 1` keeps working unchanged: version 2 - only adds host services. + only adds host services. A backend that answers `version: 2` keeps + working unchanged: version 3 only adds methods, notifications, the call + context, and failure codes and details, all of which it never sees. +- `requires.backend: 1` packages keep their meaning; a new behavior of the + backend capability is `backend` 2, never a silent change of version 1. - `fixtures/function-protocol.json` holds the golden cases, including the - host requests (`hostRequests`). Rust Core tests run them, and a scripted backend covers routing, result, failure, cancellation, + host requests (`hostRequests`), call contexts (`invocationContexts`), host + failure codes (`hostFailures`), and host notifications + (`hostNotifications`). Rust Core tests run them, and a scripted backend covers routing, result, failure, cancellation, deadline, late reply, revocation, and compatibility with a build-only backend. @@ -1524,8 +2194,21 @@ command payload is a Core `invalid_request` failure instead. | `plugin.function.recursiveCall` | Core | A backend asked the host to dispatch a domain operation or function | | `plugin.host.unavailable` | Core | A backend asked for a host capability the negotiated protocol does not offer | | `plugin.host.permissionDenied` | Core | A backend asked for a [host service](#host-services-protocol-version-2) its plugin's permissions do not grant | -| `plugin.host.invalidParams` | Core | A host-service request has malformed or unknown params | -| `plugin.host.failed` | Core | The host could not complete a host-service request, or too many were unanswered | +| `plugin.host.invalidParams` | Core | A host-service request has malformed params, or (before version 3) unknown ones | +| `plugin.host.unsupportedOption` | Core, Host | From version 3, a host-service request names a field the host does not implement; `details.option` names it | +| `plugin.host.failed` | Core | The host could not complete a host-service request, or (before version 3) too many were unanswered | +| `plugin.host.contextExpired` | Host | The request names a workspace context that closed or has a newer generation | +| `plugin.host.authorityUnsupported` | Host | The context's authority (for example a remote workspace) does not implement the operation | +| `plugin.host.versionConflict` | Host | A versioned write names an older version than the current one | +| `plugin.host.limitExceeded` | Core, Host | A host limit on size, count, or rate was exceeded | +| `plugin.host.cancelled` | Core, Host | The backend cancelled the host request, or the host stopped it | +| `plugin.host.timeout` | Host | The host's own deadline for the operation passed | +| `plugin.host.notFound` | Host | The named resource does not exist | +| `plugin.host.readOnly` | Host | The resource or workspace cannot be written | +| `plugin.host.preconditionFailed` | Host | A state the request requires does not hold | +| `plugin.host.busy` | Host | The resource is in use; retry later | +| `plugin.host.rateLimited` | Host | Too many requests in a period | +| `plugin.backend.exited` | Host | A page's call, or a host operation for the backend, ended because the backend process exited | Host-facing plugin states are `notInstalled`, `installed` (enabled or disabled), `running`, `restartRequired`, `updateRequired`, and `failed`. @@ -1626,8 +2309,9 @@ them), `lithe.context` (`pluginID`, `windowID`, `platform`, `locale`, `theme`), `lithe.request(method, params)`, which returns a promise of the result or rejects with `{ code, message }`, and `lithe.on(event, listener)`, which returns an unsubscribe function. A page reaches only its own plugin's -build systems and the open workspace. Requests that run anything go through -the same workspace trust and consent prompts as the host's own controls, and +build systems and the open workspace. Requests that run anything ask for the +same per-operation consent as the host's own controls (Lithe has no workspace +trust state at the moment), and their output appears in the host's Build output window. | Method | Parameters | Result | @@ -1884,7 +2568,8 @@ receive the selected build tool as `tool.executable`; their ServerProcess protoc is unchanged. Language Servers, Execution Providers, and other ServerProcess launch forms can later adapt into this same resolver without changing their manifest structure all at once: preserve package-realpath/executable checks, -workspace trust, auto-start restrictions, runtime ownership and cancellation. +per-operation consent (there is no workspace trust state yet), auto-start +restrictions, runtime ownership and cancellation. They do **not** advertise support for new candidate declarations until their adapter is implemented; this capability currently authorizes the Build candidate positions only. Windows does not advertise it yet. diff --git a/contract/v1/fixtures/backend-workspace.plugin.json b/contract/v1/fixtures/backend-workspace.plugin.json new file mode 100644 index 0000000..8799688 --- /dev/null +++ b/contract/v1/fixtures/backend-workspace.plugin.json @@ -0,0 +1,34 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.fixture.workspacebackend", + "displayName": "Workspace Backend", + "version": "1.0.0", + "pluginAPIVersion": 3, + "requires": { + "backend": 2, + "serverProcess": 1 + }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "backend.launch", + "workspace.metadata" + ], + "activationEvents": [ + "onWorkspaceOpen" + ], + "languages": [], + "backend": { + "launch": { + "kind": "packaged", + "path": "bin/backend" + }, + "arguments": [ + "--state", + "${stateDir}" + ] + } +} diff --git a/contract/v1/fixtures/build-debug-preparation.json b/contract/v1/fixtures/build-debug-preparation.json new file mode 100644 index 0000000..e7cf3e8 --- /dev/null +++ b/contract/v1/fixtures/build-debug-preparation.json @@ -0,0 +1,28 @@ +{ + "result": { + "program": "app/bin/sample", + "arguments": [ + "a b", + "x#y" + ], + "workingDirectory": "app", + "environment": { + "SAMPLE": "debug" + } + }, + "invalidResults": [ + { + "program": "../sample", + "arguments": [], + "workingDirectory": "app", + "environment": {} + }, + { + "program": "app/bin/sample", + "arguments": [], + "workingDirectory": "app", + "environment": {}, + "adapter": "other/native" + } + ] +} diff --git a/contract/v1/fixtures/build-execution-protocol.json b/contract/v1/fixtures/build-execution-protocol.json new file mode 100644 index 0000000..0faea86 --- /dev/null +++ b/contract/v1/fixtures/build-execution-protocol.json @@ -0,0 +1,51 @@ +{ + "version": 2, + "request": { + "method": "lithe/build/execute", + "params": { + "buildSystemID": "gradle", + "root": ".", + "task": "build", + "options": {}, + "optionArguments": [], + "environment": {}, + "userArguments": [] + } + }, + "result": { + "exitCode": 0, + "output": "built\n" + }, + "progress": { + "operationID": "42", + "message": "Building", + "percentage": 50, + "output": "compiling\n" + }, + "invalidResults": [ + {}, + { + "exitCode": true, + "output": "" + }, + { + "exitCode": 2147483648, + "output": "" + }, + { + "exitCode": 0, + "output": false + } + ], + "invalidProgress": [ + {}, + { + "operationID": "42", + "percentage": 101 + }, + { + "operationID": "42", + "output": false + } + ] +} diff --git a/contract/v1/fixtures/build-model-consumer.plugin.json b/contract/v1/fixtures/build-model-consumer.plugin.json new file mode 100644 index 0000000..57fc3c5 --- /dev/null +++ b/contract/v1/fixtures/build-model-consumer.plugin.json @@ -0,0 +1,170 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.fixture.model-consumer", + "displayName": "Model Consumer Fixture", + "version": "0.1.0", + "pluginAPIVersion": 3, + "requires": { + "buildProviders": 6, + "buildSystems": 1, + "serverProcess": 1, + "settings": 1, + "buildModelInputs": 1 + }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "languageServer.launch", + "process.build" + ], + "activationEvents": [ + "onBuildSystem:gradle" + ], + "languages": [], + "settings": [ + { + "id": "providerHeapMB", + "type": "integer", + "title": "Provider memory", + "description": "Upper bound of the Gradle provider's heap.", + "default": 1024, + "minimum": 256, + "maximum": 8192, + "unit": "MB" + } + ], + "buildSystems": [ + { + "id": "gradle", + "displayName": "Gradle", + "detection": { + "markers": [ + "build.gradle", + "build.gradle.kts", + "settings.gradle", + "settings.gradle.kts" + ], + "nesting": "outermost", + "priority": 60 + }, + "invocation": { + "executables": [ + { + "kind": "projectFile", + "path": "gradlew", + "requires": [ + "gradle/wrapper/gradle-wrapper.properties" + ], + "platforms": [ + "macos", + "linux" + ] + }, + { + "kind": "projectFile", + "path": "gradlew.bat", + "requires": [ + "gradle/wrapper/gradle-wrapper.properties" + ], + "platforms": [ + "windows" + ] + }, + { + "kind": "command", + "command": "gradle" + } + ], + "runtimeDependencies": [ + { + "id": "java", + "minimumVersion": "17" + } + ], + "globalArguments": [ + "--console=plain", + "${optionArguments}" + ] + }, + "provider": { + "launch": { + "kind": "runtime", + "tool": "java", + "minimumVersion": "17", + "runtimeArguments": [ + "-Xmx${setting:providerHeapMB}m" + ], + "entry": "provider/gradle-tooling-provider.jar" + }, + "arguments": [ + "--stdio", + "--cache", + "${workspaceStateDir}/tooling" + ], + "initializationOptions": { + "initScript": "${pluginDir}/provider/lithe-model.init.gradle" + }, + "executesProjectCode": true, + "mayUseNetwork": true + }, + "options": [ + { + "id": "offline", + "displayName": "Offline", + "kind": "boolean", + "arguments": [ + "--offline" + ] + } + ], + "model": { + "source": "provider" + }, + "tasks": [ + { + "id": "build", + "displayName": "Build", + "source": "provider", + "group": "build", + "scopes": [ + "workspace" + ], + "arguments": [] + } + ], + "run": [], + "dependencies": { + "source": "provider", + "scopes": [ + "workspace", + "project" + ] + }, + "sync": { + "watch": [ + "build.gradle", + "build.gradle.kts", + "gradle.properties", + "gradle/libs.versions.toml", + "settings.gradle", + "settings.gradle.kts" + ] + }, + "layout": { + "excluded": [ + ".gradle", + "build" + ] + }, + "modelInputs": [ + { + "pluginID": "dev.lithe.fixture.model-producer", + "buildSystemID": "source" + } + ] + } + ] +} diff --git a/contract/v1/fixtures/build-model-producer.plugin.json b/contract/v1/fixtures/build-model-producer.plugin.json new file mode 100644 index 0000000..92055e4 --- /dev/null +++ b/contract/v1/fixtures/build-model-producer.plugin.json @@ -0,0 +1,158 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.fixture.model-producer", + "displayName": "Model Producer Fixture", + "version": "0.1.0", + "pluginAPIVersion": 3, + "requires": { + "buildProviders": 1, + "buildSystems": 1, + "serverProcess": 1, + "settings": 1, + "buildModelInputs": 1 + }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "languageServer.launch", + "process.build" + ], + "activationEvents": [ + "onBuildSystem:source" + ], + "languages": [], + "settings": [ + { + "id": "providerHeapMB", + "type": "integer", + "title": "Provider memory", + "description": "Upper bound of the Gradle provider's heap.", + "default": 1024, + "minimum": 256, + "maximum": 8192, + "unit": "MB" + } + ], + "buildSystems": [ + { + "id": "source", + "displayName": "Source", + "detection": { + "markers": [ + "build.gradle", + "build.gradle.kts", + "settings.gradle", + "settings.gradle.kts" + ], + "nesting": "outermost", + "priority": 60 + }, + "invocation": { + "executables": [ + { + "kind": "projectFile", + "path": "gradlew", + "requires": [ + "gradle/wrapper/gradle-wrapper.properties" + ], + "platforms": [ + "macos", + "linux" + ] + }, + { + "kind": "projectFile", + "path": "gradlew.bat", + "requires": [ + "gradle/wrapper/gradle-wrapper.properties" + ], + "platforms": [ + "windows" + ] + }, + { + "kind": "command", + "command": "gradle" + } + ], + "runtimeDependencies": [ + { + "id": "java", + "minimumVersion": "17" + } + ], + "globalArguments": [ + "--console=plain", + "${optionArguments}" + ] + }, + "provider": { + "launch": { + "kind": "runtime", + "tool": "java", + "minimumVersion": "17", + "runtimeArguments": [ + "-Xmx${setting:providerHeapMB}m" + ], + "entry": "provider/gradle-tooling-provider.jar" + }, + "arguments": [ + "--stdio", + "--cache", + "${workspaceStateDir}/tooling" + ], + "initializationOptions": { + "initScript": "${pluginDir}/provider/lithe-model.init.gradle" + }, + "executesProjectCode": true, + "mayUseNetwork": true + }, + "options": [ + { + "id": "offline", + "displayName": "Offline", + "kind": "boolean", + "arguments": [ + "--offline" + ] + } + ], + "model": { + "source": "provider" + }, + "tasks": [], + "run": [], + "dependencies": { + "source": "provider", + "scopes": [ + "workspace", + "project" + ] + }, + "sync": { + "watch": [ + "build.gradle", + "build.gradle.kts", + "gradle.properties", + "gradle/libs.versions.toml", + "settings.gradle", + "settings.gradle.kts" + ] + }, + "layout": { + "excluded": [ + ".gradle", + "build" + ] + }, + "modelSharing": { + "options": [ + "offline" + ] + } + } + ] +} diff --git a/contract/v1/fixtures/build-provider-debug-preparation.plugin.json b/contract/v1/fixtures/build-provider-debug-preparation.plugin.json new file mode 100644 index 0000000..e6d0998 --- /dev/null +++ b/contract/v1/fixtures/build-provider-debug-preparation.plugin.json @@ -0,0 +1,254 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.fixture.build-provider-debug-preparation", + "displayName": "Native Debug Preparation Fixture", + "version": "0.1.0", + "pluginAPIVersion": 4, + "requires": { + "buildProviders": 5, + "buildSystems": 1, + "serverProcess": 1, + "settings": 1, + "debugAdapters": 2 + }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "languageServer.launch", + "process.build", + "debug.launch" + ], + "activationEvents": [ + "onBuildSystem:gradle", + "onDebug:native" + ], + "languages": [], + "settings": [ + { + "id": "providerHeapMB", + "type": "integer", + "title": "Provider memory", + "description": "Upper bound of the Gradle provider's heap.", + "default": 1024, + "minimum": 256, + "maximum": 8192, + "unit": "MB" + } + ], + "buildSystems": [ + { + "id": "gradle", + "displayName": "Gradle", + "detection": { + "markers": [ + "build.gradle", + "build.gradle.kts", + "settings.gradle", + "settings.gradle.kts" + ], + "nesting": "outermost", + "priority": 60 + }, + "invocation": { + "executables": [ + { + "kind": "projectFile", + "path": "gradlew", + "requires": [ + "gradle/wrapper/gradle-wrapper.properties" + ], + "platforms": [ + "macos", + "linux" + ] + }, + { + "kind": "projectFile", + "path": "gradlew.bat", + "requires": [ + "gradle/wrapper/gradle-wrapper.properties" + ], + "platforms": [ + "windows" + ] + }, + { + "kind": "command", + "command": "gradle" + } + ], + "runtimeDependencies": [ + { + "id": "java", + "minimumVersion": "17" + } + ], + "globalArguments": [ + "--console=plain", + "${optionArguments}" + ] + }, + "provider": { + "launch": { + "kind": "runtime", + "tool": "java", + "minimumVersion": "17", + "runtimeArguments": [ + "-Xmx${setting:providerHeapMB}m" + ], + "entry": "provider/gradle-tooling-provider.jar" + }, + "arguments": [ + "--stdio", + "--cache", + "${workspaceStateDir}/tooling" + ], + "initializationOptions": { + "initScript": "${pluginDir}/provider/lithe-model.init.gradle" + }, + "executesProjectCode": true, + "mayUseNetwork": true + }, + "options": [ + { + "id": "offline", + "displayName": "Offline", + "kind": "boolean", + "arguments": [ + "--offline" + ] + } + ], + "model": { + "source": "provider" + }, + "tasks": [ + { + "id": "build", + "displayName": "Build", + "group": "build", + "scopes": [ + "workspace", + "project", + "target" + ], + "source": "provider", + "arguments": [] + }, + { + "id": "custom", + "displayName": "Custom", + "group": "other", + "scopes": [ + "workspace" + ], + "source": "provider", + "requiresUserArguments": true, + "arguments": [] + } + ], + "dependencies": { + "source": "provider", + "scopes": [ + "workspace", + "project" + ] + }, + "sync": { + "watch": [ + "build.gradle", + "build.gradle.kts", + "gradle.properties", + "gradle/libs.versions.toml", + "settings.gradle", + "settings.gradle.kts" + ] + }, + "layout": { + "excluded": [ + ".gradle", + "build" + ] + }, + "test": { + "source": "provider", + "scopes": [ + "workspace", + "project", + "target" + ], + "filters": [ + "suite", + "case" + ], + "arguments": [], + "results": { + "decoder": "junitXml", + "reports": [ + "build/test-results/**/*.xml" + ] + }, + "discovery": true + }, + "run": [ + { + "id": "application", + "source": "provider", + "name": "Run Application", + "fromTargets": { + "tags": [ + "application" + ] + }, + "arguments": [], + "debug": { + "adapter": "native" + } + } + ] + } + ], + "debugAdapters": [ + { + "id": "native", + "displayName": "Native DAP", + "requests": [ + "launch" + ], + "selectors": [ + { + "targetKinds": [ + "executable" + ] + } + ], + "configurationTypes": [ + "native" + ], + "configurations": { + "launch": { + "targetBindings": { + "program": "target.path", + "cwd": "target.workingDirectory", + "args": "target.arguments", + "env": "target.environment" + } + } + }, + "launch": { + "process": { + "launch": { + "kind": "systemCommand", + "commands": [ + "example-dap" + ] + } + }, + "transport": "stdio" + } + } + ] +} diff --git a/contract/v1/fixtures/build-provider-execution.plugin.json b/contract/v1/fixtures/build-provider-execution.plugin.json new file mode 100644 index 0000000..725065e --- /dev/null +++ b/contract/v1/fixtures/build-provider-execution.plugin.json @@ -0,0 +1,175 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.fixture.build-provider-execution", + "displayName": "Build Provider Execution Fixture", + "version": "0.1.0", + "pluginAPIVersion": 3, + "requires": { + "buildProviders": 2, + "buildSystems": 1, + "serverProcess": 1, + "settings": 1 + }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "languageServer.launch", + "process.build" + ], + "activationEvents": [ + "onBuildSystem:gradle" + ], + "languages": [], + "settings": [ + { + "id": "providerHeapMB", + "type": "integer", + "title": "Provider memory", + "description": "Upper bound of the Gradle provider's heap.", + "default": 1024, + "minimum": 256, + "maximum": 8192, + "unit": "MB" + } + ], + "buildSystems": [ + { + "id": "gradle", + "displayName": "Gradle", + "detection": { + "markers": [ + "build.gradle", + "build.gradle.kts", + "settings.gradle", + "settings.gradle.kts" + ], + "nesting": "outermost", + "priority": 60 + }, + "invocation": { + "executables": [ + { + "kind": "projectFile", + "path": "gradlew", + "requires": [ + "gradle/wrapper/gradle-wrapper.properties" + ], + "platforms": [ + "macos", + "linux" + ] + }, + { + "kind": "projectFile", + "path": "gradlew.bat", + "requires": [ + "gradle/wrapper/gradle-wrapper.properties" + ], + "platforms": [ + "windows" + ] + }, + { + "kind": "command", + "command": "gradle" + } + ], + "runtimeDependencies": [ + { + "id": "java", + "minimumVersion": "17" + } + ], + "globalArguments": [ + "--console=plain", + "${optionArguments}" + ] + }, + "provider": { + "launch": { + "kind": "runtime", + "tool": "java", + "minimumVersion": "17", + "runtimeArguments": [ + "-Xmx${setting:providerHeapMB}m" + ], + "entry": "provider/gradle-tooling-provider.jar" + }, + "arguments": [ + "--stdio", + "--cache", + "${workspaceStateDir}/tooling" + ], + "initializationOptions": { + "initScript": "${pluginDir}/provider/lithe-model.init.gradle" + }, + "executesProjectCode": true, + "mayUseNetwork": true + }, + "options": [ + { + "id": "offline", + "displayName": "Offline", + "kind": "boolean", + "arguments": [ + "--offline" + ] + } + ], + "model": { + "source": "provider" + }, + "tasks": [ + { + "id": "build", + "displayName": "Build", + "group": "build", + "scopes": [ + "workspace", + "project", + "target" + ], + "source": "provider", + "arguments": [] + }, + { + "id": "custom", + "displayName": "Custom", + "group": "other", + "scopes": [ + "workspace" + ], + "source": "provider", + "requiresUserArguments": true, + "arguments": [] + } + ], + "dependencies": { + "source": "provider", + "scopes": [ + "workspace", + "project" + ] + }, + "sync": { + "watch": [ + "build.gradle", + "build.gradle.kts", + "gradle.properties", + "gradle/libs.versions.toml", + "settings.gradle", + "settings.gradle.kts" + ] + }, + "layout": { + "excluded": [ + ".gradle", + "build" + ] + } + } + ] +} diff --git a/contract/v1/fixtures/build-provider-run-preparation.plugin.json b/contract/v1/fixtures/build-provider-run-preparation.plugin.json new file mode 100644 index 0000000..eb5422c --- /dev/null +++ b/contract/v1/fixtures/build-provider-run-preparation.plugin.json @@ -0,0 +1,208 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.fixture.build-provider-run-preparation", + "displayName": "Provider Run Preparation Fixture", + "version": "0.1.0", + "pluginAPIVersion": 3, + "requires": { + "buildProviders": 4, + "buildSystems": 1, + "serverProcess": 1, + "settings": 1 + }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "languageServer.launch", + "process.build" + ], + "activationEvents": [ + "onBuildSystem:gradle" + ], + "languages": [], + "settings": [ + { + "id": "providerHeapMB", + "type": "integer", + "title": "Provider memory", + "description": "Upper bound of the Gradle provider's heap.", + "default": 1024, + "minimum": 256, + "maximum": 8192, + "unit": "MB" + } + ], + "buildSystems": [ + { + "id": "gradle", + "displayName": "Gradle", + "detection": { + "markers": [ + "build.gradle", + "build.gradle.kts", + "settings.gradle", + "settings.gradle.kts" + ], + "nesting": "outermost", + "priority": 60 + }, + "invocation": { + "executables": [ + { + "kind": "projectFile", + "path": "gradlew", + "requires": [ + "gradle/wrapper/gradle-wrapper.properties" + ], + "platforms": [ + "macos", + "linux" + ] + }, + { + "kind": "projectFile", + "path": "gradlew.bat", + "requires": [ + "gradle/wrapper/gradle-wrapper.properties" + ], + "platforms": [ + "windows" + ] + }, + { + "kind": "command", + "command": "gradle" + } + ], + "runtimeDependencies": [ + { + "id": "java", + "minimumVersion": "17" + } + ], + "globalArguments": [ + "--console=plain", + "${optionArguments}" + ] + }, + "provider": { + "launch": { + "kind": "runtime", + "tool": "java", + "minimumVersion": "17", + "runtimeArguments": [ + "-Xmx${setting:providerHeapMB}m" + ], + "entry": "provider/gradle-tooling-provider.jar" + }, + "arguments": [ + "--stdio", + "--cache", + "${workspaceStateDir}/tooling" + ], + "initializationOptions": { + "initScript": "${pluginDir}/provider/lithe-model.init.gradle" + }, + "executesProjectCode": true, + "mayUseNetwork": true + }, + "options": [ + { + "id": "offline", + "displayName": "Offline", + "kind": "boolean", + "arguments": [ + "--offline" + ] + } + ], + "model": { + "source": "provider" + }, + "tasks": [ + { + "id": "build", + "displayName": "Build", + "group": "build", + "scopes": [ + "workspace", + "project", + "target" + ], + "source": "provider", + "arguments": [] + }, + { + "id": "custom", + "displayName": "Custom", + "group": "other", + "scopes": [ + "workspace" + ], + "source": "provider", + "requiresUserArguments": true, + "arguments": [] + } + ], + "dependencies": { + "source": "provider", + "scopes": [ + "workspace", + "project" + ] + }, + "sync": { + "watch": [ + "build.gradle", + "build.gradle.kts", + "gradle.properties", + "gradle/libs.versions.toml", + "settings.gradle", + "settings.gradle.kts" + ] + }, + "layout": { + "excluded": [ + ".gradle", + "build" + ] + }, + "test": { + "source": "provider", + "scopes": [ + "workspace", + "project", + "target" + ], + "filters": [ + "suite", + "case" + ], + "arguments": [], + "results": { + "decoder": "junitXml", + "reports": [ + "build/test-results/**/*.xml" + ] + }, + "discovery": true + }, + "run": [ + { + "id": "application", + "source": "provider", + "name": "Run Application", + "fromTargets": { + "tags": [ + "application" + ] + }, + "arguments": [] + } + ] + } + ] +} diff --git a/contract/v1/fixtures/build-provider-tests.plugin.json b/contract/v1/fixtures/build-provider-tests.plugin.json new file mode 100644 index 0000000..3749a7a --- /dev/null +++ b/contract/v1/fixtures/build-provider-tests.plugin.json @@ -0,0 +1,195 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.fixture.build-provider-tests", + "displayName": "Build Provider Tests Fixture", + "version": "0.1.0", + "pluginAPIVersion": 3, + "requires": { + "buildProviders": 3, + "buildSystems": 1, + "serverProcess": 1, + "settings": 1 + }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "languageServer.launch", + "process.build" + ], + "activationEvents": [ + "onBuildSystem:gradle" + ], + "languages": [], + "settings": [ + { + "id": "providerHeapMB", + "type": "integer", + "title": "Provider memory", + "description": "Upper bound of the Gradle provider's heap.", + "default": 1024, + "minimum": 256, + "maximum": 8192, + "unit": "MB" + } + ], + "buildSystems": [ + { + "id": "gradle", + "displayName": "Gradle", + "detection": { + "markers": [ + "build.gradle", + "build.gradle.kts", + "settings.gradle", + "settings.gradle.kts" + ], + "nesting": "outermost", + "priority": 60 + }, + "invocation": { + "executables": [ + { + "kind": "projectFile", + "path": "gradlew", + "requires": [ + "gradle/wrapper/gradle-wrapper.properties" + ], + "platforms": [ + "macos", + "linux" + ] + }, + { + "kind": "projectFile", + "path": "gradlew.bat", + "requires": [ + "gradle/wrapper/gradle-wrapper.properties" + ], + "platforms": [ + "windows" + ] + }, + { + "kind": "command", + "command": "gradle" + } + ], + "runtimeDependencies": [ + { + "id": "java", + "minimumVersion": "17" + } + ], + "globalArguments": [ + "--console=plain", + "${optionArguments}" + ] + }, + "provider": { + "launch": { + "kind": "runtime", + "tool": "java", + "minimumVersion": "17", + "runtimeArguments": [ + "-Xmx${setting:providerHeapMB}m" + ], + "entry": "provider/gradle-tooling-provider.jar" + }, + "arguments": [ + "--stdio", + "--cache", + "${workspaceStateDir}/tooling" + ], + "initializationOptions": { + "initScript": "${pluginDir}/provider/lithe-model.init.gradle" + }, + "executesProjectCode": true, + "mayUseNetwork": true + }, + "options": [ + { + "id": "offline", + "displayName": "Offline", + "kind": "boolean", + "arguments": [ + "--offline" + ] + } + ], + "model": { + "source": "provider" + }, + "tasks": [ + { + "id": "build", + "displayName": "Build", + "group": "build", + "scopes": [ + "workspace", + "project", + "target" + ], + "source": "provider", + "arguments": [] + }, + { + "id": "custom", + "displayName": "Custom", + "group": "other", + "scopes": [ + "workspace" + ], + "source": "provider", + "requiresUserArguments": true, + "arguments": [] + } + ], + "dependencies": { + "source": "provider", + "scopes": [ + "workspace", + "project" + ] + }, + "sync": { + "watch": [ + "build.gradle", + "build.gradle.kts", + "gradle.properties", + "gradle/libs.versions.toml", + "settings.gradle", + "settings.gradle.kts" + ] + }, + "layout": { + "excluded": [ + ".gradle", + "build" + ] + }, + "test": { + "source": "provider", + "scopes": [ + "workspace", + "project", + "target" + ], + "filters": [ + "suite", + "case" + ], + "arguments": [], + "results": { + "decoder": "junitXml", + "reports": [ + "build/test-results/**/*.xml" + ] + }, + "discovery": true + } + } + ] +} diff --git a/contract/v1/fixtures/build-run-preparation.json b/contract/v1/fixtures/build-run-preparation.json new file mode 100644 index 0000000..bacabb6 --- /dev/null +++ b/contract/v1/fixtures/build-run-preparation.json @@ -0,0 +1,39 @@ +{ + "request": { + "root": ".", + "run": "application", + "project": { + "name": "app", + "path": "app" + }, + "target": { + "name": "Main", + "path": "app", + "tags": [ + "application" + ] + }, + "options": { + "offline": true + }, + "userArguments": [ + "a b" + ] + }, + "result": { + "executable": { + "kind": "toolchain", + "value": "project-jdk" + }, + "arguments": [ + "-cp", + "classes", + "example.Main", + "a b" + ], + "workingDirectory": "app", + "environment": { + "APP_ENV": "test" + } + } +} diff --git a/contract/v1/fixtures/build-test-discovery.json b/contract/v1/fixtures/build-test-discovery.json new file mode 100644 index 0000000..32b0496 --- /dev/null +++ b/contract/v1/fixtures/build-test-discovery.json @@ -0,0 +1,24 @@ +{ + "result": { + "format": "litheTestDiscovery", + "version": 1, + "tests": [ + { + "suite": "ExampleSuite", + "case": "checks spaces # [exact]", + "project": { + "name": "app", + "path": "app" + } + }, + { + "suite": "ExampleSuite", + "case": "checks spaces # [exact]", + "project": { + "name": "lib", + "path": "lib" + } + } + ] + } +} diff --git a/contract/v1/fixtures/build-test-protocol.json b/contract/v1/fixtures/build-test-protocol.json new file mode 100644 index 0000000..bd010d1 --- /dev/null +++ b/contract/v1/fixtures/build-test-protocol.json @@ -0,0 +1,48 @@ +{ + "request": { + "root": ".", + "options": { + "offline": true + }, + "project": { + "name": "app", + "path": "app" + }, + "suite": "ExampleSuite", + "case": "checks spaces # [exact]", + "userArguments": [ + "--rerun-tasks" + ] + }, + "provider": { + "method": "lithe/build/test", + "params": { + "buildSystemID": "gradle", + "root": ".", + "options": { + "offline": true + }, + "optionArguments": [ + "--offline" + ], + "environment": {}, + "project": { + "name": "app", + "path": "app", + "tags": [], + "properties": {} + }, + "suite": "ExampleSuite", + "case": "checks spaces # [exact]", + "userArguments": [ + "--rerun-tasks" + ] + } + }, + "results": { + "decoder": "junitXml", + "reports": [ + "app/build/test-results/**/*.xml" + ] + } +} diff --git a/contract/v1/fixtures/debug-adapter-language-server.plugin.json b/contract/v1/fixtures/debug-adapter-language-server.plugin.json new file mode 100644 index 0000000..aa5a9c3 --- /dev/null +++ b/contract/v1/fixtures/debug-adapter-language-server.plugin.json @@ -0,0 +1,51 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.fixture.debug-adapter-language-server", + "displayName": "Debug Adapter In Language Server Fixture", + "version": "0.1.0", + "pluginAPIVersion": 4, + "requires": { "debugAdapters": 1, "languageServers": 1, "languages": 1, "serverProcess": 1 }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "debug.launch", + "languageServer.launch" + ], + "activationEvents": [ + "onDebug:example", + "onLanguage:example" + ], + "languages": [ + { + "id": "example", + "displayName": "Example", + "fileExtensions": ["ex"], + "projectFileNames": ["example.toml"] + } + ], + "languageServers": [ + { + "languageID": "example", + "launch": { "kind": "systemCommand", "commands": ["example-ls"] }, + "arguments": ["--stdio"] + } + ], + "debugAdapters": [ + { + "id": "example", + "displayName": "Example Debugger", + "adapterID": "example-dap", + "selectors": [{ "targetKinds": ["file"], "languageIDs": ["example"] }], + "launch": { + "transport": "tcpLanguageServer", + "endpoint": { + "languageID": "example", + "command": "example.startDebugSession" + } + } + } + ] +} diff --git a/contract/v1/fixtures/debug-adapter-rejections.json b/contract/v1/fixtures/debug-adapter-rejections.json index 89ee530..19f09f9 100644 --- a/contract/v1/fixtures/debug-adapter-rejections.json +++ b/contract/v1/fixtures/debug-adapter-rejections.json @@ -344,6 +344,174 @@ "layer": "corePlan", "edits": [{"op": "set", "path": "/context/registration/currentGeneration", "value": 2}], "expected": {"code": "debug.provider.stale", "field": "registration"} + }, + { + "id": "connection-binding-on-launch", + "layer": "coreManifest", + "edits": [{"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"launch": {"targetBindings": {"connection": {"host": "hostName", "port": "port"}}}}}], + "expected": {"code": "plugin.manifest.invalid", "field": "debugAdapters[0].configurations.launch.targetBindings"} + }, + { + "id": "connection-host-without-port", + "layer": "coreManifest", + "edits": [{"op": "set", "path": "/block/debugAdapters/0/requests", "value": ["attach"]}, {"op": "set", "path": "/context/manifestMetadata/permissions", "value": ["debug.attach", "debug.launch"]}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"attach": {"targetBindings": {"connection": {"host": "hostName"}}}}}], + "expected": {"code": "plugin.manifest.invalid", "field": "debugAdapters[0].configurations.attach.targetBindings.connection.host"} + }, + { + "id": "connection-without-destination", + "layer": "coreManifest", + "edits": [{"op": "set", "path": "/block/debugAdapters/0/requests", "value": ["attach"]}, {"op": "set", "path": "/context/manifestMetadata/permissions", "value": ["debug.attach", "debug.launch"]}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"attach": {"targetBindings": {"connection": {}}}}}], + "expected": {"code": "plugin.manifest.invalid", "field": "debugAdapters[0].configurations.attach.targetBindings.connection"} + }, + { + "id": "connection-malformed-argument-path", + "layer": "coreManifest", + "edits": [{"op": "set", "path": "/block/debugAdapters/0/requests", "value": ["attach"]}, {"op": "set", "path": "/context/manifestMetadata/permissions", "value": ["debug.attach", "debug.launch"]}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"attach": {"targetBindings": {"connection": {"host": "connect..host", "port": "port"}}}}}], + "expected": {"code": "plugin.manifest.invalid", "field": "debugAdapters[0].configurations.attach.targetBindings.connection.host"} + }, + { + "id": "connection-argument-path-too-deep", + "layer": "coreManifest", + "edits": [{"op": "set", "path": "/block/debugAdapters/0/requests", "value": ["attach"]}, {"op": "set", "path": "/context/manifestMetadata/permissions", "value": ["debug.attach", "debug.launch"]}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"attach": {"targetBindings": {"connection": {"host": "a.b.c.d.e", "port": "port"}}}}}], + "expected": {"code": "plugin.manifest.invalid", "field": "debugAdapters[0].configurations.attach.targetBindings.connection.host"} + }, + { + "id": "connection-destinations-overlap", + "layer": "coreManifest", + "edits": [{"op": "set", "path": "/block/debugAdapters/0/requests", "value": ["attach"]}, {"op": "set", "path": "/context/manifestMetadata/permissions", "value": ["debug.attach", "debug.launch"]}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"attach": {"targetBindings": {"connection": {"host": "connect", "port": "connect.port"}}}}}], + "expected": {"code": "plugin.manifest.invalid", "field": "debugAdapters[0].configurations.attach.targetBindings.connection.port"} + }, + { + "id": "connection-overlaps-process-binding", + "layer": "coreManifest", + "edits": [{"op": "set", "path": "/block/debugAdapters/0/requests", "value": ["attach"]}, {"op": "set", "path": "/context/manifestMetadata/permissions", "value": ["debug.attach", "debug.launch"]}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"attach": {"targetBindings": {"processId": "target.processID", "connection": {"host": "processId", "port": "port"}}}}}], + "expected": {"code": "plugin.manifest.invalid", "field": "debugAdapters[0].configurations.attach.targetBindings.connection.host"} + }, + { + "id": "connection-collides-with-default-argument", + "layer": "coreManifest", + "edits": [{"op": "set", "path": "/block/debugAdapters/0/requests", "value": ["attach"]}, {"op": "set", "path": "/context/manifestMetadata/permissions", "value": ["debug.attach", "debug.launch"]}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"attach": {"targetBindings": {"connection": {"host": "hostName", "port": "port"}}, "arguments": {"hostName": "localhost"}}}}], + "expected": {"code": "plugin.manifest.invalid", "field": "debugAdapters[0].configurations.attach.targetBindings.connection.host"} + }, + { + "id": "connection-collides-with-default-object", + "layer": "coreManifest", + "edits": [{"op": "set", "path": "/block/debugAdapters/0/requests", "value": ["attach"]}, {"op": "set", "path": "/context/manifestMetadata/permissions", "value": ["debug.attach", "debug.launch"]}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"attach": {"targetBindings": {"connection": {"host": "connect.host", "port": "connect.port"}}, "arguments": {"connect": "tcp"}}}}], + "expected": {"code": "plugin.manifest.invalid", "field": "debugAdapters[0].configurations.attach.targetBindings.connection.host"} + }, + { + "id": "connection-path-mapping-keys-equal", + "layer": "coreManifest", + "edits": [{"op": "set", "path": "/block/debugAdapters/0/requests", "value": ["attach"]}, {"op": "set", "path": "/context/manifestMetadata/permissions", "value": ["debug.attach", "debug.launch"]}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"attach": {"targetBindings": {"connection": {"host": "connect.host", "port": "connect.port", "pathMappings": {"shape": "objects", "argument": "pathMappings", "localKey": "root", "remoteKey": "root"}}}}}}], + "expected": {"code": "plugin.manifest.invalid", "field": "debugAdapters[0].configurations.attach.targetBindings.connection.pathMappings.remoteKey"} + }, + { + "id": "connection-path-mapping-malformed-key", + "layer": "coreManifest", + "edits": [{"op": "set", "path": "/block/debugAdapters/0/requests", "value": ["attach"]}, {"op": "set", "path": "/context/manifestMetadata/permissions", "value": ["debug.attach", "debug.launch"]}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"attach": {"targetBindings": {"connection": {"host": "connect.host", "port": "connect.port", "pathMappings": {"shape": "objects", "argument": "pathMappings", "localKey": "local root", "remoteKey": "remoteRoot"}}}}}}], + "expected": {"code": "plugin.manifest.invalid", "field": "debugAdapters[0].configurations.attach.targetBindings.connection.pathMappings.localKey"} + }, + { + "id": "connection-path-mapping-overlaps-endpoint", + "layer": "coreManifest", + "edits": [{"op": "set", "path": "/block/debugAdapters/0/requests", "value": ["attach"]}, {"op": "set", "path": "/context/manifestMetadata/permissions", "value": ["debug.attach", "debug.launch"]}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"attach": {"targetBindings": {"connection": {"host": "connect.host", "port": "connect.port", "pathMappings": {"shape": "first", "local": "connect.host", "remote": "remoteRoot"}}}}}}], + "expected": {"code": "plugin.manifest.invalid", "field": "debugAdapters[0].configurations.attach.targetBindings.connection.pathMappings.local"} + }, + { + "id": "connection-path-mapping-unknown-shape", + "layer": "schema", + "edits": [{"op": "set", "path": "/block/debugAdapters/0/requests", "value": ["attach"]}, {"op": "set", "path": "/context/manifestMetadata/permissions", "value": ["debug.attach", "debug.launch"]}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"attach": {"targetBindings": {"connection": {"host": "connect.host", "port": "connect.port", "pathMappings": {"shape": "triples", "argument": "sourceMap"}}}}}}], + "expected": {"code": "plugin.manifest.invalid", "field": "debugAdapters[0].configurations.attach.targetBindings.connection.pathMappings"} + }, + { + "id": "connection-unknown-field", + "layer": "schema", + "edits": [{"op": "set", "path": "/block/debugAdapters/0/requests", "value": ["attach"]}, {"op": "set", "path": "/context/manifestMetadata/permissions", "value": ["debug.attach", "debug.launch"]}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"attach": {"targetBindings": {"connection": {"host": "hostName", "port": "port", "scheme": "https"}}}}}], + "expected": {"code": "plugin.manifest.invalid", "field": "debugAdapters[0].configurations.attach.targetBindings.connection.scheme"} + }, + { + "id": "connection-selector-without-binding", + "layer": "coreManifest", + "edits": [{"op": "set", "path": "/block/debugAdapters/0/requests", "value": ["attach"]}, {"op": "set", "path": "/context/manifestMetadata/permissions", "value": ["debug.attach", "debug.launch"]}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"attach": {"targetBindings": {"processId": "target.processID"}}}}, {"op": "set", "path": "/block/debugAdapters/0/selectors", "value": [{"targetKinds": ["connection"]}]}], + "expected": {"code": "plugin.manifest.invalid", "field": "debugAdapters[0].selectors"} + }, + { + "id": "remote-target-missing-host", + "layer": "corePlan", + "edits": [{"op": "set", "path": "/block/debugAdapters/0/requests", "value": ["attach"]}, {"op": "set", "path": "/context/manifestMetadata/permissions", "value": ["debug.attach", "debug.launch"]}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"attach": {"targetBindings": {"connection": {"host": "hostName", "port": "port"}}}}}, {"op": "set", "path": "/context/request", "value": "attach"}, {"op": "set", "path": "/context/target", "value": {"kind": "connection", "port": 5005}}], + "expected": {"code": "debug.target.invalid", "field": "target.host"} + }, + { + "id": "remote-target-host-with-scheme", + "layer": "corePlan", + "edits": [{"op": "set", "path": "/block/debugAdapters/0/requests", "value": ["attach"]}, {"op": "set", "path": "/context/manifestMetadata/permissions", "value": ["debug.attach", "debug.launch"]}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"attach": {"targetBindings": {"connection": {"host": "hostName", "port": "port"}}}}}, {"op": "set", "path": "/context/request", "value": "attach"}, {"op": "set", "path": "/context/target", "value": {"kind": "connection", "host": "http://build.example.org", "port": 5005}}], + "expected": {"code": "debug.target.invalid", "field": "target.host"} + }, + { + "id": "remote-target-missing-port", + "layer": "corePlan", + "edits": [{"op": "set", "path": "/block/debugAdapters/0/requests", "value": ["attach"]}, {"op": "set", "path": "/context/manifestMetadata/permissions", "value": ["debug.attach", "debug.launch"]}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"attach": {"targetBindings": {"connection": {"host": "hostName", "port": "port"}}}}}, {"op": "set", "path": "/context/request", "value": "attach"}, {"op": "set", "path": "/context/target", "value": {"kind": "connection", "host": "build.example.org"}}], + "expected": {"code": "debug.target.invalid", "field": "target.port"} + }, + { + "id": "remote-target-port-out-of-range", + "layer": "corePlan", + "edits": [{"op": "set", "path": "/block/debugAdapters/0/requests", "value": ["attach"]}, {"op": "set", "path": "/context/manifestMetadata/permissions", "value": ["debug.attach", "debug.launch"]}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"attach": {"targetBindings": {"connection": {"host": "hostName", "port": "port"}}}}}, {"op": "set", "path": "/context/request", "value": "attach"}, {"op": "set", "path": "/context/target", "value": {"kind": "connection", "host": "build.example.org", "port": 70000}}], + "expected": {"code": "debug.target.invalid", "field": "target.port"} + }, + { + "id": "remote-target-relative-local-root", + "layer": "corePlan", + "edits": [{"op": "set", "path": "/block/debugAdapters/0/requests", "value": ["attach"]}, {"op": "set", "path": "/context/manifestMetadata/permissions", "value": ["debug.attach", "debug.launch"]}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"attach": {"targetBindings": {"connection": {"host": "connect.host", "port": "connect.port", "pathMappings": {"shape": "objects", "argument": "pathMappings", "localKey": "localRoot", "remoteKey": "remoteRoot"}}}}}}, {"op": "set", "path": "/context/request", "value": "attach"}, {"op": "set", "path": "/context/target", "value": {"kind": "connection", "host": "build.example.org", "port": 5005, "pathMappings": [{"local": "src", "remote": "/srv/app"}]}}], + "expected": {"code": "debug.target.invalid", "field": "target.pathMappings"} + }, + { + "id": "remote-target-mappings-without-binding", + "layer": "corePlan", + "edits": [{"op": "set", "path": "/block/debugAdapters/0/requests", "value": ["attach"]}, {"op": "set", "path": "/context/manifestMetadata/permissions", "value": ["debug.attach", "debug.launch"]}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"attach": {"targetBindings": {"connection": {"host": "hostName", "port": "port"}}}}}, {"op": "set", "path": "/context/request", "value": "attach"}, {"op": "set", "path": "/context/target", "value": {"kind": "connection", "host": "build.example.org", "port": 5005, "pathMappings": [{"local": "/work/src", "remote": "/srv/app"}]}}], + "expected": {"code": "debug.target.invalid", "field": "target.pathMappings"} + }, + { + "id": "remote-target-for-launch", + "layer": "corePlan", + "edits": [{"op": "set", "path": "/block/debugAdapters/0/requests", "value": ["attach"]}, {"op": "set", "path": "/context/manifestMetadata/permissions", "value": ["debug.attach", "debug.launch"]}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"attach": {"targetBindings": {"connection": {"host": "hostName", "port": "port"}}}}}, {"op": "set", "path": "/block/debugAdapters/0/requests", "value": ["launch", "attach"]}, {"op": "set", "path": "/context/target", "value": {"kind": "connection", "host": "build.example.org", "port": 5005}}], + "expected": {"code": "debug.target.invalid", "field": "target.kind"} + }, + { + "id": "connection-path-mapping-dictionary-unknown-side", + "layer": "schema", + "edits": [{"op": "set", "path": "/block/debugAdapters/0/requests", "value": ["attach"]}, {"op": "set", "path": "/context/manifestMetadata/permissions", "value": ["debug.attach", "debug.launch"]}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"attach": {"targetBindings": {"connection": {"host": "connect.host", "port": "connect.port", "pathMappings": {"shape": "dictionary", "argument": "sourceMap", "keyedBy": "both"}}}}}}], + "expected": {"code": "plugin.manifest.invalid", "field": "debugAdapters[0].configurations.attach.targetBindings.connection.pathMappings"} + }, + { + "id": "connection-path-mapping-pairs-unknown-order", + "layer": "schema", + "edits": [{"op": "set", "path": "/block/debugAdapters/0/requests", "value": ["attach"]}, {"op": "set", "path": "/context/manifestMetadata/permissions", "value": ["debug.attach", "debug.launch"]}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"attach": {"targetBindings": {"connection": {"host": "connect.host", "port": "connect.port", "pathMappings": {"shape": "pairs", "argument": "sourceMap", "order": "sideBySide"}}}}}}], + "expected": {"code": "plugin.manifest.invalid", "field": "debugAdapters[0].configurations.attach.targetBindings.connection.pathMappings"} + }, + { + "id": "connection-path-mapping-dictionary-overlaps-endpoint", + "layer": "coreManifest", + "edits": [{"op": "set", "path": "/block/debugAdapters/0/requests", "value": ["attach"]}, {"op": "set", "path": "/context/manifestMetadata/permissions", "value": ["debug.attach", "debug.launch"]}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"attach": {"targetBindings": {"connection": {"host": "connect.host", "port": "connect.port", "pathMappings": {"shape": "dictionary", "argument": "connect.host", "keyedBy": "remote"}}}}}}], + "expected": {"code": "plugin.manifest.invalid", "field": "debugAdapters[0].configurations.attach.targetBindings.connection.pathMappings.argument"} + }, + { + "id": "connection-path-mapping-pairs-overlaps-endpoint", + "layer": "coreManifest", + "edits": [{"op": "set", "path": "/block/debugAdapters/0/requests", "value": ["attach"]}, {"op": "set", "path": "/context/manifestMetadata/permissions", "value": ["debug.attach", "debug.launch"]}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"attach": {"targetBindings": {"connection": {"host": "connect.host", "port": "connect.port", "pathMappings": {"shape": "pairs", "argument": "connect", "order": "localRemote"}}}}}}], + "expected": {"code": "plugin.manifest.invalid", "field": "debugAdapters[0].configurations.attach.targetBindings.connection.pathMappings.argument"} + }, + { + "id": "remote-target-mappings-share-a-keyed-root", + "layer": "corePlan", + "edits": [{"op": "set", "path": "/block/debugAdapters/0/requests", "value": ["attach"]}, {"op": "set", "path": "/context/manifestMetadata/permissions", "value": ["debug.attach", "debug.launch"]}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"attach": {"targetBindings": {"connection": {"host": "connect.host", "port": "connect.port", "pathMappings": {"shape": "dictionary", "argument": "sourceMap", "keyedBy": "remote"}}}}}}, {"op": "set", "path": "/context/request", "value": "attach"}, {"op": "set", "path": "/context/target", "value": {"kind": "connection", "host": "build.example.org", "port": 5005, "pathMappings": [{"local": "/work/src", "remote": "/srv/app"}, {"local": "/work/lib", "remote": "/srv/app"}]}}], + "expected": {"code": "debug.target.invalid", "field": "target.pathMappings"} + }, + { + "id": "remote-target-more-mappings-than-a-single-root-takes", + "layer": "corePlan", + "edits": [{"op": "set", "path": "/block/debugAdapters/0/requests", "value": ["attach"]}, {"op": "set", "path": "/context/manifestMetadata/permissions", "value": ["debug.attach", "debug.launch"]}, {"op": "set", "path": "/block/debugAdapters/0/configurations", "value": {"attach": {"targetBindings": {"connection": {"host": "connect.host", "port": "connect.port", "pathMappings": {"shape": "first", "local": "localRoot", "remote": "remoteRoot"}}}}}}, {"op": "set", "path": "/context/request", "value": "attach"}, {"op": "set", "path": "/context/target", "value": {"kind": "connection", "host": "build.example.org", "port": 5005, "pathMappings": [{"local": "/work/src", "remote": "/srv/app"}, {"local": "/work/lib", "remote": "/srv/lib"}]}}], + "expected": {"code": "debug.target.invalid", "field": "target.pathMappings"} } ] } diff --git a/contract/v1/fixtures/debug-adapter-remote-attach.plugin.json b/contract/v1/fixtures/debug-adapter-remote-attach.plugin.json new file mode 100644 index 0000000..fa9f868 --- /dev/null +++ b/contract/v1/fixtures/debug-adapter-remote-attach.plugin.json @@ -0,0 +1,184 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.fixture.debug-adapter-remote-attach", + "displayName": "Debug Adapter Remote Attach Fixture", + "version": "0.1.0", + "pluginAPIVersion": 4, + "requires": { "debugAdapters": 1, "serverProcess": 1 }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "debug.attach", + "debug.launch" + ], + "activationEvents": [ + "onDebug:flat", + "onDebug:nested", + "onDebug:scalar", + "onDebug:joined", + "onDebug:keyed", + "onDebug:paired" + ], + "languages": [], + "debugAdapters": [ + { + "id": "flat", + "displayName": "Flat Remote Debugger", + "adapterID": "flat-dap", + "requests": ["attach"], + "selectors": [{ "targetKinds": ["connection"] }], + "configurations": { + "attach": { + "arguments": { "timeout": 5000 }, + "targetBindings": { + "connection": { "host": "hostName", "port": "port" } + } + } + }, + "launch": { + "process": { + "launch": { "kind": "packaged", "path": "bin/flat-dap" } + }, + "transport": "stdio" + } + }, + { + "id": "nested", + "displayName": "Nested Remote Debugger", + "adapterID": "nested-dap", + "requests": ["attach"], + "selectors": [{ "targetKinds": ["connection"] }], + "configurations": { + "attach": { + "arguments": { "justMyCode": false }, + "targetBindings": { + "connection": { + "host": "connect.host", + "port": "connect.port", + "pathMappings": { + "shape": "objects", + "argument": "pathMappings", + "localKey": "localRoot", + "remoteKey": "remoteRoot" + } + } + } + } + }, + "launch": { + "process": { + "launch": { "kind": "packaged", "path": "bin/nested-dap" } + }, + "transport": "stdio" + } + }, + { + "id": "scalar", + "displayName": "Single Root Remote Debugger", + "adapterID": "scalar-dap", + "requests": ["attach"], + "selectors": [{ "targetKinds": ["connection"] }], + "configurations": { + "attach": { + "targetBindings": { + "connection": { + "host": "address", + "port": "port", + "pathMappings": { + "shape": "first", + "local": "localRoot", + "remote": "remoteRoot" + } + } + } + } + }, + "launch": { + "process": { + "launch": { "kind": "packaged", "path": "bin/scalar-dap" } + }, + "transport": "stdio" + } + }, + { + "id": "joined", + "displayName": "Address Remote Debugger", + "adapterID": "joined-dap", + "requests": ["attach"], + "selectors": [{ "targetKinds": ["connection"] }], + "configurations": { + "attach": { + "arguments": { "request": "attach" }, + "targetBindings": { + "connection": { "address": "target" } + } + } + }, + "launch": { + "process": { + "launch": { "kind": "packaged", "path": "bin/joined-dap" } + }, + "transport": "stdio" + } + }, + { + "id": "keyed", + "displayName": "Keyed Source Map Remote Debugger", + "adapterID": "keyed-dap", + "requests": ["attach"], + "selectors": [{ "targetKinds": ["connection"] }], + "configurations": { + "attach": { + "targetBindings": { + "connection": { + "host": "connect.host", + "port": "connect.port", + "pathMappings": { + "shape": "dictionary", + "argument": "sourceMap", + "keyedBy": "remote" + } + } + } + } + }, + "launch": { + "process": { + "launch": { "kind": "packaged", "path": "bin/keyed-dap" } + }, + "transport": "stdio" + } + }, + { + "id": "paired", + "displayName": "Paired Source Map Remote Debugger", + "adapterID": "paired-dap", + "requests": ["attach"], + "selectors": [{ "targetKinds": ["connection"] }], + "configurations": { + "attach": { + "targetBindings": { + "connection": { + "host": "hostName", + "port": "port", + "pathMappings": { + "shape": "pairs", + "argument": "sourceMap", + "order": "remoteLocal" + } + } + } + } + }, + "launch": { + "process": { + "launch": { "kind": "packaged", "path": "bin/paired-dap" } + }, + "transport": "stdio" + } + } + ] +} diff --git a/contract/v1/fixtures/function-protocol.json b/contract/v1/fixtures/function-protocol.json index 98d1326..5ae2cc5 100644 --- a/contract/v1/fixtures/function-protocol.json +++ b/contract/v1/fixtures/function-protocol.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "protocol": "litheFunctions", - "offer": { "versions": [1, 2] }, + "offer": { "versions": [1, 2, 3] }, "negotiation": [ { "name": "no answer leaves the protocol off", @@ -41,9 +41,14 @@ "capabilities": { "experimental": { "litheFunctions": { "version": 2, "functions": [{ "id": "query.run" }] } } }, "expected": { "functions": ["query.run"], "version": 2 } }, + { + "name": "version 3 negotiates the same registry and adds contexts and capability discovery", + "capabilities": { "experimental": { "litheFunctions": { "version": 3, "functions": [{ "id": "query.run" }] } } }, + "expected": { "functions": ["query.run"], "version": 3 } + }, { "name": "a version the host never offered is not negotiated", - "capabilities": { "experimental": { "litheFunctions": { "version": 3 } } }, + "capabilities": { "experimental": { "litheFunctions": { "version": 4 } } }, "expected": { "functions": null } }, { @@ -346,12 +351,245 @@ "expected": { "rpcCode": -32602, "code": "plugin.host.invalidParams" } }, { - "name": "unknown params are invalid so a later version can add them", + "name": "version 2 answers an unknown field as invalid params", "version": 2, "hostServices": ["credentials"], "method": "lithe/host/credentials/get", "params": { "key": "connection.prod", "scope": "global" }, "expected": { "rpcCode": -32602, "code": "plugin.host.invalidParams" } + }, + { + "name": "from version 3 an unknown field is an option this host does not support", + "version": 3, + "hostServices": ["credentials"], + "method": "lithe/host/credentials/get", + "params": { "key": "connection.prod", "scope": "global" }, + "expected": { "rpcCode": -32018, "code": "plugin.host.unsupportedOption", "details": { "option": "scope" } } + }, + { + "name": "a context method names the option it does not support", + "version": 3, + "hostServices": ["workspace"], + "method": "lithe/host/workspace/getFolders", + "params": { "context": { "contextID": "w-3f2a", "generation": 1 }, "includeHidden": true }, + "expected": { "rpcCode": -32018, "code": "plugin.host.unsupportedOption", "details": { "option": "includeHidden" } } + }, + { + "name": "capability discovery needs version 3", + "version": 2, + "hostServices": ["credentials"], + "method": "lithe/host/getCapabilities", + "expected": { "rpcCode": -32601, "code": "plugin.host.unavailable" } + }, + { + "name": "Core answers capability discovery with only the session's grants", + "version": 3, + "hostServices": ["workspace"], + "method": "lithe/host/getCapabilities", + "params": {}, + "expected": { + "answer": { + "protocolVersion": 3, + "backendAuthority": "local", + "services": { + "commands": { "methods": [], "notifications": ["lithe/host/commands/didRun"] }, + "host": { "methods": ["lithe/host/getCapabilities"], "notifications": [] }, + "subscriptions": { + "methods": ["lithe/host/subscriptions/dispose"], + "notifications": ["lithe/host/subscriptions/notify"] + }, + "workspace": { + "methods": [ + "lithe/host/workspace/listContexts", + "lithe/host/workspace/getContext", + "lithe/host/workspace/getFolders" + ], + "notifications": ["lithe/host/workspace/didChangeContexts"] + } + }, + "limits": { + "pendingHostRequests": 16, + "functionResultBytes": 1048576, + "errorMessageBytes": 1024, + "errorDetailsBytes": 4096, + "notificationBytes": 1048576 + } + } + } + }, + { + "name": "capability discovery takes no options yet", + "version": 3, + "hostServices": [], + "method": "lithe/host/getCapabilities", + "params": { "services": ["credentials"] }, + "expected": { "rpcCode": -32018, "code": "plugin.host.unsupportedOption", "details": { "option": "services" } } + }, + { + "name": "listing workspace contexts is forwarded when workspace.metadata is granted", + "version": 3, + "hostServices": ["workspace"], + "method": "lithe/host/workspace/listContexts", + "expected": { "forward": "workspace" } + }, + { + "name": "workspace contexts need the workspace.metadata grant", + "version": 3, + "hostServices": ["credentials"], + "method": "lithe/host/workspace/listContexts", + "params": {}, + "expected": { "rpcCode": -32010, "code": "plugin.host.permissionDenied" } + }, + { + "name": "workspace contexts need version 3 even when granted", + "version": 2, + "hostServices": ["workspace"], + "method": "lithe/host/workspace/listContexts", + "params": {}, + "expected": { "rpcCode": -32601, "code": "plugin.host.unavailable" } + }, + { + "name": "a context's folders are asked for by context ID and generation", + "version": 3, + "hostServices": ["workspace"], + "method": "lithe/host/workspace/getFolders", + "params": { "context": { "contextID": "w-3f2a", "generation": 2 } }, + "expected": { "forward": "workspace" } + }, + { + "name": "one context is asked for by context ID and generation", + "version": 3, + "hostServices": ["workspace"], + "method": "lithe/host/workspace/getContext", + "params": { "context": { "contextID": "w-3f2a", "generation": 2 } }, + "expected": { "forward": "workspace" } + }, + { + "name": "a context without a generation is invalid", + "version": 3, + "hostServices": ["workspace"], + "method": "lithe/host/workspace/getFolders", + "params": { "context": { "contextID": "w-3f2a" } }, + "expected": { "rpcCode": -32602, "code": "plugin.host.invalidParams" } + }, + { + "name": "a generation is positive", + "version": 3, + "hostServices": ["workspace"], + "method": "lithe/host/workspace/getContext", + "params": { "context": { "contextID": "w-3f2a", "generation": 0 } }, + "expected": { "rpcCode": -32602, "code": "plugin.host.invalidParams" } + }, + { + "name": "a context ID outside the alphabet is invalid", + "version": 3, + "hostServices": ["workspace"], + "method": "lithe/host/workspace/getContext", + "params": { "context": { "contextID": "../w", "generation": 1 } }, + "expected": { "rpcCode": -32602, "code": "plugin.host.invalidParams" } + }, + { + "name": "a context reference has no other fields", + "version": 3, + "hostServices": ["workspace"], + "method": "lithe/host/workspace/getContext", + "params": { "context": { "contextID": "w-3f2a", "generation": 1, "path": "/tmp" } }, + "expected": { "rpcCode": -32602, "code": "plugin.host.invalidParams" } + }, + { + "name": "a subscription is disposed by its ID without any grant", + "version": 3, + "hostServices": [], + "method": "lithe/host/subscriptions/dispose", + "params": { "subscriptionID": "watch-1" }, + "expected": { "forward": "subscriptions" } + }, + { + "name": "disposing needs a subscription ID", + "version": 3, + "hostServices": [], + "method": "lithe/host/subscriptions/dispose", + "params": {}, + "expected": { "rpcCode": -32602, "code": "plugin.host.invalidParams" } + }, + { + "name": "credentials keep their version 2 rules in version 3", + "version": 3, + "hostServices": ["credentials"], + "method": "lithe/host/credentials/get", + "params": { "key": "connection.prod" }, + "expected": { "forward": "credentials" } + }, + { + "name": "a method no version defines is unavailable", + "version": 3, + "hostServices": ["credentials", "workspace"], + "method": "lithe/host/process/run", + "params": {}, + "expected": { "rpcCode": -32601, "code": "plugin.host.unavailable" } + } + ], + "invocationContexts": [ + { + "name": "a version 3 backend receives the call's context", + "version": 3, + "params": { "functionID": "query.run", "operationID": "7", "params": {} }, + "context": { "contextID": "w-3f2a", "generation": 2 }, + "expected": { + "params": { "functionID": "query.run", "operationID": "7", "params": {}, "context": { "contextID": "w-3f2a", "generation": 2 } } + } + }, + { + "name": "an older backend receives the call unchanged", + "version": 2, + "params": { "functionID": "query.run", "operationID": "7", "params": {} }, + "context": { "contextID": "w-3f2a", "generation": 2 }, + "expected": { "params": { "functionID": "query.run", "operationID": "7", "params": {} } } + }, + { + "name": "a call without a context is unchanged", + "version": 3, + "params": { "functionID": "query.run", "operationID": "7", "params": {} }, + "expected": { "params": { "functionID": "query.run", "operationID": "7", "params": {} } } + }, + { + "name": "a malformed context never leaves Core", + "version": 3, + "params": { "functionID": "query.run", "operationID": "7", "params": {} }, + "context": { "contextID": "w-3f2a", "generation": -1 }, + "expected": { "code": "plugin.function.invalidParams" } } + ], + "hostFailures": [ + { "name": "no code is a generic failure", "code": null, "expected": { "code": "plugin.host.failed", "rpcCode": -32603 } }, + { "name": "a closed or replaced context", "code": "plugin.host.contextExpired", "expected": { "code": "plugin.host.contextExpired", "rpcCode": -32011 } }, + { "name": "a remote authority without the operation", "code": "plugin.host.authorityUnsupported", "expected": { "code": "plugin.host.authorityUnsupported", "rpcCode": -32012 } }, + { "name": "a stale versioned edit", "code": "plugin.host.versionConflict", "expected": { "code": "plugin.host.versionConflict", "rpcCode": -32013 } }, + { "name": "a host limit", "code": "plugin.host.limitExceeded", "expected": { "code": "plugin.host.limitExceeded", "rpcCode": -32014 } }, + { "name": "the host stopped the request", "code": "plugin.host.cancelled", "expected": { "code": "plugin.host.cancelled", "rpcCode": -32800 } }, + { "name": "the host's deadline passed", "code": "plugin.host.timeout", "expected": { "code": "plugin.host.timeout", "rpcCode": -32015 } }, + { "name": "params the service itself refuses", "code": "plugin.host.invalidParams", "expected": { "code": "plugin.host.invalidParams", "rpcCode": -32602 } }, + { "name": "the user withheld consent", "code": "plugin.host.permissionDenied", "expected": { "code": "plugin.host.permissionDenied", "rpcCode": -32010 } }, + { "name": "a service the host turned off", "code": "plugin.host.unavailable", "expected": { "code": "plugin.host.unavailable", "rpcCode": -32601 } }, + { "name": "the target does not exist", "code": "plugin.host.notFound", "expected": { "code": "plugin.host.notFound", "rpcCode": -32016 } }, + { "name": "the target cannot be written", "code": "plugin.host.readOnly", "expected": { "code": "plugin.host.readOnly", "rpcCode": -32017 } }, + { "name": "an option this host does not support", "code": "plugin.host.unsupportedOption", "expected": { "code": "plugin.host.unsupportedOption", "rpcCode": -32018 } }, + { "name": "a state the operation needs is missing", "code": "plugin.host.preconditionFailed", "expected": { "code": "plugin.host.preconditionFailed", "rpcCode": -32019 } }, + { "name": "the host is busy and the request may be retried", "code": "plugin.host.busy", "expected": { "code": "plugin.host.busy", "rpcCode": -32020 } }, + { "name": "the plugin sent requests too often", "code": "plugin.host.rateLimited", "expected": { "code": "plugin.host.rateLimited", "rpcCode": -32021 } }, + { "name": "a host cannot borrow a function code", "code": "plugin.function.unknown", "expected": { "code": "plugin.host.failed", "rpcCode": -32603 } }, + { "name": "a host cannot report a backend exit to the backend", "code": "plugin.backend.exited", "expected": { "code": "plugin.host.failed", "rpcCode": -32603 } } + ], + "hostNotifications": [ + { "name": "changed contexts reach a workspace session", "version": 3, "hostServices": ["workspace"], "method": "lithe/host/workspace/didChangeContexts", "params": { "revision": 4 }, "expected": { "delivered": true } }, + { "name": "a version 2 backend is skipped", "version": 2, "hostServices": ["workspace"], "method": "lithe/host/workspace/didChangeContexts", "params": { "revision": 4 }, "expected": { "delivered": false } }, + { "name": "changed contexts need the workspace grant", "version": 3, "hostServices": [], "method": "lithe/host/workspace/didChangeContexts", "params": { "revision": 4 }, "expected": { "code": "plugin.host.permissionDenied" } }, + { "name": "a revision is positive", "version": 3, "hostServices": ["workspace"], "method": "lithe/host/workspace/didChangeContexts", "params": { "revision": 0 }, "expected": { "code": "plugin.host.invalidParams" } }, + { "name": "a subscription event", "version": 3, "hostServices": [], "method": "lithe/host/subscriptions/notify", "params": { "subscriptionID": "s1", "sequence": 1, "event": { "kind": "changed" } }, "expected": { "delivered": true } }, + { "name": "a subscription event is an object", "version": 3, "hostServices": [], "method": "lithe/host/subscriptions/notify", "params": { "subscriptionID": "s1", "sequence": 1, "event": "changed" }, "expected": { "code": "plugin.host.invalidParams" } }, + { "name": "a command run in a window", "version": 3, "hostServices": [], "method": "lithe/host/commands/didRun", "params": { "commandID": "db.refresh", "context": { "contextID": "w1", "generation": 2 } }, "expected": { "delivered": true } }, + { "name": "a command without a window", "version": 3, "hostServices": [], "method": "lithe/host/commands/didRun", "params": { "commandID": "db.refresh" }, "expected": { "delivered": true } }, + { "name": "extra command params are refused", "version": 3, "hostServices": [], "method": "lithe/host/commands/didRun", "params": { "commandID": "db.refresh", "path": "/etc" }, "expected": { "code": "plugin.host.invalidParams" } }, + { "name": "the host cannot call the backend's own protocol", "version": 3, "hostServices": [], "method": "lithe/functions/invoke", "params": {}, "expected": { "code": "plugin.host.unavailable" } } ] } diff --git a/contract/v1/fixtures/invalid-manifests.json b/contract/v1/fixtures/invalid-manifests.json index 79b27a9..dd82092 100644 --- a/contract/v1/fixtures/invalid-manifests.json +++ b/contract/v1/fixtures/invalid-manifests.json @@ -969,7 +969,7 @@ { "name": "backend capability version this Core does not know", "base": "backend.plugin.json", - "edits": [{ "op": "set", "path": "/requires/backend", "value": 2 }], + "edits": [{ "op": "set", "path": "/requires/backend", "value": 99 }], "expectedCode": "plugin.api.unsupported" }, { @@ -1013,6 +1013,42 @@ "base": "backend.plugin.json", "edits": [{ "op": "remove", "path": "/backend" }, { "op": "set", "path": "/requires", "value": {"ui": 2} }, { "op": "set", "path": "/permissions", "value": ["credentials.store", "ui.contribute", "ui.web"] }], "expectedCode": "plugin.manifest.invalid" + }, + { + "name": "backend 2 still needs a way to start", + "base": "backend-workspace.plugin.json", + "edits": [{ "op": "set", "path": "/activationEvents", "value": [] }], + "expectedCode": "plugin.manifest.invalid" + }, + { + "name": "onWorkspaceOpen needs backend 2", + "base": "backend.plugin.json", + "edits": [{ "op": "set", "path": "/activationEvents", "value": ["onView:dev.lithe.fixture.backend.window", "onWorkspaceOpen"] }], + "expectedCode": "plugin.activation.invalid" + }, + { + "name": "workspace.metadata needs backend 2", + "base": "backend.plugin.json", + "edits": [{ "op": "set", "path": "/permissions", "value": ["backend.launch", "credentials.store", "ui.contribute", "ui.web", "workspace.metadata"] }], + "expectedCode": "plugin.manifest.invalid" + }, + { + "name": "workspace.metadata without a backend", + "base": "backend.plugin.json", + "edits": [{ "op": "remove", "path": "/backend" }, { "op": "set", "path": "/requires", "value": {"ui": 2} }, { "op": "set", "path": "/permissions", "value": ["ui.contribute", "ui.web", "workspace.metadata"] }], + "expectedCode": "plugin.manifest.invalid" + }, + { + "name": "backend 2 does not start on every launch", + "base": "backend-workspace.plugin.json", + "edits": [{ "op": "set", "path": "/activationEvents", "value": ["onStartupFinished", "onWorkspaceOpen"] }], + "expectedCode": "plugin.activation.invalid" + }, + { + "name": "backend 2 is still one process for every workspace", + "base": "backend-workspace.plugin.json", + "edits": [{ "op": "set", "path": "/backend/arguments", "value": ["--cache", "${workspaceStateDir}"] }], + "expectedCode": "plugin.manifest.invalid" } ] } diff --git a/contract/v1/fixtures/publisher-bundled.plugin.json b/contract/v1/fixtures/publisher-bundled.plugin.json new file mode 100644 index 0000000..220924b --- /dev/null +++ b/contract/v1/fixtures/publisher-bundled.plugin.json @@ -0,0 +1,22 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.fixture.publisher-bundled", + "displayName": "Publisher and bundled trust fixture", + "version": "1.0.0", + "pluginAPIVersion": 3, + "requires": {"languages": 1, "languageServers": 1, "serverProcess": 1, "packageTrust": 1}, + "vendor": { + "id": "dev.lithe", + "displayName": "Lithe", + "signatureRequirement": "publisherPackage", + "bundledSignatureRequirement": "sameTeamAsHost" + }, + "permissions": ["languageServer.launch"], + "activationEvents": ["onLanguage:fixture"], + "languages": [{"id": "fixture", "displayName": "Fixture", "fileExtensions": ["fixture"]}], + "languageServers": [{ + "languageID": "fixture", + "launch": {"kind": "runtime", "tool": "java", "packagedRuntime": "runtime/${arch}/bin/java", "entry": "server.jar"}, + "arguments": [] + }] +} diff --git a/contract/v1/plugin-manifest.schema.json b/contract/v1/plugin-manifest.schema.json index 26b7512..9612693 100644 --- a/contract/v1/plugin-manifest.schema.json +++ b/contract/v1/plugin-manifest.schema.json @@ -27,14 +27,16 @@ "minProperties": 1, "additionalProperties": false, "properties": { - "backend": { "description": "Required exactly when backend is present: the plugin's own process serving its web pages.", "enum": [1] }, - "buildProviders": { "description": "Required exactly when any buildSystems[].provider is present, including own-process and shared language-server providers. Declares host support for Provider orchestration, independent of declarative buildSystems and serverProcess launch support.", "enum": [1] }, + "backend": { "description": "Required exactly when backend is present: the plugin's own process. Version 1 serves only its web pages. Version 2 may also start on onWorkspaceOpen or a plugin command, receives workspace contexts through plugin-function protocol 3, and may be granted workspace.metadata.", "enum": [1, 2] }, + "buildModelInputs": {"description": "Optional public model sharing between already enabled workspace plugins; no installation, private function, or process authority. Required exactly when modelSharing or modelInputs is used. Consumers require buildProviders 6. Version 1 producers cannot also consume inputs.", "enum": [1]}, + "buildProviders": { "description": "Required exactly when any buildSystems[].provider is present, including own-process and shared language-server providers. Declares host support for Provider orchestration, independent of declarative buildSystems and serverProcess launch support; version 2 adds task execution, version 3 test execution/discovery, version 4 application run preparation, and version 5 native debug preparation; version 6 optional read-only model inputs.", "enum": [1, 2, 3, 4, 5, 6] }, "buildSystems": { "description": "Declarative build detection, models, and command plans; does not imply buildProviders support.", "enum": [1, 2] }, - "debugAdapters": { "description": "Required exactly when debugAdapters is non-empty. The DAP session itself is negotiated by the adapter in initialize.", "enum": [1] }, + "debugAdapters": { "description": "Required exactly when debugAdapters is non-empty. Version 2 adds bounded target argument and environment bindings. The DAP session itself is negotiated by the adapter in initialize.", "enum": [1, 2] }, "executableResolution": { "enum": [1], "description": "Shared executable candidate ordering and strict selection." }, "executionProviders": { "enum": [1] }, "languageServers": { "enum": [1] }, "languages": { "enum": [1] }, + "packageTrust": { "description": "Required exactly when vendor.bundledSignatureRequirement is present.", "enum": [1] }, "projectGenerators": { "description": "Entries of the New Project window: fields, requirements, and steps.", "enum": [1] }, "serverProcess": { "description": "The launch fields of language servers, of build providers that start their own process, of VCS providers, of debug adapters, and of the plugin backend. Version 2 adds runSelectedRuntime and is required exactly when a debug adapter uses it (pluginAPIVersion 4).", "enum": [1, 2] }, "settings": { "enum": [1] }, @@ -49,16 +51,24 @@ "properties": { "id": { "type": "string", "minLength": 1 }, "displayName": { "type": "string", "minLength": 1 }, - "signatureRequirement": { "enum": ["sameTeamAsHost", "publisherPackage", "none"] } + "signatureRequirement": { "enum": ["sameTeamAsHost", "publisherPackage", "none"] }, + "bundledSignatureRequirement": { + "description": "packageTrust 1: host-pinned official packages may trust the host's sealed bundled copy; all other origins require publisherPackage.", + "const": "sameTeamAsHost" + } } }, "permissions": { "type": "array", - "items": { "enum": ["backend.launch", "credentials.store", "debug.attach", "debug.launch", "languageServer.launch", "process.build", "process.run", "process.test", "process.vcs", "project.generate", "ui.contribute", "ui.web"] } + "items": { "enum": ["backend.launch", "credentials.store", "debug.attach", "debug.launch", "languageServer.launch", "process.build", "process.run", "process.test", "process.vcs", "project.generate", "ui.contribute", "ui.web", "workspace.metadata"] } }, "activationEvents": { "type": "array", - "items": { "type": "string", "pattern": "^(onLanguage|onRun|onTest|onView|onCommand|onBuildSystem|onVcsRoot|onDebug):[a-z0-9.+_-]+$" } + "items": { + "type": "string", + "description": "onWorkspaceOpen names no ID and needs requires.backend 2; it starts the backend once a workspace is open.", + "pattern": "^((onLanguage|onRun|onTest|onView|onCommand|onBuildSystem|onVcsRoot|onDebug):[a-z0-9.+_-]+|onWorkspaceOpen)$" + } }, "languages": { "type": "array", @@ -80,7 +90,7 @@ "items": { "$ref": "#/$defs/buildSystem" } }, "backend": { - "description": "pluginAPIVersion 3, requires.backend 1: the plugin's own LSP base-protocol process. The host starts it on the first plugin-function call from one of the plugin's web pages, keeps one process per plugin independent of any workspace, and ends it when the plugin is disabled, updated, or uninstalled. Requires backend.launch and a web tool window; credentials.store lets it use the host's private credential store.", + "description": "pluginAPIVersion 3, requires.backend 1 or 2: the plugin's own LSP base-protocol process. The host keeps one process per plugin on the machine that runs Lithe, independent of any workspace, and ends it when the plugin is disabled, updated, or uninstalled. Version 1 starts on the first plugin-function call from one of the plugin's web pages and requires a web tool window. Version 2 also starts on onWorkspaceOpen or one of the plugin's commands and requires one of the three. Requires backend.launch; credentials.store lets it use the host's private credential store; workspace.metadata (version 2) lets it list workspace contexts.", "$ref": "#/$defs/backend" }, "vcsProviders": { @@ -90,7 +100,7 @@ "items": { "$ref": "#/$defs/vcsProvider" } }, "debugAdapters": { - "description": "pluginAPIVersion 4, requires.debugAdapters 1: debug adapters the plugin implements, each started as its own DAP process. Requires debug.launch (and debug.attach when an adapter serves attach) and one onDebug: activation event per adapter.", + "description": "pluginAPIVersion 4, requires.debugAdapters 1: debug adapters the plugin implements, each started as its own DAP process, or hosted by the plugin's own language server (transport tcpLanguageServer). Requires debug.launch (and debug.attach when an adapter serves attach) and one onDebug: activation event per adapter.", "type": "array", "maxItems": 16, "items": { "$ref": "#/$defs/debugAdapter" } @@ -235,6 +245,92 @@ "minLength": 1, "pattern": "^(?!/)(?!.*(?:^|/)[.]{1,2}(?:/|$))[^\\\\:\\u0000]+$" }, + "debugArgumentName": { + "description": "One DAP argument name or object key.", + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_-]{0,63}$" + }, + "debugArgumentPath": { + "description": "Dotted path to a DAP argument, up to four names, for example `hostName` or `connect.host`. Objects on the way are created; the path must not overlap another binding or an argument the configuration sets itself.", + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_-]{0,63}(\\.[A-Za-z_][A-Za-z0-9_-]{0,63}){0,3}$" + }, + "debugConnectionBinding": { + "type": "object", + "additionalProperties": false, + "properties": { + "host": { + "description": "Receives `target.host`, a string. Declared together with `port`.", + "$ref": "#/$defs/debugArgumentPath" + }, + "port": { + "description": "Receives `target.port`, an integer 1-65535. Declared together with `host`.", + "$ref": "#/$defs/debugArgumentPath" + }, + "address": { + "description": "Receives `host:port` as one string; an IPv6 host is written in brackets.", + "$ref": "#/$defs/debugArgumentPath" + }, + "pathMappings": { + "description": "How local and remote source roots reach the adapter. Omit when the adapter takes none; the host then does not offer the choice.", + "oneOf": [ + { + "description": "A list of objects at `argument`, each with the local root under `localKey` and the remote root under `remoteKey`.", + "type": "object", + "additionalProperties": false, + "required": ["shape", "argument", "localKey", "remoteKey"], + "properties": { + "shape": { "const": "objects" }, + "argument": { "$ref": "#/$defs/debugArgumentPath" }, + "localKey": { "$ref": "#/$defs/debugArgumentName" }, + "remoteKey": { "$ref": "#/$defs/debugArgumentName" } + } + }, + { + "description": "The first mapping only, as two strings at `local` and `remote`.", + "type": "object", + "additionalProperties": false, + "required": ["shape", "local", "remote"], + "properties": { + "shape": { "const": "first" }, + "local": { "$ref": "#/$defs/debugArgumentPath" }, + "remote": { "$ref": "#/$defs/debugArgumentPath" } + } + }, + { + "description": "An object at `argument` with one root as the key and the other as the value, keyed by the `keyedBy` side. A target with the same root twice on that side is refused.", + "type": "object", + "additionalProperties": false, + "required": ["shape", "argument", "keyedBy"], + "properties": { + "shape": { "const": "dictionary" }, + "argument": { "$ref": "#/$defs/debugArgumentPath" }, + "keyedBy": { "enum": ["local", "remote"] } + } + }, + { + "description": "A list of two-element arrays at `argument`, the roots in the given `order`.", + "type": "object", + "additionalProperties": false, + "required": ["shape", "argument", "order"], + "properties": { + "shape": { "const": "pairs" }, + "argument": { "$ref": "#/$defs/debugArgumentPath" }, + "order": { "enum": ["remoteLocal", "localRemote"] } + } + } + ] + } + }, + "dependentRequired": { + "host": ["port"], + "port": ["host"] + }, + "anyOf": [ + { "required": ["host"] }, + { "required": ["address"] } + ] + }, "debugServerProcessV1": { "$ref": "#/$defs/serverProcess", "description": "Reuse existing launch fields and validation. LSP initializationOptions have no meaning for a DAP transport and are excluded.", @@ -297,7 +393,6 @@ "debugLaunch": { "type": "object", "required": [ - "process", "transport" ], "properties": { @@ -307,6 +402,9 @@ }, "oneOf": [ { + "required": [ + "process" + ], "properties": { "transport": { "const": "stdio" @@ -315,6 +413,7 @@ }, { "required": [ + "process", "endpoint" ], "properties": { @@ -355,6 +454,7 @@ }, { "required": [ + "process", "endpoint" ], "properties": { @@ -377,6 +477,44 @@ } } } + }, + { + "required": [ + "endpoint" + ], + "not": { + "required": [ + "process" + ] + }, + "properties": { + "transport": { + "const": "tcpLanguageServer" + }, + "endpoint": { + "description": "The adapter runs inside a language server this plugin declares in languageServers, which has no adapter process of its own. The host starts that server, sends workspace/executeCommand with command, and connects to the loopback port the server answers with (an integer or a numeric string).", + "type": "object", + "additionalProperties": false, + "required": [ + "languageID", + "command" + ], + "properties": { + "languageID": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z0-9+_-]+$" + }, + "command": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._:-]+$" + } + } + } + } } ], "unevaluatedProperties": false @@ -440,6 +578,12 @@ }, "cwd": { "const": "target.workingDirectory" + }, + "args": { "const": "target.arguments" }, + "env": { "const": "target.environment" }, + "connection": { + "description": "Attach only. Where the remote target's host and port go. Unlike the other bindings, each value is the destination argument path, because adapters name and nest these differently.", + "$ref": "#/$defs/debugConnectionBinding" } } }, @@ -1035,7 +1179,7 @@ ] }, "backend": { - "description": "Launch fields of the plugin backend. ${workspaceStateDir} is refused because the backend is not bound to a workspace.", + "description": "Launch fields of the plugin backend. ${workspaceStateDir} is refused because one backend process serves every workspace; version 2 receives each workspace's state directory in its context instead.", "type": "object", "$ref": "#/$defs/serverProcess", "unevaluatedProperties": false @@ -1942,6 +2086,8 @@ "invocation" ], "properties": { + "modelSharing": {"type": "object", "additionalProperties": false, "properties": {"options": {"type": "array", "maxItems": 32, "uniqueItems": true, "items": {"type": "string"}}}, "description": "Opt into publishing normalized projects, targets and content roots, plus only these explicitly shared ordinary build option values. Never tasks, run plans, settings or optionValues."}, + "modelInputs": {"type": "array", "minItems": 1, "maxItems": 8, "uniqueItems": true, "items": {"type": "object", "additionalProperties": false, "required": ["pluginID", "buildSystemID"], "properties": {"pluginID": {"type": "string"}, "buildSystemID": {"type": "string"}}}, "description": "Exact optional producer slots, sorted by Core. Missing or disabled producers are unavailable and never installed or activated automatically."}, "id": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$", @@ -2311,6 +2457,10 @@ "maxLength": 64 } }, + "source": { + "description": "Omitted expands argv in Core; provider sends lithe/build/execute. Provider tasks require buildProviders 2, empty arguments, and task execution.", + "enum": ["provider"] + }, "requiresUserArguments": { "type": "boolean" }, @@ -2341,6 +2491,15 @@ "id" ], "properties": { + "debug": { + "description": "Native executable debug preparation (buildProviders 5); same-package debugAdapters 2 launch adapter with typed program/cwd/args/env bindings.", + "type": "object", "additionalProperties": false, "required": ["adapter"], + "properties": { "adapter": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$", "maxLength": 64 } } + }, + "source": { + "description": "Provider-owned application preparation (buildProviders 4), fromTargets only, without host command templates.", + "enum": ["provider"] + }, "id": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$", @@ -2439,6 +2598,14 @@ "arguments" ], "properties": { + "discovery": { + "type": "boolean", + "description": "Opt in to lithe/build/discoverTests; requires provider test source and suite/case filters." + }, + "source": { + "description": "Omitted expands argv; provider sends lithe/build/test, requires buildProviders 3, empty arguments, and plugin-owned report decoding.", + "enum": ["provider"] + }, "scopes": { "$ref": "#/$defs/buildScopes" }, diff --git a/guides/plugin-backend-workspace.md b/guides/plugin-backend-workspace.md new file mode 100644 index 0000000..ec4112a --- /dev/null +++ b/guides/plugin-backend-workspace.md @@ -0,0 +1,193 @@ +# 插件后端:工作区上下文与宿主服务(backend 2、协议 3,开发中) + +这篇指南面向写插件后端(`backend`)的作者。目标读者已经能让自己的网页调用后端函数(`backend` 1), +现在想让后端在没有页面时也能启动、知道用户打开了哪些工作区、响应命令,并按需调用宿主能力。 + +字段与错误码的权威定义在[契约](../contract/v1/README.md#backend-2-workspace-contexts),这里只讲怎么用、为什么这样设计。 + +> **宿主支持。** `requires.backend: 2` 和插件函数协议 3 还在 Lithe `preview` 分支上开发,尚未进入稳定版。 +> 稳定版宿主只提供 `backend: [1]` 时会以 `plugin.api.unsupported` 拒绝 `backend` 2 的插件,所以老版本 Lithe +> 不会启动一个期待工作区上下文的进程。官方 SDK 目前实现到协议 2,用协议 3 需要自己处理下文的请求和通知。 + +## 1. 三个版本号各管什么 + +| 写在哪里 | 值 | 决定什么 | +| --- | --- | --- | +| `requires.backend` | `1` 或 `2` | 后端进程**什么时候启动**、能不能声明 `workspace.metadata` 和 `onWorkspaceOpen` | +| `initialize` 应答的 `litheFunctions.version` | `1`、`2` 或 `3` | 进程启动后**线路协议**的规则:`2` 起有宿主服务(凭据),`3` 起有能力发现、工作区上下文、宿主通知和带类型的失败码 | +| `getCapabilities.services` | 运行时查询结果 | 本次会话**实际能调用**哪些宿主方法、会收到哪些通知 | + +清单只做声明,宿主读清单决定装不装、什么时候启动;进程起来以后能做什么,靠协商和 `getCapabilities` 判断。 + +## 2. 最小清单 + +```json +{ + "schemaVersion": 2, + "id": "io.github.<你的用户名>.orders", + "displayName": "Orders", + "version": "1.0.0", + "pluginAPIVersion": 3, + "requires": { "backend": 2, "serverProcess": 1 }, + "vendor": { "id": "io.github.<你的用户名>", "displayName": "<你的名字>", "signatureRequirement": "none" }, + "permissions": ["backend.launch", "workspace.metadata"], + "activationEvents": ["onWorkspaceOpen"], + "languages": [], + "backend": { + "launch": { "kind": "packaged", "path": "bin/backend" }, + "arguments": ["--state", "${stateDir}"] + } +} +``` + +- `backend` 2 至少要有一种启动方式:网页工具窗口(首次页面调用,同 `backend` 1)、`ui.commands[]` 里的命令 + (`onCommand:` 也会启动后端),或 `onWorkspaceOpen`。一种都没有是 `plugin.manifest.invalid`。 +- `workspace.metadata` 只让后端知道打开的工作区名字、根目录、ID、来源(本地或远程)和每个工作区的私有状态目录, + 不读任何文件内容。不需要这些信息就不要声明,安装审查会把它列给用户。 +- 仍然拒绝 `${workspaceStateDir}`:一个插件只有一个后端进程,被所有窗口共享,工作区不是进程状态。 + 每个工作区的状态目录从 `getContext` 的 `stateDirectory` 取。 + +## 3. 启动时机 + +- `onWorkspaceOpen` 不会拖慢打开工作区。宿主在窗口显示之后逐个启动这些后端;`initialize` 超时的后端会被停止, + 并像其他启动失败一样报告,不会挡住工作区。 +- 后端在 Lithe 所在的机器上运行,远程工作区也一样。 +- 激活只看插件是否已安装、启用、通过审查。Lithe 目前没有工作区信任状态;会运行或修改东西的操作,由宿主逐次询问用户。 + +## 4. 协商协议 3 + +宿主在 `initialize` 里提供 `capabilities.experimental.litheFunctions: { "versions": [1, 2, 3] }`。后端从中选自己实现的最高版本作答: + +```json +"experimental": { "litheFunctions": { "version": 3, "functions": [{ "id": "orders.refresh" }] } } +``` + +只回答你真正实现了的版本。回答 `2` 的后端继续按协议 2 工作,永远看不到协议 3 新增的通知、上下文字段和失败码。 + +## 5. 先问宿主能做什么 + +协商到 3 以后,第一件事是调 `lithe/host/getCapabilities`(不带参数或 `{}`): + +```json +{ + "protocolVersion": 3, + "backendAuthority": "local", + "services": { + "commands": { "methods": [], "notifications": ["lithe/host/commands/didRun"] }, + "host": { "methods": ["lithe/host/getCapabilities"], "notifications": [] }, + "subscriptions": { + "methods": ["lithe/host/subscriptions/dispose"], + "notifications": ["lithe/host/subscriptions/notify"] + }, + "workspace": { + "methods": ["lithe/host/workspace/listContexts", "lithe/host/workspace/getContext", "lithe/host/workspace/getFolders"], + "notifications": ["lithe/host/workspace/didChangeContexts"] + } + }, + "limits": { + "pendingHostRequests": 16, + "functionResultBytes": 1048576, + "errorMessageBytes": 1024, + "errorDetailsBytes": 4096, + "notificationBytes": 1048576 + } +} +``` + +上例是声明了 `workspace.metadata` 的会话。`host`、`subscriptions`、`commands` 每个插件会话都有;`services` 只列本次会话可用的方法, +没声明的权限、宿主没有实现的服务都不会出现。数值以实际返回为准。**按 `services` 判断能不能调用,不要按协议版本号推断**,见第 8 节。 + +## 6. 工作区上下文 + +一个上下文就是“某个窗口里打开的某个工作区”: + +```json +{ + "contextID": "w-3f2a9c", + "generation": 2, + "name": "orders", + "authority": { "kind": "local" }, + "folders": [{ "rootID": "r0c1d2e3f4a5b6c7d", "name": "orders", "path": "/Users/me/orders" }], + "stateDirectory": "/Users/me/Library/Application Support/Lithe/PluginState//backend/workspaces/" +} +``` + +| 字段 | 怎么用 | +| --- | --- | +| `contextID` | 窗口显示这个工作区期间不变。窗口切到别的工作区时,旧上下文关闭、新上下文换新 ID,所以按 ID 缓存的状态不会串到别的工作区 | +| `generation` | 同一工作区的根目录或来源变化时加一。请求里带旧的 `generation`,或上下文已关闭,会得到 `plugin.host.contextExpired`,重新 `listContexts` 即可 | +| `rootID` | 同一位置跨重启稳定,不暴露路径;按插件加密派生,两个插件对同一目录看到的 ID 不同 | +| `path` | 只有本地工作区有。远程工作区(`authority.kind` 为 `remote`)没有 `path`,宿主在本地为它保留的目录只是空占位 | +| `stateDirectory` | 插件与工作区私有,在插件包和应用包之外。只有 `getContext` 返回它并在那时创建;`listContexts` 不创建目录 | + +常用请求: + +| 请求 | 参数 | 结果 | +| --- | --- | --- | +| `lithe/host/workspace/listContexts` | 无 | `{ revision, contexts }`,条目不含 `stateDirectory` | +| `lithe/host/workspace/getContext` | `{ context: { contextID, generation } }` | 完整上下文 | +| `lithe/host/workspace/getFolders` | `{ context: { contextID, generation } }` | `{ folders }` | + +页面调用后端函数时,宿主会按页面所在窗口填好上下文:协议 3 的后端在 `lithe/function/invoke` 的 +`params.context` 里收到 `{ contextID, generation }`,直接拿它调 `getContext`。 + +## 7. 宿主通知 + +宿主用 JSON-RPC 通知告诉后端发生了变化,后端不应答: + +| 通知 | 参数 | 什么时候来 | +| --- | --- | --- | +| `lithe/host/workspace/didChangeContexts` | `{ revision }` | 有上下文打开、关闭或变化;收到后重新 `listContexts` | +| `lithe/host/commands/didRun` | `{ commandID, context? }` | 用户运行了插件自己的命令。后端没在运行且声明了 `onCommand:` 时,宿主先启动它,就绪后再送达 | +| `lithe/host/subscriptions/notify` | `{ subscriptionID, sequence, event }` | 某个订阅的事件。`sequence` 每个订阅从 1 起逐一递增,可以据此发现丢失;用 `lithe/host/subscriptions/dispose` 取消订阅 | + +后端晚启动时不会补发之前的 `didChangeContexts`,启动后自己调一次 `listContexts`。 + +## 8. 宿主失败与错误码 + +协议 3 的宿主失败带类型:JSON-RPC 号码与 `error.data.code` 一一对应,号码永不改义,部分失败还带 `error.data.details`。 + +| `error.data.code` | 号码 | 后端通常怎么做 | +| --- | --- | --- | +| `plugin.host.unavailable` | `-32601` | 宿主没有这个方法;先查 `getCapabilities` | +| `plugin.host.invalidParams` | `-32602` | 参数格式错误,修代码 | +| `plugin.host.failed` | `-32603` | 其他失败,报告给用户 | +| `plugin.host.cancelled` | `-32800` | 你自己用 `$/cancelRequest` 取消了它 | +| `plugin.host.permissionDenied` | `-32010` | 没声明权限,或用户拒绝 | +| `plugin.host.contextExpired` | `-32011` | 重新 `listContexts` 后再试 | +| `plugin.host.authorityUnsupported` | `-32012` | 该服务不支持远程工作区,宿主不会改在本地执行 | +| `plugin.host.versionConflict` | `-32013` | 资源版本已变;`details.currentVersion` 给出当前版本 | +| `plugin.host.limitExceeded` | `-32014` | 超出限制,例如同时挂起的请求超过 `limits.pendingHostRequests` | +| `plugin.host.timeout` | `-32015` | 宿主超时 | +| `plugin.host.notFound` | `-32016` | 资源不存在 | +| `plugin.host.readOnly` | `-32017` | 资源或工作区不可写 | +| `plugin.host.unsupportedOption` | `-32018` | 宿主不认识请求里的某个字段;`details.option` 指明是哪个,去掉它重试 | +| `plugin.host.preconditionFailed` | `-32019` | 所需状态不成立,例如用户没有确认 | +| `plugin.host.busy` | `-32020` | 资源正被占用,稍后重试 | +| `plugin.host.rateLimited` | `-32021` | 请求太频繁;有 `details.retryAfterMilliseconds` 时按它等待 | + +`-32000` 到 `-32009` 留给后端自己的失败,宿主号码从 `-32010` 起。 + +## 9. 协议 3 只增不改:后端要遵守的四条 + +从协议 3 起,宿主再开放新能力(新服务及其方法和通知、可选参数、结果字段、错误码)都在协议 3 内新增, +不再升协议版本;只有会让已有后端读错的改动(删除或改名、改字段含义或类型、把可选参数改成必填、改错误码号码) +才会出协议 4。这样插件只要实现一次协议 3,就能在今后所有协议 3 宿主上工作,前提是遵守下面四条: + +1. **只调用 `getCapabilities.services` 列出的方法。** 其他方法会得到 `-32601` `plugin.host.unavailable`。 +2. **忽略不认识的字段和通知。** 结果、通知、`details`、`limits` 里多出来的字段,以及不认识的 `lithe/host/*` 通知,都直接忽略。 +3. **新选项不被支持时会得到明确回答。** 请求里带了宿主不认识的字段,Core 在宿主看到请求之前回 + `-32018` `plugin.host.unsupportedOption`,`details: { "option": "<字段名>" }`。宿主不会悄悄忽略它, + 你可以去掉这个选项重试,或告诉用户需要更新 Lithe。 +4. **不认识的号码按宿主失败处理。** 新宿主可能带来你没见过的错误码;认识 `error.data.code` 时按它分支,否则当作一般失败。 + +新服务总是配一个新的清单权限。旧宿主不认识这个权限时,会在安装时以 `plugin.permission.unknown` 拒绝整个插件包, +权限不会被悄悄授予或忽略。 + +## 10. 验证 + +- 用契约里的 [`fixtures/function-protocol.json`](../contract/v1/fixtures/function-protocol.json) 核对你的实现: + `hostRequests` 有不认识的选项等请求用例,`invocationContexts` 有调用上下文,`hostFailures` 有失败码映射, + `hostNotifications` 有通知用例。 +- 清单示例是 [`fixtures/backend-workspace.plugin.json`](../contract/v1/fixtures/backend-workspace.plugin.json); + `fixtures/invalid-manifests.json` 里的 backend 用例是会被拒绝的写法。