From fd994411b19962dde66c1ac18edb16ad244be4bd Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Tue, 3 Nov 2026 10:41:58 -0800 Subject: [PATCH] Add typed shortcut import provider support --- scripts/sdk-e2e.mjs | 9 + scripts/sdk-e2e.test.mjs | 8 +- sdk/e2e/plugin_sdk/keymap.rs | 45 +++ sdk/e2e/plugin_sdk/mod.rs | 1 + sdk/e2e/plugin_sdk_java.rs | 6 + sdk/e2e/plugin_sdk_node.rs | 7 + sdk/e2e/plugin_sdk_rust.rs | 6 + sdk/java/README.md | 17 +- .../org/example/samplebuild/SampleBuild.java | 8 + .../github/litheidea/plugin/sdk/Backend.java | 138 ++++++++- .../litheidea/plugin/sdk/KeymapTest.java | 92 ++++++ sdk/node/README.md | 15 +- .../examples/sample-build/backend/main.mjs | 7 + sdk/node/src/backend.mjs | 71 ++++- sdk/node/src/index.d.ts | 19 +- sdk/node/test/keymap.test.mjs | 50 +++ sdk/node/test/types/keymap.mts | 8 + sdk/rust/Cargo.lock | 7 + sdk/rust/README.md | 17 +- sdk/rust/examples/sample-backend/src/main.rs | 8 + sdk/rust/plugin-sdk/Cargo.toml | 1 + sdk/rust/plugin-sdk/src/backend.rs | 290 +++++++++++++++++- sdk/rust/plugin-sdk/src/lib.rs | 5 +- sdk/rust/plugin-sdk/tests/keymap.rs | 135 ++++++++ 24 files changed, 957 insertions(+), 13 deletions(-) create mode 100644 sdk/e2e/plugin_sdk/keymap.rs create mode 100644 sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/KeymapTest.java create mode 100644 sdk/node/test/keymap.test.mjs create mode 100644 sdk/node/test/types/keymap.mts create mode 100644 sdk/rust/plugin-sdk/tests/keymap.rs diff --git a/scripts/sdk-e2e.mjs b/scripts/sdk-e2e.mjs index 800be89..80fd836 100644 --- a/scripts/sdk-e2e.mjs +++ b/scripts/sdk-e2e.mjs @@ -35,6 +35,11 @@ export function includesNativeWorkflows(schema, requested = false) { return requested || versions.includes(6); } +/** Exercise shortcut import when the selected host declares the capability. */ +export function includesKeymapImport(schema) { + return schema?.properties?.requires?.properties?.keymapImportProviders?.const === 1; +} + function run(command, args, options = {}) { return new Promise((resolvePromise, reject) => { const child = spawn(command, args, { stdio: 'inherit', ...options }); @@ -157,7 +162,10 @@ async function main() { const rust = await rustSample(); const revision = await checkoutCore(source, ref); if (values['working-tree']) await copyWorkingTree(source, work); + const hostSchema = JSON.parse(await readFile(join(work, 'shared/contracts/plugins/v1/plugin-manifest.schema.json'), 'utf8')); + const keymapImport = includesKeymapImport(hostSchema); console.log(`Running the SDK end-to-end scenario against Lithe ${revision}${values['working-tree'] ? ' plus explicit working-tree changes' : ''}`); + console.log(`Shortcut import scenario: ${keymapImport ? 'enabled' : 'not supported by this host'}`); await copyScenario(join(work, 'rust/lithe-core/tests')); await run('cargo', ['test', '--locked', '--manifest-path', join(work, 'rust/Cargo.toml'), '-p', 'lithe-core', @@ -170,6 +178,7 @@ async function main() { LITHE_PLUGIN_SDK_JAVA_SAMPLE: jar, LITHE_PLUGIN_SDK_JAVA_NATIVE_SAMPLE: nativeJar, LITHE_PLUGIN_SDK_RUST_SAMPLE: rust, + LITHE_PLUGIN_SDK_KEYMAP_IMPORT: keymapImport ? '1' : '0', }, }); } diff --git a/scripts/sdk-e2e.test.mjs b/scripts/sdk-e2e.test.mjs index 646214b..0b9775d 100644 --- a/scripts/sdk-e2e.test.mjs +++ b/scripts/sdk-e2e.test.mjs @@ -4,7 +4,13 @@ import { mkdtemp, mkdir, readFile, writeFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; -import { copyWorkingTree, includesNativeWorkflows } from './sdk-e2e.mjs'; +import { copyWorkingTree, includesKeymapImport, includesNativeWorkflows } from './sdk-e2e.mjs'; + +test('shortcut import coverage follows the selected host capability', () => { + assert.equal(includesKeymapImport({}), false); + assert.equal(includesKeymapImport({ properties: { requires: { properties: { keymapImportProviders: { const: 1 } } } } }), true); + assert.equal(includesKeymapImport({ properties: { requires: { properties: { keymapImportProviders: { const: 2 } } } } }), false); +}); test('the pinned contract turns native workflow coverage on after protocol 6 is published', () => { const schema = versions => ({ properties: { requires: { properties: { buildProviders: { enum: versions } } } } }); diff --git a/sdk/e2e/plugin_sdk/keymap.rs b/sdk/e2e/plugin_sdk/keymap.rs new file mode 100644 index 0000000..4311bd0 --- /dev/null +++ b/sdk/e2e/plugin_sdk/keymap.rs @@ -0,0 +1,45 @@ +//! The same selected-source import over Core for each official backend SDK. +use super::{core, Fixture, Session, WAIT}; +use serde_json::{json, Value}; +use std::fs; + +pub fn run(label: &str, executable: &str, prefix: &[String]) { + if std::env::var_os("LITHE_PLUGIN_SDK_KEYMAP_IMPORT").as_deref() != Some(std::ffi::OsStr::new("1")) { + eprintln!("Skipping shortcut import scenario: selected host does not declare the capability"); + return; + } + let fixture = Fixture::new(label); + let workspace = fixture.workspace(); + let state = fixture.root.join("state"); + fs::create_dir_all(&workspace).unwrap(); + fs::create_dir_all(&state).unwrap(); + let mut arguments: Vec = prefix.iter().map(|arg| json!(arg)).collect(); + arguments.extend([json!("--state"), json!(state)]); + let plugin_id = format!("org.example.sdk-keymap-{label}"); + let started = core("lsp.startServer", json!({ + "providerId": format!("pluginBackend:{plugin_id}"), "pluginId": plugin_id, + "executablePath": executable, "arguments": arguments, + "rootUri": format!("file://{}/", workspace.display()), "workingDirectory": workspace, + "hostServices": ["lithe/host/keymapImport/listInputs", "lithe/host/keymapImport/readChunk"], + "keymapImportProviders": ["fixture"], "initializeTimeoutMilliseconds": WAIT.as_millis() as u64, + "requestTimeoutMilliseconds": WAIT.as_millis() as u64, "shutdownTimeoutMilliseconds": 5000 + })); + let mut session = Session { id: started["sessionId"].as_str().unwrap().into(), plugin_id, events: Vec::new() }; + session.await_event("keymap provider negotiation", |event| event["type"] == "keymapImportProviders"); + session.await_event("session ready", |event| event["type"] == "stateChanged" && event["state"] == "ready"); + core("lsp.keymapImportRequest", json!({"sessionId":session.id, "operationId":"keymap-e2e", + "providerID":"fixture", "snapshotID":"snapshot-1", "timeoutMilliseconds":5000})); + for (method, result) in [ + ("lithe/host/keymapImport/listInputs", json!({"files":[{"id":"file-0","path":"keys.json","byteCount":4}]})), + ("lithe/host/keymapImport/readChunk", json!({"data":"ZGF0YQ=="})), + ] { + let request = session.await_event(method, |event| event["type"] == "hostRequest" && event["method"] == method); + assert_eq!(request["result"]["params"]["snapshotID"], "snapshot-1"); + core("lsp.hostResponse", json!({"sessionId":session.id, "hostRequestID":request["result"]["hostRequestID"], "result":result})); + } + let completed = session.completion("keymap-e2e"); + let reply = completed["result"]["value"]["response"]["result"].clone(); + let normalized = core("keymap.decodeImport", json!({"commandIDs":["find-next"], "reply":reply})); + assert_eq!(normalized, json!({"bindings":[{"commandID":"find-next", "keys":[{"key":"f3", "modifiers":[]}]}]})); + session.stop(); +} diff --git a/sdk/e2e/plugin_sdk/mod.rs b/sdk/e2e/plugin_sdk/mod.rs index b677dd2..8d534a5 100644 --- a/sdk/e2e/plugin_sdk/mod.rs +++ b/sdk/e2e/plugin_sdk/mod.rs @@ -19,6 +19,7 @@ #![allow(dead_code)] pub mod backend; +pub mod keymap; pub mod native_build; pub mod vcs; diff --git a/sdk/e2e/plugin_sdk_java.rs b/sdk/e2e/plugin_sdk_java.rs index 574a0c2..334f109 100644 --- a/sdk/e2e/plugin_sdk_java.rs +++ b/sdk/e2e/plugin_sdk_java.rs @@ -10,6 +10,12 @@ mod plugin_sdk; +#[test] +fn the_java_sdk_import_provider_reads_a_selected_source_through_core() { + let (Some(java), Some(jar)) = (std::env::var_os("LITHE_PLUGIN_SDK_JAVA"), std::env::var_os("LITHE_PLUGIN_SDK_JAVA_SAMPLE")) else { return; }; + plugin_sdk::keymap::run("java-keymap", &java.to_string_lossy(), &["-jar".into(), jar.to_string_lossy().into_owned()]); +} + #[test] fn the_java_sdk_sample_backend_serves_build_requests_and_plugin_functions() { let (Some(java), Some(jar)) = ( diff --git a/sdk/e2e/plugin_sdk_node.rs b/sdk/e2e/plugin_sdk_node.rs index 7c451b5..f4e4bf6 100644 --- a/sdk/e2e/plugin_sdk_node.rs +++ b/sdk/e2e/plugin_sdk_node.rs @@ -6,6 +6,13 @@ mod plugin_sdk; +#[test] +fn the_node_sdk_import_provider_reads_a_selected_source_through_core() { + let Some(node) = plugin_sdk::node_from_environment("the Node keymap provider") else { return; }; + let script = plugin_sdk::repository_path("sdk/node/examples/sample-build/backend/main.mjs"); + plugin_sdk::keymap::run("node-keymap", &node, &[script.to_string_lossy().into_owned()]); +} + #[test] fn the_node_sdk_sample_backend_serves_build_requests_and_plugin_functions() { let Some(node) = plugin_sdk::node_from_environment("the Node.js SDK end-to-end check") else { diff --git a/sdk/e2e/plugin_sdk_rust.rs b/sdk/e2e/plugin_sdk_rust.rs index 217a2ea..48f5bac 100644 --- a/sdk/e2e/plugin_sdk_rust.rs +++ b/sdk/e2e/plugin_sdk_rust.rs @@ -8,6 +8,12 @@ mod plugin_sdk; +#[test] +fn the_rust_sdk_import_provider_reads_a_selected_source_through_core() { + let Some(executable) = std::env::var_os("LITHE_PLUGIN_SDK_RUST_SAMPLE") else { return; }; + plugin_sdk::keymap::run("rust-keymap", &executable.to_string_lossy(), &[]); +} + #[test] fn the_rust_sdk_sample_backend_serves_functions_and_host_services() { let Some(executable) = std::env::var_os("LITHE_PLUGIN_SDK_RUST_SAMPLE") else { diff --git a/sdk/java/README.md b/sdk/java/README.md index c214a7d..bd281dd 100644 --- a/sdk/java/README.md +++ b/sdk/java/README.md @@ -4,7 +4,7 @@ the process a plugin starts through a `runtime` launch (`{ "kind": "runtime", "tool": "java", "minimumVersion": "8", "entry": "backend/app.jar" }`). It is the Java twin of the [Node.js SDK](../node/README.md). Both implement the -same two protocols from +the following protocols from [plugin contract v1](../../contract/v1/README.md) with the same rules and error mapping: @@ -16,11 +16,24 @@ same rules and error mapping: cancellation, and registration at runtime. From version 2 a backend may call [host services](#host-services); version 3 adds workspace, document, editor, and window services, command events, and workspace contexts. +- **`litheKeymapImport` 1** (unreleased): independently negotiated shortcut + import providers reading only explicitly selected source snapshots. The SDK requires Java 8 or newer and has no runtime dependencies. JSON values are plain `Map`, `List`, `String`, `Long`, `Double`, `Boolean`, and `null` (see `Json`). +## Shortcut import providers (unreleased) + +Declare `backend.keymapImportProvider(id, handler)` before listening. Its +handler receives a `KeymapSnapshot` with `providerId()`, `snapshotId()`, +`listInputs()` and `readChunk(fileId, offset, maximumBytes)`, plus the normal +call context. Paths returned by `listInputs` are relative labels; reads use +opaque file IDs. Return a normalized `{ bindings: [...] }` map and observe +the call's cancellation. The host revokes the snapshot when the call ends. +Do not turn conditional or multi-stroke rules into global bindings. Product +discovery and the import workflow are not available yet. + ## A minimal backend ```java @@ -75,7 +88,7 @@ JAR it starts must hold everything it loads. | API | Purpose | | --- | --- | -| `Backend.create(name, version[, capabilities])` | Creates the backend. `capabilities` are standard LSP server capabilities. `experimental.litheBuild` and `experimental.litheFunctions` are reserved for the SDK. | +| `Backend.create(name, version[, capabilities])` | Creates the backend. `capabilities` are standard LSP server capabilities; the SDK negotiates its experimental protocols. | | `onModel(handler)`, `onDependencies(handler)` | Implement the typed build requests. The SDK stamps `format` and `version`. Core validates the document. `litheBuild` is answered only when a handler exists. | | `onExecute(handler)` | Protocol 2+: a bounded provider-owned task, returning `{ exitCode, output }`. | | `onTest(handler)`, `onDiscoverTests(handler)` | Protocol 3+: exact execution and runnable selectors; discovery format/version are stamped. | diff --git a/sdk/java/examples/sample-build/src/main/java/org/example/samplebuild/SampleBuild.java b/sdk/java/examples/sample-build/src/main/java/org/example/samplebuild/SampleBuild.java index 492f9e2..cc89284 100644 --- a/sdk/java/examples/sample-build/src/main/java/org/example/samplebuild/SampleBuild.java +++ b/sdk/java/examples/sample-build/src/main/java/org/example/samplebuild/SampleBuild.java @@ -57,6 +57,14 @@ public static void main(String[] arguments) { } private void serve() { + // Exercised only when the Core end-to-end harness offers this provider. + backend.keymapImportProvider("fixture", (snapshot, context) -> { + Backend.KeymapSourceFile file = snapshot.listInputs().get().get(0); + byte[] bytes = snapshot.readChunk(file.id(), 0, 4).get(); + if (!"data".equals(new String(bytes, StandardCharsets.UTF_8))) throw new IllegalStateException("Unexpected fixture source"); + return object("bindings", java.util.Collections.singletonList(object("commandID", "find-next", "keys", + java.util.Collections.singletonList(object("key", "f3", "modifiers", java.util.Collections.emptyList()))))); + }); backend.onInitialize((params, negotiated) -> { // `Paths.get(URI)` accepts the trailing slash a directory URI may carry. workspaceRoot = Paths.get(URI.create((String) params.get("rootUri"))).toAbsolutePath().normalize(); diff --git a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Backend.java b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Backend.java index 9ef2992..9058e61 100644 --- a/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Backend.java +++ b/sdk/java/plugin-sdk/src/main/java/io/github/litheidea/plugin/sdk/Backend.java @@ -13,6 +13,9 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.Base64; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.concurrent.ExecutionException; @@ -45,6 +48,74 @@ public interface Handler { Object handle(Map params, CallContext context) throws Exception; } + /** Answers an independently negotiated shortcut import request. */ + @FunctionalInterface + public interface KeymapImportHandler { + Object handle(KeymapSnapshot snapshot, CallContext context) throws Exception; + } + + /** One file within the user's selected source; its path is a relative label. */ + public static final class KeymapSourceFile { + private final String id; + private final String path; + private final long byteCount; + KeymapSourceFile(String id, String path, long byteCount) { this.id = id; this.path = path; this.byteCount = byteCount; } + public String id() { return id; } + public String path() { return path; } + public long byteCount() { return byteCount; } + } + + /** Opaque selected source scoped to one import and backend session. */ + public static final class KeymapSnapshot { + private final String providerId; + private final String snapshotId; + private final Host host; + KeymapSnapshot(String providerId, String snapshotId, Host host) { + this.providerId = providerId; this.snapshotId = snapshotId; this.host = host; + } + public String providerId() { return providerId; } + public String snapshotId() { return snapshotId; } + private Map scope() { + Map result = singleton("providerID", providerId); + result.put("snapshotID", snapshotId); + return result; + } + public CompletableFuture> listInputs() { + return host.request("lithe/host/keymapImport/listInputs", scope()).thenApply(raw -> { + Object files = raw instanceof Map ? ((Map) raw).get("files") : null; + if (!(files instanceof List) || ((List) files).size() > 128) throw invalidKeymapResponse(); + List result = new ArrayList<>(); + for (Object item : (List) files) { + if (!(item instanceof Map)) throw invalidKeymapResponse(); + Map file = (Map) item; + Object id = file.get("id"), path = file.get("path"), count = file.get("byteCount"); + if (!(id instanceof String) || !validSnapshotToken((String) id) || !(path instanceof String) + || ((String) path).isEmpty() || ((String) path).getBytes(StandardCharsets.UTF_8).length > 4096 + || !(count instanceof Number) || ((Number) count).doubleValue() != ((Number) count).longValue() + || ((Number) count).longValue() < 0 || ((Number) count).longValue() > 1048576) throw invalidKeymapResponse(); + result.add(new KeymapSourceFile((String) id, (String) path, ((Number) count).longValue())); + } + return result; + }); + } + public CompletableFuture readChunk(String fileId, long offset, int maximumBytes) { + if (!validSnapshotToken(fileId) || offset < 0 || offset > 1048576 || maximumBytes < 1 || maximumBytes > 131072) { + throw invalidKeymapResponse(); + } + Map params = scope(); + params.put("fileID", fileId); params.put("offset", offset); params.put("maximumBytes", maximumBytes); + return host.request("lithe/host/keymapImport/readChunk", params).thenApply(raw -> { + Object encoded = raw instanceof Map ? ((Map) raw).get("data") : null; + if (!(encoded instanceof String) || ((String) encoded).length() > 174764) throw invalidKeymapResponse(); + final byte[] bytes; + try { bytes = Base64.getDecoder().decode((String) encoded); } + catch (IllegalArgumentException error) { throw invalidKeymapResponse(); } + if (bytes.length > maximumBytes || !Base64.getEncoder().encodeToString(bytes).equals(encoded)) throw invalidKeymapResponse(); + return bytes; + }); + } + } + /** Answers a standard LSP request, for a backend that is also a language server. */ @FunctionalInterface public interface RequestHandler { @@ -135,6 +206,8 @@ private enum Phase { IDLE, LISTENING, INITIALIZING, READY, SHUTTING_DOWN, EXITED private final Object lock = new Object(); private final Object writeLock = new Object(); private final Map functions = new HashMap<>(); + private final Map keymapProviders = new HashMap<>(); + private final Set selectedKeymapProviders = new TreeSet<>(); private final Map buildHandlers = new HashMap<>(); private final Map requestHandlers = new HashMap<>(); private final Map notificationHandlers = new HashMap<>(); @@ -164,9 +237,10 @@ private Backend(String name, String version, Map capabilities) { Object experimental = capabilities.get("experimental"); if (experimental instanceof Map && (((Map) experimental).containsKey("litheBuild") - || ((Map) experimental).containsKey("litheFunctions"))) { + || ((Map) experimental).containsKey("litheFunctions") + || ((Map) experimental).containsKey("litheKeymapImport"))) { throw new PluginSdkError("sdk.capabilities.reserved", - "litheBuild and litheFunctions are negotiated by the SDK; declare handlers instead."); + "litheBuild, litheFunctions and litheKeymapImport are negotiated by the SDK; declare handlers instead."); } this.name = name == null ? "lithe-plugin-backend" : name; this.version = version; @@ -273,6 +347,18 @@ public Backend function(String id, Handler handler) { return this; } + /** Declares a shortcut import provider before initialization. */ + public Backend keymapImportProvider(String id, KeymapImportHandler handler) { + synchronized (lock) { + if (!beforeAnswer() || !validKeymapProvider(id) || handler == null + || keymapProviders.containsKey(id) || keymapProviders.size() >= 8) { + throw new PluginSdkError("sdk.keymap.invalidRegistration", "Invalid or duplicate shortcut import provider."); + } + keymapProviders.put(id, handler); + } + return this; + } + /** Handles a standard LSP request, for a backend that is also a language server. */ public Backend onRequest(String method, RequestHandler handler) { checkDeclared(method, handler); @@ -578,6 +664,10 @@ private void handleRequest(Object id, String method, Object params) { invokeFunction(id, params); return; } + if (method.equals("lithe/keymap/import")) { + invokeKeymap(id, params); + return; + } Handler build; RequestHandler handler; synchronized (lock) { @@ -742,6 +832,22 @@ private void initialize(Object id, Map params) { + "; functions stay unavailable."); } Map experimental = new LinkedHashMap<>(experimental(capabilities)); + Map offeredCapabilities = experimental(params.get("capabilities")); + Object keymap = offeredCapabilities.get("litheKeymapImport"); + if (offers(keymap, 1) && keymap instanceof Map) { + Object providers = ((Map) keymap).get("providers"); + if (providers instanceof List && ((List) providers).size() <= 8) { + selectedKeymapProviders.clear(); + for (Object provider : (List) providers) { + if (provider instanceof String && keymapProviders.containsKey(provider)) selectedKeymapProviders.add((String) provider); + } + if (!selectedKeymapProviders.isEmpty()) { + Map entry = singleton("version", 1); + entry.put("providers", new ArrayList<>(selectedKeymapProviders)); + experimental.put("litheKeymapImport", entry); + } + } + } if (agreed.build()) { experimental.put("litheBuild", singleton("version", agreed.buildVersion())); } @@ -792,6 +898,34 @@ private void shutdown(Object id) { }); } + private void invokeKeymap(Object id, Object raw) { + Map params = raw instanceof Map ? Json.object(raw) : new LinkedHashMap<>(); + Object provider = params.get("providerID"), snapshot = params.get("snapshotID"); + KeymapImportHandler handler; + synchronized (lock) { + handler = provider instanceof String && selectedKeymapProviders.contains(provider) ? keymapProviders.get(provider) : null; + } + if (params.size() != 2 || !(provider instanceof String) || !validKeymapProvider((String) provider) + || !(snapshot instanceof String) || !validSnapshotToken((String) snapshot) || handler == null) { + respond(id, null, error(Protocol.INVALID_PARAMS, "Invalid shortcut import request.")); + return; + } + run(id, null, null, context -> handler.handle(new KeymapSnapshot((String) provider, (String) snapshot, context.host()), context), + Backend::functionResult, null); + } + + private static boolean validKeymapProvider(String id) { + return id != null && id.matches("[a-z][a-z0-9-]{0,63}"); + } + + private static boolean validSnapshotToken(String id) { + return id != null && id.matches("[a-zA-Z0-9_-]{1,128}"); + } + + private static PluginSdkError invalidKeymapResponse() { + return new PluginSdkError("sdk.keymap.invalidResponse", "Invalid shortcut source response."); + } + private void invokeFunction(Object id, Object raw) { Map invocation = raw instanceof Map ? Json.object(raw) : new LinkedHashMap<>(); Object functionId = invocation.get("functionID"); diff --git a/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/KeymapTest.java b/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/KeymapTest.java new file mode 100644 index 0000000..4a25697 --- /dev/null +++ b/sdk/java/plugin-sdk/src/test/java/io/github/litheidea/plugin/sdk/KeymapTest.java @@ -0,0 +1,92 @@ +package io.github.litheidea.plugin.sdk; + +import static io.github.litheidea.plugin.sdk.Fixtures.map; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.util.Arrays; +import java.util.Collections; +import java.util.Map; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +@Timeout(value = 30, unit = TimeUnit.SECONDS) +class KeymapTest { + @SuppressWarnings("unchecked") + private static Map object(Object value) { return (Map) value; } + + @Test + void negotiatesOnlyOfferedProvidersAndRejectsUnscopedRequests() { + Backend backend = Backend.create("test-backend", null) + .keymapImportProvider("first", (snapshot, context) -> { + assertEquals("first", snapshot.providerId()); + assertEquals("snapshot-1", snapshot.snapshotId()); + return map("bindings", Collections.emptyList()); + }) + .keymapImportProvider("not-offered", (snapshot, context) -> map("bindings", Collections.emptyList())); + TestHost host = new TestHost(backend); + try { + Map initialized = host.initialize(map("litheKeymapImport", + map("versions", Arrays.asList(1), "providers", Arrays.asList("first", "unknown")))); + Map capabilities = object(object(initialized.get("result")).get("capabilities")); + assertEquals(map("version", 1L, "providers", Arrays.asList("first")), + object(capabilities.get("experimental")).get("litheKeymapImport")); + assertEquals(map("bindings", Collections.emptyList()), host.request("lithe/keymap/import", + map("providerID", "first", "snapshotID", "snapshot-1")).get("result")); + assertNotNull(host.request("lithe/keymap/import", map("providerID", "not-offered", "snapshotID", "snapshot-1")).get("error")); + assertNotNull(host.request("lithe/keymap/import", map("providerID", "first", "snapshotID", "../private")).get("error")); + } finally { host.close(); } + } + + @Test + void readsTheSelectedSnapshotThroughTheScopedHost() { + Backend backend = Backend.create("test-backend", null).keymapImportProvider("first", (snapshot, context) -> { + Backend.KeymapSourceFile file = snapshot.listInputs().get(3, TimeUnit.SECONDS).get(0); + byte[] bytes = snapshot.readChunk(file.id(), 0, 4).get(3, TimeUnit.SECONDS); + assertEquals("data", new String(bytes, StandardCharsets.UTF_8)); + return map("bindings", Arrays.asList(map("commandID", "find-next", "keys", + Arrays.asList(map("key", "f3", "modifiers", Collections.emptyList()))))); + }); + TestHost host = new TestHost(backend); + try { + host.initialize(map("litheFunctions", map("versions", Arrays.asList(3)), "litheKeymapImport", + map("versions", Arrays.asList(1), "providers", Arrays.asList("first")))); + CompletableFuture> pending = host.requestAsync("lithe/keymap/import", + map("providerID", "first", "snapshotID", "snapshot-1"), 101L); + Map list = host.waitFor(message -> "lithe/host/keymapImport/listInputs".equals(message.get("method")), "list inputs"); + host.send(map("id", list.get("id"), "result", map("files", Arrays.asList(map("id", "file-0", "path", "keys.json", "byteCount", 4))))); + Map read = host.waitFor(message -> "lithe/host/keymapImport/readChunk".equals(message.get("method")), "read chunk"); + assertEquals("file-0", object(read.get("params")).get("fileID")); + host.send(map("id", read.get("id"), "result", map("data", "ZGF0YQ=="))); + assertEquals(map("bindings", Arrays.asList(map("commandID", "find-next", "keys", + Arrays.asList(map("key", "f3", "modifiers", Collections.emptyList()))))), + Fixtures.await(pending, "import result").get("result")); + } finally { host.close(); } + } + + @Test + void cancellationReachesTheRunningProvider() { + CountDownLatch entered = new CountDownLatch(1), cancelled = new CountDownLatch(1); + Backend backend = Backend.create("test-backend", null).keymapImportProvider("first", (snapshot, context) -> { + context.cancellation().onCancel(cancelled::countDown); + entered.countDown(); + if (!cancelled.await(3, TimeUnit.SECONDS)) throw new AssertionError("provider was not cancelled"); + context.throwIfCancelled(); + return map("bindings", Collections.emptyList()); + }); + TestHost host = new TestHost(backend); + try { + host.initialize(map("litheKeymapImport", map("versions", Arrays.asList(1), "providers", Arrays.asList("first")))); + CompletableFuture> pending = host.requestAsync("lithe/keymap/import", + map("providerID", "first", "snapshotID", "snapshot-1"), 102L); + Fixtures.await(entered, "provider started"); + host.notify("$/cancelRequest", map("id", 102L)); + Fixtures.await(cancelled, "provider cancelled"); + assertEquals(-32800L, object(Fixtures.await(pending, "cancelled import").get("error")).get("code")); + } finally { host.close(); } + } +} diff --git a/sdk/node/README.md b/sdk/node/README.md index 2ddcf60..292491b 100644 --- a/sdk/node/README.md +++ b/sdk/node/README.md @@ -19,11 +19,24 @@ on stdio and implements Lithe's backend protocols from - **`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). +- **`litheKeymapImport` 1** (unreleased): independently negotiated shortcut + import providers reading only explicitly selected source snapshots. The SDK owns the wire format, negotiation, cancellation, process cleanup, and error mapping. Your code supplies only handlers. Node 20.3 or newer is required; declare it as the runtime's `minimumVersion`. +## Shortcut import providers (unreleased) + +Declare `backend.keymapImportProvider(id, handler)` before listening. Its +handler receives a snapshot with `providerID`, `snapshotID`, `listInputs()` +and `readChunk(fileID, offset, maximumBytes)`, plus the normal call context. +Paths returned by `listInputs` are relative labels; reads use opaque file IDs. +Return `{ bindings: [...] }` in the normalized contract format and observe the +call's cancellation signal. The host revokes the snapshot when the call ends. +Do not turn conditional or multi-stroke rules into global bindings. Product +discovery and the import workflow are not available yet. + ## A minimal backend ```js @@ -121,7 +134,7 @@ package. The SDK has no dependencies. | API | Purpose | | --- | --- | -| `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. | +| `createBackend({ name?, version?, capabilities? })` | Creates the backend. `capabilities` are standard LSP server capabilities; the SDK negotiates its experimental protocols. | | `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.build.execute(handler)` | Protocol 2+: provider-owned bounded task, returning `{ exitCode, output }`. | | `backend.build.test(handler)`, `backend.build.discoverTests(handler)` | Protocol 3+: exact test execution and runnable selectors; discovery gets `format: "litheTestDiscovery"`, `version: 1`. | diff --git a/sdk/node/examples/sample-build/backend/main.mjs b/sdk/node/examples/sample-build/backend/main.mjs index 0d3a826..79ca6b0 100644 --- a/sdk/node/examples/sample-build/backend/main.mjs +++ b/sdk/node/examples/sample-build/backend/main.mjs @@ -24,6 +24,13 @@ import { fileURLToPath } from 'node:url'; import { createBackend, openStateStore, PluginError } from '../../../src/index.mjs'; const backend = createBackend({ name: 'sample-build', version: '0.1.0' }); +// Exercised only when the Core end-to-end harness offers this provider. +backend.keymapImportProvider('fixture', async (snapshot) => { + const files = await snapshot.listInputs(); + const bytes = await snapshot.readChunk(files[0].id, 0, 4); + if (Buffer.from(bytes).toString('utf8') !== 'data') throw new Error('Unexpected fixture source'); + return { bindings: [{ commandID: 'find-next', keys: [{ key: 'f3', modifiers: [] }] }] }; +}); let workspaceRoot; let store; diff --git a/sdk/node/src/backend.mjs b/sdk/node/src/backend.mjs index dd3a94e..184577a 100644 --- a/sdk/node/src/backend.mjs +++ b/sdk/node/src/backend.mjs @@ -52,7 +52,7 @@ const VCS_TRAITS = Object.freeze(['push', 'staging']); * @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`, - * `experimental.litheFunctions`, and `experimental.litheVcs` are negotiated by + * `experimental.litheFunctions`, `experimental.litheVcs`, and `experimental.litheKeymapImport` are negotiated by * the SDK and cannot be set here. */ export function createBackend(options = {}) { @@ -64,6 +64,8 @@ class Backend { #version; #capabilities; #functions = new Map(); + #keymapProviders = new Map(); + #selectedKeymapProviders = new Set(); #buildHandlers = new Map(); #buildVersion = null; #functionsVersion = null; @@ -104,10 +106,10 @@ class Backend { constructor({ name = 'lithe-plugin-backend', version, capabilities = {} } = {}) { const experimental = capabilities.experimental ?? {}; - if ('litheBuild' in experimental || 'litheFunctions' in experimental || 'litheVcs' in experimental) { + if ('litheBuild' in experimental || 'litheFunctions' in experimental || 'litheVcs' in experimental || 'litheKeymapImport' in experimental) { throw new PluginSdkError( 'sdk.capabilities.reserved', - 'litheBuild, litheFunctions, and litheVcs are negotiated by the SDK; declare handlers instead.', + 'litheBuild, litheFunctions, litheVcs, and litheKeymapImport are negotiated by the SDK; declare handlers instead.', ); } this.#name = String(name); @@ -173,6 +175,16 @@ class Backend { return this; } + /** Declares an independent typed shortcut import provider. */ + keymapImportProvider(id, handler) { + if (!this.#beforeAnswer() || !validKeymapProvider(id) || typeof handler !== 'function' + || this.#keymapProviders.has(id) || this.#keymapProviders.size >= 8) { + throw new PluginSdkError('sdk.keymap.invalidRegistration', 'Invalid or duplicate shortcut import provider.'); + } + this.#keymapProviders.set(id, handler); + return this; + } + /** * Declares the VCS this backend provides and the operations it answers, * before `listen()`. Each handler gets the operation's arguments plus @@ -449,6 +461,10 @@ class Backend { this.#invokeFunction(id, params ?? {}); return; } + if (method === 'lithe/keymap/import') { + this.#invokeKeymap(id, params); + return; + } const build = this.#buildHandlers.get(method); if (build && this.#buildVersion >= BUILD_METHOD_VERSIONS[method]) { this.#invokeBuild(id, method, params ?? {}, build); @@ -556,6 +572,13 @@ class Backend { this.log(`The host did not offer a litheFunctions version from 1 to ${FUNCTIONS_VERSION}; functions stay unavailable.`); } const experimental = { ...(this.#capabilities.experimental ?? {}) }; + if (offers(offered.litheKeymapImport, 1) && Array.isArray(offered.litheKeymapImport?.providers) + && offered.litheKeymapImport.providers.length <= 8) { + this.#selectedKeymapProviders = new Set(offered.litheKeymapImport.providers.filter((id) => this.#keymapProviders.has(id))); + if (this.#selectedKeymapProviders.size) { + experimental.litheKeymapImport = { version: 1, providers: [...this.#selectedKeymapProviders].sort() }; + } + } if (this.#negotiated.build) experimental.litheBuild = { version: this.#buildVersion }; if (this.#negotiated.vcs) { experimental.litheVcs = { version: VCS_VERSION, vcs: this.#vcs.id, capabilities: this.#vcs.capabilities }; @@ -620,6 +643,16 @@ class Backend { } : context), (result) => buildResult(method, result), () => { reporting = false; }); } + #invokeKeymap(id, params) { + if (!isObject(params) || Object.keys(params).length !== 2 || !validKeymapProvider(params.providerID) + || !validSnapshotToken(params.snapshotID) || !this.#selectedKeymapProviders.has(params.providerID)) { + this.#respond(id, undefined, { code: ERROR_CODES.invalidParams, message: 'Invalid shortcut import request.' }); + return; + } + const handler = this.#keymapProviders.get(params.providerID); + this.#run(id, null, (context) => handler(keymapSnapshot(params, context.host), context), functionResult); + } + #invokeFunction(id, { functionID, operationID, params, context: workspaceContext }) { const handler = this.#negotiated.functions ? this.#functions.get(functionID) : undefined; if (!handler) { @@ -914,6 +947,38 @@ function isObject(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); } +function validKeymapProvider(id) { + return typeof id === 'string' && /^[a-z][a-z0-9-]{0,63}$/.test(id); +} + +function validSnapshotToken(id) { + return typeof id === 'string' && /^[a-zA-Z0-9_-]{1,128}$/.test(id); +} + +function keymapSnapshot({ providerID, snapshotID }, host) { + const scope = { providerID, snapshotID }; + const invalid = () => new PluginSdkError('sdk.keymap.invalidResponse', 'Invalid shortcut source response.'); + return Object.freeze({ + providerID, snapshotID, + async listInputs() { + const result = await host.request('lithe/host/keymapImport/listInputs', scope); + if (!Array.isArray(result?.files) || result.files.length > 128 || result.files.some((file) => + !validSnapshotToken(file?.id) || typeof file.path !== 'string' || !file.path || Buffer.byteLength(file.path) > 4096 + || !Number.isSafeInteger(file.byteCount) || file.byteCount < 0 || file.byteCount > 1048576)) throw invalid(); + return result.files.map(({ id, path, byteCount }) => ({ id, path, byteCount })); + }, + async readChunk(fileID, offset, maximumBytes) { + if (!validSnapshotToken(fileID) || !Number.isSafeInteger(offset) || offset < 0 || offset > 1048576 + || !Number.isInteger(maximumBytes) || maximumBytes < 1 || maximumBytes > 131072) throw invalid(); + const result = await host.request('lithe/host/keymapImport/readChunk', { ...scope, fileID, offset, maximumBytes }); + if (typeof result?.data !== 'string' || result.data.length > 174764) throw invalid(); + const bytes = Buffer.from(result.data, 'base64'); + if (bytes.length > maximumBytes || bytes.toString('base64') !== result.data) throw invalid(); + return bytes; + }, + }); +} + /** * 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. diff --git a/sdk/node/src/index.d.ts b/sdk/node/src/index.d.ts index a2cbc50..d3d2af4 100644 --- a/sdk/node/src/index.d.ts +++ b/sdk/node/src/index.d.ts @@ -311,6 +311,21 @@ export interface FunctionContext extends RequestContext { export type FunctionHandler = (params: JsonObject, context: FunctionContext) => unknown | Promise; export type BuildHandler = (params: BuildRequestParams, context: RequestContext) => JsonObject | Promise; +/** A file captured under the selected shortcut source. `path` is a relative label. */ +export interface KeymapSourceFile { id: string; path: string; byteCount: number } +export interface KeymapImportSnapshot { + readonly providerID: string; + readonly snapshotID: string; + listInputs(): Promise; + readChunk(fileID: string, offset: number, maximumBytes: number): Promise; +} +export interface KeymapImportBinding { + commandID: string; + keys: Array<{ key: string; modifiers: Array<'control' | 'alt' | 'shift' | 'meta'> }>; +} +export type KeymapImportHandler = (snapshot: KeymapImportSnapshot, context: RequestContext) => + { bindings: KeymapImportBinding[] } | Promise<{ bindings: KeymapImportBinding[] }>; + /** Params of `lithe/build/model` and `lithe/build/dependencies`. */ export interface BuildRequestParams { /** Read-only host snapshot; missing producers remain unavailable. Requires protocol 6. */ @@ -465,7 +480,7 @@ export interface Negotiated { export interface BackendOptions { name?: string; version?: string; - /** Standard LSP server capabilities; `experimental.litheBuild`, `litheFunctions`, and `litheVcs` are reserved. */ + /** Standard LSP server capabilities; the SDK negotiates its experimental protocols. */ capabilities?: JsonObject; } @@ -498,6 +513,8 @@ export interface Backend { }; vcs(id: string, handlers: VcsHandlers, traits?: VcsTraits): Backend; function(id: string, handler: FunctionHandler): Backend; + /** Declares an independent shortcut import provider before initialize. */ + keymapImportProvider(id: string, handler: KeymapImportHandler): Backend; registerFunction(id: string, handler: FunctionHandler): Promise; unregisterFunction(id: string): Promise; onRequest(method: string, handler: (params: unknown, context: RequestContext) => unknown): Backend; diff --git a/sdk/node/test/keymap.test.mjs b/sdk/node/test/keymap.test.mjs new file mode 100644 index 0000000..50170ad --- /dev/null +++ b/sdk/node/test/keymap.test.mjs @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { createBackend } from '../src/index.mjs'; +import { gate, TestHost, withTimeout } from './harness.mjs'; + +test('shortcut providers negotiate separately and read scoped snapshot inputs', async () => { + const backend = createBackend({ name: 'test-backend' }); + backend.keymapImportProvider('first', async (snapshot) => { + const files = await snapshot.listInputs(); + const bytes = await snapshot.readChunk(files[0].id, 0, 4); + assert.equal(Buffer.from(bytes).toString('utf8'), 'data'); + return { bindings: [{ commandID: 'find-next', keys: [{ key: 'f3', modifiers: [] }] }] }; + }); + backend.keymapImportProvider('not-offered', () => ({ bindings: [] })); + const host = new TestHost(backend); + try { + const { result } = await host.initialize({ + litheFunctions: { versions: [3] }, + litheKeymapImport: { versions: [1], providers: ['first', 'unknown'] }, + }); + assert.deepEqual(result.capabilities.experimental.litheKeymapImport, { version: 1, providers: ['first'] }); + const pending = host.request('lithe/keymap/import', { providerID: 'first', snapshotID: 'snapshot-1' }); + const list = await host.waitFor((message) => message.method === 'lithe/host/keymapImport/listInputs'); + host.send({ id: list.id, result: { files: [{ id: 'file-0', path: 'keys.json', byteCount: 4 }] } }); + const read = await host.waitFor((message) => message.method === 'lithe/host/keymapImport/readChunk'); + assert.equal(read.params.fileID, 'file-0'); + host.send({ id: read.id, result: { data: 'ZGF0YQ==' } }); + assert.deepEqual((await pending).result, { bindings: [{ commandID: 'find-next', keys: [{ key: 'f3', modifiers: [] }] }] }); + assert.ok((await host.request('lithe/keymap/import', { providerID: 'not-offered', snapshotID: 'snapshot-1' })).error); + assert.ok((await host.request('lithe/keymap/import', { providerID: 'first', snapshotID: '../private' })).error); + } finally { await host.close(); } +}); + +test('cancellation reaches an active shortcut provider', async () => { + const started = gate(); + const backend = createBackend().keymapImportProvider('first', async (_snapshot, context) => { + started.open(); + await new Promise((resolve) => context.signal.addEventListener('abort', resolve, { once: true })); + context.throwIfCancelled(); + return { bindings: [] }; + }); + const host = new TestHost(backend); + try { + await host.initialize({ litheKeymapImport: { versions: [1], providers: ['first'] } }); + const pending = host.request('lithe/keymap/import', { providerID: 'first', snapshotID: 'snapshot-1' }, 42); + await withTimeout(started.opened, 'provider to start'); + host.notify('$/cancelRequest', { id: 42 }); + assert.equal((await pending).error.code, -32800); + } finally { await host.close(); } +}); diff --git a/sdk/node/test/types/keymap.mts b/sdk/node/test/types/keymap.mts new file mode 100644 index 0000000..3254575 --- /dev/null +++ b/sdk/node/test/types/keymap.mts @@ -0,0 +1,8 @@ +import { createBackend } from '../../src/index.mjs'; + +createBackend().keymapImportProvider('example', async (snapshot, context) => { + context.throwIfCancelled(); + const files = await snapshot.listInputs(); + const bytes: Uint8Array = await snapshot.readChunk(files[0].id, 0, 1024); + return { bindings: bytes.length ? [{ commandID: 'find-next', keys: [{ key: 'f3', modifiers: [] }] }] : [] }; +}); diff --git a/sdk/rust/Cargo.lock b/sdk/rust/Cargo.lock index 615f3ed..dca7db9 100644 --- a/sdk/rust/Cargo.lock +++ b/sdk/rust/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "itoa" version = "1.0.18" @@ -12,6 +18,7 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" name = "lithe-plugin-sdk" version = "0.1.0" dependencies = [ + "base64", "serde_json", ] diff --git a/sdk/rust/README.md b/sdk/rust/README.md index 334ea7b..7496a78 100644 --- a/sdk/rust/README.md +++ b/sdk/rust/README.md @@ -15,15 +15,30 @@ platform. It speaks the LSP base protocol on stdio and implements, from by the host in the platform's secret store (the Keychain on macOS), and from version 3 workspace contexts, files, documents, editors, preview, the browser, window messages, commands, and subscriptions. +- **`litheKeymapImport` 1** (unreleased): independently negotiated shortcut + import providers reading only explicitly selected source snapshots. The SDK owns the wire format, negotiation, cancellation, and error mapping. -Your code supplies only handlers. It depends only on `serde_json`, runs +Your code supplies only handlers. It depends on `serde_json` and `base64`, runs every call on a thread of its own, and needs Rust 1.87 or newer. Build providers and VCS providers are not part of this SDK yet; write build providers with the [Node.js](../node/README.md) or [Java](../java/README.md) SDK, and VCS providers with the Node.js SDK. +## Shortcut import providers (unreleased) + +Declare `keymap_import_provider(id, handler)` before listening. The host +negotiates IDs independently of plugin functions; a handler receives a +`KeymapImportRequest` and a call-scoped `Context`. Use +`request.list_inputs(context.host())` and `request.read_chunk(context.host(), +file_id, offset, maximum_bytes)` to read declared files in bounded chunks. +`KeymapSourceFile.path` is a relative label; the file ID is the authority. +Return `{ "bindings": [...] }` in the normalized contract format. The host +cancels the call on import cancellation and revokes the snapshot when it ends. +Do not convert conditional or multi-stroke rules into global bindings. Product +discovery and the import workflow are not available yet. + ## A minimal backend ```json diff --git a/sdk/rust/examples/sample-backend/src/main.rs b/sdk/rust/examples/sample-backend/src/main.rs index 7b69d04..6512dee 100644 --- a/sdk/rust/examples/sample-backend/src/main.rs +++ b/sdk/rust/examples/sample-backend/src/main.rs @@ -38,6 +38,14 @@ fn main() { let mut backend = lithe_plugin_sdk::Backend::new("sample-backend").version(env!("CARGO_PKG_VERSION")); + // Exercised only when the Core end-to-end harness offers this provider. + backend.keymap_import_provider("fixture", |snapshot, context| { + let files = snapshot.list_inputs(context.host())?; + let bytes = snapshot.read_chunk(context.host(), &files[0].id, 0, 4)?; + if bytes != b"data" { return Err(FunctionError::internal("Unexpected fixture source")); } + Ok(json!({"bindings": [{"commandID": "find-next", "keys": [{"key": "f3", "modifiers": []}]}]})) + }).expect("valid fixture provider"); + let notes = Arc::clone(&state); backend .function("note.save", move |params, context| { diff --git a/sdk/rust/plugin-sdk/Cargo.toml b/sdk/rust/plugin-sdk/Cargo.toml index aeb2eae..7097aab 100644 --- a/sdk/rust/plugin-sdk/Cargo.toml +++ b/sdk/rust/plugin-sdk/Cargo.toml @@ -9,3 +9,4 @@ publish = false [dependencies] serde_json.workspace = true +base64 = "0.22" diff --git a/sdk/rust/plugin-sdk/src/backend.rs b/sdk/rust/plugin-sdk/src/backend.rs index 25497f1..57aad09 100644 --- a/sdk/rust/plugin-sdk/src/backend.rs +++ b/sdk/rust/plugin-sdk/src/backend.rs @@ -17,9 +17,10 @@ use crate::protocol::{ bounded_text, error_codes, is_command_id, is_function_id, methods, CONTEXT_VERSION, FUNCTIONS_VERSIONS, MAX_FUNCTIONS, MAX_MESSAGE_BYTES, MAX_RESULT_BYTES, }; +use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; use serde_json::{json, Map, Value}; use std::any::Any; -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::fmt::Display; use std::io::{self, BufReader, Read, Write}; use std::panic::{catch_unwind, AssertUnwindSafe}; @@ -51,6 +52,8 @@ pub(crate) enum Phase { pub(crate) struct State { pub(crate) phase: Phase, functions: BTreeMap, + keymap_providers: BTreeMap, + selected_keymap_providers: BTreeSet, /// The negotiated plugin-function protocol version. functions_version: Option, /// Host requests being handled, by JSON-RPC ID. @@ -133,6 +136,8 @@ impl Backend { state: Mutex::new(State { phase: Phase::Idle, functions: BTreeMap::new(), + keymap_providers: BTreeMap::new(), + selected_keymap_providers: BTreeSet::new(), functions_version: None, incoming: HashMap::new(), outgoing: HashMap::new(), @@ -175,6 +180,51 @@ impl Backend { Ok(self) } + /// Declares an independent typed shortcut import provider. Its handler + /// receives an opaque snapshot, never a filesystem path; it can use the + /// call-scoped host to list and read the explicitly selected inputs. + pub fn keymap_import_provider(&mut self, id: &str, handler: F) -> Result<&mut Self, SdkError> + where + F: Fn(KeymapImportRequest, &Context) -> Result + + Send + + Sync + + 'static, + { + if !valid_keymap_provider_id(id) { + return Err(SdkError::new( + "sdk.keymap.invalidID", + "Invalid shortcut import provider ID.", + )); + } + let mut state = lock(&self.shared.state); + if state.keymap_providers.contains_key(id) || state.keymap_providers.len() >= 8 { + return Err(SdkError::new( + "sdk.keymap.invalidRegistration", + "Duplicate provider or provider limit exceeded.", + )); + } + state.keymap_providers.insert( + id.to_string(), + Arc::new(move |params, context| { + handler( + KeymapImportRequest { + provider_id: params["providerID"] + .as_str() + .unwrap_or_default() + .to_string(), + snapshot_id: params["snapshotID"] + .as_str() + .unwrap_or_default() + .to_string(), + }, + context, + ) + }), + ); + drop(state); + Ok(self) + } + /// Runs before the `initialize` answer. A hook may read the host's /// params and add functions; an error refuses `initialize`, mapped like /// a handler's. @@ -350,6 +400,9 @@ impl Backend { Phase::Ready if method == methods::FUNCTION_INVOKE => { Shared::invoke(&self.shared, id, ¶ms) } + Phase::Ready if method == "lithe/keymap/import" => { + Shared::invoke_keymap(&self.shared, id, ¶ms) + } Phase::Ready => self.shared.respond( &id, Err(rpc_error( @@ -447,6 +500,9 @@ impl Backend { } state.phase = Phase::Initializing; } + let keymap_offer = params + .pointer("/capabilities/experimental/litheKeymapImport") + .cloned(); let offered = params .pointer("/capabilities/experimental/litheFunctions/versions") .and_then(Value::as_array); @@ -484,6 +540,32 @@ impl Backend { state.functions.extend(initialize.added); state.functions_version = functions_version; let mut experimental = Map::new(); + if let Some(offer) = keymap_offer { + let version = offer + .get("versions") + .and_then(Value::as_array) + .is_some_and(|versions| versions.iter().any(|version| version.as_u64() == Some(1))); + if version { + if let Some(providers) = offer + .get("providers") + .and_then(Value::as_array) + .filter(|ids| ids.len() <= 8) + { + state.selected_keymap_providers = providers + .iter() + .filter_map(Value::as_str) + .filter(|id| state.keymap_providers.contains_key(*id)) + .map(str::to_string) + .collect(); + if !state.selected_keymap_providers.is_empty() { + experimental.insert( + "litheKeymapImport".into(), + json!({"version":1,"providers":state.selected_keymap_providers}), + ); + } + } + } + } match functions_version { Some(version) => { let functions: Vec<_> = state @@ -640,6 +722,90 @@ impl Shared { }); } + fn invoke_keymap(shared: &Arc, id: Value, params: &Value) { + let provider = params + .get("providerID") + .and_then(Value::as_str) + .unwrap_or_default(); + let snapshot = params + .get("snapshotID") + .and_then(Value::as_str) + .unwrap_or_default(); + if !params.as_object().is_some_and(|object| object.len() == 2) + || !valid_keymap_provider_id(provider) + || !valid_snapshot_id(snapshot) + { + return shared.respond( + &id, + Err(rpc_error( + error_codes::INVALID_PARAMS, + "Invalid shortcut import request.", + )), + ); + } + let call_id = format!("keymap:{provider}"); + let key = request_key(&id); + let cancellation = Cancellation::new(); + let handler = { + let mut state = lock(&shared.state); + let handler = state + .selected_keymap_providers + .contains(provider) + .then(|| state.keymap_providers.get(provider).cloned()) + .flatten(); + if handler.is_some() { + state.incoming.insert( + key.clone(), + Running { + function_id: call_id.clone(), + cancellation: cancellation.clone(), + }, + ); + } + handler + }; + let Some(handler) = handler else { + return shared.respond( + &id, + Err(rpc_error( + error_codes::INVALID_PARAMS, + "Shortcut import provider was not negotiated.", + )), + ); + }; + let worker = Arc::clone(shared); + let call_params = params.clone(); + let thread_key = key.clone(); + let thread_id = id.clone(); + let spawned = thread::Builder::new() + .name("lithe-keymap-import".into()) + .spawn(move || { + let ended = Cancellation::new(); + let context = Context { + shared: Arc::clone(&worker), + function_id: call_id, + operation_id: Value::Null, + host: Host::new( + Arc::clone(&worker), + vec![cancellation.clone(), ended.clone()], + ), + cancellation, + workspace_context: None, + reporting: AtomicBool::new(false), + }; + let outcome = catch_unwind(AssertUnwindSafe(|| handler(call_params, &context))); + ended.cancel(); + let answer = worker.function_answer(&context, outcome); + lock(&worker.state).incoming.remove(&thread_key); + worker.respond(&thread_id, answer); + }); + if let Err(error) = spawned { + lock(&shared.state).incoming.remove(&key); + shared.log(format_args!("The import could not start: {error}")); + shared.respond(&id, Err(internal_response())); + } + } + fn invoke(shared: &Arc, id: Value, params: &Value) { let function_id = params["functionID"] .as_str() @@ -956,6 +1122,128 @@ impl Context { } } +/// A single host-authorized source snapshot for a shortcut import. +pub struct KeymapImportRequest { + provider_id: String, + snapshot_id: String, +} + +/// A source file in the host's selected, read-only snapshot. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct KeymapSourceFile { + pub id: String, + pub path: String, + pub byte_count: u64, +} + +impl KeymapImportRequest { + pub fn provider_id(&self) -> &str { + &self.provider_id + } + pub fn snapshot_id(&self) -> &str { + &self.snapshot_id + } + + /// Lists the declared files captured for this request. The path is + /// relative to the selected source and is only a label, never authority. + pub fn list_inputs(&self, host: &Host) -> Result, SdkError> { + let result = host.request( + "lithe/host/keymapImport/listInputs", + json!({ + "providerID": self.provider_id, "snapshotID": self.snapshot_id + }), + )?; + let invalid = || { + SdkError::new( + "sdk.keymap.invalidResponse", + "Invalid shortcut source list.", + ) + }; + let files = result + .get("files") + .and_then(Value::as_array) + .filter(|files| files.len() <= 128) + .ok_or_else(invalid)?; + files + .iter() + .map(|file| { + let id = file + .get("id") + .and_then(Value::as_str) + .filter(|id| valid_snapshot_id(id)) + .ok_or_else(invalid)?; + let path = file + .get("path") + .and_then(Value::as_str) + .filter(|path| !path.is_empty() && path.len() <= 4096) + .ok_or_else(invalid)?; + let byte_count = file + .get("byteCount") + .and_then(Value::as_u64) + .filter(|size| *size <= 1_048_576) + .ok_or_else(invalid)?; + Ok(KeymapSourceFile { + id: id.into(), + path: path.into(), + byte_count, + }) + }) + .collect() + } + + /// Reads one bounded chunk from a file ID returned by `list_inputs`. + pub fn read_chunk( + &self, + host: &Host, + file_id: &str, + offset: u64, + maximum_bytes: u32, + ) -> Result, SdkError> { + let invalid = || SdkError::new("sdk.keymap.invalidChunk", "Invalid shortcut source chunk."); + if !valid_snapshot_id(file_id) + || offset > 1_048_576 + || maximum_bytes == 0 + || maximum_bytes > 131_072 + { + return Err(invalid()); + } + let result = host.request( + "lithe/host/keymapImport/readChunk", + json!({ + "providerID": self.provider_id, "snapshotID": self.snapshot_id, + "fileID": file_id, "offset": offset, "maximumBytes": maximum_bytes + }), + )?; + let encoded = result + .get("data") + .and_then(Value::as_str) + .filter(|data| data.len() <= 174_764) + .ok_or_else(invalid)?; + let bytes = BASE64.decode(encoded).map_err(|_| invalid())?; + if bytes.len() > maximum_bytes as usize { + return Err(invalid()); + } + Ok(bytes) + } +} + +fn valid_keymap_provider_id(id: &str) -> bool { + !id.is_empty() + && id.len() <= 64 + && id.as_bytes()[0].is_ascii_lowercase() + && id + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') +} + +fn valid_snapshot_id(id: &str) -> bool { + !id.is_empty() + && id.len() <= 128 + && id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_') +} + /// A clonable handle to a backend for any thread. #[derive(Clone)] pub struct BackendHandle { diff --git a/sdk/rust/plugin-sdk/src/lib.rs b/sdk/rust/plugin-sdk/src/lib.rs index deb51f0..69615e1 100644 --- a/sdk/rust/plugin-sdk/src/lib.rs +++ b/sdk/rust/plugin-sdk/src/lib.rs @@ -30,7 +30,10 @@ mod host; mod protocol; mod stdio; -pub use backend::{Backend, BackendHandle, Cancellation, Context, Initialize}; +pub use backend::{ + Backend, BackendHandle, Cancellation, Context, Initialize, KeymapImportRequest, + KeymapSourceFile, +}; pub use error::{sdk_codes, Cancelled, FunctionError, PluginError, SdkError, HOST_ERROR_CODES}; pub use host::{ Browser, Credentials, Documents, Editor, Event, Host, Preview, Subscription, Subscriptions, diff --git a/sdk/rust/plugin-sdk/tests/keymap.rs b/sdk/rust/plugin-sdk/tests/keymap.rs new file mode 100644 index 0000000..b1e2bab --- /dev/null +++ b/sdk/rust/plugin-sdk/tests/keymap.rs @@ -0,0 +1,135 @@ +//! Shortcut providers negotiate independently of plugin functions. +mod common; + +use common::Host; +use lithe_plugin_sdk::Backend; +use serde_json::{json, Value}; +use std::sync::mpsc; +use std::time::Duration; + +#[test] +fn negotiates_only_offered_providers_and_invokes_typed_domain_request() { + let mut backend = Backend::new("test-backend"); + backend + .keymap_import_provider("first", |request, _context| { + assert_eq!(request.provider_id(), "first"); + assert_eq!(request.snapshot_id(), "snapshot-1"); + Ok(json!({"bindings": []})) + }) + .unwrap(); + backend + .keymap_import_provider("not-offered", |_request, _context| { + Ok(json!({"bindings": []})) + }) + .unwrap(); + let mut host = Host::start(backend); + host.request( + 0, + "initialize", + json!({"capabilities": {"experimental": { + "litheKeymapImport": {"versions": [1], "providers": ["first", "unknown"]} + }}}), + ); + let result = host.answer(0).0["result"].clone(); + assert_eq!( + result["capabilities"]["experimental"]["litheKeymapImport"], + json!({"version":1,"providers":["first"]}) + ); + host.request( + 1, + "lithe/keymap/import", + json!({"providerID":"first", "snapshotID":"snapshot-1"}), + ); + assert_eq!(host.answer(1).0["result"], json!({"bindings":[]})); + host.request( + 2, + "lithe/keymap/import", + json!({"providerID":"not-offered", "snapshotID":"snapshot-1"}), + ); + assert!(host.answer(2).0["error"].is_object()); + host.request( + 3, + "lithe/keymap/import", + json!({"providerID":"first", "snapshotID":"../private"}), + ); + assert!(host.answer(3).0["error"].is_object()); + host.request(4, "shutdown", Value::Null); + assert_eq!(host.answer(4).0["result"], Value::Null); + host.notify("exit", Value::Null); + assert_eq!(host.exit_code(), 0); +} + +#[test] +fn cancellation_reaches_a_running_provider() { + let (entered_tx, entered_rx) = mpsc::channel(); + let (cancelled_tx, cancelled_rx) = mpsc::channel(); + let mut backend = Backend::new("test-backend"); + backend + .keymap_import_provider("first", move |_request, context| { + let (release_tx, release_rx) = mpsc::channel(); + let cancelled_tx = cancelled_tx.clone(); + context.cancellation().on_cancel(move || { + let _ = cancelled_tx.send(()); + let _ = release_tx.send(()); + }); + entered_tx.send(()).unwrap(); + release_rx + .recv_timeout(Duration::from_secs(3)) + .expect("provider cancellation"); + context.check_cancelled()?; + Ok(json!({"bindings": []})) + }) + .unwrap(); + let mut host = Host::start(backend); + host.request(0, "initialize", json!({"capabilities":{"experimental":{"litheKeymapImport":{"versions":[1],"providers":["first"]}}}})); + host.answer(0); + host.request( + 1, + "lithe/keymap/import", + json!({"providerID":"first", "snapshotID":"snapshot-1"}), + ); + entered_rx + .recv_timeout(Duration::from_secs(3)) + .expect("provider started"); + host.notify("$/cancelRequest", json!({"id":1})); + cancelled_rx + .recv_timeout(Duration::from_secs(3)) + .expect("provider cancelled"); + assert_eq!(host.answer(1).0["error"]["code"], -32800); + host.close(); +} + +#[test] +fn provider_lists_and_reads_selected_files_through_scoped_host() { + let mut backend = Backend::new("test-backend"); + backend + .keymap_import_provider("first", |request, context| { + let files = request.list_inputs(context.host())?; + let bytes = request.read_chunk(context.host(), &files[0].id, 0, 4)?; + assert_eq!(String::from_utf8(bytes).unwrap(), "data"); + Ok(json!({"bindings": [{"commandID": "find-next", "keys": [{"key": "f3", "modifiers": []}]}]})) + }) + .unwrap(); + let mut host = Host::start(backend); + host.request(0, "initialize", json!({"capabilities":{"experimental":{ + "litheFunctions":{"versions":[3]}, "litheKeymapImport":{"versions":[1],"providers":["first"]} + }}})); + host.answer(0); + host.request( + 1, + "lithe/keymap/import", + json!({"providerID":"first", "snapshotID":"snapshot-1"}), + ); + let list = host.next(); + assert_eq!(list["method"], "lithe/host/keymapImport/listInputs"); + host.send(json!({"jsonrpc":"2.0", "id":list["id"], "result":{"files":[{"id":"file-0", "path":"keys.json", "byteCount":4}]}})); + let read = host.next(); + assert_eq!(read["method"], "lithe/host/keymapImport/readChunk"); + assert_eq!(read["params"]["fileID"], "file-0"); + host.send(json!({"jsonrpc":"2.0", "id":read["id"], "result":{"data":"ZGF0YQ=="}})); + assert_eq!( + host.answer(1).0["result"], + json!({"bindings": [{"commandID": "find-next", "keys": [{"key": "f3", "modifiers": []}]}]}) + ); + host.close(); +}