diff --git a/contract.lock.json b/contract.lock.json index ae52f31..fabf8b6 100644 --- a/contract.lock.json +++ b/contract.lock.json @@ -3,12 +3,12 @@ "source": { "repository": "https://github.com/1lck/Lithe-IDEA", "directory": "shared/contracts/plugins/v1", - "revision": "d37e4bf15608d278d2771495575c264ce5e00532" + "revision": "18345cc96a89e1af3b04b48aacd9af89350202cb" }, "files": [ { "path": "README.md", - "sha256": "2e2a80f1d3b2be4ecbd91257affff45c25397d85f7fe77d102669ba8ebd9ab0e" + "sha256": "21fc28bb9c7299959adbe0223c348ff0b1f0764f16fa87e21bbebf50256a49e2" }, { "path": "fixtures/build-command-output-file.plugin.json", @@ -102,6 +102,10 @@ "path": "fixtures/settings-v2.plugin.json", "sha256": "83d4c7faf7bfbb9c474639bfc0ef42dae8cd7d370d388f8527589f2afed12981" }, + { + "path": "fixtures/vcs-provider.plugin.json", + "sha256": "7d0c85b6e0531a28e877050c2da15470d0bfe5ccd4c98215e7824d4445d977a9" + }, { "path": "fixtures/web-panel.plugin.json", "sha256": "1ccaf5bb58e0296b8d244e6951d62c1f8e9e35e2e2bd467d4368fc31d7f42fd8" @@ -112,7 +116,7 @@ }, { "path": "plugin-manifest.schema.json", - "sha256": "50af17e6b69e20d4691d6896ec2b9275fb567852760537c5a2914dad558b27b3" + "sha256": "27b01d48b99377eeaf473b1b670c4c19dbf86df81c6623205401226d13d351c8" } ] } diff --git a/contract/v1/README.md b/contract/v1/README.md index 981f4c0..5405eb9 100644 --- a/contract/v1/README.md +++ b/contract/v1/README.md @@ -197,8 +197,8 @@ contains a machine path, and the host decides where state lives. | `${setting:}` | Current value of a declared [plugin setting](#plugin-settings-api-2). Not available in `validationArguments`, and never inside a package path. | `plugins.declarative.languageServerLaunchPlan` expands a server's launch. The -host selects the process with exactly one of `languageID` (a language server) -and `buildSystemID` (a build provider), passes `{pluginDirectory, +host selects the process with exactly one of `languageID` (a language server), +`buildSystemID` (a build provider), and `vcsProviderID` (a VCS provider), passes `{pluginDirectory, stateDirectory, workspaceStateDirectory, architecture, settings?}`, and receives: @@ -213,9 +213,11 @@ receives: | `requiredPackagePaths` | Sorted package-relative paths the launch points at. The host verifies each is inside the package and not a symbolic link before starting the server. | | `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. | -A language without a server, an undeclared build system, and a build system -without a provider are `plugin.launch.unavailable`. Core never creates or +A language without a server, an undeclared build system, a build system +without a provider, and an undeclared VCS provider are +`plugin.launch.unavailable`. Core never creates or reads any of these paths. The command also serves API 1 manifests, whose arguments are returned unchanged. @@ -368,7 +370,7 @@ files sorted by path, or an empty list when no project marker is present. | --- | --- | | `1` | Languages, `packaged` and `systemCommand` language servers, `node` and `go` runtime dependencies, run and test providers. | | `2` | `runtime` launches with a packaged or discovered runtime, host integrations, the `java` runtime, launch templates, `initializationOptions`, and plugin settings. | -| `3` | Capability versions (`requires`), and `buildSystems`: declarative build tools with detection, options, a project model, tasks, run entries, tests, diagnostics, dependencies, and sync. | +| `3` | Capability versions (`requires`), `buildSystems`: declarative build tools with detection, options, a project model, tasks, run entries, tests, diagnostics, dependencies, and sync, and [`vcsProviders`](#api-level-3-vcs-providers). | A level 2 feature in a level 1 manifest is refused with `plugin.manifest.invalid` (`plugin.runtime.unsupported` for the `java` tool), @@ -392,12 +394,13 @@ hosts. Level 3 versions each section on its own. A level 3 manifest lists in | --- | --- | --- | | `languages` | `languages[]` | `1` | | `languageServers` | `languageServers[]` apart from their launch fields | `1`, `2` (adds [`workspaceConfiguration`](#workspace-configuration-languageservers-2)) | -| `serverProcess` | The launch fields of language servers and of build providers with a process of their own: `launch`, `arguments`, `validationArguments`, `runtimeDependencies`, `initializationOptions` | `1` | +| `serverProcess` | The launch fields of language servers, of build providers with a process of their own, and of VCS providers: `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` | +| `vcsProviders` | `vcsProviders[]` | `1` | Version 1 of every capability means what its section means at level 2 (runtime launches, launch templates, `initializationOptions`, and so on), so @@ -418,7 +421,8 @@ moving a level 2 manifest to level 3 only adds `requires`. `buildSystems[].provider` requires `buildProviders: 1`, whether it starts its own process or shares a language server. A template-only build plugin must omit `buildProviders`. Launch support alone does not implement Provider - negotiation, requests, and lifecycle. + negotiation, requests, and lifecycle. Every VCS provider starts its own + process, so `vcsProviders` always comes with `serverProcess`. - The base fields (`schemaVersion`, `id`, `displayName`, `version`, `vendor`, `permissions`, `activationEvents`, and `requires` itself) change only with a new `pluginAPIVersion`. @@ -443,8 +447,10 @@ 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. Core supports -`buildProviders: 1` for validation and plans without a host table. Windows has +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. Core supports `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. @@ -473,10 +479,11 @@ Rules for publishing across levels and capability versions: | Permission | Required when | | --- | --- | -| `languageServer.launch` | `languageServers` is not empty | +| `languageServer.launch` | `languageServers` or `vcsProviders` is not empty, or a build provider starts its own process | | `process.run` | any provider declares `run` | | `process.test` | any provider declares `test` | | `process.build` | `buildSystems` is not empty | +| `process.vcs` | `vcsProviders` is not empty | | `ui.contribute` | `ui` is declared | | `ui.web` | a tool window shows [web content](#web-content-ui-2) | @@ -486,6 +493,7 @@ Rules for publishing across levels and capability versions: | `onRun:` | Activate the run provider when the user runs a file of the language. | | `onTest:` | Activate the test provider when the user lists or runs tests. | | `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. | Each provider needs its event and each event needs a provider. Enabling a plugin only permits activation; nothing starts until its event occurs. @@ -622,7 +630,8 @@ the plugin, sorted by `kind`, language, and tool: | `runtimeLanguageServer` | `languageID`, `tool`, `packagedRuntime`, `entry`, `runtimeArguments`, `arguments`, `validationArguments` | Package entry started by a runtime; `packagedRuntime` is `null` for a discovered runtime | | `buildSystem` | `buildSystemID`, `invocation`, `options`, `tasks`, `run`, `test`, `model`, `dependencies`, `hostIntegration`?, `provider`? | Every command line the build system can plan; `provider` is the shared form, which sends build requests to a language server | | `packagedBuildProvider`, `systemBuildProvider`, `runtimeBuildProvider` | the matching language-server fields with `buildSystemID`, `executesProjectCode`, `mayUseNetwork` instead of `languageID` | A build provider with a process of its own | -| `runtime` | `tool`, `minimumVersion`? | Runtime the host discovers and passes to a language server or build provider | +| `packagedVcsProvider`, `systemVcsProvider`, `runtimeVcsProvider` | the matching language-server fields with `vcs`, `vcsTool`, `mayUseNetwork` instead of `languageID` | A VCS provider process; `vcsTool` is the declared VCS client or `null` | +| `runtime` | `tool`, `minimumVersion`? | Runtime the host discovers and passes to a language server, build provider, or VCS provider | | `run` | `languageID`, `executable`, `arguments` | Run command and its argument template | | `test` | `languageID`, `executable`, `workspaceArguments`, `fileArguments`, `testCaseArguments` | Test command and its argument templates | @@ -965,6 +974,25 @@ digests the watched files. A selection the declaration does not allow is `plugin.build.invalidSelection`; an operation it does not declare is `plugin.build.unavailable`. +## API level 3: VCS providers + +`vcsProviders[]` declares version control systems the plugin implements, each +through its own LSP base-protocol process: the VCS ID, the static markers that +identify a root, metadata paths the host watches, the VCS client the provider +drives, and the shared [`serverProcess`](#capability-versions-level-3) launch +fields. The ID `git` is reserved for the built-in support +(`plugin.vcs.reserved`). Capabilities belong to the running process and are +negotiated in `initialize` under `experimental.litheVcs`, never declared in +the manifest. + +Detection never starts a process: `vcs.detectRoots` finds roots from the +markers, and the host starts a provider only after a root of its VCS is +found. The install review lists the provider as a `…VcsProvider` program with +its VCS client (`vcsTool`) and `mayUseNetwork`. Requests go through +`lsp.vcsRequest` by operation name and replies are normalized by +`vcs.decodeReply`. The fields, operations, reply shapes, and error codes are +defined in [the VCS provider contract](https://github.com/1lck/Lithe-IDEA/blob/preview/shared/contracts/vcs-provider-v1.md). + ## Plugin functions A plugin backend (a language server or build provider process the plugin @@ -1169,7 +1197,7 @@ valid. ## Outcomes and error codes -`plugins.declarative.*`, `plugins.marketplace.*`, and `build.*` commands return `{"status": "ok", …}` or +`plugins.declarative.*`, `plugins.marketplace.*`, `build.*`, and `vcs.*` commands return `{"status": "ok", …}` or `{"status": "rejected", "error": {"code", "message", "field"?}}`. A malformed command payload is a Core `invalid_request` failure instead. @@ -1188,11 +1216,13 @@ command payload is a Core `invalid_request` failure instead. | `plugin.test.unsupportedProject` | Core | No project marker in the workspace | | `plugin.execution.unavailable` | Core | The plugin has no run/test provider for the language | | `plugin.integration.unsupported` | Core | A host integration the host does not implement, or one requested by a plugin outside the official namespace | -| `plugin.launch.unavailable` | Core | The plugin has no language server for the language, or no provider process for the build system | +| `plugin.launch.unavailable` | Core | The plugin has no language server for the language, no provider process for the build system, or no such VCS provider | | `plugin.build.unavailable` | Core | The plugin declares no such build system, or the build system does not declare the requested operation | | `plugin.build.invalidSelection` | Core | A build request selects a scope, target, filter, or argument the declaration does not allow, or misses a required path | | `plugin.build.invalidOutput` | Core | Build-tool output or a report does not fit its decoder | | `plugin.launch.invalidRequest` | Core | A launch plan request has an unusable directory or an unknown architecture | +| `plugin.vcs.reserved` | Core | A VCS provider claims the ID of the built-in Git support | +| `plugin.vcs.invalidOutput` | Core | A VCS provider's reply does not fit the [VCS provider contract](https://github.com/1lck/Lithe-IDEA/blob/preview/shared/contracts/vcs-provider-v1.md) | | `plugin.index.invalid` | Core | Malformed index root or entry, or an invalid URL, checksum, platform, version, or date | | `plugin.index.schemaUnsupported` | Core | The index `schemaVersion` is not `1` | | `plugin.index.officialNamespace` | Core | A third-party source lists a `dev.lithe` ID | diff --git a/contract/v1/fixtures/vcs-provider.plugin.json b/contract/v1/fixtures/vcs-provider.plugin.json new file mode 100644 index 0000000..b78094b --- /dev/null +++ b/contract/v1/fixtures/vcs-provider.plugin.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.fixture.vcs-provider", + "displayName": "VCS Provider Fixture", + "version": "0.1.0", + "pluginAPIVersion": 3, + "requires": { "serverProcess": 1, "vcsProviders": 1 }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "languageServer.launch", + "process.vcs" + ], + "activationEvents": [ + "onVcsRoot:svn" + ], + "languages": [], + "vcsProviders": [ + { + "id": "svn", + "displayName": "Subversion", + "detection": { + "markers": [{ "name": ".svn", "type": "directory" }] + }, + "metadataPaths": [".svn/wc.db"], + "tool": { + "commands": ["svn"], + "validationArguments": ["--version", "--quiet"] + }, + "launch": { + "kind": "runtime", + "tool": "node", + "minimumVersion": "20.3", + "entry": "provider/main.mjs" + }, + "arguments": ["--stdio"], + "mayUseNetwork": true + } + ] +} diff --git a/contract/v1/plugin-manifest.schema.json b/contract/v1/plugin-manifest.schema.json index 3571dd1..c24d138 100644 --- a/contract/v1/plugin-manifest.schema.json +++ b/contract/v1/plugin-manifest.schema.json @@ -27,9 +27,10 @@ "executionProviders": { "enum": [1] }, "languageServers": { "enum": [1] }, "languages": { "enum": [1] }, - "serverProcess": { "description": "The launch fields of language servers and of build providers that start their own process.", "enum": [1] }, + "serverProcess": { "description": "The launch fields of language servers, of build providers that start their own process, and of VCS providers.", "enum": [1] }, "settings": { "enum": [1] }, - "ui": { "description": "Version 2 adds web content in tool windows.", "enum": [1, 2] } + "ui": { "description": "Version 2 adds web content in tool windows.", "enum": [1, 2] }, + "vcsProviders": { "description": "Required exactly when vcsProviders is non-empty. The provider protocol itself is negotiated in initialize (shared/contracts/vcs-provider-v1.md).", "enum": [1] } } }, "vendor": { @@ -44,11 +45,11 @@ }, "permissions": { "type": "array", - "items": { "enum": ["languageServer.launch", "process.build", "process.run", "process.test", "ui.contribute", "ui.web"] } + "items": { "enum": ["languageServer.launch", "process.build", "process.run", "process.test", "process.vcs", "ui.contribute", "ui.web"] } }, "activationEvents": { "type": "array", - "items": { "type": "string", "pattern": "^(onLanguage|onRun|onTest|onView|onCommand|onBuildSystem):[a-z0-9.+_-]+$" } + "items": { "type": "string", "pattern": "^(onLanguage|onRun|onTest|onView|onCommand|onBuildSystem|onVcsRoot):[a-z0-9.+_-]+$" } }, "languages": { "type": "array", @@ -69,6 +70,12 @@ "maxItems": 16, "items": { "$ref": "#/$defs/buildSystem" } }, + "vcsProviders": { + "description": "pluginAPIVersion 3: version control systems the plugin provides, each through its own LSP base-protocol process. Requires languageServer.launch, process.vcs, and one onVcsRoot: activation event per provider.", + "type": "array", + "maxItems": 8, + "items": { "$ref": "#/$defs/vcsProvider" } + }, "ui": { "type": "object", "additionalProperties": false, @@ -362,7 +369,7 @@ } }, "serverProcess": { - "description": "The launch fields a language server and a pluginAPIVersion 3 build provider share; both are LSP base-protocol processes and pass the same validation.", + "description": "The launch fields a language server, a pluginAPIVersion 3 build provider, and a VCS provider share; all are LSP base-protocol processes and pass the same validation.", "type": "object", "required": ["launch"], "properties": { @@ -452,6 +459,66 @@ } ] }, + "vcsProvider": { + "description": "pluginAPIVersion 3: a VCS provider process (shared/contracts/vcs-provider-v1.md). Detection uses the markers only and never starts the process; capabilities are negotiated, not declared.", + "type": "object", + "$ref": "#/$defs/serverProcess", + "unevaluatedProperties": false, + "required": ["id", "displayName", "detection", "mayUseNetwork"], + "properties": { + "id": { + "description": "Stable VCS ID; git is reserved for the built-in support.", + "type": "string", + "pattern": "^[a-z][a-z0-9-]{0,31}$", + "not": { "const": "git" } + }, + "displayName": { "type": "string", "minLength": 1, "maxLength": 64 }, + "detection": { + "type": "object", + "additionalProperties": false, + "required": ["markers"], + "properties": { + "markers": { + "description": "Entries directly inside a root that mark it.", + "type": "array", + "minItems": 1, + "maxItems": 8, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["name", "type"], + "properties": { + "name": { "type": "string", "minLength": 1, "maxLength": 255, "pattern": "^[^/\\\\]+$", "not": { "enum": [".", ".."] } }, + "type": { "enum": ["directory", "file"] } + } + } + } + } + }, + "metadataPaths": { + "description": "Root-relative paths the host watches; a change means the status may be stale.", + "type": "array", + "maxItems": 16, + "items": { "type": "string", "minLength": 1 } + }, + "tool": { + "description": "The VCS client the provider drives. The host resolves it like a systemCommand launch and passes its absolute path with every request.", + "type": "object", + "additionalProperties": false, + "required": ["commands"], + "properties": { + "commands": { + "type": "array", + "minItems": 1, + "maxItems": 8, + "items": { "type": "string", "pattern": "^[^/\\\\\\s]+$" } + }, + "validationArguments": { "type": "array", "items": { "type": "string" } } + } + }, + "mayUseNetwork": { "type": "boolean" } + } + }, "minimumVersion": { "description": "pluginAPIVersion 2: one to three dot-separated numbers, for example 21 or 18.17.", "type": "string", diff --git a/sdk/e2e/plugin_sdk/mod.rs b/sdk/e2e/plugin_sdk/mod.rs index 0a44dc3..a3664f8 100644 --- a/sdk/e2e/plugin_sdk/mod.rs +++ b/sdk/e2e/plugin_sdk/mod.rs @@ -18,6 +18,8 @@ // Each test binary includes this module and uses only the helpers its SDK needs. #![allow(dead_code)] +pub mod vcs; + use lithe_core::execute_json; use serde_json::{json, Value}; use std::collections::BTreeMap; @@ -102,10 +104,27 @@ struct Session { impl Session { fn start(plugin_id: &str, executable: &str, arguments: &[Value], workspace: &Path) -> Self { + Self::start_provider( + &format!("buildProvider:{plugin_id}:{BUILD_SYSTEM}"), + plugin_id, + executable, + arguments, + workspace, + ) + } + + /// Starts a plugin-owned session under the host's provider ID. + fn start_provider( + provider_id: &str, + plugin_id: &str, + executable: &str, + arguments: &[Value], + workspace: &Path, + ) -> Self { let started = core( "lsp.startServer", json!({ - "providerId": format!("buildProvider:{plugin_id}:{BUILD_SYSTEM}"), + "providerId": provider_id, "pluginId": plugin_id, "executablePath": executable, "arguments": arguments, diff --git a/sdk/e2e/plugin_sdk/vcs.rs b/sdk/e2e/plugin_sdk/vcs.rs new file mode 100644 index 0000000..6f5cd74 --- /dev/null +++ b/sdk/e2e/plugin_sdk/vcs.rs @@ -0,0 +1,307 @@ +//! End-to-end scenario of a VCS provider written with the Node.js SDK +//! (`sdk/node/examples/sample-vcs`), driven through the JSON commands a host +//! sends: root detection from the manifest, the launch plan, the `litheVcs` +//! negotiation, every operation through `lsp.vcsRequest` with its reply +//! decoded by `vcs.decodeReply`, a provider error code, and the execution +//! report a provider's tool run produces. + +use super::{core, listing, repository_path, Fixture, Session}; +use serde_json::{json, Value}; +use std::fs; +use std::path::Path; + +const VCS: &str = "sample"; + +/// Revision 1 of the working copy, as the sample stores it. +fn initial_revision() -> Value { + json!([{ + "id": "1", + "author": "sample", + "date": "2026-01-01T00:00:00.000Z", + "message": "Initial", + "files": { "a.txt": "one\ntwo\n", "docs/b.txt": "bee\n" }, + "changes": [ + { "path": "a.txt", "status": "added" }, + { "path": "docs/b.txt", "status": "added" } + ] + }]) +} + +/// One provider session with what it negotiated and the host's resolved tool. +struct Provider<'a> { + session: Session, + capabilities: Value, + root: &'a Path, + tool: &'a str, + sequence: usize, +} + +impl Provider<'_> { + /// Sends one operation and returns its `requestCompleted` event. + fn send(&mut self, operation: &str, arguments: &Value) -> Value { + self.sequence += 1; + let operation_id = format!("{operation}-{}", self.sequence); + core( + "lsp.vcsRequest", + json!({ + "sessionId": self.session.id, + "operationId": operation_id, + "vcs": VCS, + "operation": operation, + "root": self.root, + "tool": self.tool, + "arguments": arguments, + }), + ); + self.session.completion(&operation_id) + } + + /// Sends one operation and returns the reply as `vcs.decodeReply` normalizes it. + fn request(&mut self, operation: &str, arguments: Value) -> Value { + let completed = self.send(operation, &arguments); + assert!( + completed.get("error").is_none(), + "{operation} failed: {completed}" + ); + let decoded = core( + "vcs.decodeReply", + json!({ + "operation": operation, + "capabilities": self.capabilities, + "request": arguments, + "reply": completed["result"]["value"], + }), + ); + assert_eq!(decoded["status"], "ok", "{operation}: {decoded}"); + decoded["result"].clone() + } + + /// `(path, status, oldPath)` of each change, in Core's order. + fn changes(&mut self) -> Vec<(String, String, Value)> { + self.request("status", json!({}))["changes"] + .as_array() + .expect("decoded changes") + .iter() + .map(|change| { + ( + change["path"].as_str().unwrap().to_string(), + change["status"].as_str().unwrap().to_string(), + change["oldPath"].clone(), + ) + }) + .collect() + } +} + +fn change(path: &str, status: &str) -> (String, String, Value) { + (path.to_string(), status.to_string(), Value::Null) +} + +fn revision_ids(entries: &Value) -> Vec { + entries + .as_array() + .expect("entries") + .iter() + .map(|entry| entry["revision"]["id"].as_str().unwrap().to_string()) + .collect() +} + +/// Runs the scenario with `node` as both the provider's runtime and the +/// sample VCS's "client". +pub fn run_vcs_scenario(node: &str) { + let package = repository_path("sdk/node/examples/sample-vcs"); + let manifest: Value = + serde_json::from_slice(&fs::read(package.join("plugin.json")).expect("sample manifest")) + .expect("sample manifest JSON"); + let plugin_id = manifest["id"].as_str().expect("plugin ID").to_string(); + let package_before = listing(&package); + let validated = core( + "plugins.declarative.validate", + json!({ "manifest": manifest }), + ); + assert_eq!(validated["status"], "ok", "{validated}"); + + let fixture = Fixture::new("vcs"); + let workspace = fixture.workspace(); + fs::create_dir_all(workspace.join(".samplevcs")).expect("metadata directory"); + fs::create_dir_all(workspace.join("docs")).expect("docs directory"); + fs::write( + workspace.join(".samplevcs/revisions.json"), + initial_revision().to_string(), + ) + .expect("revisions"); + fs::write(workspace.join("a.txt"), "one\ntwo\n").expect("a.txt"); + fs::write(workspace.join("docs/b.txt"), "bee\n").expect("b.txt"); + + // Core finds the working copy from the manifest's marker alone. + let detection = core( + "vcs.detectRoots", + json!({ "workspaceRoot": workspace, "plugins": [{ "manifest": manifest }] }), + ); + assert_eq!(detection["status"], "ok", "{detection}"); + let root = detection["detection"]["roots"] + .as_array() + .expect("roots") + .iter() + .find(|root| root["vcs"] == VCS) + .unwrap_or_else(|| panic!("no sample root: {detection}")) + .clone(); + assert_eq!(root["id"], ""); + assert_eq!(root["path"], json!(workspace)); + + // The host starts the provider from the plan Core expands. + let plan = core( + "plugins.declarative.languageServerLaunchPlan", + json!({ + "manifest": manifest, + "vcsProviderID": VCS, + "request": { + "pluginDirectory": package, + "stateDirectory": fixture.root.join("state"), + "workspaceStateDirectory": fixture.root.join("workspace-state"), + "architecture": if cfg!(target_arch = "aarch64") { "arm64" } else { "x86_64" }, + } + }), + ); + assert_eq!(plan["status"], "ok", "{plan}"); + let plan = &plan["plan"]; + assert_eq!(plan["vcsProvider"]["vcs"], VCS, "{plan}"); + assert_eq!(plan["launch"]["tool"], "node"); + let arguments = plan["arguments"].as_array().expect("arguments").clone(); + let mut session = Session::start_provider( + &format!("vcsProvider:{plugin_id}:{VCS}"), + &plugin_id, + node, + &arguments, + &workspace, + ); + + // The provider negotiated one capability per handler it declared. + let negotiated = session.await_event("the VCS negotiation", |event| { + event["type"] == "vcsProvider" + }); + assert_eq!(negotiated["result"]["vcs"], VCS, "{negotiated}"); + let capabilities = negotiated["result"]["capabilities"].clone(); + assert_eq!( + capabilities, + json!({ + "annotate": 1, "baseContent": 1, "checkin": 1, "cleanup": 1, "conflicts": 1, + "fileHistory": 1, "fileOperations": 1, "history": 1, "rollback": 1, "status": 1, + "update": 1 + }) + ); + let mut provider = Provider { + session, + capabilities, + root: &workspace, + tool: node, + sequence: 0, + }; + + // Local edits come back as one sorted status snapshot. + fs::write(workspace.join("a.txt"), "one\nTWO\nthree\n").expect("edit a.txt"); + fs::remove_file(workspace.join("docs/b.txt")).expect("remove b.txt"); + fs::write(workspace.join("c.txt"), "sea\n").expect("c.txt"); + assert_eq!( + provider.changes(), + [ + change("a.txt", "modified"), + change("c.txt", "unversioned"), + change("docs/b.txt", "missing"), + ] + ); + provider.request( + "fileOperation", + json!({ "kind": "add", "paths": ["c.txt"] }), + ); + assert_eq!(provider.changes()[1], change("c.txt", "added")); + assert_eq!( + provider.request("baseContent", json!({ "path": "a.txt" })), + json!({ "content": "one\ntwo\n", "binary": false }) + ); + + // A provider's contract code reaches the host; anything else would not. + fs::write(workspace.join(".samplevcs/lock"), "").expect("lock"); + let locked = provider.send( + "checkin", + &json!({ "paths": ["a.txt", "c.txt"], "message": "Second" }), + ); + assert_eq!( + locked["error"]["code"], "vcs.workingCopy.locked", + "{locked}" + ); + // Cleanup runs the tool, which the session reports for the console. + provider.request("cleanup", json!({})); + let execution = provider + .session + .await_event("the execution report", |event| { + event["type"] == "log" && event["message"] == "lithe/vcs/execution" + }); + let report: Value = serde_json::from_str(execution["detail"].as_str().expect("report detail")) + .expect("report JSON"); + assert_eq!(report["command"], json!([node, "--version"]), "{report}"); + assert_eq!(report["exitCode"], 0, "{report}"); + + let checked_in = provider.request( + "checkin", + json!({ "paths": ["c.txt", "a.txt"], "message": "Second" }), + ); + assert_eq!( + checked_in["revision"], + json!({ "id": "2", "display": "r2", "kind": "number" }) + ); + assert_eq!(provider.changes(), [change("docs/b.txt", "missing")]); + provider.request("rollback", json!({ "paths": ["docs/b.txt"] })); + assert_eq!( + fs::read_to_string(workspace.join("docs/b.txt")).expect("restored b.txt"), + "bee\n" + ); + assert!(provider.changes().is_empty()); + + // History pages keep the provider's order and resume from its cursor. + let first = provider.request("history", json!({ "limit": 1 })); + assert_eq!(revision_ids(&first["entries"]), ["2"]); + assert_eq!(first["nextCursor"], "1"); + let second = provider.request("history", json!({ "limit": 1, "cursor": "1" })); + assert_eq!(revision_ids(&second["entries"]), ["1"]); + assert_eq!(second["nextCursor"], Value::Null); + let file_history = provider.request("fileHistory", json!({ "path": "c.txt" })); + assert_eq!(revision_ids(&file_history["entries"]), ["2"]); + let annotation = provider.request("annotate", json!({ "path": "a.txt" })); + assert_eq!(annotation["lines"], json!(["1", "2", "2"])); + assert_eq!(revision_ids(&annotation["revisions"]), ["1", "2"]); + assert_eq!( + provider.request("update", json!({})), + json!({ "revision": { "id": "2", "display": "r2", "kind": "number" }, "conflicts": [] }) + ); + + // A tracked move is reported as a rename from its source. + provider.request( + "fileOperation", + json!({ "kind": "move", "from": "c.txt", "to": "docs/c.txt" }), + ); + assert_eq!( + provider.changes(), + [( + "docs/c.txt".to_string(), + "renamed".to_string(), + json!("c.txt") + )] + ); + provider.request("resolve", json!({ "paths": ["docs/c.txt"] })); + // Rolling the move back restores the source and clears the rename. + provider.request("rollback", json!({ "paths": ["docs/c.txt"] })); + assert!(!workspace.join("docs/c.txt").exists()); + assert_eq!( + fs::read_to_string(workspace.join("c.txt")).expect("restored c.txt"), + "sea\n" + ); + assert!(provider.changes().is_empty()); + + provider.session.stop(); + drop(provider); + assert!( + listing(&package) == package_before, + "the plugin package changed at runtime" + ); +} diff --git a/sdk/e2e/plugin_sdk_node.rs b/sdk/e2e/plugin_sdk_node.rs index f37fd80..7c451b5 100644 --- a/sdk/e2e/plugin_sdk_node.rs +++ b/sdk/e2e/plugin_sdk_node.rs @@ -1,7 +1,8 @@ //! End-to-end check of the official Node.js plugin SDK (`sdk/node`) against //! Rust Core; the scenario is shared with the Java SDK in `plugin_sdk/mod.rs`. -//! It runs only when `LITHE_PLUGIN_SDK_NODE` names a Node.js 20.3 or newer -//! executable; `scripts/sdk-e2e.mjs` sets it. +//! The VCS scenario is in `plugin_sdk/vcs.rs`. Both run only when +//! `LITHE_PLUGIN_SDK_NODE` names a Node.js 20.3 or newer executable; +//! `scripts/sdk-e2e.mjs` sets it. mod plugin_sdk; @@ -17,3 +18,12 @@ fn the_node_sdk_sample_backend_serves_build_requests_and_plugin_functions() { node, }); } + +#[test] +fn the_node_sdk_sample_vcs_provider_answers_every_operation() { + let Some(node) = plugin_sdk::node_from_environment("the Node.js SDK VCS end-to-end check") + else { + return; + }; + plugin_sdk::vcs::run_vcs_scenario(&node); +} diff --git a/sdk/node/README.md b/sdk/node/README.md index 37f19c6..e0c72e5 100644 --- a/sdk/node/README.md +++ b/sdk/node/README.md @@ -3,7 +3,7 @@ `@lithe/plugin-sdk` is the official way to write a plugin backend in JavaScript: the process a plugin starts through a `runtime` launch (`{ "kind": "runtime", "tool": "node", … }`). It speaks the LSP base protocol -on stdio and implements Lithe's two backend protocols from +on stdio and implements Lithe's backend protocols from [plugin contract v1](../../contract/v1/README.md): - **`litheBuild` 1**: typed `lithe/build/model` and `lithe/build/dependencies` @@ -12,6 +12,9 @@ on stdio and implements Lithe's two backend protocols from - **`litheFunctions` 1**: plugin functions, which are plugin-private actions that Lithe invokes for the same plugin, with progress, cancellation, and registration at runtime. +- **`litheVcs` 1**: typed `lithe/vcs/*` requests, for a plugin's + `vcsProviders` entry, from the + [VCS provider contract](https://github.com/1lck/Lithe-IDEA/blob/preview/shared/contracts/vcs-provider-v1.md). The SDK owns the wire format, negotiation, cancellation, process cleanup, and error mapping. Your code supplies only handlers. Node 20.3 or newer is @@ -45,6 +48,53 @@ build provider, functions, persistent state, a function registered at runtime, and cancellation. `node scripts/sdk-e2e.mjs` drives it through a real Lithe Core session with the scenario in [`sdk/e2e`](../e2e/plugin_sdk/mod.rs). +## A VCS provider + +```js +import { createBackend, PluginError, VCS_ERRORS } from './vendor/lithe-plugin-sdk/src/index.mjs'; + +createBackend({ name: 'example-vcs', version: '1.0.0' }) + .vcs('example', { + status: async ({ root, tool }, context) => { + if (!tool) throw new PluginError(VCS_ERRORS.toolUnavailable, 'The example client was not found.'); + const result = await context.run(tool.executable, ['status', '--json'], { cwd: root.path }); + return { changes: JSON.parse(result.stdout).changes }; + }, + baseContent: ({ path }) => ({ content: readBase(path), binary: false }), + checkin: ({ paths, message }) => ({ revision: commit(paths, message) }), + rollback: ({ paths }) => revert(paths), + }) + .listen(); +``` + +`backend.vcs(id, handlers, traits?)` names the VCS of the manifest's +`vcsProviders` entry and its handlers, keyed by operation: `status`, +`baseContent`, `checkin`, and `rollback` are required; `history`, +`fileHistory`, `annotate`, `update`, `fileOperation`, `resolve`, and +`cleanup` are optional. Each handler you declare negotiates its capability, +so the host offers only what you implement. `traits` describes the VCS +itself: `{ push: true }` when a check-in is local and publishing is a +separate step, `{ staging: true }` when changes have a staged dimension. +`git` is reserved for Lithe. + +A handler receives the operation's arguments plus `root: { path }` and +`tool: { executable } | null` (the client the host resolved), and returns the +reply of the contract; returning nothing answers `{}`. Core validates every +reply, sorts paths, and refuses one that leaves the root. Throw +`PluginError` with a `VCS_ERRORS` code (`conflict`, `outOfDate`, +`authenticationFailed`, `workingCopyLocked`, `workingCopyUnsupported`, +`toolUnavailable`) so the host can explain the failure; other codes reach it +as `vcs.provider.failed`. A VCS handler's `context.run` also reports each +finished command (command line, exit code, duration, and at most 16 KiB of +stderr) as `lithe/vcs/execution` for the project console; pass +`{ report: false }` to skip one. Arguments are reported as given, so never +put a credential in them. + +[`examples/sample-vcs`](examples/sample-vcs) is a complete provider for a +toy VCS that keeps revisions in a JSON file. Rust Core's +`sdk/e2e/plugin_sdk_node.rs` drives every operation through a +real Core session. + ## Getting the SDK Releases are published on [this repository's Releases page](https://github.com/Lithe-IDEA/lithe-plugin-docs/releases) and @@ -67,8 +117,9 @@ package. The SDK has no dependencies. | API | Purpose | | --- | --- | -| `createBackend({ name?, version?, capabilities? })` | Creates the backend. `capabilities` are standard LSP server capabilities; `experimental.litheBuild` and `experimental.litheFunctions` are reserved for the SDK. | +| `createBackend({ name?, version?, capabilities? })` | Creates the backend. `capabilities` are standard LSP server capabilities; `experimental.litheBuild`, `experimental.litheFunctions`, and `experimental.litheVcs` are reserved for the SDK. | | `backend.build.model(handler)`, `backend.build.dependencies(handler)` | Implements the typed build requests. The SDK stamps `format` and `version`; a handler returning another format or a version other than 1 fails. Core validates the document. `litheBuild` is answered only when a handler exists. | +| `backend.vcs(id, handlers, traits?)` | Implements the typed VCS requests; see [A VCS provider](#a-vcs-provider). `litheVcs` is answered only when the host offers it. | | `backend.function(id, handler)` | Declares a function offered in `initialize`. | | `backend.registerFunction(id, handler)`, `backend.unregisterFunction(id)` | Adds or withdraws a function. Called before the `initialize` answer (for example from `onInitialize`), they change the initial list; later they send `lithe/function/register` / `unregister`. Withdrawing revokes running calls. | | `backend.onInitialize(hook)`, `backend.onShutdown(hook)` | Lifecycle hooks. `onInitialize` receives the `initialize` params and what was negotiated. | @@ -76,7 +127,7 @@ package. The SDK has no dependencies. | `backend.sendRequest`, `backend.sendNotification` | Ordinary LSP messages to the host; `lithe/*` methods are reserved. | | `backend.log(...)` | Writes a line to stderr, which the host keeps in its log. | | `backend.listen()` | Serves stdio until `exit` or the end of input. | -| `PluginError(code, message)` | A failure meant for your own UI. | +| `PluginError(code, message)` | A failure meant for your own UI, or with a `VCS_ERRORS` code, for the host's. | | `runProcess(command, args, options)` | Runs a process without a shell, as one cancellable process tree. | | `openStateStore(directory)` | A small atomic JSON store in a host-provided directory. | @@ -86,7 +137,7 @@ Every handler receives a context: | --- | --- | | `signal` | Aborted when Lithe cancels the call, its deadline passes, the function is withdrawn, or the session ends; also once the handler has settled, so work it left running stops. | | `throwIfCancelled()` | Throws the cancellation if `signal` is aborted. | -| `run(command, args, options)` | `runProcess` bound to the call: cancelling or ending the call ends the whole process tree. | +| `run(command, args, options)` | `runProcess` bound to the call: cancelling or ending the call ends the whole process tree. In VCS handlers it also reports the finished command unless `options.report` is `false`. | | `log(...)` | Same as `backend.log`. | | `functionID`, `operationID` | Functions only. `operationID` is opaque; never derive anything from it. | | `progress({ message?, percentage? })` | Functions only. The message is cut to 1024 bytes and the percentage clamped to 0–100; reports after the call ends are dropped. | @@ -114,14 +165,15 @@ UI, and state, but these rules keep Lithe's workflows, permissions, and lifecycle unified. The SDK enforces the ones it can. 1. **Domain capabilities stay typed.** Build models and dependency trees go - through `backend.build.*`, never through a function, so every host and IDE - workflow reads one contract. Functions are for actions only your plugin - understands. + through `backend.build.*`, and version control through `backend.vcs`, + never through a function, so every host and IDE workflow reads one + contract. Functions are for actions only your plugin understands. 2. **No calls back into Lithe's dispatch.** A backend never sends `lithe/function/invoke` or `lithe/build/*`; Lithe would route it back to the same process, which would then wait for itself. Lithe refuses these with `plugin.function.recursiveCall`, and the SDK refuses to send any `lithe/*` - method. + method. The only `lithe/vcs/*` message a provider sends is the execution + report, which `context.run` writes for you. 3. **stdout belongs to the protocol.** When serving stdio, the SDK moves `console.log`, `console.info`, and `console.debug` to stderr. Never write to `process.stdout` yourself. diff --git a/sdk/node/examples/sample-vcs/plugin.json b/sdk/node/examples/sample-vcs/plugin.json new file mode 100644 index 0000000..f21a591 --- /dev/null +++ b/sdk/node/examples/sample-vcs/plugin.json @@ -0,0 +1,35 @@ +{ + "schemaVersion": 2, + "id": "org.example.sample-vcs", + "displayName": "Sample VCS", + "version": "0.1.0", + "pluginAPIVersion": 3, + "requires": { "serverProcess": 1, "vcsProviders": 1 }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": ["languageServer.launch", "process.vcs"], + "activationEvents": ["onVcsRoot:sample"], + "languages": [], + "vcsProviders": [ + { + "id": "sample", + "displayName": "Sample VCS", + "detection": { + "markers": [{ "name": ".samplevcs", "type": "directory" }] + }, + "metadataPaths": [".samplevcs/revisions.json"], + "tool": { "commands": ["node"], "validationArguments": ["--version"] }, + "launch": { + "kind": "runtime", + "tool": "node", + "minimumVersion": "20.3", + "entry": "provider/main.mjs" + }, + "arguments": ["--stdio"], + "mayUseNetwork": false + } + ] +} diff --git a/sdk/node/examples/sample-vcs/provider/main.mjs b/sdk/node/examples/sample-vcs/provider/main.mjs new file mode 100644 index 0000000..37fc8e8 --- /dev/null +++ b/sdk/node/examples/sample-vcs/provider/main.mjs @@ -0,0 +1,250 @@ +// A sample VCS provider written with the Lithe plugin SDK. It is not a real +// version control system: a working copy is a directory with a +// `.samplevcs/revisions.json` file that stores every revision as a full +// snapshot of the files' text, so the sample shows the protocol without a +// VCS client. A real provider, such as the Subversion community plugin, runs +// its client through `context.run` and parses the output instead. +// +// Rust Core's end-to-end test (sdk/e2e/plugin_sdk/vcs.rs) +// drives every operation below through a real session. A packaged plugin +// vendors the SDK inside its package, for example as +// `provider/vendor/lithe-plugin-sdk/`, and imports it from there. + +import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { createBackend, PluginError, VCS_ERRORS } from '../../../src/index.mjs'; + +const METADATA = '.samplevcs'; + +/** The working copy's metadata: its revisions, oldest first, and pending operations. */ +function load(root) { + const read = (name, fallback) => { + const path = join(root, METADATA, name); + return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : fallback; + }; + return { + revisions: read('revisions.json', []), + pending: read('pending.json', { added: [], deleted: [], moved: {} }), + }; +} + +function save(root, { revisions, pending }) { + writeFileSync(join(root, METADATA, 'revisions.json'), `${JSON.stringify(revisions, null, 2)}\n`); + writeFileSync(join(root, METADATA, 'pending.json'), `${JSON.stringify(pending, null, 2)}\n`); +} + +/** Text of every file below `root`, by `/`-separated relative path. */ +function workingFiles(root, directory = '', files = {}) { + for (const entry of readdirSync(join(root, directory), { withFileTypes: true })) { + if (entry.name === METADATA) continue; + const path = directory ? `${directory}/${entry.name}` : entry.name; + if (entry.isDirectory()) workingFiles(root, path, files); + else if (entry.isFile()) files[path] = readFileSync(join(root, path), 'utf8'); + } + return files; +} + +const head = (revisions) => revisions.at(-1) ?? null; +const base = (revisions) => head(revisions)?.files ?? {}; +const revisionOf = (entry) => ({ id: entry.id, display: `r${entry.id}`, kind: 'number' }); + +function rootOf(params) { + const root = params.root.path; + if (existsSync(join(root, METADATA, 'lock'))) { + throw new PluginError(VCS_ERRORS.workingCopyLocked, 'The working copy is locked; run Cleanup.'); + } + return root; +} + +function status(params) { + const root = rootOf(params); + const { revisions, pending } = load(root); + const committed = base(revisions); + const disk = workingFiles(root); + const moveSources = new Set(Object.values(pending.moved)); + const changes = []; + for (const [path, text] of Object.entries(committed)) { + if (moveSources.has(path)) continue; + if (!(path in disk)) { + changes.push({ path, status: pending.deleted.includes(path) ? 'deleted' : 'missing' }); + } else if (disk[path] !== text) { + changes.push({ path, status: 'modified' }); + } + } + for (const path of Object.keys(disk)) { + if (path in committed) continue; + if (pending.moved[path]) changes.push({ path, oldPath: pending.moved[path], status: 'renamed' }); + else changes.push({ path, status: pending.added.includes(path) ? 'added' : 'unversioned' }); + } + const latest = head(revisions); + // Core sorts the changes and checks every path; the provider only reports. + return { head: { revision: latest && revisionOf(latest), branch: null, location: null }, changes }; +} + +function baseContent(params) { + const content = base(load(rootOf(params)).revisions)[params.path]; + return { content: content ?? null, binary: false }; +} + +function checkin(params) { + const root = rootOf(params); + const state = load(root); + const files = { ...base(state.revisions) }; + const disk = workingFiles(root); + const changes = []; + for (const path of params.paths) { + const source = state.pending.moved[path]; + if (source) { + delete files[source]; + delete state.pending.moved[path]; + changes.push({ path, oldPath: source, status: 'renamed' }); + } else if (!(path in disk)) { + changes.push({ path, status: 'deleted' }); + } else { + changes.push({ path, status: path in files ? 'modified' : 'added' }); + } + if (path in disk) files[path] = disk[path]; + else delete files[path]; + state.pending.added = state.pending.added.filter((added) => added !== path); + state.pending.deleted = state.pending.deleted.filter((deleted) => deleted !== path); + } + const entry = { + id: String(state.revisions.length + 1), + author: 'sample', + date: new Date(Date.UTC(2026, 0, 1, 0, 0, state.revisions.length)).toISOString(), + message: params.message, + files, + changes, + }; + state.revisions.push(entry); + save(root, state); + return { revision: revisionOf(entry) }; +} + +function rollback(params) { + const root = rootOf(params); + const state = load(root); + const committed = base(state.revisions); + for (const path of params.paths) { + // Rolling back a move target puts the file back where it came from. + const source = state.pending.moved[path]; + if (source) { + mkdirSync(dirname(join(root, source)), { recursive: true }); + if (existsSync(join(root, path))) renameSync(join(root, path), join(root, source)); + if (source in committed) writeFileSync(join(root, source), committed[source]); + delete state.pending.moved[path]; + continue; + } + if (path in committed) { + mkdirSync(dirname(join(root, path)), { recursive: true }); + writeFileSync(join(root, path), committed[path]); + } + // Like `svn revert`, an added file stays on disk, unversioned again. + state.pending.added = state.pending.added.filter((added) => added !== path); + state.pending.deleted = state.pending.deleted.filter((deleted) => deleted !== path); + } + save(root, state); +} + +/** One page of `entries`, newest first, resumed from an opaque cursor. */ +function page(entries, { cursor, limit = 100 }) { + const start = cursor ? Number(cursor) : 0; + const slice = entries.slice(start, start + limit); + return { + entries: slice.map((entry) => ({ + revision: revisionOf(entry), + author: entry.author, + date: entry.date, + message: entry.message, + changes: entry.changes, + changesTruncated: false, + })), + nextCursor: start + limit < entries.length ? String(start + limit) : null, + }; +} + +function history(params) { + return page(load(rootOf(params)).revisions.toReversed(), params); +} + +function fileHistory(params) { + const touched = (entry) => entry.changes.some((change) => change.path === params.path || change.oldPath === params.path); + return page(load(rootOf(params)).revisions.filter(touched).toReversed(), params); +} + +/** Each line comes from the oldest revision since which the file kept it. */ +function annotate(params) { + const root = rootOf(params); + const { revisions } = load(root); + const lines = readFileSync(join(root, params.path), 'utf8').split('\n'); + if (lines.at(-1) === '') lines.pop(); + const origins = lines.map((line) => { + let origin = null; + for (const entry of revisions.toReversed()) { + const text = entry.files[params.path]; + if (text === undefined || !text.split('\n').includes(line)) break; + origin = entry; + } + return origin; + }); + const used = new Map(origins.filter(Boolean).map((entry) => [entry.id, entry])); + return { + revisions: [...used.values()].map((entry) => ({ + revision: revisionOf(entry), + author: entry.author, + date: entry.date, + message: entry.message, + })), + lines: origins.map((entry) => entry?.id ?? null), + }; +} + +/** The sample has no server, so an update only reports where the copy is. */ +function update(params) { + const latest = head(load(rootOf(params)).revisions); + return { revision: latest && revisionOf(latest), conflicts: [] }; +} + +function fileOperation(params) { + const root = rootOf(params); + const state = load(root); + if (params.kind === 'add') { + state.pending.added = [...new Set([...state.pending.added, ...params.paths])]; + } else if (params.kind === 'delete') { + for (const path of params.paths) rmSync(join(root, path), { force: true }); + state.pending.deleted = [...new Set([...state.pending.deleted, ...params.paths])]; + } else { + mkdirSync(dirname(join(root, params.to)), { recursive: true }); + renameSync(join(root, params.from), join(root, params.to)); + state.pending.moved[params.to] = params.from; + } + save(root, state); +} + +/** + * Clears an interrupted operation's lock. A real provider runs its client + * here (`svn cleanup`); the sample runs the tool's version check so the host + * sees an execution report in its console. + */ +async function cleanup(params, context) { + if (!params.tool) throw new PluginError(VCS_ERRORS.toolUnavailable, 'The sample tool was not found.'); + const result = await context.run(params.tool.executable, ['--version']); + if (result.exitCode !== 0) throw new PluginError(VCS_ERRORS.toolUnavailable, 'The sample tool did not run.'); + rmSync(join(params.root.path, METADATA, 'lock'), { force: true }); +} + +createBackend({ name: 'sample-vcs', version: '0.1.0' }) + .vcs('sample', { + status, + baseContent, + checkin, + rollback, + history, + fileHistory, + annotate, + update, + fileOperation, + resolve: () => {}, + cleanup, + }) + .listen(); diff --git a/sdk/node/src/backend.mjs b/sdk/node/src/backend.mjs index 7cf3d6d..19d883e 100644 --- a/sdk/node/src/backend.mjs +++ b/sdk/node/src/backend.mjs @@ -4,8 +4,8 @@ // cancellation, error codes, and the `lithe/*` namespace -- and leaves the // plugin free to decide what its handlers do. // -// Contract: contract/v1/README.md ("Build providers" and -// "Plugin functions"). +// Contracts: contract/v1/README.md ("Build providers", "Plugin functions" +// and "VCS providers"), and Lithe's shared/contracts/vcs-provider-v1.md. import { format } from 'node:util'; import { encodeFrame, FrameDecoder, FramingError, MALFORMED } from './framing.mjs'; @@ -18,8 +18,12 @@ import { FUNCTIONS_VERSION, isFunctionId, isReservedMethod, + isVcsId, LIMITS, METHODS, + VCS_OPERATIONS, + VCS_REQUIRED_OPERATIONS, + VCS_VERSION, } from './protocol.mjs'; /** Canonical result format of each typed build request. */ @@ -28,6 +32,9 @@ const BUILD_FORMATS = Object.freeze({ [METHODS.buildDependencies]: 'litheDependencies', }); +/** VCS capabilities that describe the VCS rather than a request it answers. */ +const VCS_TRAITS = Object.freeze(['push', 'staging']); + /** * Creates a backend. Declare handlers, then call `listen()`. * @@ -35,8 +42,9 @@ const BUILD_FORMATS = Object.freeze({ * @param {string} [options.name] Reported as `serverInfo.name` and used as the log prefix. * @param {string} [options.version] Reported as `serverInfo.version`. * @param {object} [options.capabilities] Standard LSP server capabilities, for a - * backend that is also a language server. `experimental.litheBuild` and - * `experimental.litheFunctions` are negotiated by the SDK and cannot be set here. + * backend that is also a language server. `experimental.litheBuild`, + * `experimental.litheFunctions`, and `experimental.litheVcs` are negotiated by + * the SDK and cannot be set here. */ export function createBackend(options = {}) { return new Backend(options); @@ -48,13 +56,15 @@ class Backend { #capabilities; #functions = new Map(); #buildHandlers = new Map(); + /** The declared VCS provider: `{ id, handlers: Map, capabilities }`. */ + #vcs = null; #requestHandlers = new Map(); #notificationHandlers = new Map(); #initializeHooks = []; #shutdownHooks = []; /** `idle` → `listening` → `initializing` → `ready` → `shuttingDown` → `exited`. */ #phase = 'idle'; - #negotiated = Object.freeze({ functions: false, build: false }); + #negotiated = Object.freeze({ functions: false, build: false, vcs: false }); #initializeParams = null; /** Host requests being handled, by JSON-RPC ID. */ #incoming = new Map(); @@ -72,10 +82,10 @@ class Backend { constructor({ name = 'lithe-plugin-backend', version, capabilities = {} } = {}) { const experimental = capabilities.experimental ?? {}; - if ('litheBuild' in experimental || 'litheFunctions' in experimental) { + if ('litheBuild' in experimental || 'litheFunctions' in experimental || 'litheVcs' in experimental) { throw new PluginSdkError( 'sdk.capabilities.reserved', - 'litheBuild and litheFunctions are negotiated by the SDK; declare handlers instead.', + 'litheBuild, litheFunctions, and litheVcs are negotiated by the SDK; declare handlers instead.', ); } this.#name = String(name); @@ -89,7 +99,7 @@ class Backend { }); } - /** Protocols the host agreed to in `initialize`; both false before it. */ + /** Protocols the host agreed to in `initialize`; all false before it. */ get negotiated() { return this.#negotiated; } @@ -120,6 +130,53 @@ class Backend { return this; } + /** + * Declares the VCS this backend provides and the operations it answers, + * before `listen()`. Each handler gets the operation's arguments plus + * `root: { path }` and `tool: { executable } | null`, and returns the reply + * object of shared/contracts/vcs-provider-v1.md. Declaring a handler + * negotiates its capability; `status`, `baseContent`, `checkin`, and + * `rollback` are required. + * + * @param {string} id The VCS ID of the manifest's `vcsProviders` entry, such as `svn`. + * @param {Record} handlers Keyed by operation name. + * @param {object} [traits] + * @param {boolean} [traits.push] Check-in is local and publishing is a separate step. + * @param {boolean} [traits.staging] Changes carry a staged/unstaged dimension. + */ + vcs(id, handlers, traits = {}) { + if (this.#phase !== 'idle') { + throw new PluginSdkError('sdk.vcs.lateDeclaration', 'Declare the VCS provider before listen().'); + } + if (this.#vcs) throw new PluginSdkError('sdk.vcs.duplicate', 'A backend provides one VCS.'); + if (!isVcsId(id)) { + throw new PluginSdkError( + 'sdk.vcs.invalidID', + `VCS ID ${JSON.stringify(id)} must be lowercase letters, digits, and "-", start with a letter, and be at most ${LIMITS.maxVcsIdBytes} bytes.`, + ); + } + if (id === 'git') throw new PluginSdkError('sdk.vcs.reserved', 'git is provided by Lithe.'); + const methods = new Map(); + const capabilities = {}; + for (const [operation, handler] of Object.entries(handlers ?? {})) { + const declared = VCS_OPERATIONS[operation]; + if (!declared) throw new PluginSdkError('sdk.vcs.unknownOperation', `${operation} is not a VCS operation.`); + if (typeof handler !== 'function') throw new TypeError(`The ${operation} handler must be a function.`); + methods.set(declared.method, handler); + capabilities[declared.capability] = VCS_VERSION; + } + const missing = VCS_REQUIRED_OPERATIONS.filter((operation) => !methods.has(VCS_OPERATIONS[operation].method)); + if (missing.length > 0) { + throw new PluginSdkError('sdk.vcs.missingOperation', `A VCS provider must answer ${missing.join(', ')}.`); + } + for (const [trait, enabled] of Object.entries(traits)) { + if (!VCS_TRAITS.includes(trait)) throw new PluginSdkError('sdk.vcs.unknownTrait', `${trait} is not a VCS trait.`); + if (enabled) capabilities[trait] = VCS_VERSION; + } + this.#vcs = { id, methods, capabilities: sortedKeys(capabilities) }; + return this; + } + /** Handles a standard LSP request, for a backend that is also a language server. */ onRequest(method, handler) { this.#declareHandler(this.#requestHandlers, method, handler); @@ -311,6 +368,11 @@ class Backend { this.#run(id, null, (context) => build(params ?? {}, context), (result) => buildResult(method, result)); return; } + const vcs = this.#negotiated.vcs ? this.#vcs.methods.get(method) : undefined; + if (vcs) { + this.#run(id, null, (context) => vcs(params ?? {}, this.#vcsContext(context)), (result) => vcsResult(method, result)); + return; + } const handler = this.#requestHandlers.get(method); if (handler) { this.#run(id, null, (context) => handler(params, context), (result) => result ?? null); @@ -348,6 +410,7 @@ class Backend { this.#negotiated = Object.freeze({ functions: offers(offered.litheFunctions, FUNCTIONS_VERSION), build: this.#buildHandlers.size > 0 && offers(offered.litheBuild, BUILD_VERSION), + vcs: this.#vcs !== null && offers(offered.litheVcs, VCS_VERSION), }); try { for (const hook of this.#initializeHooks) { @@ -370,6 +433,11 @@ class Backend { } const experimental = { ...(this.#capabilities.experimental ?? {}) }; if (this.#negotiated.build) experimental.litheBuild = { version: BUILD_VERSION }; + if (this.#negotiated.vcs) { + experimental.litheVcs = { version: VCS_VERSION, vcs: this.#vcs.id, capabilities: this.#vcs.capabilities }; + } else if (this.#vcs) { + this.log('The host did not offer litheVcs version 1; the VCS provider stays unavailable.'); + } if (this.#negotiated.functions) { experimental.litheFunctions = { version: FUNCTIONS_VERSION, @@ -475,6 +543,44 @@ class Backend { }); } + /** + * The request context of a VCS handler. Its `run` reports every finished + * command to the host as `lithe/vcs/execution`, for the project console, + * unless the call passes `report: false`. Arguments are reported as given, + * so they must never carry a credential. + */ + #vcsContext(context) { + const report = (command, args, started, outcome) => { + const params = { + command: [String(command), ...args.map(String)], + exitCode: outcome.exitCode ?? null, + durationMilliseconds: Math.max(0, Math.round(performance.now() - started)), + stderr: boundedText(outcome.stderr ?? '', LIMITS.maxExecutionErrorOutputBytes), + }; + this.#write({ jsonrpc: '2.0', method: METHODS.vcsExecution, params }); + }; + return { + ...context, + run: (command, args = [], { report: reporting = true, ...options } = {}) => { + const started = performance.now(); + const running = context.run(command, args, options); + if (!reporting) return running; + const reported = running.then( + (result) => { + report(command, args, started, result); + return result; + }, + (error) => { + if (!context.signal.aborted) report(command, args, started, { exitCode: null, stderr: error?.message }); + throw error; + }, + ); + reported.catch(() => {}); + return reported; + }, + }; + } + #declareBuild(method, handler) { if (this.#phase !== 'idle') { throw new PluginSdkError('sdk.build.lateDeclaration', 'Declare build handlers before listen().'); @@ -614,6 +720,23 @@ function buildResult(method, result) { return { ...result, format: expected, version: 1 }; } +/** + * A VCS reply must be a JSON object; Core validates its fields and refuses + * anything else as `plugin.vcs.invalidOutput`, so it fails here with a log. + */ +function vcsResult(method, result) { + if (result === undefined) return {}; + if (result === null || typeof result !== 'object' || Array.isArray(result)) { + throw new PluginSdkError('sdk.vcs.invalidResult', `${method} must return a reply object.`); + } + return result; +} + +/** A copy of `object` with its keys sorted, for a stable `initialize` answer. */ +function sortedKeys(object) { + return Object.fromEntries(Object.entries(object).sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))); +} + /** * A function result as the JSON value Core accepts. A value JSON cannot * encode, or one over Core's 1 MiB limit, fails here as a backend bug with a diff --git a/sdk/node/src/index.d.ts b/sdk/node/src/index.d.ts index 8841d24..dd7b030 100644 --- a/sdk/node/src/index.d.ts +++ b/sdk/node/src/index.d.ts @@ -72,15 +72,58 @@ export interface BuildRequestParams { [key: string]: unknown; } +/** Params every VCS handler receives, on top of its operation's arguments. */ +export interface VcsRequestParams { + root: { path: string }; + /** The VCS client the host resolved; null when it could not, so report `vcs.tool.unavailable`. */ + tool: { executable: string } | null; + [key: string]: unknown; +} + +export interface VcsRunOptions extends RunProcessOptions { + /** Report the finished command as `lithe/vcs/execution`; default true. */ + report?: boolean; +} + +export interface VcsContext extends RequestContext { + /** `RequestContext.run` that also reports each finished command to the host's console. */ + run(command: string, args?: string[], options?: VcsRunOptions): Promise; +} + +export type VcsHandler = (params: VcsRequestParams, context: VcsContext) => JsonObject | void | Promise; + +/** Handlers keyed by operation; see shared/contracts/vcs-provider-v1.md for each reply. */ +export interface VcsHandlers { + status: VcsHandler; + baseContent: VcsHandler; + checkin: VcsHandler; + rollback: VcsHandler; + history?: VcsHandler; + fileHistory?: VcsHandler; + annotate?: VcsHandler; + update?: VcsHandler; + fileOperation?: VcsHandler; + resolve?: VcsHandler; + cleanup?: VcsHandler; +} + +export interface VcsTraits { + /** Check-in is local and publishing is a separate step. */ + push?: boolean; + /** Changes carry a staged/unstaged dimension. */ + staging?: boolean; +} + export interface Negotiated { functions: boolean; build: boolean; + vcs: boolean; } export interface BackendOptions { name?: string; version?: string; - /** Standard LSP server capabilities; `experimental.litheBuild` and `experimental.litheFunctions` are reserved. */ + /** Standard LSP server capabilities; `experimental.litheBuild`, `litheFunctions`, and `litheVcs` are reserved. */ capabilities?: JsonObject; } @@ -100,6 +143,7 @@ export interface Backend { model(handler: BuildHandler): Backend; dependencies(handler: BuildHandler): Backend; }; + vcs(id: string, handlers: VcsHandlers, traits?: VcsTraits): Backend; function(id: string, handler: FunctionHandler): Backend; registerFunction(id: string, handler: FunctionHandler): Promise; unregisterFunction(id: string): Promise; @@ -137,10 +181,24 @@ export function openStateStore(directory: string, options?: { packageDirectory?: export function isFunctionId(value: unknown): boolean; +export function isVcsId(value: unknown): boolean; + +/** Provider error codes Core passes on; use them as `PluginError` codes. */ +export const VCS_ERRORS: Readonly<{ + authenticationFailed: 'vcs.authentication.failed'; + conflict: 'vcs.conflict'; + outOfDate: 'vcs.outOfDate'; + toolUnavailable: 'vcs.tool.unavailable'; + workingCopyLocked: 'vcs.workingCopy.locked'; + workingCopyUnsupported: 'vcs.workingCopy.unsupported'; +}>; + export const LIMITS: Readonly<{ maxFunctions: number; maxFunctionIdBytes: number; maxPluginErrorCodeBytes: number; maxMessageBytes: number; maxResultBytes: number; + maxVcsIdBytes: number; + maxExecutionErrorOutputBytes: number; }>; diff --git a/sdk/node/src/index.mjs b/sdk/node/src/index.mjs index b4cbd15..765aee8 100644 --- a/sdk/node/src/index.mjs +++ b/sdk/node/src/index.mjs @@ -5,4 +5,4 @@ export { createBackend } from './backend.mjs'; export { PluginError, PluginSdkError } from './errors.mjs'; export { runProcess } from './process.mjs'; export { openStateStore } from './state.mjs'; -export { isFunctionId, LIMITS } from './protocol.mjs'; +export { isFunctionId, isVcsId, LIMITS, VCS_ERRORS } from './protocol.mjs'; diff --git a/sdk/node/src/protocol.mjs b/sdk/node/src/protocol.mjs index b3222b0..99d87a8 100644 --- a/sdk/node/src/protocol.mjs +++ b/sdk/node/src/protocol.mjs @@ -1,12 +1,13 @@ // Wire constants and validation shared with Lithe's Rust Core -// (rust/lithe-core/src/lsp/interface/plugin_function.rs in the Lithe -// repository). The SDK checks the +// (rust/lithe-core/src/lsp/interface/plugin_function.rs and +// rust/lithe-core/src/vcs/ in the Lithe repository). The SDK checks the // same rules locally so a plugin author sees a mistake when registering a // handler instead of as a refused session at runtime. /** Protocol versions this SDK can answer. */ export const FUNCTIONS_VERSION = 1; export const BUILD_VERSION = 1; +export const VCS_VERSION = 1; export const METHODS = Object.freeze({ initialize: 'initialize', @@ -20,6 +21,43 @@ export const METHODS = Object.freeze({ functionProgress: 'lithe/function/progress', buildModel: 'lithe/build/model', buildDependencies: 'lithe/build/dependencies', + vcsExecution: 'lithe/vcs/execution', +}); + +/** + * VCS-provider operations: the handler name a plugin declares, the request + * Core sends for it, and the capability declaring the handler negotiates. + * Contract: shared/contracts/vcs-provider-v1.md. + */ +export const VCS_OPERATIONS = Object.freeze({ + status: Object.freeze({ method: 'lithe/vcs/status', capability: 'status' }), + baseContent: Object.freeze({ method: 'lithe/vcs/baseContent', capability: 'baseContent' }), + history: Object.freeze({ method: 'lithe/vcs/history', capability: 'history' }), + fileHistory: Object.freeze({ method: 'lithe/vcs/fileHistory', capability: 'fileHistory' }), + annotate: Object.freeze({ method: 'lithe/vcs/annotate', capability: 'annotate' }), + checkin: Object.freeze({ method: 'lithe/vcs/checkin', capability: 'checkin' }), + rollback: Object.freeze({ method: 'lithe/vcs/rollback', capability: 'rollback' }), + update: Object.freeze({ method: 'lithe/vcs/update', capability: 'update' }), + fileOperation: Object.freeze({ method: 'lithe/vcs/fileOperation', capability: 'fileOperations' }), + resolve: Object.freeze({ method: 'lithe/vcs/resolve', capability: 'conflicts' }), + cleanup: Object.freeze({ method: 'lithe/vcs/cleanup', capability: 'cleanup' }), +}); + +/** Handlers every VCS provider must declare; Core refuses a provider without them. */ +export const VCS_REQUIRED_OPERATIONS = Object.freeze(['status', 'baseContent', 'checkin', 'rollback']); + +/** + * The provider error codes Core passes on (`error.data.code`). Throw a + * `PluginError` with one of them; any other code reaches the host as + * `vcs.provider.failed`. + */ +export const VCS_ERRORS = Object.freeze({ + authenticationFailed: 'vcs.authentication.failed', + conflict: 'vcs.conflict', + outOfDate: 'vcs.outOfDate', + toolUnavailable: 'vcs.tool.unavailable', + workingCopyLocked: 'vcs.workingCopy.locked', + workingCopyUnsupported: 'vcs.workingCopy.unsupported', }); /** JSON-RPC and LSP error codes the SDK answers with. */ @@ -40,6 +78,8 @@ export const LIMITS = Object.freeze({ maxPluginErrorCodeBytes: 64, maxMessageBytes: 1024, maxResultBytes: 1 << 20, + maxVcsIdBytes: 32, + maxExecutionErrorOutputBytes: 16 * 1024, }); /** Dot-separated lowerCamelCase segments, for example `snapshot.refresh`. */ @@ -52,6 +92,11 @@ export function isFunctionId(value) { ); } +/** A VCS ID: lowercase ASCII letters, digits, and `-`, starting with a letter, such as `svn`. */ +export function isVcsId(value) { + return typeof value === 'string' && value.length <= LIMITS.maxVcsIdBytes && /^[a-z][a-z0-9-]*$/.test(value); +} + /** The plugin error code alphabet Core forwards as `pluginErrorCode`. */ export function isPluginErrorCode(value) { return ( diff --git a/sdk/node/test/backend.test.mjs b/sdk/node/test/backend.test.mjs index 479d6c4..ced0a62 100644 --- a/sdk/node/test/backend.test.mjs +++ b/sdk/node/test/backend.test.mjs @@ -24,7 +24,7 @@ test('initialize answers the protocols the host offered, with sorted functions', litheFunctions: { version: 1, functions: [{ id: 'install.prepare' }, { id: 'snapshot.refresh' }] }, }); assert.deepEqual(result.serverInfo, { name: 'test-backend', version: '1.0.0' }); - assert.deepEqual(backend.negotiated, { functions: true, build: true }); + assert.deepEqual(backend.negotiated, { functions: true, build: true, vcs: false }); assert.equal(await host.close(), 1); }); diff --git a/sdk/node/test/vcs.test.mjs b/sdk/node/test/vcs.test.mjs new file mode 100644 index 0000000..7ee7f2c --- /dev/null +++ b/sdk/node/test/vcs.test.mjs @@ -0,0 +1,166 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { createBackend, isVcsId, PluginError, PluginSdkError, VCS_ERRORS } from '../src/index.mjs'; +import { ERROR_CODES, VCS_OPERATIONS } from '../src/protocol.mjs'; +import { TestHost } from './harness.mjs'; + +// The capabilities of the full provider in Lithe's +// shared/fixtures/vcs/model-v1.json, which is not part of the contract mirror; +// sdk/e2e/plugin_sdk/vcs.rs negotiates them with a real Core session. +const FIXTURE_CAPABILITIES = [ + 'annotate', 'baseContent', 'checkin', 'cleanup', 'conflicts', 'fileHistory', + 'fileOperations', 'history', 'rollback', 'status', 'update', +]; + +const OFFER = { litheVcs: { versions: [1] } }; +const ROOT = { path: '/workspace/legacy' }; + +/** The four operations every provider answers, echoing what they received. */ +function required(overrides = {}) { + return { + status: () => ({ changes: [] }), + baseContent: (params) => ({ content: `base of ${params.path}`, binary: false }), + checkin: () => ({ revision: { id: '2' } }), + rollback: () => {}, + ...overrides, + }; +} + +test('every VCS operation negotiates a capability of the contract, and the IDs follow Core', () => { + // The fixture provider declares every operation-backed capability. + assert.deepEqual( + Object.values(VCS_OPERATIONS).map(({ capability }) => capability).sort(), + [...FIXTURE_CAPABILITIES].sort(), + ); + for (const id of ['svn', 'hg', 'p4-helix', 'a1']) assert.ok(isVcsId(id), id); + for (const id of ['', 'SVN', '1svn', 'svn_x', 'é', 'a'.repeat(33)]) assert.ok(!isVcsId(id), id); +}); + +test('a provider declaration is checked when it is made', () => { + const declare = (...args) => () => createBackend().vcs(...args); + const code = (expected) => (error) => error instanceof PluginSdkError && error.code === expected; + assert.throws(declare('SVN', required()), code('sdk.vcs.invalidID')); + assert.throws(declare('git', required()), code('sdk.vcs.reserved')); + assert.throws(declare('svn', required({ push: () => ({}) })), code('sdk.vcs.unknownOperation')); + assert.throws(declare('svn', { status: () => ({}) }), code('sdk.vcs.missingOperation')); + assert.throws(declare('svn', required(), { locks: true }), code('sdk.vcs.unknownTrait')); + assert.throws(declare('svn', required({ history: 'not a function' })), TypeError); + assert.throws(() => createBackend().vcs('svn', required()).vcs('hg', required()), code('sdk.vcs.duplicate')); + assert.throws( + () => createBackend({ capabilities: { experimental: { litheVcs: { version: 1 } } } }), + code('sdk.capabilities.reserved'), + ); + // lithe/vcs/* belongs to the typed handlers, never to onRequest. + assert.throws(() => createBackend().onRequest('lithe/vcs/status', () => {}), code('sdk.method.reserved')); +}); + +test('initialize answers the VCS and the capabilities its handlers and traits imply', async () => { + const backend = createBackend().vcs( + 'svn', + required({ fileOperation: () => {}, resolve: () => {}, history: () => ({ entries: [], nextCursor: null }) }), + { push: false, staging: false }, + ); + const host = new TestHost(backend); + const { result } = await host.initialize(OFFER); + assert.deepEqual(result.capabilities.experimental.litheVcs, { + version: 1, + vcs: 'svn', + capabilities: { baseContent: 1, checkin: 1, conflicts: 1, fileOperations: 1, history: 1, rollback: 1, status: 1 }, + }); + assert.deepEqual(backend.negotiated, { functions: false, build: false, vcs: true }); + await host.close(); + + const distributed = new TestHost(createBackend().vcs('hg', required(), { push: true })); + // Traits are capabilities without an operation; a false trait is not claimed. + const answer = (await distributed.initialize(OFFER)).result.capabilities.experimental.litheVcs; + assert.deepEqual(answer.capabilities, { baseContent: 1, checkin: 1, push: 1, rollback: 1, status: 1 }); + await distributed.close(); +}); + +test('a host that does not offer litheVcs gets no provider and no VCS answers', async () => { + const backend = createBackend().vcs('svn', required()); + const host = new TestHost(backend); + const { result } = await host.initialize({ litheVcs: { versions: [2] } }); + assert.deepEqual(result.capabilities.experimental, {}); + assert.equal(backend.negotiated.vcs, false); + const refused = await host.request('lithe/vcs/status', { root: ROOT, tool: null }); + assert.equal(refused.error.code, ERROR_CODES.methodNotFound); + await host.close(); +}); + +test('handlers receive the planned params and answer with the reply object', async () => { + const received = []; + const backend = createBackend().vcs( + 'svn', + required({ + status: (params) => { + received.push(params); + return { changes: [{ path: 'a.txt', status: 'modified' }] }; + }, + cleanup: () => undefined, + update: () => 'not an object', + }), + ); + const host = new TestHost(backend); + await host.initialize(OFFER); + const params = { includeIgnored: false, root: ROOT, tool: { executable: '/usr/bin/svn' } }; + const status = await host.request('lithe/vcs/status', params); + assert.deepEqual(status.result, { changes: [{ path: 'a.txt', status: 'modified' }] }); + assert.deepEqual(received, [params]); + // A handler that returns nothing answers the empty reply of rollback-like operations. + assert.deepEqual((await host.request('lithe/vcs/cleanup', { root: ROOT, tool: null })).result, {}); + // A reply that is not an object is a backend bug, reported without internals. + const invalid = await host.request('lithe/vcs/update', { root: ROOT, tool: null }); + assert.equal(invalid.error.code, ERROR_CODES.internalError); + // An operation the backend did not declare is not one of its capabilities. + assert.equal((await host.request('lithe/vcs/annotate', { root: ROOT })).error.code, ERROR_CODES.methodNotFound); + await host.close(); +}); + +test('provider failures carry the contract code Core passes on', async () => { + const backend = createBackend().vcs( + 'svn', + required({ + checkin: () => { + throw new PluginError(VCS_ERRORS.outOfDate, 'Update the working copy before committing.'); + }, + }), + ); + const host = new TestHost(backend); + await host.initialize(OFFER); + const failed = await host.request('lithe/vcs/checkin', { paths: ['a.txt'], message: 'Fix', root: ROOT, tool: null }); + assert.deepEqual(failed.error, { + code: ERROR_CODES.requestFailed, + message: 'Update the working copy before committing.', + data: { code: 'vcs.outOfDate' }, + }); + await host.close(); +}); + +test('commands a handler runs are reported to the host unless it opts out', async () => { + const script = "process.stderr.write('warning: x'); process.exit(3)"; + const backend = createBackend().vcs( + 'svn', + required({ + status: async (params, context) => { + const reported = await context.run(process.execPath, ['-e', script]); + await context.run(process.execPath, ['-e', 'process.exit(0)'], { report: false }); + return { changes: [], exitCode: reported.exitCode }; + }, + }), + ); + const host = new TestHost(backend); + await host.initialize(OFFER); + const status = await host.request('lithe/vcs/status', { root: ROOT, tool: null }); + assert.equal(status.result.exitCode, 3); + const executions = host.messages.filter((message) => message.method === 'lithe/vcs/execution'); + assert.equal(executions.length, 1); + const [{ params }] = executions; + assert.deepEqual(params.command, [process.execPath, '-e', script]); + assert.equal(params.exitCode, 3); + assert.equal(params.stderr, 'warning: x'); + assert.ok(Number.isInteger(params.durationMilliseconds) && params.durationMilliseconds >= 0); + // The report precedes the response that used its result. + assert.ok(host.messages.indexOf(executions[0]) < host.messages.indexOf(status)); + await host.close(); +});